From 69f7f56462cef5c39edc5d57279164acfb17baf7 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 11 Jul 2026 14:54:14 +0200 Subject: [PATCH 1/7] feat: commitments can be scoped to specific sensors, binding their aggregate flow A flex-context commitment gains an optional 'sensors' field: instead of binding each device of the matching commodity separately, the commitment binds the aggregate flow of the devices whose power sensors are listed, as one grouped commitment (device_group machinery). Useful to commit a band on a subset of devices, e.g. an aFRR upward-regulation band on a site's e-heaters (aggregate consumption >= band, deviation penalized). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B --- flexmeasures/data/models/planning/storage.py | 33 ++++++++ .../models/planning/tests/test_commitments.py | 80 +++++++++++++++++++ .../data/schemas/scheduling/__init__.py | 9 +++ flexmeasures/ui/static/openapi-specs.json | 6 ++ 4 files changed, 128 insertions(+) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index dc0c02b7f7..9f3892790a 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1351,6 +1351,39 @@ def convert_to_commitments( start, end, timing_kwargs["resolution"] ) commitment_commodity = commitment_spec.get("commodity", "electricity") + + # A commitment scoped to specific sensors binds the *aggregate* flow + # of those devices as one commitment, rather than each device separately. + scoped_sensors = commitment_spec.pop("sensors", None) + if scoped_sensors is not None: + scoped_sensor_ids = { + sensor.id if hasattr(sensor, "id") else sensor + for sensor in scoped_sensors + } + scoped_devices = [ + d + for d, flex_model_d in enumerate(flex_model) + if getattr(flex_model_d.get("sensor"), "id", None) + in scoped_sensor_ids + ] + if not scoped_devices: + current_app.logger.warning( + f"Commitment '{commitment_spec.get('name')}' is scoped to" + f" sensors {sorted(scoped_sensor_ids)}, none of which appear" + " in the flex-model. This commitment will not bind any device." + ) + continue + index = commitment_spec["index"] + group_label = commitment_spec.get("name", "scoped commitment") + commitment = FlowCommitment( + device=pd.Series([scoped_devices] * len(index), index=index), + # device_group maps device index -> group label; one shared + # label makes the engine bind the aggregate flow. + device_group=pd.Series({d: group_label for d in scoped_devices}), + **commitment_spec, + ) + commitments.append(commitment) + continue for d, flex_model_d in enumerate(flex_model): device_commodity = flex_model_d.get("commodity", "electricity") if device_commodity != commitment_commodity: diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 4275892c53..38417735d8 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1782,3 +1782,83 @@ def test_electricity_device_indices_exclude_other_commodities(): assert mapping["electricity"] == [0, 2, 3, 4] assert mapping["gas"] == [1, 5] assert scheduler._electricity_device_indices() == [0, 2, 3, 4] + + +def test_sensor_scoped_commitment_binds_aggregate_of_selected_devices(app, db): + """A commitment scoped to specific sensors (here: two e-heaters) binds their + aggregate flow as one commitment: a baseline of 10 MW with a steep penalty on + downward deviation keeps their combined consumption at 10 MW even though a + cheaper allocation (0 MW) exists, while an unscoped battery stays unaffected. + """ + heater_type = get_or_create_model(GenericAssetType, name="e-heater") + site = GenericAsset( + name="Band site (scoped commitment test)", generic_asset_type=heater_type + ) + db.session.add(site) + db.session.flush() + + resolution = pd.Timedelta("1h") + start = pd.Timestamp("2026-02-01T00:00:00+01:00") + end = pd.Timestamp("2026-02-01T04:00:00+01:00") + + def sensor(name): + s = Sensor( + name=name, unit="MW", event_resolution=resolution, generic_asset=site + ) + db.session.add(s) + return s + + heater_1 = sensor("band heater 1") + heater_2 = sensor("band heater 2") + db.session.flush() + + flex_model = [ + { + # Heaters burn money at the consumption price; without the band + # commitment the optimum is to stay off. + "sensor": heater_1.id, + "power-capacity": "8 MW", + "consumption-capacity": "8 MW", + "production-capacity": "0 kW", + }, + { + "sensor": heater_2.id, + "power-capacity": "8 MW", + "consumption-capacity": "8 MW", + "production-capacity": "0 kW", + }, + ] + flex_context = { + "consumption-price": "50 EUR/MWh", + "production-price": "50 EUR/MWh", + "site-power-capacity": "1 GW", + "commitments": [ + { + "name": "reserved band", + "sensors": [heater_1.id, heater_2.id], + "baseline": "10 MW", + # Steep penalty for consuming less than the band (negative price + # penalizes downward deviation); consuming more is free. + "down-price": "-10000 EUR/MWh", + } + ], + } + + scheduler = StorageScheduler( + asset_or_sensor=site, + start=start, + end=end, + resolution=resolution, + belief_time=start, + flex_model=flex_model, + flex_context=flex_context, + return_multiple=True, + ) + results = scheduler.compute(skip_validation=True) + schedules = { + r["sensor"]: r["data"] for r in results if r.get("name") == "storage_schedule" + } + combined = schedules[heater_1] + schedules[heater_2] + # The band keeps the aggregate at 10 MW (cheapest way to avoid the penalty), + # even though each heater alone (8 MW max) could not carry it. + np.testing.assert_allclose(combined.iloc[:-1], 10.0, rtol=1e-4) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 93c86ac555..015ea1f0da 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -71,6 +71,15 @@ def forbid_time_series_specs(self, data: dict, **kwargs): class CommitmentSchema(Schema): name = fields.Str(required=True, data_key="name") + # Optional scoping: bind this commitment to the aggregate flow of the + # devices whose power sensors are listed, rather than binding each device + # separately. Useful to commit a band on a subset of devices (e.g. an + # aFRR band on a site's e-heaters). + sensors = fields.List( + SensorIdField(), + required=False, + data_key="sensors", + ) baseline = VariableQuantityField("MW", required=False, data_key="baseline") up_price = VariableQuantityField("/MW", required=False, data_key="up-price") down_price = VariableQuantityField( diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 04a5018e4e..f117054999 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4575,6 +4575,12 @@ "name": { "type": "string" }, + "sensors": { + "type": "array", + "items": { + "type": "integer" + } + }, "baseline": {}, "up-price": {}, "down-price": {} From a696cd4c935ef2ae3501aaff207cc4d8474970f8 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 16 Jul 2026 09:19:46 +0200 Subject: [PATCH 2/7] docs: changelog entry for sensor-scoped commitments (#2295) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015qxM7UZ5wHTz3ftz1Mf9yy Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 0eb355d858..6f4e7954a8 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -26,6 +26,7 @@ New features * Improve chart axis domain for event values not around zero, with a per-sub-chart ``y-axis`` option in ``sensors_to_show`` (default ``zero``, which pads the axis out to include zero) that can be set to ``data`` to fit a sub-chart's y-axis to the values shown, to an explicit ``[min, max]`` domain that the axis will cover at least (expanding to fit data beyond it), or to a strict ``{"min": min, "max": max}`` domain that the axis will never exceed (clamping data beyond it, with a warning when that happens), editable from the graph editor [see `PR #2244 `_] * Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 `_] * Extended the scheduling job ``result`` field with a ``num-beliefs`` field reporting the total number of beliefs (scheduled values) saved to the database [see `PR #2280 `_] +* Flex-context commitments can be scoped to specific device sensors (via a new optional ``sensors`` field), binding their aggregate flow as one commitment instead of binding each device separately [see `PR #2295 `_] * Migrate the asset tree in the UI's Structure tab from Vega to ECharts, adding interactive pan/zoom navigation and refreshed node styling [see `PR #2025 `_] Infrastructure / Support From f92699e0069b42a3525627c7197a9411b55be773 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 31 Jul 2026 21:17:12 +0200 Subject: [PATCH 3/7] feat: allow scoping a commitment to a group (alongside the sensor list) Follow-up on #2295. A commitment's scope can now be given as a 'group' reference (the members of an electrical group, reusing group_to_devices) in addition to the raw 'sensors' list; at most one of the two (schema-validated). A group scope includes the group's inflexible members (total node flow), whereas a sensors scope binds the listed flexible devices only -- documented on the schema field and the resolver. Also: validate that a scoped set shares one commodity, tag scoped commitments with provenance='custom' (was missing), and give each a unique device_group label so same-named commitments never merge. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TaMepbxRYzJxQKFtu6Hq4p Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 2 +- flexmeasures/data/models/planning/storage.py | 121 +++++++++++++----- .../models/planning/tests/test_commitments.py | 106 +++++++++++++++ .../data/schemas/scheduling/__init__.py | 28 +++- flexmeasures/ui/static/openapi-specs.json | 27 ++-- 5 files changed, 236 insertions(+), 48 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 9fbb386721..162b25af8b 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -43,7 +43,7 @@ New features * Add support for intermediate power constraints on groups of devices, via a new ``group`` field in the storage flex-model [see `PR #2276 `_ and `issue #2092 `_] * The ``group`` field now also accepts a ``{"asset": }`` reference (in addition to ``{"sensor": }``), allowing intermediate power constraints to be defined entirely from flex-models stored on the asset tree, with results saved via the group's ``consumption``/``production`` output sensors, without needing any flex-model in the scheduling trigger [see `issue #2092 `_] * Extended the scheduling job ``result`` field with a ``num-beliefs`` field reporting the total number of beliefs (scheduled values) saved to the database [see `PR #2280 `_] -* Flex-context commitments can be scoped to specific device sensors (via a new optional ``sensors`` field), binding their aggregate flow as one commitment instead of binding each device separately [see `PR #2295 `_] +* Flex-context commitments can be scoped to a subset of devices (via a new optional ``sensors`` list or ``group`` reference), binding their aggregate flow as one commitment instead of binding each device separately; a ``sensors`` scope can cherry-pick devices across electrical groups, while a ``group`` scope reuses a group's members (including its inflexible ones) [see `PR #2295 `_] * Migrate the asset tree in the UI's Structure tab from Vega to ECharts, adding interactive pan/zoom navigation and refreshed node styling [see `PR #2025 `_ and `PR #2365 `_] Infrastructure / Support diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 900e8835fa..7ec4645229 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -22,6 +22,7 @@ DeviceInventory, _resolve_stock_key, group_key_label, + resolve_group_reference, ) from flexmeasures.data.models.planning.linear_optimization import device_scheduler from flexmeasures.data.models.planning.utils import ( @@ -1545,6 +1546,82 @@ def device_list_series( commitments, ) + def _resolve_commitment_scope( + self, scoped_sensors, scoped_group + ) -> tuple[list[int], str]: + """Resolve a scoped commitment's device set to canonical solver indices. + + A ``group`` scope yields the group's (leaf) members, which -- unlike the + by-sensor scope -- include any inflexible members, so the aggregate covers the + node's total flow (fixed load included). A ``sensors`` scope yields the listed + *flexible* devices (``by_sensor_id`` returns schedulable devices only, so + inflexible devices are not bound by a sensor scope). Canonical indices always + come from the device inventory, never from re-enumerating raw flex-model lists. + + :returns: A ``(sorted device indices, human-readable scope description)`` pair. + """ + if scoped_group is not None: + group_key = resolve_group_reference(scoped_group) + scoped_devices = sorted( + self.device_inventory.group_to_devices.get(group_key, []) + ) + description = ( + f"group {group_key_label(group_key)}" + if group_key is not None + else "an unresolved group reference" + ) + return scoped_devices, description + scoped_sensor_ids = { + sensor.id if hasattr(sensor, "id") else sensor for sensor in scoped_sensors + } + scoped_devices = sorted( + device.index + for sensor_id in scoped_sensor_ids + for device in self.device_inventory.by_sensor_id(sensor_id) + ) + return scoped_devices, f"sensors {sorted(scoped_sensor_ids)}" + + def _build_scoped_commitment( + self, commitment_spec, scoped_sensors, scoped_group, commitment_index + ) -> "FlowCommitment | None": + """Build one aggregate-flow FlowCommitment for a scoped commitment. + + Returns None (logged) when the scope matches no device in the flex-model, so + the commitment binds nothing rather than failing the whole schedule. + + :raises ValueError: When the scoped devices span more than one commodity. + """ + scoped_devices, scope_description = self._resolve_commitment_scope( + scoped_sensors, scoped_group + ) + if not scoped_devices: + current_app.logger.warning( + f"Commitment '{commitment_spec.get('name')}' is scoped to" + f" {scope_description}, none of which appear in the flex-model." + " This commitment will not bind any device." + ) + return None + commodities = { + self.device_inventory.by_index(d).commodity for d in scoped_devices + } + if len(commodities) > 1: + raise ValueError( + f"Commitment '{commitment_spec.get('name')}' is scoped to devices of" + f" more than one commodity ({sorted(commodities)}); a commitment binds" + " the aggregate flow of a single commodity." + ) + index = commitment_spec["index"] + # device_group maps device index -> group label; one shared label makes the + # engine bind the aggregate flow. The label is unique per commitment so two + # scoped commitments never merge, even if they share a name. + group_label = f"scoped-commitment-{commitment_index}" + return FlowCommitment( + device=pd.Series([scoped_devices] * len(index), index=index), + device_group=pd.Series({d: group_label for d in scoped_devices}), + provenance="custom", + **commitment_spec, + ) + def convert_to_commitments( self, flex_model, @@ -1579,7 +1656,7 @@ def convert_to_commitments( commitments = [] # The specs were copied above, so converting (which pops fields) does not # mutate self.flex_context and repeated conversions see the original specs. - for commitment_spec in commitment_specs: + for commitment_index, commitment_spec in enumerate(commitment_specs): # Convert baseline, up_price and down_price to pd.Series, then create FlowCommitment if "up_price" in commitment_spec: @@ -1609,38 +1686,20 @@ def convert_to_commitments( ) commitment_commodity = commitment_spec.get("commodity", "electricity") - # A commitment scoped to specific sensors binds the *aggregate* flow - # of those devices as one commitment, rather than each device separately. + # A commitment scoped to a subset of devices binds the *aggregate* flow of + # those devices as one commitment, rather than each device separately. The + # scope is given either as a raw list of power `sensors` (a cherry-pick that + # may span electrical groups, e.g. an aFRR band on a site's e-heaters) or as + # a `group` reference (the members of an electrical group, reusing its + # already-resolved membership). The schema allows at most one of the two. scoped_sensors = commitment_spec.pop("sensors", None) - if scoped_sensors is not None: - scoped_sensor_ids = { - sensor.id if hasattr(sensor, "id") else sensor - for sensor in scoped_sensors - } - # Canonical solver device indices come from the device inventory - # (never from re-enumerating raw flex-model entry lists). - scoped_devices = sorted( - device.index - for sensor_id in scoped_sensor_ids - for device in self.device_inventory.by_sensor_id(sensor_id) - ) - if not scoped_devices: - current_app.logger.warning( - f"Commitment '{commitment_spec.get('name')}' is scoped to" - f" sensors {sorted(scoped_sensor_ids)}, none of which appear" - " in the flex-model. This commitment will not bind any device." - ) - continue - index = commitment_spec["index"] - group_label = commitment_spec.get("name", "scoped commitment") - commitment = FlowCommitment( - device=pd.Series([scoped_devices] * len(index), index=index), - # device_group maps device index -> group label; one shared - # label makes the engine bind the aggregate flow. - device_group=pd.Series({d: group_label for d in scoped_devices}), - **commitment_spec, + scoped_group = commitment_spec.pop("group", None) + if scoped_sensors is not None or scoped_group is not None: + scoped = self._build_scoped_commitment( + commitment_spec, scoped_sensors, scoped_group, commitment_index ) - commitments.append(commitment) + if scoped is not None: + commitments.append(scoped) continue bound_device_count = 0 for d, flex_model_d in enumerate(flex_model): diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 903a520d62..8a7670d3b9 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -2165,6 +2165,112 @@ def sensor(name): np.testing.assert_allclose(combined.iloc[:-1], 10.0, rtol=1e-4) +def test_commitment_scope_sensors_and_group_are_mutually_exclusive(app, db): + """A commitment's scope is either a sensor list or a group reference, not both.""" + from flexmeasures.data.schemas.scheduling import CommitmentSchema + from marshmallow import ValidationError + + heater_type = get_or_create_model(GenericAssetType, name="e-heater") + site = GenericAsset(name="scope-conflict site", generic_asset_type=heater_type) + db.session.add(site) + db.session.flush() + power = Sensor( + name="scope-conflict power", + unit="MW", + event_resolution=pd.Timedelta("1h"), + generic_asset=site, + ) + db.session.add(power) + db.session.flush() + + with pytest.raises(ValidationError, match="not both"): + CommitmentSchema().load( + { + "name": "conflicted", + "baseline": "1 MW", + "up-price": "1 EUR/MWh", + "sensors": [power.id], + "group": {"sensor": power.id}, + } + ) + + +def test_group_scoped_commitment_binds_group_aggregate(app, db): + """A commitment scoped to a ``group`` reference binds the aggregate flow of that + group's members, reusing the group's resolved membership -- the same band effect as + listing the members' sensors, but pointing at the group instead.""" + heater_type = get_or_create_model(GenericAssetType, name="e-heater") + site = GenericAsset(name="Group-scoped band site", generic_asset_type=heater_type) + db.session.add(site) + db.session.flush() + + resolution = pd.Timedelta("1h") + start = pd.Timestamp("2026-02-01T00:00:00+01:00") + end = pd.Timestamp("2026-02-01T04:00:00+01:00") + + def sensor(name): + s = Sensor( + name=name, unit="MW", event_resolution=resolution, generic_asset=site + ) + db.session.add(s) + return s + + heater_1 = sensor("group band heater 1") + heater_2 = sensor("group band heater 2") + group_sensor = sensor("group aggregate sensor") + db.session.flush() + + flex_model = [ + { + "sensor": heater_1.id, + "power-capacity": "8 MW", + "consumption-capacity": "8 MW", + "production-capacity": "0 kW", + "group": {"sensor": group_sensor.id}, + }, + { + "sensor": heater_2.id, + "power-capacity": "8 MW", + "consumption-capacity": "8 MW", + "production-capacity": "0 kW", + "group": {"sensor": group_sensor.id}, + }, + # The group entry (a loose cap so it does not itself bind the aggregate). + {"sensor": group_sensor.id, "power-capacity": "1 GW"}, + ] + flex_context = { + "consumption-price": "50 EUR/MWh", + "production-price": "50 EUR/MWh", + "site-power-capacity": "1 GW", + "commitments": [ + { + "name": "reserved band on the group", + "group": {"sensor": group_sensor.id}, + "baseline": "10 MW", + "down-price": "-10000 EUR/MWh", + } + ], + } + + scheduler = StorageScheduler( + asset_or_sensor=site, + start=start, + end=end, + resolution=resolution, + belief_time=start, + flex_model=flex_model, + flex_context=flex_context, + return_multiple=True, + ) + results = scheduler.compute(skip_validation=True) + schedules = { + r["sensor"]: r["data"] for r in results if r.get("name") == "storage_schedule" + } + combined = schedules[heater_1] + schedules[heater_2] + # The band on the group keeps its members' aggregate at 10 MW. + np.testing.assert_allclose(combined.iloc[:-1], 10.0, rtol=1e-4) + + def test_commitments_in_commodity_contexts_are_converted(app): """Commitments saved within a commodity context (as the UI editor does per commodity tab) are picked up by the scheduler and bind that context's commodity. diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index f027ff935b..911d501c56 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -30,6 +30,7 @@ PriceField, ) from flexmeasures.data.schemas.scheduling import metadata +from flexmeasures.data.schemas.scheduling.groups import GroupReferenceSchema from flexmeasures.data.schemas.units import UnitField from flexmeasures.utils.doc_utils import rst_to_openapi from flexmeasures.data.schemas.times import ( @@ -75,15 +76,25 @@ def forbid_time_series_specs(self, data: dict, **kwargs): class CommitmentSchema(Schema): name = fields.Str(required=True, data_key="name", validate=validate.Length(min=1)) - # Optional scoping: bind this commitment to the aggregate flow of the - # devices whose power sensors are listed, rather than binding each device - # separately. Useful to commit a band on a subset of devices (e.g. an - # aFRR band on a site's e-heaters). + # Optional scoping: bind this commitment to the aggregate flow of a subset of + # devices, rather than binding each device of the commodity separately. Give either + # a list of power `sensors` (a cherry-pick that may span electrical groups, e.g. an + # aFRR band on a site's e-heaters) or a `group` reference (the members of an + # electrical group). At most one of the two. + # + # A sensor scope binds the listed *flexible* devices only; a group scope binds the + # group's members, which include any inflexible (fixed-load) members, so the + # aggregate covers the node's total flow -- see StorageScheduler._resolve_commitment_scope. sensors = fields.List( SensorIdField(), required=False, data_key="sensors", ) + group = fields.Nested( + GroupReferenceSchema, + required=False, + data_key="group", + ) # Not described in UI_FLEX_CONTEXT_SCHEMA or the Sphinx docs (it does show up # in the generated OpenAPI schema, without being promoted in field descriptions). # Internal bookkeeping only: not the documented way to associate a commitment @@ -110,6 +121,15 @@ class CommitmentSchema(Schema): data_key="down-price", ) + @validates_schema + def forbid_scope_conflict(self, commitment, **kwargs): + """A commitment's scope is a sensor list or a group reference, not both.""" + if "sensors" in commitment and "group" in commitment: + raise ValidationError( + "A commitment may be scoped by 'sensors' or by 'group', not both.", + field_name="sensors", + ) + @validates_schema def require_a_price(self, commitment, **kwargs): """A commitment is worthless without at least one deviation price. diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index bef5862aaf..afc7ce3704 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4920,6 +4920,18 @@ } } }, + "GroupReference": { + "type": "object", + "properties": { + "sensor": { + "type": "integer" + }, + "asset": { + "type": "integer" + } + }, + "description": "Reference to a group of devices whose aggregate power is constrained." + }, "Commitment": { "type": "object", "properties": { @@ -4933,6 +4945,9 @@ "type": "integer" } }, + "group": { + "$ref": "#/components/schemas/GroupReference" + }, "commodity": { "type": "string", "default": "electricity" @@ -6755,18 +6770,6 @@ } ] }, - "GroupReference": { - "type": "object", - "properties": { - "sensor": { - "type": "integer" - }, - "asset": { - "type": "integer" - } - }, - "description": "Reference to a group of devices whose aggregate power is constrained." - }, "StorageFlexModelSchemaOpenAPI": { "type": "object", "properties": { From c35820a1597d1294bb0e48486396feef9b2422a5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 1 Aug 2026 01:29:28 +0200 Subject: [PATCH 4/7] fix: pin a scoped commitment's commodity to its scoped devices; tests + comment tidy Addresses Copilot's review on #2295: - A scoped commitment now takes its commodity from the (single) commodity of its scoped devices, overriding the schema's electricity default, so its cost is not misattributed to the wrong commodity. - Add tests for the commodity pinning and for the 'scope matches no device -> warn and bind nothing' path. - Fix the sensor-scoped test docstring (it mentioned a battery the test never had). - Reflow the CommitmentSchema scoping comment to break only after punctuation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TaMepbxRYzJxQKFtu6Hq4p Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 5 + .../models/planning/tests/test_commitments.py | 94 ++++++++++++++++++- .../data/schemas/scheduling/__init__.py | 17 ++-- 3 files changed, 106 insertions(+), 10 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 7ec4645229..4ecacc916b 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1610,6 +1610,11 @@ def _build_scoped_commitment( f" more than one commodity ({sorted(commodities)}); a commitment binds" " the aggregate flow of a single commodity." ) + # A scoped commitment's commodity is defined by its scope, so pin it to the + # scoped devices' (single) commodity -- otherwise the commitment keeps the + # schema's electricity default (or a mismatching explicit value) and its cost + # would be misattributed to the wrong commodity. + commitment_spec["commodity"] = next(iter(commodities)) index = commitment_spec["index"] # device_group maps device index -> group label; one shared label makes the # engine bind the aggregate flow. The label is unique per commitment so two diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 8a7670d3b9..2e575a619b 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -2089,7 +2089,7 @@ def test_sensor_scoped_commitment_binds_aggregate_of_selected_devices(app, db): """A commitment scoped to specific sensors (here: two e-heaters) binds their aggregate flow as one commitment: a baseline of 10 MW with a steep penalty on downward deviation keeps their combined consumption at 10 MW even though a - cheaper allocation (0 MW) exists, while an unscoped battery stays unaffected. + cheaper allocation (0 MW) exists. """ heater_type = get_or_create_model(GenericAssetType, name="e-heater") site = GenericAsset( @@ -2271,6 +2271,98 @@ def sensor(name): np.testing.assert_allclose(combined.iloc[:-1], 10.0, rtol=1e-4) +def test_scoped_commitment_pins_commodity_to_scoped_devices(app): + """A scoped commitment's commodity follows its scoped devices, overriding the + schema's electricity default -- so its cost is attributed to the right commodity.""" + from flexmeasures.data.models.planning.devices import DeviceInventory + + scheduler = object.__new__(StorageScheduler) + start = pd.Timestamp("2024-01-01T00:00:00+01:00") + end = pd.Timestamp("2024-01-01T03:00:00+01:00") + resolution = pd.Timedelta("1h") + + gas_load = Sensor( + name="scoped gas load", + unit="MW", + event_resolution=resolution, + generic_asset_id=1, + ) + gas_load.id = 12 + flex_model = [{"sensor": gas_load, "commodity": "gas"}] + scheduler.flex_context = { + "shared_currency_unit": "EUR", + "commitments": [ + { + "name": "gas band", + "commodity": "electricity", # as the schema's electricity default supplies + "sensors": [gas_load], + "baseline": ur.Quantity("1 MW"), + "up_price": ur.Quantity("1 EUR/MWh"), + } + ], + } + scheduler.device_inventory = DeviceInventory.from_flex_config( + flex_model, scheduler.flex_context + ) + + commitments = scheduler.convert_to_commitments( + flex_model, + query_window=(start, end), + resolution=resolution, + beliefs_before=start, + ) + assert len(commitments) == 1 + # Pinned to the scoped device's commodity, not the "electricity" default. + assert commitments[0].commodity == "gas" + + +def test_scoped_commitment_with_no_matching_devices_warns_and_binds_nothing( + app, caplog +): + """A scope that matches no device in the flex-model logs a warning and binds + nothing, rather than failing the whole schedule.""" + import logging + from flexmeasures.data.models.planning.devices import DeviceInventory + + scheduler = object.__new__(StorageScheduler) + start = pd.Timestamp("2024-01-01T00:00:00+01:00") + end = pd.Timestamp("2024-01-01T03:00:00+01:00") + resolution = pd.Timedelta("1h") + + present = Sensor( + name="present device", + unit="MW", + event_resolution=resolution, + generic_asset_id=1, + ) + present.id = 1 + flex_model = [{"sensor": present, "commodity": "electricity"}] + scheduler.flex_context = { + "shared_currency_unit": "EUR", + "commitments": [ + { + "name": "orphan band", + "sensors": [999], # no device in the flex-model records this sensor + "baseline": ur.Quantity("1 MW"), + "up_price": ur.Quantity("1 EUR/MWh"), + } + ], + } + scheduler.device_inventory = DeviceInventory.from_flex_config( + flex_model, scheduler.flex_context + ) + + with caplog.at_level(logging.WARNING): + commitments = scheduler.convert_to_commitments( + flex_model, + query_window=(start, end), + resolution=resolution, + beliefs_before=start, + ) + assert commitments == [] # bound nothing, did not raise + assert "will not bind any device" in caplog.text + + def test_commitments_in_commodity_contexts_are_converted(app): """Commitments saved within a commodity context (as the UI editor does per commodity tab) are picked up by the scheduler and bind that context's commodity. diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 911d501c56..2e1ad470a9 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -76,15 +76,14 @@ def forbid_time_series_specs(self, data: dict, **kwargs): class CommitmentSchema(Schema): name = fields.Str(required=True, data_key="name", validate=validate.Length(min=1)) - # Optional scoping: bind this commitment to the aggregate flow of a subset of - # devices, rather than binding each device of the commodity separately. Give either - # a list of power `sensors` (a cherry-pick that may span electrical groups, e.g. an - # aFRR band on a site's e-heaters) or a `group` reference (the members of an - # electrical group). At most one of the two. - # - # A sensor scope binds the listed *flexible* devices only; a group scope binds the - # group's members, which include any inflexible (fixed-load) members, so the - # aggregate covers the node's total flow -- see StorageScheduler._resolve_commitment_scope. + # Optional scoping: bind this commitment to the aggregate flow of a subset of devices, + # rather than binding each device of the commodity separately. + # Give either a list of power `sensors` (a cherry-pick that may span electrical groups, + # e.g. an aFRR band on a site's e-heaters), + # or a `group` reference (the members of an electrical group); at most one of the two. + # A sensor scope binds the listed *flexible* devices only; + # a group scope binds the group's members, which include any inflexible (fixed-load) members, + # so the aggregate covers the node's total flow (see StorageScheduler._resolve_commitment_scope). sensors = fields.List( SensorIdField(), required=False, From 81f00efe426c19af6504279c2b4d53eed3888d5b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 1 Aug 2026 11:51:19 +0200 Subject: [PATCH 5/7] feat: a sensor-scoped commitment also binds inflexible devices; document net signed flow A commitment's 'sensors' scope now resolves through a new inventory helper, scheduled_devices_by_sensor_id, that includes inflexible (fixed-power) devices -- not just flexible ones (by_sensor_id stays flexible-only for its other uses). So a sensor scope and a group scope handle inflexible members the same way, and listing a group's member sensors binds the same set as scoping by that group. Also document that a scoped commitment binds the net *signed* aggregate flow (consumption positive, production negative), so consumers add, producers subtract, and any fixed member contributes its fixed signed power -- on the schema field, the resolver docstring and the changelog. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TaMepbxRYzJxQKFtu6Hq4p Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 2 +- flexmeasures/data/models/planning/devices.py | 12 ++++ flexmeasures/data/models/planning/storage.py | 19 +++--- .../models/planning/tests/test_commitments.py | 62 +++++++++++++++++++ .../planning/tests/test_device_inventory.py | 18 ++++++ .../data/schemas/scheduling/__init__.py | 9 ++- 6 files changed, 111 insertions(+), 11 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 162b25af8b..127dc0740f 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -43,7 +43,7 @@ New features * Add support for intermediate power constraints on groups of devices, via a new ``group`` field in the storage flex-model [see `PR #2276 `_ and `issue #2092 `_] * The ``group`` field now also accepts a ``{"asset": }`` reference (in addition to ``{"sensor": }``), allowing intermediate power constraints to be defined entirely from flex-models stored on the asset tree, with results saved via the group's ``consumption``/``production`` output sensors, without needing any flex-model in the scheduling trigger [see `issue #2092 `_] * Extended the scheduling job ``result`` field with a ``num-beliefs`` field reporting the total number of beliefs (scheduled values) saved to the database [see `PR #2280 `_] -* Flex-context commitments can be scoped to a subset of devices (via a new optional ``sensors`` list or ``group`` reference), binding their aggregate flow as one commitment instead of binding each device separately; a ``sensors`` scope can cherry-pick devices across electrical groups, while a ``group`` scope reuses a group's members (including its inflexible ones) [see `PR #2295 `_] +* Flex-context commitments can be scoped to a subset of devices (via a new optional ``sensors`` list or ``group`` reference), binding the net signed aggregate flow (consumption positive, production negative) of those devices as one commitment instead of binding each device separately; a ``sensors`` scope can cherry-pick devices across electrical groups, a ``group`` scope reuses a group's members, and either scope includes a device whether flexible or inflexible [see `PR #2295 `_] * Migrate the asset tree in the UI's Structure tab from Vega to ECharts, adding interactive pan/zoom navigation and refreshed node styling [see `PR #2025 `_ and `PR #2365 `_] Infrastructure / Support diff --git a/flexmeasures/data/models/planning/devices.py b/flexmeasures/data/models/planning/devices.py index e0eb1852bd..404ee4ca9a 100644 --- a/flexmeasures/data/models/planning/devices.py +++ b/flexmeasures/data/models/planning/devices.py @@ -624,6 +624,18 @@ def by_sensor_id(self, sensor_id: int) -> list[FlexDevice]: """Return the flexible devices whose power sensor has the given id.""" return [device for device in self.devices if device.sensor_id == sensor_id] + def scheduled_devices_by_sensor_id(self, sensor_id: int) -> list[FlexDevice]: + """Return all devices in the optimization -- flexible *and* inflexible -- whose power sensor has the given id. + + Unlike :meth:`by_sensor_id`, this includes inflexible (fixed-power) devices, so a + commitment scoped to a sensor list can bind an inflexible device's flow too. + """ + return [ + device + for device in (*self.devices, *self.inflexible_devices) + if device.sensor_id == sensor_id + ] + @cached_property def stock_groups(self) -> dict[int, list[int]]: """Map each stock key to the indices of the devices drawing from that stock. diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 4ecacc916b..0d68d57140 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1551,12 +1551,15 @@ def _resolve_commitment_scope( ) -> tuple[list[int], str]: """Resolve a scoped commitment's device set to canonical solver indices. - A ``group`` scope yields the group's (leaf) members, which -- unlike the - by-sensor scope -- include any inflexible members, so the aggregate covers the - node's total flow (fixed load included). A ``sensors`` scope yields the listed - *flexible* devices (``by_sensor_id`` returns schedulable devices only, so - inflexible devices are not bound by a sensor scope). Canonical indices always - come from the device inventory, never from re-enumerating raw flex-model lists. + Both scopes include a device whether it is flexible or inflexible: a ``group`` + scope yields the group's (leaf) members, and a ``sensors`` scope yields the + devices recording the listed power sensors. So listing a group's member sensors + resolves to the same set as scoping by that group. Canonical indices always come + from the device inventory, never from re-enumerating raw flex-model lists. + + The commitment then binds the *net signed* aggregate of these devices' flow + (consumption positive, production negative), so consumers add, producers + subtract, and any inflexible (fixed) member contributes its fixed signed power. :returns: A ``(sorted device indices, human-readable scope description)`` pair. """ @@ -1577,7 +1580,9 @@ def _resolve_commitment_scope( scoped_devices = sorted( device.index for sensor_id in scoped_sensor_ids - for device in self.device_inventory.by_sensor_id(sensor_id) + for device in self.device_inventory.scheduled_devices_by_sensor_id( + sensor_id + ) ) return scoped_devices, f"sensors {sorted(scoped_sensor_ids)}" diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 2e575a619b..390b6c06b3 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -2271,6 +2271,68 @@ def sensor(name): np.testing.assert_allclose(combined.iloc[:-1], 10.0, rtol=1e-4) +def test_sensor_scope_includes_inflexible_and_matches_group_scope(app): + """A sensors scope includes an inflexible device (by its power sensor), so listing a + group's member sensors binds the same device set as scoping by that group.""" + from flexmeasures.data.models.planning.devices import DeviceInventory + + scheduler = object.__new__(StorageScheduler) + start = pd.Timestamp("2024-01-01T00:00:00+01:00") + end = pd.Timestamp("2024-01-01T03:00:00+01:00") + resolution = pd.Timedelta("1h") + + def mk(sid, name): + s = Sensor( + name=name, unit="MW", event_resolution=resolution, generic_asset_id=1 + ) + s.id = sid + return s + + battery = mk(1, "scope battery") + load = mk(12, "scope fixed load") + group_sensor = mk(10, "scope group sensor") + + flex_model = [ + {"sensor": battery, "group": {"sensor": group_sensor}}, + { + "asset": object(), + "inflexible_consumption": load, + "group": {"sensor": group_sensor}, + }, + {"sensor": group_sensor, "power_capacity_in_mw": ur.Quantity("1 GW")}, + ] + + def commitment_devices(scope): + scheduler.flex_context = { + "shared_currency_unit": "EUR", + "commitments": [ + { + "name": "band", + "baseline": ur.Quantity("1 MW"), + "up_price": ur.Quantity("1 EUR/MWh"), + **scope, + } + ], + } + scheduler.device_inventory = DeviceInventory.from_flex_config( + flex_model, scheduler.flex_context + ) + commitments = scheduler.convert_to_commitments( + flex_model, + query_window=(start, end), + resolution=resolution, + beliefs_before=start, + ) + # FlowCommitment.device is a Series of (identical) device-index lists. + return set(commitments[0].device.iloc[0]) + + by_sensors = commitment_devices({"sensors": [battery, load]}) + by_group = commitment_devices({"group": {"sensor": group_sensor}}) + + assert by_sensors == by_group # listing the members == scoping the group + assert by_sensors == {0, 1} # the flexible battery (0) and the inflexible load (1) + + def test_scoped_commitment_pins_commodity_to_scoped_devices(app): """A scoped commitment's commodity follows its scoped devices, overriding the schema's electricity default -- so its cost is attributed to the right commodity.""" diff --git a/flexmeasures/data/models/planning/tests/test_device_inventory.py b/flexmeasures/data/models/planning/tests/test_device_inventory.py index 6705e1070b..d179d94faa 100644 --- a/flexmeasures/data/models/planning/tests/test_device_inventory.py +++ b/flexmeasures/data/models/planning/tests/test_device_inventory.py @@ -210,6 +210,24 @@ def test_by_sensor_id(): assert inventory.by_sensor_id(3) == [] +def test_scheduled_devices_by_sensor_id_includes_inflexible(): + """scheduled_devices_by_sensor_id returns flexible *and* inflexible devices, whereas + by_sensor_id returns flexible devices only.""" + battery = make_sensor(1) + load = make_sensor(12) + inventory = DeviceInventory.from_flex_config( + [ + {"sensor": battery}, + {"asset": object(), "inflexible_consumption": load}, + ] + ) + # by_sensor_id is flexible-only, so the inflexible load's sensor matches nothing. + assert inventory.by_sensor_id(12) == [] + # scheduled_devices_by_sensor_id includes the inflexible device (index 1). + assert [d.index for d in inventory.scheduled_devices_by_sensor_id(12)] == [1] + assert [d.index for d in inventory.scheduled_devices_by_sensor_id(1)] == [0] + + def test_state_of_charge_as_time_series_forms_own_stock(): """A state of charge given as a value or time series (rather than a sensor reference) cannot link devices into a shared stock: the device keeps its own diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 2e1ad470a9..8034dcb66f 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -81,9 +81,12 @@ class CommitmentSchema(Schema): # Give either a list of power `sensors` (a cherry-pick that may span electrical groups, # e.g. an aFRR band on a site's e-heaters), # or a `group` reference (the members of an electrical group); at most one of the two. - # A sensor scope binds the listed *flexible* devices only; - # a group scope binds the group's members, which include any inflexible (fixed-load) members, - # so the aggregate covers the node's total flow (see StorageScheduler._resolve_commitment_scope). + # Either scope includes a device whether flexible or inflexible, so listing a group's + # member sensors resolves to the same set as scoping by that group. + # The commitment binds the net signed aggregate of the scoped devices (consumption + # positive, production negative), so consumers add, producers subtract, and any + # inflexible (fixed) member contributes its fixed signed power -- see + # StorageScheduler._resolve_commitment_scope. sensors = fields.List( SensorIdField(), required=False, From 4e36ef5eb60864796aad19dd06b01872ebde3cb2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 1 Aug 2026 12:13:54 +0200 Subject: [PATCH 6/7] style: reflow scoped-commitment docstrings and comments to break only after punctuation Follows the repo docstring/comment convention (.github/instructions/docstrings.instructions.md): line breaks only after punctuation. Addresses the four docstring/comment findings Copilot suppressed on #2295, and the other mid-phrase breaks introduced alongside them. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TaMepbxRYzJxQKFtu6Hq4p Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/devices.py | 6 +-- flexmeasures/data/models/planning/storage.py | 46 +++++++++---------- .../models/planning/tests/test_commitments.py | 36 ++++++++------- .../planning/tests/test_device_inventory.py | 4 +- .../data/schemas/scheduling/__init__.py | 11 ++--- 5 files changed, 52 insertions(+), 51 deletions(-) diff --git a/flexmeasures/data/models/planning/devices.py b/flexmeasures/data/models/planning/devices.py index 404ee4ca9a..092586c2bc 100644 --- a/flexmeasures/data/models/planning/devices.py +++ b/flexmeasures/data/models/planning/devices.py @@ -625,10 +625,10 @@ def by_sensor_id(self, sensor_id: int) -> list[FlexDevice]: return [device for device in self.devices if device.sensor_id == sensor_id] def scheduled_devices_by_sensor_id(self, sensor_id: int) -> list[FlexDevice]: - """Return all devices in the optimization -- flexible *and* inflexible -- whose power sensor has the given id. + """Return all devices (flexible and inflexible) whose power sensor has the given id. - Unlike :meth:`by_sensor_id`, this includes inflexible (fixed-power) devices, so a - commitment scoped to a sensor list can bind an inflexible device's flow too. + Unlike :meth:`by_sensor_id`, this includes inflexible (fixed-power) devices, + so a commitment scoped to a sensor list can bind an inflexible device's flow too. """ return [ device diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 0d68d57140..e4d6765c24 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1551,15 +1551,15 @@ def _resolve_commitment_scope( ) -> tuple[list[int], str]: """Resolve a scoped commitment's device set to canonical solver indices. - Both scopes include a device whether it is flexible or inflexible: a ``group`` - scope yields the group's (leaf) members, and a ``sensors`` scope yields the - devices recording the listed power sensors. So listing a group's member sensors - resolves to the same set as scoping by that group. Canonical indices always come - from the device inventory, never from re-enumerating raw flex-model lists. + Both scopes include a device whether it is flexible or inflexible: + a ``group`` scope yields the group's (leaf) members, + and a ``sensors`` scope yields the devices recording the listed power sensors. + So listing a group's member sensors resolves to the same set as scoping by that group. + Canonical indices always come from the device inventory, + never from re-enumerating raw flex-model lists. - The commitment then binds the *net signed* aggregate of these devices' flow - (consumption positive, production negative), so consumers add, producers - subtract, and any inflexible (fixed) member contributes its fixed signed power. + The commitment then binds the *net signed* aggregate of these devices' flow (consumption positive, production negative), + so consumers add, producers subtract, and any inflexible (fixed) member contributes its fixed signed power. :returns: A ``(sorted device indices, human-readable scope description)`` pair. """ @@ -1591,8 +1591,8 @@ def _build_scoped_commitment( ) -> "FlowCommitment | None": """Build one aggregate-flow FlowCommitment for a scoped commitment. - Returns None (logged) when the scope matches no device in the flex-model, so - the commitment binds nothing rather than failing the whole schedule. + Returns None (logged) when the scope matches no device in the flex-model, + so the commitment binds nothing rather than failing the whole schedule. :raises ValueError: When the scoped devices span more than one commodity. """ @@ -1615,15 +1615,15 @@ def _build_scoped_commitment( f" more than one commodity ({sorted(commodities)}); a commitment binds" " the aggregate flow of a single commodity." ) - # A scoped commitment's commodity is defined by its scope, so pin it to the - # scoped devices' (single) commodity -- otherwise the commitment keeps the - # schema's electricity default (or a mismatching explicit value) and its cost - # would be misattributed to the wrong commodity. + # A scoped commitment's commodity is defined by its scope, + # so pin it to the scoped devices' (single) commodity; + # otherwise the commitment keeps the schema's electricity default (or a mismatching explicit value), + # and its cost would be misattributed to the wrong commodity. commitment_spec["commodity"] = next(iter(commodities)) index = commitment_spec["index"] - # device_group maps device index -> group label; one shared label makes the - # engine bind the aggregate flow. The label is unique per commitment so two - # scoped commitments never merge, even if they share a name. + # device_group maps device index -> group label; + # one shared label makes the engine bind the aggregate flow. + # The label is unique per commitment, so two scoped commitments never merge, even if they share a name. group_label = f"scoped-commitment-{commitment_index}" return FlowCommitment( device=pd.Series([scoped_devices] * len(index), index=index), @@ -1696,12 +1696,12 @@ def convert_to_commitments( ) commitment_commodity = commitment_spec.get("commodity", "electricity") - # A commitment scoped to a subset of devices binds the *aggregate* flow of - # those devices as one commitment, rather than each device separately. The - # scope is given either as a raw list of power `sensors` (a cherry-pick that - # may span electrical groups, e.g. an aFRR band on a site's e-heaters) or as - # a `group` reference (the members of an electrical group, reusing its - # already-resolved membership). The schema allows at most one of the two. + # A commitment scoped to a subset of devices binds the *aggregate* flow of those devices as one commitment, + # rather than each device separately. + # The scope is given either as a raw list of power `sensors` (a cherry-pick that may span electrical groups, + # e.g. an aFRR band on a site's e-heaters), + # or as a `group` reference (the members of an electrical group, reusing its already-resolved membership); + # the schema allows at most one of the two. scoped_sensors = commitment_spec.pop("sensors", None) scoped_group = commitment_spec.pop("group", None) if scoped_sensors is not None or scoped_group is not None: diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 390b6c06b3..67abe0d4ca 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -2086,10 +2086,9 @@ def test_commitment_commodity_does_not_bind_other_commodity_devices(): def test_sensor_scoped_commitment_binds_aggregate_of_selected_devices(app, db): - """A commitment scoped to specific sensors (here: two e-heaters) binds their - aggregate flow as one commitment: a baseline of 10 MW with a steep penalty on - downward deviation keeps their combined consumption at 10 MW even though a - cheaper allocation (0 MW) exists. + """A commitment scoped to specific sensors (here: two e-heaters) binds their aggregate flow as one commitment: + a baseline of 10 MW with a steep penalty on downward deviation keeps their combined consumption at 10 MW, + even though a cheaper allocation (0 MW) exists. """ heater_type = get_or_create_model(GenericAssetType, name="e-heater") site = GenericAsset( @@ -2115,8 +2114,8 @@ def sensor(name): flex_model = [ { - # Heaters burn money at the consumption price; without the band - # commitment the optimum is to stay off. + # Heaters burn money at the consumption price; + # without the band commitment the optimum is to stay off. "sensor": heater_1.id, "power-capacity": "8 MW", "consumption-capacity": "8 MW", @@ -2138,8 +2137,8 @@ def sensor(name): "name": "reserved band", "sensors": [heater_1.id, heater_2.id], "baseline": "10 MW", - # Steep penalty for consuming less than the band (negative price - # penalizes downward deviation); consuming more is free. + # Steep penalty for consuming less than the band (negative price penalizes downward deviation); + # consuming more is free. "down-price": "-10000 EUR/MWh", } ], @@ -2196,9 +2195,10 @@ def test_commitment_scope_sensors_and_group_are_mutually_exclusive(app, db): def test_group_scoped_commitment_binds_group_aggregate(app, db): - """A commitment scoped to a ``group`` reference binds the aggregate flow of that - group's members, reusing the group's resolved membership -- the same band effect as - listing the members' sensors, but pointing at the group instead.""" + """A commitment scoped to a ``group`` reference binds the aggregate flow of that group's members, + reusing the group's resolved membership; + the same band effect as listing the members' sensors, but pointing at the group instead. + """ heater_type = get_or_create_model(GenericAssetType, name="e-heater") site = GenericAsset(name="Group-scoped band site", generic_asset_type=heater_type) db.session.add(site) @@ -2272,8 +2272,9 @@ def sensor(name): def test_sensor_scope_includes_inflexible_and_matches_group_scope(app): - """A sensors scope includes an inflexible device (by its power sensor), so listing a - group's member sensors binds the same device set as scoping by that group.""" + """A sensors scope includes an inflexible device (by its power sensor), + so listing a group's member sensors binds the same device set as scoping by that group. + """ from flexmeasures.data.models.planning.devices import DeviceInventory scheduler = object.__new__(StorageScheduler) @@ -2334,8 +2335,9 @@ def commitment_devices(scope): def test_scoped_commitment_pins_commodity_to_scoped_devices(app): - """A scoped commitment's commodity follows its scoped devices, overriding the - schema's electricity default -- so its cost is attributed to the right commodity.""" + """A scoped commitment's commodity follows its scoped devices, + overriding the schema's electricity default, so its cost is attributed to the right commodity. + """ from flexmeasures.data.models.planning.devices import DeviceInventory scheduler = object.__new__(StorageScheduler) @@ -2381,8 +2383,8 @@ def test_scoped_commitment_pins_commodity_to_scoped_devices(app): def test_scoped_commitment_with_no_matching_devices_warns_and_binds_nothing( app, caplog ): - """A scope that matches no device in the flex-model logs a warning and binds - nothing, rather than failing the whole schedule.""" + """A scope that matches no device in the flex-model logs a warning and binds nothing, + rather than failing the whole schedule.""" import logging from flexmeasures.data.models.planning.devices import DeviceInventory diff --git a/flexmeasures/data/models/planning/tests/test_device_inventory.py b/flexmeasures/data/models/planning/tests/test_device_inventory.py index d179d94faa..67eec9e8f2 100644 --- a/flexmeasures/data/models/planning/tests/test_device_inventory.py +++ b/flexmeasures/data/models/planning/tests/test_device_inventory.py @@ -211,8 +211,8 @@ def test_by_sensor_id(): def test_scheduled_devices_by_sensor_id_includes_inflexible(): - """scheduled_devices_by_sensor_id returns flexible *and* inflexible devices, whereas - by_sensor_id returns flexible devices only.""" + """scheduled_devices_by_sensor_id returns flexible *and* inflexible devices, + whereas by_sensor_id returns flexible devices only.""" battery = make_sensor(1) load = make_sensor(12) inventory = DeviceInventory.from_flex_config( diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 8034dcb66f..ae640c2f94 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -81,12 +81,11 @@ class CommitmentSchema(Schema): # Give either a list of power `sensors` (a cherry-pick that may span electrical groups, # e.g. an aFRR band on a site's e-heaters), # or a `group` reference (the members of an electrical group); at most one of the two. - # Either scope includes a device whether flexible or inflexible, so listing a group's - # member sensors resolves to the same set as scoping by that group. - # The commitment binds the net signed aggregate of the scoped devices (consumption - # positive, production negative), so consumers add, producers subtract, and any - # inflexible (fixed) member contributes its fixed signed power -- see - # StorageScheduler._resolve_commitment_scope. + # Either scope includes a device whether flexible or inflexible, + # so listing a group's member sensors resolves to the same set as scoping by that group. + # The commitment binds the net signed aggregate of the scoped devices (consumption positive, production negative), + # so consumers add, producers subtract, + # and any inflexible (fixed) member contributes its fixed signed power (see StorageScheduler._resolve_commitment_scope). sensors = fields.List( SensorIdField(), required=False, From 67ed44bb6f3b8cf470081d9164e4816659a927db Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 1 Aug 2026 17:08:05 +0200 Subject: [PATCH 7/7] docs: explain the 'band' term in the scoped-commitment test; reword changelog Addresses two review comments on #2295: - Define 'band' (a committed power level the aggregate is held to by penalising deviation; here a floor, since only downward deviation is priced) in the sensor-scoped test docstring. - Reword the changelog to describe the final feature (a commitment binds the net signed aggregate of the scoped devices, flexible and inflexible alike), rather than the dev story of how the two scopes treat inflexible members. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TaMepbxRYzJxQKFtu6Hq4p Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 2 +- flexmeasures/data/models/planning/tests/test_commitments.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 127dc0740f..ac246dff09 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -43,7 +43,7 @@ New features * Add support for intermediate power constraints on groups of devices, via a new ``group`` field in the storage flex-model [see `PR #2276 `_ and `issue #2092 `_] * The ``group`` field now also accepts a ``{"asset": }`` reference (in addition to ``{"sensor": }``), allowing intermediate power constraints to be defined entirely from flex-models stored on the asset tree, with results saved via the group's ``consumption``/``production`` output sensors, without needing any flex-model in the scheduling trigger [see `issue #2092 `_] * Extended the scheduling job ``result`` field with a ``num-beliefs`` field reporting the total number of beliefs (scheduled values) saved to the database [see `PR #2280 `_] -* Flex-context commitments can be scoped to a subset of devices (via a new optional ``sensors`` list or ``group`` reference), binding the net signed aggregate flow (consumption positive, production negative) of those devices as one commitment instead of binding each device separately; a ``sensors`` scope can cherry-pick devices across electrical groups, a ``group`` scope reuses a group's members, and either scope includes a device whether flexible or inflexible [see `PR #2295 `_] +* Flex-context commitments can be scoped to a subset of devices, via a new optional ``sensors`` list (any devices, possibly across electrical groups) or ``group`` reference (an electrical group's members); the commitment binds the net signed aggregate flow (consumption positive, production negative) of those devices — flexible and inflexible alike — as one commitment, instead of binding each device separately [see `PR #2295 `_] * Migrate the asset tree in the UI's Structure tab from Vega to ECharts, adding interactive pan/zoom navigation and refreshed node styling [see `PR #2025 `_ and `PR #2365 `_] Infrastructure / Support diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 67abe0d4ca..ca0fc154f3 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -2089,6 +2089,10 @@ def test_sensor_scoped_commitment_binds_aggregate_of_selected_devices(app, db): """A commitment scoped to specific sensors (here: two e-heaters) binds their aggregate flow as one commitment: a baseline of 10 MW with a steep penalty on downward deviation keeps their combined consumption at 10 MW, even though a cheaper allocation (0 MW) exists. + + "Band" (as in the "reserved band" commitment name) means a committed power level the aggregate is held to, + by penalising deviation from the baseline; + here only downward deviation is priced, so the band acts as a floor rather than a two-sided range. """ heater_type = get_or_create_model(GenericAssetType, name="e-heater") site = GenericAsset(