Skip to content

Commit 69f7f56

Browse files
Flix6xclaude
andcommitted
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B
1 parent 60cfda8 commit 69f7f56

4 files changed

Lines changed: 128 additions & 0 deletions

File tree

flexmeasures/data/models/planning/storage.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1351,6 +1351,39 @@ def convert_to_commitments(
13511351
start, end, timing_kwargs["resolution"]
13521352
)
13531353
commitment_commodity = commitment_spec.get("commodity", "electricity")
1354+
1355+
# A commitment scoped to specific sensors binds the *aggregate* flow
1356+
# of those devices as one commitment, rather than each device separately.
1357+
scoped_sensors = commitment_spec.pop("sensors", None)
1358+
if scoped_sensors is not None:
1359+
scoped_sensor_ids = {
1360+
sensor.id if hasattr(sensor, "id") else sensor
1361+
for sensor in scoped_sensors
1362+
}
1363+
scoped_devices = [
1364+
d
1365+
for d, flex_model_d in enumerate(flex_model)
1366+
if getattr(flex_model_d.get("sensor"), "id", None)
1367+
in scoped_sensor_ids
1368+
]
1369+
if not scoped_devices:
1370+
current_app.logger.warning(
1371+
f"Commitment '{commitment_spec.get('name')}' is scoped to"
1372+
f" sensors {sorted(scoped_sensor_ids)}, none of which appear"
1373+
" in the flex-model. This commitment will not bind any device."
1374+
)
1375+
continue
1376+
index = commitment_spec["index"]
1377+
group_label = commitment_spec.get("name", "scoped commitment")
1378+
commitment = FlowCommitment(
1379+
device=pd.Series([scoped_devices] * len(index), index=index),
1380+
# device_group maps device index -> group label; one shared
1381+
# label makes the engine bind the aggregate flow.
1382+
device_group=pd.Series({d: group_label for d in scoped_devices}),
1383+
**commitment_spec,
1384+
)
1385+
commitments.append(commitment)
1386+
continue
13541387
for d, flex_model_d in enumerate(flex_model):
13551388
device_commodity = flex_model_d.get("commodity", "electricity")
13561389
if device_commodity != commitment_commodity:

flexmeasures/data/models/planning/tests/test_commitments.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1782,3 +1782,83 @@ def test_electricity_device_indices_exclude_other_commodities():
17821782
assert mapping["electricity"] == [0, 2, 3, 4]
17831783
assert mapping["gas"] == [1, 5]
17841784
assert scheduler._electricity_device_indices() == [0, 2, 3, 4]
1785+
1786+
1787+
def test_sensor_scoped_commitment_binds_aggregate_of_selected_devices(app, db):
1788+
"""A commitment scoped to specific sensors (here: two e-heaters) binds their
1789+
aggregate flow as one commitment: a baseline of 10 MW with a steep penalty on
1790+
downward deviation keeps their combined consumption at 10 MW even though a
1791+
cheaper allocation (0 MW) exists, while an unscoped battery stays unaffected.
1792+
"""
1793+
heater_type = get_or_create_model(GenericAssetType, name="e-heater")
1794+
site = GenericAsset(
1795+
name="Band site (scoped commitment test)", generic_asset_type=heater_type
1796+
)
1797+
db.session.add(site)
1798+
db.session.flush()
1799+
1800+
resolution = pd.Timedelta("1h")
1801+
start = pd.Timestamp("2026-02-01T00:00:00+01:00")
1802+
end = pd.Timestamp("2026-02-01T04:00:00+01:00")
1803+
1804+
def sensor(name):
1805+
s = Sensor(
1806+
name=name, unit="MW", event_resolution=resolution, generic_asset=site
1807+
)
1808+
db.session.add(s)
1809+
return s
1810+
1811+
heater_1 = sensor("band heater 1")
1812+
heater_2 = sensor("band heater 2")
1813+
db.session.flush()
1814+
1815+
flex_model = [
1816+
{
1817+
# Heaters burn money at the consumption price; without the band
1818+
# commitment the optimum is to stay off.
1819+
"sensor": heater_1.id,
1820+
"power-capacity": "8 MW",
1821+
"consumption-capacity": "8 MW",
1822+
"production-capacity": "0 kW",
1823+
},
1824+
{
1825+
"sensor": heater_2.id,
1826+
"power-capacity": "8 MW",
1827+
"consumption-capacity": "8 MW",
1828+
"production-capacity": "0 kW",
1829+
},
1830+
]
1831+
flex_context = {
1832+
"consumption-price": "50 EUR/MWh",
1833+
"production-price": "50 EUR/MWh",
1834+
"site-power-capacity": "1 GW",
1835+
"commitments": [
1836+
{
1837+
"name": "reserved band",
1838+
"sensors": [heater_1.id, heater_2.id],
1839+
"baseline": "10 MW",
1840+
# Steep penalty for consuming less than the band (negative price
1841+
# penalizes downward deviation); consuming more is free.
1842+
"down-price": "-10000 EUR/MWh",
1843+
}
1844+
],
1845+
}
1846+
1847+
scheduler = StorageScheduler(
1848+
asset_or_sensor=site,
1849+
start=start,
1850+
end=end,
1851+
resolution=resolution,
1852+
belief_time=start,
1853+
flex_model=flex_model,
1854+
flex_context=flex_context,
1855+
return_multiple=True,
1856+
)
1857+
results = scheduler.compute(skip_validation=True)
1858+
schedules = {
1859+
r["sensor"]: r["data"] for r in results if r.get("name") == "storage_schedule"
1860+
}
1861+
combined = schedules[heater_1] + schedules[heater_2]
1862+
# The band keeps the aggregate at 10 MW (cheapest way to avoid the penalty),
1863+
# even though each heater alone (8 MW max) could not carry it.
1864+
np.testing.assert_allclose(combined.iloc[:-1], 10.0, rtol=1e-4)

flexmeasures/data/schemas/scheduling/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ def forbid_time_series_specs(self, data: dict, **kwargs):
7171

7272
class CommitmentSchema(Schema):
7373
name = fields.Str(required=True, data_key="name")
74+
# Optional scoping: bind this commitment to the aggregate flow of the
75+
# devices whose power sensors are listed, rather than binding each device
76+
# separately. Useful to commit a band on a subset of devices (e.g. an
77+
# aFRR band on a site's e-heaters).
78+
sensors = fields.List(
79+
SensorIdField(),
80+
required=False,
81+
data_key="sensors",
82+
)
7483
baseline = VariableQuantityField("MW", required=False, data_key="baseline")
7584
up_price = VariableQuantityField("/MW", required=False, data_key="up-price")
7685
down_price = VariableQuantityField(

flexmeasures/ui/static/openapi-specs.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4575,6 +4575,12 @@
45754575
"name": {
45764576
"type": "string"
45774577
},
4578+
"sensors": {
4579+
"type": "array",
4580+
"items": {
4581+
"type": "integer"
4582+
}
4583+
},
45784584
"baseline": {},
45794585
"up-price": {},
45804586
"down-price": {}

0 commit comments

Comments
 (0)