diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 0375d293a4..a9a19df3aa 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -23,7 +23,7 @@ New features * Improve UX after deleting a child asset through the UI [see `PR #2119 `_] * Improve source filtering in the sensor data GET endpoint by exposing the documented query parameters in Swagger and allowing filtering by the account linked to data sources [see `PR #2083 `_ and `PR #2151 `_] * Support sensor references for efficiency fields in storage flex-models [see `PR #2142 `_] -* Introduce the ``consumption`` and ``production`` flex-model fields for the ``StorageScheduler`` to save schedules to [see `PR #2190 `_] +* Introduce the ``consumption`` and ``production`` flex-model fields for the ``StorageScheduler`` to save schedules to [see `PR #2190 `_ and `PR #2213 `_] * Added a unified job status endpoint ``GET /api/v3_0/jobs/`` to retrieve the current execution status and result message for any background job [see `PR #2141 `_] * Add ``flexmeasures jobs inspect-job`` CLI command to show job status and metadata information (similar to the job status endpoint in the API) [see `PR #2202 `_] * New ``GET /api/v3_0/sources`` endpoint to list accessible data sources and defined types, with ``only_latest=true`` by default to return only the most recent version per source [see `PR #2126 `_] @@ -212,7 +212,7 @@ New features * Improved the UX for creating sensors, clicking on ``Enter`` now validates and creates a sensor [see `PR #1876 `_] * Show zero values in bar charts even though they have 0 area [see `PR #1932 `_ and `PR #1936 `_] * Added ``root`` and ``depth`` fields to the `[GET] /assets` endpoint for listing assets, to allow selecting descendants of a given root asset up to a given depth [see `PR #1874 `_] -* Support creating schedules with only information known prior to some time, now also via the CLI (the API already supported it) [see `PR #1871 `_]. +* Support creating schedules with only information known prior to some time, now also via the CLI (the API already supported it) [see `PR #1871 `_] * Added capability to update an asset's parent from the UI [`PR #1957 `_] * Add ``fields`` param to the asset-listing endpoints, to save bandwidth in response data [see `PR #1884 `_] @@ -306,7 +306,7 @@ v0.30.0 | December 2, 2025 New features ------------- -* Improve ``flexmeasures add forecasts`` CLI command with clearer success and error messages when creating jobs or beliefs [see `PR #1822 `_]. +* Improve ``flexmeasures add forecasts`` CLI command with clearer success and error messages when creating jobs or beliefs [see `PR #1822 `_] * Major overhaul of ``flexmeasures add forecasts`` (use the ``--help`` option to learn more) [see `PR #1546 `_, `PR #1744 `_ and `PR #1834 `_] * The new ``commitments`` field in the ``flex-context`` can be used to specify previous commitments (e.g. market positions) that the scheduler needs to take into account [see `PR #1754 `_ and `PR #1793 `_] * Many more field descriptions in Swagger, including flex-model and flex-context fields [see `PR #1777 `_ and `PR #1841 `_] diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 4af89ae816..58284ca278 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -908,7 +908,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 device_constraints[d]["efficiency"] = storage_efficiency[d] # Convert efficiency from sensor resolution to scheduling resolution - if isinstance(storage_efficiency[d], Sensor): + if device_constraints[d]["efficiency"].dropna().eq(1).all(): + # Only missing or unit efficiency; no resampling needed. + pass + elif isinstance(storage_efficiency[d], Sensor): # Resample from the resolution of the storage-efficiency sensor device_constraints[d]["efficiency"] **= ( resolution / storage_efficiency[d].event_resolution @@ -932,6 +935,13 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 device_constraints[d]["efficiency"] **= ( resolution / production[d].event_resolution ) + else: + raise ValueError( + "The storage-efficiency cannot be interpreted without a resolution. " + "Record the storage-efficiency on a sensor instead (with a non-zero resolution) and then reference that sensor in the flex-model. " + "Alternatively, set the consumption or production field in the flex-model to reference a sensor, " + "and the scheduler will assume their resolution is the one to use.", + ) # check that storage constraints are fulfilled if not skip_validation: diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 183565c4b5..4015efe0e2 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -325,6 +325,31 @@ def validate_asset(self, asset: Asset, **kwargs): if self.sensor is not None and self.sensor.asset != asset: raise ValidationError("Sensor does not belong to asset.") + @validates_schema + def validate_storage_efficiency_resolution(self, data: dict, **kwargs): + unit = data.get("storage_efficiency") + consumption = data.get("consumption") + production = data.get("production") + consumption_is_sensor = isinstance(consumption, dict) and isinstance( + consumption.get("sensor"), Sensor + ) + production_is_sensor = isinstance(production, dict) and isinstance( + production.get("sensor"), Sensor + ) + if ( + isinstance(unit, ur.Quantity) + and not self.sensor + and not consumption_is_sensor + and not production_is_sensor + ): + raise ValidationError( + "The storage-efficiency cannot be interpreted without a resolution. " + "Record the storage-efficiency on a sensor instead (with a non-zero resolution) and then reference that sensor in the flex-model. " + "Alternatively, set the consumption or production field in the flex-model to reference a sensor, " + "and the scheduler will assume their resolution is the one to use.", + field_name="storage-efficiency", + ) + @validates_schema def check_redundant_efficiencies(self, data: dict, **kwargs): """ @@ -509,22 +534,6 @@ def __init__(self, *args, **kwargs): for field in self.declared_fields } - @validates("storage_efficiency") - def validate_storage_efficiency_resolution( - self, unit: Sensor | ur.Quantity, **kwargs - ): - if ( - isinstance(unit, ur.Quantity) - and not isinstance(self.consumption, Sensor) - and not isinstance(self.production, Sensor) - ): - raise ValidationError( - "The storage-efficiency cannot be interpreted without a resolution. " - "Record the storage-efficiency on a sensor instead (with a non-zero resolution) and then reference that sensor in the flex-model. " - "Alternatively, set the consumption or production field in the flex-model to reference a sensor, " - "and the scheduler will assume their resolution is the one to use." - ) - @validates_schema def forbid_time_series_specs(self, data: dict, **kwargs): """Do not allow time series specs for the flex-model fields saved in the db.""" diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index 4fb1bf67af..eb54348f29 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -749,7 +749,7 @@ def test_get_variable_quantity_unit( ) -# test DBStorageFlexModelSchema +# test StorageFlexModelSchema and DBStorageFlexModelSchema @pytest.mark.parametrize( ["flex_model", "fails"], [ @@ -798,12 +798,30 @@ def test_get_variable_quantity_unit( {"storage-efficiency": {"sensor": "power-sensor"}}, {"storage-efficiency": "Cannot convert MW to %"}, ), + # plain quantity storage-efficiency without sensor-backed consumption/production should fail + ( + {"storage-efficiency": "90%"}, + [ + { + "storage-efficiency": "The storage-efficiency cannot be interpreted without a resolution." + }, + False, + ], + ), + # plain quantity storage-efficiency is valid when consumption is sensor-backed + ( + { + "storage-efficiency": "90%", + "consumption": {"sensor": "power-sensor"}, + }, + False, + ), ], ) -def test_db_flex_model_schema( +def test_flex_model_schemas( db, app, setup_dummy_sensors, setup_efficiency_sensors, flex_model, fails ): - """Validate DBStorageFlexModelSchema for accepted and rejected flex-model inputs. + """Validate StorageFlexModelSchema and DBStorageFlexModelSchema for accepted and rejected flex-model inputs. Input under test: - ``flex_model`` payloads with fixed quantities, sensor references, and list fields @@ -816,8 +834,16 @@ def test_db_flex_model_schema( - When ``fails`` is ``False``, schema loading succeeds. - When ``fails`` is a field-to-message mapping, schema loading raises ``ValidationError`` and contains the expected field-specific error message(s). + - When ``fails`` is a list, its first entry represents the expectation for the StorageFlexModelSchema, + and the second entry represents the expectation for the DBStorageFlexModelSchema. """ - schema = DBStorageFlexModelSchema() + schemas = [ + StorageFlexModelSchema(start=datetime(2026, 6, 1), sensor=None), + DBStorageFlexModelSchema(), + ] + if not isinstance(fails, list): + # Then the same expectation holds for both schemas + fails = [fails, fails] sensors = { "energy-sensor": setup_dummy_sensors[0], @@ -839,21 +865,24 @@ def test_db_flex_model_schema( for item in field_value ] - if fails: - with pytest.raises(ValidationError) as e_info: + for schema, fail in zip(schemas, fails): + if fail: + with pytest.raises(ValidationError) as e_info: + schema.load(flex_model) + for field_name, expected_message in fail.items(): + assert field_name in e_info.value.messages + if field_name in ["soc-gain", "soc-usage"]: + for index, message_list in e_info.value.messages[ + field_name + ].items(): + assert message_list[0] == expected_message[index][0] + else: + # 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_model) - for field_name, expected_message in fails.items(): - assert field_name in e_info.value.messages - if field_name in ["soc-gain", "soc-usage"]: - for index, message_list in e_info.value.messages[field_name].items(): - assert message_list[0] == expected_message[index][0] - else: - # 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_model)