From b02e111de18853f71d5ddb51393dcd11276b9140 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 18 May 2026 17:05:00 +0200 Subject: [PATCH 01/11] feat: allow setting a power sensor in the flex_model of an asset when patching Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/storage.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 807774fe5a..eaf8b69425 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -18,7 +18,7 @@ from flexmeasures.data.schemas.generic_assets import GenericAssetIdField from flexmeasures.data.schemas.units import QuantityField from flexmeasures.data.schemas.scheduling import metadata -from flexmeasures.data.schemas.sensors import VariableQuantityField +from flexmeasures.data.schemas.sensors import SensorIdField, VariableQuantityField from flexmeasures.utils.unit_utils import ( ur, is_power_unit, @@ -374,6 +374,10 @@ class DBStorageFlexModelSchema(Schema): Schema for flex-models stored in the db. Supports fixed quantities and sensor references, while disallowing time series specs. """ + sensor = SensorIdField( + required=False, + ) + soc_min = VariableQuantityField( to_unit="MWh", data_key="soc-min", From 3ffdaa00fdc0bcc9054979180943c6159e6599eb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 20 May 2026 15:46:18 +0200 Subject: [PATCH 02/11] fix: fields without a data_key just use the variable name as data_key Signed-off-by: F.N. Claessen --- flexmeasures/ui/tests/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/ui/tests/test_utils.py b/flexmeasures/ui/tests/test_utils.py index 57f42bb166..09a68ff005 100644 --- a/flexmeasures/ui/tests/test_utils.py +++ b/flexmeasures/ui/tests/test_utils.py @@ -106,7 +106,7 @@ def test_ui_flexmodel_schema(): schema_keys = [] for value in DBStorageFlexModelSchema().fields.values(): - schema_keys.append(value.data_key) + schema_keys.append(value.data_key if value.data_key else value.name) schema_keys = set(schema_keys) ui_flexmodel_schema_fields = set(ui_flexmodel_schema_fields) From 4f5b116f9a610652bbeba3440f1e772863857f99 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 20 May 2026 15:57:20 +0200 Subject: [PATCH 03/11] feat: switch to consumption and production sensor reference fields in DBStorageFlexModelSchema Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/storage.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 2bb74626c7..fb266efed9 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -18,7 +18,10 @@ from flexmeasures.data.schemas.generic_assets import GenericAssetIdField from flexmeasures.data.schemas.units import QuantityField from flexmeasures.data.schemas.scheduling import metadata -from flexmeasures.data.schemas.sensors import SensorIdField, VariableQuantityField +from flexmeasures.data.schemas.sensors import ( + SensorReferenceSchema, + VariableQuantityField, +) from flexmeasures.utils.unit_utils import ( ur, is_power_unit, @@ -375,9 +378,8 @@ class DBStorageFlexModelSchema(Schema): Schema for flex-models stored in the db. Supports fixed quantities and sensor references, while disallowing time series specs. """ - sensor = SensorIdField( - required=False, - ) + consumption = fields.Nested(SensorReferenceSchema) + production = fields.Nested(SensorReferenceSchema) soc_min = VariableQuantityField( to_unit="MWh", From c3f2d68d5d5cc6c3b4667eefa0ff5e26bfb77a15 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 20 May 2026 15:57:39 +0200 Subject: [PATCH 04/11] feat: add UI support for new fields Signed-off-by: F.N. Claessen --- .../data/schemas/scheduling/__init__.py | 18 ++++++++++++++++++ .../data/schemas/scheduling/metadata.py | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 917a6ba990..09d5114783 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -582,6 +582,24 @@ def _to_currency_per_mwh(price_unit: str) -> str: } UI_FLEX_MODEL_SCHEMA: Dict[str, Dict[str, Any]] = { + "consumption": { + "default": None, + "description": rst_to_openapi(metadata.CONSUMPTION.description), + "types": { + "backend": "typeTwo", + "ui": "A sensor which records the scheduled consumption.", + }, + "example-units": EXAMPLE_UNIT_TYPES["power"], + }, + "production": { + "default": None, + "description": rst_to_openapi(metadata.PRODUCTION.description), + "types": { + "backend": "typeTwo", + "ui": "A sensor which records the scheduled production.", + }, + "example-units": EXAMPLE_UNIT_TYPES["power"], + }, "soc-min": { "default": None, "description": rst_to_openapi(metadata.SOC_MIN.description), diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 5852d6d286..4daebfbfba 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -185,6 +185,14 @@ def to_dict(self): # FLEX-MODEL +CONSUMPTION = MetaData( + description="Sensor used to record the scheduled consumption.", + example={"sensor": 14}, +) +PRODUCTION = MetaData( + description="Sensor used to record the scheduled production.", + example={"sensor": 15}, +) STATE_OF_CHARGE = MetaData( description="Sensor used to record the scheduled state of charge. If ``soc-at-start`` is omitted, FlexMeasures will also use this field to infer the starting state of charge. For this use case, the field may also contain a time series specification instead. When a sensor is used, its unit may be an energy unit (e.g. MWh or kWh) or a percentage (%). For sensors with a % unit, the ``soc-max`` flex-model field must be set to a non-zero value to allow converting between the energy-based schedule and a percentage.", example={"sensor": 12}, From 711e228228175b1f67e242648e3e27201b3847f2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 20 May 2026 15:58:52 +0200 Subject: [PATCH 05/11] docs: document new fields in scheduling.rst Signed-off-by: F.N. Claessen --- documentation/features/scheduling.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index 3801f2bda0..1643330a6d 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -182,7 +182,13 @@ For more details on the possible formats for field values, see :ref:`variable_qu * - Field - Example value - - Description + - Description + * - ``consumption`` + - |CONSUMPTION.example| + - .. include:: ../_autodoc/CONSUMPTION.rst + * - ``production`` + - |PRODUCTION.example| + - .. include:: ../_autodoc/PRODUCTION.rst * - ``state-of-charge`` - |STATE_OF_CHARGE.example| - .. include:: ../_autodoc/STATE_OF_CHARGE.rst From 1fa459e4766b96c6305aed9aea53034faf18b12e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 20 May 2026 16:10:33 +0200 Subject: [PATCH 06/11] feat: add new fields to StorageFlexModelSchema Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/storage.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index fb266efed9..aa186edba3 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -85,6 +85,15 @@ class StorageFlexModelSchema(Schema): metadata=dict(description="ID of the asset that is requested to be scheduled."), ) + consumption = fields.Nested( + SensorReferenceSchema, + metadata=metadata.CONSUMPTION.to_dict(), + ) + production = fields.Nested( + SensorReferenceSchema, + metadata=metadata.PRODUCTION.to_dict(), + ) + soc_at_start = QuantityField( required=False, to_unit="MWh", From ed640b2db6e4e2c39619fb529c7d7516fe40bc4d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 20 May 2026 16:17:16 +0200 Subject: [PATCH 07/11] chore: update openapi-specs.json Signed-off-by: F.N. Claessen --- flexmeasures/ui/static/openapi-specs.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 74153ec68b..f2bc2bedd0 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5992,6 +5992,20 @@ "StorageFlexModelSchemaOpenAPI": { "type": "object", "properties": { + "consumption": { + "description": "Sensor used to record the scheduled consumption.", + "example": { + "sensor": 14 + }, + "$ref": "#/components/schemas/SensorReference" + }, + "production": { + "description": "Sensor used to record the scheduled production.", + "example": { + "sensor": 15 + }, + "$ref": "#/components/schemas/SensorReference" + }, "soc-at-start": { "type": "string", "description": "The (estimated) state of charge at the beginning of the schedule (for storage devices, this defaults to 0).\nUsually added to each scheduling request.\n", From 4733868d8843ebdc3063c29ea607741b1535ae59 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 20 May 2026 16:44:02 +0200 Subject: [PATCH 08/11] =?UTF-8?q?storage:=20add=20consumption=20and=20prod?= =?UTF-8?q?uction=20output=20sensor=20support=20Context:=20-=20StorageSche?= =?UTF-8?q?duler=20could=20already=20write=20state-of-charge=20schedules?= =?UTF-8?q?=20to=20a=20secondary=20sensor=20-=20Users=20wanted=20the=20sam?= =?UTF-8?q?e=20for=20consumption=20and=20production=20power=20Change:=20-?= =?UTF-8?q?=20Add=20StorageScheduler.=5Fbuild=5Fconsumption=5Fproduction?= =?UTF-8?q?=5Fschedules()=20static=20method=20-=20Call=20it=20in=20compute?= =?UTF-8?q?(),=20with=20resampling=20and=20rounding=20matching=20the=20soc?= =?UTF-8?q?=5Fschedule=20pattern=20-=20If=20only=20consumption=20sensor=20?= =?UTF-8?q?defined:=20full=20power=20profile=20(consumption=20positive,=20?= =?UTF-8?q?production=20negative)=20-=20If=20only=20production=20sensor=20?= =?UTF-8?q?defined:=20full=20power=20profile=20inverted=20(production=20po?= =?UTF-8?q?sitive,=20consumption=20negative)=20-=20If=20both=20defined:=20?= =?UTF-8?q?split=20=E2=80=94=20non-negative=20part=20to=20consumption=20se?= =?UTF-8?q?nsor,=20sign-flipped=20non-positive=20part=20to=20production=20?= =?UTF-8?q?sensor=20-=20Include=20results=20in=20return=5Fmultiple=20outpu?= =?UTF-8?q?t=20as=20consumption=5Fschedule=20/=20production=5Fschedule=20e?= =?UTF-8?q?ntries=20-=20Sign=20convention=20is=20encoded=20in=20the=20key?= =?UTF-8?q?=20name=20so=20no=20consumption=5Fis=5Fpositive=20attribute=20i?= =?UTF-8?q?s=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flexmeasures/data/models/planning/storage.py | 118 ++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 93a67a3e96..93db661796 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1567,6 +1567,83 @@ def _build_soc_schedule( ) return soc_schedule + @staticmethod + def _build_consumption_production_schedules( + flex_model: list[dict], + ems_schedule: pd.DataFrame, + ) -> dict: + """Build consumption and/or production power schedules for devices that define output sensors. + + Each device's flex model may define a ``consumption`` sensor, a ``production`` sensor, or both. + The schedule stored on each sensor depends on which sensors are defined: + + - **Only** ``consumption`` **sensor defined**: the full power schedule is written to that + sensor using the standard FlexMeasures sign convention (consumption positive, production + negative). + - **Only** ``production`` **sensor defined**: the full power schedule is written to that + sensor with the sign inverted (production positive, consumption negative). + - **Both** ``consumption`` **and** ``production`` **sensors defined**: only the non-negative + part of the schedule is written to the consumption sensor, and only the non-positive part + (sign-flipped to positive values) is written to the production sensor. + + Because the sign convention is encoded in the sensor key name (``consumption`` vs. + ``production``), these sensors do not need a ``consumption_is_positive`` attribute. + + Unit conversion from MW to each sensor's unit is applied. + + :param flex_model: List of per-device flex models (after deserialization). + :param ems_schedule: DataFrame of per-device power schedules in MW (consumption positive). + :returns: Dict mapping each output sensor to its power schedule. + """ + schedules: dict = {} + for d, flex_model_d in enumerate(flex_model): + consumption_field = flex_model_d.get("consumption") + production_field = flex_model_d.get("production") + consumption_sensor = ( + consumption_field["sensor"] + if isinstance(consumption_field, dict) and "sensor" in consumption_field + else None + ) + production_sensor = ( + production_field["sensor"] + if isinstance(production_field, dict) and "sensor" in production_field + else None + ) + if consumption_sensor is None and production_sensor is None: + continue + power_series = ems_schedule[d] # in MW; consumption is positive + if consumption_sensor is not None and production_sensor is None: + # Full power profile on the consumption sensor (consumption positive, production negative). + schedules[consumption_sensor] = convert_units( + power_series, + "MW", + consumption_sensor.unit, + event_resolution=consumption_sensor.event_resolution, + ) + elif production_sensor is not None and consumption_sensor is None: + # Full power profile on the production sensor (production positive, consumption negative). + schedules[production_sensor] = convert_units( + -power_series, + "MW", + production_sensor.unit, + event_resolution=production_sensor.event_resolution, + ) + else: + # Both sensors defined: split into non-negative (consumption) and non-positive (production) parts. + schedules[consumption_sensor] = convert_units( + power_series.clip(lower=0), + "MW", + consumption_sensor.unit, + event_resolution=consumption_sensor.event_resolution, + ) + schedules[production_sensor] = convert_units( + (-power_series).clip(lower=0), + "MW", + production_sensor.unit, + event_resolution=production_sensor.event_resolution, + ) + return schedules + def compute(self, skip_validation: bool = False) -> SchedulerOutputType: """Schedule a battery or Charge Point based directly on the latest beliefs regarding market prices within the specified time window. For the resulting consumption schedule, consumption is defined as positive values. @@ -1640,6 +1717,10 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: flex_model, ems_schedule, soc_at_start, device_constraints, resolution ) + consumption_production_schedule = self._build_consumption_production_schedules( + flex_model, ems_schedule + ) + # Resample each device schedule to the resolution of the device's power sensor if self.resolution is None: storage_schedule = { @@ -1649,6 +1730,12 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: for sensor in storage_schedule.keys() if sensor is not None } + consumption_production_schedule = { + sensor: consumption_production_schedule[sensor] + .resample(sensor.event_resolution) + .mean() + for sensor in consumption_production_schedule.keys() + } # Round schedule if self.round_to_decimals: @@ -1661,6 +1748,12 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: sensor: soc_schedule[sensor].round(self.round_to_decimals) for sensor in soc_schedule.keys() } + consumption_production_schedule = { + sensor: consumption_production_schedule[sensor].round( + self.round_to_decimals + ) + for sensor in consumption_production_schedule.keys() + } if self.return_multiple: storage_schedules = [ @@ -1694,7 +1787,30 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: } for sensor, soc in soc_schedule.items() ] - return storage_schedules + commitment_costs + soc_schedules + # Determine which sensors are consumption vs. production output sensors + consumption_output_sensors = { + flex_model_d["consumption"]["sensor"] + for flex_model_d in flex_model + if isinstance(flex_model_d.get("consumption"), dict) + and "sensor" in flex_model_d["consumption"] + } + consumption_production_schedules = [ + { + "name": "consumption_schedule" + if sensor in consumption_output_sensors + else "production_schedule", + "data": data, + "sensor": sensor, + "unit": sensor.unit, + } + for sensor, data in consumption_production_schedule.items() + ] + return ( + storage_schedules + + commitment_costs + + soc_schedules + + consumption_production_schedules + ) else: return storage_schedule[sensors[0]] From a96edd484ec6a19940ef2a30c38027620c19d5c8 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 20 May 2026 16:50:30 +0200 Subject: [PATCH 09/11] =?UTF-8?q?tests:=20expand=20storage=20scheduler=20t?= =?UTF-8?q?ests=20for=20consumption/production=20output=20sensors=20Contex?= =?UTF-8?q?t:=20-=20StorageScheduler=20now=20supports=20writing=20schedule?= =?UTF-8?q?s=20to=20consumption=20and=20production=20sensors=20Change:=20-?= =?UTF-8?q?=20test=5Fbattery=5Fsolver=5Fmulti=5Fcommitment:=20add=20consum?= =?UTF-8?q?ption=20and=20production=20output=20sensors=20=20=20to=20the=20?= =?UTF-8?q?battery,=20include=20them=20in=20the=20flex-model,=20and=20veri?= =?UTF-8?q?fy=20unit=20conversion=20(MW=20=E2=86=92=20kW)=20=20=20and=20th?= =?UTF-8?q?e=20split=20logic=20(all-positive=20schedule=20=E2=86=92=20cons?= =?UTF-8?q?umption=20all=20positive,=20production=20all=20zero)=20-=20test?= =?UTF-8?q?=5Ftrigger=5Fschedule=5Fuses=5Fstate=5Fof=5Fcharge=5Fsensor=5Ff?= =?UTF-8?q?or=5Fsoc=5Fat=5Fstart:=20add=20production=20=20=20output=20sens?= =?UTF-8?q?or=20and=20verify=2096=20beliefs=20are=20stored=20after=20sched?= =?UTF-8?q?uling=20-=20test=5Fadd=5Fstorage=5Fschedule=5Fuses=5Fstate=5Fof?= =?UTF-8?q?=5Fcharge=5Fsensor=5Ffor=5Fsoc=5Fat=5Fstart:=20add=20consumptio?= =?UTF-8?q?n=20=20=20output=20sensor=20and=20verify=2048=20beliefs=20are?= =?UTF-8?q?=20stored=20after=20scheduling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_sensor_schedules_fresh_db.py | 25 +++++++++++- .../cli/tests/test_data_add_fresh_db.py | 21 ++++++++++ .../models/planning/tests/test_storage.py | 38 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py b/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py index 200243b1fe..853fa8e3c1 100644 --- a/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py @@ -235,14 +235,26 @@ def test_trigger_schedule_uses_state_of_charge_sensor_for_soc_at_start( event_value=50, ) ) - fresh_db.session.commit() + # Also add a production output sensor to verify the production schedule is stored. + # (Using only a production sensor means the full power profile is stored with sign inverted: + # production positive, consumption negative.) sensor = ( Sensor.query.filter(Sensor.name == "power") .join(GenericAsset, GenericAsset.id == Sensor.generic_asset_id) .filter(GenericAsset.name == "Test battery") .one_or_none() ) + production_output_sensor = Sensor( + name="production output", + generic_asset=sensor.generic_asset, + unit="MW", + event_resolution=sensor.event_resolution, + ) + fresh_db.session.add(production_output_sensor) + fresh_db.session.commit() + + message["flex-model"]["production"] = {"sensor": production_output_sensor.id} with app.test_client() as client: trigger_schedule_response = client.post( @@ -268,6 +280,17 @@ def test_trigger_schedule_uses_state_of_charge_sensor_for_soc_at_start( sensor = fresh_db.session.get(Sensor, sensor.id) assert sensor.generic_asset.get_attribute("soc_in_mwh") == pytest.approx(0.02) + # Verify the production output sensor received schedule data. + # Only the production sensor is defined (no consumption sensor), so the full power profile + # is stored with inverted sign: production as positive, consumption as negative. + production_output_sensor = fresh_db.session.get( + Sensor, production_output_sensor.id + ) + production_beliefs = production_output_sensor.search_beliefs( + event_starts_after=parse_datetime(message["start"]) + ) + assert len(production_beliefs) == 96 # 24h at 15-min resolution + @pytest.mark.parametrize( "context_sensor, asset_sensor, parent_sensor, expect_sensor", diff --git a/flexmeasures/cli/tests/test_data_add_fresh_db.py b/flexmeasures/cli/tests/test_data_add_fresh_db.py index 6abc036585..12a85505ab 100644 --- a/flexmeasures/cli/tests/test_data_add_fresh_db.py +++ b/flexmeasures/cli/tests/test_data_add_fresh_db.py @@ -488,6 +488,17 @@ def test_add_storage_schedule_uses_state_of_charge_sensor_for_soc_at_start( belief_time=datetime.fromisoformat(start), ) ) + + # Add a consumption output sensor to verify the full power profile is stored on it + # (only the consumption sensor is defined, so the sign convention is consumption positive, + # production negative). + consumption_output_sensor = Sensor( + name="consumption output", + generic_asset=charging_station, + unit="MW", + event_resolution=power_sensor.event_resolution, + ) + fresh_db.session.add(consumption_output_sensor) fresh_db.session.commit() cli_input_params = { @@ -504,6 +515,7 @@ def test_add_storage_schedule_uses_state_of_charge_sensor_for_soc_at_start( "soc-min": "0 MWh", "soc-max": "5 MWh", "power-capacity": "2 MW", + "consumption": {"sensor": consumption_output_sensor.id}, } ), } @@ -513,3 +525,12 @@ def test_add_storage_schedule_uses_state_of_charge_sensor_for_soc_at_start( check_command_ran_without_error(result) assert len(power_sensor.search_beliefs()) == 48 assert power_sensor.generic_asset.get_attribute("soc_in_mwh") == 2.5 + + # Verify the consumption output sensor received the full power schedule. + # A charging station is consumption-only (non-negative), so the full schedule + # is non-negative and equals what is stored on the power sensor. + consumption_output_sensor = fresh_db.session.get( + Sensor, consumption_output_sensor.id + ) + assert len(consumption_output_sensor.search_beliefs()) == 48 + diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index dbb5d792f7..16e2608643 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -29,6 +29,24 @@ def test_battery_solver_multi_commitment(add_battery_assets, db): index = initialize_index(start=start, end=end, resolution=resolution) production_prices = pd.Series(90, index=index) consumption_prices = pd.Series(100, index=index) + + # Add consumption and production output sensors to the battery asset + consumption_output_sensor = Sensor( + name="consumption output", + generic_asset=battery.generic_asset, + unit="kW", + event_resolution=resolution, + ) + production_output_sensor = Sensor( + name="production output", + generic_asset=battery.generic_asset, + unit="kW", + event_resolution=resolution, + ) + db.session.add(consumption_output_sensor) + db.session.add(production_output_sensor) + db.session.flush() + scheduler: Scheduler = StorageScheduler( battery, start, @@ -37,6 +55,8 @@ def test_battery_solver_multi_commitment(add_battery_assets, db): flex_model={ "soc-at-start": f"{soc_at_start} MWh", "soc-min": "0 MWh", + "consumption": {"sensor": consumption_output_sensor.id}, + "production": {"sensor": production_output_sensor.id}, "soc-max": "1 MWh", "power-capacity": "1 MVA", "soc-minima": [ @@ -131,6 +151,24 @@ def test_battery_solver_multi_commitment(add_battery_assets, db): costs["a sample commitment penalizing demand/supply"], 1 * (1 - 0.4) ) + # Check consumption/production output sensor schedules. + # The battery charges at a constant rate (all positive values), so the consumption schedule + # should match the power schedule in kW, and the production schedule should be all zeros. + consumption_result = next( + r for r in results if r.get("name") == "consumption_schedule" + ) + production_result = next( + r for r in results if r.get("name") == "production_schedule" + ) + assert consumption_result["sensor"] is consumption_output_sensor + assert consumption_result["unit"] == "kW" + assert production_result["sensor"] is production_output_sensor + assert production_result["unit"] == "kW" + # Both sensors have the same resolution as the power sensor, so no resampling occurs. + expected_kw = (1 - 0.4) / 24 * 1000 # MW -> kW + np.testing.assert_allclose(consumption_result["data"], expected_kw) + np.testing.assert_allclose(production_result["data"], 0) + def test_battery_relaxation(add_battery_assets, db): """Check that resolving SoC breaches is more important than resolving device power breaches. From e63ee437abf815ccdcd8f0a82766e63f13b6a114 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 20 May 2026 16:51:15 +0200 Subject: [PATCH 10/11] docs: document consumption/production output sensor semantics and add changelog entry Context: - StorageScheduler now writes schedules to consumption/production sensors Change: - Expand CONSUMPTION and PRODUCTION metadata descriptions with the split logic (only consumption, only production, or both defined) and clarify that the sign convention is encoded in the key name (no consumption_is_positive attribute needed) - Add changelog entry in v0.33.0 New features section (PR number TBD) --- documentation/changelog.rst | 1 + .../data/schemas/scheduling/metadata.py | 22 +++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 4a760c37ff..8d91db102c 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -23,6 +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 `_] +* The ``consumption`` and ``production`` flex-model fields for the ``StorageScheduler`` now act as output sensors: the scheduler writes the resulting power schedule to those sensors, with unit conversion and resampling applied. When only one of the two is defined, the full power profile is written (sign-inverted for the production sensor). When both are defined, the schedule is split into its non-negative (consumption) and non-positive (production) parts [see `PR #XXXX `_] * 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 `_] * 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 `_] * Add support for filtering sensor data GET requests by ``source-type`` on ``/api/v3_0/sensors//data`` [see `PR #2127 `_] diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 4daebfbfba..b64fe14d07 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -186,11 +186,29 @@ def to_dict(self): CONSUMPTION = MetaData( - description="Sensor used to record the scheduled consumption.", + description="""Sensor used to record the scheduled power as seen from a consumption perspective. + +The sign convention is determined by the key name, so the sensor itself does not need a ``consumption_is_positive`` attribute. + +Depending on which output sensors are defined: + +- **Only** ``consumption`` **defined**: the full power schedule is stored on this sensor using the + standard FlexMeasures sign convention (consumption positive, production negative). +- **Only** ``production`` **defined**: the full power schedule is stored on the production sensor + with the sign inverted (production positive, consumption negative). +- **Both defined**: only the non-negative part of the schedule is stored on this sensor (zero for + time steps with net production), and only the non-positive part (sign-flipped) is stored on the + production sensor. +""", example={"sensor": 14}, ) PRODUCTION = MetaData( - description="Sensor used to record the scheduled production.", + description="""Sensor used to record the scheduled power as seen from a production perspective. + +The sign convention is determined by the key name, so the sensor itself does not need a ``consumption_is_positive`` attribute. + +See ``consumption`` for the full description of the split logic when both sensors are defined. +""", example={"sensor": 15}, ) STATE_OF_CHARGE = MetaData( From 82c6015b3608ba8efef46aa0795099cb019eb80c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 20 May 2026 17:02:47 +0200 Subject: [PATCH 11/11] style: black Signed-off-by: F.N. Claessen --- .../api/v3_0/tests/test_sensor_schedules_fresh_db.py | 4 +--- flexmeasures/cli/tests/test_data_add_fresh_db.py | 1 - flexmeasures/data/models/planning/storage.py | 8 +++++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py b/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py index 853fa8e3c1..c60626b13e 100644 --- a/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py @@ -283,9 +283,7 @@ def test_trigger_schedule_uses_state_of_charge_sensor_for_soc_at_start( # Verify the production output sensor received schedule data. # Only the production sensor is defined (no consumption sensor), so the full power profile # is stored with inverted sign: production as positive, consumption as negative. - production_output_sensor = fresh_db.session.get( - Sensor, production_output_sensor.id - ) + production_output_sensor = fresh_db.session.get(Sensor, production_output_sensor.id) production_beliefs = production_output_sensor.search_beliefs( event_starts_after=parse_datetime(message["start"]) ) diff --git a/flexmeasures/cli/tests/test_data_add_fresh_db.py b/flexmeasures/cli/tests/test_data_add_fresh_db.py index 12a85505ab..66c81e8f9c 100644 --- a/flexmeasures/cli/tests/test_data_add_fresh_db.py +++ b/flexmeasures/cli/tests/test_data_add_fresh_db.py @@ -533,4 +533,3 @@ def test_add_storage_schedule_uses_state_of_charge_sensor_for_soc_at_start( Sensor, consumption_output_sensor.id ) assert len(consumption_output_sensor.search_beliefs()) == 48 - diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 93db661796..8539e8e8bc 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1796,9 +1796,11 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: } consumption_production_schedules = [ { - "name": "consumption_schedule" - if sensor in consumption_output_sensors - else "production_schedule", + "name": ( + "consumption_schedule" + if sensor in consumption_output_sensors + else "production_schedule" + ), "data": data, "sensor": sensor, "unit": sensor.unit,