Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2276>`_ and `issue #2092 <https://github.com/FlexMeasures/flexmeasures/issues/2092>`_]
* The ``group`` field now also accepts a ``{"asset": <id>}`` reference (in addition to ``{"sensor": <id>}``), 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 <https://github.com/FlexMeasures/flexmeasures/issues/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2025>`_]

Infrastructure / Support
Expand Down
34 changes: 34 additions & 0 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Is the regular commitment not aggregating over all devices (of one grid-connected commodity), rather than applied to each device individually?

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")
Expand Down
80 changes: 80 additions & 0 deletions flexmeasures/data/models/planning/tests/test_commitments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Explain the term "band" in the docstring.

)
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)
9 changes: 9 additions & 0 deletions flexmeasures/data/schemas/scheduling/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions flexmeasures/ui/static/openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -4621,6 +4621,12 @@
"name": {
"type": "string"
},
"sensors": {
"type": "array",
"items": {
"type": "integer"
}
},
"commodity": {
"type": "string",
"default": "electricity"
Expand Down
Loading