diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 9578577684..7ccb06822f 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 #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 `_] 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.",