From 5be555b73838e5e3cf923f800a2ba45681e1042f Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 3 Feb 2026 01:42:08 +0100 Subject: [PATCH 01/17] support commodity based EMS flow commitments and grouped devices Signed-off-by: Ahmad-Wahid --- .../models/planning/linear_optimization.py | 82 +++++++++++-------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 37bada383b..0dfc3ebb2a 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -135,6 +135,20 @@ def device_scheduler( # noqa C901 df["group"] = group commitments.append(df) + # commodity → set(device indices) + commodity_devices = {} + + for df in commitments: + if "commodity" not in df.columns or "device" not in df.columns: + continue + + for _, row in df[["commodity", "device"]].dropna().iterrows(): + devices = row["device"] + if not isinstance(devices, (list, tuple, set)): + devices = [devices] + + commodity_devices.setdefault(row["commodity"], set()).update(devices) + # Check if commitments have the same time window and resolution as the constraints for commitment in commitments: start_c = commitment.index.to_pydatetime()[0] @@ -579,45 +593,33 @@ def device_stock_commitment_equalities(m, c, j, d): ) def ems_flow_commitment_equalities(m, c, j): - """Couple EMS flows (sum over devices) to each commitment. + """ + Enforce an EMS-level flow commitment for a given commodity. - - Creates an inequality for one-sided commitments. - - Creates an equality for two-sided commitments and for groups of size 1. + Couples the commitment baseline (plus deviation variables) to the sum of EMS + power over all devices belonging to the commitment’s commodity. Skips + non-flow commitments or commodities without associated devices. """ - if ( - "device" in commitments[c].columns - and not pd.isnull(commitments[c]["device"]).all() - ) or m.commitment_quantity[c, j] == -infinity: - # Commitment c does not concern EMS + if commitments[c]["class"].iloc[0] != FlowCommitment: return Constraint.Skip - if ( - "class" in commitments[c].columns - and not ( - commitments[c]["class"].apply(lambda cl: cl == FlowCommitment) - ).all() - ): - raise NotImplementedError( - "StockCommitment on an EMS level has not been implemented. Please file a GitHub ticket explaining your use case." - ) + + commodity = ( + commitments[c]["commodity"].iloc[0] + if "commodity" in commitments[c].columns + else None + ) + devices = commodity_devices.get(commodity, set()) + + if not devices: + return Constraint.Skip + return ( - ( - 0 - if len(commitments[c]) == 1 - or "upwards deviation price" in commitments[c].columns - else None - ), - # 0 if "upwards deviation price" in commitments[c].columns else None, # todo: possible simplification + None, m.commitment_quantity[c, j] + m.commitment_downwards_deviation[c] + m.commitment_upwards_deviation[c] - - sum(m.ems_power[:, j]), - ( - 0 - if len(commitments[c]) == 1 - or "downwards deviation price" in commitments[c].columns - else None - ), - # 0 if "downwards deviation price" in commitments[c].columns else None, # todo: possible simplification + - sum(m.ems_power[d, j] for d in devices), + None, ) def device_derivative_equalities(m, d, j): @@ -718,6 +720,22 @@ def cost_function(m): ) model.commitment_costs = commitment_costs + commodity_costs = {} + for c in model.c: + commodity = None + if "commodity" in commitments[c].columns: + commodity = commitments[c]["commodity"].iloc[0] + if commodity is None or (isinstance(commodity, float) and np.isnan(commodity)): + continue + + cost = value( + model.commitment_downwards_deviation[c] * model.down_price[c] + + model.commitment_upwards_deviation[c] * model.up_price[c] + ) + commodity_costs[commodity] = commodity_costs.get(commodity, 0) + cost + + model.commodity_costs = commodity_costs + # model.pprint() # model.display() # print(results.solver.termination_condition) From 6f2d7450b96637e9e68fe312cc5062bc3059ff01 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 3 Feb 2026 01:43:40 +0100 Subject: [PATCH 02/17] Add commodity field and support multi-device commitments Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/__init__.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index a1e7cfa747..32b83314ee 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -288,8 +288,24 @@ class Commitment: quantity: pd.Series = 0 upwards_deviation_price: pd.Series = 0 downwards_deviation_price: pd.Series = 0 + commodity: str | pd.Series | None = None def __post_init__(self): + if ( + isinstance(self, FlowCommitment) + and isinstance(self.commodity, pd.Series) + and self.device is not None + ): + devices = extract_devices(self.device) + missing = set(devices) - set(self.commodity.index) + if missing: + raise ValueError(f"commodity mapping missing for devices: {missing}") + + if isinstance(self, FlowCommitment) and self.commodity is None: + raise ValueError( + "FlowCommitment requires `commodity` (str or pd.Series mapping device→commodity)" + ) + series_attributes = [ attr for attr, _type in self.__annotations__.items() @@ -412,12 +428,25 @@ def to_frame(self) -> pd.DataFrame: ], axis=1, ) - # map device → device_group + # device_group if self.device is not None: df["device_group"] = map_device_to_group(self.device, self.device_group) else: df["device_group"] = 0 + # commodity + if getattr(self, "commodity", None) is None: + df["commodity"] = None + elif isinstance(self.commodity, pd.Series): + # commodity is a device→commodity mapping, like device_group + if self.device is None: + df["commodity"] = None + else: + df["commodity"] = map_device_to_group(self.device, self.commodity) + else: + # scalar commodity + df["commodity"] = self.commodity + return df From 1d63893eaf0e3a3e4fa91f82634203291c2709da Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 3 Feb 2026 01:45:35 +0100 Subject: [PATCH 03/17] test shared buffer and multi-comodity commitments Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/tests/test_commitments.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 639813a090..7b99afcb88 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -118,6 +118,7 @@ def test_multi_feed_device_scheduler_shared_buffer(): downwards_deviation_price=prices[device_commodity[d]], device=pd.Series(d, index=index), device_group=device_commodity, + commodity=device_commodity[d], ) ) @@ -130,6 +131,7 @@ def test_multi_feed_device_scheduler_shared_buffer(): downwards_deviation_price=sloped_prices, device=pd.Series(d, index=index), device_group=device_commodity, + commodity=device_commodity[d], ) ) From 8c9b1b535c1121872b30139986093d2ec029ff2d Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 3 Feb 2026 02:09:28 +0100 Subject: [PATCH 04/17] remove hard check for commodity to make backward compatible Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 32b83314ee..2659faa3da 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -301,11 +301,6 @@ def __post_init__(self): if missing: raise ValueError(f"commodity mapping missing for devices: {missing}") - if isinstance(self, FlowCommitment) and self.commodity is None: - raise ValueError( - "FlowCommitment requires `commodity` (str or pd.Series mapping device→commodity)" - ) - series_attributes = [ attr for attr, _type in self.__annotations__.items() From a8f3b89a898345627e283320e3c32b40e7418f26 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 3 Feb 2026 02:10:42 +0100 Subject: [PATCH 05/17] update the function to support backward compatibility Signed-off-by: Ahmad-Wahid --- .../models/planning/linear_optimization.py | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 0dfc3ebb2a..4141042509 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -593,25 +593,22 @@ def device_stock_commitment_equalities(m, c, j, d): ) def ems_flow_commitment_equalities(m, c, j): - """ - Enforce an EMS-level flow commitment for a given commodity. + """Couple EMS flow commitments to device flows, optionally filtered by commodity.""" - Couples the commitment baseline (plus deviation variables) to the sum of EMS - power over all devices belonging to the commitment’s commodity. Skips - non-flow commitments or commodities without associated devices. - """ if commitments[c]["class"].iloc[0] != FlowCommitment: return Constraint.Skip - commodity = ( - commitments[c]["commodity"].iloc[0] - if "commodity" in commitments[c].columns - else None - ) - devices = commodity_devices.get(commodity, set()) - - if not devices: - return Constraint.Skip + # Legacy behavior: no commodity → sum over all devices + if "commodity" not in commitments[c].columns: + devices = m.d + else: + commodity = commitments[c]["commodity"].iloc[0] + if pd.isna(commodity): + devices = m.d + else: + devices = commodity_devices.get(commodity, set()) + if not devices: + return Constraint.Skip return ( None, From be09c4d88691cb68a6a8a72d5a3240be4b5122d0 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 10 Feb 2026 11:48:35 +0100 Subject: [PATCH 06/17] feat: add commodity field to the flexmodel and DBstorage-flex-model schemas Signed-off-by: Ahmad-Wahid --- .../data/schemas/scheduling/storage.py | 20 +++++++++++++++++++ flexmeasures/ui/static/openapi-specs.json | 9 +++++++++ 2 files changed, 29 insertions(+) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 9eaf1dcf7c..1154c4c97f 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -25,6 +25,8 @@ is_energy_unit, ) +ALLOWED_COMMODITIES = {"electricity", "gas"} + # Telling type hints what to expect after schema parsing SoCTarget = TypedDict( "SoCTarget", @@ -222,6 +224,12 @@ class StorageFlexModelSchema(Schema): validate=validate.Length(min=1), metadata=metadata.SOC_USAGE.to_dict(), ) + commodity = fields.Str( + required=False, + load_default="electricity", + validate=OneOf(["electricity", "gas"]), + metadata=dict(description="Commodity label for this device/asset."), + ) def __init__( self, @@ -343,6 +351,11 @@ def check_redundant_efficiencies(self, data: dict, **kwargs): f"Fields `{field}` and `roundtrip_efficiency` are mutually exclusive." ) + @validates("commodity") + def validate_commodity(self, commodity: str, **kwargs): + if not isinstance(commodity, str) or not commodity.strip(): + raise ValidationError("commodity must be a non-empty string.") + @post_load def post_load_sequence(self, data: dict, **kwargs) -> dict: """Perform some checks and corrections after we loaded.""" @@ -492,6 +505,13 @@ class DBStorageFlexModelSchema(Schema): metadata={"deprecated field": "production_capacity"}, ) + commodity = fields.Str( + required=False, + load_default="electricity", + validate=OneOf(["electricity", "gas"]), + metadata=dict(description="Commodity label for this device/asset."), + ) + mapped_schema_keys: dict def __init__(self, *args, **kwargs): diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 89629bb1cf..f98ea620fb 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5388,6 +5388,15 @@ } ], "items": {} + }, + "commodity": { + "type": "string", + "default": "electricity", + "enum": [ + "electricity", + "gas" + ], + "description": "Commodity label for this device/asset." } }, "additionalProperties": false From 07822eb8471db1f1bbbd7f0ad60e653f12b37ead Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Feb 2026 12:18:08 +0100 Subject: [PATCH 07/17] fix: use devices as index rather than time series Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 2659faa3da..5f0e6fe2e9 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -304,7 +304,9 @@ def __post_init__(self): series_attributes = [ attr for attr, _type in self.__annotations__.items() - if _type == "pd.Series" and hasattr(self, attr) + if _type == "pd.Series" + and hasattr(self, attr) + and attr not in ("device_group", "commodity") ] for series_attr in series_attributes: val = getattr(self, series_attr) @@ -374,6 +376,10 @@ def _init_device_group(self): range(len(devices)), index=devices, name="device_group" ) else: + if not isinstance(self.device_group, pd.Series): + self.device_group = pd.Series( + self.device_group, index=devices, name="device_group" + ) # Validate custom grouping missing = set(devices) - set(self.device_group.index) if missing: From 92eabc763ae081ab741ab414d09d607b238d870a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Feb 2026 12:50:30 +0100 Subject: [PATCH 08/17] fix: exclude gas-power devices from electricity commitments Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 370 +++++++++++-------- 1 file changed, 206 insertions(+), 164 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index a0d6f8da19..2b866878a4 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -238,7 +238,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Set up commitments to optimise for commitments = self.convert_to_commitments( - query_window=(start, end), resolution=resolution, beliefs_before=belief_time + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + flex_model=flex_model, ) index = initialize_index(start, end, resolution) @@ -257,188 +260,220 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) # Set up commitments DataFrame - commitment = FlowCommitment( - name="energy", - quantity=commitment_quantities, - upwards_deviation_price=commitment_upwards_deviation_price, - downwards_deviation_price=commitment_downwards_deviation_price, - index=index, - ) - commitments.append(commitment) - - # Set up peak commitments - if self.flex_context.get("ems_peak_consumption_price") is not None: - ems_peak_consumption = get_continuous_series_sensor_or_quantity( - variable_quantity=self.flex_context.get("ems_peak_consumption_in_mw"), - unit="MW", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - max_value=np.inf, # np.nan -> np.inf to ignore commitment if no quantity is given - fill_sides=True, - ) - ems_peak_consumption_price = self.flex_context.get( - "ems_peak_consumption_price" - ) - ems_peak_consumption_price = get_continuous_series_sensor_or_quantity( - variable_quantity=ems_peak_consumption_price, - unit=self.flex_context["shared_currency_unit"] + "/MW", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - fill_sides=True, - ) - - # Set up commitments DataFrame - commitment = FlowCommitment( - name="consumption peak", - quantity=ems_peak_consumption, - # positive price because breaching in the upwards (consumption) direction is penalized - upwards_deviation_price=ems_peak_consumption_price, - _type="any", - index=index, - ) - commitments.append(commitment) - if self.flex_context.get("ems_peak_production_price") is not None: - ems_peak_production = get_continuous_series_sensor_or_quantity( - variable_quantity=self.flex_context.get("ems_peak_production_in_mw"), - unit="MW", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - max_value=np.inf, # np.nan -> np.inf to ignore commitment if no quantity is given - fill_sides=True, - ) - ems_peak_production_price = self.flex_context.get( - "ems_peak_production_price" - ) - ems_peak_production_price = get_continuous_series_sensor_or_quantity( - variable_quantity=ems_peak_production_price, - unit=self.flex_context["shared_currency_unit"] + "/MW", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - fill_sides=True, - ) + for d, flex_model_d in enumerate(flex_model): + # todo: use the right commodity prices for non-electricity devices + if flex_model_d["commodity"] != "electricity": + continue - # Set up commitments DataFrame commitment = FlowCommitment( - name="production peak", - quantity=-ems_peak_production, # production is negative quantity - # negative price because peaking in the downwards (production) direction is penalized - downwards_deviation_price=-ems_peak_production_price, - _type="any", + # todo: report aggregate energy costs, too (need to be backwards compatible) + name=f"energy {d}", + quantity=commitment_quantities, + upwards_deviation_price=commitment_upwards_deviation_price, + downwards_deviation_price=commitment_downwards_deviation_price, index=index, + device=d, + device_group=flex_model_d["commodity"], ) commitments.append(commitment) - # Set up capacity breach commitments and EMS capacity constraints - ems_consumption_breach_price = self.flex_context.get( - "ems_consumption_breach_price" - ) + # Set up peak commitments + if self.flex_context.get("ems_peak_consumption_price") is not None: + ems_peak_consumption = get_continuous_series_sensor_or_quantity( + variable_quantity=self.flex_context.get( + "ems_peak_consumption_in_mw" + ), + unit="MW", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + max_value=np.inf, # np.nan -> np.inf to ignore commitment if no quantity is given + fill_sides=True, + ) + ems_peak_consumption_price = self.flex_context.get( + "ems_peak_consumption_price" + ) + ems_peak_consumption_price = get_continuous_series_sensor_or_quantity( + variable_quantity=ems_peak_consumption_price, + unit=self.flex_context["shared_currency_unit"] + "/MW", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + fill_sides=True, + ) - ems_production_breach_price = self.flex_context.get( - "ems_production_breach_price" - ) + # Set up commitments DataFrame + commitment = FlowCommitment( + name=f"consumption peak {d}", + quantity=ems_peak_consumption, + # positive price because breaching in the upwards (consumption) direction is penalized + upwards_deviation_price=ems_peak_consumption_price, + _type="any", + index=index, + device=d, + device_group=flex_model_d["commodity"], + ) + commitments.append(commitment) + if self.flex_context.get("ems_peak_production_price") is not None: + ems_peak_production = get_continuous_series_sensor_or_quantity( + variable_quantity=self.flex_context.get( + "ems_peak_production_in_mw" + ), + unit="MW", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + max_value=np.inf, # np.nan -> np.inf to ignore commitment if no quantity is given + fill_sides=True, + ) + ems_peak_production_price = self.flex_context.get( + "ems_peak_production_price" + ) + ems_peak_production_price = get_continuous_series_sensor_or_quantity( + variable_quantity=ems_peak_production_price, + unit=self.flex_context["shared_currency_unit"] + "/MW", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + fill_sides=True, + ) - ems_constraints = initialize_df( - StorageScheduler.COLUMNS, start, end, resolution - ) - if ems_consumption_breach_price is not None: + # Set up commitments DataFrame + commitment = FlowCommitment( + name=f"production peak {d}", + quantity=-ems_peak_production, # production is negative quantity + # negative price because peaking in the downwards (production) direction is penalized + downwards_deviation_price=-ems_peak_production_price, + _type="any", + index=index, + device=d, + device_group=flex_model_d["commodity"], + ) + commitments.append(commitment) - # Convert to Series - any_ems_consumption_breach_price = get_continuous_series_sensor_or_quantity( - variable_quantity=ems_consumption_breach_price, - unit=self.flex_context["shared_currency_unit"] + "/MW", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - fill_sides=True, - ) - all_ems_consumption_breach_price = get_continuous_series_sensor_or_quantity( - variable_quantity=ems_consumption_breach_price, - unit=self.flex_context["shared_currency_unit"] - + "/MW*h", # from EUR/MWh to EUR/MW/resolution - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - fill_sides=True, + # Set up capacity breach commitments and EMS capacity constraints + ems_consumption_breach_price = self.flex_context.get( + "ems_consumption_breach_price" ) - # Set up commitments DataFrame to penalize any breach - commitment = FlowCommitment( - name="any consumption breach", - quantity=ems_consumption_capacity, - # positive price because breaching in the upwards (consumption) direction is penalized - upwards_deviation_price=any_ems_consumption_breach_price, - _type="any", - index=index, + ems_production_breach_price = self.flex_context.get( + "ems_production_breach_price" ) - commitments.append(commitment) - # Set up commitments DataFrame to penalize each breach - commitment = FlowCommitment( - name="all consumption breaches", - quantity=ems_consumption_capacity, - # positive price because breaching in the upwards (consumption) direction is penalized - upwards_deviation_price=all_ems_consumption_breach_price, - index=index, + ems_constraints = initialize_df( + StorageScheduler.COLUMNS, start, end, resolution ) - commitments.append(commitment) + if ems_consumption_breach_price is not None: - # Take the physical capacity as a hard constraint - ems_constraints["derivative max"] = ems_power_capacity_in_mw - else: - # Take the contracted capacity as a hard constraint - ems_constraints["derivative max"] = ems_consumption_capacity + # Convert to Series + any_ems_consumption_breach_price = ( + get_continuous_series_sensor_or_quantity( + variable_quantity=ems_consumption_breach_price, + unit=self.flex_context["shared_currency_unit"] + "/MW", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + fill_sides=True, + ) + ) + all_ems_consumption_breach_price = ( + get_continuous_series_sensor_or_quantity( + variable_quantity=ems_consumption_breach_price, + unit=self.flex_context["shared_currency_unit"] + + "/MW*h", # from EUR/MWh to EUR/MW/resolution + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + fill_sides=True, + ) + ) - if ems_production_breach_price is not None: + # Set up commitments DataFrame to penalize any breach + commitment = FlowCommitment( + name=f"any consumption breach {d}", + quantity=ems_consumption_capacity, + # positive price because breaching in the upwards (consumption) direction is penalized + upwards_deviation_price=any_ems_consumption_breach_price, + _type="any", + index=index, + device=d, + device_group=flex_model_d["commodity"], + ) + commitments.append(commitment) - # Convert to Series - any_ems_production_breach_price = get_continuous_series_sensor_or_quantity( - variable_quantity=ems_production_breach_price, - unit=self.flex_context["shared_currency_unit"] + "/MW", - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - fill_sides=True, - ) - all_ems_production_breach_price = get_continuous_series_sensor_or_quantity( - variable_quantity=ems_production_breach_price, - unit=self.flex_context["shared_currency_unit"] - + "/MW*h", # from EUR/MWh to EUR/MW/resolution - query_window=(start, end), - resolution=resolution, - beliefs_before=belief_time, - fill_sides=True, - ) + # Set up commitments DataFrame to penalize each breach + commitment = FlowCommitment( + name=f"all consumption breaches {d}", + quantity=ems_consumption_capacity, + # positive price because breaching in the upwards (consumption) direction is penalized + upwards_deviation_price=all_ems_consumption_breach_price, + index=index, + device=d, + device_group=flex_model_d["commodity"], + ) + commitments.append(commitment) - # Set up commitments DataFrame to penalize any breach - commitment = FlowCommitment( - name="any production breach", - quantity=ems_production_capacity, - # negative price because breaching in the downwards (production) direction is penalized - downwards_deviation_price=-any_ems_production_breach_price, - _type="any", - index=index, - ) - commitments.append(commitment) + # Take the physical capacity as a hard constraint + ems_constraints["derivative max"] = ems_power_capacity_in_mw + else: + # Take the contracted capacity as a hard constraint + ems_constraints["derivative max"] = ems_consumption_capacity - # Set up commitments DataFrame to penalize each breach - commitment = FlowCommitment( - name="all production breaches", - quantity=ems_production_capacity, - # negative price because breaching in the downwards (production) direction is penalized - downwards_deviation_price=-all_ems_production_breach_price, - index=index, - ) - commitments.append(commitment) + if ems_production_breach_price is not None: - # Take the physical capacity as a hard constraint - ems_constraints["derivative min"] = -ems_power_capacity_in_mw - else: - # Take the contracted capacity as a hard constraint - ems_constraints["derivative min"] = ems_production_capacity + # Convert to Series + any_ems_production_breach_price = ( + get_continuous_series_sensor_or_quantity( + variable_quantity=ems_production_breach_price, + unit=self.flex_context["shared_currency_unit"] + "/MW", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + fill_sides=True, + ) + ) + all_ems_production_breach_price = ( + get_continuous_series_sensor_or_quantity( + variable_quantity=ems_production_breach_price, + unit=self.flex_context["shared_currency_unit"] + + "/MW*h", # from EUR/MWh to EUR/MW/resolution + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + fill_sides=True, + ) + ) + + # Set up commitments DataFrame to penalize any breach + commitment = FlowCommitment( + name=f"any production breach {d}", + quantity=ems_production_capacity, + # negative price because breaching in the downwards (production) direction is penalized + downwards_deviation_price=-any_ems_production_breach_price, + _type="any", + index=index, + device=d, + device_group=flex_model_d["commodity"], + ) + commitments.append(commitment) + + # Set up commitments DataFrame to penalize each breach + commitment = FlowCommitment( + name=f"all production breaches {d}", + quantity=ems_production_capacity, + # negative price because breaching in the downwards (production) direction is penalized + downwards_deviation_price=-all_ems_production_breach_price, + index=index, + device=d, + device_group=flex_model_d["commodity"], + ) + commitments.append(commitment) + + # Take the physical capacity as a hard constraint + ems_constraints["derivative min"] = -ems_power_capacity_in_mw + else: + # Take the contracted capacity as a hard constraint + ems_constraints["derivative min"] = ems_production_capacity # Flow commitments per device @@ -932,6 +967,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 def convert_to_commitments( self, + flex_model, **timing_kwargs, ) -> list[FlowCommitment]: """Convert list of commitment specifications (dicts) to a list of FlowCommitments.""" @@ -969,7 +1005,13 @@ def convert_to_commitments( commitment_spec["index"] = initialize_index( start, end, timing_kwargs["resolution"] ) - commitments.append(FlowCommitment(**commitment_spec)) + for d, flex_model_d in enumerate(flex_model): + commitment = FlowCommitment( + device=d, + device_group=flex_model_d["commodity"], + **commitment_spec, + ) + commitments.append(commitment) return commitments From 325bfe8fdc879ce0d3826f9d3b65352fb8739261 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 10 Feb 2026 13:00:26 +0100 Subject: [PATCH 09/17] feat: add gas-price field to the Flex-context schema Signed-off-by: Ahmad-Wahid --- flexmeasures/data/schemas/scheduling/__init__.py | 7 +++++++ flexmeasures/data/schemas/scheduling/metadata.py | 5 +++++ flexmeasures/ui/static/openapi-specs.json | 7 +++++++ 3 files changed, 19 insertions(+) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index dd2caded11..148f095e44 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -299,6 +299,13 @@ class FlexContextSchema(Schema): data_key="aggregate-power", required=False, ) + 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 diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index e5a1f26f9d..0e5a2b3552 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -52,6 +52,11 @@ 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]_ diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index da8db49566..6224dafe03 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4106,6 +4106,13 @@ }, "aggregate-power": { "$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 926162925150563e143b028f8bd0b033b749f2ee Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 10 Feb 2026 13:04:02 +0100 Subject: [PATCH 10/17] apply black Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/tests/test_commitments.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index cf8c4cbe48..35e486e7cc 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1,7 +1,11 @@ import pandas as pd import numpy as np -from flexmeasures.data.models.planning import Commitment, StockCommitment, FlowCommitment +from flexmeasures.data.models.planning import ( + Commitment, + StockCommitment, + FlowCommitment, +) from flexmeasures.data.models.planning.utils import ( initialize_index, add_tiny_price_slope, From 9dd58020efd5bd2165589e88e91689c323c3ef96 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 10 Feb 2026 22:55:05 +0100 Subject: [PATCH 11/17] feat: add a test case for two flexible devices with commodity Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 35e486e7cc..6e1e0a310e 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1,6 +1,7 @@ import pandas as pd import numpy as np +from flexmeasures.data.services.utils import get_or_create_model from flexmeasures.data.models.planning import ( Commitment, StockCommitment, @@ -10,7 +11,10 @@ initialize_index, add_tiny_price_slope, ) +from flexmeasures.data.models.planning.storage import StorageScheduler +from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.models.planning.linear_optimization import device_scheduler +from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType def test_multi_feed_device_scheduler_shared_buffer(): @@ -312,3 +316,100 @@ def test_each_type_assigns_unique_group_per_slot(): device=pd.Series("dev", index=idx), ) assert list(c.group) == list(range(len(idx))) + + +def test_two_flexible_assets_with_commodity(app, db): + """ + Test scheduling two flexible assets (battery + heat pump) + with explicit electricity commodity. + """ + # ---- asset types + battery_type = get_or_create_model(GenericAssetType, name="battery") + hp_type = get_or_create_model(GenericAssetType, name="heat-pump") + + # ---- time setup + start = pd.Timestamp("2024-01-01T00:00:00+01:00") + end = pd.Timestamp("2024-01-02T00:00:00+01:00") + resolution = pd.Timedelta("1h") + + # ---- assets + battery = GenericAsset( + name="Battery", + generic_asset_type=battery_type, + attributes={"energy-capacity": "100 kWh"}, + ) + heat_pump = GenericAsset( + name="Heat Pump", + generic_asset_type=hp_type, + attributes={"energy-capacity": "50 kWh"}, + ) + db.session.add_all([battery, heat_pump]) + db.session.commit() + + # ---- sensors + battery_power = Sensor( + name="battery power", + unit="kW", + event_resolution=resolution, + generic_asset=battery, + ) + hp_power = Sensor( + name="heat pump power", + unit="kW", + event_resolution=resolution, + generic_asset=heat_pump, + ) + db.session.add_all([battery_power, hp_power]) + db.session.commit() + + # ---- flex-model (list = multi-asset) + flex_model = [ + { + # Battery as storage + "sensor": battery_power.id, + "commodity": "electricity", + "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, + }, + { + # Heat pump modeled as storage + "sensor": hp_power.id, + "commodity": "electricity", + "soc-at-start": 10.0, + "soc-min": 0.0, + "soc-max": 50.0, + "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 40.0}], + "power-capacity": "10 kW", + "production-capacity": "0 kW", + "charging-efficiency": 0.95, + }, + ] + + # ---- flex-context (single electricity market) + flex_context = { + "consumption-price": "100 EUR/MWh", + "production-price": "100 EUR/MWh", + } + + # ---- run scheduler (use one asset as entry point) + scheduler = StorageScheduler( + asset_or_sensor=battery, + start=start, + end=end, + resolution=resolution, + belief_time=start, + flex_model=flex_model, + flex_context=flex_context, + return_multiple=True, + ) + + schedules = scheduler.compute(skip_validation=True) + + # ---- assertions + assert isinstance(schedules, list) + assert len(schedules) >= 2 # at least one schedule per device From b22c6d78bf2bc494b26d3d591023169908e202cc Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Mon, 16 Feb 2026 12:30:15 +0100 Subject: [PATCH 12/17] use expected datatypes Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 4caf906a60..6f0da76aa0 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -280,8 +280,8 @@ class Commitment: """ name: str - device: pd.Series = None - device_group: pd.Series = None + device: int | pd.Series = None + device_group: int | str | pd.Series = None index: pd.DatetimeIndex = field(repr=False, default=None) _type: str = field(repr=False, default="each") group: pd.Series = field(init=False) From c88af5c963ab8c1328385ef044ac9a533fcc72c2 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Mon, 16 Feb 2026 12:33:08 +0100 Subject: [PATCH 13/17] feat: split commitments per commodity Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 47 +++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 48a593875a..0101b84d0f 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -159,12 +159,15 @@ 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 @@ -175,6 +178,23 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Fetch the device's power capacity (required Sensor attribute) 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, @@ -262,19 +282,32 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Set up commitments DataFrame for d, flex_model_d in enumerate(flex_model): - # todo: use the right commodity prices for non-electricity devices - if flex_model_d["commodity"] != "electricity": - continue + commodity = flex_model_d.get("commodity", "electricity") + 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: + raise ValueError( + f"Unsupported commodity {commodity} in flex-model. Only 'electricity' and 'gas' are supported." + ) commitment = FlowCommitment( # todo: report aggregate energy costs, too (need to be backwards compatible) - name=f"energy {d}", + name=f"{commodity} energy {d}", quantity=commitment_quantities, - upwards_deviation_price=commitment_upwards_deviation_price, - downwards_deviation_price=commitment_downwards_deviation_price, + upwards_deviation_price=up_price, + downwards_deviation_price=down_price, + commodity=commodity, index=index, device=d, - device_group=flex_model_d["commodity"], + device_group=commodity, ) commitments.append(commitment) From c2341f1acff38b13e52b59706d04c079152bffa8 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Mon, 16 Feb 2026 12:33:08 +0100 Subject: [PATCH 14/17] feat: split commitments per commodity Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 47 +++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 48a593875a..0101b84d0f 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -159,12 +159,15 @@ 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 @@ -175,6 +178,23 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Fetch the device's power capacity (required Sensor attribute) 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, @@ -262,19 +282,32 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Set up commitments DataFrame for d, flex_model_d in enumerate(flex_model): - # todo: use the right commodity prices for non-electricity devices - if flex_model_d["commodity"] != "electricity": - continue + commodity = flex_model_d.get("commodity", "electricity") + 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: + raise ValueError( + f"Unsupported commodity {commodity} in flex-model. Only 'electricity' and 'gas' are supported." + ) commitment = FlowCommitment( # todo: report aggregate energy costs, too (need to be backwards compatible) - name=f"energy {d}", + name=f"{commodity} energy {d}", quantity=commitment_quantities, - upwards_deviation_price=commitment_upwards_deviation_price, - downwards_deviation_price=commitment_downwards_deviation_price, + upwards_deviation_price=up_price, + downwards_deviation_price=down_price, + commodity=commodity, index=index, device=d, - device_group=flex_model_d["commodity"], + device_group=commodity, ) commitments.append(commitment) From be05a199d6f61c56cf614b4658f39f91c4e1bfe8 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Mon, 16 Feb 2026 12:41:37 +0100 Subject: [PATCH 15/17] Revert "use expected datatypes" This reverts commit b22c6d78bf2bc494b26d3d591023169908e202cc. --- flexmeasures/data/models/planning/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 6f0da76aa0..4caf906a60 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -280,8 +280,8 @@ class Commitment: """ name: str - device: int | pd.Series = None - device_group: int | str | pd.Series = None + device: pd.Series = None + device_group: pd.Series = None index: pd.DatetimeIndex = field(repr=False, default=None) _type: str = field(repr=False, default="each") group: pd.Series = field(init=False) From f4ffd8a3f8ebe0dfe6902240ed7de5b39f751ad1 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Mon, 16 Feb 2026 12:50:54 +0100 Subject: [PATCH 16/17] feat: add a test case for different commodities Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 6e1e0a310e..055af2fb3e 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -413,3 +413,106 @@ def test_two_flexible_assets_with_commodity(app, db): # ---- assertions assert isinstance(schedules, list) assert len(schedules) >= 2 # at least one schedule per device + + +def test_mixed_gas_and_electricity_assets(app, db): + """ + Test scheduling two flexible assets with different commodities: + - Battery (electricity) + - Gas boiler (gas) + """ + + battery_type = get_or_create_model(GenericAssetType, name="battery") + boiler_type = get_or_create_model(GenericAssetType, name="gas-boiler") + + start = pd.Timestamp("2024-01-01T00:00:00+01:00") + end = pd.Timestamp("2024-01-02T00:00:00+01:00") + resolution = pd.Timedelta("1h") + + battery = GenericAsset( + name="Battery", + generic_asset_type=battery_type, + attributes={"energy-capacity": "100 kWh"}, + ) + + gas_boiler = GenericAsset( + name="Gas Boiler", + generic_asset_type=boiler_type, + ) + + db.session.add_all([battery, gas_boiler]) + db.session.commit() + + battery_power = Sensor( + name="battery power", + unit="kW", + event_resolution=resolution, + generic_asset=battery, + ) + + boiler_power = Sensor( + name="boiler power", + unit="kW", + event_resolution=resolution, + generic_asset=gas_boiler, + ) + + db.session.add_all([battery_power, boiler_power]) + db.session.commit() + + flex_model = [ + { + # Electricity battery + "sensor": battery_power.id, + "commodity": "electricity", + "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, + }, + { + # Gas-powered device (no storage behavior) + "sensor": boiler_power.id, + "commodity": "gas", + "power-capacity": "30 kW", + "consumption-capacity": "30 kW", + }, + ] + + flex_context = { + "consumption-price": "100 EUR/MWh", # electricity price + "production-price": "100 EUR/MWh", + "gas-price": "50 EUR/MWh", # gas price + } + + scheduler = StorageScheduler( + asset_or_sensor=battery, + start=start, + end=end, + resolution=resolution, + belief_time=start, + flex_model=flex_model, + flex_context=flex_context, + return_multiple=True, + ) + + schedules = scheduler.compute(skip_validation=True) + + assert isinstance(schedules, list) + + scheduled_sensors = { + entry["sensor"] + for entry in schedules + if entry.get("name") == "storage_schedule" + } + + assert battery_power in scheduled_sensors + assert boiler_power in scheduled_sensors + + commitment_costs = [ + entry for entry in schedules if entry.get("name") == "commitment_costs" + ] + assert len(commitment_costs) == 1 From c43d2ad588fb6444e4084de0e6d9261293c4b748 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 11:06:37 +0100 Subject: [PATCH 17/17] fix: do not produce gas Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_commitments.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 055af2fb3e..c288a4c619 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -479,6 +479,7 @@ def test_mixed_gas_and_electricity_assets(app, db): "commodity": "gas", "power-capacity": "30 kW", "consumption-capacity": "30 kW", + "production-capacity": "0 kW", }, ]