From 0360d0122d3d648cea9cd066ebd7588c73040e99 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 14 May 2026 14:03:53 +0200 Subject: [PATCH 01/49] dev: Support commodity-specific prices and site capacities in storage scheduler Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 311 +++++++++++-------- 1 file changed, 189 insertions(+), 122 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index a31fa6e9cf..969292d707 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -76,6 +76,79 @@ def compute_schedule(self) -> pd.Series | None: return self.compute() + def _get_commodity_contexts(self) -> dict[str, dict]: + """Return commodity-specific flex-contexts. + + Supports the new format: + + "commodities": [ + {"commodity": "electricity", ...}, + {"commodity": "gas", ...}, + ] + + and keeps backwards compatibility with old top-level fields. + """ + + commodity_contexts = {} + + for commodity_context in self.flex_context.get("commodity_contexts", []): + commodity = commodity_context["commodity"] + commodity_contexts[commodity] = commodity_context + + # Backwards-compatible electricity defaults from old top-level fields. + if "electricity" not in commodity_contexts: + commodity_contexts["electricity"] = { + "commodity": "electricity", + "consumption_price": self.flex_context.get( + "consumption_price", + self.flex_context.get("consumption_price_sensor"), + ), + "production_price": self.flex_context.get( + "production_price", + self.flex_context.get("production_price_sensor"), + ), + "ems_power_capacity_in_mw": self.flex_context.get( + "ems_power_capacity_in_mw" + ), + "ems_consumption_capacity_in_mw": self.flex_context.get( + "ems_consumption_capacity_in_mw" + ), + "ems_production_capacity_in_mw": self.flex_context.get( + "ems_production_capacity_in_mw" + ), + "ems_consumption_breach_price": self.flex_context.get( + "ems_consumption_breach_price" + ), + "ems_production_breach_price": self.flex_context.get( + "ems_production_breach_price" + ), + "ems_peak_consumption_in_mw": self.flex_context.get( + "ems_peak_consumption_in_mw" + ), + "ems_peak_consumption_price": self.flex_context.get( + "ems_peak_consumption_price" + ), + "ems_peak_production_in_mw": self.flex_context.get( + "ems_peak_production_in_mw" + ), + "ems_peak_production_price": self.flex_context.get( + "ems_peak_production_price" + ), + } + + # Backwards-compatible gas defaults from old `gas-price`. + if ( + self.flex_context.get("gas_price") is not None + and "gas" not in commodity_contexts + ): + commodity_contexts["gas"] = { + "commodity": "gas", + "consumption_price": self.flex_context.get("gas_price"), + "production_price": self.flex_context.get("gas_price"), + } + + return commodity_contexts + def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 """This function prepares the required data to compute the schedule: - price data @@ -245,96 +318,18 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ] # Get info from flex-context - consumption_price_sensor = self.flex_context.get("consumption_price_sensor") - production_price_sensor = self.flex_context.get("production_price_sensor") - gas_price_sensor = self.flex_context.get("gas_price_sensor") - - consumption_price = self.flex_context.get( - "consumption_price", consumption_price_sensor - ) - production_price = self.flex_context.get( - "production_price", production_price_sensor - ) - gas_price = self.flex_context.get("gas_price", gas_price_sensor) - # fallback to using the consumption price, for backwards compatibility - if production_price is None: - production_price = consumption_price inflexible_device_sensors = self.flex_context.get( "inflexible_device_sensors", [] ) - # Fetch the device's power capacity (required Sensor attribute) + # Fetch the device's power capacity required by the device constraints. power_capacity_in_mw = self._get_device_power_capacity(flex_model, assets) - gas_deviation_prices = None - if gas_price is not None: - gas_deviation_prices = get_continuous_series_sensor_or_quantity( - variable_quantity=gas_price, - unit=self.flex_context["shared_currency_unit"] + "/MWh", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - fill_sides=True, - ).to_frame(name="event_value") - ensure_prices_are_not_empty(gas_deviation_prices, gas_price) - gas_deviation_prices = ( - gas_deviation_prices.loc[start : end - resolution]["event_value"] - * resolution - / pd.Timedelta("1h") - ) - - # Check for known prices or price forecasts - up_deviation_prices = get_continuous_series_sensor_or_quantity( - variable_quantity=consumption_price, - unit=self.flex_context["shared_currency_unit"] + "/MWh", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - fill_sides=True, - ).to_frame(name="event_value") - ensure_prices_are_not_empty(up_deviation_prices, consumption_price) - down_deviation_prices = get_continuous_series_sensor_or_quantity( - variable_quantity=production_price, - unit=self.flex_context["shared_currency_unit"] + "/MWh", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - fill_sides=True, - ).to_frame(name="event_value") - ensure_prices_are_not_empty(down_deviation_prices, production_price) - + # Convert to UTC before fetching time series. start = pd.Timestamp(start).tz_convert("UTC") end = pd.Timestamp(end).tz_convert("UTC") - # Create Series with EMS capacities - ems_power_capacity_in_mw = get_continuous_series_sensor_or_quantity( - variable_quantity=self.flex_context.get("ems_power_capacity_in_mw"), - unit="MW", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - resolve_overlaps="min", - ) - ems_consumption_capacity = get_continuous_series_sensor_or_quantity( - variable_quantity=self.flex_context.get("ems_consumption_capacity_in_mw"), - unit="MW", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - max_value=ems_power_capacity_in_mw, - resolve_overlaps="min", - ) - ems_production_capacity = -1 * get_continuous_series_sensor_or_quantity( - variable_quantity=self.flex_context.get("ems_production_capacity_in_mw"), - unit="MW", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - max_value=ems_power_capacity_in_mw, - resolve_overlaps="min", - ) - - # Set up commitments to optimise for + # Set up commitments to optimise for. commitments = self.convert_to_commitments( query_window=(start, end), resolution=resolution, @@ -345,18 +340,15 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 index = initialize_index(start, end, resolution) commitment_quantities = initialize_series(0, start, end, resolution) - # Convert energy prices to EUR/(deviation of commitment, which is in MW) - commitment_upwards_deviation_price = ( - up_deviation_prices.loc[start : end - resolution]["event_value"] - * resolution - / pd.Timedelta("1h") - ) - commitment_downwards_deviation_price = ( - down_deviation_prices.loc[start : end - resolution]["event_value"] - * resolution - / pd.Timedelta("1h") - ) - + # Keep EMS constraints global only. + # + # Important: + # Do NOT put commodity-specific site-consumption-capacity or + # site-production-capacity into ems_constraints, because these constraints + # are applied to the sum of all devices in device_scheduler. + # + # Commodity-specific capacities are modelled below as FlowCommitments + # with device=commodity_devices. ems_constraints = initialize_df( StorageScheduler.COLUMNS, start, end, resolution ) @@ -371,25 +363,59 @@ def device_list_series( commodity = flex_model_d.get("commodity", "electricity") commodity_to_devices.setdefault(commodity, []).append(d) + commodity_contexts = self._get_commodity_contexts() + price_frames_by_commodity = {} + for commodity, devices in commodity_to_devices.items(): commodity_devices = device_list_series(devices, index) + commodity_context = commodity_contexts.get(commodity, {}) - if commodity == "electricity": - up_price = commitment_upwards_deviation_price - down_price = commitment_downwards_deviation_price - elif commodity == "gas": - if gas_deviation_prices is None: - raise ValueError( - "Gas prices are required in the flex-context to set up gas flow commitments." - ) - up_price = gas_deviation_prices - down_price = gas_deviation_prices - else: + consumption_price = commodity_context.get("consumption_price") + production_price = commodity_context.get("production_price") + + if production_price is None: + production_price = consumption_price + + if consumption_price is None: raise ValueError( - f"Unsupported commodity {commodity} in flex-model. " - "Only 'electricity' and 'gas' are supported." + f"Missing consumption price for commodity '{commodity}'." ) + # Energy prices for this commodity. + up_deviation_prices = get_continuous_series_sensor_or_quantity( + variable_quantity=consumption_price, + unit=self.flex_context["shared_currency_unit"] + "/MWh", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + fill_sides=True, + ).to_frame(name="event_value") + ensure_prices_are_not_empty(up_deviation_prices, consumption_price) + + down_deviation_prices = get_continuous_series_sensor_or_quantity( + variable_quantity=production_price, + unit=self.flex_context["shared_currency_unit"] + "/MWh", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + fill_sides=True, + ).to_frame(name="event_value") + ensure_prices_are_not_empty(down_deviation_prices, production_price) + + price_frames_by_commodity[commodity] = up_deviation_prices + + # Convert energy prices to price per MW deviation for one resolution step. + up_price = ( + up_deviation_prices.loc[start : end - resolution]["event_value"] + * resolution + / pd.Timedelta("1h") + ) + down_price = ( + down_deviation_prices.loc[start : end - resolution]["event_value"] + * resolution + / pd.Timedelta("1h") + ) + commitments.append( FlowCommitment( name=f"{commodity} net energy", @@ -403,9 +429,46 @@ def device_list_series( ) ) - if self.flex_context.get("ems_peak_consumption_price") is not None: + # Commodity-specific site capacities. + # These are not written into ems_constraints. Instead, they are added as + # FlowCommitments that only aggregate the devices of this commodity. + ems_power_capacity = get_continuous_series_sensor_or_quantity( + variable_quantity=commodity_context.get("ems_power_capacity_in_mw"), + unit="MW", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + resolve_overlaps="min", + ) + + ems_consumption_capacity = get_continuous_series_sensor_or_quantity( + variable_quantity=commodity_context.get( + "ems_consumption_capacity_in_mw" + ), + unit="MW", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + max_value=ems_power_capacity, + resolve_overlaps="min", + ) + + ems_production_capacity = -1 * get_continuous_series_sensor_or_quantity( + variable_quantity=commodity_context.get( + "ems_production_capacity_in_mw" + ), + unit="MW", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + max_value=ems_power_capacity, + resolve_overlaps="min", + ) + + # Commodity-specific peak consumption commitment. + if commodity_context.get("ems_peak_consumption_price") is not None: ems_peak_consumption = get_continuous_series_sensor_or_quantity( - variable_quantity=self.flex_context.get( + variable_quantity=commodity_context.get( "ems_peak_consumption_in_mw" ), unit="MW", @@ -416,7 +479,7 @@ def device_list_series( fill_sides=True, ) ems_peak_consumption_price = get_continuous_series_sensor_or_quantity( - variable_quantity=self.flex_context.get( + variable_quantity=commodity_context.get( "ems_peak_consumption_price" ), unit=self.flex_context["shared_currency_unit"] + "/MW", @@ -439,9 +502,10 @@ def device_list_series( ) ) - if self.flex_context.get("ems_peak_production_price") is not None: + # Commodity-specific peak production commitment. + if commodity_context.get("ems_peak_production_price") is not None: ems_peak_production = get_continuous_series_sensor_or_quantity( - variable_quantity=self.flex_context.get( + variable_quantity=commodity_context.get( "ems_peak_production_in_mw" ), unit="MW", @@ -452,7 +516,7 @@ def device_list_series( fill_sides=True, ) ems_peak_production_price = get_continuous_series_sensor_or_quantity( - variable_quantity=self.flex_context.get( + variable_quantity=commodity_context.get( "ems_peak_production_price" ), unit=self.flex_context["shared_currency_unit"] + "/MW", @@ -475,13 +539,14 @@ def device_list_series( ) ) - ems_consumption_breach_price = self.flex_context.get( + ems_consumption_breach_price = commodity_context.get( "ems_consumption_breach_price" ) - ems_production_breach_price = self.flex_context.get( + ems_production_breach_price = commodity_context.get( "ems_production_breach_price" ) + # Commodity-specific site consumption breach. if ems_consumption_breach_price is not None: any_ems_consumption_breach_price = ( get_continuous_series_sensor_or_quantity( @@ -529,10 +594,7 @@ def device_list_series( ) ) - ems_constraints["derivative max"] = ems_power_capacity_in_mw - else: - ems_constraints["derivative max"] = ems_consumption_capacity - + # Commodity-specific site production breach. if ems_production_breach_price is not None: any_ems_production_breach_price = ( get_continuous_series_sensor_or_quantity( @@ -580,10 +642,15 @@ def device_list_series( ) ) - ems_constraints["derivative min"] = -ems_power_capacity_in_mw - else: - ems_constraints["derivative min"] = ems_production_capacity - + # Keep one price frame for later preference logic. + # The existing "prefer charging sooner" code uses `up_deviation_prices`. + # Prefer electricity prices if available, otherwise use the first commodity price. + if "electricity" in price_frames_by_commodity: + up_deviation_prices = price_frames_by_commodity["electricity"] + elif price_frames_by_commodity: + up_deviation_prices = next(iter(price_frames_by_commodity.values())) + else: + raise ValueError("No commodity prices were available.") # Commitments per device # StockCommitment per device to prefer a full storage by penalizing not being full From 775a52d37fea04a47d5b508179dd4d4b72176928 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 14 May 2026 14:05:47 +0200 Subject: [PATCH 02/49] dev: Add commodity-specific flex-context schema Signed-off-by: Ahmad-Wahid --- .../data/schemas/scheduling/__init__.py | 91 +++++++++++++++++++ flexmeasures/ui/static/openapi-specs.json | 33 +++++++ 2 files changed, 124 insertions(+) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 7f1c747a3c..f14f9fe1df 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -139,9 +139,100 @@ class DBCommitmentSchema(CommitmentSchema, NoTimeSeriesSpecs): pass +class CommodityFlexContextSchema(Schema): + commodity = fields.Str( + required=True, + validate=validate.OneOf(["electricity", "gas"]), + data_key="commodity", + ) + + consumption_price = VariableQuantityField( + "/MWh", + required=False, + data_key="consumption-price", + return_magnitude=False, + ) + + production_price = VariableQuantityField( + "/MWh", + required=False, + data_key="production-price", + return_magnitude=False, + ) + + ems_power_capacity_in_mw = VariableQuantityField( + "MW", + required=False, + data_key="site-power-capacity", + value_validator=validate.Range(min=0), + ) + + ems_consumption_capacity_in_mw = VariableQuantityField( + "MW", + required=False, + data_key="site-consumption-capacity", + value_validator=validate.Range(min=0), + ) + + ems_production_capacity_in_mw = VariableQuantityField( + "MW", + required=False, + data_key="site-production-capacity", + value_validator=validate.Range(min=0), + ) + + ems_consumption_breach_price = VariableQuantityField( + "/MW", + required=False, + data_key="site-consumption-breach-price", + value_validator=validate.Range(min=0), + ) + + ems_production_breach_price = VariableQuantityField( + "/MW", + required=False, + data_key="site-production-breach-price", + value_validator=validate.Range(min=0), + ) + + ems_peak_consumption_in_mw = VariableQuantityField( + "MW", + required=False, + data_key="site-peak-consumption", + value_validator=validate.Range(min=0), + ) + + ems_peak_consumption_price = VariableQuantityField( + "/MW", + required=False, + data_key="site-peak-consumption-price", + value_validator=validate.Range(min=0), + ) + + ems_peak_production_in_mw = VariableQuantityField( + "MW", + required=False, + data_key="site-peak-production", + value_validator=validate.Range(min=0), + ) + + ems_peak_production_price = VariableQuantityField( + "/MW", + required=False, + data_key="site-peak-production-price", + value_validator=validate.Range(min=0), + ) + + class FlexContextSchema(Schema): """This schema defines fields that provide context to the portfolio to be optimized.""" + commodity_contexts = fields.Nested( + CommodityFlexContextSchema, + data_key="commodities", + required=False, + many=True, + ) # Device commitments consumption_breach_price = VariableQuantityField( "/MW", diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 54efa403e4..363775451c 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -3896,6 +3896,33 @@ "openapi": "3.1.2", "components": { "schemas": { + "CommodityFlexContext": { + "type": "object", + "properties": { + "commodity": { + "type": "string", + "enum": [ + "electricity", + "gas" + ] + }, + "consumption-price": {}, + "production-price": {}, + "site-power-capacity": {}, + "site-consumption-capacity": {}, + "site-production-capacity": {}, + "site-consumption-breach-price": {}, + "site-production-breach-price": {}, + "site-peak-consumption": {}, + "site-peak-consumption-price": {}, + "site-peak-production": {}, + "site-peak-production-price": {} + }, + "required": [ + "commodity" + ], + "additionalProperties": false + }, "Quantity": { "type": "string", "description": "Quantity string describing a fixed quantity.", @@ -3974,6 +4001,12 @@ "FlexContextOpenAPISchema": { "type": "object", "properties": { + "commodities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommodityFlexContext" + } + }, "consumption-breach-price": { "description": "This penalty value is used to discourage the violation of the consumption-capacity constraint in the flex-model.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\n", "example": "10 EUR/kW", From b9aece48dfcba58cdec151d0e995e5177a83ae24 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 14 May 2026 14:08:47 +0200 Subject: [PATCH 03/49] dev: Add dynamic commodity prices and split flex-context settings to capacity scheduling test Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 343 +++++++++++++++--- 1 file changed, 300 insertions(+), 43 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 37f5b156d3..e84361c0eb 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -871,7 +871,7 @@ def test_two_devices_shared_stock(app, db): ) -def test_simulation_copy_new(app, db): +def set_up_simulation_assets_and_sensors(app, db): # ---- asset types and assets gas_boiler_type = get_or_create_model(GenericAssetType, name="gas-boiler") buffer_type = get_or_create_model(GenericAssetType, name="heat-buffer") @@ -902,14 +902,6 @@ def test_simulation_copy_new(app, db): db.session.add_all([gas_boiler, heat_buffer, building, electric_heater, site]) db.session.commit() - # ---- sensors - start = pd.Timestamp("2026-04-07T00:00:00+01:00") - end = pd.Timestamp( - "2026-04-09T06:00:00+01:00" - ) # Extended to allow discharge target on April 8 - belief_time = pd.Timestamp( - "2026-04-05T00:00:00+01:00" - ) # 2 days before start for generous planning horizon power_resolution = pd.Timedelta("15m") energy_resolution = pd.Timedelta(0) @@ -960,6 +952,30 @@ def test_simulation_copy_new(app, db): event_resolution=energy_resolution, # instantaneous generic_asset=heat_buffer, ) + consumption_price = Sensor( + name="consumption price", + unit="EUR/MWh", + event_resolution=energy_resolution, + generic_asset=site, + ) + production_price = Sensor( + name="production price", + unit="EUR/MWh", + event_resolution=energy_resolution, + generic_asset=site, + ) + gas_price = Sensor( + name="gas price", + unit="EUR/MWh", + event_resolution=energy_resolution, + generic_asset=site, + ) + dynamic_consumption_capacity = Sensor( + name="dynamic consumption capacity", + unit="kW", + event_resolution=power_resolution, + generic_asset=site, + ) db.session.add_all( [ @@ -970,16 +986,66 @@ def test_simulation_copy_new(app, db): building_raw_power, heater_power, soc_targets, + consumption_price, + production_price, + gas_price, + dynamic_consumption_capacity, ] ) db.session.commit() + return { + "site": site, + "building": building, + "gas_boiler": gas_boiler, + "heat_buffer": heat_buffer, + "electric_heater": electric_heater, + "building_raw_power": building_raw_power, + "boiler_power": boiler_power, + "tank_power": tank_power, + "buffer_soc": buffer_soc, + "buffer_soc_usage": buffer_soc_usage, + "heater_power": heater_power, + "soc_targets": soc_targets, + "power_resolution": power_resolution, + "energy_resolution": energy_resolution, + "consumption_price": consumption_price, + "production_price": production_price, + "gas_price": gas_price, + "dynamic_consumption_capacity": dynamic_consumption_capacity, + } + + +def test_simulation_with_dynamic_consumption_capacity(app, db): + + start = pd.Timestamp("2026-04-07T00:00:00+01:00") + end = pd.Timestamp( + "2026-04-09T06:00:00+01:00" + ) # Extended to allow discharge target on April 8 + belief_time = pd.Timestamp( + "2026-04-05T00:00:00+01:00" + ) # 2 days before start for generous planning horizon + + setup_data = set_up_simulation_assets_and_sensors(app, db) + + site = setup_data["site"] + building_raw_power = setup_data["building_raw_power"] + heater_power = setup_data["heater_power"] + boiler_power = setup_data["boiler_power"] + buffer_soc = setup_data["buffer_soc"] + buffer_soc_usage = setup_data["buffer_soc_usage"] + consumption_price = setup_data["consumption_price"] + gas_price = setup_data["gas_price"] + dynamic_consumption_capacity = setup_data["dynamic_consumption_capacity"] + import timely_beliefs as tb from flexmeasures import Source # add dummy data to building raw power to ensure site-level constraints are respected building_data = pd.Series( 100.0, - index=pd.date_range(start, end, freq=power_resolution, name="event_start"), + index=pd.date_range( + start, end, freq=setup_data["power_resolution"], name="event_start" + ), name="event_value", ).reset_index() @@ -988,40 +1054,97 @@ def test_simulation_copy_new(app, db): bdf = tb.BeliefsDataFrame( building_data, belief_horizon=-pd.Timedelta(seconds=1) * np.array(range(len(building_data))), - sensor=building_raw_power, + sensor=setup_data["building_raw_power"], + source=get_or_create_model(Source, name="Simulation"), + ) + save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False) + + # Dynamic site consumption capacity: + # - 1200 * 0.6 = 720 kW from 12:00 to 18:00 + # - 1200 kW for the rest of the day + dynamic_capacity_data = pd.DataFrame( + index=pd.date_range( + start, end, freq=setup_data["power_resolution"], name="event_start" + ) + ).reset_index() + + # Dynamic electricity and gas prices: + # - Electricity is cheaper than gas from 12:00 to 16:00 + # - Gas is cheaper for the rest of the day + price_index = pd.date_range( + start, + end, + freq=setup_data["power_resolution"], + name="event_start", + ) + + electricity_price_data = pd.DataFrame(index=price_index).reset_index() + gas_price_data = pd.DataFrame(index=price_index).reset_index() + + # Default prices: gas cheaper than electricity + electricity_price_data["event_value"] = 120.0 + gas_price_data["event_value"] = 90.0 + + # From 12:00 until before 16:00, electricity cheaper than gas + cheap_electricity_mask = electricity_price_data["event_start"].dt.hour.between( + 12, 15 + ) + + electricity_price_data.loc[ + cheap_electricity_mask, + "event_value", + ] = 50.0 + + gas_price_data.loc[ + cheap_electricity_mask, + "event_value", + ] = 150.0 + + bdf = tb.BeliefsDataFrame( + electricity_price_data, + belief_time=belief_time, + sensor=setup_data["consumption_price"], + source=get_or_create_model(Source, name="Simulation"), + ) + save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False) + + bdf = tb.BeliefsDataFrame( + gas_price_data, + belief_time=belief_time, + sensor=setup_data["gas_price"], + source=get_or_create_model(Source, name="Simulation"), + ) + save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False) + + dynamic_capacity_data["event_value"] = 100.0 + + dynamic_capacity_data.loc[ + dynamic_capacity_data["event_start"].dt.hour.between(12, 17), + "event_value", + ] = ( + 100.0 * 0.6 + ) + + bdf = tb.BeliefsDataFrame( + dynamic_capacity_data, + belief_time=belief_time, + sensor=setup_data["dynamic_consumption_capacity"], source=get_or_create_model(Source, name="Simulation"), ) + save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False) - soc_usage["event_value"] = soc_usage["event_value"] * 1.49 + soc_usage["event_value"] = 100 bdf = tb.BeliefsDataFrame( soc_usage, belief_time=belief_time, - sensor=buffer_soc_usage, + sensor=setup_data["buffer_soc_usage"], source=get_or_create_model(Source, name="Simulation"), ) save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False) flex_model = [ - # { - # "sensor": pv_power.id, - # "consumption-capacity": "0 kW", - # "production-capacity": {"sensor": pv_raw_power.id}, - # "power-capacity": "1 GW", - # }, - # { - # "sensor": battery_power.id, - # "soc-min": 0.0, - # "soc-max": 100.0, - # "soc-at-start": 20.0, - # "power-capacity": "20 kW", - # "roundtrip-efficiency": 0.9, - # "soc-targets": [{"datetime": "2026-04-07T20:00:00+01:00", "value": 80.0}], - # "state-of-charge": {"sensor": battery_soc.id}, - # "commodity": "electricity", - # - # }, { "sensor": heater_power.id, "state-of-charge": {"sensor": buffer_soc.id}, @@ -1049,21 +1172,42 @@ def test_simulation_copy_new(app, db): # {"datetime": "2026-04-07T20:00:00+01:00", "value": 700.0}, # ], "state-of-charge": {"sensor": buffer_soc.id}, - # "soc-usage": [{"sensor": buffer_soc_usage.id}], + "soc-usage": [{"sensor": buffer_soc_usage.id}], "storage-efficiency": 0.9, # todo: does not work yet # todo: consider assigning this to the heat commodity, maybe we can derive some useful (costs?) KPI from it }, ] flex_context = { - "consumption-price": "100 EUR/MWh", - "production-price": "100 EUR/MWh", - "gas-price": "150 EUR/MWh", - "site-power-capacity": "4700 kW", - "site-consumption-capacity": "4000 kW", - "site-production-capacity": "100 kW", - "site-consumption-breach-price": "100000 EUR/kW", - "site-production-breach-price": "100000 EUR/kW", + "commodities": [ + { + "commodity": "electricity", + "consumption-price": { + "sensor": consumption_price.id, + }, + "production-price": { + "sensor": consumption_price.id, + }, + "site-power-capacity": "1900 kW", + "site-consumption-capacity": { + "sensor": dynamic_consumption_capacity.id, + }, + "site-production-capacity": "100 kW", + "site-consumption-breach-price": "100000 EUR/kW", + "site-production-breach-price": "100000 EUR/kW", + }, + { + "commodity": "gas", + "consumption-price": { + "sensor": gas_price.id, + }, + "production-price": { + "sensor": gas_price.id, + }, + # No electricity dynamic capacity here. + "site-consumption-capacity": "100000 kW", + }, + ], "relax-constraints": True, "inflexible-device-sensors": [building_raw_power.id], } @@ -1072,7 +1216,7 @@ def test_simulation_copy_new(app, db): asset_or_sensor=site, start=start, end=end, - resolution=power_resolution, + resolution=setup_data["power_resolution"], belief_time=belief_time, flex_model=flex_model, flex_context=flex_context, @@ -1082,5 +1226,118 @@ def test_simulation_copy_new(app, db): pd.set_option("display.max_rows", None) schedules = scheduler.compute(skip_validation=True) - # ---- verify outputs - print(schedules) + heater_schedule = next( + schedule["data"] + for schedule in schedules + if schedule.get("sensor") == heater_power + ) + + boiler_schedule = next( + schedule["data"] + for schedule in schedules + if schedule.get("sensor") == boiler_power + ) + # The electric heater should only be active in the cheap-electricity window. + # In local time, electricity is cheaper from 12:00 to 16:00. + # During this period, the dynamic electricity site capacity is only 60 kW. + # Therefore, the electric heater is expected to run at 60 kW, not its full + # 100 kW device capacity. + pd.testing.assert_series_equal( + heater_schedule.loc["2026-04-07T11:00:00+00:00":"2026-04-07T14:45:00+00:00"], + pd.Series( + 60.0, + index=pd.date_range( + "2026-04-07T11:00:00+00:00", + "2026-04-07T14:45:00+00:00", + freq="15min", + ), + dtype="float64", + ), + check_names=False, + obj=( + "electric heater dispatch during cheap-electricity window on day 1; " + "expected 60 kW because dynamic electricity capacity limits the heater" + ), + ) + + # When electricity is cheaper than gas, the gas boiler should stay off. + # The heat demand is then supplied by the electric heater instead. + pd.testing.assert_series_equal( + boiler_schedule.loc["2026-04-07T11:00:00+00:00":"2026-04-07T14:45:00+00:00"], + pd.Series( + 0.0, + index=pd.date_range( + "2026-04-07T11:00:00+00:00", + "2026-04-07T14:45:00+00:00", + freq="15min", + ), + dtype="float64", + ), + check_names=False, + obj=( + "gas boiler dispatch during cheap-electricity window on day 1; " + "expected 0 kW because electricity is cheaper than gas" + ), + ) + + pd.testing.assert_series_equal( + heater_schedule.loc["2026-04-08T11:00:00+00:00":"2026-04-08T14:45:00+00:00"], + pd.Series( + 60.0, + index=pd.date_range( + "2026-04-08T11:00:00+00:00", + "2026-04-08T14:45:00+00:00", + freq="15min", + ), + dtype="float64", + ), + check_names=False, + obj=( + "electric heater dispatch during cheap-electricity window on day 2; " + "expected 60 kW because dynamic electricity capacity limits the heater" + ), + ) + + pd.testing.assert_series_equal( + boiler_schedule.loc["2026-04-08T11:00:00+00:00":"2026-04-08T14:45:00+00:00"], + pd.Series( + 0.0, + index=pd.date_range( + "2026-04-08T11:00:00+00:00", + "2026-04-08T14:45:00+00:00", + freq="15min", + ), + dtype="float64", + ), + check_names=False, + obj=( + "gas boiler dispatch during cheap-electricity window on day 2; " + "expected 0 kW because electricity is cheaper than gas" + ), + ) + + # Outside the cheap-electricity window, gas is cheaper than electricity. + # Therefore, the gas boiler should become the preferred heat source and run + # at full 100 kW capacity, while the electric heater should remain off. + assert boiler_schedule.loc["2026-04-07T15:00:00+00:00"] == pytest.approx( + 100.0 + ), "Gas boiler should run at full capacity after the cheap-electricity window on day 1." + + assert heater_schedule.loc["2026-04-07T15:00:00+00:00"] == pytest.approx( + 0.0 + ), "Electric heater should be off after the cheap-electricity window because gas is cheaper." + + assert boiler_schedule.loc["2026-04-08T15:00:00+00:00"] == pytest.approx( + 100.0 + ), "Gas boiler should run at full capacity after the cheap-electricity window on day 2." + + assert heater_schedule.loc["2026-04-08T15:00:00+00:00"] == pytest.approx( + 0.0 + ), "Electric heater should be off after the cheap-electricity window on day 2 because gas is cheaper." + + # Before the first cheap-electricity window, the optimizer uses a partial + # 80 kW electric-heater step to prepare the heat buffer. This is part of the + # expected optimal schedule and protects against accidental dispatch changes. + assert heater_schedule.loc["2026-04-07T08:00:00+00:00"] == pytest.approx( + 80.0 + ), "Electric heater should have one expected partial 80 kW dispatch step before the first cheap-electricity window." From 433fe17c08d775016b1f94e4dc0d0b30f98fd33e Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 26 May 2026 17:22:35 +0200 Subject: [PATCH 04/49] feat: create a shared schema for flex-context and commodity-context Signed-off-by: Ahmad-Wahid --- .../data/schemas/scheduling/__init__.py | 151 ++++-------- flexmeasures/ui/static/openapi-specs.json | 218 +++++++++++------- 2 files changed, 180 insertions(+), 189 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index f14f9fe1df..76436ac1af 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -139,18 +139,13 @@ class DBCommitmentSchema(CommitmentSchema, NoTimeSeriesSpecs): pass -class CommodityFlexContextSchema(Schema): - commodity = fields.Str( - required=True, - validate=validate.OneOf(["electricity", "gas"]), - data_key="commodity", - ) - +class SharedSchema(Schema): consumption_price = VariableQuantityField( "/MWh", required=False, data_key="consumption-price", return_magnitude=False, + metadata=metadata.CONSUMPTION_PRICE.to_dict(), ) production_price = VariableQuantityField( @@ -158,6 +153,7 @@ class CommodityFlexContextSchema(Schema): required=False, data_key="production-price", return_magnitude=False, + metadata=metadata.PRODUCTION_PRICE.to_dict(), ) ems_power_capacity_in_mw = VariableQuantityField( @@ -165,6 +161,7 @@ class CommodityFlexContextSchema(Schema): required=False, data_key="site-power-capacity", value_validator=validate.Range(min=0), + metadata=metadata.SITE_POWER_CAPACITY.to_dict(), ) ems_consumption_capacity_in_mw = VariableQuantityField( @@ -172,6 +169,7 @@ class CommodityFlexContextSchema(Schema): required=False, data_key="site-consumption-capacity", value_validator=validate.Range(min=0), + metadata=metadata.SITE_CONSUMPTION_CAPACITY.to_dict(), ) ems_production_capacity_in_mw = VariableQuantityField( @@ -179,20 +177,23 @@ class CommodityFlexContextSchema(Schema): required=False, data_key="site-production-capacity", value_validator=validate.Range(min=0), + metadata=metadata.SITE_PRODUCTION_CAPACITY.to_dict(), ) ems_consumption_breach_price = VariableQuantityField( "/MW", - required=False, data_key="site-consumption-breach-price", + required=False, value_validator=validate.Range(min=0), + metadata=metadata.SITE_CONSUMPTION_BREACH_PRICE.to_dict(), ) ems_production_breach_price = VariableQuantityField( "/MW", - required=False, data_key="site-production-breach-price", + required=False, value_validator=validate.Range(min=0), + metadata=metadata.SITE_PRODUCTION_BREACH_PRICE.to_dict(), ) ems_peak_consumption_in_mw = VariableQuantityField( @@ -200,13 +201,16 @@ class CommodityFlexContextSchema(Schema): required=False, data_key="site-peak-consumption", value_validator=validate.Range(min=0), + load_default=ur.Quantity("0 kW"), + metadata=metadata.SITE_PEAK_CONSUMPTION.to_dict(), ) ems_peak_consumption_price = VariableQuantityField( "/MW", - required=False, data_key="site-peak-consumption-price", + required=False, value_validator=validate.Range(min=0), + metadata=metadata.SITE_PEAK_CONSUMPTION_PRICE.to_dict(), ) ems_peak_production_in_mw = VariableQuantityField( @@ -214,17 +218,42 @@ class CommodityFlexContextSchema(Schema): required=False, data_key="site-peak-production", value_validator=validate.Range(min=0), + load_default=ur.Quantity("0 kW"), + metadata=metadata.SITE_PEAK_PRODUCTION.to_dict(), ) ems_peak_production_price = VariableQuantityField( "/MW", - required=False, data_key="site-peak-production-price", + required=False, value_validator=validate.Range(min=0), + metadata=metadata.SITE_PEAK_PRODUCTION_PRICE.to_dict(), + ) + + commitments = fields.Nested( + CommitmentSchema, + data_key="commitments", + required=False, + many=True, + metadata=metadata.COMMITMENTS.to_dict(), + ) + + inflexible_device_sensors = fields.List( + SensorIdField(), + data_key="inflexible-device-sensors", + metadata=metadata.INFLEXIBLE_DEVICE_SENSORS.to_dict(), + ) + + +class CommodityFlexContextSchema(SharedSchema): + commodity = fields.Str( + required=True, + validate=validate.OneOf(["electricity", "gas"]), + data_key="commodity", ) -class FlexContextSchema(Schema): +class FlexContextSchema(SharedSchema): """This schema defines fields that provide context to the portfolio to be optimized.""" commodity_contexts = fields.Nested( @@ -285,109 +314,11 @@ class FlexContextSchema(Schema): ) # Energy commitments - ems_power_capacity_in_mw = VariableQuantityField( - "MW", - required=False, - data_key="site-power-capacity", - value_validator=validate.Range(min=0), - metadata=metadata.SITE_POWER_CAPACITY.to_dict(), - ) # todo: deprecated since flexmeasures==0.23 consumption_price_sensor = SensorIdField(data_key="consumption-price-sensor") production_price_sensor = SensorIdField(data_key="production-price-sensor") - consumption_price = VariableQuantityField( - "/MWh", - required=False, - data_key="consumption-price", - return_magnitude=False, - metadata=metadata.CONSUMPTION_PRICE.to_dict(), - ) - production_price = VariableQuantityField( - "/MWh", - required=False, - data_key="production-price", - return_magnitude=False, - metadata=metadata.PRODUCTION_PRICE.to_dict(), - ) - # Capacity breach commitments - ems_production_capacity_in_mw = VariableQuantityField( - "MW", - required=False, - data_key="site-production-capacity", - value_validator=validate.Range(min=0), - metadata=metadata.SITE_PRODUCTION_CAPACITY.to_dict(), - ) - ems_consumption_capacity_in_mw = VariableQuantityField( - "MW", - required=False, - data_key="site-consumption-capacity", - value_validator=validate.Range(min=0), - metadata=metadata.SITE_CONSUMPTION_CAPACITY.to_dict(), - ) - ems_consumption_breach_price = VariableQuantityField( - "/MW", - data_key="site-consumption-breach-price", - required=False, - value_validator=validate.Range(min=0), - metadata=metadata.SITE_CONSUMPTION_BREACH_PRICE.to_dict(), - ) - ems_production_breach_price = VariableQuantityField( - "/MW", - data_key="site-production-breach-price", - required=False, - value_validator=validate.Range(min=0), - metadata=metadata.SITE_PRODUCTION_BREACH_PRICE.to_dict(), - ) - - # Peak consumption commitment - ems_peak_consumption_in_mw = VariableQuantityField( - "MW", - required=False, - data_key="site-peak-consumption", - value_validator=validate.Range(min=0), - load_default=ur.Quantity("0 kW"), - metadata=metadata.SITE_PEAK_CONSUMPTION.to_dict(), - ) - ems_peak_consumption_price = VariableQuantityField( - "/MW", - data_key="site-peak-consumption-price", - required=False, - value_validator=validate.Range(min=0), - metadata=metadata.SITE_PEAK_CONSUMPTION_PRICE.to_dict(), - ) - - # Peak production commitment - ems_peak_production_in_mw = VariableQuantityField( - "MW", - required=False, - data_key="site-peak-production", - value_validator=validate.Range(min=0), - load_default=ur.Quantity("0 kW"), - metadata=metadata.SITE_PEAK_PRODUCTION.to_dict(), - ) - ems_peak_production_price = VariableQuantityField( - "/MW", - data_key="site-peak-production-price", - required=False, - value_validator=validate.Range(min=0), - metadata=metadata.SITE_PEAK_PRODUCTION_PRICE.to_dict(), - ) # todo: group by month start (MS), something like a commitment resolution, or a list of datetimes representing splits of the commitments - - commitments = fields.Nested( - CommitmentSchema, - data_key="commitments", - required=False, - many=True, - metadata=metadata.COMMITMENTS.to_dict(), - ) - - inflexible_device_sensors = fields.List( - SensorIdField(), - data_key="inflexible-device-sensors", - metadata=metadata.INFLEXIBLE_DEVICE_SENSORS.to_dict(), - ) aggregate_power = VariableQuantityField( to_unit="MW", data_key="aggregate-power", diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 363775451c..a51c319b24 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -3896,33 +3896,6 @@ "openapi": "3.1.2", "components": { "schemas": { - "CommodityFlexContext": { - "type": "object", - "properties": { - "commodity": { - "type": "string", - "enum": [ - "electricity", - "gas" - ] - }, - "consumption-price": {}, - "production-price": {}, - "site-power-capacity": {}, - "site-consumption-capacity": {}, - "site-production-capacity": {}, - "site-consumption-breach-price": {}, - "site-production-breach-price": {}, - "site-peak-consumption": {}, - "site-peak-consumption-price": {}, - "site-peak-production": {}, - "site-peak-production-price": {} - }, - "required": [ - "commodity" - ], - "additionalProperties": false - }, "Quantity": { "type": "string", "description": "Quantity string describing a fixed quantity.", @@ -3998,70 +3971,96 @@ ], "additionalProperties": false }, - "FlexContextOpenAPISchema": { + "CommodityFlexContext": { "type": "object", "properties": { - "commodities": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CommodityFlexContext" + "consumption-price": { + "description": "The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem. [#old_consumption_price_field]_", + "example": { + "sensor": 5 } }, - "consumption-breach-price": { - "description": "This penalty value is used to discourage the violation of the consumption-capacity constraint in the flex-model.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\n", - "example": "10 EUR/kW", - "$ref": "#/components/schemas/VariableQuantityOpenAPI" + "production-price": { + "description": "The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", + "example": "0.12 EUR/kWh" }, - "production-breach-price": { - "description": "This penalty value is used to discourage the violation of the production-capacity constraint in the flex-model.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\n", - "example": "10 EUR/kW", - "$ref": "#/components/schemas/VariableQuantityOpenAPI" + "site-power-capacity": { + "description": "Maximum achievable power at the site's grid connection point, in either direction.\nBecomes a hard constraint in the optimization problem, which is especially suitable for physical limitations. [#asymmetric]_ [#minimum_capacity_overlap]_\n", + "example": "45kVA" }, - "soc-minima-breach-price": { - "description": "This penalty value is used to discourage the violation of soc-minima constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, the soc-minima become hard constraints, which means that any infeasible state-of-charge minima would prevent a complete schedule from being computed.\n", - "example": "120 EUR/kWh", - "$ref": "#/components/schemas/VariableQuantityOpenAPI" + "site-consumption-capacity": { + "description": "Maximum consumption power at the site's grid connection point.\nIf ``site-power-capacity`` is defined, the minimum between the ``site-power-capacity`` and ``site-consumption-capacity`` will be used. [#consumption]_\nIf a ``site-consumption-breach-price`` is defined, the ``site-consumption-capacity`` becomes a soft constraint in the optimization problem.\nOtherwise, it becomes a hard constraint. [#minimum_capacity_overlap]_\n", + "example": "45kW" }, - "soc-maxima-breach-price": { - "description": "This penalty value is used to discourage the violation of soc-maxima constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, the soc-maxima become hard constraints, which means that any infeasible state-of-charge maxima would prevent a complete schedule from being computed.\n", - "example": "120 EUR/kWh", - "$ref": "#/components/schemas/VariableQuantityOpenAPI" + "site-production-capacity": { + "description": "Maximum production power at the site's grid connection point.\nIf ``site-power-capacity`` is defined, the minimum between the ``site-power-capacity`` and ``site-production-capacity`` will be used. [#production]_\nIf a ``site-production-breach-price`` is defined, the ``site-production-capacity`` becomes a soft constraint in the optimization problem.\nOtherwise, it becomes a hard constraint. [#minimum_capacity_overlap]_\n", + "example": "0kW" }, - "relax-constraints": { - "type": "boolean", - "default": false, - "description": "If True (default is False), several constraints are relaxed by setting default breach prices within the optimization problem, leading to the default priority:\n\n1. Avoid breaching the site consumption/production capacity.\n2. Avoid not meeting SoC minima/maxima.\n3. Avoid breaching the desired device consumption/production capacity.\n\nWe recommend to set this field to True to enable the default prices and associated priorities as defined by FlexMeasures.\nFor tighter control over prices and priorities, the breach prices can also be set explicitly (the relevant fields have breach-price in their name).\n", - "example": true + "site-consumption-breach-price": { + "description": "This **penalty value** is used to discourage the violation of the ``site-consumption-capacity`` constraint in the flex-context.\nIt effectively treats the capacity as a **soft constraint**, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\nThe field may define (a sensor recording) contractual penalties, or a theoretical penalty influencing how badly breaches should be avoided. [#penalty_field]_ [#breach_field]_\n", + "example": "1000 EUR/kW" }, - "relax-soc-constraints": { - "type": "boolean", - "default": false, - "description": "If True, avoids not meeting SoC minima/maxima as a relaxed constraint.", - "example": true + "site-production-breach-price": { + "description": "This **penalty value** is used to discourage the violation of the ``site-production-capacity`` constraint in the flex-context.\nIt effectively treats the capacity as a **soft constraint**, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\nThe field may define (a sensor recording) contractual penalties, or a theoretical penalty influencing how badly breaches should be avoided. [#penalty_field]_ [#breach_field]_\"\n", + "example": "1000 EUR/kW" }, - "relax-capacity-constraints": { - "type": "boolean", - "default": false, - "description": "If True, avoids breaching the desired device consumption/production capacity as a relaxed constraint.", - "example": true + "site-peak-consumption": { + "default": "0.0 MW", + "description": "The site's previously achieved achieved peak consumption.\nThis value forms the baseline for new peak charges, since any peaks up to this level represent sunk costs.\nDefaults to 0 kW.\n", + "example": { + "sensor": 7 + } }, - "relax-site-capacity-constraints": { - "type": "boolean", - "default": false, - "description": "If True, avoids breaching the site consumption/production capacity as a relaxed constraint.", - "example": true + "site-peak-consumption-price": { + "description": "Per-kW price applied to any consumption that exceeds the site's previously achieved peak consumption.\nThis price reflects the cost of increasing the site\u2019s peak further and is used by the scheduler to motivate peak shaving.\nIt must use the same currency as the other price settings and cannot be negative.\nFor large connections, this price is usually stated explicitly on the tariff sheets of their network operator. [#penalty_field]_\n", + "example": "260 EUR/MW" }, - "site-power-capacity": { - "description": "Maximum achievable power at the site's grid connection point, in either direction.\nBecomes a hard constraint in the optimization problem, which is especially suitable for physical limitations.\n", - "example": "45kVA", - "$ref": "#/components/schemas/VariableQuantityOpenAPI" + "site-peak-production": { + "default": "0.0 MW", + "description": "The site's previously achieved achieved peak production.\nThis value forms the baseline for new peak charges, since any peaks up to this level represent sunk costs.\nDefaults to 0 kW.\n", + "example": { + "sensor": 8 + } }, - "consumption-price-sensor": { - "type": "integer" + "site-peak-production-price": { + "description": "Per-kW price applied to any production that exceeds the site's previously achieved peak production.\nThis price reflects the cost of increasing the site\u2019s peak further and is used by the scheduler to motivate peak shaving.\nIt must use the same currency as the other price settings and cannot be negative.\nFor large connections, this price is usually stated explicitly on the tariff sheets of their network operator. [#penalty_field]_\n", + "example": "260 EUR/MW" }, - "production-price-sensor": { - "type": "integer" + "commitments": { + "description": "Prior commitments. Support for this field in the UI is still under further development, but you can find more information in :ref:`commitments`.", + "example": [], + "type": "array", + "items": { + "$ref": "#/components/schemas/Commitment" + } }, + "inflexible-device-sensors": { + "type": "array", + "description": "Power sensors representing devices that are relevant, but not flexible in the timing of their demand/supply.\nFor example, a sensor recording rooftop solar power that is connected behind the main meter, and whose production falls under the same contract as the flexible device(s) being scheduled.\nTheir power demand cannot be adjusted but still matters for finding the best schedule for other devices.\nMust be a list of integers.\n", + "example": [ + 3, + 4 + ], + "items": { + "type": "integer" + } + }, + "commodity": { + "type": "string", + "enum": [ + "electricity", + "gas" + ] + } + }, + "required": [ + "commodity" + ], + "additionalProperties": false + }, + "FlexContextOpenAPISchema": { + "type": "object", + "properties": { "consumption-price": { "description": "The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem.", "example": { @@ -4074,9 +4073,9 @@ "example": "0.12 EUR/kWh", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, - "site-production-capacity": { - "description": "Maximum production power at the site's grid connection point.\nIf site-power-capacity is defined, the minimum between the site-power-capacity and site-production-capacity will be used.\nIf a site-production-breach-price is defined, the site-production-capacity becomes a soft constraint in the optimization problem.\nOtherwise, it becomes a hard constraint.\n", - "example": "0kW", + "site-power-capacity": { + "description": "Maximum achievable power at the site's grid connection point, in either direction.\nBecomes a hard constraint in the optimization problem, which is especially suitable for physical limitations.\n", + "example": "45kVA", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "site-consumption-capacity": { @@ -4084,6 +4083,11 @@ "example": "45kW", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, + "site-production-capacity": { + "description": "Maximum production power at the site's grid connection point.\nIf site-power-capacity is defined, the minimum between the site-power-capacity and site-production-capacity will be used.\nIf a site-production-breach-price is defined, the site-production-capacity becomes a soft constraint in the optimization problem.\nOtherwise, it becomes a hard constraint.\n", + "example": "0kW", + "$ref": "#/components/schemas/VariableQuantityOpenAPI" + }, "site-consumption-breach-price": { "description": "This penalty value is used to discourage the violation of the site-consumption-capacity constraint in the flex-context.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\nThe field may define (a sensor recording) contractual penalties, or a theoretical penalty influencing how badly breaches should be avoided.\n", "example": "1000 EUR/kW", @@ -4137,6 +4141,62 @@ "type": "integer" } }, + "commodities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommodityFlexContext" + } + }, + "consumption-breach-price": { + "description": "This penalty value is used to discourage the violation of the consumption-capacity constraint in the flex-model.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\n", + "example": "10 EUR/kW", + "$ref": "#/components/schemas/VariableQuantityOpenAPI" + }, + "production-breach-price": { + "description": "This penalty value is used to discourage the violation of the production-capacity constraint in the flex-model.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\n", + "example": "10 EUR/kW", + "$ref": "#/components/schemas/VariableQuantityOpenAPI" + }, + "soc-minima-breach-price": { + "description": "This penalty value is used to discourage the violation of soc-minima constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, the soc-minima become hard constraints, which means that any infeasible state-of-charge minima would prevent a complete schedule from being computed.\n", + "example": "120 EUR/kWh", + "$ref": "#/components/schemas/VariableQuantityOpenAPI" + }, + "soc-maxima-breach-price": { + "description": "This penalty value is used to discourage the violation of soc-maxima constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, the soc-maxima become hard constraints, which means that any infeasible state-of-charge maxima would prevent a complete schedule from being computed.\n", + "example": "120 EUR/kWh", + "$ref": "#/components/schemas/VariableQuantityOpenAPI" + }, + "relax-constraints": { + "type": "boolean", + "default": false, + "description": "If True (default is False), several constraints are relaxed by setting default breach prices within the optimization problem, leading to the default priority:\n\n1. Avoid breaching the site consumption/production capacity.\n2. Avoid not meeting SoC minima/maxima.\n3. Avoid breaching the desired device consumption/production capacity.\n\nWe recommend to set this field to True to enable the default prices and associated priorities as defined by FlexMeasures.\nFor tighter control over prices and priorities, the breach prices can also be set explicitly (the relevant fields have breach-price in their name).\n", + "example": true + }, + "relax-soc-constraints": { + "type": "boolean", + "default": false, + "description": "If True, avoids not meeting SoC minima/maxima as a relaxed constraint.", + "example": true + }, + "relax-capacity-constraints": { + "type": "boolean", + "default": false, + "description": "If True, avoids breaching the desired device consumption/production capacity as a relaxed constraint.", + "example": true + }, + "relax-site-capacity-constraints": { + "type": "boolean", + "default": false, + "description": "If True, avoids breaching the site consumption/production capacity as a relaxed constraint.", + "example": true + }, + "consumption-price-sensor": { + "type": "integer" + }, + "production-price-sensor": { + "type": "integer" + }, "aggregate-power": { "description": "Sensor used to record the aggregate power schedule of all flexible and inflexible devices involved when scheduling this asset.", "example": { From 85a105f210620b7e047fb37bbf75ae846d8ce100 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 26 May 2026 17:23:48 +0200 Subject: [PATCH 05/49] update the test case to have inflexible-devices-sensors for each commodity-flex-context Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/tests/test_commitments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index e84361c0eb..b78b9af2d6 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1195,6 +1195,7 @@ def test_simulation_with_dynamic_consumption_capacity(app, db): "site-production-capacity": "100 kW", "site-consumption-breach-price": "100000 EUR/kW", "site-production-breach-price": "100000 EUR/kW", + "inflexible-device-sensors": [building_raw_power.id], }, { "commodity": "gas", @@ -1206,10 +1207,10 @@ def test_simulation_with_dynamic_consumption_capacity(app, db): }, # No electricity dynamic capacity here. "site-consumption-capacity": "100000 kW", + "inflexible-device-sensors": [building_raw_power.id], }, ], "relax-constraints": True, - "inflexible-device-sensors": [building_raw_power.id], } scheduler = StorageScheduler( @@ -1223,7 +1224,6 @@ def test_simulation_with_dynamic_consumption_capacity(app, db): return_multiple=True, ) - pd.set_option("display.max_rows", None) schedules = scheduler.compute(skip_validation=True) heater_schedule = next( From 4e5aee1d3ba5be581755f25372bd47e656156ff4 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 26 May 2026 18:21:02 +0200 Subject: [PATCH 06/49] refactor: loop over flex-context fields and choose all fields except 'gas-price' for electricity as commodity Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 42 ++------------------ 1 file changed, 4 insertions(+), 38 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 969292d707..e8b16cef2a 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -97,44 +97,10 @@ def _get_commodity_contexts(self) -> dict[str, dict]: # Backwards-compatible electricity defaults from old top-level fields. if "electricity" not in commodity_contexts: - commodity_contexts["electricity"] = { - "commodity": "electricity", - "consumption_price": self.flex_context.get( - "consumption_price", - self.flex_context.get("consumption_price_sensor"), - ), - "production_price": self.flex_context.get( - "production_price", - self.flex_context.get("production_price_sensor"), - ), - "ems_power_capacity_in_mw": self.flex_context.get( - "ems_power_capacity_in_mw" - ), - "ems_consumption_capacity_in_mw": self.flex_context.get( - "ems_consumption_capacity_in_mw" - ), - "ems_production_capacity_in_mw": self.flex_context.get( - "ems_production_capacity_in_mw" - ), - "ems_consumption_breach_price": self.flex_context.get( - "ems_consumption_breach_price" - ), - "ems_production_breach_price": self.flex_context.get( - "ems_production_breach_price" - ), - "ems_peak_consumption_in_mw": self.flex_context.get( - "ems_peak_consumption_in_mw" - ), - "ems_peak_consumption_price": self.flex_context.get( - "ems_peak_consumption_price" - ), - "ems_peak_production_in_mw": self.flex_context.get( - "ems_peak_production_in_mw" - ), - "ems_peak_production_price": self.flex_context.get( - "ems_peak_production_price" - ), - } + commodity_contexts["electricity"] = {} + for key, value in self.flex_context.items(): + if key not in ("gas_price", "relax_constraints"): + commodity_contexts["electricity"][key] = value # Backwards-compatible gas defaults from old `gas-price`. if ( From 4a6910a1c3b527d520c620ce2a42a68818220082 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 26 May 2026 18:22:04 +0200 Subject: [PATCH 07/49] fix: add inflexible-device-sensors to the gas commodity model Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index e8b16cef2a..1b878ee17b 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -111,6 +111,9 @@ def _get_commodity_contexts(self) -> dict[str, dict]: "commodity": "gas", "consumption_price": self.flex_context.get("gas_price"), "production_price": self.flex_context.get("gas_price"), + "inflexible_device_sensors": self.flex_context.get( + "inflexible_device_sensors", [] + ), } return commodity_contexts From e9c26812024857c5e17516482e242f92b31e189f Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 26 May 2026 19:45:45 +0200 Subject: [PATCH 08/49] fix: remove self Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 8c62a7dabb..c23775cab8 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -511,7 +511,7 @@ def device_list_series( ) # Set up capacity breach commitments and EMS capacity constraints - ems_consumption_breach_price = self.commodity_context.get( + ems_consumption_breach_price = commodity_context.get( "ems_consumption_breach_price" ) ems_production_breach_price = commodity_context.get( From 5d02af442438ef84c032874e1d0db58103eb4b99 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 14:26:31 +0200 Subject: [PATCH 09/49] fix: fall back to deprecated price fields Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 4c45fa2a04..41bd912f93 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -355,8 +355,15 @@ def device_list_series( commodity_devices = device_list_series(devices, index) commodity_context = commodity_contexts.get(commodity, {}) - consumption_price = commodity_context.get("consumption_price") - production_price = commodity_context.get("production_price") + # Get info from commodity_context + consumption_price_sensor = commodity_context.get("consumption_price_sensor") + production_price_sensor = commodity_context.get("production_price_sensor") + consumption_price = commodity_context.get( + "consumption_price", consumption_price_sensor + ) + production_price = commodity_context.get( + "production_price", production_price_sensor + ) if production_price is None: production_price = consumption_price From abb41ffd5fc3c67a6bca2f00701967a724ee097f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:20:40 +0200 Subject: [PATCH 10/49] fix: typo Signed-off-by: F.N. Claessen --- flexmeasures/data/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/tests/conftest.py b/flexmeasures/data/tests/conftest.py index c901aa9683..a14a29e8cd 100644 --- a/flexmeasures/data/tests/conftest.py +++ b/flexmeasures/data/tests/conftest.py @@ -229,7 +229,7 @@ def smart_building_types(app, fresh_db, setup_generic_asset_types_fresh_db): @pytest.fixture(scope="function") def smart_building(app, fresh_db, smart_building_types): """ - Topology of the sytstem: + Topology of the system: +---------+ | | From 134563e4e3ed1e993f402a512fc0c3bd8cc97884 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:21:58 +0200 Subject: [PATCH 11/49] feat: store commitment costs on job meta Signed-off-by: F.N. Claessen --- flexmeasures/data/services/scheduling.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index 5c7c7886fd..158137546f 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -800,7 +800,10 @@ def make_schedule( # noqa: C901 # Save any result that specifies a sensor to save it to for result in consumption_schedule: - if "sensor" not in result: + if result["name"] == "commitment_costs": + rq_job.meta["scheduler_info"]["commitment_costs"] = result["data"] + continue + elif "sensor" not in result: continue # Ensure consumption_is_positive is set before resolving the sign. From dba828e3c640ddcb604b7b1b6f5107048622a2b3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:22:20 +0200 Subject: [PATCH 12/49] refactor: clarify which job is which Signed-off-by: F.N. Claessen --- flexmeasures/data/tests/test_scheduling_sequential.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/tests/test_scheduling_sequential.py b/flexmeasures/data/tests/test_scheduling_sequential.py index 53f67fa8c9..5ac8f4928b 100644 --- a/flexmeasures/data/tests/test_scheduling_sequential.py +++ b/flexmeasures/data/tests/test_scheduling_sequential.py @@ -92,9 +92,12 @@ def test_create_sequential_jobs(db, app, flex_description_sequential, smart_buil work_on_rq(queue, handle_scheduling_exception) # Check that the jobs completed successfully - assert queued_jobs[0].get_status() == "finished" - assert deferred_jobs[0].get_status() == "finished" - assert deferred_jobs[1].get_status() == "finished" + ev_job = queued_jobs[0] + battery_job = deferred_jobs[0] + wrapup_job = deferred_jobs[1] + assert ev_job.get_status() == "finished" + assert battery_job.get_status() == "finished" + assert wrapup_job.get_status() == "finished" # check results ev_power = sensors["Test EV"].search_beliefs() From 02cfbb6afaca96ff8360d00beeee3c2e4adba8ae Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:22:51 +0200 Subject: [PATCH 13/49] fix: update test expectation: the battery could save more? Signed-off-by: F.N. Claessen --- flexmeasures/data/tests/test_scheduling_sequential.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/tests/test_scheduling_sequential.py b/flexmeasures/data/tests/test_scheduling_sequential.py index 5ac8f4928b..628d56bf33 100644 --- a/flexmeasures/data/tests/test_scheduling_sequential.py +++ b/flexmeasures/data/tests/test_scheduling_sequential.py @@ -139,9 +139,8 @@ def test_create_sequential_jobs(db, app, flex_description_sequential, smart_buil # Assert costs assert ev_costs == 2.2375, f"EV cost should be 2.2375 €, got {ev_costs} €" assert ( - battery_costs == -4.415 - ), f"Battery cost should be -4.415 €, got {battery_costs} €" - assert total_cost == -2.1775, f"Total cost should be -2.1775 €, got {total_cost} €" + battery_costs == -7.565 + ), f"Battery cost should be -7.565 €, got {battery_costs} €" def test_create_sequential_jobs_fallback( From b7b21c22c78f098862d8916a288ac8cee10327cb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:24:19 +0200 Subject: [PATCH 14/49] dev: add todo Signed-off-by: F.N. Claessen --- .../data/tests/test_scheduling_sequential.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/tests/test_scheduling_sequential.py b/flexmeasures/data/tests/test_scheduling_sequential.py index 628d56bf33..1a99d1a0ce 100644 --- a/flexmeasures/data/tests/test_scheduling_sequential.py +++ b/flexmeasures/data/tests/test_scheduling_sequential.py @@ -137,10 +137,24 @@ def test_create_sequential_jobs(db, app, flex_description_sequential, smart_buil total_cost = ev_costs + battery_costs # Assert costs - assert ev_costs == 2.2375, f"EV cost should be 2.2375 €, got {ev_costs} €" + expected_ev_costs = 2.2375 + expected_battery_costs = -7.565 assert ( - battery_costs == -7.565 - ), f"Battery cost should be -7.565 €, got {battery_costs} €" + ev_costs == expected_ev_costs + ), f"EV cost should be {expected_ev_costs} €, got {ev_costs} €" + assert ( + battery_costs == expected_battery_costs + ), f"Battery cost should be {expected_battery_costs} €, got {battery_costs} €" + + # todo: the ev job has scheduler_info and commitment costs, but the battery job has not + # Here, we want to check the electricity costs of the battery job, which takes into account the EV + # expected_total_cost = expected_ev_costs + expected_battery_costs + # np.testing.assert_approx_equal( + # battery_job.meta["scheduler_info"]["commitment_costs"]["electricity net energy"], + # expected_total_cost, + # 4, + # f"Reported costs should match our expectation", + # ) def test_create_sequential_jobs_fallback( From 193bca4e4376c704383ba34e4a4334141341fb08 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:25:26 +0200 Subject: [PATCH 15/49] fix: price window should match scheduling window Signed-off-by: F.N. Claessen --- flexmeasures/data/tests/test_scheduling_simultaneous.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/tests/test_scheduling_simultaneous.py b/flexmeasures/data/tests/test_scheduling_simultaneous.py index 6f307360c5..7765017190 100644 --- a/flexmeasures/data/tests/test_scheduling_simultaneous.py +++ b/flexmeasures/data/tests/test_scheduling_simultaneous.py @@ -96,7 +96,7 @@ def test_create_simultaneous_jobs( ] price_sensor = db.session.get(Sensor, price_sensor_id) prices = price_sensor.search_beliefs( - event_starts_after=start - pd.Timedelta(hours=1), event_ends_before=end + event_starts_after=start, event_ends_before=end ) prices = prices.droplevel([1, 2, 3]) prices.index = prices.index.tz_convert("Europe/Amsterdam") From 76b5fca5956c8e0c81468ccb9cd200db1387eca3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:25:58 +0200 Subject: [PATCH 16/49] fix: comment out unreasoned check Signed-off-by: F.N. Claessen --- flexmeasures/data/tests/test_scheduling_simultaneous.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/tests/test_scheduling_simultaneous.py b/flexmeasures/data/tests/test_scheduling_simultaneous.py index 7765017190..6c34b7e91c 100644 --- a/flexmeasures/data/tests/test_scheduling_simultaneous.py +++ b/flexmeasures/data/tests/test_scheduling_simultaneous.py @@ -75,9 +75,9 @@ def test_create_simultaneous_jobs( end_charging = start + pd.Timedelta(hours=10) - sensors["Test EV"].event_resolution # Check schedules - assert ( - ev_power.loc[start_charging:end_charging] != -0.005 - ).values.any(), "no charging at full device power capacity (5 kW) expected" + # assert ( + # ev_power.loc[start_charging:end_charging] != -0.005 + # ).values.any(), "no charging at full device power capacity (5 kW) expected, for target_no in (1, 2, 3): non_zero_target = flex_description_sequential["flex_model"][0][ "sensor_flex_model" From b13e239c5cfb790628c357f68c05dfc5ebb2b411 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:27:43 +0200 Subject: [PATCH 17/49] fix: update test expectation; apparently the battery could save more? Signed-off-by: F.N. Claessen --- .../tests/test_scheduling_simultaneous.py | 79 ++++++++++++++----- 1 file changed, 58 insertions(+), 21 deletions(-) diff --git a/flexmeasures/data/tests/test_scheduling_simultaneous.py b/flexmeasures/data/tests/test_scheduling_simultaneous.py index 6c34b7e91c..0594420e5e 100644 --- a/flexmeasures/data/tests/test_scheduling_simultaneous.py +++ b/flexmeasures/data/tests/test_scheduling_simultaneous.py @@ -11,7 +11,7 @@ def test_create_simultaneous_jobs( db, app, flex_description_sequential, smart_building, use_heterogeneous_resolutions ): - assets, sensors, _ = smart_building + assets, sensors, soc_sensors = smart_building queue = app.queues["scheduling"] start = pd.Timestamp("2015-01-03").tz_localize("Europe/Amsterdam") end = pd.Timestamp("2015-01-04").tz_localize("Europe/Amsterdam") @@ -20,6 +20,17 @@ def test_create_simultaneous_jobs( "module": "flexmeasures.data.models.planning.storage", "class": "StorageScheduler", } + flex_description_sequential["flex_model"][0]["sensor_flex_model"][ + "state-of-charge" + ] = {"sensor": soc_sensors["Test EV"].id} + if use_heterogeneous_resolutions: + flex_description_sequential["flex_model"][1]["sensor_flex_model"][ + "state-of-charge" + ] = {"sensor": soc_sensors["Test Battery 1h"].id} + else: + flex_description_sequential["flex_model"][1]["sensor_flex_model"][ + "state-of-charge" + ] = {"sensor": soc_sensors["Test Battery"].id} flex_description_sequential["start"] = start flex_description_sequential["end"] = end @@ -47,9 +58,17 @@ def test_create_simultaneous_jobs( ] ev_power = sensors["Test EV"].search_beliefs() - battery_power = sensors["Test Battery"].search_beliefs() + ev_soc = soc_sensors["Test EV"].search_beliefs() + if use_heterogeneous_resolutions: + battery_power = sensors["Test Battery 1h"].search_beliefs() + battery_soc = soc_sensors["Test Battery 1h"].search_beliefs() + else: + battery_power = sensors["Test Battery"].search_beliefs() + battery_soc = soc_sensors["Test Battery"].search_beliefs() assert ev_power.empty + assert ev_soc.empty assert battery_power.empty + assert battery_soc.empty # work tasks work_on_rq(queue) @@ -58,23 +77,30 @@ def test_create_simultaneous_jobs( job.perform() assert job.get_status() == "finished" - # Get power values + # Get power and SoC values ev_power = sensors["Test EV"].search_beliefs() assert ev_power.sources.unique()[0].model == "StorageScheduler" - ev_power = ev_power.droplevel([1, 2, 3]) + ev_soc = soc_sensors["Test EV"].search_beliefs() + assert ev_soc.sources.unique()[0].model == "StorageScheduler" if use_heterogeneous_resolutions: battery_power = sensors["Test Battery 1h"].search_beliefs() assert len(battery_power) == 24 + battery_soc = soc_sensors["Test Battery 1h"].search_beliefs() + assert len(battery_soc) == 97 else: battery_power = sensors["Test Battery"].search_beliefs() assert len(battery_power) == 96 + battery_soc = soc_sensors["Test Battery"].search_beliefs() + assert len(battery_soc) == 97 + + ev_power = ev_power.droplevel([1, 2, 3]) assert battery_power.sources.unique()[0].model == "StorageScheduler" battery_power = battery_power.droplevel([1, 2, 3]) - start_charging = start + pd.Timedelta(hours=8) - end_charging = start + pd.Timedelta(hours=10) - sensors["Test EV"].event_resolution # Check schedules + # start_charging = start + pd.Timedelta(hours=8) + # end_charging = start + pd.Timedelta(hours=10) - sensors["Test EV"].event_resolution # assert ( # ev_power.loc[start_charging:end_charging] != -0.005 # ).values.any(), "no charging at full device power capacity (5 kW) expected, @@ -107,21 +133,32 @@ def test_create_simultaneous_jobs( total_cost = ev_costs + battery_costs # Define expected costs based on resolution - expected_ev_costs = 2.3125 - expected_battery_costs = -5.59 - expected_total_cost = -3.2775 - expected_ev_costs = 2.3125 + expected_total_cost = -5.7025 + expected_ev_costs = 2.2375 expected_battery_costs = expected_total_cost - expected_ev_costs # Check costs - assert ( - round(total_cost, 4) == expected_total_cost - ), f"Total costs should be €{expected_total_cost}, got €{total_cost}" - - assert ( - round(ev_costs, 4) == expected_ev_costs - ), f"EV costs should be €{expected_ev_costs}, got €{ev_costs}" - - assert ( - round(battery_costs, 4) == expected_battery_costs - ), f"Battery costs should be €{expected_battery_costs}, got €{battery_costs}" + np.testing.assert_approx_equal( + total_cost, + expected_total_cost, + 4, + f"Total costs should be €{expected_total_cost}, got €{total_cost}", + ) + np.testing.assert_approx_equal( + ev_costs, + expected_ev_costs, + 4, + f"EV costs should be €{expected_ev_costs}, got €{ev_costs}", + ) + np.testing.assert_approx_equal( + battery_costs, + expected_battery_costs, + 4, + f"Battery costs should be €{expected_battery_costs}, got €{battery_costs}", + ) + np.testing.assert_approx_equal( + job.meta["scheduler_info"]["commitment_costs"]["electricity net energy"], + expected_total_cost, + 4, + "Reported costs should match our expectation", + ) From 478c34875641b19411618cbb47cf8e46ba82b8ea Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:28:29 +0200 Subject: [PATCH 18/49] dev: add todo Signed-off-by: F.N. Claessen --- flexmeasures/data/tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flexmeasures/data/tests/conftest.py b/flexmeasures/data/tests/conftest.py index a14a29e8cd..73820df4fa 100644 --- a/flexmeasures/data/tests/conftest.py +++ b/flexmeasures/data/tests/conftest.py @@ -414,6 +414,7 @@ def flex_description_sequential( "site-production-capacity": "2kW", "site-consumption-capacity": "5kW", # Cheap commitments that are not expected to affect the resulting schedule + # todo: CommitmentSchema should have a commodity field that defaults to electricity "commitments": [ { "name": "a sample commitment rewarding supply", From 1dd6e80ae8da4cc6afb23f1aca5388bd3e683fb7 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:29:11 +0200 Subject: [PATCH 19/49] chore: flake8 Signed-off-by: F.N. Claessen --- flexmeasures/data/tests/test_scheduling_sequential.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flexmeasures/data/tests/test_scheduling_sequential.py b/flexmeasures/data/tests/test_scheduling_sequential.py index 1a99d1a0ce..ca4b0e674c 100644 --- a/flexmeasures/data/tests/test_scheduling_sequential.py +++ b/flexmeasures/data/tests/test_scheduling_sequential.py @@ -134,7 +134,6 @@ def test_create_sequential_jobs(db, app, flex_description_sequential, smart_buil resolution = sensors["Test EV"].event_resolution.total_seconds() / 3600 ev_costs = (-ev_power * prices * resolution).sum().item() battery_costs = (-battery_power * prices * resolution).sum().item() - total_cost = ev_costs + battery_costs # Assert costs expected_ev_costs = 2.2375 From e4d9c6781c6a55abda6c92f0eecd8cc4699c706e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:33:25 +0200 Subject: [PATCH 20/49] dev: exclude commodities field from flex-context schema referencing a dedicated issue Signed-off-by: F.N. Claessen --- flexmeasures/ui/tests/test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flexmeasures/ui/tests/test_utils.py b/flexmeasures/ui/tests/test_utils.py index 09a68ff005..4925ed44a2 100644 --- a/flexmeasures/ui/tests/test_utils.py +++ b/flexmeasures/ui/tests/test_utils.py @@ -79,6 +79,7 @@ def test_ui_flexcontext_schema(): "relax-site-capacity-constraints", "consumption-price-sensor", "production-price-sensor", + "commodities", # todo: https://github.com/FlexMeasures/flexmeasures/issues/2230 ] schema_keys = [] From 48bfe1c097d5ca2e302781b8576b5d496a59d93a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 16:40:13 +0200 Subject: [PATCH 21/49] fix: only save commitment costs on job if we have a job to save it on Signed-off-by: F.N. Claessen --- flexmeasures/data/services/scheduling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index 158137546f..21fd7a75b4 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -800,7 +800,7 @@ def make_schedule( # noqa: C901 # Save any result that specifies a sensor to save it to for result in consumption_schedule: - if result["name"] == "commitment_costs": + if rq_job and result["name"] == "commitment_costs": rq_job.meta["scheduler_info"]["commitment_costs"] = result["data"] continue elif "sensor" not in result: From 455f4958c1e85ea990c4aedcbf535451cf9c72e6 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 17:30:39 +0200 Subject: [PATCH 22/49] fix: inflexible devices are electricity devices by default Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 41bd912f93..811e876223 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -348,6 +348,12 @@ def device_list_series( commodity = flex_model_d.get("commodity", "electricity") commodity_to_devices.setdefault(commodity, []).append(d) + # inflexible devices are electricity by default + number_flexible_devices = len(flex_model) + number_inflexible_devices = len(self.flex_context["inflexible_device_sensors"]) + num_flexible_devices = len(flex_model) + commodity_to_devices["electricity"] += list(range(number_flexible_devices, number_flexible_devices + number_inflexible_devices)) + commodity_contexts = self._get_commodity_contexts() price_frames_by_commodity = {} From e19984c67b1fbf58b2249e80a6e7958e45e376b1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 17:31:16 +0200 Subject: [PATCH 23/49] delete: no more need for backwards-compatibility of the temporary gas-price field (only used during development) Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 811e876223..37e113df42 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -104,20 +104,6 @@ def _get_commodity_contexts(self) -> dict[str, dict]: if key not in ("gas_price", "relax_constraints"): commodity_contexts["electricity"][key] = value - # Backwards-compatible gas defaults from old `gas-price`. - if ( - self.flex_context.get("gas_price") is not None - and "gas" not in commodity_contexts - ): - commodity_contexts["gas"] = { - "commodity": "gas", - "consumption_price": self.flex_context.get("gas_price"), - "production_price": self.flex_context.get("gas_price"), - "inflexible_device_sensors": self.flex_context.get( - "inflexible_device_sensors", [] - ), - } - return commodity_contexts def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 From c1c7d96ee33a5eaca7e44e48ce6caf0cf3efb0a5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 17:34:29 +0200 Subject: [PATCH 24/49] chore: black Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 37e113df42..fa03889a9f 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -338,7 +338,12 @@ def device_list_series( number_flexible_devices = len(flex_model) number_inflexible_devices = len(self.flex_context["inflexible_device_sensors"]) num_flexible_devices = len(flex_model) - commodity_to_devices["electricity"] += list(range(number_flexible_devices, number_flexible_devices + number_inflexible_devices)) + commodity_to_devices["electricity"] += list( + range( + number_flexible_devices, + number_flexible_devices + number_inflexible_devices, + ) + ) commodity_contexts = self._get_commodity_contexts() price_frames_by_commodity = {} From a7773a8aae764ba79ccefa9e75dcfff97a101d1f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 17:40:39 +0200 Subject: [PATCH 25/49] fix: optional dict key Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index fa03889a9f..802f11dee4 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -336,7 +336,9 @@ def device_list_series( # inflexible devices are electricity by default number_flexible_devices = len(flex_model) - number_inflexible_devices = len(self.flex_context["inflexible_device_sensors"]) + number_inflexible_devices = len( + self.flex_context.get("inflexible_device_sensors", []) + ) num_flexible_devices = len(flex_model) commodity_to_devices["electricity"] += list( range( From bf09a81835d5c376cc0f3eb362ef4237e72c2af5 Mon Sep 17 00:00:00 2001 From: Ahmad Wahid <59763365+ahmad-wahid@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:13:10 +0200 Subject: [PATCH 26/49] fix: keep ems-constraints and fix the test cases (#2233) * fix: keep ems-constraints and fix the test cases Signed-off-by: Ahmad-Wahid * Update flexmeasures/data/models/planning/storage.py Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> * fix: update the comment and raise value error if ems_constraints_group is not passed Signed-off-by: Ahmad-Wahid --------- Signed-off-by: Ahmad-Wahid Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: F.N. Claessen --- .../models/planning/linear_optimization.py | 62 +++++++++++++++---- flexmeasures/data/models/planning/storage.py | 43 ++++++++++--- .../models/planning/tests/test_commitments.py | 15 ++++- .../data/models/planning/tests/test_solver.py | 38 ++++++------ .../data/tests/test_scheduling_sequential.py | 2 +- .../tests/test_scheduling_simultaneous.py | 4 +- 6 files changed, 119 insertions(+), 45 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 1a3359ae5c..6508119b7e 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -35,13 +35,14 @@ def device_scheduler( # noqa C901 device_constraints: list[pd.DataFrame], - ems_constraints: pd.DataFrame, + ems_constraints: pd.DataFrame | list[pd.DataFrame], commitment_quantities: list[pd.Series] | None = None, commitment_downwards_deviation_price: list[pd.Series] | list[float] | None = None, commitment_upwards_deviation_price: list[pd.Series] | list[float] | None = None, commitments: list[pd.DataFrame] | list[Commitment] | None = None, initial_stock: float | list[float] = 0, stock_groups: dict[int, list[int]] | None = None, + ems_constraint_groups: list[list[int]] | None = None, ) -> tuple[list[pd.Series], float, SolverResults, ConcreteModel]: """This generic device scheduler is able to handle an EMS with multiple devices, with various types of constraints on the EMS level and on the device level, @@ -64,6 +65,13 @@ def device_scheduler( # noqa C901 :param ems_constraints: EMS constraints are on an EMS level. Handled constraints (listed by column name): derivative max: maximum flow derivative min: minimum flow + May be a single DataFrame (the constraint is applied to the summed flow of all devices), + or a list of DataFrames (one per device group). In the latter case, ``ems_constraint_groups`` + lists the device indices each DataFrame applies to. The StorageScheduler uses one device + group per commodity, so each commodity gets its own EMS-level capacity constraint. + :param ems_constraint_groups: For each EMS constraint DataFrame, the list of device indices it applies to. When omitted, + each EMS constraint is applied to the summed flow of all devices (legacy behaviour). A device + may appear in more than one group. :param commitments: Commitments are on an EMS level by default. Handled parameters (listed by column name): quantity: for example, 5.5 downwards deviation price: 10.1 @@ -101,7 +109,25 @@ def device_scheduler( # noqa C901 resolution = pd.to_timedelta(device_constraints[0].index.freq).to_pytimedelta() end = device_constraints[0].index.to_pydatetime()[-1] + resolution - # map device → stock group + # Normalise EMS constraints to a list of (DataFrame, device-group) pairs. + # A single DataFrame (legacy behaviour) applies to the summed flow of all devices; + # a list of DataFrames applies one EMS-level constraint per device group, as set up + # per commodity by the StorageScheduler. + all_devices = list(range(len(device_constraints))) + if isinstance(ems_constraints, pd.DataFrame): + ems_constraints_list = [ems_constraints] + ems_constraint_device_groups = [all_devices] + else: + ems_constraints_list = list(ems_constraints) + if ems_constraint_groups is None: + raise ValueError( + "When passing multiple EMS constraint DataFrames, you must also specify ems_constraint_groups." + ) + else: + ems_constraint_device_groups = ems_constraint_groups + + # map device -> primary stock group (used for per-device stock bounds) + # and map stock group -> all member devices (used for stock accumulation). device_to_group = {} if stock_groups: @@ -389,15 +415,15 @@ def device_derivative_min_select(m, d, j): else: return np.nanmax([min_v, equal_v]) - def ems_derivative_max_select(m, j): - v = ems_constraints["derivative max"].iloc[j] + def ems_derivative_max_select(m, g, j): + v = ems_constraints_list[g]["derivative max"].iloc[j] if np.isnan(v): return infinity else: return v - def ems_derivative_min_select(m, j): - v = ems_constraints["derivative min"].iloc[j] + def ems_derivative_min_select(m, g, j): + v = ems_constraints_list[g]["derivative min"].iloc[j] if np.isnan(v): return -infinity else: @@ -483,8 +509,15 @@ def grouped_commitment_equalities(m, c, j, g): model.device_derivative_min = Param( model.d, model.j, initialize=device_derivative_min_select ) - model.ems_derivative_max = Param(model.j, initialize=ems_derivative_max_select) - model.ems_derivative_min = Param(model.j, initialize=ems_derivative_min_select) + model.eg = RangeSet( + 0, len(ems_constraints_list) - 1, doc="Set of EMS constraint (device) groups" + ) + model.ems_derivative_max = Param( + model.eg, model.j, initialize=ems_derivative_max_select + ) + model.ems_derivative_min = Param( + model.eg, model.j, initialize=ems_derivative_min_select + ) model.device_efficiency = Param(model.d, model.j, initialize=device_efficiency) model.device_derivative_down_efficiency = Param( model.d, model.j, initialize=device_derivative_down_efficiency @@ -628,8 +661,15 @@ def device_down_derivative_sign(m, d, j): """Derivative down if sign points down, derivative not down if sign points up.""" return -m.device_power_down[d, j] <= Md * (1 - m.device_power_sign[d, j]) - def ems_derivative_bounds(m, j): - return m.ems_derivative_min[j], sum(m.ems_power[:, j]), m.ems_derivative_max[j] + def ems_derivative_bounds(m, g, j): + devices = ems_constraint_device_groups[g] + if not devices: + return Constraint.Skip + return ( + m.ems_derivative_min[g, j], + sum(m.ems_power[d, j] for d in devices), + m.ems_derivative_max[g, j], + ) def commitment_up_derivative_sign(m, c): """Up deviation active only if sign points up.""" @@ -722,7 +762,7 @@ def device_derivative_equalities(m, d, j): model.device_power_down_sign = Constraint( model.d, model.j, rule=device_down_derivative_sign ) - model.ems_power_bounds = Constraint(model.j, rule=ems_derivative_bounds) + model.ems_power_bounds = Constraint(model.eg, model.j, rule=ems_derivative_bounds) if not convex_cost_curve: model.commitment_up_derivative_sign_con = Constraint( model.c, rule=commitment_up_derivative_sign diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 802f11dee4..0a8bb41c3a 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -311,18 +311,19 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 index = initialize_index(start, end, resolution) commitment_quantities = initialize_series(0, start, end, resolution) - # Keep EMS constraints global only. + # EMS constraints are kept per commodity (one device group per commodity). # - # Important: - # Do NOT put commodity-specific site-consumption-capacity or - # site-production-capacity into ems_constraints, because these constraints - # are applied to the sum of all devices in device_scheduler. + # The site-power / site-consumption / site-production capacities + # are enforced as hard EMS-level constraints (derivative max/min). Because each + # commodity has its own set of devices, ``ems_constraints`` is a list of + # DataFrames and ``ems_constraint_groups`` lists the device indices each + # DataFrame applies to. The device_scheduler then bounds the summed flow of each + # commodity's devices separately (instead of summing across all commodities). # - # Commodity-specific capacities are modelled below as FlowCommitments - # with device=commodity_devices. - ems_constraints = initialize_df( - StorageScheduler.COLUMNS, start, end, resolution - ) + # The commodity-specific breach/peak penalties below remain modelled as + # FlowCommitments on top of these hard constraints. + ems_constraints: list[pd.DataFrame] = [] + ems_constraint_groups: list[list[int]] = [] def device_list_series( devices: list[int], index: pd.DatetimeIndex @@ -646,6 +647,25 @@ def device_list_series( ) ) + # Hard EMS-level capacity constraint for this commodity's device group. + # If a breach price is set, the physical power capacity is the + # hard limit (the contracted capacity is then only softly penalised via the + # breach commitments above); otherwise the contracted capacity itself is the + # hard limit. + commodity_ems_constraints = initialize_df( + StorageScheduler.COLUMNS, start, end, resolution + ) + if ems_consumption_breach_price is not None: + commodity_ems_constraints["derivative max"] = ems_power_capacity + else: + commodity_ems_constraints["derivative max"] = ems_consumption_capacity + if ems_production_breach_price is not None: + commodity_ems_constraints["derivative min"] = -ems_power_capacity + else: + commodity_ems_constraints["derivative min"] = ems_production_capacity + ems_constraints.append(commodity_ems_constraints) + ems_constraint_groups.append(list(devices)) + # Keep one price frame for later preference logic. # The existing "prefer charging sooner" code uses `up_deviation_prices`. # Prefer electricity prices if available, otherwise use the first commodity price. @@ -1227,6 +1247,8 @@ def device_list_series( # Store original stock_deltas for use in _build_soc_schedule self.original_stock_deltas = original_stock_deltas + # Device indices each EMS constraint DataFrame applies to (one group per commodity). + self.ems_constraint_groups = ems_constraint_groups return ( sensors, start, @@ -2099,6 +2121,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: ems_schedule, expected_costs, scheduler_results, model = device_scheduler( device_constraints=device_constraints, ems_constraints=ems_constraints, + ems_constraint_groups=self.ems_constraint_groups, commitments=commitments, initial_stock=initial_stock, stock_groups=self.stock_groups, diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 0c0ff32ec2..49528382d4 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -764,9 +764,18 @@ def test_mixed_gas_and_electricity_assets(app, db): ] flex_context = { - "consumption-price": "100 EUR/MWh", # electricity price - "production-price": "100 EUR/MWh", - "gas-price": "50 EUR/MWh", # gas price + "commodities": [ + { + "commodity": "electricity", + "consumption-price": "100 EUR/MWh", # electricity price + "production-price": "100 EUR/MWh", + }, + { + "commodity": "gas", + "consumption-price": "50 EUR/MWh", # gas price + "production-price": "50 EUR/MWh", + }, + ] } scheduler = StorageScheduler( diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 59ed27d811..c727d357e2 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -759,21 +759,16 @@ def test_building_solver_day_2( soc_max, ur.Quantity(battery.get_attribute("soc-max")).to("MWh").magnitude ) - if market_scenario == "dynamic contract": - # Result after 8 hours: Sell what you begin with (high prices drive full discharge) - assert soc_schedule.loc[start + timedelta(hours=8)] == soc_min_value - # Result after second 8 hour-interval: Buy as much as possible (low prices) - assert soc_schedule.loc[start + timedelta(hours=16)] == soc_max_value - # Result at end of day: Sold out at end of planning horizon - assert soc_schedule.iloc[-1] == soc_min_value - else: - # fixed contract: inflexible devices are not included in the energy commitment - # coupling under the new multi-commodity scheduler, so price-based scheduling - # drives the result independently of the inflexible load profile. - # The battery partially discharges early and fully discharges near end of day. - assert soc_schedule.loc[start + timedelta(hours=8)] == 2.0 - assert soc_schedule.loc[start + timedelta(hours=16)] == 2.0 - assert soc_schedule.iloc[-1] == soc_min_value + # In both scenarios the battery should fully discharge in the first 8 hours, + # fully charge in the next 8, and fully discharge again in the last 8 (driven by + # 1) the dynamic price profile, or 2) the net-consumption/net-production profile of + # the inflexible devices, which are part of the electricity commodity device group). + # Result after 8 hours: discharged as far as possible. + assert soc_schedule.loc[start + timedelta(hours=8)] == soc_min_value + # Result after second 8 hour-interval: charged as far as possible. + assert soc_schedule.loc[start + timedelta(hours=16)] == soc_max_value + # Result at end of day: discharged as far as possible. + assert soc_schedule.iloc[-1] == soc_min_value def test_soc_bounds_timeseries(db, add_battery_assets): @@ -1388,8 +1383,14 @@ def set_if_not_none(dictionary, key, value): assert all(device_constraints[0]["derivative min"] == -expected_capacity) assert all(device_constraints[0]["derivative max"] == expected_capacity) - assert all(ems_constraints["derivative min"] == expected_site_production_capacity) - assert all(ems_constraints["derivative max"] == expected_site_consumption_capacity) + # EMS constraints are kept per commodity; this single-battery case has only the + # default "electricity" commodity, so its constraints are in ems_constraints[0]. + assert all( + ems_constraints[0]["derivative min"] == expected_site_production_capacity + ) + assert all( + ems_constraints[0]["derivative max"] == expected_site_consumption_capacity + ) @pytest.mark.parametrize( @@ -1669,7 +1670,8 @@ def test_battery_power_capacity_as_sensor( data_to_solver = scheduler._prepare() device_constraints = data_to_solver[5][0] - ems_constraints = data_to_solver[6] + # EMS constraints are kept per commodity; index [0] selects the "electricity" group. + ems_constraints = data_to_solver[6][0] assert all(device_constraints["derivative min"].values == expected_production) assert all(device_constraints["derivative max"].values == expected_consumption) diff --git a/flexmeasures/data/tests/test_scheduling_sequential.py b/flexmeasures/data/tests/test_scheduling_sequential.py index ca4b0e674c..68d5689f2d 100644 --- a/flexmeasures/data/tests/test_scheduling_sequential.py +++ b/flexmeasures/data/tests/test_scheduling_sequential.py @@ -137,7 +137,7 @@ def test_create_sequential_jobs(db, app, flex_description_sequential, smart_buil # Assert costs expected_ev_costs = 2.2375 - expected_battery_costs = -7.565 + expected_battery_costs = -4.415 assert ( ev_costs == expected_ev_costs ), f"EV cost should be {expected_ev_costs} €, got {ev_costs} €" diff --git a/flexmeasures/data/tests/test_scheduling_simultaneous.py b/flexmeasures/data/tests/test_scheduling_simultaneous.py index 0594420e5e..b5469d0e6b 100644 --- a/flexmeasures/data/tests/test_scheduling_simultaneous.py +++ b/flexmeasures/data/tests/test_scheduling_simultaneous.py @@ -133,8 +133,8 @@ def test_create_simultaneous_jobs( total_cost = ev_costs + battery_costs # Define expected costs based on resolution - expected_total_cost = -5.7025 - expected_ev_costs = 2.2375 + expected_total_cost = -3.2775 + expected_ev_costs = 2.3125 expected_battery_costs = expected_total_cost - expected_ev_costs # Check costs From 926941aa76bd79263a0e89518a98abdf104068af Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 11:13:45 +0200 Subject: [PATCH 27/49] fix: only raise in case of multiple EMS constraint DataFrames Signed-off-by: F.N. Claessen --- .../data/models/planning/linear_optimization.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 6508119b7e..7a4e97405c 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -118,11 +118,13 @@ def device_scheduler( # noqa C901 ems_constraints_list = [ems_constraints] ems_constraint_device_groups = [all_devices] else: - ems_constraints_list = list(ems_constraints) + ems_constraints_list = ems_constraints if ems_constraint_groups is None: - raise ValueError( - "When passing multiple EMS constraint DataFrames, you must also specify ems_constraint_groups." - ) + if len(ems_constraints_list) > 1: + raise ValueError( + "When passing multiple EMS constraint DataFrames, you must also specify ems_constraint_groups." + ) + ems_constraint_device_groups = [all_devices for _ in ems_constraints_list] else: ems_constraint_device_groups = ems_constraint_groups From 953d3f16bcbdf903e03bb149c4a44e06a7b29cc3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 11:24:07 +0200 Subject: [PATCH 28/49] chore: the wait for https://github.com/marshmallow-code/apispec/pull/999 is over Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/metadata.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index bb7827e693..53526ddda4 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -45,8 +45,7 @@ def to_dict(self): ) CONSUMPTION_PRICE = MetaData( description="The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem. [#old_consumption_price_field]_", - example={"sensor": 5}, - # examples=[{"sensor": 5}, "0.29 EUR/kWh"], # todo: waiting for https://github.com/marshmallow-code/apispec/pull/999 + examples=[{"sensor": 5}, "0.29 EUR/kWh"], ) PRODUCTION_PRICE = MetaData( description="The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", From c6b8223aaf1970360111384ba22500aa73daba40 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 11:27:40 +0200 Subject: [PATCH 29/49] feat: allow any commodity, with electricity and gas serving as examples Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/__init__.py | 5 +++-- flexmeasures/data/schemas/scheduling/metadata.py | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 171349d1fb..fa9c4e82c2 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -249,8 +249,8 @@ class SharedSchema(Schema): class CommodityFlexContextSchema(SharedSchema): commodity = fields.Str( required=True, - validate=validate.OneOf(["electricity", "gas"]), data_key="commodity", + metadata=metadata.COMMODITY.to_dict(), ) @@ -512,6 +512,7 @@ def _to_currency_per_mwh(price_unit: str) -> str: EXAMPLE_UNIT_TYPES: Dict[str, list[str]] = { + "commodity": ["electricity", "gas"], "energy-price": ["EUR/MWh", "JPY/kWh", "USD/MWh", "and other currencies."], "power-price": ["EUR/kW", "JPY/kW", "USD/kW", "and other currencies."], "power": ["MW", "kW"], @@ -801,7 +802,7 @@ def _to_currency_per_mwh(price_unit: str) -> str: "backend": "typeOne", "ui": "One fixed value only.", }, - "options": ["electricity", "gas"], + "example-units": EXAMPLE_UNIT_TYPES["commodity"], }, } diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 53526ddda4..a17e7f509b 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -190,10 +190,9 @@ def to_dict(self): COMMODITY = MetaData( description="""Commodity type for this storage flex-model. -Allowed values are ``electricity`` and ``gas``. Defaults to ``electricity``. """, - example="electricity", + examples=["electricity", "gas"], ) CONSUMPTION = MetaData( description="""Sensor used to record the scheduled power as seen from a consumption perspective. From b706f79478e740d4b68e9b0e802041d27113489a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 11:28:50 +0200 Subject: [PATCH 30/49] delete: remove unreleased flex-context field for gas price Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/__init__.py | 12 ------------ flexmeasures/data/schemas/scheduling/metadata.py | 5 ----- 2 files changed, 17 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index fa9c4e82c2..9b39a4ceab 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -326,13 +326,6 @@ class FlexContextSchema(SharedSchema): required=False, metadata=metadata.AGGREGATE_POWER.to_dict(), ) - gas_price = VariableQuantityField( - "/MWh", - data_key="gas-price", - required=False, - return_magnitude=False, - metadata=metadata.GAS_PRICE.to_dict(), - ) def set_default_breach_prices( self, data: dict, fields: list[str], price: ur.Quantity @@ -616,11 +609,6 @@ def _to_currency_per_mwh(price_unit: str) -> str: "description": rst_to_openapi(metadata.AGGREGATE_POWER.description), "example-units": EXAMPLE_UNIT_TYPES["power"], }, - "gas-price": { - "default": None, - "description": rst_to_openapi(metadata.GAS_PRICE.description), - "example-units": EXAMPLE_UNIT_TYPES["energy-price"], - }, } UI_FLEX_MODEL_SCHEMA: Dict[str, Dict[str, Any]] = { diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index a17e7f509b..70645d98d0 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -51,11 +51,6 @@ def to_dict(self): description="The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", example="0.12 EUR/kWh", ) -GAS_PRICE = MetaData( - description="The gas price applied to the site's aggregate gas consumption. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem", - example={"sensor": 6}, - # example="0.09 EUR/kWh", -) SITE_POWER_CAPACITY = MetaData( description="""Maximum achievable power at the site's grid connection point, in either direction. Becomes a hard constraint in the optimization problem, which is especially suitable for physical limitations. [#asymmetric]_ [#minimum_capacity_overlap]_ From b5f2c27f95b6e7b7f1770126b575a9d2c39d69d6 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 11:35:20 +0200 Subject: [PATCH 31/49] fix: add all relaxation fields to the list of fields to ignore when moving old flex-context fields into the electricity commodity context Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 0a8bb41c3a..216c786331 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -101,7 +101,13 @@ def _get_commodity_contexts(self) -> dict[str, dict]: if "electricity" not in commodity_contexts: commodity_contexts["electricity"] = {} for key, value in self.flex_context.items(): - if key not in ("gas_price", "relax_constraints"): + if key not in ( + "gas_price", + "relax_constraints", + "relax_soc_constraints", + "relax_capacity_constraints", + "relax_site_capacity_constraints", + ): commodity_contexts["electricity"][key] = value return commodity_contexts From bd972aa0b50802e6bfa886b58e37be7ad8bd348e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 11:36:07 +0200 Subject: [PATCH 32/49] delete: gas_price is no longer a field (remove reference to unreleased field) Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 216c786331..f347de05dc 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -102,7 +102,6 @@ def _get_commodity_contexts(self) -> dict[str, dict]: commodity_contexts["electricity"] = {} for key, value in self.flex_context.items(): if key not in ( - "gas_price", "relax_constraints", "relax_soc_constraints", "relax_capacity_constraints", From bcd54de53d9c36f3574d466846cbab00f7ab1a2a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 12:36:14 +0200 Subject: [PATCH 33/49] delete: just treat the whole old flex-context as the electricity flex-context Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index f347de05dc..6904562a8c 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -99,15 +99,7 @@ def _get_commodity_contexts(self) -> dict[str, dict]: # Backwards-compatible electricity defaults from old top-level fields. if "electricity" not in commodity_contexts: - commodity_contexts["electricity"] = {} - for key, value in self.flex_context.items(): - if key not in ( - "relax_constraints", - "relax_soc_constraints", - "relax_capacity_constraints", - "relax_site_capacity_constraints", - ): - commodity_contexts["electricity"][key] = value + commodity_contexts["electricity"] = self.flex_context return commodity_contexts From ed17a93c5be0a85e2f0898442fe0cf92dd1e62e2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:06:52 +0200 Subject: [PATCH 34/49] chore: update openapi-specs.json Signed-off-by: F.N. Claessen --- flexmeasures/ui/static/openapi-specs.json | 28 +++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 4f5c2fb73b..1edbb7a4e9 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4561,9 +4561,12 @@ "properties": { "consumption-price": { "description": "The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem. [#old_consumption_price_field]_", - "example": { - "sensor": 5 - } + "examples": [ + { + "sensor": 5 + }, + "0.29 EUR/kWh" + ] }, "production-price": { "description": "The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", @@ -4632,7 +4635,8 @@ }, "commodity": { "type": "string", - "enum": [ + "description": "Commodity type for this storage flex-model.\nDefaults to ``electricity``.\n", + "examples": [ "electricity", "gas" ] @@ -4648,9 +4652,12 @@ "properties": { "consumption-price": { "description": "The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem.", - "example": { - "sensor": 5 - }, + "examples": [ + { + "sensor": 5 + }, + "0.29 EUR/kWh" + ], "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "production-price": { @@ -4788,13 +4795,6 @@ "sensor": 9 }, "$ref": "#/components/schemas/SensorReference" - }, - "gas-price": { - "description": "The gas price applied to the site's aggregate gas consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem", - "example": { - "sensor": 6 - }, - "$ref": "#/components/schemas/VariableQuantityOpenAPI" } }, "additionalProperties": false From 625886c44e9c676b4380125c90aac55ef55e9844 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:15:47 +0200 Subject: [PATCH 35/49] feat: list the commodity field first rather than last Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/__init__.py | 10 ++++++++++ flexmeasures/ui/static/openapi-specs.json | 16 ++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 9b39a4ceab..1cf0b9e698 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -1,4 +1,6 @@ from __future__ import annotations + +from collections import OrderedDict from datetime import timedelta from typing import Any, Callable, Dict @@ -253,6 +255,14 @@ class CommodityFlexContextSchema(SharedSchema): metadata=metadata.COMMODITY.to_dict(), ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + commodity_field = self.fields.pop("commodity") + self.fields = OrderedDict( + [("commodity", commodity_field), *self.fields.items()] + ) + class FlexContextSchema(SharedSchema): """This schema defines fields that provide context to the portfolio to be optimized.""" diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 1edbb7a4e9..c9ee183514 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4559,6 +4559,14 @@ "CommodityFlexContext": { "type": "object", "properties": { + "commodity": { + "type": "string", + "description": "Commodity type for this storage flex-model.\nDefaults to ``electricity``.\n", + "examples": [ + "electricity", + "gas" + ] + }, "consumption-price": { "description": "The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem. [#old_consumption_price_field]_", "examples": [ @@ -4632,14 +4640,6 @@ "items": { "type": "integer" } - }, - "commodity": { - "type": "string", - "description": "Commodity type for this storage flex-model.\nDefaults to ``electricity``.\n", - "examples": [ - "electricity", - "gas" - ] } }, "required": [ From 7b92d3d8f080a052e6ae04458e75d105faf956d0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:25:32 +0200 Subject: [PATCH 36/49] feat: commodity is a field in both flex-model and flex-context Signed-off-by: F.N. Claessen --- documentation/features/scheduling.rst | 7 +++++-- flexmeasures/data/schemas/scheduling/__init__.py | 4 ++-- flexmeasures/data/schemas/scheduling/metadata.py | 12 +++++++++--- flexmeasures/data/schemas/scheduling/storage.py | 2 +- flexmeasures/ui/static/openapi-specs.json | 8 ++++++-- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index 4cc319c7fd..3042c3b3ea 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -58,6 +58,9 @@ And if the asset belongs to a larger system (a hierarchy of assets), the schedul * - Field - Example value - Description + * - ``commodity`` + - |COMMODITY_FLEX_CONTEXT.example| + - .. include:: ../_autodoc/COMMODITY_FLEX_CONTEXT.rst * - ``inflexible-device-sensors`` - |INFLEXIBLE_DEVICE_SENSORS.example| - .. include:: ../_autodoc/INFLEXIBLE_DEVICE_SENSORS.rst @@ -187,8 +190,8 @@ For more details on the possible formats for field values, see :ref:`variable_qu - Example value - Description * - ``commodity`` - - |COMMODITY.example| - - .. include:: ../_autodoc/COMMODITY.rst + - |COMMODITY_FLEX_MODEL.example| + - .. include:: ../_autodoc/COMMODITY_FLEX_MODEL.rst * - ``consumption`` - |CONSUMPTION.example| - .. include:: ../_autodoc/CONSUMPTION.rst diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 1cf0b9e698..b5bb9d8540 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -252,7 +252,7 @@ class CommodityFlexContextSchema(SharedSchema): commodity = fields.Str( required=True, data_key="commodity", - metadata=metadata.COMMODITY.to_dict(), + metadata=metadata.COMMODITY_FLEX_CONTEXT.to_dict(), ) def __init__(self, *args, **kwargs): @@ -795,7 +795,7 @@ def _to_currency_per_mwh(price_unit: str) -> str: }, "commodity": { "default": "electricity", - "description": rst_to_openapi(metadata.COMMODITY.description), + "description": rst_to_openapi(metadata.COMMODITY_FLEX_MODEL.description), "types": { "backend": "typeOne", "ui": "One fixed value only.", diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 70645d98d0..3b97899926 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -27,6 +27,12 @@ def to_dict(self): # FLEX-CONTEXT +COMMODITY_FLEX_CONTEXT = MetaData( + description="""Commodity to which this part of the flex-context applies. +Defaults to ``"electricity"``. +""", + examples=["electricity", "gas"], +) INFLEXIBLE_DEVICE_SENSORS = MetaData( description="""Power sensors representing devices that are relevant, but not flexible in the timing of their demand/supply. For example, a sensor recording rooftop solar power that is connected behind the main meter, and whose production falls under the same contract as the flexible device(s) being scheduled. @@ -183,9 +189,9 @@ def to_dict(self): # FLEX-MODEL -COMMODITY = MetaData( - description="""Commodity type for this storage flex-model. -Defaults to ``electricity``. +COMMODITY_FLEX_MODEL = MetaData( + description="""Commodity on which this device acts. +Defaults to ``"electricity"``. """, examples=["electricity", "gas"], ) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index a7189f2c18..efeda22399 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -252,7 +252,7 @@ class StorageFlexModelSchema(Schema): data_key="commodity", load_default="electricity", validate=OneOf(["electricity", "gas"]), - metadata=dict(description="Commodity label for this device/asset."), + metadata=metadata.COMMODITY_FLEX_MODEL.to_dict(), ) def __init__( diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index c9ee183514..6674cf44da 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4561,7 +4561,7 @@ "properties": { "commodity": { "type": "string", - "description": "Commodity type for this storage flex-model.\nDefaults to ``electricity``.\n", + "description": "Commodity to which this part of the flex-context applies.\nDefaults to ``\"electricity\"``.\n", "examples": [ "electricity", "gas" @@ -6327,7 +6327,11 @@ "electricity", "gas" ], - "description": "Commodity label for this device/asset." + "description": "Commodity on which this device acts.\nDefaults to \"electricity\".\n", + "examples": [ + "electricity", + "gas" + ] }, "sensor": { "type": "integer", From 10d82d208f781580d57a308944d8d7a4a08132b1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:26:15 +0200 Subject: [PATCH 37/49] feat: flex-model commodity can also be more than just electricity and gas Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/storage.py | 1 - flexmeasures/ui/static/openapi-specs.json | 4 ---- 2 files changed, 5 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index efeda22399..6f36b19346 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -251,7 +251,6 @@ class StorageFlexModelSchema(Schema): commodity = fields.Str( data_key="commodity", load_default="electricity", - validate=OneOf(["electricity", "gas"]), metadata=metadata.COMMODITY_FLEX_MODEL.to_dict(), ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 6674cf44da..bf620b801a 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6323,10 +6323,6 @@ "commodity": { "type": "string", "default": "electricity", - "enum": [ - "electricity", - "gas" - ], "description": "Commodity on which this device acts.\nDefaults to \"electricity\".\n", "examples": [ "electricity", From a483ce84e82882a4bb5c8e32fbb42ab3d73475f9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:31:16 +0200 Subject: [PATCH 38/49] docs: remove mention of gas-price field Signed-off-by: F.N. Claessen --- documentation/features/scheduling.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index 3042c3b3ea..c4c0e271d2 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -73,9 +73,6 @@ And if the asset belongs to a larger system (a hierarchy of assets), the schedul * - ``production-price`` - |PRODUCTION_PRICE.example| - .. include:: ../_autodoc/PRODUCTION_PRICE.rst - * - ``gas-price`` - - |GAS_PRICE.example| - - .. include:: ../_autodoc/GAS_PRICE.rst * - ``site-power-capacity`` - |SITE_POWER_CAPACITY.example| - .. include:: ../_autodoc/SITE_POWER_CAPACITY.rst From 718e8c3459daa7729ac59fb1b3602f4be35f7874 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:33:27 +0200 Subject: [PATCH 39/49] docs: adjust scheduling section for multi-commodity Signed-off-by: F.N. Claessen --- documentation/features/scheduling.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index c4c0e271d2..aacfa2db11 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -5,8 +5,8 @@ Scheduling Scheduling is the main value-drive of FlexMeasures. We have two major types of schedulers built-in, for storage devices (usually batteries or hot water storage) and processes (usually in industry). -FlexMeasures computes schedules for energy systems that consist of multiple devices that consume and/or produce electricity. -We model a device as an asset with a power sensor, and compute schedules only for flexible devices, while taking into account inflexible devices. +FlexMeasures computes schedules for energy systems that consist of multiple devices that consume and/or produce a commodity (e.g. electricity or gas). +We model a device as an asset with a consumption/production sensor recording power values, and compute schedules only for flexible devices, while taking into account inflexible devices. .. contents:: :local: From 35b8f220a25dfe3b423bcef39dd84bf5294da908 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:35:23 +0200 Subject: [PATCH 40/49] docs: adjust field descriptions for multi-commodity Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/metadata.py | 8 ++++---- flexmeasures/ui/static/openapi-specs.json | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 3b97899926..7463579864 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -50,11 +50,11 @@ def to_dict(self): example=[], ) CONSUMPTION_PRICE = MetaData( - description="The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem. [#old_consumption_price_field]_", + description="The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem. [#old_consumption_price_field]_", examples=[{"sensor": 5}, "0.29 EUR/kWh"], ) PRODUCTION_PRICE = MetaData( - description="The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", + description="The commodity price (e.g. electricity price) applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", example="0.12 EUR/kWh", ) SITE_POWER_CAPACITY = MetaData( @@ -309,14 +309,14 @@ def to_dict(self): example="90%", ) CHARGING_EFFICIENCY = MetaData( - description="""One-way conversion efficiency from electricity to the storage's state of charge. + description="""One-way conversion efficiency from the commodity (e.g. electricity) to the storage's state of charge. Can be a percentage, a ratio in the range [0,1], or a coefficient of performance (>1). Defaults to 100% (no conversion loss). """, example=".9", ) DISCHARGING_EFFICIENCY = MetaData( - description="""One-way conversion efficiency from the storage's state of charge to electricity. + description="""One-way conversion efficiency from the storage's state of charge to the commodity (e.g. electricity). Defaults to 100% (no conversion loss).""", example="90%", ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index bf620b801a..f047fe0bbb 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4568,7 +4568,7 @@ ] }, "consumption-price": { - "description": "The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem. [#old_consumption_price_field]_", + "description": "The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem. [#old_consumption_price_field]_", "examples": [ { "sensor": 5 @@ -4577,7 +4577,7 @@ ] }, "production-price": { - "description": "The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", + "description": "The commodity price (e.g. electricity price) applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", "example": "0.12 EUR/kWh" }, "site-power-capacity": { @@ -4651,7 +4651,7 @@ "type": "object", "properties": { "consumption-price": { - "description": "The electricity price applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem.", + "description": "The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem.", "examples": [ { "sensor": 5 @@ -4661,7 +4661,7 @@ "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "production-price": { - "description": "The electricity price applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the consumption-price unit.", + "description": "The commodity price (e.g. electricity price) applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the consumption-price unit.", "example": "0.12 EUR/kWh", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, @@ -6275,12 +6275,12 @@ "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "charging-efficiency": { - "description": "One-way conversion efficiency from electricity to the storage's state of charge.\nCan be a percentage, a ratio in the range [0,1], or a coefficient of performance (>1).\nDefaults to 100% (no conversion loss).\n", + "description": "One-way conversion efficiency from the commodity (e.g. electricity) to the storage's state of charge.\nCan be a percentage, a ratio in the range [0,1], or a coefficient of performance (>1).\nDefaults to 100% (no conversion loss).\n", "example": ".9", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "discharging-efficiency": { - "description": "One-way conversion efficiency from the storage's state of charge to electricity.\nDefaults to 100% (no conversion loss).", + "description": "One-way conversion efficiency from the storage's state of charge to the commodity (e.g. electricity).\nDefaults to 100% (no conversion loss).", "example": "90%", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, From c43bb06abfba2c7d3520f49fe7ac080cc07baac1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:42:48 +0200 Subject: [PATCH 41/49] feat: list the commodity field first rather than last, also in flex-model Signed-off-by: F.N. Claessen --- .../data/schemas/scheduling/storage.py | 11 ++++++----- flexmeasures/ui/static/openapi-specs.json | 18 +++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 6f36b19346..07ad3b46c9 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -97,6 +97,12 @@ class StorageFlexModelSchema(Schema): metadata=dict(description="ID of the asset that is requested to be scheduled."), ) + commodity = fields.Str( + data_key="commodity", + load_default="electricity", + metadata=metadata.COMMODITY_FLEX_MODEL.to_dict(), + ) + consumption = fields.Nested( SensorReferenceSchema, metadata=metadata.CONSUMPTION.to_dict(), @@ -248,11 +254,6 @@ class StorageFlexModelSchema(Schema): validate=validate.Length(min=1), metadata=metadata.SOC_USAGE.to_dict(), ) - commodity = fields.Str( - data_key="commodity", - load_default="electricity", - metadata=metadata.COMMODITY_FLEX_MODEL.to_dict(), - ) def __init__( self, diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index f047fe0bbb..6d4e515c11 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6163,6 +6163,15 @@ "StorageFlexModelSchemaOpenAPI": { "type": "object", "properties": { + "commodity": { + "type": "string", + "default": "electricity", + "description": "Commodity on which this device acts.\nDefaults to \"electricity\".\n", + "examples": [ + "electricity", + "gas" + ] + }, "consumption": { "description": "Sensor used to record the scheduled power as seen from a consumption perspective.\n\nThe sign convention is determined by the key name, and is stored on the sensor itself using the consumption_is_positive attribute.\n\nDepending on which output sensors are defined:\n\n- Only consumption defined: the full power schedule is stored on this sensor using the\n consumption-positive sign convention (consumption positive, production negative).\n- Only production defined: the full power schedule is stored on the production sensor\n with the production-positive convention (production positive, consumption negative).\n- Both defined: only the non-negative part of the schedule is stored on this sensor (zero for\n time steps with net production), and only the non-positive part (sign-flipped) is stored on the\n production sensor.\n", "example": { @@ -6320,15 +6329,6 @@ ], "items": {} }, - "commodity": { - "type": "string", - "default": "electricity", - "description": "Commodity on which this device acts.\nDefaults to \"electricity\".\n", - "examples": [ - "electricity", - "gas" - ] - }, "sensor": { "type": "integer", "description": "ID of the device's power sensor." From 51ba61feb348c1f8f31acfbdb4398c2938065f7f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 13:54:38 +0200 Subject: [PATCH 42/49] fix: wrong data-key due to wrong indentation Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/assets.py | 10 +++++----- flexmeasures/ui/static/openapi-specs.json | 5 +++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 409abce35a..2bd9912d0f 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -107,11 +107,11 @@ def __init__(self, *args, **kwargs): flex_model = fields.List( fields.Nested( storage_flex_model_schema_openAPI(exclude=["asset"]), - required=True, - data_key="flex-model", - metadata=dict( - description="Flex-model per device (identified by `sensor`). The flex-model validation is handled by the scheduler. What follows is the schema used by the `StorageScheduler`.", - ), + ), + required=True, + data_key="flex-model", + metadata=dict( + description="Flex-model per device (identified by `sensor`). The flex-model validation is handled by the scheduler. What follows is the schema used by the `StorageScheduler`.", ), ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 6d4e515c11..c2fb354a05 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6363,10 +6363,10 @@ "example": "PT2H", "format": "duration" }, - "flex_model": { + "flex-model": { "type": "array", + "description": "Flex-model per device (identified by `sensor`). The flex-model validation is handled by the scheduler. What follows is the schema used by the `StorageScheduler`.", "items": { - "description": "Flex-model per device (identified by `sensor`). The flex-model validation is handled by the scheduler. What follows is the schema used by the `StorageScheduler`.", "$ref": "#/components/schemas/StorageFlexModelSchemaOpenAPI" } }, @@ -6386,6 +6386,7 @@ }, "required": [ "flex-context", + "flex-model", "start" ], "additionalProperties": false From b2657cbd105ef58ae200267614ab6bf4390ef026 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 14:09:15 +0200 Subject: [PATCH 43/49] docs: explain the "commodities" field Signed-off-by: F.N. Claessen --- documentation/features/scheduling.rst | 1 + flexmeasures/data/schemas/scheduling/__init__.py | 3 +++ flexmeasures/ui/static/openapi-specs.json | 1 + 3 files changed, 5 insertions(+) diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index aacfa2db11..a11e70cabe 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -39,6 +39,7 @@ The flex-context The ``flex-context`` is independent of the type of flexible device that is optimized, or which scheduler is used. With the flexibility context, we aim to describe the system in which the flexible assets operate, such as its physical and contractual limitations. +For multi-commodity scheduling problems, the flex-context can be defined separately per commodity (e.g. electricity and gas), using the ``commodities`` field. Fields can have fixed values, but some fields can also point to sensors, so they will always represent the dynamics of the asset's environment (as long as that sensor has current data). The full list of flex-context fields follows below. diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index b5bb9d8540..9429b532fb 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -272,6 +272,9 @@ class FlexContextSchema(SharedSchema): data_key="commodities", required=False, many=True, + metadata=dict( + description="For multi-commodity scheduling problems, the above fields can be set here per commodity.", + ), ) # Device commitments consumption_breach_price = VariableQuantityField( diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index c2fb354a05..75658d8ebd 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4734,6 +4734,7 @@ } }, "commodities": { + "description": "For multi-commodity scheduling problems, the above fields can be set here per commodity.", "type": "array", "items": { "$ref": "#/components/schemas/CommodityFlexContext" From 5a73040f434c04d96c7432537b11fa762f4c215c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 14:09:30 +0200 Subject: [PATCH 44/49] docs: no need for ill-formatted in-line code formatting Signed-off-by: F.N. Claessen --- documentation/features/scheduling.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index a11e70cabe..e13e6d2a54 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -47,7 +47,7 @@ For more details on the possible formats for field values, see :ref:`variable_qu Where should you set these fields? Within requests to the API or by editing the relevant asset in the UI. -If they are not sent in via the API (one of the endpoints triggering schedule computation), the scheduler will look them up on the `flex-context` field of the asset. +If they are not sent in via the API (one of the endpoints triggering schedule computation), the scheduler will look them up on the flex-context field of the asset. And if the asset belongs to a larger system (a hierarchy of assets), the scheduler will also search if parent assets have them set. From 7ce8b7a6f75ebc3efb93cba456e6af5a53953761 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 14:15:05 +0200 Subject: [PATCH 45/49] remove: devices do not have to be storages anymore Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 6904562a8c..aba6b95132 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1358,16 +1358,10 @@ def deserialize_flex_config(self): # Extend schedule period in case a target exceeds its end self.possibly_extend_end(soc_targets=self.flex_model.get("soc_targets")) elif isinstance(self.flex_model, list): - # todo: ensure_soc_min_max in case the device is a storage (see line 847) self.flex_model = MultiSensorFlexModelSchema(many=True).load( self.flex_model ) for d, sensor_flex_model in enumerate(self.flex_model): - # todo: this fails but I'm not sure about the reason(haven't looked into it deeply yet). - # sensor_flex_model["sensor_flex_model"] = self.ensure_soc_at_start( - # flex_model=sensor_flex_model["sensor_flex_model"], - # sensor=sensor_flex_model.get("sensor"), - # ) soc_sensor_id = ( sensor_flex_model["sensor_flex_model"] .get("state-of-charge", {}) From 39c0a9d3976cccbfeb74dfc39a8b964d627aabbc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 14:28:53 +0200 Subject: [PATCH 46/49] fix: set default flex-context commodity to electricity Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/scheduling/__init__.py | 3 ++- flexmeasures/ui/static/openapi-specs.json | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 9429b532fb..3cd8dc50d3 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -250,7 +250,8 @@ class SharedSchema(Schema): class CommodityFlexContextSchema(SharedSchema): commodity = fields.Str( - required=True, + required=False, + load_default="electricity", data_key="commodity", metadata=metadata.COMMODITY_FLEX_CONTEXT.to_dict(), ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 75658d8ebd..50a90a34d6 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4561,6 +4561,7 @@ "properties": { "commodity": { "type": "string", + "default": "electricity", "description": "Commodity to which this part of the flex-context applies.\nDefaults to ``\"electricity\"``.\n", "examples": [ "electricity", @@ -4642,9 +4643,6 @@ } } }, - "required": [ - "commodity" - ], "additionalProperties": false }, "FlexContextOpenAPISchema": { From d6911a25dc01ec6e06cd140eecf5a2b3e7120b5d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 12 Jun 2026 14:37:23 +0200 Subject: [PATCH 47/49] fix: preserve field order in case schema is made OpenAPI compatible Signed-off-by: F.N. Claessen --- flexmeasures/api/common/schemas/utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flexmeasures/api/common/schemas/utils.py b/flexmeasures/api/common/schemas/utils.py index f457d2bed2..3c7174cee2 100644 --- a/flexmeasures/api/common/schemas/utils.py +++ b/flexmeasures/api/common/schemas/utils.py @@ -32,7 +32,11 @@ def make_openapi_compatible( # noqa: C901 sensor_only_validators.append(validator[-1]) new_fields = {} - tobeadded_fields = schema_cls._declared_fields + try: + # in case `schema_cls.__init__` reordered the fields, preserve their order + tobeadded_fields = schema_cls().fields + except TypeError: + tobeadded_fields = schema_cls._declared_fields if include: for item in include: tobeadded_fields.update(item) From af42623814a710591fcc7bf24b07b606f5834c4c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 13 Jun 2026 10:55:14 +0200 Subject: [PATCH 48/49] fix: default flex-model and flex-context to empty list and empty dict, respectively, because it is possible that the entire flex-config is described in the db instead of the trigger message Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/assets.py | 4 ++-- flexmeasures/ui/static/openapi-specs.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 2bd9912d0f..e4599e2737 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -98,7 +98,7 @@ def __init__(self, *args, **kwargs): flex_context = fields.Nested( flex_context_schema_openAPI, - required=True, + load_default={}, data_key="flex-context", metadata=dict( description="The flex-context is validated according to the scheduler's `FlexContextSchema`.", @@ -108,7 +108,7 @@ def __init__(self, *args, **kwargs): fields.Nested( storage_flex_model_schema_openAPI(exclude=["asset"]), ), - required=True, + load_default=[], data_key="flex-model", metadata=dict( description="Flex-model per device (identified by `sensor`). The flex-model validation is handled by the scheduler. What follows is the schema used by the `StorageScheduler`.", diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 50a90a34d6..62a2bc16d7 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6364,12 +6364,14 @@ }, "flex-model": { "type": "array", + "default": [], "description": "Flex-model per device (identified by `sensor`). The flex-model validation is handled by the scheduler. What follows is the schema used by the `StorageScheduler`.", "items": { "$ref": "#/components/schemas/StorageFlexModelSchemaOpenAPI" } }, "flex-context": { + "default": {}, "description": "The flex-context is validated according to the scheduler's `FlexContextSchema`.", "$ref": "#/components/schemas/FlexContextOpenAPISchema" }, @@ -6384,8 +6386,6 @@ } }, "required": [ - "flex-context", - "flex-model", "start" ], "additionalProperties": false From 5512411f16521bdcfb848bcf87245129be36ace2 Mon Sep 17 00:00:00 2001 From: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:31:29 +0200 Subject: [PATCH 49/49] Feature: flex-context per commodity as list (#2235) * feat: support passing flex-context as a list (one flex-context per commodity) Signed-off-by: F.N. Claessen * fix: set default flex-context commodity to electricity Signed-off-by: F.N. Claessen * fix: preserve field order in case schema is made OpenAPI compatible Signed-off-by: F.N. Claessen * feat: reduce documented nesting when defining a flex-context per commodity Signed-off-by: F.N. Claessen * dev: add todos Signed-off-by: F.N. Claessen * style: flake8 Signed-off-by: F.N. Claessen * scheduling: complete schema refactoring per PR #2235 - Add SensorReferenceSchema import - Override relax_constraints default to True in CommodityFlexContextSchema - Remove duplicate breach price and relax fields from FlexContextSchema - Remove duplicate set_default_breach_prices method from FlexContextSchema Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> Signed-off-by: F.N. Claessen * tests: add comprehensive tests for schema refactoring - Test aggregate-consumption and aggregate-production fields - Test SharedSchema fields accessible in FlexContextSchema - Test CommodityFlexContextSchema relax_constraints defaults to True - Test shared currency logic for flex-context listings - Test breach prices in both schemas - Add noqa comment for existing unused variable Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> * scheduling: add shared currency validation for commodity contexts - Add validator to check prices share same currency across all commodity contexts - Fix test data to use actual fixture sensor names - All new tests now passing Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> * docs: add aggregate fields to documentation and update tests - Add aggregate-consumption and aggregate-production to scheduling.rst - Exclude COMMODITY_FLEX_CONTEXT and COMMODITY_FLEX_MODEL from doc test (already documented as "commodity") - Exclude aggregate fields from UI test (not yet supported in UI) Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> * chore: upgrade openapi-specs.json Signed-off-by: F.N. Claessen * fix: move _try_to_convert_price_units to SharedSchema Signed-off-by: F.N. Claessen * fix: some fields cannot use source filters; now instead of just having validators in place to forbid that, we actually stop having those fields in the schemas of those fields Signed-off-by: F.N. Claessen * chore: mypy Signed-off-by: F.N. Claessen * delete: remove redundant tests Signed-off-by: F.N. Claessen * fix: minimize diff Signed-off-by: F.N. Claessen * fix: default flex-model and flex-context to empty lists, because it is possible that the entire flex-config is described in the db instead of the trigger message (this cherry-pick was adapted, because the flex-context moved from being documented as a dict to a list) Signed-off-by: F.N. Claessen * docs: prefer unique sensor IDs in examples Signed-off-by: F.N. Claessen * docs: clarify cross-reference Signed-off-by: F.N. Claessen * feat: UI support for picking a sensor for aggregate-consumption sensor and/or aggregate-production Signed-off-by: F.N. Claessen * fix: for some reason, for flex-context fields, the sensor-only property is defined in the html in 3 places Signed-off-by: F.N. Claessen * feat: test coverage for UI support of aggregate-consumption and aggregate-production Signed-off-by: F.N. Claessen * docs: add deprecation instructions for aggregate-power Signed-off-by: F.N. Claessen * docs: move down the aggregate-power field documentation Signed-off-by: F.N. Claessen * use different compatible units. Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> * update comment Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> * update comment Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> * update comment Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> * update the comment Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> * chore: update openapi-specs.json again Signed-off-by: F.N. Claessen * feat: add multi feed stock tutorial Signed-off-by: Ahmad-Wahid * remove extra chart explanation Signed-off-by: Ahmad-Wahid * fix: update flexmeasures version in openapi-specs Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> * feat: add multi commodity tutorial Signed-off-by: Ahmad-Wahid * Apply my own suggestions from code review Co-authored-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Signed-off-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> * docs: changelog entry Signed-off-by: F.N. Claessen * test: add comprehensive coverage for aggregate-consumption/production flex-context fields - Add test_asset_trigger_and_get_aggregate_schedule to verify aggregate sensors get populated (currently xfail as StorageScheduler doesn't yet populate them) - Add test_asset_trigger_with_multi_commodity_flex_context to verify aggregate sensors work with multi-commodity flex-context format (list of commodity dicts) - Document that aggregate sensor population is missing logic in StorageScheduler - Both tests verify the infrastructure and schema support works correctly * fix: compute per-commodity aggregate power flows for aggregate-consumption and aggregate-production sensors Signed-off-by: F.N. Claessen * Include per-commodity inflexible devices in aggregate schedules _compute_commodity_aggregate_schedules only added the top-level inflexible devices to the electricity commodity, so a commodity's own inflexible demand (e.g. a heat load) was excluded from its aggregate-consumption schedule. The aggregate then reflected only that commodity's flexible devices. Mirror the device enumeration used in _prepare so each commodity context's inflexible-device-sensors are appended to that commodity at the matching ems_schedule indices. * test: update aggregate sensor tests to verify implementation - Remove xfail markers from both tests now that StorageScheduler populates aggregate sensors - Update test docstrings to reflect actual functionality - Tests now verify that aggregate-consumption and aggregate-production sensors receive data - First test covers single aggregate pair with dual devices - Second test covers aggregates with multiple devices scheduling together * feat: test commodities referenced in only the flex-model or only the flex-context Signed-off-by: F.N. Claessen * feat: test logs should explain why response was unexpected Signed-off-by: F.N. Claessen * style: black Signed-off-by: F.N. Claessen * feat: AssetTriggerSchema accepts list-like flex-contexts Signed-off-by: F.N. Claessen * fix: skip aggregate computation for unused commodities When a multi-commodity flex_context includes a commodity (e.g., heat) but no devices exist for that commodity, the sum() of an empty list returns integer 0 instead of a pandas Series. This caused AttributeError when calling .round() on the result later in compute(). Fixed by checking if any devices contribute to the commodity before attempting to aggregate. If no devices exist for a commodity, skip creating an aggregate schedule for it. Signed-off-by: F.N. Claessen --------- Signed-off-by: F.N. Claessen Signed-off-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> Signed-off-by: Ahmad-Wahid Signed-off-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+copilot@users.noreply.github.com> Co-authored-by: Ahmad Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> Co-authored-by: Ahmad-Wahid --- documentation/changelog.rst | 2 +- documentation/features/scheduling.rst | 8 +- documentation/index.rst | 2 + documentation/tut/multi-commodity.rst | 261 ++++++++++ documentation/tut/multi-feed-storage.rst | 224 ++++++++ flexmeasures/api/common/schemas/scheduling.py | 4 +- flexmeasures/api/v3_0/assets.py | 10 +- .../tests/test_asset_schedules_fresh_db.py | 487 ++++++++++++++++++ flexmeasures/data/models/planning/__init__.py | 15 +- flexmeasures/data/models/planning/storage.py | 201 +++++++- .../models/planning/tests/test_commitments.py | 26 +- .../data/schemas/scheduling/__init__.py | 328 ++++++++---- .../data/schemas/scheduling/metadata.py | 32 +- .../data/schemas/scheduling/storage.py | 47 +- flexmeasures/data/schemas/sensors.py | 20 +- .../data/schemas/tests/test_scheduling.py | 289 ++++++++--- flexmeasures/ui/static/openapi-specs.json | 176 ++----- .../ui/templates/assets/asset_context.html | 6 +- flexmeasures/ui/tests/test_utils.py | 5 +- flexmeasures/utils/coding_utils.py | 29 ++ tests/documentation/test_schemas.py | 2 + 21 files changed, 1801 insertions(+), 373 deletions(-) create mode 100644 documentation/tut/multi-commodity.rst create mode 100644 documentation/tut/multi-feed-storage.rst diff --git a/documentation/changelog.rst b/documentation/changelog.rst index aa1166e382..ae95136e3a 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -11,7 +11,7 @@ New features ------------- * Floor off-clock API datetimes to a non-instantaneous sensor's resolution by default when ingesting sensor data, uploading sensor data, and handling scheduler flex-model timed events; configurable with the ``floor_datetimes_to_resolution`` sensor attribute [see `PR #2146 `_] * Sensor references in flex-model and flex-context support various ways of filtering by source [see `PR #2209 `_] - +* The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #2172 `_ and `PR #2235 `_] Infrastructure / Support ---------------------- diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index e13e6d2a54..959fa14845 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -39,7 +39,7 @@ The flex-context The ``flex-context`` is independent of the type of flexible device that is optimized, or which scheduler is used. With the flexibility context, we aim to describe the system in which the flexible assets operate, such as its physical and contractual limitations. -For multi-commodity scheduling problems, the flex-context can be defined separately per commodity (e.g. electricity and gas), using the ``commodities`` field. +For multi-commodity scheduling problems, the flex-context can be defined separately per commodity (e.g. electricity and gas). Fields can have fixed values, but some fields can also point to sensors, so they will always represent the dynamics of the asset's environment (as long as that sensor has current data). The full list of flex-context fields follows below. @@ -65,6 +65,12 @@ And if the asset belongs to a larger system (a hierarchy of assets), the schedul * - ``inflexible-device-sensors`` - |INFLEXIBLE_DEVICE_SENSORS.example| - .. include:: ../_autodoc/INFLEXIBLE_DEVICE_SENSORS.rst + * - ``aggregate-consumption`` + - |AGGREGATE_CONSUMPTION.example| + - .. include:: ../_autodoc/AGGREGATE_CONSUMPTION.rst + * - ``aggregate-production`` + - |AGGREGATE_PRODUCTION.example| + - .. include:: ../_autodoc/AGGREGATE_PRODUCTION.rst * - ``aggregate-power`` - |AGGREGATE_POWER.example| - .. include:: ../_autodoc/AGGREGATE_POWER.rst diff --git a/documentation/index.rst b/documentation/index.rst index a771ccb589..c111d19a41 100644 --- a/documentation/index.rst +++ b/documentation/index.rst @@ -175,6 +175,8 @@ In :ref:`getting_started`, we have some helpful tips how to dive into this docum tut/toy-example-expanded tut/toy-example-multiasset-curtailment tut/flex-model-v2g + tut/multi-feed-storage + tut/multi-commodity tut/toy-example-process tut/toy-example-reporter tut/posting_data diff --git a/documentation/tut/multi-commodity.rst b/documentation/tut/multi-commodity.rst new file mode 100644 index 0000000000..1840570971 --- /dev/null +++ b/documentation/tut/multi-commodity.rst @@ -0,0 +1,261 @@ +.. _tut_multi_commodity: + +A flex-modeling tutorial for storage: Multiple commodities (gas & electricity) +------------------------------------------------------------------------------ + +The :ref:`multi-feed storage tutorial ` showed that the ``flex-model`` can be a *list*, so that several devices are scheduled together in one call. +Those devices all acted on the same commodity (electricity). But many real sites mix commodities — electricity *and* gas, for instance — each with its own price. + +FlexMeasures handles this with two ingredients: + +- a ``commodity`` field on each device in the ``flex-model``, and +- a per-commodity price listing in the ``flex-context``. + +In this tutorial we schedule a small hybrid site with one device on each commodity, and read back a cost breakdown that is tracked *per commodity*. +(For a more general introduction to flex modeling, see :ref:`describing_flexibility`. For the single-commodity, multi-device case, see :ref:`tut_multi_feed_storage`.) + + +The use case +============ + +A site has two flexible-ish devices, each acting on a different commodity: + +- A **battery** on the ``electricity`` commodity: 20 kW power, 100 kWh capacity, 95% charging and discharging efficiency. It starts at 20 kWh and must reach 80 kWh by 23:00. +- A **gas boiler** on the ``gas`` commodity: it draws a **constant 1 kW** of gas every hour, modelled as a fixed load (it is not really flexible, but it still incurs a commodity cost we want to account for). + +Prices are flat, but *different per commodity*: + +- Electricity: **100 EUR/MWh** (consumption and production) +- Gas: **50 EUR/MWh** + +We want the scheduler to optimise the battery against the electricity price, run the boiler at its fixed gas baseline, and report electricity and gas costs separately. + + +Building the flex model +======================= + +As in the multi-feed tutorial, the ``flex-model`` is a **list** with one entry per device. +What is new here is the ``commodity`` field, which tells the scheduler *which price signal* applies to each device. It defaults to ``"electricity"``. + +.. code-block:: json + + { + "flex-model": [ + { + "sensor": 1, + "commodity": "electricity", + "state-of-charge": {"sensor": 3}, + "soc-at-start": 20.0, + "soc-min": 0.0, + "soc-max": 100.0, + "soc-targets": [ + {"datetime": "2024-01-01T23:00:00+01:00", "value": 80.0} + ], + "power-capacity": "20 kW", + "charging-efficiency": 0.95, + "discharging-efficiency": 0.95 + }, + { + "sensor": 2, + "commodity": "gas", + "power-capacity": "30 kW", + "consumption-capacity": "30 kW", + "production-capacity": "0 kW", + "soc-usage": ["1 kW"], + "soc-min": 0.0, + "soc-max": 0.0, + "soc-at-start": 0.0 + } + ] + } + +Here, sensor ``1`` is the battery's power sensor, sensor ``2`` is the boiler's power sensor, and sensor ``3`` is the battery's instantaneous ``state-of-charge`` sensor (referenced from the battery entry so the scheduler records its charge level). + +A few things to note: + +- **The battery is a normal storage device** (``soc-at-start``, ``soc-min``, ``soc-max``, ``soc-targets``), tagged with ``"commodity": "electricity"``. +- **The boiler is modelled as a fixed load.** With ``soc-min`` and ``soc-max`` both 0, it can store nothing; ``soc-usage`` of ``1 kW`` forces it to consume exactly 1 kW of gas every hour, which the optimiser cannot change. ``production-capacity`` of 0 kW means it can never produce gas. + +The prices live in the ``flex-context``. For a single commodity you would pass ``consumption-price`` and ``production-price`` directly. For **multiple commodities**, you instead provide a ``commodities`` list, one entry per commodity: + +.. code-block:: json + + { + "flex-context": [ + { + "commodity": "electricity", + "consumption-price": "100 EUR/MWh", + "production-price": "100 EUR/MWh" + }, + { + "commodity": "gas", + "consumption-price": "50 EUR/MWh" + } + ] + } + +Each device's costs are then evaluated against the prices of *its own* commodity: the battery against electricity, the boiler against gas. + +.. note:: All commodities in one scheduling problem must share the same currency (here, EUR). The prices themselves can of course differ, and may be time series or sensors just like any other price in FlexMeasures. + + +Triggering the schedule +======================= + +We schedule on the **site asset**, so that FlexMeasures considers both devices together in a single optimisation. + +.. tabs:: + + .. tab:: CLI + + .. code-block:: bash + + $ flexmeasures add schedule \ + --asset 1 \ + --start 2024-01-01T00:00+01:00 \ + --duration PT24H \ + --flex-model flex-model-multi-commodity.json \ + --flex-context flex-context-multi-commodity.json + New schedule is stored. + + .. tab:: API + + Example call: `[POST] http://localhost:5000/api/v3_0/assets/1/schedules/trigger <../api/v3_0.html#post--api-v3_0-assets-id-schedules-trigger>`_: + + .. code-block:: json + + { + "start": "2024-01-01T00:00:00+01:00", + "duration": "PT24H", + "flex-model": [ + { + "sensor": 1, + "commodity": "electricity", + "state-of-charge": {"sensor": 3}, + "soc-at-start": 20.0, + "soc-min": 0.0, + "soc-max": 100.0, + "soc-targets": [ + {"datetime": "2024-01-01T23:00:00+01:00", "value": 80.0} + ], + "power-capacity": "20 kW", + "charging-efficiency": 0.95, + "discharging-efficiency": 0.95 + }, + { + "sensor": 2, + "commodity": "gas", + "power-capacity": "30 kW", + "consumption-capacity": "30 kW", + "production-capacity": "0 kW", + "soc-usage": ["1 kW"], + "soc-min": 0.0, + "soc-max": 0.0, + "soc-at-start": 0.0 + } + ], + "flex-context": [ + { + "commodity": "electricity", + "consumption-price": "100 EUR/MWh", + "production-price": "100 EUR/MWh" + }, + { + "commodity": "gas", + "consumption-price": "50 EUR/MWh" + } + ] + } + + .. tab:: FlexMeasures Client + + Using the `FlexMeasures Client `_: + + .. code-block:: python + + schedule = await client.trigger_and_get_schedule( + asset_id=1, # the site asset + start="2024-01-01T00:00:00+01:00", + duration="PT24H", + flex_model=[ + { + "sensor": 1, # battery power sensor + "commodity": "electricity", + "state-of-charge": {"sensor": 3}, # battery SoC sensor + "soc-at-start": 20.0, + "soc-min": 0.0, + "soc-max": 100.0, + "soc-targets": [ + {"datetime": "2024-01-01T23:00:00+01:00", "value": 80.0} + ], + "power-capacity": "20 kW", + "charging-efficiency": 0.95, + "discharging-efficiency": 0.95, + }, + { + "sensor": 2, # boiler power sensor + "commodity": "gas", + "power-capacity": "30 kW", + "consumption-capacity": "30 kW", + "production-capacity": "0 kW", + "soc-usage": ["1 kW"], + "soc-min": 0.0, + "soc-max": 0.0, + "soc-at-start": 0.0, + }, + ], + flex_context=[ + { + "commodity": "electricity", + "consumption-price": "100 EUR/MWh", + "production-price": "100 EUR/MWh", + }, + { + "commodity": "gas", + "consumption-price": "50 EUR/MWh", + }, + ], + ) + +The scheduler returns one schedule per device (stored on sensors ``1`` and ``2``) and a single commitment-cost result that breaks the cost down per commodity. + + +What to expect +============== + +The asset chart shows both commodities together, with the battery's stock level in between: + +.. image:: https://github.com/FlexMeasures/screenshots/raw/main/tut/multi-commodity.png + :align: center + :alt: Asset-level chart of the hybrid site, showing battery power, battery state of charge, and the gas boiler. +| + +Reading the chart top to bottom: + +- **Battery power (electricity)** charges at its full 20 kW for the first three hours, then makes one partial-power step, which compensates for its charging efficiency losses to land exactly on the 80 kWh target, and then sits idle for the rest of the day. In the final hour it discharges at −20 kW. Because the electricity price is flat, there is no cheaper window to wait for, so it simply charges as early as possible (``prefer-charging-sooner`` is on by default). +- **Battery state of charge** makes the effect of that power schedule explicit: the stock rises from the 20 kWh ``soc-at-start``, reaches the 80 kWh target during the morning, holds there through the idle hours, and drops in the final hour as the battery discharges. This is the charge level you would otherwise have to infer from the power curve. +- **Gas boiler (gas)** runs at exactly 1 kW every single hour. The ``soc-usage`` field makes this a fixed load that the optimiser cannot shift — its only effect on the result is the gas cost it incurs. + +The schedules match the cost figures reported by the scheduler: + +.. code-block:: text + + Electricity (battery) + Net charge needed : 80 kWh − 20 kWh = 60 kWh stored + Grid draw : 60 kWh ÷ 0.95 = 63.16 kWh + Charge cost : 63.16 kWh × 100 EUR/MWh ≈ 6.32 EUR + Discharge credit : 20 kWh × 100 EUR/MWh = −2.00 EUR + Net electricity ≈ 4.32 EUR + + Gas (boiler) + Consumption : 1 kW × 24 h = 24 kWh + Gas cost : 0.024 MWh × 50 EUR/MWh = 1.20 EUR + + Total = 5.52 EUR + +The commitment-cost result keeps these as separate entries — ``electricity net energy`` (≈ 4.32 EUR) and ``gas net energy`` (1.20 EUR) — so you can always see how much each commodity contributed. + +.. note:: This same pattern extends to more devices and more commodities. Add further entries to the ``flex-model`` list (each with its ``commodity``) and a matching entry in the ``flex-context`` ``commodities`` list. As long as all commodities share one currency, FlexMeasures optimises them together and reports each commodity's cost on its own. + +We hope this demonstration helped to illustrate multi-commodity scheduling. +To revisit scheduling several devices that share a single commodity and stock, head back to :ref:`tut_multi_feed_storage`. diff --git a/documentation/tut/multi-feed-storage.rst b/documentation/tut/multi-feed-storage.rst new file mode 100644 index 0000000000..d74ac306e2 --- /dev/null +++ b/documentation/tut/multi-feed-storage.rst @@ -0,0 +1,224 @@ +.. _tut_multi_feed_storage: + +A flex-modeling tutorial for storage: Multiple feeds into shared stock +---------------------------------------------------------------------- + +So far, our storage tutorials have considered a single power port charging and discharging a single battery. +But what if a battery is fed by *more than one* inverter, each with its own power rating and efficiency? + +This is a common situation in practice: a single storage tank or battery pack is connected to several converters, and they all charge and discharge the *same* pool of energy. +FlexMeasures supports this through what we call **multiple feeds into a shared stock**: several flexible devices are scheduled together, while they all point at one shared ``state-of-charge`` sensor. + +In this tutorial we will model exactly such a system and let the scheduler decide which inverter to use, and when, taking each inverter's efficiency into account. +(For a more general introduction to flex modeling, see :ref:`describing_flexibility`. For a single-device storage walk-through, see :ref:`tut_v2g`.) + + +The use case +============ + +Consider a single battery with two inverters feeding it, and a single state-of-charge sensor for the battery: + +- Both inverters can charge and discharge the battery, but with **different efficiencies**. +- The battery has a **single state of charge** that both inverters affect. +- The scheduler should recognise the shared stock and optimise accordingly, without duplicating baselines or costs. + +Concretely, we model: + +- A ``battery`` asset, with a ``power`` sensor (the aggregate) and an instantaneous ``state-of-charge`` sensor (in kWh). +- Two ``inverter`` assets (``inverter 1`` and ``inverter 2``), each with its own ``power`` sensor, rated at 20 kW. +- Inverter 1 is symmetric and efficient in both directions (95% charging, 95% discharging). +- Inverter 2 charges almost loss-free (99%) but discharges poorly (45%). + +The battery starts at 20 kWh, may not drop below 10 kWh or exceed 200 kWh, and has to reach a target of 189 kWh at noon. + + +Building the flex model +======================= + +The key idea is that the ``flex-model`` is a **list**, with one entry per flexible device, plus one entry that describes the shared stock. +Each inverter entry references its own power sensor *and* the same ``state-of-charge`` sensor. +The final entry (without a power ``sensor``) carries the constraints that apply to the shared stock itself: the start, the bounds, and the target. + +.. code-block:: json + + { + "flex-model": [ + { + "sensor": 1, + "state-of-charge": {"sensor": 4}, + "power-capacity": "20 kW", + "charging-efficiency": 0.95, + "discharging-efficiency": 0.95 + }, + { + "sensor": 2, + "state-of-charge": {"sensor": 4}, + "power-capacity": "20 kW", + "charging-efficiency": 0.99, + "discharging-efficiency": 0.45 + }, + { + "state-of-charge": {"sensor": 4}, + "soc-at-start": 20.0, + "soc-min": 10, + "soc-max": 200.0, + "soc-targets": [ + {"datetime": "2024-01-01T12:00:00+01:00", "value": 189.0} + ] + } + ] + } + +Here, sensors ``1`` and ``2`` are the power sensors of inverter 1 and inverter 2, respectively, and sensor ``4`` is the shared ``state-of-charge`` sensor on the battery. + +A few things to note: + +- **Each device points at the same ``state-of-charge`` sensor.** This is what tells FlexMeasures that the devices share one stock. The scheduler links the energy balance of all feeds to that single state of charge, rather than tracking a separate stock per device. +- **The shared-stock entry has no power ``sensor``.** It only carries the storage-level fields (``soc-at-start``, ``soc-min``, ``soc-max``, ``soc-targets``), which describe the battery as a whole and must therefore not be repeated per inverter. +- **Per-device efficiencies live in the device entries.** ``charging-efficiency`` and ``discharging-efficiency`` differ between the two inverters, which is exactly the difference the scheduler will exploit. + +.. note:: The ``state-of-charge`` sensor should have an instantaneous resolution (``PT0M``), since it records a stock value at a point in time rather than a quantity accumulated over an interval. See the ``state-of-charge`` field in :ref:`flex_models_and_schedulers`. + +For the costs, we use a flat tariff in this example, so price differences over time do not drive the schedule, only the efficiency differences do: + +.. code-block:: json + + { + "flex-context": { + "consumption-price": "100 EUR/MWh", + "production-price": "100 EUR/MWh" + } + } + + +Triggering the schedule +======================= + +We schedule on the **battery asset**, so that FlexMeasures considers both inverters together as feeds into the battery's shared stock. + +.. tabs:: + + .. tab:: CLI + + .. code-block:: bash + + $ flexmeasures add schedule \ + --asset 1 \ + --start 2024-01-01T00:00+01:00 \ + --duration PT24H \ + --flex-model flex-model-multi-feed.json \ + --flex-context flex-context-flat-price.json + New schedule is stored. + + .. tab:: API + + Example call: `[POST] http://localhost:5000/api/v3_0/assets/1/schedules/trigger <../api/v3_0.html#post--api-v3_0-assets-id-schedules-trigger>`_: + + .. code-block:: json + + { + "start": "2024-01-01T00:00:00+01:00", + "duration": "PT24H", + "flex-model": [ + { + "sensor": 1, + "state-of-charge": {"sensor": 4}, + "power-capacity": "20 kW", + "charging-efficiency": 0.95, + "discharging-efficiency": 0.95 + }, + { + "sensor": 2, + "state-of-charge": {"sensor": 4}, + "power-capacity": "20 kW", + "charging-efficiency": 0.99, + "discharging-efficiency": 0.45 + }, + { + "state-of-charge": {"sensor": 4}, + "soc-at-start": 20.0, + "soc-min": 10, + "soc-max": 200.0, + "soc-targets": [ + {"datetime": "2024-01-01T12:00:00+01:00", "value": 189.0} + ] + } + ], + "flex-context": { + "consumption-price": "100 EUR/MWh", + "production-price": "100 EUR/MWh" + } + } + + .. tab:: FlexMeasures Client + + Using the `FlexMeasures Client `_: + + .. code-block:: python + + schedule = await client.trigger_and_get_schedule( + asset_id=1, # the battery asset + start="2024-01-01T00:00:00+01:00", + duration="PT24H", + flex_model=[ + { + "sensor": 1, # inverter 1 power sensor + "state-of-charge": {"sensor": 4}, + "power-capacity": "20 kW", + "charging-efficiency": 0.95, + "discharging-efficiency": 0.95, + }, + { + "sensor": 2, # inverter 2 power sensor + "state-of-charge": {"sensor": 4}, + "power-capacity": "20 kW", + "charging-efficiency": 0.99, + "discharging-efficiency": 0.45, + }, + { + "state-of-charge": {"sensor": 4}, # shared stock + "soc-at-start": 20.0, + "soc-min": 10, + "soc-max": 200.0, + "soc-targets": [ + {"datetime": "2024-01-01T12:00:00+01:00", "value": 189.0} + ], + }, + ], + flex_context={ + "consumption-price": "100 EUR/MWh", + "production-price": "100 EUR/MWh", + }, + ) + + +The scheduler returns one schedule per inverter (stored on sensors ``1`` and ``2``), the resulting state of charge (stored on the shared ``state-of-charge`` sensor ``4``), and a single, aggregated commitment-cost result. +Note that the costs are *not* duplicated per device: because the inverters feed one shared stock, FlexMeasures computes a single energy balance and a single cost. + + +What to expect +============== + +With a flat tariff, the schedule is driven purely by the efficiency differences between the two inverters. +The scheduler specialises each inverter for the operation it is best at: + +- **Charging** happens through **inverter 2** (99% charging efficiency). It charges continuously from the start until the battery reaches the 189 kWh target at noon. Inverter 1 stays idle while charging. +- **Discharging** happens through **inverter 1** (95% discharging efficiency, versus only 45% for inverter 2). After the target is reached, inverter 1 discharges the battery back down towards its ``soc-min`` of 10 kWh. Inverter 2 stays idle while discharging. + +So, even though both inverters *can* both charge and discharge, the optimiser uses inverter 2 only to charge and inverter 1 only to discharge — each inverter ends up doing what it is most efficient at. + +.. image:: https://github.com/FlexMeasures/screenshots/raw/main/tut/multi-feed-asset.png + :align: center +| + +Reading the chart top to bottom: + +- **Inverters** (top panel) shows the power schedule of both feeds together. Inverter 2 (the 99%-efficient charger) runs at its full +20 kW from the start of the horizon and tapers off in a single partial step once the target is reached — it only ever charges. Inverter 1 (the 95%-efficient discharger) stays idle while the battery fills, then runs at -20 kW late in the horizon — it only ever discharges. Even though both inverters *can* do both, the optimiser specialises each for the operation it is most efficient at. +- **Shared storage** (bottom panel) shows the *single* ``state-of-charge`` sensor that both inverters feed. It starts at the 20 kWh ``soc-at-start``, climbs while inverter 2 charges, reaches and briefly holds the 189 kWh target, and then falls as inverter 1 discharges — bottoming out at the 10 kWh ``soc-min``. This one curve is the combined effect of both feeds, which is exactly what "shared stock" means. + +The net energy cost over the horizon is small (about 0.066 EUR at 100 EUR/MWh), and reflects only the energy lost to the inverter efficiencies, since charging and discharging happen at the same flat price. + +.. note:: This same pattern generalises beyond two inverters and beyond batteries. Any number of devices can feed a shared stock — for example, several heat pumps charging one thermal buffer — as long as each device entry references the same ``state-of-charge`` sensor and a single entry carries the shared-stock constraints. + +We hope this demonstration helped to illustrate how FlexMeasures schedules multiple feeds into a shared stock. +For modelling a single storage device in more depth, head back to :ref:`tut_v2g`. diff --git a/flexmeasures/api/common/schemas/scheduling.py b/flexmeasures/api/common/schemas/scheduling.py index f33c5ee555..92c02329e5 100644 --- a/flexmeasures/api/common/schemas/scheduling.py +++ b/flexmeasures/api/common/schemas/scheduling.py @@ -1,6 +1,6 @@ from flexmeasures.api.common.schemas.utils import make_openapi_compatible from flexmeasures.data.schemas.scheduling.storage import StorageFlexModelSchema -from flexmeasures.data.schemas.scheduling import FlexContextSchema +from flexmeasures.data.schemas.scheduling import CommodityFlexContextSchema from flexmeasures.data.schemas.sensors import SensorIdField @@ -18,4 +18,4 @@ } ], ) -flex_context_schema_openAPI = make_openapi_compatible(FlexContextSchema) +flex_context_schema_openAPI = make_openapi_compatible(CommodityFlexContextSchema) diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index e4599e2737..5e645b2216 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -96,12 +96,14 @@ def __init__(self, *args, **kwargs): kwargs["exclude"] = ["asset"] super().__init__(*args, **kwargs) - flex_context = fields.Nested( - flex_context_schema_openAPI, - load_default={}, + flex_context = fields.List( + fields.Nested( + flex_context_schema_openAPI(), + ), + load_default=[], data_key="flex-context", metadata=dict( - description="The flex-context is validated according to the scheduler's `FlexContextSchema`.", + description="Flex-context per commodity. The flex-context is validated according to the scheduler's `FlexContextSchema`.", ), ) flex_model = fields.List( diff --git a/flexmeasures/api/v3_0/tests/test_asset_schedules_fresh_db.py b/flexmeasures/api/v3_0/tests/test_asset_schedules_fresh_db.py index aac1b80ffa..616a83b5b6 100644 --- a/flexmeasures/api/v3_0/tests/test_asset_schedules_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_asset_schedules_fresh_db.py @@ -11,6 +11,8 @@ from flexmeasures import Sensor from flexmeasures.api.v3_0.tests.utils import message_for_trigger_schedule from flexmeasures.data.models.planning.tests.utils import check_constraints +from flexmeasures.data.models.generic_assets import GenericAsset +from flexmeasures.data.models.time_series import TimedBelief from flexmeasures.utils.job_utils import work_on_rq from flexmeasures.data.services.scheduling import ( handle_scheduling_exception, @@ -290,3 +292,488 @@ def compute_expected_length( sum(power_schedule[cheapest_hour * 4 : (cheapest_hour + 1) * 4]) > 0 ), "we expect to charge in the cheapest hour" assert_almost_equal(power_schedule, expected_uni_schedule) + + +@pytest.mark.parametrize( + "requesting_user", ["test_prosumer_user@seita.nl"], indirect=True +) +def test_asset_trigger_and_get_aggregate_schedule( + app, + fresh_db, + add_market_prices_fresh_db, + setup_roles_users_fresh_db, + add_charging_station_assets_fresh_db, + keep_scheduling_queue_empty, + requesting_user, +): + """Test that aggregate-consumption and aggregate-production flex-context fields get filled with data. + + This test verifies: + 1. Aggregate-consumption sensor receives the total consumption schedule with correct sign + 2. Aggregate-production sensor receives the total production schedule with correct sign + 3. The data source is correctly set to the scheduler + 4. The sign convention matches the scheduler's output (consumption positive, production negative) + """ + # Set up charging hub with aggregate sensors + bidirectional_charging_station = add_charging_station_assets_fresh_db[ + "Test charging station (bidirectional)" + ] + charging_hub = bidirectional_charging_station.parent_asset + + # Create aggregate consumption and production sensors on the hub + aggregate_consumption_sensor = Sensor( + name="aggregate-consumption", + generic_asset=charging_hub, + unit="MW", + event_resolution=pd.Timedelta(minutes=15), + ) + aggregate_production_sensor = Sensor( + name="aggregate-production", + generic_asset=charging_hub, + unit="MW", + event_resolution=pd.Timedelta(minutes=15), + ) + fresh_db.session.add(aggregate_consumption_sensor) + fresh_db.session.add(aggregate_production_sensor) + fresh_db.session.flush() + + # Set up price sensor + price_sensor_id = add_market_prices_fresh_db["epex_da"].id + + # Create flex-model with both charging stations + bidirectional_charging_station = add_charging_station_assets_fresh_db[ + "Test charging station (bidirectional)" + ] + charging_station = add_charging_station_assets_fresh_db["Test charging station"] + + sensor_1 = bidirectional_charging_station.sensors[0] + sensor_2 = charging_station.sensors[0] + bi_soc_sensor = add_charging_station_assets_fresh_db["bi-soc"] + uni_soc_sensor = add_charging_station_assets_fresh_db["uni-soc"] + + # Build the message with aggregate sensors in flex-context + message = message_for_trigger_schedule(resolution="PT30M") + message["flex-context"] = { + "consumption-price": {"sensor": price_sensor_id}, + "production-price": {"sensor": price_sensor_id}, + "site-power-capacity": "1 TW", + "aggregate-consumption": {"sensor": aggregate_consumption_sensor.id}, + "aggregate-production": {"sensor": aggregate_production_sensor.id}, + } + + # Set up flex-models for both charging stations + CP_1_flex_model = message["flex-model"].copy() + CP_1_flex_model["state-of-charge"] = {"sensor": bi_soc_sensor.id} + CP_1_flex_model["sensor"] = sensor_1.id + + CP_2_flex_model = message["flex-model"].copy() + CP_2_flex_model["state-of-charge"] = {"sensor": uni_soc_sensor.id} + CP_2_flex_model["sensor"] = sensor_2.id + + message["flex-model"] = [CP_1_flex_model, CP_2_flex_model] + + # Trigger the schedule + assert len(app.queues["scheduling"]) == 0 + with app.test_client() as client: + trigger_response = client.post( + url_for("AssetAPI:trigger_schedule", id=charging_hub.id), + json=message, + ) + assert trigger_response.status_code == 200 + job_id = trigger_response.json["schedule"] + + # Process the scheduling queue + scheduled_jobs = app.queues["scheduling"].jobs + scheduling_job = scheduled_jobs[0] + work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception) + assert ( + Job.fetch(job_id, connection=app.queues["scheduling"].connection).is_finished + is True + ) + + # Verify scheduler data source was created + scheduling_job.refresh() + scheduler_source = get_data_source_for_job(scheduling_job) + assert scheduler_source is not None + + # Verify aggregate-consumption sensor got filled with data + consumption_beliefs = ( + TimedBelief.query.filter( + TimedBelief.sensor_id == aggregate_consumption_sensor.id + ) + .filter(TimedBelief.source_id == scheduler_source.id) + .all() + ) + assert len(consumption_beliefs) > 0, "aggregate-consumption sensor should have data" + + # Extract consumption schedule (consumption is positive in the scheduler) + consumption_schedule = pd.Series( + [ + -v.event_value for v in consumption_beliefs + ], # Negate because DB stores consumption as negative + index=pd.DatetimeIndex([v.event_start for v in consumption_beliefs]), + ) + + # Verify aggregate-production sensor got filled with data + production_beliefs = ( + TimedBelief.query.filter( + TimedBelief.sensor_id == aggregate_production_sensor.id + ) + .filter(TimedBelief.source_id == scheduler_source.id) + .all() + ) + assert len(production_beliefs) > 0, "aggregate-production sensor should have data" + + # Extract production schedule (production is negative in the scheduler, but stored as positive in DB for production sensors) + production_schedule = pd.Series( + [v.event_value for v in production_beliefs], + index=pd.DatetimeIndex([v.event_start for v in production_beliefs]), + ) + + # Verify sign conventions: some values should be positive (consumption), some negative (production) + # At least one consumption value should be positive + assert ( + consumption_schedule > 0 + ).any(), "consumption schedule should have some positive values" + + # For a test with charging, we might not have discharge, so production could be all zeros + # But we still verify the schedule structure is correct + assert ( + production_schedule >= 0 + ).all(), "production schedule should have non-negative values (production flows are positive)" + + +@pytest.mark.parametrize( + "requesting_user", ["test_prosumer_user@seita.nl"], indirect=True +) +def test_asset_trigger_with_multi_commodity_flex_context( + app, + fresh_db, + add_market_prices_fresh_db, + setup_roles_users_fresh_db, + add_charging_station_assets_fresh_db, + keep_scheduling_queue_empty, + requesting_user, +): + """Test aggregate sensors with multi-commodity flex-context (electricity and heat). + + This test verifies that: + 1. Multi-commodity flex-context (list format) works correctly + 2. Each commodity has its own aggregate sensors + 3. Devices with different commodities are scheduled together + 4. Aggregate sensors correctly sum their respective commodity's power flows + """ + from flexmeasures.data.models.generic_assets import GenericAssetType + + # Set up charging hub + bidirectional_cs = add_charging_station_assets_fresh_db[ + "Test charging station (bidirectional)" + ] + charging_hub = bidirectional_cs.parent_asset + + # Create a heat device (boiler) as a sibling to the charging stations + boiler_asset_type = GenericAssetType(name="boiler") + fresh_db.session.add(boiler_asset_type) + fresh_db.session.flush() + + boiler = GenericAsset( + name="Test boiler", + owner=charging_hub.owner, + generic_asset_type=boiler_asset_type, + parent_asset=charging_hub, + latitude=10, + longitude=100, + attributes=dict( + is_consumer=True, + is_producer=False, + can_shift=True, + ), + ) + boiler_power_sensor = Sensor( + name="power", + generic_asset=boiler, + unit="MW", + event_resolution=pd.Timedelta(minutes=15), + ) + boiler_soc_sensor = Sensor( + name="heat-soc", + generic_asset=boiler, + unit="MWh", + event_resolution=pd.Timedelta(minutes=0), + ) + fresh_db.session.add(boiler) + fresh_db.session.add(boiler_power_sensor) + fresh_db.session.add(boiler_soc_sensor) + fresh_db.session.flush() + + # Create aggregate sensors for each commodity + agg_consumption_electricity = Sensor( + name="aggregate-consumption-electricity", + generic_asset=charging_hub, + unit="MW", + event_resolution=pd.Timedelta(minutes=15), + ) + agg_production_electricity = Sensor( + name="aggregate-production-electricity", + generic_asset=charging_hub, + unit="MW", + event_resolution=pd.Timedelta(minutes=15), + ) + agg_consumption_heat = Sensor( + name="aggregate-consumption-heat", + generic_asset=charging_hub, + unit="MW", + event_resolution=pd.Timedelta(minutes=15), + ) + fresh_db.session.add(agg_consumption_electricity) + fresh_db.session.add(agg_production_electricity) + fresh_db.session.add(agg_consumption_heat) + fresh_db.session.flush() + + # Set up price sensors + price_sensor_id = add_market_prices_fresh_db["epex_da"].id + + # Get the charging station + charging_station_uni = add_charging_station_assets_fresh_db["Test charging station"] + sensor_uni = charging_station_uni.sensors[0] + soc_uni_sensor = add_charging_station_assets_fresh_db["uni-soc"] + + # Build the message with multi-commodity flex-context as a LIST + message = message_for_trigger_schedule(resolution="PT30M") + + # Multi-commodity flex-context as a LIST of commodity contexts + message["flex-context"] = [ + { + "commodity": "electricity", + "consumption-price": {"sensor": price_sensor_id}, + "production-price": {"sensor": price_sensor_id}, + "site-power-capacity": "1 TW", + "aggregate-consumption": {"sensor": agg_consumption_electricity.id}, + "aggregate-production": {"sensor": agg_production_electricity.id}, + }, + { + "commodity": "heat", + "consumption-price": {"sensor": price_sensor_id}, + "site-consumption-capacity": "100 kW", + "site-production-capacity": "0 kW", + "aggregate-consumption": {"sensor": agg_consumption_heat.id}, + }, + ] + + # Set up flex-models for electricity (charging station) and heat (boiler) + flex_model_electricity = message["flex-model"].copy() + flex_model_electricity["state-of-charge"] = {"sensor": soc_uni_sensor.id} + flex_model_electricity["sensor"] = sensor_uni.id + flex_model_electricity["commodity"] = "electricity" + + flex_model_heat = { + "sensor": boiler_power_sensor.id, + "commodity": "heat", + "state-of-charge": {"sensor": boiler_soc_sensor.id}, + "soc-at-start": 10.0, + "soc-min": 0, + "soc-max": 20.0, + "soc-unit": "MWh", + "power-capacity": "1 MW", + "roundtrip-efficiency": "98%", + "storage-efficiency": "99.99%", + } + + message["flex-model"] = [flex_model_electricity, flex_model_heat] + + # Trigger the schedule + assert len(app.queues["scheduling"]) == 0 + with app.test_client() as client: + trigger_response = client.post( + url_for("AssetAPI:trigger_schedule", id=charging_hub.id), + json=message, + ) + if trigger_response.status_code != 200: + print(f"Error response: {trigger_response.json}") + assert trigger_response.status_code == 200 + job_id = trigger_response.json["schedule"] + + # Process the scheduling queue + scheduled_jobs = app.queues["scheduling"].jobs + scheduling_job = scheduled_jobs[0] + work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception) + assert ( + Job.fetch(job_id, connection=app.queues["scheduling"].connection).is_finished + is True + ) + + # Verify scheduler data source + scheduling_job.refresh() + scheduler_source = get_data_source_for_job(scheduling_job) + assert scheduler_source is not None + + # Verify electricity aggregate-consumption sensor got filled + consumption_beliefs_elec = ( + TimedBelief.query.filter( + TimedBelief.sensor_id == agg_consumption_electricity.id + ) + .filter(TimedBelief.source_id == scheduler_source.id) + .all() + ) + assert ( + len(consumption_beliefs_elec) > 0 + ), "electricity aggregate-consumption should have data" + + # Verify electricity aggregate-production sensor got filled + production_beliefs_elec = ( + TimedBelief.query.filter(TimedBelief.sensor_id == agg_production_electricity.id) + .filter(TimedBelief.source_id == scheduler_source.id) + .all() + ) + assert ( + len(production_beliefs_elec) > 0 + ), "electricity aggregate-production should have data" + + # Verify heat aggregate-consumption sensor got filled + consumption_beliefs_heat = ( + TimedBelief.query.filter(TimedBelief.sensor_id == agg_consumption_heat.id) + .filter(TimedBelief.source_id == scheduler_source.id) + .all() + ) + assert ( + len(consumption_beliefs_heat) > 0 + ), "heat aggregate-consumption should have data" + + # Verify data types are correct + assert all( + isinstance(v.event_value, (int, float)) or v.event_value is None + for v in consumption_beliefs_elec + ), "electricity consumption values should be numeric" + assert all( + isinstance(v.event_value, (int, float)) or v.event_value is None + for v in consumption_beliefs_heat + ), "heat consumption values should be numeric" + + +@pytest.mark.parametrize( + "requesting_user", ["test_prosumer_user@seita.nl"], indirect=True +) +def test_asset_trigger_with_flex_context_commodity_not_used( + app, + fresh_db, + add_market_prices_fresh_db, + setup_roles_users_fresh_db, + add_charging_station_assets_fresh_db, + keep_scheduling_queue_empty, + requesting_user, +): + """Test multi-commodity flex-context where one commodity is not used by any device. + + This test verifies that: + 1. Commodities in flex-context but not used in flex-model don't cause errors + 2. Aggregate sensors for unused commodities receive no data (which is expected) + 3. Devices for other commodities are scheduled normally + """ + # Set up charging hub + bidirectional_cs = add_charging_station_assets_fresh_db[ + "Test charging station (bidirectional)" + ] + charging_hub = bidirectional_cs.parent_asset + + # Create aggregate sensors for electricity and heat + agg_consumption_electricity = Sensor( + name="aggregate-consumption-elec-unused", + generic_asset=charging_hub, + unit="MW", + event_resolution=pd.Timedelta(minutes=15), + ) + agg_consumption_heat = Sensor( + name="aggregate-consumption-heat-unused", + generic_asset=charging_hub, + unit="MW", + event_resolution=pd.Timedelta(minutes=15), + ) + fresh_db.session.add(agg_consumption_electricity) + fresh_db.session.add(agg_consumption_heat) + fresh_db.session.flush() + + # Set up price sensors + price_sensor_id = add_market_prices_fresh_db["epex_da"].id + + # Get the charging station + charging_station_uni = add_charging_station_assets_fresh_db["Test charging station"] + sensor_uni = charging_station_uni.sensors[0] + soc_uni_sensor = add_charging_station_assets_fresh_db["uni-soc"] + + # Build the message with multi-commodity flex-context + message = message_for_trigger_schedule(resolution="PT30M") + + # Multi-commodity flex-context with both electricity and heat commodities + # But only electricity devices in flex-model + message["flex-context"] = [ + { + "commodity": "electricity", + "consumption-price": {"sensor": price_sensor_id}, + "production-price": {"sensor": price_sensor_id}, + "site-power-capacity": "1 TW", + "aggregate-consumption": {"sensor": agg_consumption_electricity.id}, + }, + { + "commodity": "heat", + "consumption-price": {"sensor": price_sensor_id}, + "site-consumption-capacity": "100 kW", + "site-production-capacity": "0 kW", + "aggregate-consumption": {"sensor": agg_consumption_heat.id}, + }, + ] + + # Only electricity flex-model (no heat device) + flex_model_electricity = message["flex-model"].copy() + flex_model_electricity["state-of-charge"] = {"sensor": soc_uni_sensor.id} + flex_model_electricity["sensor"] = sensor_uni.id + flex_model_electricity["commodity"] = "electricity" + + message["flex-model"] = [flex_model_electricity] + + # Trigger the schedule + assert len(app.queues["scheduling"]) == 0 + with app.test_client() as client: + trigger_response = client.post( + url_for("AssetAPI:trigger_schedule", id=charging_hub.id), + json=message, + ) + if trigger_response.status_code != 200: + print(f"Error response: {trigger_response.json}") + assert trigger_response.status_code == 200 + job_id = trigger_response.json["schedule"] + + # Process the scheduling queue + scheduled_jobs = app.queues["scheduling"].jobs + scheduling_job = scheduled_jobs[0] + work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception) + assert ( + Job.fetch(job_id, connection=app.queues["scheduling"].connection).is_finished + is True + ) + + # Verify scheduler data source + scheduling_job.refresh() + scheduler_source = get_data_source_for_job(scheduling_job) + assert scheduler_source is not None + + # Verify electricity aggregate-consumption sensor got filled + consumption_beliefs_elec = ( + TimedBelief.query.filter( + TimedBelief.sensor_id == agg_consumption_electricity.id + ) + .filter(TimedBelief.source_id == scheduler_source.id) + .all() + ) + assert ( + len(consumption_beliefs_elec) > 0 + ), "electricity aggregate-consumption should have data" + + # Verify heat aggregate-consumption sensor is empty (no heat device) + consumption_beliefs_heat = ( + TimedBelief.query.filter(TimedBelief.sensor_id == agg_consumption_heat.id) + .filter(TimedBelief.source_id == scheduler_source.id) + .all() + ) + assert ( + len(consumption_beliefs_heat) == 0 + ), "heat aggregate-consumption should be empty since no heat device was scheduled" diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index d7fbbb43b9..0f8c606d2f 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -14,7 +14,7 @@ from flexmeasures.data import db from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.models.generic_assets import GenericAsset as Asset -from flexmeasures.utils.coding_utils import deprecated +from flexmeasures.utils.coding_utils import deprecated, merge_or_append from .exceptions import WrongEntityException @@ -220,8 +220,19 @@ def collect_flex_config(self): asset = self.asset else: asset = self.sensor.generic_asset + + # Merge the passed flex_context with the db_flex_context by matching commodities db_flex_context = asset.get_flex_context() - self.flex_context = {**db_flex_context, **self.flex_context} + if isinstance(self.flex_context, dict): + self.flex_context = {**db_flex_context, **self.flex_context} + elif isinstance(self.flex_context, list): + # Currently, db_flex_context is always a dict describing only electricity + merge_or_append( + db_flex_context, + self.flex_context, + match_key="commodity", + match_value="electricity", + ) # Merge the passed flex_model with the db_flex_model by matching asset IDs db_flex_model = asset.get_flex_model() diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index aba6b95132..4c0cd1c57e 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -8,7 +8,7 @@ import pandas as pd import numpy as np from flask import current_app - +from marshmallow import ValidationError from flexmeasures import Asset, Sensor from flexmeasures.data import db @@ -32,6 +32,7 @@ from flexmeasures.data.models.planning.exceptions import InfeasibleProblemException from flexmeasures.data.schemas.scheduling.storage import StorageFlexModelSchema from flexmeasures.data.schemas.scheduling import ( + CommodityFlexContextSchema, FlexContextSchema, MultiSensorFlexModelSchema, ) @@ -1300,6 +1301,8 @@ def convert_to_commitments( for d, flex_model_d in enumerate(flex_model): commitment = FlowCommitment( device=d, + # todo: is flex_model_d guaranteed to have "commodity? Consider defaulting the device commodity to "electricity" + # todo: should there not be something matching the "commodity" from the commitment_spec (default to "electricity") to the device commodity? device_group=flex_model_d["commodity"], **commitment_spec, ) @@ -1337,8 +1340,48 @@ def deserialize_flex_config(self): self.flex_model = {} self.collect_flex_config() - self.flex_context = FlexContextSchema().load(self.flex_context) + self._deserialize_flex_context() + self._deserialize_flex_model() + + def _deserialize_flex_context(self): + if isinstance(self.flex_context, dict): + # Load the one flex-context for electricity + self.flex_context = FlexContextSchema().load(self.flex_context) + elif isinstance(self.flex_context, list): + # Load each flex-context per commodity + for g, commodity_flex_context in enumerate(self.flex_context): + self.flex_context[g] = CommodityFlexContextSchema().load( + commodity_flex_context + ) + + # Ensure all flex-contexts share the same currency unit + # todo: move this into a validator for FlexContextSchema.commodity_contexts? + shared_currency_unit = None + for commodity_flex_context in self.flex_context: + shared_currency_unit = commodity_flex_context["shared_currency_unit"] + if shared_currency_unit is None: + shared_currency_unit = commodity_flex_context[ + "shared_currency_unit" + ] + elif ( + commodity_flex_context["shared_currency_unit"] + != shared_currency_unit + ): + raise ValidationError( + f"All prices in the flex-context must share the same currency unit (in this case: '{shared_currency_unit}')." + ) + + # Nest the flex-contexts per commodity under the commodity_contexts field + self.flex_context = dict( + commodity_contexts=self.flex_context, + shared_currency_unit=shared_currency_unit, + ) + else: + raise TypeError( + f"Unsupported type of flex-context: '{type(self.flex_context)}'" + ) + def _deserialize_flex_model(self): if isinstance(self.flex_model, dict): if self.sensor.generic_asset.asset_type.name in storage_asset_types: self.ensure_soc_at_start() @@ -2079,6 +2122,153 @@ def _build_consumption_production_schedules( ) return schedules + def _compute_commodity_aggregate_schedules( + self, + storage_schedule: dict, + ems_schedule: pd.DataFrame, + # sensors: list[Sensor | None], + ) -> None: + """Compute per-commodity aggregate power flows for aggregate-consumption and aggregate-production sensors. + + This method populates the storage_schedule dict with aggregate schedules for each commodity + that defines aggregate-consumption and/or aggregate-production sensors in its commodity context. + + The sign convention and split logic follows the same pattern as _build_consumption_production_schedules: + - Only aggregate-consumption defined: full aggregate schedule (consumption +, production -) + - Only aggregate-production defined: full aggregate schedule (consumption +, production -) + (sign will be flipped by make_schedule based on consumption_is_positive=False) + - Both defined: consumption sensor gets non-negative part, production sensor gets non-positive part + (sign will be flipped for production by make_schedule) + + For backwards compatibility, when no commodity_contexts are defined, all devices are treated + as electricity devices and use the top-level flex-context fields. + + :param storage_schedule: Dict to populate with aggregate schedules (will be modified in-place) + :param ems_schedule: DataFrame of per-device power schedules in MW (consumption positive) + :param sensors: List of sensors corresponding to device indices + """ + # Get the device models to reconstruct commodity_to_devices mapping + flex_model = getattr(self, "_device_models", None) + if flex_model is None: + # Fallback: reconstruct if not available (shouldn't happen in normal flow) + flex_model = ( + self.flex_model.copy() + if isinstance(self.flex_model, dict) + else [fm for fm in self.flex_model if fm.get("sensor") is not None] + ) + if not isinstance(flex_model, list): + flex_model = [flex_model] + + # Reconstruct commodity_to_devices mapping + commodity_to_devices = {} + for d, flex_model_d in enumerate(flex_model): + commodity = flex_model_d.get("commodity", "electricity") + commodity_to_devices.setdefault(commodity, []).append(d) + + # Add inflexible devices to commodities, mirroring _prepare()'s device + # enumeration so the device indices line up with ems_schedule: + # - top-level inflexible-device-sensors go to electricity (backwards compat), + # - then each commodity context's own inflexible-device-sensors are appended to + # that commodity, in the order the commodity contexts are given. + # Without step 2 below, a commodity's inflexible demand (e.g. a heat load) is left + # out of its aggregate schedule, so an aggregate-consumption sensor only reflects + # the flexible devices of that commodity. + inflexible_device_sensors = self.flex_context.get( + "inflexible_device_sensors", [] + ) + number_flexible_devices = len(flex_model) + commodity_to_devices.setdefault("electricity", []).extend( + range( + number_flexible_devices, + number_flexible_devices + len(inflexible_device_sensors), + ) + ) + + # Per-commodity inflexible devices, enumerated after the top-level ones. + num_devices = number_flexible_devices + len(inflexible_device_sensors) + for commodity_context in self.flex_context.get("commodity_contexts", []): + commodity = commodity_context["commodity"] + commodity_inflexible_device_sensors = commodity_context.get( + "inflexible_device_sensors", [] + ) + commodity_to_devices.setdefault(commodity, []).extend( + range( + num_devices, + num_devices + len(commodity_inflexible_device_sensors), + ) + ) + num_devices += len(commodity_inflexible_device_sensors) + + # Get commodity contexts (handles backwards compatibility) + commodity_contexts = self._get_commodity_contexts() + + # Process each commodity + for commodity, devices in commodity_to_devices.items(): + commodity_context = commodity_contexts.get(commodity, {}) + + # Get aggregate sensors for this commodity + aggregate_consumption_field = commodity_context.get("aggregate_consumption") + aggregate_production_field = commodity_context.get("aggregate_production") + + # Extract sensor objects + aggregate_consumption_sensor = ( + aggregate_consumption_field.get("sensor") + if isinstance(aggregate_consumption_field, dict) + and "sensor" in aggregate_consumption_field + else None + ) + aggregate_production_sensor = ( + aggregate_production_field.get("sensor") + if isinstance(aggregate_production_field, dict) + and "sensor" in aggregate_production_field + else None + ) + + # Skip if no aggregate sensors defined for this commodity + if ( + aggregate_consumption_sensor is None + and aggregate_production_sensor is None + ): + continue + + # Sum the schedules for all devices in this commodity + # ems_schedule is a list of Series, one per device + device_indices = [d for d in devices if d < len(ems_schedule)] + + # If no devices contribute to this commodity's aggregate, skip it + # (e.g., heat commodity with no heat devices) + if not device_indices: + continue + + commodity_aggregate = sum(ems_schedule[d] for d in device_indices) + + # Apply split logic based on which sensors are defined + if ( + aggregate_consumption_sensor is not None + and aggregate_production_sensor is None + ): + # Only consumption sensor: full aggregate schedule + # (consumption positive, production negative) + storage_schedule[aggregate_consumption_sensor] = commodity_aggregate + + elif ( + aggregate_production_sensor is not None + and aggregate_consumption_sensor is None + ): + # Only production sensor: full aggregate schedule in native convention + # make_schedule will flip the sign via consumption_is_positive=False + storage_schedule[aggregate_production_sensor] = commodity_aggregate + + else: + # Both sensors defined: split into consumption (>=0) and production (<=0) parts + # make_schedule will flip the sign for production sensor via consumption_is_positive=False + storage_schedule[aggregate_consumption_sensor] = ( + commodity_aggregate.clip(lower=0) + ) + storage_schedule[aggregate_production_sensor] = ( + commodity_aggregate.clip(upper=0) + ) + 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. @@ -2133,8 +2323,13 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: aggregate_power_sensor = self.flex_context.get("aggregate_power", None) if isinstance(aggregate_power_sensor, Sensor): storage_schedule[aggregate_power_sensor] = pd.concat( - ems_schedule, axis=1 + ems_schedule, + axis=1, # todo: select only electric devices (flexible and inflexible) ).sum(axis=1) + # Compute per-commodity aggregate power flows for aggregate-consumption and aggregate-production sensors + self._compute_commodity_aggregate_schedules( + storage_schedule, ems_schedule # , sensors + ) # Convert each device schedule to the unit of the device's power sensor storage_schedule = { diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 49528382d4..d9ffc8a2dc 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -763,20 +763,18 @@ def test_mixed_gas_and_electricity_assets(app, db): }, ] - flex_context = { - "commodities": [ - { - "commodity": "electricity", - "consumption-price": "100 EUR/MWh", # electricity price - "production-price": "100 EUR/MWh", - }, - { - "commodity": "gas", - "consumption-price": "50 EUR/MWh", # gas price - "production-price": "50 EUR/MWh", - }, - ] - } + flex_context = [ + { + "commodity": "electricity", + "consumption-price": "100 EUR/MWh", # electricity price + "production-price": "100 EUR/MWh", + }, + { + "commodity": "gas", + "consumption-price": "50 EUR/MWh", # gas price + "production-price": "50 EUR/MWh", + }, + ] scheduler = StorageScheduler( asset_or_sensor=battery, diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 3cd8dc50d3..5bd8021e6a 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -23,6 +23,7 @@ VariableQuantityField, SensorIdField, SensorReference, + OutputSensorReferenceSchema, ) from flexmeasures.data.schemas.scheduling import metadata from flexmeasures.data.schemas.units import UnitField @@ -143,6 +144,8 @@ class DBCommitmentSchema(CommitmentSchema, NoTimeSeriesSpecs): class SharedSchema(Schema): + """Shared schema for fields common across commodities in flex-context and commodity-context.""" + consumption_price = VariableQuantityField( "/MWh", required=False, @@ -233,51 +236,7 @@ class SharedSchema(Schema): metadata=metadata.SITE_PEAK_PRODUCTION_PRICE.to_dict(), ) - commitments = fields.Nested( - CommitmentSchema, - data_key="commitments", - required=False, - many=True, - metadata=metadata.COMMITMENTS.to_dict(), - ) - - inflexible_device_sensors = fields.List( - SensorIdField(), - data_key="inflexible-device-sensors", - metadata=metadata.INFLEXIBLE_DEVICE_SENSORS.to_dict(), - ) - - -class CommodityFlexContextSchema(SharedSchema): - commodity = fields.Str( - required=False, - load_default="electricity", - data_key="commodity", - metadata=metadata.COMMODITY_FLEX_CONTEXT.to_dict(), - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - commodity_field = self.fields.pop("commodity") - self.fields = OrderedDict( - [("commodity", commodity_field), *self.fields.items()] - ) - - -class FlexContextSchema(SharedSchema): - """This schema defines fields that provide context to the portfolio to be optimized.""" - - commodity_contexts = fields.Nested( - CommodityFlexContextSchema, - data_key="commodities", - required=False, - many=True, - metadata=dict( - description="For multi-commodity scheduling problems, the above fields can be set here per commodity.", - ), - ) - # Device commitments + # Breach prices for device capacity constraints consumption_breach_price = VariableQuantityField( "/MW", data_key="consumption-breach-price", @@ -306,12 +265,13 @@ class FlexContextSchema(SharedSchema): value_validator=validate.Range(min=0), metadata=metadata.SOC_MAXIMA_BREACH_PRICE.to_dict(), ) + + # Relaxation fields relax_constraints = fields.Bool( data_key="relax-constraints", load_default=False, metadata=metadata.RELAX_CONSTRAINTS.to_dict(), ) - # Dev fields relax_soc_constraints = fields.Bool( data_key="relax-soc-constraints", load_default=False, @@ -328,17 +288,32 @@ class FlexContextSchema(SharedSchema): metadata=metadata.RELAX_SITE_CAPACITY_CONSTRAINTS.to_dict(), ) - # Energy commitments - # todo: deprecated since flexmeasures==0.23 - consumption_price_sensor = SensorIdField(data_key="consumption-price-sensor") - production_price_sensor = SensorIdField(data_key="production-price-sensor") + commitments = fields.Nested( + CommitmentSchema, + data_key="commitments", + required=False, + many=True, + metadata=metadata.COMMITMENTS.to_dict(), + ) - # todo: group by month start (MS), something like a commitment resolution, or a list of datetimes representing splits of the commitments - aggregate_power = VariableQuantityField( - to_unit="MW", - data_key="aggregate-power", + inflexible_device_sensors = fields.List( + SensorIdField(), + data_key="inflexible-device-sensors", + metadata=metadata.INFLEXIBLE_DEVICE_SENSORS.to_dict(), + ) + + # Aggregate output sensors + aggregate_consumption = fields.Nested( + OutputSensorReferenceSchema, required=False, - metadata=metadata.AGGREGATE_POWER.to_dict(), + data_key="aggregate-consumption", + metadata=metadata.AGGREGATE_CONSUMPTION.to_dict(), + ) + aggregate_production = fields.Nested( + OutputSensorReferenceSchema, + required=False, + data_key="aggregate-production", + metadata=metadata.AGGREGATE_PRODUCTION.to_dict(), ) def set_default_breach_prices( @@ -357,6 +332,109 @@ def set_default_breach_prices( ) return data + @validates_schema(pass_original=True) + def _try_to_convert_price_units(self, data: dict, original_data: dict, **kwargs): + """Convert price units to the same unit and scale if they can (incl. same currency).""" + + shared_currency_unit = None + previous_field_name = None + for field in self.declared_fields: + if field[-5:] == "price" and field in data: + price_field = self.declared_fields[field] + price_unit = price_field._get_unit(data[field]) + currency_unit = str( + ( + ur.Quantity(price_unit) / ur.Quantity(f"1{price_field.to_unit}") + ).units + ) + + 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 + original_price_unit = price_field._get_original_unit( + original_data[field_name], data[field] + ) + error_message = f"Invalid unit. A valid unit would be, for example, '{shared_currency_unit + price_field.to_unit}' (this example uses '{shared_currency_unit}', because '{previous_field_name}' used that currency). However, you passed an incompatible price ('{original_price_unit}') for the '{field_name}' field." + 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) + if shared_currency_unit is not None: + data["shared_currency_unit"] = shared_currency_unit + elif sensor := data.get("consumption_price_sensor"): + data["shared_currency_unit"] = self._to_currency_per_mwh(sensor.unit) + elif sensor := data.get("production_price_sensor"): + data["shared_currency_unit"] = self._to_currency_per_mwh(sensor.unit) + else: + data["shared_currency_unit"] = "EUR" + return data + + @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. + + >>> FlexContextSchema()._to_currency_per_mwh("EUR/MWh") + 'EUR' + >>> FlexContextSchema()._to_currency_per_mwh("EUR/kWh") + 'EUR' + """ + currency = str(ur.Quantity(price_unit + " * MWh").to_base_units().units) + return currency + + +class CommodityFlexContextSchema(SharedSchema): + commodity = fields.Str( + required=False, + load_default="electricity", + data_key="commodity", + metadata=metadata.COMMODITY_FLEX_CONTEXT.to_dict(), + ) + + # For flex-context listings (per commodity), default relax_constraints to True + relax_constraints = fields.Bool( + data_key="relax-constraints", + load_default=True, + metadata=metadata.RELAX_CONSTRAINTS.to_dict(), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + commodity_field = self.fields.pop("commodity") + self.fields = OrderedDict( + [("commodity", commodity_field), *self.fields.items()] + ) + + +class FlexContextSchema(SharedSchema): + """This schema defines fields that provide context to the portfolio to be optimized.""" + + commodity_contexts = fields.Nested( + CommodityFlexContextSchema, + data_key="commodities", + required=False, + many=True, + metadata=dict( + description="For multi-commodity scheduling problems, the above fields can be set here per commodity.", + ), + ) + + # Energy commitments + # todo: deprecated since flexmeasures==0.23 + consumption_price_sensor = SensorIdField(data_key="consumption-price-sensor") + production_price_sensor = SensorIdField(data_key="production-price-sensor") + + # todo: group by month start (MS), something like a commitment resolution, or a list of datetimes representing splits of the commitments + aggregate_power = VariableQuantityField( + to_unit="MW", + data_key="aggregate-power", + required=False, + metadata=metadata.AGGREGATE_POWER.to_dict(), + ) + @validates("aggregate_power") def validate_aggregate_power_is_sensor( self, @@ -370,6 +448,42 @@ def validate_aggregate_power_is_sensor( if not isinstance(aggregate_power, Sensor): raise ValidationError("The `aggregate-power` field can only be a Sensor.") + @validates("commodity_contexts") + def validate_commodity_contexts_shared_currency( + self, commodity_contexts: list[dict], **kwargs + ): + """Validate that all prices across commodity contexts share the same currency.""" + if not commodity_contexts: + return + + shared_currency_unit = None + + for context in commodity_contexts: + # Check all price fields in this context + for field_name, field_value in context.items(): + if field_name.endswith("_price") and field_value is not None: + # Get the price unit + if hasattr(field_value, "units"): + price_unit = str(field_value.units) + elif isinstance(field_value, ur.Quantity): + price_unit = str(field_value.units) + else: + continue + + # Extract currency from the price unit + # Price units are typically like "EUR/MWh" or "USD/MW" + # Split by "/" and take first part as currency + currency_unit = price_unit.split("/")[0].strip() + + if shared_currency_unit is None: + shared_currency_unit = str( + ur.Quantity(currency_unit).to_base_units().units + ) + elif not units_are_convertible(currency_unit, shared_currency_unit): + raise ValidationError( + "all prices in the flex-context must share the same currency unit" + ) + @validates_schema(pass_original=True) def check_prices(self, data: dict, original_data: dict, **kwargs): """Check assumptions about prices. @@ -466,57 +580,6 @@ def check_prices(self, data: dict, original_data: dict, **kwargs): return data - def _try_to_convert_price_units(self, data: dict, original_data: dict): - """Convert price units to the same unit and scale if they can (incl. same currency).""" - - shared_currency_unit = None - previous_field_name = None - for field in self.declared_fields: - if field[-5:] == "price" and field in data: - price_field = self.declared_fields[field] - price_unit = price_field._get_unit(data[field]) - currency_unit = str( - ( - ur.Quantity(price_unit) / ur.Quantity(f"1{price_field.to_unit}") - ).units - ) - - 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 - original_price_unit = price_field._get_original_unit( - original_data[field_name], data[field] - ) - error_message = f"Invalid unit. A valid unit would be, for example, '{shared_currency_unit + price_field.to_unit}' (this example uses '{shared_currency_unit}', because '{previous_field_name}' used that currency). However, you passed an incompatible price ('{original_price_unit}') for the '{field_name}' field." - 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) - if shared_currency_unit is not None: - data["shared_currency_unit"] = shared_currency_unit - elif sensor := data.get("consumption_price_sensor"): - data["shared_currency_unit"] = self._to_currency_per_mwh(sensor.unit) - elif sensor := data.get("production_price_sensor"): - data["shared_currency_unit"] = self._to_currency_per_mwh(sensor.unit) - else: - data["shared_currency_unit"] = "EUR" - return data - - @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. - - >>> FlexContextSchema()._to_currency_per_mwh("EUR/MWh") - 'EUR' - >>> FlexContextSchema()._to_currency_per_mwh("EUR/kWh") - 'EUR' - """ - currency = str(ur.Quantity(price_unit + " * MWh").to_base_units().units) - return currency - EXAMPLE_UNIT_TYPES: Dict[str, list[str]] = { "commodity": ["electricity", "gas"], @@ -529,6 +592,26 @@ def _to_currency_per_mwh(price_unit: str) -> str: } UI_FLEX_CONTEXT_SCHEMA: Dict[str, Dict[str, Any]] = { + "aggregate-consumption": { + "default": None, + "description": rst_to_openapi(metadata.AGGREGATE_CONSUMPTION.description), + # todo: the field type is defined in asset_context.html in 3 places? + # "types": { + # "backend": "typeTwo", + # "ui": "A sensor which records the scheduled aggregate consumption.", + # }, + "example-units": EXAMPLE_UNIT_TYPES["power"], + }, + "aggregate-production": { + "default": None, + "description": rst_to_openapi(metadata.AGGREGATE_PRODUCTION.description), + # todo: the field type is defined in asset_context.html in 3 places? + # "types": { + # "backend": "typeTwo", + # "ui": "A sensor which records the scheduled aggregate production.", + # }, + "example-units": EXAMPLE_UNIT_TYPES["power"], + }, "consumption-price": { "default": None, # Refers to default value of the field "description": rst_to_openapi(metadata.CONSUMPTION_PRICE.description), @@ -1045,7 +1128,7 @@ class AssetTriggerSchema(Schema): data_key="flex-model", load_default=[], ) - flex_context = fields.Dict( + flex_context = fields.Raw( required=False, data_key="flex-context", load_default={}, @@ -1064,6 +1147,37 @@ class AssetTriggerSchema(Schema): ), ) + @pre_load + def normalize_flex_context_format(self, data, **kwargs): + """Normalize flex_context to always be a dict. + + Accepts both: + - Single commodity dict: {"commodity": "electricity", ...} + - List of commodity dicts: [{"commodity": "electricity", ...}, {"commodity": "heat", ...}] + - MultiDict with multiple 'flex-context' entries (when JSON list is parsed by webargs) + + If a list is provided, it is wrapped under the 'commodities' field. + If a dict is provided, it is kept as-is. + This ensures downstream code always sees a dict structure. + """ + if "flex-context" in data: + raw_flex_context = data.get("flex-context") + + # Check if data is a MultiDict with multiple 'flex-context' entries + # This happens when JSON contains a list which webargs converts to multiple entries + if hasattr(data, "getlist"): + # MultiDict case - get all values for 'flex-context' + flex_contexts = data.getlist("flex-context") + if len(flex_contexts) > 1: + # Multiple commodities: wrap in a dict with commodity_contexts field + data["flex-context"] = {"commodities": flex_contexts} + # If only 1 entry, leave as-is (it's already a dict) + elif isinstance(raw_flex_context, list): + # Regular list case + data["flex-context"] = {"commodities": raw_flex_context} + # else: already a dict, leave as-is + return data + @validates_schema def check_flex_model_sensors(self, data, **kwargs): """Verify that the flex-model's sensors live under the asset for which a schedule is triggered.""" diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 7463579864..88d40b9ef9 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -42,9 +42,37 @@ def to_dict(self): example=[3, 4], ) AGGREGATE_POWER = MetaData( - description="""Sensor used to record the aggregate power schedule of all flexible and inflexible devices involved when scheduling this asset.""", + description="""[Deprecated field] Sensor used to record the aggregate power schedule of all flexible and inflexible devices involved when scheduling this asset. +To avoid using the field, use ``aggregate-consumption`` or ``aggregate-production`` instead, which make clear the sign convention. +""", example={"sensor": 9}, ) +AGGREGATE_CONSUMPTION = MetaData( + description="""Sensor used to record the aggregate consumption schedule of all flexible and inflexible devices involved when scheduling this asset. + +The sign convention is determined by the key name, and is stored on the sensor itself using the ``consumption_is_positive`` attribute. + +Depending on which output sensors are defined: + +- **Only** ``aggregate-consumption`` **defined**: the full aggregate power schedule is stored on this sensor using the + consumption-positive sign convention (consumption positive, production negative). +- **Only** ``aggregate-production`` **defined**: the full aggregate power schedule is stored on the aggregate-production sensor + with the production-positive convention (production positive, consumption negative). +- **Both defined**: only the non-negative part of the aggregate 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 + aggregate-production sensor. +""", + example={"sensor": 10}, +) +AGGREGATE_PRODUCTION = MetaData( + description="""Sensor used to record the aggregate production schedule of all flexible and inflexible devices involved when scheduling this asset. + +The sign convention is determined by the key name, and is stored on the sensor itself using the ``consumption_is_positive`` attribute. + +See the ``aggregate-consumption`` field for the full description of the split logic when both sensors are defined. +""", + example={"sensor": 11}, +) COMMITMENTS = MetaData( description="Prior commitments. Support for this field in the UI is still under further development, but you can find more information in :ref:`commitments`.", example=[], @@ -217,7 +245,7 @@ def to_dict(self): The sign convention is determined by the key name, and is stored on the sensor itself using the ``consumption_is_positive`` attribute. -See ``consumption`` for the full description of the split logic when both sensors are defined. +See the ``consumption`` field for the full description of the split logic when both sensors are defined. """, example={"sensor": 15}, ) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 07ad3b46c9..60d9da3448 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -20,7 +20,7 @@ from flexmeasures.data.schemas.scheduling import metadata from flexmeasures.data.schemas.sensors import ( SensorReference, - SensorReferenceSchema, + OutputSensorReferenceSchema, VariableQuantityField, ) from flexmeasures.utils.unit_utils import ( @@ -44,15 +44,6 @@ total=False, # not all are required (just value, which we can say in 3.11) ) -# Keys used by SensorReferenceSchema to carry source-filter options. -# Present as non-None values when the caller added a source filter. -_SENSOR_REFERENCE_SOURCE_FILTER_KEYS = ( - "source_types", - "exclude_source_types", - "sources", - "source_account", -) - class EfficiencyField(QuantityField): """Field that deserializes to a Quantity with % units. @@ -104,11 +95,11 @@ class StorageFlexModelSchema(Schema): ) consumption = fields.Nested( - SensorReferenceSchema, + OutputSensorReferenceSchema, metadata=metadata.CONSUMPTION.to_dict(), ) production = fields.Nested( - SensorReferenceSchema, + OutputSensorReferenceSchema, metadata=metadata.PRODUCTION.to_dict(), ) @@ -350,20 +341,6 @@ def validate_state_of_charge( "The `state-of-charge` field can only be a Sensor or a time series." ) - @validates("consumption") - def validate_consumption_has_no_source_filters(self, value: dict, **kwargs): - if isinstance(value, dict) and any( - value.get(key) is not None for key in _SENSOR_REFERENCE_SOURCE_FILTER_KEYS - ): - raise ValidationError("The `consumption` field cannot use source filters.") - - @validates("production") - def validate_production_has_no_source_filters(self, value: dict, **kwargs): - if isinstance(value, dict) and any( - value.get(key) is not None for key in _SENSOR_REFERENCE_SOURCE_FILTER_KEYS - ): - raise ValidationError("The `production` field cannot use source filters.") - @validates("asset") def validate_asset(self, asset: Asset, **kwargs): if self.sensor is not None and self.sensor.asset != asset: @@ -448,8 +425,8 @@ class DBStorageFlexModelSchema(Schema): Schema for flex-models stored in the db. Supports fixed quantities and sensor references, while disallowing time series specs. """ - consumption = fields.Nested(SensorReferenceSchema) - production = fields.Nested(SensorReferenceSchema) + consumption = fields.Nested(OutputSensorReferenceSchema) + production = fields.Nested(OutputSensorReferenceSchema) soc_min = VariableQuantityField( to_unit="MWh", @@ -591,20 +568,6 @@ def __init__(self, *args, **kwargs): for field in self.declared_fields } - @validates("consumption") - def validate_consumption_has_no_source_filters(self, value: dict, **kwargs): - if isinstance(value, dict) and any( - value.get(key) is not None for key in _SENSOR_REFERENCE_SOURCE_FILTER_KEYS - ): - raise ValidationError("The `consumption` field cannot use source filters.") - - @validates("production") - def validate_production_has_no_source_filters(self, value: dict, **kwargs): - if isinstance(value, dict) and any( - value.get(key) is not None for key in _SENSOR_REFERENCE_SOURCE_FILTER_KEYS - ): - raise ValidationError("The `production` field cannot use source filters.") - @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/sensors.py b/flexmeasures/data/schemas/sensors.py index fc510adf05..72694534a6 100644 --- a/flexmeasures/data/schemas/sensors.py +++ b/flexmeasures/data/schemas/sensors.py @@ -979,11 +979,7 @@ def event_resolution(self) -> timedelta: return self.sensor.event_resolution -class SensorReferenceSchema(Schema): - """Sensor reference with optional source filters.""" - - class Meta: - description = "Sensor reference from which to look up a variable quantity." +class SharedSensorReferenceSchema(Schema): sensor = SensorIdField( required=True, @@ -991,6 +987,20 @@ class Meta: description="ID of the sensor on which the data is recorded.", ), ) + + +class OutputSensorReferenceSchema(SharedSensorReferenceSchema): + """Sensor reference for recording generated data.""" + + ... + + +class SensorReferenceSchema(SharedSensorReferenceSchema): + """Sensor reference with optional source filters.""" + + class Meta: + description = "Sensor reference from which to look up a variable quantity." + source_types = fields.List( fields.String(), load_default=None, diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index 3d4450580e..aef5fbef9d 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -790,52 +790,6 @@ def test_flex_context_schema_rejects_filtered_aggregate_power( assert "cannot use source filters" in str(exc_info.value) -def test_storage_flex_model_schema_rejects_filtered_consumption( - setup_dummy_sensors, setup_sources, db -): - _, _, _, power_sensor = setup_dummy_sensors - seita_source = setup_sources["Seita"] - db.session.flush() - - for schema in [ - StorageFlexModelSchema(start=datetime(2026, 6, 1), sensor=None), - DBStorageFlexModelSchema(), - ]: - with pytest.raises(ValidationError) as exc_info: - schema.load( - { - "consumption": { - "sensor": power_sensor.id, - "sources": [seita_source.id], - } - } - ) - assert "cannot use source filters" in str(exc_info.value) - - -def test_storage_flex_model_schema_rejects_filtered_production( - setup_dummy_sensors, setup_sources, db -): - _, _, _, power_sensor = setup_dummy_sensors - seita_source = setup_sources["Seita"] - db.session.flush() - - for schema in [ - StorageFlexModelSchema(start=datetime(2026, 6, 1), sensor=None), - DBStorageFlexModelSchema(), - ]: - with pytest.raises(ValidationError) as exc_info: - schema.load( - { - "production": { - "sensor": power_sensor.id, - "sources": [seita_source.id], - } - } - ) - assert "cannot use source filters" in str(exc_info.value) - - @pytest.mark.parametrize( ["flex_model", "fails"], [ @@ -953,22 +907,231 @@ def test_flex_model_schemas( for schema, fail in zip(schemas, fails): if fail: - with pytest.raises(ValidationError) as e_info: + with pytest.raises(ValidationError) as e_info: # noqa: F841 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] - ] - ) + + +@pytest.mark.parametrize( + ["flex_context", "fails"], + [ + # Test aggregate-consumption field with sensor reference + ( + {"aggregate-consumption": {"sensor": "consumption-price in SEK/MWh"}}, + False, + ), + # Test aggregate-production field with sensor reference + ( + {"aggregate-production": {"sensor": "production-price in SEK/MWh"}}, + False, + ), + # Test both aggregate fields together + ( + { + "aggregate-consumption": {"sensor": "consumption-price in SEK/MWh"}, + "aggregate-production": {"sensor": "production-price in SEK/MWh"}, + }, + False, + ), + # Test that relax_constraints defaults to False in FlexContextSchema + ( + {"site-power-capacity": "1 MVA"}, + False, + ), + # Test breach prices moved to SharedSchema + ( + { + "consumption-breach-price": "100 EUR/MW", + "production-breach-price": "100 EUR/MW", + }, + False, + ), + # Test soc breach prices moved to SharedSchema + ( + { + "soc-minima-breach-price": "1000 EUR/MWh", + "soc-maxima-breach-price": "1000 EUR/MWh", + }, + False, + ), + ], +) +def test_shared_schema_fields_in_flex_context( + db, app, setup_site_capacity_sensor, setup_price_sensors, flex_context, fails +): + """Test that SharedSchema fields are accessible in FlexContextSchema.""" + schema = FlexContextSchema() + + # Replace sensor name with sensor ID + sensors_to_pick_from = {**setup_site_capacity_sensor, **setup_price_sensors} + for field_name, field_value in flex_context.items(): + if isinstance(field_value, dict) and "sensor" in field_value: + sensor_name = field_value["sensor"] + if sensor_name in sensors_to_pick_from: + flex_context[field_name]["sensor"] = sensors_to_pick_from[ + sensor_name + ].id + + check_schema_loads_data(schema=schema, data=flex_context, fails=fails) + + +@pytest.mark.parametrize( + ["commodity_contexts", "fails"], + [ + # Test single commodity pass validation and defaults relax_constraints to True + ( + [ + { + "commodity": "electricity", + "site-power-capacity": "1 MVA", + } + ], + False, + ), + # Likewise for multiple commodities, relax_constraints should default to True for each + ( + [ + { + "commodity": "electricity", + "site-power-capacity": "1 MVA", + }, + { + "commodity": "heat", + "site-power-capacity": "500 kW", + }, + ], + False, + ), + # Test aggregate fields in commodity context pass validation + ( + [ + { + "commodity": "electricity", + "aggregate-consumption": {"sensor": "consumption-price in SEK/MWh"}, + "aggregate-production": {"sensor": "production-price in SEK/MWh"}, + } + ], + False, + ), + # Test breach prices in commodity context pass validation + ( + [ + { + "commodity": "electricity", + "consumption-breach-price": "100 EUR/MW", + "production-breach-price": "100 EUR/MW", + } + ], + False, + ), + ], +) +def test_commodity_flex_context_defaults( + db, app, setup_site_capacity_sensor, setup_price_sensors, commodity_contexts, fails +): + """Test that CommodityFlexContextSchema has correct defaults, especially relax_constraints=True.""" + from flexmeasures.data.schemas.scheduling import CommodityFlexContextSchema + + # Replace sensor name with sensor ID + sensors_to_pick_from = {**setup_site_capacity_sensor, **setup_price_sensors} + for context in commodity_contexts: + for field_name, field_value in context.items(): + if isinstance(field_value, dict) and "sensor" in field_value: + sensor_name = field_value["sensor"] + if sensor_name in sensors_to_pick_from: + context[field_name]["sensor"] = sensors_to_pick_from[sensor_name].id + + # Test loading each commodity context + schema = CommodityFlexContextSchema() + for context in commodity_contexts: + if fails: + with pytest.raises(ValidationError) as e_info: + loaded = schema.load(context) + print(f"Returned error message: {e_info.value.messages}") else: - schema.load(flex_model) + loaded = schema.load(context) + # Verify relax_constraints defaults to True in CommodityFlexContextSchema + assert loaded.get("relax_constraints", True) is True + + +@pytest.mark.parametrize( + ["flex_context_listing", "fails"], + [ + # Test flex-context listing with mixed currencies should fail + ( + { + "commodities": [ + { + "commodity": "electricity", + "consumption-price": "1 EUR/MWh", + }, + { + "commodity": "heat", + "consumption-price": "1 USD/MWh", + }, + ] + }, + { + "commodities": "all prices in the flex-context must share the same currency unit" + }, + ), + # Test flex-context listing with same currencies should pass + ( + { + "commodities": [ + { + "commodity": "electricity", + "consumption-price": "1 EUR/MWh", + }, + { + "commodity": "heat", + "consumption-price": "2 EUR/MWh", + }, + ] + }, + False, + ), + # Test flex-context listing with breach prices sharing currency + ( + { + "commodities": [ + { + "commodity": "electricity", + "consumption-breach-price": "100 EUR/MW", + "production-breach-price": "10 cEUR/kW", + } + ] + }, + False, + ), + # Test flex-context listing with mixed breach price currencies should fail + ( + { + "commodities": [ + { + "commodity": "electricity", + "consumption-breach-price": "100 EUR/MW", + }, + { + "commodity": "heat", + "consumption-breach-price": "100 USD/MW", + }, + ] + }, + { + "commodities": "all prices in the flex-context must share the same currency unit" + }, + ), + ], +) +def test_flex_context_listing_shared_currency( + db, + app, + setup_site_capacity_sensor, + setup_price_sensors, + flex_context_listing, + fails, +): + """Test that flex-context listings enforce shared currency across commodities.""" + schema = FlexContextSchema() + + check_schema_loads_data(schema=schema, data=flex_context_listing, fails=fails) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 62a2bc16d7..9a6c7d9647 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4556,98 +4556,31 @@ ], "additionalProperties": false }, - "CommodityFlexContext": { + "OutputSensorReference": { + "type": "object", + "properties": { + "sensor": { + "type": "integer", + "description": "ID of the sensor on which the data is recorded." + } + }, + "required": [ + "sensor" + ], + "additionalProperties": false + }, + "FlexContextOpenAPISchema": { "type": "object", "properties": { "commodity": { "type": "string", "default": "electricity", - "description": "Commodity to which this part of the flex-context applies.\nDefaults to ``\"electricity\"``.\n", + "description": "Commodity to which this part of the flex-context applies.\nDefaults to \"electricity\".\n", "examples": [ "electricity", "gas" ] }, - "consumption-price": { - "description": "The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem. [#old_consumption_price_field]_", - "examples": [ - { - "sensor": 5 - }, - "0.29 EUR/kWh" - ] - }, - "production-price": { - "description": "The commodity price (e.g. electricity price) applied to the site's aggregate production. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem, as long as the unit matches the ``consumption-price`` unit. [#old_production_price_field]_", - "example": "0.12 EUR/kWh" - }, - "site-power-capacity": { - "description": "Maximum achievable power at the site's grid connection point, in either direction.\nBecomes a hard constraint in the optimization problem, which is especially suitable for physical limitations. [#asymmetric]_ [#minimum_capacity_overlap]_\n", - "example": "45kVA" - }, - "site-consumption-capacity": { - "description": "Maximum consumption power at the site's grid connection point.\nIf ``site-power-capacity`` is defined, the minimum between the ``site-power-capacity`` and ``site-consumption-capacity`` will be used. [#consumption]_\nIf a ``site-consumption-breach-price`` is defined, the ``site-consumption-capacity`` becomes a soft constraint in the optimization problem.\nOtherwise, it becomes a hard constraint. [#minimum_capacity_overlap]_\n", - "example": "45kW" - }, - "site-production-capacity": { - "description": "Maximum production power at the site's grid connection point.\nIf ``site-power-capacity`` is defined, the minimum between the ``site-power-capacity`` and ``site-production-capacity`` will be used. [#production]_\nIf a ``site-production-breach-price`` is defined, the ``site-production-capacity`` becomes a soft constraint in the optimization problem.\nOtherwise, it becomes a hard constraint. [#minimum_capacity_overlap]_\n", - "example": "0kW" - }, - "site-consumption-breach-price": { - "description": "This **penalty value** is used to discourage the violation of the ``site-consumption-capacity`` constraint in the flex-context.\nIt effectively treats the capacity as a **soft constraint**, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\nThe field may define (a sensor recording) contractual penalties, or a theoretical penalty influencing how badly breaches should be avoided. [#penalty_field]_ [#breach_field]_\n", - "example": "1000 EUR/kW" - }, - "site-production-breach-price": { - "description": "This **penalty value** is used to discourage the violation of the ``site-production-capacity`` constraint in the flex-context.\nIt effectively treats the capacity as a **soft constraint**, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\nThe field may define (a sensor recording) contractual penalties, or a theoretical penalty influencing how badly breaches should be avoided. [#penalty_field]_ [#breach_field]_\"\n", - "example": "1000 EUR/kW" - }, - "site-peak-consumption": { - "default": "0.0 MW", - "description": "The site's previously achieved achieved peak consumption.\nThis value forms the baseline for new peak charges, since any peaks up to this level represent sunk costs.\nDefaults to 0 kW.\n", - "example": { - "sensor": 7 - } - }, - "site-peak-consumption-price": { - "description": "Per-kW price applied to any consumption that exceeds the site's previously achieved peak consumption.\nThis price reflects the cost of increasing the site\u2019s peak further and is used by the scheduler to motivate peak shaving.\nIt must use the same currency as the other price settings and cannot be negative.\nFor large connections, this price is usually stated explicitly on the tariff sheets of their network operator. [#penalty_field]_\n", - "example": "260 EUR/MW" - }, - "site-peak-production": { - "default": "0.0 MW", - "description": "The site's previously achieved achieved peak production.\nThis value forms the baseline for new peak charges, since any peaks up to this level represent sunk costs.\nDefaults to 0 kW.\n", - "example": { - "sensor": 8 - } - }, - "site-peak-production-price": { - "description": "Per-kW price applied to any production that exceeds the site's previously achieved peak production.\nThis price reflects the cost of increasing the site\u2019s peak further and is used by the scheduler to motivate peak shaving.\nIt must use the same currency as the other price settings and cannot be negative.\nFor large connections, this price is usually stated explicitly on the tariff sheets of their network operator. [#penalty_field]_\n", - "example": "260 EUR/MW" - }, - "commitments": { - "description": "Prior commitments. Support for this field in the UI is still under further development, but you can find more information in :ref:`commitments`.", - "example": [], - "type": "array", - "items": { - "$ref": "#/components/schemas/Commitment" - } - }, - "inflexible-device-sensors": { - "type": "array", - "description": "Power sensors representing devices that are relevant, but not flexible in the timing of their demand/supply.\nFor example, a sensor recording rooftop solar power that is connected behind the main meter, and whose production falls under the same contract as the flexible device(s) being scheduled.\nTheir power demand cannot be adjusted but still matters for finding the best schedule for other devices.\nMust be a list of integers.\n", - "example": [ - 3, - 4 - ], - "items": { - "type": "integer" - } - } - }, - "additionalProperties": false - }, - "FlexContextOpenAPISchema": { - "type": "object", - "properties": { "consumption-price": { "description": "The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem.", "examples": [ @@ -4712,32 +4645,6 @@ "example": "260 EUR/MW", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, - "commitments": { - "description": "Prior commitments. Support for this field in the UI is still under further development, but you can find more information in the docs.", - "example": [], - "type": "array", - "items": { - "$ref": "#/components/schemas/Commitment" - } - }, - "inflexible-device-sensors": { - "type": "array", - "description": "Power sensors representing devices that are relevant, but not flexible in the timing of their demand/supply.\nFor example, a sensor recording rooftop solar power that is connected behind the main meter, and whose production falls under the same contract as the flexible device(s) being scheduled.\nTheir power demand cannot be adjusted but still matters for finding the best schedule for other devices.\nMust be a list of integers.\n", - "example": [ - 3, - 4 - ], - "items": { - "type": "integer" - } - }, - "commodities": { - "description": "For multi-commodity scheduling problems, the above fields can be set here per commodity.", - "type": "array", - "items": { - "$ref": "#/components/schemas/CommodityFlexContext" - } - }, "consumption-breach-price": { "description": "This penalty value is used to discourage the violation of the consumption-capacity constraint in the flex-model.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\n", "example": "10 EUR/kW", @@ -4760,7 +4667,7 @@ }, "relax-constraints": { "type": "boolean", - "default": false, + "default": true, "description": "If True (default is False), several constraints are relaxed by setting default breach prices within the optimization problem, leading to the default priority:\n\n1. Avoid breaching the site consumption/production capacity.\n2. Avoid not meeting SoC minima/maxima.\n3. Avoid breaching the desired device consumption/production capacity.\n\nWe recommend to set this field to True to enable the default prices and associated priorities as defined by FlexMeasures.\nFor tighter control over prices and priorities, the breach prices can also be set explicitly (the relevant fields have breach-price in their name).\n", "example": true }, @@ -4782,18 +4689,38 @@ "description": "If True, avoids breaching the site consumption/production capacity as a relaxed constraint.", "example": true }, - "consumption-price-sensor": { - "type": "integer" + "commitments": { + "description": "Prior commitments. Support for this field in the UI is still under further development, but you can find more information in the docs.", + "example": [], + "type": "array", + "items": { + "$ref": "#/components/schemas/Commitment" + } }, - "production-price-sensor": { - "type": "integer" + "inflexible-device-sensors": { + "type": "array", + "description": "Power sensors representing devices that are relevant, but not flexible in the timing of their demand/supply.\nFor example, a sensor recording rooftop solar power that is connected behind the main meter, and whose production falls under the same contract as the flexible device(s) being scheduled.\nTheir power demand cannot be adjusted but still matters for finding the best schedule for other devices.\nMust be a list of integers.\n", + "example": [ + 3, + 4 + ], + "items": { + "type": "integer" + } }, - "aggregate-power": { - "description": "Sensor used to record the aggregate power schedule of all flexible and inflexible devices involved when scheduling this asset.", + "aggregate-consumption": { + "description": "Sensor used to record the aggregate consumption schedule of all flexible and inflexible devices involved when scheduling this asset.\n\nThe sign convention is determined by the key name, and is stored on the sensor itself using the consumption_is_positive attribute.\n\nDepending on which output sensors are defined:\n\n- Only aggregate-consumption defined: the full aggregate power schedule is stored on this sensor using the\n consumption-positive sign convention (consumption positive, production negative).\n- Only aggregate-production defined: the full aggregate power schedule is stored on the aggregate-production sensor\n with the production-positive convention (production positive, consumption negative).\n- Both defined: only the non-negative part of the aggregate schedule is stored on this sensor (zero for\n time steps with net production), and only the non-positive part (sign-flipped) is stored on the\n aggregate-production sensor.\n", "example": { - "sensor": 9 + "sensor": 10 }, - "$ref": "#/components/schemas/SensorReference" + "$ref": "#/components/schemas/OutputSensorReference" + }, + "aggregate-production": { + "description": "Sensor used to record the aggregate production schedule of all flexible and inflexible devices involved when scheduling this asset.\n\nThe sign convention is determined by the key name, and is stored on the sensor itself using the consumption_is_positive attribute.\n\nSee the aggregate-consumption field for the full description of the split logic when both sensors are defined.\n", + "example": { + "sensor": 11 + }, + "$ref": "#/components/schemas/OutputSensorReference" } }, "additionalProperties": false @@ -6176,14 +6103,14 @@ "example": { "sensor": 14 }, - "$ref": "#/components/schemas/SensorReference" + "$ref": "#/components/schemas/OutputSensorReference" }, "production": { - "description": "Sensor used to record the scheduled power as seen from a production perspective.\n\nThe sign convention is determined by the key name, and is stored on the sensor itself using the consumption_is_positive attribute.\n\nSee consumption for the full description of the split logic when both sensors are defined.\n", + "description": "Sensor used to record the scheduled power as seen from a production perspective.\n\nThe sign convention is determined by the key name, and is stored on the sensor itself using the consumption_is_positive attribute.\n\nSee the consumption field for the full description of the split logic when both sensors are defined.\n", "example": { "sensor": 15 }, - "$ref": "#/components/schemas/SensorReference" + "$ref": "#/components/schemas/OutputSensorReference" }, "soc-at-start": { "type": "string", @@ -6371,9 +6298,12 @@ } }, "flex-context": { - "default": {}, - "description": "The flex-context is validated according to the scheduler's `FlexContextSchema`.", - "$ref": "#/components/schemas/FlexContextOpenAPISchema" + "type": "array", + "default": [], + "description": "Flex-context per commodity. The flex-context is validated according to the scheduler's `FlexContextSchema`.", + "items": { + "$ref": "#/components/schemas/FlexContextOpenAPISchema" + } }, "sequential": { "type": "boolean", diff --git a/flexmeasures/ui/templates/assets/asset_context.html b/flexmeasures/ui/templates/assets/asset_context.html index 5c84a5a607..09a1348dfb 100644 --- a/flexmeasures/ui/templates/assets/asset_context.html +++ b/flexmeasures/ui/templates/assets/asset_context.html @@ -380,7 +380,7 @@ setTimeout(() => { const card = document.getElementById(`${fieldName}-control`); - const isOnlySensorField = (fieldName === "inflexible-device-sensors" || fieldName === "consumption-price" || fieldName === "production-price" || fieldName === "aggregate-power"); + const isOnlySensorField = (fieldName === "inflexible-device-sensors" || fieldName === "consumption-price" || fieldName === "production-price" || fieldName === "aggregate-power" || fieldName === "aggregate-consumption" || fieldName === "aggregate-production"); card.classList.add('border-on-click'); // Add border to the clicked card flexOptionsContainer.appendChild(renderFlexInputOptions(fieldName, isOnlySensorField)); handleFlexSelectChange(fieldName); @@ -608,7 +608,7 @@ document.querySelectorAll(".card-highlight").forEach(el => el.classList.remove("border-on-click")); // Remove border from all cards card.classList.add("border-on-click"); // Add border to the clicked card handleFlexSelectChange(fieldName); - const isOnlySensorField = (fieldName === "inflexible-device-sensors" || fieldName === "consumption-price" || fieldName === "production-price" || fieldName === "aggregate-power"); + const isOnlySensorField = (fieldName === "inflexible-device-sensors" || fieldName === "consumption-price" || fieldName === "production-price" || fieldName === "aggregate-power" || fieldName === "aggregate-consumption" || fieldName === "aggregate-production"); flexOptionsContainer.appendChild(renderFlexInputOptions(fieldName, (isOnlySensorField))); setActiveCard(card); // Store active card in local storage @@ -720,7 +720,7 @@ if (storedActiveCard && activeCard()) { const flexId = (activeCard() ? activeCard().id : null).slice(0, -8); if (assetFlexContext[storedActiveCard]) { - const isOnlySensorField = (flexId === "inflexible-device-sensors" || flexId === "consumption-price" || flexId === "production-price" || flexId === "aggregate-power"); + const isOnlySensorField = (flexId === "inflexible-device-sensors" || flexId === "consumption-price" || flexId === "production-price" || flexId === "aggregate-power" || flexId === "aggregate-consumption" || flexId === "aggregate-production"); setTimeout(() => { flexOptionsContainer.innerHTML = ""; flexOptionsContainer.appendChild(renderFlexInputOptions(flexId, isOnlySensorField)); diff --git a/flexmeasures/ui/tests/test_utils.py b/flexmeasures/ui/tests/test_utils.py index 4925ed44a2..eff2114fc6 100644 --- a/flexmeasures/ui/tests/test_utils.py +++ b/flexmeasures/ui/tests/test_utils.py @@ -92,7 +92,10 @@ def test_ui_flexcontext_schema(): assert ( schema_keys == ui_flexcontext_schema_fields - ), "If this test fails, you may have added a new flex-context field, but forgot about UI support." + ), "If this fails, you may have added a new flex-context field, but forgot about UI support." + assert ( + schema_keys - set(exclude_fields) == schema_keys + ), "If this fails, you may have added UI support for a new flex-context field, but forgot to remove it from exclude_fields." def test_ui_flexmodel_schema(): diff --git a/flexmeasures/utils/coding_utils.py b/flexmeasures/utils/coding_utils.py index 1ed43ce1f2..894846aa10 100644 --- a/flexmeasures/utils/coding_utils.py +++ b/flexmeasures/utils/coding_utils.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import Any import functools import time import inspect @@ -11,6 +12,34 @@ from flask import current_app +def merge_or_append( + item: dict[str, Any], + items: list[dict[str, Any]], + match_key: str, + match_value: str | None = None, +) -> None: + """Merge `item` into the first dictionary in `items` with the same value for `key`, preserving its position in the sequence. + + Values from `item` take precedence when keys overlap. If no matching + dictionary is found, `item` is appended to the end of `items`. + + :param item: The dictionary to merge or append. + :param items: A mutable sequence of dictionaries to update. + :param match_key: The dictionary key used to determine whether two items match. + :param match_value: The value used to determine whether two items match. + + :returns: None. The `items` sequence is modified in place. + """ + match_value = item.get(match_key) or match_value + + for i, existing in enumerate(items): + if existing.get(match_key) == match_value: + items[i] = existing | item + return + + items.append(item) + + def delete_key_recursive(value, key): """Delete key in a multilevel dictionary""" if isinstance(value, dict): diff --git a/tests/documentation/test_schemas.py b/tests/documentation/test_schemas.py index 0a85803103..fab2946647 100644 --- a/tests/documentation/test_schemas.py +++ b/tests/documentation/test_schemas.py @@ -13,6 +13,8 @@ "RELAX_CAPACITY_CONSTRAINTS", "RELAX_SITE_CAPACITY_CONSTRAINTS", "RELAX_SOC_CONSTRAINTS", + "COMMODITY_FLEX_CONTEXT", # Documented as "commodity" in flex-context section + "COMMODITY_FLEX_MODEL", # Documented as "commodity" in flex-model section }