diff --git a/documentation/changelog.rst b/documentation/changelog.rst index ed5b1019f1..e40d2803f0 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -29,6 +29,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 `_] * 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 diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 2c2211e439..3c8cd462a7 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1571,6 +1571,40 @@ 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 + } + # 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, + ) + commitments.append(commitment) + continue bound_device_count = 0 for d, flex_model_d in enumerate(flex_model): device_commodity = flex_model_d.get("commodity", "electricity") diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 7c37334299..ff82ed89e4 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -2046,3 +2046,83 @@ def test_commitment_commodity_does_not_bind_other_commodity_devices(): # the electricity device (index 0), not the gas device (index 1). assert (electricity_commitment.device == 0).all() assert set(electricity_commitment.device_group.unique()) == {"electricity"} + + +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 fdfb71f151..f5d4d7f5d0 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -73,6 +73,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", + ) # Undocumented for now (not part of UI_FLEX_CONTEXT_SCHEMA, OpenAPI or Sphinx docs). # Internal bookkeeping only: not the documented way to associate a commitment # with a commodity. API users should instead place the commitment under the diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 854f57305b..7d45a67241 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4621,6 +4621,12 @@ "name": { "type": "string" }, + "sensors": { + "type": "array", + "items": { + "type": "integer" + } + }, "commodity": { "type": "string", "default": "electricity"