Skip to content

Confine device power to operation-mode power bands - #2278

Merged
Flix6x merged 11 commits into
mainfrom
feat/2113-operation-mode-power-bands
Jul 26, 2026
Merged

Confine device power to operation-mode power bands#2278
Flix6x merged 11 commits into
mainfrom
feat/2113-operation-mode-power-bands

Conversation

@Flix6x

@Flix6x Flix6x commented Jul 10, 2026

Copy link
Copy Markdown
Member

Description

Adds support for devices whose power is not free to modulate within [0, P_max], but is confined to a set of power bands — e.g. a device that is either off or running at (or above) some minimum power. Fixes #2113.

Following the S2 standard's FRBC OperationMode terminology, the storage flex-model gains an operation-modes field: a list of power ranges, each declared with an explicit sign convention (consumption-range and/or production-range, both non-negative). The device must operate within one of the declared ranges at every time step:

"operation-modes": [
    {"consumption-range": ["0 W", "0 W"]},
    {"consumption-range": ["883.7 W", "883.7 W"]}
]

In the device_scheduler, this adds one binary variable per device per band per time step (exactly one band active; device power bounded by the active band's range), turning the LP into a MILP when the field is used. Without the field, the model is unchanged.

Motivation / validation

When a control layer (e.g. an S2 CEM/RM pair) receives a fractional schedule for an on/off device, it has to round it — typically up — which can overshoot site capacity limits. In a two-house community co-simulation with on/off heaters, a community-level capacity breach could not be resolved because the scheduler kept planning fractional heater power that was implemented as full-on blocks. With this field, the schedule only contains implementable power values; in that same co-simulation the breach was fully resolved (community peak landed exactly on the inflexible-load floor), with no observed solver slowdown (~10s solves, HiGHS, 96 time steps).

Runtime impact (LP vs MILP)

Benchmarked device_scheduler directly (HiGHS via appsi_highs, wall time incl. model build): a synthetic battery-like device doing price arbitrage at 15-min resolution (±1 MW, ±2 MWh, sinusoidal day/night prices, one price commitment per device).

Scenario T=96 (1 day) T=192 (2 days) T=672 (1 week)
LP baseline (no bands) 0.45 s 0.71 s 2.15 s
MILP, 2 bands (off / [0.4, 1] MW) 0.41 s 1.02 s 2.74 s
MILP, 3 bands (± bands and off) 0.56 s 1.08 s 3.08 s
MILP, 5 bands 1.77 s 1.11 s 3.38 s

Multiple banded devices scale roughly linearly here (T=96, 3 bands: 3 devices 1.14 s, 5 devices 1.56 s).

So on these instances the binaries cost ~1.3–2× the LP solve time (worst observed: ~4×), not orders of magnitude. Two structural reasons:

  • The formulation (sum(b) = 1, with power bounded by Σ b·min ≤ p ≤ Σ b·max) is the tight disjunctive formulation: its LP relaxation equals the convex hull of the band union per time step, so the root relaxation is strong. A big-M formulation would be strictly weaker.
  • The binaries don't couple across time steps except indirectly through the stock recursion.

Adversarial instances (near-flat prices creating many equally good band choices, tight SoC targets interacting with band gaps, several banded devices competing for site capacity) can branch harder than this. Practical levers, should that occur:

  • Convexity preprocessing (possible follow-up): binaries are only necessary when the band union has gaps; bands that merge into one contiguous interval could be collapsed into plain derivative min/max bounds, skipping the MILP entirely.
  • Solver options: FLEXMEASURES_LP_SOLVER_OPTIONS can pass mip_rel_gap (0.1–1% is usually invisible in schedule quality) and time_limit to HiGHS.

Scope

  • New OperationModeSchema + operation-modes field in the storage flex-model schema (documented via the metadata module, so it shows up in the docs table and OpenAPI specs).
  • device_power_bands argument on device_scheduler, wired through StorageScheduler._prepare/compute.
  • Docs: field documented in the storage flex-model table (with S2 reference) + changelog entry.
  • Tests: flexmeasures/data/models/planning/tests/test_operation_modes.py (on/off band, min-power band, and a no-bands sanity check).

🤖 Generated with Claude Code

Flix6x and others added 2 commits July 10, 2026 17:36
New storage flex-model field "operation-modes" (S2 terminology): a list of
signed power ranges; the device must operate within one of them at every time
step. Adds one binary per device per band per time step to the device
scheduler, so devices that cannot modulate below a minimum power (or are
strictly on/off) no longer receive fractional schedules that their control
layer must round up, overshooting site capacity limits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the field to the storage flex-model table (via a new OPERATION_MODES
MetaData entry, which the schema now also uses for its API docs), including
the sign convention, an on/off device example, the MILP note, and a reference
to the S2 standard's FRBC OperationMode concept. Also adds a changelog entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Combine the operation-modes power-bands flex-model field with the new
group-based intermediate power constraints from PR #2276. Conflicts were
co-located additions in the storage flex-model schema, metadata
descriptions, docs, and the generated OpenAPI spec; both features kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B
Signed-off-by: F.N. Claessen <claessen@seita.nl>
@Flix6x Flix6x self-assigned this Jul 22, 2026
Signed-off-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com>
Comment thread documentation/changelog.rst Outdated
Comment thread flexmeasures/data/schemas/scheduling/metadata.py Outdated
Replace the single signed `power-range` on an operation mode with explicit
`consumption-range` (positive = consumption) and/or `production-range`
(positive = production). A mode may use either or both; combining both (each
starting at 0) forms one band through zero. Document that the S2 power-range
maps to the FM consumption-range (S2 fixes one sign convention; FM leaves it
to the user). Also update the changelog reference to PR #2278.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B
Signed-off-by: F.N. Claessen <claessen@seita.nl>
@Flix6x

Flix6x commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Addressed:

  • Sign-explicit ranges: replaced the single signed power-range with consumption-range (positive = consumption) and/or production-range (positive = production). A mode may use either or both; combining both (each starting at 0) forms one band through zero [-production_max, +consumption_max]. Documented that the S2 signed power-range maps to the FM consumption-range. Added schema tests for the mapping + validation; OpenAPI regenerated.
  • Changelog: applied your suggested wording (now references PR Confine device power to operation-mode power bands #2278).

The unit-commitment work stays out of this PR (separate branches). I will reconcile the branches stacked on this one — #2327 (running-cost) and the operation-modes UC route — to the new key names.

Add integration-level tests that exercise StorageScheduler.compute()
end-to-end with "operation-modes" set in the flex-model, so that a
regression severing the plumbing that reads operation-modes and passes
device_power_bands into device_scheduler() would be caught. Previously
only LP-level tests (calling device_scheduler() directly) covered this
feature, which is why a recent merge could sever the wiring without
any test failing.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Comment thread flexmeasures/data/models/planning/linear_optimization.py Outdated
Comment thread flexmeasures/data/models/planning/linear_optimization.py Outdated
Comment thread flexmeasures/data/models/planning/storage.py Outdated
Comment thread flexmeasures/data/schemas/scheduling/storage.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for constraining device power to discrete “operation modes” (power bands) in the storage flex-model, wiring the new field through scheduling so the device scheduler can enforce banded operation (MILP when used), along with documentation, OpenAPI updates, and regression tests.

Changes:

  • Add operation-modes to the storage flex-model schema (with validation and metadata/docs exposure).
  • Wire operation-modes through StorageScheduler into device_scheduler via a new device_power_bands argument and corresponding MILP constraints.
  • Add tests covering LP-level band behavior and end-to-end wiring through StorageScheduler, plus docs/changelog/OpenAPI updates.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
flexmeasures/ui/static/openapi-specs.json Adds OpenAPI schemas/field docs for OperationMode and operation-modes.
flexmeasures/data/schemas/scheduling/storage.py Introduces OperationModeSchema and adds operation-modes to StorageFlexModelSchema.
flexmeasures/data/schemas/scheduling/metadata.py Adds OPERATION_MODES metadata used for generated docs/OpenAPI descriptions.
flexmeasures/data/models/planning/storage.py Converts operation-modes to signed MW bands and forwards them into device_scheduler.
flexmeasures/data/models/planning/linear_optimization.py Extends device_scheduler with optional per-device power band constraints (MILP).
flexmeasures/data/models/planning/tests/test_operation_modes.py New targeted tests for band constraints and schema-to-band mapping.
flexmeasures/data/models/planning/tests/test_solver.py Integration/regression tests ensuring StorageScheduler wiring enforces/forwards bands.
documentation/features/scheduling.rst Documents the new operation-modes flex-model field.
documentation/changelog.rst Adds changelog entry announcing the new operation-modes capability.

Comment thread flexmeasures/data/models/planning/linear_optimization.py
Comment thread flexmeasures/data/schemas/scheduling/metadata.py Outdated
Comment thread flexmeasures/data/schemas/scheduling/storage.py
- Index band choice/lower/upper constraints over a per-device set (bd)
  instead of anchoring them at band 0 of the (device, band) set, and
  explain the band-selection mechanics in inline comments
- Fail fast when device_power_bands and device_constraints disagree on
  the number of devices
- Move the signed-band conversion into OperationModeSchema.signed_band,
  with doctests covering consumption-only, production-only and combined
  modes
- Clarify that an S2 signed power-range maps onto consumption-range /
  production-range by sign (docs, field descriptions, OpenAPI specs)
- Hint the remedy in the negative-range validation error

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111ySFfFSbFBoGXgzUfDTmP
Signed-off-by: F.N. Claessen <claessen@seita.nl>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment thread flexmeasures/ui/static/openapi-specs.json
Comment thread flexmeasures/data/schemas/scheduling/metadata.py
… rule

- rst_to_openapi now converts external RST hyperlinks to HTML anchors,
  so descriptions read well in both Sphinx and OpenAPI consumers
- The published OperationMode schema now declares via anyOf that at
  least one of consumption-range/production-range is required, matching
  the backend validation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111ySFfFSbFBoGXgzUfDTmP
Signed-off-by: F.N. Claessen <claessen@seita.nl>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Comment thread flexmeasures/utils/doc_utils.py
Comment thread flexmeasures/utils/doc_utils.py
Comment thread flexmeasures/data/models/planning/linear_optimization.py
Comment thread flexmeasures/data/schemas/scheduling/storage.py Outdated
- Add rel="noopener noreferrer" to anchors generated by rst_to_openapi
  (external links and docs search links)
- Validate each power band is a (min, max) pair with min <= max before
  building the MILP
- Raise an explicit ValueError instead of asserting when an operation
  mode declares no range at all

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111ySFfFSbFBoGXgzUfDTmP
Signed-off-by: F.N. Claessen <claessen@seita.nl>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

flexmeasures/data/schemas/scheduling/metadata.py:400

  • PR description example for operation-modes uses {"power-range": [...]} (signed range), but the implemented/public schema and docs use consumption-range / production-range (and no power-range field). To avoid confusing reviewers/API users, please update the PR description example to match the actual payload shape.
    description="""Confine the device's power to one of several power ranges at every time step.
Each operation mode declares a ``consumption-range`` (non-negative, positive is consumption) and/or a ``production-range`` (non-negative, positive is production); a mode may use either or both, and combining both (each starting at 0) forms a single band through zero.
This is useful for devices that cannot modulate their power freely, such as a device that is either off or running at some minimum power (or at one fixed power).
Terminology and semantics follow the `operation modes of the S2 standard <https://docs.s2standard.org/model-reference/FRBC/FRBC.OperationMode/>`_.
S2 fixes one sign convention for power (positive is consumption), whereas FM leaves it to the user; an S2 signed power-range therefore maps onto these fields by sign: its non-negative part corresponds to the FM ``consumption-range``, negative S2 power values (production) correspond to the FM ``production-range`` (with their sign flipped to non-negative), and an S2 range spanning zero maps to a combination of both.

Comment thread flexmeasures/utils/doc_utils.py Outdated
Prevents invalid HTML (and attribute injection) in generated anchors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111ySFfFSbFBoGXgzUfDTmP
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…ode-power-bands-wt

Signed-off-by: F.N. Claessen <claessen@seita.nl>

# Conflicts:
#	flexmeasures/ui/static/openapi-specs.json
@Flix6x
Flix6x merged commit 853f9af into main Jul 26, 2026
12 of 13 checks passed
@Flix6x
Flix6x deleted the feat/2113-operation-mode-power-bands branch July 26, 2026 19:40
Flix6x added a commit that referenced this pull request Aug 2, 2026
Brings in #2295 (sensor/group-scoped commitments), #2358 (inflexible-consumption
/ inflexible-production replacing inflexible-device-sensors), #2374 (inflexible
devices as assets), #2278 (operation-mode power bands) and #2306 (rate limiting).

Conflict resolutions of note:

* schemas/scheduling/storage.py -- main moved GroupReferenceSchema into the new
  schemas/scheduling/groups.py and storage.py now imports it, so the branch's
  local copy is dropped rather than merged. Kept _validate_coupling_name (still
  used by both flex-model schemas) alongside main's new
  validate_inflexible_flex_model_entry.
* linear_optimization.py -- coupling groups (this branch) and operation-mode
  power bands (main) are independent features; both kept.
* devices.py -- FlexDevice gains main's inflexible-device fields next to this
  branch's coupling fields.
* storage.py -- the device_scheduler call passes both coupling_groups and
  device_power_bands.

flexmeasures/data/models/planning + flexmeasures/data/schemas: 579 passed,
3 xfailed. black and flake8 clean.

Note: #2306 imports `limits`, which is resolved via uv.lock but not declared in
pyproject.toml, so existing venvs need `uv sync --all-groups`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEvWAj45zXaod5WjniF81D
Signed-off-by: F.N. Claessen <felix@seita.nl>
Flix6x added a commit that referenced this pull request Aug 2, 2026
Picks up feat/chp's merge of origin/main (#2295 sensor/group-scoped commitments,
#2358 inflexible-consumption/-production, #2374 inflexible devices as assets,
#2278 operation-mode power bands, #2306 rate limiting).

Two conflicts, both additive: this branch's internal-node balance groups and
feat/chp's operation-mode power bands are independent features. The
device_scheduler call now passes all three feature arguments side by side:
coupling_groups (converters), balance_groups (internal commodity nodes) and
device_power_bands (operation modes).

flexmeasures/data/models/planning + flexmeasures/data/schemas: 586 passed,
3 xfailed. black and flake8 clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEvWAj45zXaod5WjniF81D
Signed-off-by: F.N. Claessen <felix@seita.nl>
@nhoening nhoening added this to the 1.0.0 milestone Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: non-zero minimum power of flexible devices

3 participants