Skip to content
Merged
6 changes: 3 additions & 3 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ New features
* Improve UX after deleting a child asset through the UI [see `PR #2119 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2083>`_ and `PR #2151 <https://www.github.com/FlexMeasures/flexmeasures/pull/2151>`_]
* Support sensor references for efficiency fields in storage flex-models [see `PR #2142 <https://www.github.com/FlexMeasures/flexmeasures/pull/2142>`_]
* Introduce the ``consumption`` and ``production`` flex-model fields for the ``StorageScheduler`` to save schedules to [see `PR #2190 <https://www.github.com/FlexMeasures/flexmeasures/pull/2190>`_]
* Introduce the ``consumption`` and ``production`` flex-model fields for the ``StorageScheduler`` to save schedules to [see `PR #2190 <https://www.github.com/FlexMeasures/flexmeasures/pull/2190>`_ and `PR #2213 <https://www.github.com/FlexMeasures/flexmeasures/pull/2213>`_]
* Added a unified job status endpoint ``GET /api/v3_0/jobs/<uuid>`` to retrieve the current execution status and result message for any background job [see `PR #2141 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2126>`_]
Expand Down Expand Up @@ -212,7 +212,7 @@ New features
* Improved the UX for creating sensors, clicking on ``Enter`` now validates and creates a sensor [see `PR #1876 <https://www.github.com/FlexMeasures/flexmeasures/pull/1876>`_]
* Show zero values in bar charts even though they have 0 area [see `PR #1932 <https://www.github.com/FlexMeasures/flexmeasures/pull/1932>`_ and `PR #1936 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/1871>`_]
* Added capability to update an asset's parent from the UI [`PR #1957 <https://www.github.com/FlexMeasures/flexmeasures/pull/1957>`_]
* Add ``fields`` param to the asset-listing endpoints, to save bandwidth in response data [see `PR #1884 <https://www.github.com/FlexMeasures/flexmeasures/pull/1884>`_]

Expand Down Expand Up @@ -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 <https://github.com/FlexMeasures/flexmeasures/pull/1822>`_].
* Improve ``flexmeasures add forecasts`` CLI command with clearer success and error messages when creating jobs or beliefs [see `PR #1822 <https://github.com/FlexMeasures/flexmeasures/pull/1822>`_]
* Major overhaul of ``flexmeasures add forecasts`` (use the ``--help`` option to learn more) [see `PR #1546 <https://github.com/FlexMeasures/flexmeasures/pull/1546>`_, `PR #1744 <https://github.com/FlexMeasures/flexmeasures/pull/1744>`_ and `PR #1834 <https://github.com/FlexMeasures/flexmeasures/pull/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 <https://github.com/FlexMeasures/flexmeasures/pull/1754>`_ and `PR #1793 <https://github.com/FlexMeasures/flexmeasures/pull/1793>`_]
* Many more field descriptions in Swagger, including flex-model and flex-context fields [see `PR #1777 <https://www.github.com/FlexMeasures/flexmeasures/pull/1777>`_ and `PR #1841 <https://www.github.com/FlexMeasures/flexmeasures/pull/1841>`_]
Expand Down
12 changes: 11 additions & 1 deletion flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
41 changes: 25 additions & 16 deletions flexmeasures/data/schemas/scheduling/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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."""
Expand Down
71 changes: 50 additions & 21 deletions flexmeasures/data/schemas/tests/test_scheduling.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ def test_get_variable_quantity_unit(
)


# test DBStorageFlexModelSchema
# test StorageFlexModelSchema and DBStorageFlexModelSchema
@pytest.mark.parametrize(
["flex_model", "fails"],
[
Expand Down Expand Up @@ -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
Expand All @@ -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],
Expand All @@ -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)
Loading