Skip to content

Commit 90160f1

Browse files
committed
Backport PR #1710: Fix/better documentation for price incompatibilities (#1710)
* docs: fix price units in examples Signed-off-by: F.N. Claessen <felix@seita.nl> * feat: improve error message in case of incompatible price units Signed-off-by: F.N. Claessen <felix@seita.nl> * docs: changelog entry Signed-off-by: F.N. Claessen <felix@seita.nl> * fix: update test case Signed-off-by: F.N. Claessen <felix@seita.nl> * fix: type annotation Signed-off-by: F.N. Claessen <felix@seita.nl> * feat: test VariableQuantityField._get_unit Signed-off-by: F.N. Claessen <felix@seita.nl> * fix: xfail test cases for serialized variable quantities Signed-off-by: F.N. Claessen <felix@seita.nl> * feat: _get_original_unit Signed-off-by: F.N. Claessen <felix@seita.nl> * fix: error message Signed-off-by: F.N. Claessen <felix@seita.nl> * refactor: a single _get_unit method returning either the original unit from the serialized variable quantity or the unit from the deserialized variable quantity Signed-off-by: F.N. Claessen <felix@seita.nl> * Revert "refactor: a single _get_unit method returning either the original unit from the serialized variable quantity or the unit from the deserialized variable quantity" This reverts commit 66885ab. * docs: keep clarifying inline comment Signed-off-by: F.N. Claessen <felix@seita.nl> --------- Signed-off-by: F.N. Claessen <felix@seita.nl> (cherry picked from commit c614bdb)
1 parent 13bc7de commit 90160f1

File tree

5 files changed

+69
-14
lines changed

5 files changed

+69
-14
lines changed

documentation/changelog.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ v0.28.1 | September XX, 2025
1010
Bugfixes
1111
-----------
1212
* Fix schema validation in ``PATCH /assets/(id)`` [see `PR #1711 <https://www.github.com/FlexMeasures/flexmeasures/pull/1711>`_]
13-
13+
* Fixed example values for peak pricing and improved error message for incompatible price units set in the ``flex-context`` [see `PR #1710 <https://github.com/FlexMeasures/flexmeasures/pull/1710>`_]
1414

1515
v0.28.0 | September 10, 2025
1616
============================

documentation/features/scheduling.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,14 @@ And if the asset belongs to a larger system (a hierarchy of assets), the schedul
111111
- The price of breaching the ``site-production-capacity``, useful to treat ``site-production-capacity`` as a soft constraint but still make the scheduler attempt to respect it.
112112
Can be (a sensor recording) contractual penalties, but also a theoretical penalty just to allow the scheduler to breach the production capacity, while influencing how badly breaches should be avoided. [#penalty_field]_ [#breach_field]_
113113
* - ``site-peak-consumption-price``
114-
- ``"260 EUR/MWh"``
114+
- ``"260 EUR/MW"``
115115
- Consumption peaks above the ``site-peak-consumption`` are penalized against this per-kW price. [#penalty_field]_
116116
* - ``site-peak-production``
117117
- ``{"sensor": 8}``
118118
- Current peak production.
119119
Costs from peaks below it are considered sunk costs. Default to 0 kW.
120120
* - ``site-peak-production-price``
121-
- ``"260 EUR/MWh"``
121+
- ``"260 EUR/MW"``
122122
- Production peaks above the ``site-peak-production`` are penalized against this per-kW price. [#penalty_field]_
123123
* - ``soc-minima-breach-price``
124124
- ``"120 EUR/kWh"``

flexmeasures/data/schemas/scheduling/__init__.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,8 @@ def set_default_breach_prices(
169169
)
170170
return data
171171

172-
@validates_schema
173-
def check_prices(self, data: dict, **kwargs):
172+
@validates_schema(pass_original=True)
173+
def check_prices(self, data: dict, original_data: dict, **kwargs):
174174
"""Check assumptions about prices.
175175
176176
1. The flex-context must contain at most 1 consumption price and at most 1 production price field.
@@ -221,7 +221,7 @@ def check_prices(self, data: dict, **kwargs):
221221
# make sure that the prices fields are valid price units
222222

223223
# All prices must share the same unit
224-
data = self._try_to_convert_price_units(data)
224+
data = self._try_to_convert_price_units(data, original_data)
225225
shared_currency = ur.Quantity(data["shared_currency_unit"])
226226

227227
# Fill in default soc breach prices when asked to relax SoC constraints, unless already set explicitly.
@@ -265,7 +265,7 @@ def check_prices(self, data: dict, **kwargs):
265265

266266
return data
267267

268-
def _try_to_convert_price_units(self, data):
268+
def _try_to_convert_price_units(self, data: dict, original_data: dict):
269269
"""Convert price units to the same unit and scale if they can (incl. same currency)."""
270270

271271
shared_currency_unit = None
@@ -287,10 +287,13 @@ def _try_to_convert_price_units(self, data):
287287
previous_field_name = price_field.data_key
288288
if not units_are_convertible(currency_unit, shared_currency_unit):
289289
field_name = price_field.data_key
290-
raise ValidationError(
291-
f"Prices must share the same monetary unit. '{field_name}' uses '{currency_unit}', but '{previous_field_name}' used '{shared_currency_unit}'.",
292-
field_name=field_name,
290+
original_price_unit = price_field._get_original_unit(
291+
original_data[field_name], data[field]
293292
)
293+
error_message = f"Invalid unit. A valid unit would be, for example, '{shared_currency_unit + price_field.to_unit}' (this example uses '{shared_currency_unit}', because '{previous_field_name}' used that currency). However, you passed an incompatible price ('{original_price_unit}') for the '{field_name}' field."
294+
if shared_currency_unit not in price_unit:
295+
error_message += f" Also note that all prices in the flex-context must share the same currency unit (in this case: '{shared_currency_unit}')."
296+
raise ValidationError(error_message, field_name=field_name)
294297
if shared_currency_unit is not None:
295298
data["shared_currency_unit"] = shared_currency_unit
296299
elif sensor := data.get("consumption_price_sensor"):

flexmeasures/data/schemas/sensors.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,8 +411,27 @@ def convert(self, value, param, ctx, **kwargs):
411411

412412
return super().convert(_value, param, ctx, **kwargs)
413413

414-
def _get_unit(self, variable_quantity: ur.Quantity | list[dict | Sensor]) -> str:
415-
"""Obtain the unit from the variable quantity."""
414+
def _get_original_unit(
415+
self,
416+
serialized_variable_quantity: str | list[dict] | dict,
417+
deserialized_variable_quantity: ur.Quantity | list[dict] | Sensor,
418+
) -> str:
419+
"""Obtain the original unit from the still serialized variable quantity."""
420+
if isinstance(serialized_variable_quantity, str):
421+
unit = str(ur.Quantity(serialized_variable_quantity).units)
422+
elif isinstance(serialized_variable_quantity, list):
423+
unit = str(ur.Quantity(serialized_variable_quantity[0]["value"]).units)
424+
elif isinstance(serialized_variable_quantity, dict):
425+
# use deserialized quantity to avoid another Sensor query; the serialized quantity only has the sensor ID
426+
unit = deserialized_variable_quantity.unit
427+
else:
428+
raise NotImplementedError(
429+
f"Unexpected type '{type(serialized_variable_quantity)}' for serialized_variable_quantity describing '{self.data_key}': {serialized_variable_quantity}."
430+
)
431+
return unit
432+
433+
def _get_unit(self, variable_quantity: ur.Quantity | list[dict] | Sensor) -> str:
434+
"""Obtain the unit from the (deserialized) variable quantity."""
416435
if isinstance(variable_quantity, ur.Quantity):
417436
unit = str(variable_quantity.units)
418437
elif isinstance(variable_quantity, list):

flexmeasures/data/schemas/tests/test_scheduling.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from flexmeasures.data.schemas.scheduling.storage import (
1414
StorageFlexModelSchema,
1515
)
16-
from flexmeasures.data.schemas.sensors import TimedEventSchema
16+
from flexmeasures.data.schemas.sensors import TimedEventSchema, VariableQuantityField
1717

1818

1919
@pytest.mark.parametrize(
@@ -242,7 +242,9 @@ def load_schema():
242242
"consumption-price": "1 KRW/MWh",
243243
"site-peak-production-price": "1 EUR/MW",
244244
},
245-
{"site-peak-production-price": "Prices must share the same monetary unit."},
245+
{
246+
"site-peak-production-price": "all prices in the flex-context must share the same currency unit"
247+
},
246248
),
247249
(
248250
{
@@ -501,3 +503,34 @@ def test_db_flex_context_schema(
501503
)
502504
else:
503505
schema.load(flex_context)
506+
507+
508+
@pytest.mark.parametrize(
509+
["variable_quantity", "expected_unit"],
510+
[
511+
("1 kWh", "kWh"),
512+
(
513+
[{"start": "2025-09-17T00:00+02", "duration": "PT3H", "value": "1 kWh"}],
514+
"kWh",
515+
),
516+
({"sensor": "epex_da"}, "EUR/MWh"),
517+
],
518+
)
519+
@pytest.mark.parametrize("deserialized", [True, False])
520+
def test_get_variable_quantity_unit(
521+
setup_markets, variable_quantity, expected_unit: str, deserialized: bool
522+
):
523+
# Use sensor name to look up sensor ID from fixture
524+
if isinstance(variable_quantity, dict):
525+
variable_quantity = variable_quantity.copy()
526+
variable_quantity["sensor"] = setup_markets[variable_quantity["sensor"]].id
527+
528+
field = VariableQuantityField("/1") # we use to_unit="/1" here to allow any unit
529+
deserialized_variable_quantity = field.deserialize(variable_quantity)
530+
if deserialized:
531+
assert field._get_unit(deserialized_variable_quantity) == expected_unit
532+
else:
533+
assert (
534+
field._get_original_unit(variable_quantity, deserialized_variable_quantity)
535+
== expected_unit
536+
)

0 commit comments

Comments
 (0)