From eae4f63029d2f712256d55b03615d7dca7a5a1e4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Jul 2026 00:14:20 +0200 Subject: [PATCH 1/3] refactor: select price fields for currency validation by type Introduce a PriceField subclass of VariableQuantityField marking monetary fields. The flex-context's shared-currency check now selects fields by isinstance instead of the *price name-suffix convention, and extends to the nested commitment prices (up-price/down-price), which were previously not held to the flex-context's shared currency. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LQFkeGxDwErydUV5ZmHmki --- documentation/changelog.rst | 2 + .../data/schemas/scheduling/__init__.py | 74 +++++++++++++++---- flexmeasures/data/schemas/sensors.py | 12 +++ .../data/schemas/tests/test_scheduling.py | 47 ++++++++++++ flexmeasures/ui/static/openapi-specs.json | 2 +- 5 files changed, 123 insertions(+), 14 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 9578577684..68c89e07da 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -28,6 +28,8 @@ New features Infrastructure / Support ---------------------- + +* Price fields in the flex-context (including nested commitment prices, which are now also held to the flex-context's shared currency) are selected for currency validation by field type (``PriceField``) instead of by name suffix [see `PR #XXXX `_] * Upgraded dependencies [see `PR #1485 `_, `PR #2215 `_ and `PR #2243 `_] * Prepare the ``device_scheduler`` to deal with commitments per device group [see `PR #1934 `_] * Support storing encrypted connection secrets on organisations and assets, including utility functions, encryption key configuration, CLI commands to set and delete secrets, and UI tables that show stored secret names and optional expiration times without exposing their values [see `PR #2236 `_] diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 93c86ac555..14284cade9 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -24,6 +24,7 @@ SensorIdField, SensorReference, OutputSensorReferenceSchema, + PriceField, ) from flexmeasures.data.schemas.scheduling import metadata from flexmeasures.data.schemas.units import UnitField @@ -72,8 +73,8 @@ def forbid_time_series_specs(self, data: dict, **kwargs): class CommitmentSchema(Schema): name = fields.Str(required=True, data_key="name") baseline = VariableQuantityField("MW", required=False, data_key="baseline") - up_price = VariableQuantityField("/MW", required=False, data_key="up-price") - down_price = VariableQuantityField( + up_price = PriceField("/MW", required=False, data_key="up-price") + down_price = PriceField( "/MW", required=False, data_key="down-price", @@ -147,7 +148,7 @@ class DBCommitmentSchema(CommitmentSchema, NoTimeSeriesSpecs): class SharedSchema(Schema): """Shared schema for fields common across commodities in flex-context and commodity-context.""" - consumption_price = VariableQuantityField( + consumption_price = PriceField( "/MWh", required=False, data_key="consumption-price", @@ -155,7 +156,7 @@ class SharedSchema(Schema): metadata=metadata.CONSUMPTION_PRICE.to_dict(), ) - production_price = VariableQuantityField( + production_price = PriceField( "/MWh", required=False, data_key="production-price", @@ -187,7 +188,7 @@ class SharedSchema(Schema): metadata=metadata.SITE_PRODUCTION_CAPACITY.to_dict(), ) - ems_consumption_breach_price = VariableQuantityField( + ems_consumption_breach_price = PriceField( "/MW", data_key="site-consumption-breach-price", required=False, @@ -195,7 +196,7 @@ class SharedSchema(Schema): metadata=metadata.SITE_CONSUMPTION_BREACH_PRICE.to_dict(), ) - ems_production_breach_price = VariableQuantityField( + ems_production_breach_price = PriceField( "/MW", data_key="site-production-breach-price", required=False, @@ -212,7 +213,7 @@ class SharedSchema(Schema): metadata=metadata.SITE_PEAK_CONSUMPTION.to_dict(), ) - ems_peak_consumption_price = VariableQuantityField( + ems_peak_consumption_price = PriceField( "/MW", data_key="site-peak-consumption-price", required=False, @@ -229,7 +230,7 @@ class SharedSchema(Schema): metadata=metadata.SITE_PEAK_PRODUCTION.to_dict(), ) - ems_peak_production_price = VariableQuantityField( + ems_peak_production_price = PriceField( "/MW", data_key="site-peak-production-price", required=False, @@ -238,28 +239,28 @@ class SharedSchema(Schema): ) # Breach prices for device capacity constraints - consumption_breach_price = VariableQuantityField( + consumption_breach_price = PriceField( "/MW", data_key="consumption-breach-price", required=False, value_validator=validate.Range(min=0), metadata=metadata.CONSUMPTION_BREACH_PRICE.to_dict(), ) - production_breach_price = VariableQuantityField( + production_breach_price = PriceField( "/MW", data_key="production-breach-price", required=False, value_validator=validate.Range(min=0), metadata=metadata.PRODUCTION_BREACH_PRICE.to_dict(), ) - soc_minima_breach_price = VariableQuantityField( + soc_minima_breach_price = PriceField( "/MWh", data_key="soc-minima-breach-price", required=False, value_validator=validate.Range(min=0), metadata=metadata.SOC_MINIMA_BREACH_PRICE.to_dict(), ) - soc_maxima_breach_price = VariableQuantityField( + soc_maxima_breach_price = PriceField( "/MWh", data_key="soc-maxima-breach-price", required=False, @@ -340,7 +341,7 @@ def _try_to_convert_price_units(self, data: dict, original_data: dict, **kwargs) shared_currency_unit = None previous_field_name = None for field in self.declared_fields: - if field[-5:] == "price" and field in data: + if isinstance(self.declared_fields[field], PriceField) and field in data: price_field = self.declared_fields[field] price_unit = price_field._get_unit(data[field]) currency_unit = str( @@ -363,6 +364,13 @@ def _try_to_convert_price_units(self, data: dict, original_data: dict, **kwargs) if shared_currency_unit not in price_unit: error_message += f" Also note that all prices in the flex-context must share the same currency unit (in this case: '{shared_currency_unit}')." raise ValidationError(error_message, field_name=field_name) + # Also hold the nested commitment prices to the shared currency + shared_currency_unit, previous_field_name = ( + self._validate_commitment_price_units( + data, shared_currency_unit, previous_field_name + ) + ) + if shared_currency_unit is not None: data["shared_currency_unit"] = shared_currency_unit elif sensor := data.get("consumption_price_sensor"): @@ -373,6 +381,46 @@ def _try_to_convert_price_units(self, data: dict, original_data: dict, **kwargs) data["shared_currency_unit"] = "EUR" return data + def _validate_commitment_price_units( + self, + data: dict, + shared_currency_unit: str | None, + previous_field_name: str | None, + ) -> tuple[str | None, str | None]: + """Hold the nested commitment prices to the shared currency.""" + for commitment in data.get("commitments", []): + for field, price_field in CommitmentSchema._declared_fields.items(): + if not isinstance(price_field, PriceField) or field not in commitment: + continue + price_unit = price_field._get_unit(commitment[field]) + currency_unit = self._extract_currency_unit(price_unit) + if shared_currency_unit is None: + shared_currency_unit = str( + ur.Quantity(currency_unit).to_base_units().units + ) + previous_field_name = price_field.data_key + if not units_are_convertible(currency_unit, shared_currency_unit): + field_name = price_field.data_key + error_message = f"Invalid unit. A valid unit would be, for example, '{shared_currency_unit}/MWh' (this example uses '{shared_currency_unit}', because '{previous_field_name}' used that currency). However, you passed an incompatible price ('{price_unit}') for the '{field_name}' field of commitment '{commitment.get('name')}'. Also note that all prices in the flex-context must share the same currency unit (in this case: '{shared_currency_unit}')." + raise ValidationError(error_message, field_name="commitments") + return shared_currency_unit, previous_field_name + + @staticmethod + def _extract_currency_unit(price_unit: str) -> str: + """Obtain the currency part of a price unit, whose denominator may vary. + + >>> FlexContextSchema()._extract_currency_unit("EUR/MWh") + 'EUR' + >>> FlexContextSchema()._extract_currency_unit("USD/MW") + 'USD' + """ + q = ur.Quantity(f"1 {price_unit}") + for denominator in ("MWh", "MW"): + candidate = str((q * ur.Quantity(f"1 {denominator}")).to_base_units().units) + if is_currency_unit(candidate): + return candidate + return str(q.units) + @staticmethod def _to_currency_per_mwh(price_unit: str) -> str: """Convert a price unit to a base currency used to express that price per MWh. diff --git a/flexmeasures/data/schemas/sensors.py b/flexmeasures/data/schemas/sensors.py index 72694534a6..58975ded70 100644 --- a/flexmeasures/data/schemas/sensors.py +++ b/flexmeasures/data/schemas/sensors.py @@ -678,6 +678,18 @@ def _get_unit( return unit +class PriceField(VariableQuantityField): + """VariableQuantityField for monetary values. + + Price fields participate in currency validation: all price fields in the + flex-context must share one currency (recorded as the flex-context's + ``shared_currency_unit``), and price fields in a flex-model must use a + currency that is convertible to the flex-context's shared currency. + """ + + pass + + class RepurposeValidatorToIgnoreSensorsAndLists(validate.Validator): """Validator that executes another validator (the one you initialize it with) only on non-Sensor and non-list values.""" diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index 90842d2cf9..1441cb1a28 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -341,6 +341,53 @@ def load_schema(): }, {"commitments.0.baseline": "Cannot convert value `10 kWh` to 'MW'"}, ), + # Commitment prices must share the flex-context's currency + ( + { + "consumption-price": "100 EUR/MWh", + "commitments": [ + { + "name": "a sample commitment", + "baseline": "10 kW", + "up-price": "100 USD/MWh", + } + ], + }, + { + "commitments": "all prices in the flex-context must share the same currency unit" + }, + ), + # Commitment prices sharing the flex-context's currency are fine + ( + { + "consumption-price": "100 EUR/MWh", + "commitments": [ + { + "name": "a sample commitment", + "baseline": "10 kW", + "up-price": "100 EUR/MWh", + "down-price": "0.12 EUR/kWh", + } + ], + }, + False, + ), + # Commitments can also set the shared currency (mixed currencies still fail) + ( + { + "commitments": [ + { + "name": "a sample commitment", + "baseline": "10 kW", + "up-price": "100 USD/MWh", + "down-price": "120 EUR/MWh", + } + ] + }, + { + "commitments": "all prices in the flex-context must share the same currency unit" + }, + ), # Energy price units with a power baseline ( { diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index c796dcdd5c..0eab864eef 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "0.33.2" + "version": "1.0.0" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", From a6a3704517fb97d46d3c460c6fbb38d287abc4a4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Jul 2026 00:15:12 +0200 Subject: [PATCH 2/3] docs: fill in PR number in changelog Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LQFkeGxDwErydUV5ZmHmki --- documentation/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 68c89e07da..7ccb06822f 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -29,7 +29,7 @@ New features Infrastructure / Support ---------------------- -* Price fields in the flex-context (including nested commitment prices, which are now also held to the flex-context's shared currency) are selected for currency validation by field type (``PriceField``) instead of by name suffix [see `PR #XXXX `_] +* Price fields in the flex-context (including nested commitment prices, which are now also held to the flex-context's shared currency) are selected for currency validation by field type (``PriceField``) instead of by name suffix [see `PR #2311 `_] * Upgraded dependencies [see `PR #1485 `_, `PR #2215 `_ and `PR #2243 `_] * Prepare the ``device_scheduler`` to deal with commitments per device group [see `PR #1934 `_] * Support storing encrypted connection secrets on organisations and assets, including utility functions, encryption key configuration, CLI commands to set and delete secrets, and UI tables that show stored secret names and optional expiration times without exposing their values [see `PR #2236 `_] From e6c6e0e32cb70131e8694a65726b5d7d36cbcc4b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sun, 26 Jul 2026 21:50:36 +0200 Subject: [PATCH 3/3] refactor: derive currency-denominated fields from PriceField type Replaces the hardcoded _CURRENCY_DENOMINATED_FIELDS tuple with a classmethod that selects the schema's PriceField-typed fields, applying this PR's select-by-type principle to currency rebasing as well. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0111ySFfFSbFBoGXgzUfDTmP Signed-off-by: F.N. Claessen --- .../data/schemas/scheduling/__init__.py | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 0cb3e2dd6c..af6bf39b4b 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -462,21 +462,22 @@ def _extract_currency_unit(price_unit: str) -> str: return candidate return str(q.units) - # Currency-denominated fields that CommodityFlexContextSchema's smart defaults - # (fill_grid_connection_defaults) may fill with a fallback "EUR" price/breach - # price when a context has no user-given price fields at all. - _CURRENCY_DENOMINATED_FIELDS = ( - "consumption_price", - "production_price", - "ems_consumption_breach_price", - "ems_production_breach_price", - "consumption_breach_price", - "production_breach_price", - "soc_minima_breach_price", - "soc_maxima_breach_price", - "ems_peak_consumption_price", - "ems_peak_production_price", - ) + @classmethod + def _currency_denominated_fields(cls) -> tuple[str, ...]: + """Names of the schema's currency-denominated fields, selected by type (PriceField). + + These are the fields that CommodityFlexContextSchema's smart defaults + (fill_grid_connection_defaults) may fill with a fallback "EUR" price/breach + price when a context has no user-given price fields at all. + + >>> FlexContextSchema._currency_denominated_fields()[:2] + ('consumption_price', 'production_price') + """ + return tuple( + name + for name, field in cls._declared_fields.items() + if isinstance(field, PriceField) + ) @classmethod def _rebase_default_context_currency(cls, context: dict, new_currency: str): @@ -490,7 +491,7 @@ def _rebase_default_context_currency(cls, context: dict, new_currency: str): magnitudes carry over unchanged under the new currency label (no FX conversion is implied or attempted). """ - for field in cls._CURRENCY_DENOMINATED_FIELDS: + for field in cls._currency_denominated_fields(): value = context.get(field) if not isinstance(value, ur.Quantity): continue