Skip to content

production-capacity: 0 means two different things, and the rule that separates them is unwritten #2395

Description

@Flix6x

production-capacity: 0 means two different things, and the rule that separates them is unwritten

Blocking for v1: stop relax-constraints auto-filling device directional breach prices (#2172). Then: state the zero rule explicitly, and give physics a home of its own.

The overload

A fixed zero directional capacity is used by real users to say two incompatible things:

  • "This device physically cannot." The S2 CEM in Explicit zero directional capacity should stay a hard constraint under relax-constraints #2323 derives production-capacity: 0 kW from a heat pump's operation modes. The device cannot produce at any price.
  • "Don't do this right now." V2G-Liberty sets a windowed production-capacity: 0 kW during a calendar car reservation, so the charger stands idle once the soc-minima is reached and the user can unplug immediately. (DC charging risks an arc if uncoupled while power is flowing.) This is in the shipped docs — flex-model-v2g.rst pairs a 0 kW production-capacity over 08:00–08:15 with a soc-minima, under the words "to keep the car from discharging altogether during that time".

The first must be unbreachable. The second must not be: if the reservation's SoC target cannot otherwise be met, we want a schedule that breaks the idleness, not a lost schedule.

So there is no correct default. Hardening every zero breaks V2G-Liberty; softening every zero causes #2323 and #2329.

(Citations by symbol name; this code moves between branches. Verified against origin/main @ 65d2a70.)

Part 1 — the blocking bit

Fix: in check_prices, do not auto-fill consumption_breach_price / production_breach_price from relax_constraints. Keep the flip for SoC, where softening was the point.

This does not fix #2329, which sets 'relax-capacity-constraints': true explicitly — that branch still fills the device breach prices. Part 1 shrinks the blast radius from "everyone" to "users who opted into capacity relaxation".

Prerequisite bug: the relax-* flags do not compose

Each of the three guards in check_prices parses as A or (B and C and D), so the "not already set" conditions guard only the relax_constraints branch. Hence relax-soc-constraints: False cannot keep SoC constraints hard while relax-constraints defaults to True, and relax-soc-constraints: True overwrites explicitly-provided breach prices (set_default_breach_prices assigns unconditionally). Fixing the first needs tri-state flags — relax_soc_constraints has load_default=False, so "unset" and "explicitly False" are indistinguishable.

Part 2 — the signal that separates the two meanings

The two uses differ on one property: physics zeros span the whole scheduling window; preference zeros are windowed. A heat pump never produces. A reservation is a window.

That is exactly what PR #2345's production_capacity_d.max() > 0 tests. Against the reported cases:

case declared production/consumption capacity read as correct?
#2323 heat pump 0 across the horizon physical → hard yes
#2329 EVSE, production single segment covering the whole PT27H window at 0 W physical → hard yes — fixes the reported discharge
#2329 EVSE, consumption 11040 W, with 0 W only at 17:00–18:00, coinciding with that device's soc-minima preference → soft yes — that is the reservation pattern
V2G-Liberty 0 kW for 08:00–08:15 within a longer horizon preference → soft yes

So #2345 should land as written. An earlier version of this issue argued for making the guard elementwise, on the grounds that a per-window test applied to a per-timestep constraint is incoherent. That is wrong: the whole window is the only place the distinguishing information lives, and elementwise would harden V2G-Liberty's reservation windows and the documented tutorial along with them.

The real defect is that this rule is emergent rather than stated. Nobody reading the flex-model docs could predict it. Write it down:

A directional capacity that is zero throughout the scheduling window is read as a physical property of the device and is enforced strictly. A zero covering only part of the window is read as a preference and may be breached at the applicable breach price.

Where the rule fails

The classifier's reliability depends on the length of the scheduling window, which the user controls. A horizon falling entirely inside a reservation is all-zero, so a preference is misread as physics and hardened — and a user who simply shortens their planning window can trigger that without changing anything about the device.

Mitigation today: SoC constraints are soft by default, so the LP breaches the soc-minimum and reports it rather than returning INFEASIBLE — a degraded schedule, not a lost one.

A possible refinement is to inspect the declared capacity beyond the planning window before classifying, so a windowed zero is still recognised as windowed when the horizon happens to end inside it. That would make the heuristic markedly more robust, but it is a follow-up rather than a blocker — and it is second-best to simply making the physics explicit, below, which removes the need to classify at all.

Give physics a home, so the rule becomes a shim — the preferred direction

is_strictly_non_positive / is_strictly_non_negative already exist as sensor attributes, hard-set the bound, and bypass the breach-price branch entirely. They were already the #2323 workaround — "applied in our S2 CEM". They are undocumented as the way to declare directional physics, and unavailable per-request.

Add flex-model equivalents of these two attributes, and document them as the first-class way to say "this device cannot produce / cannot consume". A CEM can then send device physics with the request rather than depending on asset onboarding, which is exactly what the S2 case needs. This is preferred over refining the classifier: once physics has an explicit home, the whole-window rule becomes a compatibility shim for clients that never migrate, rather than the mechanism, and its window-length sensitivity stops mattering.

(An earlier draft proposed instead adding a hard directional envelope field, e.g. production-rating. It does not survive contact: a new field is invisible to validate_coupling_direction_is_unambiguous, which reads a fixed zero to determine a coupled port's flow direction; it silently falsifies the tutorials that teach production-capacity: 0; it has no deprecation channel because nothing is being deprecated; and it leaves the fallback path below untouched. Note also that power-capacity (hard) plus a directional capacity (soft) already expresses "physically 11 kW, prefer under 6 kW" — only a non-zero asymmetric hard bound is unexpressible, and no reported issue needs one.)

Related defects found while investigating

The fallback policy ignores every soft preference — an argument for retiring it sooner. fallback_charging_policy reads derivative max / derivative min straight off the constraints frame, and soft limits live in commitments, not constraints. So if the main scheduler fails, the fallback may discharge a car during its reservation window — V2G-Liberty's exact scenario, with the physical risk that the idle window exists to avoid — and, before #2345, may discharge a one-way EVSE at nameplate. #2345 fixes the second case for free; the first would need the fallback to consider directional capacities directly.

#2252 retires the fallback scheduler in v1 or v1.1. Rather than teach it about soft constraints it structurally cannot see, this is an argument for landing that retirement sooner rather than later — a fallback that silently ignores every preference the user expressed is worse than no fallback, because it runs precisely when something has already gone wrong.

Groups have no hard directional path at all. group_ems_constraints takes hard bounds only from group_power_capacity; directional group capacities are always soft at a hardcoded default_group_breach_price of 10 000/kW with no opt-out. A group with production-capacity: 0 is unconditionally breachable. No zero-as-preference use case has appeared for groups yet, but they are new, so I would rather note this than harden on an absence of evidence.

Diagnosability is poor exactly where it matters. validate_storage_constraints is skipped whenever soc_at_start[d] is None — precisely the #2323/#2329 device class (heat pumps without a stock, PV, converter ports) — and nothing anywhere checks derivative min <= derivative max. Both worth adding, since hardening converts silent wrongness into infeasibility.

Operation-mode bands are never reconciled with the derivative bounds. device_band_choice forces exactly one band per timestep and device_band_power_lower forces power ≥ its minimum, so a device whose bands exclude zero cannot idle; a hard zero against such a band is flatly infeasible with nothing clipping or validating the intersection. Coupling compounds it: flow_coupling_rule is ems_power[d] == coeff * coupling_alpha[g], so a hard derivative min = 0 on any port pins the whole group off at that step.

This is not hypothetical. In the S2 v1.0.0 schemas, OMBC.OperationMode requires only id, power_ranges (≥1) and abnormal_condition_only; nothing obliges any mode to contain zero power. abnormal_condition_only exists on both OMBC.OperationMode and Transition, so a resource can declare its off mode — or the transition into it — as usable only during an abnormal condition: a standard-sanctioned "cannot be switched off in normal operation". Minimum on-time and startup delay come from Transition.start_timers / blocking_timers / transition_duration. FlexMeasures' OperationModeSchema carries none of this, so FM cannot distinguish "genuinely cannot idle" from "no off band declared". Raise a specific error for the combination; do not auto-inject a zero band, which would fabricate an off state the resource may not have.

Sites are a different shape and should be left alone. They already carry three flow tiers — site-power-capacity (hard) → site-consumption-capacity (soft) → ems_peak_consumption (peak-priced) — and a hard site bound is dangerous in a way a device bound is not: inflexible devices are pinned by measured power (derivative equals) and the EMS bound constrains the group sum, so a bad forecast or a missing submeter makes it infeasible with nothing able to rescue it. The general principle: hard where a conflict could only come from another declaration; soft where measured reality can contradict the declaration.

Consequences for the in-flight PRs

#2345 — land as written

No change to the guard. Add the documented rule above to the flex-model docs, and a changelog note that an all-window zero directional capacity is now strict. Its soft_breach_names == [] assertion over-specifies (the commitments can harmlessly remain) and should go. Worth a test asserting the windowed zero stays soft — that is the V2G-Liberty contract, and nothing currently pins it.

#2267 — split it

Stocks are the half of the model that already separates the two concerns: soc-min / soc-max are a hard envelope that also clips, soc-minima / soc-maxima are soft. The risk in #2267 is collapsing that into one tier.

Land now: the default field on sensor references, plus docs / OpenAPI / UI.
Hold: the soc-minsoc_minima folding, the scalar softening, and the deprecation of soc-minima / soc-maxima.

Also: default is not soc-min. A default fills slots where the sensor is silent; soc-min additionally clips, including slots where the sensor did speak, so a sensor reporting a bad value is today clamped and would not be. That loss is independent of the hardness question.

#2390 — independent

Relaxing soc-targets stands on its own: one unreachable target costing the user their whole schedule is bad regardless of anything here.

Proposed work, in order

  1. Narrow feat: Split flex-context settings by commodity for dynamic capacity scheduling #2172's default so relax-constraints no longer softens device directional capacities. Blocking for v1.
  2. Land Keep explicit zero directional capacity hard under relax-constraints #2345 as written, plus the documented rule and a test pinning the windowed zero as soft.
  3. Add flex-model equivalents of is_strictly_non_positive / is_strictly_non_negative, documented as the way to declare directional physics. This is the real fix; everything above is containment.
  4. Retire the fallback scheduler (Feat/retire fallback scheduler #2252) — brought forward, per the reasoning above.

Open questions

  1. Groups: harden declared zeros, or wait until a group use case actually appears?
  2. Fix the relax-* precedence bug (and make the flags tri-state) before or with this?
  3. Should the classifier look beyond the planning window before deciding, as a stopgap for users with short horizons — or is that effort better spent on (3) above?
  4. Should the flex-model carry S2's abnormal_condition_only, transitions and timers, so a must-run resource is representable rather than inferred from a missing band?

Related: #2323, #2329, #2345, #2267, #2251, #2252, #2272, #2172, #2390

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions