Skip to content

Commit 693d1ab

Browse files
BelhsanHmidaFlix6x
andauthored
Fix: power capacity does not need to be mandatory (#2222)
* fix: infer storage power capacity from directional limits Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * test: cover storage power capacity directional fallback Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * docs: clarify storage power capacity fallback Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * docs: add storage power capacity fallback changelog Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * docs: update changelog entry with pr number Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * Update flexmeasures/data/schemas/scheduling/metadata.py Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> * Update flexmeasures/data/schemas/scheduling/metadata.py Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> * docs: keep production capacity metadata focused Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * fix: default missing directional capacity to zero Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * test: cover one-sided directional capacities Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * docs: clarify one-sided directional capacities Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * Update flexmeasures/data/models/planning/storage.py Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> * Update flexmeasures/data/schemas/scheduling/metadata.py Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> * Update flexmeasures/data/models/planning/storage.py Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> * Update flexmeasures/data/schemas/scheduling/metadata.py Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> * docs: move power capacity changelog to features Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * fix: preserve fallback for zero directional capacity Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * docs: update power capacity openapi description Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> * style: run pre-commit Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> --------- Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com> Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com>
1 parent 42a1b55 commit 693d1ab

5 files changed

Lines changed: 197 additions & 13 deletions

File tree

documentation/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ New features
1111
-------------
1212
* Floor off-clock API datetimes to a non-instantaneous sensor's resolution by default when ingesting sensor data, uploading sensor data, and handling scheduler flex-model timed events; configurable with the ``floor_datetimes_to_resolution`` sensor attribute [see `PR #2146 <https://www.github.com/FlexMeasures/flexmeasures/pull/2146>`_]
1313
* Sensor references in flex-model and flex-context support various ways of filtering by source [see `PR #2209 <https://www.github.com/FlexMeasures/flexmeasures/pull/2209>`_]
14+
* Let storage scheduling infer missing ``power-capacity`` from directional device capacities before falling back to site capacity, and default the missing opposite capacity to zero when only a non-zero ``consumption-capacity`` or ``production-capacity`` is configured [see `PR #2222 <https://www.github.com/FlexMeasures/flexmeasures/pull/2222>`_]
1415

1516

1617
Infrastructure / Support

flexmeasures/data/models/planning/storage.py

Lines changed: 120 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
124124
flex_model = self.flex_model.copy()
125125
if not isinstance(flex_model, list):
126126
flex_model = [flex_model]
127+
else:
128+
flex_model = [flex_model_d.copy() for flex_model_d in flex_model]
129+
for flex_model_d in flex_model:
130+
self._default_missing_directional_capacity_to_zero(flex_model_d)
127131

128132
# total number of flexible devices D described in the flex-model
129133
num_flexible_devices = len(flex_model)
@@ -176,8 +180,14 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
176180
"inflexible_device_sensors", []
177181
)
178182

179-
# Fetch the device's power capacity (required Sensor attribute)
180-
power_capacity_in_mw = self._get_device_power_capacity(flex_model, assets)
183+
# Fetch the device's power capacity (required to keep the optimization problem bounded)
184+
power_capacity_in_mw = self._get_device_power_capacity(
185+
flex_model,
186+
assets,
187+
query_window=(start, end),
188+
resolution=resolution,
189+
beliefs_before=belief_time,
190+
)
181191

182192
# Check for known prices or price forecasts
183193
up_deviation_prices = get_continuous_series_sensor_or_quantity(
@@ -1448,14 +1458,20 @@ def ensure_soc_min_max(self):
14481458
)
14491459

14501460
def _get_device_power_capacity(
1451-
self, flex_model: list[dict], assets: list[Asset]
1452-
) -> list[ur.Quantity]:
1461+
self,
1462+
flex_model: list[dict],
1463+
assets: list[Asset],
1464+
query_window: tuple[datetime, datetime],
1465+
resolution: timedelta,
1466+
beliefs_before: datetime | None,
1467+
) -> list[Sensor | SensorReference | list[dict] | ur.Quantity | pd.Series]:
14531468
"""The device power capacity for each device must be known for the optimization problem to stay bounded.
14541469
14551470
We search for the power capacity in the following order:
14561471
1. Look for the power_capacity_in_mw field in the deserialized flex-model.
14571472
2. Look for the power-capacity flex-model field of the asset.
1458-
3. Look for the site-power-capacity attribute of the asset.
1473+
3. Look for the greatest device consumption-capacity or production-capacity.
1474+
4. Look for the site-power-capacity attribute of the asset.
14591475
"""
14601476
power_capacities = []
14611477
for flex_model_d, asset in zip(flex_model, assets):
@@ -1472,6 +1488,21 @@ def _get_device_power_capacity(
14721488
continue
14731489

14741490
# 3
1491+
fallback_capacity = self._get_largest_device_capacity(
1492+
flex_model_d=flex_model_d,
1493+
query_window=query_window,
1494+
resolution=resolution,
1495+
beliefs_before=beliefs_before,
1496+
)
1497+
if fallback_capacity is not None:
1498+
current_app.logger.warning(
1499+
f"Missing 'power-capacity' on asset {asset.id}. "
1500+
"Using the largest configured directional capacity instead."
1501+
)
1502+
power_capacities.append(fallback_capacity)
1503+
continue
1504+
1505+
# 4
14751506
site_power_capacity = asset.get_attribute("site-power-capacity")
14761507
if site_power_capacity is not None:
14771508
current_app.logger.warning(
@@ -1494,14 +1525,95 @@ def _get_device_power_capacity(
14941525
)
14951526
return power_capacities
14961527

1528+
@staticmethod
1529+
def _default_missing_directional_capacity_to_zero(flex_model_d: dict) -> None:
1530+
"""Given a missing capacity opposite a non-zero directional capacity, default the missing capacity to zero."""
1531+
consumption_capacity = flex_model_d.get("consumption_capacity")
1532+
production_capacity = flex_model_d.get("production_capacity")
1533+
has_consumption_capacity = consumption_capacity is not None
1534+
has_production_capacity = production_capacity is not None
1535+
1536+
if (
1537+
has_consumption_capacity
1538+
and not has_production_capacity
1539+
and MetaStorageScheduler._is_non_zero_capacity(consumption_capacity)
1540+
):
1541+
flex_model_d["production_capacity"] = ur.Quantity("0 MW")
1542+
elif (
1543+
has_production_capacity
1544+
and not has_consumption_capacity
1545+
and MetaStorageScheduler._is_non_zero_capacity(production_capacity)
1546+
):
1547+
flex_model_d["consumption_capacity"] = ur.Quantity("0 MW")
1548+
1549+
@staticmethod
1550+
def _is_non_zero_capacity(
1551+
capacity: str | int | float | ur.Quantity | Sensor | SensorReference | list,
1552+
) -> bool:
1553+
"""Return whether a configured capacity should imply zero capacity in the opposite direction."""
1554+
if isinstance(capacity, (Sensor, SensorReference)):
1555+
return True
1556+
if isinstance(capacity, list):
1557+
return any(
1558+
MetaStorageScheduler._is_non_zero_capacity(event["value"])
1559+
for event in capacity
1560+
)
1561+
if isinstance(capacity, str):
1562+
capacity = ur.Quantity(capacity)
1563+
if isinstance(capacity, ur.Quantity):
1564+
return bool(np.any(capacity.magnitude != 0))
1565+
return capacity != 0
1566+
1567+
def _get_largest_device_capacity(
1568+
self,
1569+
flex_model_d: dict,
1570+
query_window: tuple[datetime, datetime],
1571+
resolution: timedelta,
1572+
beliefs_before: datetime | None,
1573+
) -> Sensor | SensorReference | list[dict] | ur.Quantity | pd.Series | None:
1574+
"""Return the largest configured directional capacity, if any."""
1575+
capacity_fields = ("consumption_capacity", "production_capacity")
1576+
configured_capacity_fields = [
1577+
field for field in capacity_fields if flex_model_d.get(field) is not None
1578+
]
1579+
if not configured_capacity_fields:
1580+
return None
1581+
capacities = [
1582+
self._ensure_variable_quantity(flex_model_d[field], "MW")
1583+
for field in configured_capacity_fields
1584+
]
1585+
1586+
capacity_series = [
1587+
get_continuous_series_sensor_or_quantity(
1588+
variable_quantity=capacity,
1589+
unit="MW",
1590+
query_window=query_window,
1591+
resolution=resolution,
1592+
beliefs_before=beliefs_before,
1593+
min_value=0,
1594+
# Normally, we'd resolve overlapping time series segments for capacities with "min", but here our goal is to find the maximum capacity.
1595+
resolve_overlaps="max",
1596+
)
1597+
for capacity in capacities
1598+
]
1599+
largest_capacity = pd.concat(capacity_series, axis=1).max(axis=1)
1600+
if largest_capacity.isna().all():
1601+
return None
1602+
if (
1603+
len(configured_capacity_fields) == 1
1604+
and largest_capacity.fillna(0).eq(0).all()
1605+
):
1606+
return None
1607+
return largest_capacity
1608+
14971609
def _ensure_variable_quantity(
1498-
self, value: str | int | float | ur.Quantity, unit: str
1499-
) -> Sensor | SensorReference | list[dict] | ur.Quantity:
1610+
self, value: str | int | float | ur.Quantity | pd.Series, unit: str
1611+
) -> Sensor | SensorReference | list[dict] | ur.Quantity | pd.Series:
15001612
if isinstance(value, str):
15011613
q = ur.Quantity(value).to(unit)
15021614
elif isinstance(value, (float, int)):
15031615
q = ur.Quantity(f"{value} {unit}")
1504-
elif isinstance(value, (Sensor, SensorReference, list, ur.Quantity)):
1616+
elif isinstance(value, (Sensor, SensorReference, list, ur.Quantity, pd.Series)):
15051617
q = value
15061618
else:
15071619
raise TypeError(

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

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,6 +1390,75 @@ def set_if_not_none(dictionary, key, value):
13901390
assert all(ems_constraints["derivative max"] == expected_site_consumption_capacity)
13911391

13921392

1393+
@pytest.mark.parametrize(
1394+
"configured_capacities, expected_capacity, expected_derivative_min, expected_derivative_max",
1395+
[
1396+
(
1397+
{"production-capacity": "300 kW", "consumption-capacity": "700 kW"},
1398+
0.7,
1399+
-0.3,
1400+
0.7,
1401+
),
1402+
(
1403+
{"production-capacity": "1.1 MW", "consumption-capacity": "200 kW"},
1404+
1.1,
1405+
-1.1,
1406+
0.2,
1407+
),
1408+
({"consumption-capacity": "700 kW"}, 0.7, 0, 0.7),
1409+
({"production-capacity": "300 kW"}, 0.3, -0.3, 0),
1410+
({"consumption-capacity": "0 kW"}, 2, -2, 0),
1411+
({"production-capacity": "0 kW"}, 2, 0, 2),
1412+
],
1413+
)
1414+
def test_device_power_capacity_uses_directional_capacity_before_site_fallback(
1415+
db,
1416+
add_battery_assets,
1417+
configured_capacities,
1418+
expected_capacity,
1419+
expected_derivative_min,
1420+
expected_derivative_max,
1421+
):
1422+
_, battery = get_sensors_from_db(db, add_battery_assets)
1423+
1424+
start = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 2))
1425+
end = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 3))
1426+
resolution = timedelta(minutes=15)
1427+
scheduler = StorageScheduler(
1428+
asset_or_sensor=battery,
1429+
start=start,
1430+
end=end,
1431+
resolution=resolution,
1432+
flex_model={
1433+
"soc-at-start": 0,
1434+
"soc-min": 0,
1435+
"soc-max": 5,
1436+
**configured_capacities,
1437+
},
1438+
flex_context={"consumption-price": "1 EUR/MWh"},
1439+
)
1440+
scheduler.deserialize_config()
1441+
1442+
power_capacity = scheduler._get_device_power_capacity(
1443+
[scheduler.flex_model],
1444+
[battery.generic_asset],
1445+
query_window=(start, end),
1446+
resolution=resolution,
1447+
beliefs_before=scheduler.belief_time,
1448+
)[0]
1449+
1450+
if isinstance(power_capacity, ur.Quantity):
1451+
actual_capacity = power_capacity.to("MW").magnitude
1452+
else:
1453+
actual_capacity = power_capacity.values
1454+
assert np.allclose(actual_capacity, expected_capacity)
1455+
1456+
device_constraints = scheduler._prepare(skip_validation=True)[5]
1457+
1458+
assert np.allclose(device_constraints[0]["derivative min"], expected_derivative_min)
1459+
assert np.allclose(device_constraints[0]["derivative max"], expected_derivative_max)
1460+
1461+
13931462
@pytest.mark.parametrize(
13941463
["soc_values", "log_message", "expected_num_targets"],
13951464
[
@@ -1507,7 +1576,7 @@ def test_build_device_soc_values(caplog, soc_values, log_message, expected_num_t
15071576
True,
15081577
None,
15091578
None,
1510-
# from the power sensor attribute 'consumption_capacity'
1579+
# from the power sensor attribute 'production_capacity'
15111580
[-8] * 24 * 4,
15121581
# from the flex model field 'consumption-capacity' (a sensor),
15131582
# and when absent, defaulting to the max value from the power sensor attribute capacity_in_mw
@@ -1573,8 +1642,8 @@ def test_build_device_soc_values(caplog, soc_values, log_message, expected_num_t
15731642
None,
15741643
# from the flex model field 'production-capacity' (a quantity)
15751644
-0.01,
1576-
# from the asset attribute 'capacity_in_mw'
1577-
2,
1645+
# missing consumption-capacity defaults to zero when production-capacity is provided
1646+
0,
15781647
False,
15791648
False,
15801649
-1.1,

flexmeasures/data/schemas/scheduling/metadata.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,9 @@ def to_dict(self):
336336
example=True,
337337
)
338338
POWER_CAPACITY = MetaData(
339-
description="Device-level power constraint. How much power can be applied to this asset. [#minimum_overlap]_",
339+
description="""Symmetric device-level power constraint. How much power can be applied to this asset in either direction.
340+
If omitted, the scheduler infers this limit from the greatest of ``consumption-capacity`` and ``production-capacity`` when either is configured, before falling back to ``site-power-capacity``.
341+
When exactly one of ``consumption-capacity`` or ``production-capacity`` is configured to non-zero capacity, the missing opposite capacity defaults to zero. [#minimum_overlap]_""",
340342
example="50 kVA",
341343
)
342344
CONSUMPTION_CAPACITY = MetaData(

flexmeasures/ui/static/openapi-specs.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6094,7 +6094,7 @@
60946094
"example": "7 kWh"
60956095
},
60966096
"power-capacity": {
6097-
"description": "Device-level power constraint. How much power can be applied to this asset.",
6097+
"description": "Symmetric device-level power constraint. How much power can be applied to this asset in either direction.\nIf omitted, the scheduler infers this limit from the greatest of <code>consumption-capacity</code> and <code>production-capacity</code> when either is configured, before falling back to <code>site-power-capacity</code>.\nWhen exactly one of <code>consumption-capacity</code> or <code>production-capacity</code> is configured to non-zero capacity, the missing opposite capacity defaults to zero.",
60986098
"example": "50 kVA",
60996099
"$ref": "#/components/schemas/VariableQuantityOpenAPI"
61006100
},

0 commit comments

Comments
 (0)