diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 2928a14ce..2e3a7105a 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -11,7 +11,7 @@ v0.25.0 | February XX, 2025 New features ------------- -* Added form modal to edit an asset's ``flex_context`` [see `PR #1320 `_ and `PR #1320 `] +* Added form modal to edit an asset's ``flex_context`` [see `PR #1320 `_, `PR #1365 `_ and `PR #1364 `_] * Better y-axis titles for charts that show multiple sensors with a shared unit [see `PR #1346 `_] * Add CLI command ``flexmeasures jobs save-last-failed`` for saving the last failed jobs [see `PR #1342 `_ and `PR #1359 `_] diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 9cce3acfd..8909759e2 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -import pint from marshmallow import ( Schema, fields, @@ -19,7 +18,15 @@ ) from flexmeasures.data.schemas.utils import FMValidationError from flexmeasures.data.schemas.times import AwareDateTimeField, PlanningDurationField -from flexmeasures.utils.unit_utils import ur, units_are_convertible +from flexmeasures.utils.flexmeasures_inflection import p +from flexmeasures.utils.unit_utils import ( + ur, + units_are_convertible, + is_capacity_price_unit, + is_energy_price_unit, + is_power_unit, + is_energy_unit, +) class FlexContextSchema(Schema): @@ -153,6 +160,8 @@ def check_prices(self, data: dict, **kwargs): f"""Please switch to using `production-price: {{"sensor": {data[field_map["production-price-sensor"]].id}}}`.""" ) + # make sure that the prices fields are valid price units + # All prices must share the same unit data = self._try_to_convert_price_units(data) @@ -224,40 +233,122 @@ def _get_variable_quantity_unit( class DBFlexContextSchema(FlexContextSchema): + mapped_schema_keys = { + field: FlexContextSchema().declared_fields[field].data_key + for field in FlexContextSchema().declared_fields + } @validates_schema def forbid_time_series_specs(self, data: dict, **kwargs): """Do not allow time series specs for the flex-context fields saved in the db.""" - keys_to_check = [] # List of keys to check for time series specs + keys_to_check = [] # All the keys in this list are all fields of type VariableQuantity for field_var, field in self.declared_fields.items(): if isinstance(field, VariableQuantityField): - keys_to_check.append(field_var) + keys_to_check.append((field_var, field)) # Check each key and raise a ValidationError if it's a list - for key in keys_to_check: - if key in data and isinstance(data[key], list): + for field_var, field in keys_to_check: + if field_var in data and isinstance(data[field_var], list): raise ValidationError( - f"Time series specs are not allowed in flex-context fields in the DB for '{key}'." + "A time series specification (listing segments) is not supported when storing flex-context fields. Use a fixed quantity or a sensor reference instead.", + field_name=field.data_key, ) @validates_schema - def forbid_fixed_prices(self, data: dict, **kwargs): - """Do not allow fixed consumption price or fixed production price in the flex-context fields saved in the db.""" + def validate_fields_unit(self, data: dict, **kwargs): + """Check that each field value has a valid unit.""" + + self._validate_price_fields(data) + self._validate_power_fields(data) + self._validate_inflexible_device_sensors(data) + + def _validate_price_fields(self, data: dict): + """Validate price fields.""" + energy_price_fields = [ + "consumption_price", + "production_price", + ] + capacity_price_fields = [ + "ems_consumption_breach_price", + "ems_production_breach_price", + "ems_peak_consumption_price", + "ems_peak_production_price", + ] + + # Check that consumption and production prices are Sensors + self._forbid_fixed_prices(data) + + for field in energy_price_fields: + if field in data: + self._validate_field(data, "energy price", field, is_energy_price_unit) + for field in capacity_price_fields: + if field in data: + self._validate_field( + data, "capacity price", field, is_capacity_price_unit + ) + + def _validate_power_fields(self, data: dict): + """Validate power fields.""" + power_fields = [ + "ems_power_capacity_in_mw", + "ems_production_capacity_in_mw", + "ems_consumption_capacity_in_mw", + "ems_peak_consumption_in_mw", + "ems_peak_production_in_mw", + ] + + for field in power_fields: + if field in data: + self._validate_field(data, "power", field, is_power_unit) + + def _validate_field(self, data: dict, field_type: str, field: str, unit_validator): + """Validate fields based on type and unit validator.""" + + if isinstance(data[field], ur.Quantity): + if not unit_validator(str(data[field].units)): + raise ValidationError( + f"{field_type.capitalize()} field '{self.mapped_schema_keys[field]}' must have {p.a(field_type)} unit.", + field_name=self.mapped_schema_keys[field], + ) + elif isinstance(data[field], Sensor): + if not unit_validator(data[field].unit): + raise ValidationError( + f"{field_type.capitalize()} field '{self.mapped_schema_keys[field]}' must have {p.a(field_type)} unit.", + field_name=self.mapped_schema_keys[field], + ) + + def _validate_inflexible_device_sensors(self, data: dict): + """Validate inflexible device sensors.""" + if "inflexible_device_sensors" in data: + for sensor in data["inflexible_device_sensors"]: + if not is_power_unit(sensor.unit) and not is_energy_unit(sensor.unit): + raise ValidationError( + f"Inflexible device sensor '{sensor.id}' must have a power or energy unit.", + field_name="inflexible-device-sensors", + ) + + def _forbid_fixed_prices(self, data: dict, **kwargs): + """Do not allow fixed consumption price or fixed production price in the flex-context fields saved in the db. + + This is a temporary restriction as future iterations will allow fixed prices on these fields as well. + """ if "consumption_price" in data and isinstance( - data["consumption_price"], pint.Quantity + data["consumption_price"], ur.Quantity ): raise ValidationError( - "Fixed prices are not currently supported for consumption_price in flex-context fields in the DB." + "Fixed prices are not currently supported for consumption-price in flex-context fields in the DB.", + field_name="consumption-price", ) if "production_price" in data and isinstance( - data["production_price"], pint.Quantity + data["production_price"], ur.Quantity ): raise ValidationError( - "Fixed prices are not currently supported for production_price in flex-context fields in the DB." + "Fixed prices are not currently supported for production-price in flex-context fields in the DB.", + field_name="production-price", ) diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index 09bf1b1a4..f0d12d54c 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -5,7 +5,7 @@ from marshmallow.validate import ValidationError import pandas as pd -from flexmeasures.data.schemas.scheduling import FlexContextSchema +from flexmeasures.data.schemas.scheduling import FlexContextSchema, DBFlexContextSchema from flexmeasures.data.schemas.scheduling.process import ( ProcessSchedulerFlexModelSchema, ProcessType, @@ -284,3 +284,170 @@ def test_flex_context_schema(db, app, setup_site_capacity_sensor, flex_context, ) else: schema.load(flex_context) + + +# test DBFlexContextSchema +@pytest.mark.parametrize( + ["flex_context", "fails"], + [ + ( + {"consumption-price": "13000 kW"}, + { + "consumption-price": "Fixed prices are not currently supported for consumption-price in flex-context fields in the DB.", + }, + ), + ( + { + "production-price": { + "sensor": "placeholder for site-power-capacity sensor" + } + }, + { + "production-price": "Energy price field 'production-price' must have an energy price unit." + }, + ), + ( + {"production-price": {"sensor": "placeholder for price sensor"}}, + False, + ), + ( + {"consumption-price": "100 EUR/MWh"}, + { + "consumption-price": "Fixed prices are not currently supported for consumption-price in flex-context fields in the DB.", + }, + ), + ( + {"production-price": "100 EUR/MW"}, + { + "production-price": "Fixed prices are not currently supported for production-price in flex-context fields in the DB." + }, + ), + ( + {"site-power-capacity": 100}, + { + "site-power-capacity": f"Unsupported value type. `{type(100)}` was provided but only dict, list and str are supported." + }, + ), + ( + { + "site-power-capacity": [ + { + "value": "100 kW", + "start": "2025-03-18T00:00+01:00", + "duration": "P2D", + } + ] + }, + { + "site-power-capacity": "A time series specification (listing segments) is not supported when storing flex-context fields. Use a fixed quantity or a sensor reference instead." + }, + ), + ( + {"site-power-capacity": "5 kWh"}, + {"site-power-capacity": "Cannot convert value `5 kWh` to 'MW'"}, + ), + ( + {"site-consumption-capacity": "6 kWh"}, + {"site-consumption-capacity": "Cannot convert value `6 kWh` to 'MW'"}, + ), + ( + {"site-consumption-capacity": "6000 kW"}, + False, + ), + ( + {"site-production-capacity": "6 kWh"}, + {"site-production-capacity": "Cannot convert value `6 kWh` to 'MW'"}, + ), + ( + {"site-production-capacity": "7000 kW"}, + False, + ), + ( + {"site-consumption-breach-price": "6 kWh"}, + { + "site-consumption-breach-price": "Capacity price field 'site-consumption-breach-price' must have a capacity price unit." + }, + ), + ( + {"site-consumption-breach-price": "450 EUR/MW"}, + False, + ), + ( + {"site-production-breach-price": "550 EUR/MWh"}, + { + "site-production-breach-price": "Capacity price field 'site-production-breach-price' must have a capacity price unit." + }, + ), + ( + {"site-production-breach-price": "3500 EUR/MW"}, + False, + ), + ( + {"site-peak-consumption": "60 EUR/MWh"}, + {"site-peak-consumption": "Cannot convert value `60 EUR/MWh` to 'MW'"}, + ), + ( + {"site-peak-consumption": "3500 kW"}, + False, + ), + ( + {"site-peak-consumption-price": "6 orange/Mw"}, + { + "site-peak-consumption-price": "Cannot convert value '6 orange/Mw' to a valid quantity. 'orange' is not defined in the unit registry" + }, + ), + ( + {"site-peak-consumption-price": "100 EUR/MW"}, + False, + ), + ( + {"site-peak-production": "75kWh"}, + {"site-peak-production": "Cannot convert value `75kWh` to 'MW'"}, + ), + ( + {"site-peak-production": "17000 kW"}, + False, + ), + ( + {"site-peak-production-price": "4500 EUR/MWh"}, + { + "site-peak-production-price": "Capacity price field 'site-peak-production-price' must have a capacity price unit." + }, + ), + ( + {"site-peak-consumption-price": "700 EUR/MW"}, + False, + ), + ], +) +def test_db_flex_context_schema( + db, app, setup_dummy_sensors, setup_site_capacity_sensor, flex_context, fails +): + schema = DBFlexContextSchema() + + price_sensor = setup_dummy_sensors[1] + capacity_sensor = setup_site_capacity_sensor["site-power-capacity"] + + # Replace sensor name with sensor ID + for field_name, field_value in flex_context.items(): + if isinstance(field_value, dict): + if field_value["sensor"] == "placeholder for site-power-capacity sensor": + flex_context[field_name]["sensor"] = capacity_sensor.id + elif field_value["sensor"] == "placeholder for price sensor": + flex_context[field_name]["sensor"] = price_sensor.id + + if fails: + with pytest.raises(ValidationError) as e_info: + schema.load(flex_context) + print(e_info.value.messages) + for field_name, expected_message in fails.items(): + assert field_name in e_info.value.messages + # Check all messages for the given field for the expected message + assert any( + [ + expected_message in message + for message in e_info.value.messages[field_name] + ] + ) + else: + schema.load(flex_context) diff --git a/flexmeasures/ui/templates/crud/asset.html b/flexmeasures/ui/templates/crud/asset.html index 4a3c0ef2d..98ba484e5 100644 --- a/flexmeasures/ui/templates/crud/asset.html +++ b/flexmeasures/ui/templates/crud/asset.html @@ -1421,51 +1421,51 @@
${sensor.name}
const alertStyle = "alert alert-light"; if (selectedOption === "consumption-price") { const description = "Set the sensor that represents the consumption price of the site. This value will be used in the optimization"; - const allowedUnits = ["EUR", "EUR/MWh"]; + const allowedUnits = ["EUR/MWh", "JPY/kWh", "USD/MWh, and other currencies."]; flexInfoContainer.innerHTML = renderSelectInfoCards("consumption-price", description, allowedUnits); } else if (selectedOption === "production-price") { const description = "Set the sensor that represents the production price of the site. This value will be used in the optimization"; - const allowedUnits = ["EUR", "EUR/MWh"]; + const allowedUnits = ["EUR/MWh", "JPY/kWh", "USD/MWh, and other currencies."]; flexInfoContainer.innerHTML = renderSelectInfoCards("production-price", description, allowedUnits); } else if (selectedOption == "site-power-capacity") { const description = "This value represents the maximum power that the site can consume or produce. This value will be used in the optimization"; - const allowedUnits = ["kW"]; + const allowedUnits = ["kW", "kVA", "MVA"]; flexInfoContainer.innerHTML = renderSelectInfoCards("site-power-capacity", description, allowedUnits); } else if (selectedOption == "site-production-capacity") { const description = "This value represents the maximum power that the site can produce. This value will be used in the optimization"; - const allowedUnits = ["kW", "kWh"]; + const allowedUnits = ["kW"]; flexInfoContainer.innerHTML = renderSelectInfoCards("site-production-capacity", description, allowedUnits); } else if (selectedOption == "site-consumption-capacity") { const description = "This value represents the maximum power that the site can consume. This value will be used in the optimization"; - const allowedUnits = ["kW", "kWh"]; + const allowedUnits = ["kW"]; flexInfoContainer.innerHTML = renderSelectInfoCards("site-consumption-capacity", description, allowedUnits); } else if (selectedOption == "site-consumption-breach-price") { const description = "This value represents the price that will be paid if the site consumes more power than the site consumption capacity. This value will be used in the optimization"; - const allowedUnits = ["EUR", "EUR/MWh"]; + const allowedUnits = ["EUR/MW", "JPY/kW", "USD/MW, and other currencies."]; flexInfoContainer.innerHTML = renderSelectInfoCards("site-consumption-breach-price", description, allowedUnits); } else if (selectedOption == "site-production-breach-price") { const description = "This value represents the price that will be paid if the site produces more power than the site production capacity. This value will be used in the optimization"; - const allowedUnits = ["EUR", "EUR/MWh"]; + const allowedUnits = ["EUR/MW", "JPY/kW", "USD/MW, and other currencies."]; flexInfoContainer.innerHTML = renderSelectInfoCards("site-production-breach-price", description, allowedUnits); } else if (selectedOption == "site-peak-consumption") { const description = "This value represents the peak consumption of the site. This value will be used in the optimization"; - const allowedUnits = ["kW", "kWh"]; + const allowedUnits = ["kW"]; flexInfoContainer.innerHTML = renderSelectInfoCards("site-peak-consumption", description, allowedUnits); } else if (selectedOption == "site-peak-production") { const description = "This value represents the peak production of the site. This value will be used in the optimization"; - const allowedUnits = ["kW", "kWh"]; + const allowedUnits = ["kW"]; flexInfoContainer.innerHTML = renderSelectInfoCards("site-peak-production", description, allowedUnits); } else if (selectedOption == "site-peak-consumption-price") { const description = "This value represents the price that will be paid if the site consumes more power than the site peak consumption. This value will be used in the optimization"; - const allowedUnits = ["EUR", "EUR/MWh"]; + const allowedUnits = ["EUR/MW", "JPY/kW", "USD/MW, and other currencies."]; flexInfoContainer.innerHTML = renderSelectInfoCards("site-peak-consumption-price", description, allowedUnits); } else if (selectedOption == "site-peak-production-price") { const description = "This value represents the price that will be paid if the site produces more power than the site peak production. This value will be used in the optimization"; - const allowedUnits = ["EUR", "EUR/MWh"]; + const allowedUnits = ["EUR/MW", "JPY/kW", "USD/MW, and other currencies."]; flexInfoContainer.innerHTML = renderSelectInfoCards("site-peak-production-price", description, allowedUnits); } else if (selectedOption == "inflexible-device-sensors") { const description = "This value represents the sensors that are inflexible and cannot be controlled. These sensors will be used in the optimization"; - const allowedUnits = ["sensors only"]; + const allowedUnits = ["kW"]; flexInfoContainer.innerHTML = renderSelectInfoCards("inflexible-device-sensors", description, allowedUnits); } } diff --git a/flexmeasures/utils/unit_utils.py b/flexmeasures/utils/unit_utils.py index e16399cff..519058064 100644 --- a/flexmeasures/utils/unit_utils.py +++ b/flexmeasures/utils/unit_utils.py @@ -251,6 +251,22 @@ def is_energy_price_unit(unit: str) -> bool: return False +def is_capacity_price_unit(unit: str) -> bool: + """For example: + >>> is_capacity_price_unit("EUR/MW") + True + >>> is_capacity_price_unit("KRW/MW") + True + >>> is_capacity_price_unit("KRW/MWh") + False + >>> is_capacity_price_unit("beans/MWh") + False + """ + if is_price_unit(unit) and is_power_unit(unit[4:]): + return True + return False + + def is_speed_unit(unit: str) -> bool: """For example: >>> is_speed_unit("m/s")