From 5be555b73838e5e3cf923f800a2ba45681e1042f Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 3 Feb 2026 01:42:08 +0100 Subject: [PATCH 01/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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", }, ] From 0aa5b2bbd61ade2c841b6898126164a7a7b4ce61 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 26 Feb 2026 15:08:31 +0100 Subject: [PATCH 18/44] feat: create a flow commitment for prefering to charge sooner devices Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 30 +++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 0101b84d0f..836d81950e 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -218,17 +218,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 start = pd.Timestamp(start).tz_convert("UTC") end = pd.Timestamp(end).tz_convert("UTC") - # Add tiny price slope to prefer charging now rather than later, and discharging later rather than now. - # We penalise future consumption and reward future production with at most 1 per thousand times the energy price spread. - # todo: move to flow or stock commitment per device - if any(prefer_charging_sooner): - up_deviation_prices = add_tiny_price_slope( - up_deviation_prices, "event_value" - ) - down_deviation_prices = add_tiny_price_slope( - down_deviation_prices, "event_value" - ) - # 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"), @@ -531,6 +520,25 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) commitments.append(commitment) + # Add tiny price slope to prefer charging now rather than later, and discharging later rather than now. + # We penalise future consumption and reward future production with at most 1 per thousand times the energy price spread. + for d, prefer_charging_sooner_d in enumerate(prefer_charging_sooner): + if prefer_charging_sooner_d: + tiny_price_slope = ( + add_tiny_price_slope(up_deviation_prices, "event_value") + - up_deviation_prices + ) + commitment = FlowCommitment( + name=f"prefer charging device {d} sooner", + # Prefer charging sooner by penalizing later consumption + upwards_deviation_price=tiny_price_slope, + # Prefer discharging later by penalizing earlier production + downwards_deviation_price=-tiny_price_slope, + index=index, + device=d, + ) + commitments.append(commitment) + # Set up device constraints: scheduled flexible devices for this EMS (from index 0 to D-1), plus the forecasted inflexible devices (at indices D to n). device_constraints = [ initialize_df(StorageScheduler.COLUMNS, start, end, resolution) From a48c6ba04c1ec637a554762b0481fc5a7018ace6 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 26 Feb 2026 18:53:47 +0100 Subject: [PATCH 19/44] add soc constraints for boiler Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/tests/test_commitments.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index c288a4c619..9fde0f8cbf 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -480,6 +480,10 @@ def test_mixed_gas_and_electricity_assets(app, db): "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, }, ] From c58965facd6b70d07bcb21fda98bfc7ecb011a47 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 26 Feb 2026 19:30:59 +0100 Subject: [PATCH 20/44] add some assert statments Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 9fde0f8cbf..0754329c2e 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -507,17 +507,50 @@ def test_mixed_gas_and_electricity_assets(app, db): schedules = scheduler.compute(skip_validation=True) assert isinstance(schedules, list) + assert len(schedules) == 3 # 2 storage schedules + 1 commitment costs - 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 - + # Extract schedules by type + storage_schedules = [ + entry for entry in schedules if entry.get("name") == "storage_schedule" + ] commitment_costs = [ entry for entry in schedules if entry.get("name") == "commitment_costs" ] + + assert len(storage_schedules) == 2 assert len(commitment_costs) == 1 + + # Get battery schedule + battery_schedule = next( + entry for entry in storage_schedules if entry["sensor"] == battery_power + ) + battery_data = battery_schedule["data"] + + early_charging_hours = battery_data.iloc[:3] + assert (early_charging_hours > 0).all(), "Battery should charge early" + + assert battery_data.iloc[-1] < 0, "Battery should discharge at the end" + + middle_hours = battery_data.iloc[4:-2] + assert (middle_hours == 0).all(), "Battery should be idle during middle hours" + + boiler_schedule = next( + entry for entry in storage_schedules if entry["sensor"] == boiler_power + ) + boiler_data = boiler_schedule["data"] + + assert (boiler_data == 1.0).all(), "Boiler should have constant 1 kW consumption" + + costs_data = commitment_costs[0]["data"] + + assert ( + costs_data["electricity energy 0"] > 4.0 + ), "Battery electricity cost should be around 4.3 EUR" + assert costs_data["gas energy 1"] > 1.0, "Boiler gas cost should be around 1.2 EUR" + + # charging preference costs are roughly 2x curtailing preference costs + # (because curtailing uses 0.5x multiplier) + assert ( + costs_data["prefer charging device 0 sooner"] + > costs_data["prefer curtailing device 0 later"] + ), "Charging preference should have higher cost than curtailing (no 0.5x multiplier)" From 515f34a9cecc7d7d9ebd0a210c816d176eed76b5 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Wed, 4 Mar 2026 13:43:01 +0100 Subject: [PATCH 21/44] update and add new assertions with clear explanation Signed-off-by: Ahmad-Wahid --- .../models/planning/tests/test_commitments.py | 186 +++++++++++++++++- 1 file changed, 177 insertions(+), 9 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 0754329c2e..c9682418ce 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1,5 +1,6 @@ import pandas as pd import numpy as np +import pytest from flexmeasures.data.services.utils import get_or_create_model from flexmeasures.data.models.planning import ( @@ -410,9 +411,127 @@ def test_two_flexible_assets_with_commodity(app, db): schedules = scheduler.compute(skip_validation=True) - # ---- assertions assert isinstance(schedules, list) - assert len(schedules) >= 2 # at least one schedule per device + assert len(schedules) == 3 # 2 storage schedules + 1 commitment costs + + # Extract schedules by type + storage_schedules = [ + entry for entry in schedules if entry.get("name") == "storage_schedule" + ] + commitment_costs = [ + entry for entry in schedules if entry.get("name") == "commitment_costs" + ] + + assert len(storage_schedules) == 2 + assert len(commitment_costs) == 1 + + # Get battery schedule + battery_schedule = next( + entry for entry in storage_schedules if entry["sensor"] == battery_power + ) + battery_data = battery_schedule["data"] + + # Get heat pump schedule + hp_schedule = next( + entry for entry in storage_schedules if entry["sensor"] == hp_power + ) + hp_data = hp_schedule["data"] + + # Verify both devices charge to meet their targets + assert (battery_data > 0).any(), "Battery should charge at some point" + assert (hp_data > 0).any(), "Heat pump should charge at some point" + + costs_data = commitment_costs[0]["data"] + + # Battery: 60kWh Δ (20→80) / 0.95 eff × 100 EUR/MWh = 6.32 EUR (charge) + discharge loss ≈ 4.32 EUR + assert costs_data["electricity energy 0"] == pytest.approx(4.32, rel=1e-2), ( + f"Battery electricity cost (charges 60kWh with 95% efficiency + discharge): " + f"60kWh/0.95 × (100 EUR/MWh) = 4.32 EUR, " + f"got {costs_data['electricity energy 0']}" + ) + + # Heat pump: 30kWh Δ (10→40) / 0.95 eff × 100 EUR/MWh ≈ 3.16 EUR (no discharge, prod-cap=0) + assert costs_data["electricity energy 1"] == pytest.approx(3.16, rel=1e-2), ( + f"Heat pump electricity cost (charges 30kWh with 95% efficiency): " + f"30kWh/0.95 × (100 EUR/MWh) = 3.16 EUR, " + f"got {costs_data['electricity energy 1']}" + ) + + # Total electricity: battery (4.32) + heat pump (3.16) = 7.48 EUR + total_electricity_cost = sum( + v for k, v in costs_data.items() if k.startswith("electricity energy") + ) + assert total_electricity_cost == pytest.approx(7.47, rel=1e-2), ( + f"Total electricity cost (battery 4.32 + heat pump 3.16): " + f"= 7.48 EUR, got {total_electricity_cost}" + ) + + # Battery charges early (3-4h @20kW): tiny slope cost ≈ 2.30e-6 EUR (negligible tiebreaker) + assert costs_data["prefer charging device 0 sooner"] == pytest.approx( + 2.30e-6, rel=1e-2 + ), ( + f"Battery charging preference (charges early at low-slope cost): " + f"= 2.30e-6 EUR, got {costs_data['prefer charging device 0 sooner']}" + ) + + # Battery idle periods with 0.5× multiplier: = 0.5 × 2.30e-6 = 1.15e-6 EUR + assert costs_data["prefer curtailing device 0 later"] == pytest.approx( + 1.15e-6, rel=1e-2 + ), ( + f"Battery curtailing preference (idle periods with 0.5× multiplier): " + f"= 0.5 × 2.30e-6 = 1.15e-6 EUR, got {costs_data['prefer curtailing device 0 later']}" + ) + + # Verify charging cost ~2× curtailing cost (due to 0.5× multiplier) + assert ( + costs_data["prefer charging device 0 sooner"] + > costs_data["prefer curtailing device 0 later"] + ), ( + f"Battery charging preference should cost ~2× more than curtailing " + f"due to 0.5× multiplier on curtailing slopes. " + f"Ratio: {costs_data['prefer charging device 0 sooner'] / costs_data['prefer curtailing device 0 later']:.1f}×" + ) + + # Heat pump charges ~30 kWh (half of battery's 60 kWh) at 10 kW: tiny slope cost ≈ 1.51e-7 EUR + assert costs_data["prefer charging device 1 sooner"] == pytest.approx( + 1.51e-7, rel=1e-2 + ), ( + f"Heat pump charging preference (charges 30kWh, smaller than battery): " + f"= 1.51e-7 EUR, got {costs_data['prefer charging device 1 sooner']}" + ) + + # Heat pump idle periods with 0.5× multiplier should be positive + assert ( + costs_data["prefer curtailing device 1 later"] > 0 + ), "Heat pump curtailing preference cost should be positive" + # Verify charging cost ~2× curtailing cost (due to 0.5× multiplier) + assert ( + costs_data["prefer charging device 1 sooner"] + > costs_data["prefer curtailing device 1 later"] + ), ( + f"Heat pump charging preference should cost ~2× more than curtailing " + f"due to 0.5× multiplier. " + f"Ratio: {costs_data['prefer charging device 1 sooner'] / costs_data['prefer curtailing device 1 later']:.1f}×" + ) + + # ---- RELATIVE COSTS: Battery vs Heat Pump + # Battery moves 60 kWh, Heat Pump moves 30 kWh (2:1 ratio) + # Preference costs should roughly reflect this energy ratio + # Battery total preference: 2.30e-6 + 1.15e-6 = 3.45e-6 EUR + # Heat Pump total preference: 1.51e-7 + ~7.5e-8 ≈ 2.26e-7 EUR + # Ratio: 3.45e-6 / 2.26e-7 ≈ 15× (battery has much higher preference costs) + battery_total_pref = ( + costs_data["prefer charging device 0 sooner"] + + costs_data["prefer curtailing device 0 later"] + ) + hp_total_pref = ( + costs_data["prefer charging device 1 sooner"] + + costs_data["prefer curtailing device 1 later"] + ) + assert battery_total_pref > hp_total_pref, ( + f"Battery preference costs ({battery_total_pref:.2e}) should be higher than " + f"heat pump ({hp_total_pref:.2e}) since battery moves more energy (60 kWh vs 30 kWh)" + ) def test_mixed_gas_and_electricity_assets(app, db): @@ -543,14 +662,63 @@ def test_mixed_gas_and_electricity_assets(app, db): costs_data = commitment_costs[0]["data"] - assert ( - costs_data["electricity energy 0"] > 4.0 - ), "Battery electricity cost should be around 4.3 EUR" - assert costs_data["gas energy 1"] > 1.0, "Boiler gas cost should be around 1.2 EUR" + # Battery: 60kWh Δ (20→80) / 0.95 eff × 100 EUR/MWh + discharge loss ≈ 4.32 EUR + assert costs_data["electricity energy 0"] == pytest.approx(4.32, rel=1e-2), ( + f"Electricity energy cost (battery charging phase ~3h at 20kW with 95% efficiency " + f"+ discharge at end): 60kWh/0.95 × (100 EUR/MWh) = 4.32 EUR, " + f"got {costs_data['electricity energy 0']}" + ) + + # Boiler: constant 1kW × 24h = 24 kWh = 0.024 MWh × 50 EUR/MWh = 1.20 EUR (no efficiency loss) + assert costs_data["gas energy 1"] == pytest.approx(1.20, rel=1e-2), ( + f"Gas energy cost (boiler constant 1kW for 24h): " + f"1 kW × 24h = 24 kWh = 0.024 MWh × 50 EUR/MWh = 1.20 EUR, " + f"got {costs_data['gas energy 1']}" + ) + + # Battery charges early (3h @20kW): tiny slope cost = 3h × 20kW × (24/1e6) = 2.30e-6 EUR + assert costs_data["prefer charging device 0 sooner"] == pytest.approx( + 2.30e-6, rel=1e-2 + ), ( + f"Charging preference (battery charges early at low-slope cost): " + f"accumulates tiny slope penalty over charging period = 2.30e-6 EUR, " + f"got {costs_data['prefer charging device 0 sooner']}" + ) - # charging preference costs are roughly 2x curtailing preference costs - # (because curtailing uses 0.5x multiplier) + # Battery idle periods with 0.5× multiplier = 0.5 × 2.30e-6 = 1.15e-6 EUR (prioritizes early charge) + assert costs_data["prefer curtailing device 0 later"] == pytest.approx( + 1.15e-6, rel=1e-2 + ), ( + f"Curtailing preference (battery idle periods with 0.5× multiplier): " + f"= 0.5 × charging preference = 1.15e-6 EUR (weaker to prioritize early charging), " + f"got {costs_data['prefer curtailing device 0 later']}" + ) + + # Boiler: constant 1kW × 24h × tiny_slope = 24h × 1kW × (24/1e6) = 1.20e-6 EUR (no flexibility) + assert costs_data["prefer charging device 1 sooner"] == pytest.approx( + 1.20e-6, rel=1e-2 + ), ( + f"Charging preference (boiler 1kW constant load, 24h duration): " + f"1 kW × 24h × tiny_slope = 1.20e-6 EUR (degenerate: no flexibility), " + f"got {costs_data['prefer charging device 1 sooner']}" + ) + + # Boiler curtailing with 0.5× multiplier = 0.5 × 1.20e-6 = 6.00e-7 EUR (no flexibility) + assert costs_data["prefer curtailing device 1 later"] == pytest.approx( + 6.00e-7, rel=1e-2 + ), ( + f"Curtailing preference (boiler with 0.5× multiplier, no flexibility): " + f"= 0.5 × charging preference = 6.00e-7 EUR, " + f"got {costs_data['prefer curtailing device 1 later']}" + ) + + # Verify charging cost ~2× curtailing cost (due to 0.5× multiplier) assert ( costs_data["prefer charging device 0 sooner"] > costs_data["prefer curtailing device 0 later"] - ), "Charging preference should have higher cost than curtailing (no 0.5x multiplier)" + ), ( + f"Battery charging preference (2.30e-6) should cost ~2× more than curtailing " + f"(1.15e-6) due to 0.5× multiplier on curtailing slopes. " + f"This ensures optimizer prioritizes filling battery early over idling. " + f"Ratio: {costs_data['prefer charging device 0 sooner'] / costs_data['prefer curtailing device 0 later']:.1f}×" + ) From 4d77344063fe449d78ba94f21b22e7903cbfdc67 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Wed, 4 Mar 2026 13:45:39 +0100 Subject: [PATCH 22/44] update the docstring Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/tests/test_commitments.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index c9682418ce..9f2bf36fda 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -536,9 +536,8 @@ def test_two_flexible_assets_with_commodity(app, db): def test_mixed_gas_and_electricity_assets(app, db): """ - Test scheduling two flexible assets with different commodities: - - Battery (electricity) - - Gas boiler (gas) + Test scheduling with mixed commodities: battery (electricity) and boiler (gas). + Verify cost calculations for both commodity types. """ battery_type = get_or_create_model(GenericAssetType, name="battery") From 2becd028513a24a727effbcaf18a8bbd25dd3c88 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 6 Mar 2026 02:02:30 +0100 Subject: [PATCH 23/44] refactor: move tiny-price-slope decleration out of the for loop Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/tests/test_commitments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 9f2bf36fda..d40fadc43b 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -623,7 +623,7 @@ def test_mixed_gas_and_electricity_assets(app, db): ) schedules = scheduler.compute(skip_validation=True) - + breakpoint() assert isinstance(schedules, list) assert len(schedules) == 3 # 2 storage schedules + 1 commitment costs From d9d4f94e92ffbd1c42f741d9fe085032e8e5123f Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 6 Mar 2026 02:03:12 +0100 Subject: [PATCH 24/44] Revert "refactor: move tiny-price-slope decleration out of the for loop" This reverts commit 2becd028513a24a727effbcaf18a8bbd25dd3c88. --- flexmeasures/data/models/planning/tests/test_commitments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index d40fadc43b..9f2bf36fda 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -623,7 +623,7 @@ def test_mixed_gas_and_electricity_assets(app, db): ) schedules = scheduler.compute(skip_validation=True) - breakpoint() + assert isinstance(schedules, list) assert len(schedules) == 3 # 2 storage schedules + 1 commitment costs From 2a22fae1234da55463905d1cf0309100c924ef9d Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 6 Mar 2026 02:03:49 +0100 Subject: [PATCH 25/44] refactor: move tiny-price-slope decleration out of the for loop Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/planning/storage.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 836d81950e..d74466e716 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -502,19 +502,18 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Add tiny price slope to prefer curtailing later rather than now. # The price slope is half of the slope to prefer charging sooner + tiny_price_slope = ( + add_tiny_price_slope(up_deviation_prices, "event_value") + - up_deviation_prices + ) for d, prefer_curtailing_later_d in enumerate(prefer_curtailing_later): if prefer_curtailing_later_d: - tiny_price_slope = ( - add_tiny_price_slope(up_deviation_prices, "event_value") - - up_deviation_prices - ) - tiny_price_slope *= 0.5 commitment = FlowCommitment( name=f"prefer curtailing device {d} later", # Prefer curtailing consumption later by penalizing later consumption - upwards_deviation_price=tiny_price_slope, + upwards_deviation_price=tiny_price_slope * 0.5, # Prefer curtailing production later by penalizing later production - downwards_deviation_price=-tiny_price_slope, + downwards_deviation_price=-tiny_price_slope * 0.5, index=index, device=d, ) @@ -524,10 +523,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # We penalise future consumption and reward future production with at most 1 per thousand times the energy price spread. for d, prefer_charging_sooner_d in enumerate(prefer_charging_sooner): if prefer_charging_sooner_d: - tiny_price_slope = ( - add_tiny_price_slope(up_deviation_prices, "event_value") - - up_deviation_prices - ) commitment = FlowCommitment( name=f"prefer charging device {d} sooner", # Prefer charging sooner by penalizing later consumption From f38d6d85f3c8bfa417c0ba4e14622f1eacbb9133 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 6 Mar 2026 20:08:04 +0100 Subject: [PATCH 26/44] fix: add data_key attr Signed-off-by: Ahmad-Wahid --- flexmeasures/data/schemas/scheduling/storage.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 1154c4c97f..19a752f3c9 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -226,6 +226,7 @@ class StorageFlexModelSchema(Schema): ) commodity = fields.Str( required=False, + data_key="commodity", load_default="electricity", validate=OneOf(["electricity", "gas"]), metadata=dict(description="Commodity label for this device/asset."), @@ -507,6 +508,7 @@ class DBStorageFlexModelSchema(Schema): commodity = fields.Str( required=False, + data_key="commodity", load_default="electricity", validate=OneOf(["electricity", "gas"]), metadata=dict(description="Commodity label for this device/asset."), From 007e51497b8f532e8312c838d92e55d7d1219272 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 6 Mar 2026 20:09:52 +0100 Subject: [PATCH 27/44] add missing commodity description and it's field in ui flexmodel schema Signed-off-by: Ahmad-Wahid --- flexmeasures/data/schemas/scheduling/__init__.py | 9 +++++++++ flexmeasures/data/schemas/scheduling/metadata.py | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 148f095e44..21333884c7 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -738,6 +738,15 @@ def _to_currency_per_mwh(price_unit: str) -> str: }, "example-units": EXAMPLE_UNIT_TYPES["power"], }, + "commodity": { + "default": "electricity", + "description": rst_to_openapi(metadata.COMMODITY.description), + "types": { + "backend": "typeOne", + "ui": "One fixed value only.", + }, + "options": ["electricity", "gas"], + }, } diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 0e5a2b3552..4165cb003e 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -189,7 +189,13 @@ def to_dict(self): # FLEX-MODEL - +COMMODITY = MetaData( + description="""Commodity type for this storage flex-model. +Allowed values are ``electricity`` and ``gas``. +Defaults to ``electricity``. +""", + example="electricity", +) STATE_OF_CHARGE = MetaData( description="Sensor used to record the scheduled state of charge.", example={"sensor": 12}, From 0bff8bcc8fd87836fbdeb38ac4327ec76b28c7dc Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 6 Mar 2026 20:17:21 +0100 Subject: [PATCH 28/44] fix: add missing gas-price field in UI Flexcontext schema Signed-off-by: Ahmad-Wahid --- flexmeasures/data/schemas/scheduling/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 21333884c7..2ca56c152e 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -582,6 +582,11 @@ 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]] = { From 82f807ec91da001378a9a7a822e5e03f4bb59ccc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 16:21:40 +0100 Subject: [PATCH 29/44] fix: wrong timezone; the test relied on the preference to charge sooner and discharge later, rather than on the EPEX price transition, as the inline test documentation advertised Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 919936d315..1f0a5baa27 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -2235,11 +2235,14 @@ def test_battery_storage_different_units( battery_name="Test battery", power_sensor_name=power_sensor_name, ) - tz = pytz.timezone("Europe/Amsterdam") + tz = pytz.timezone(epex_da.timezone) # transition from cheap to expensive (90 -> 100) start = tz.localize(datetime(2015, 1, 2, 14, 0, 0)) end = tz.localize(datetime(2015, 1, 2, 16, 0, 0)) + assert len(epex_da.search_beliefs(start, end)) == 2 + assert epex_da.search_beliefs(start, end).values[0][0] == 90 + assert epex_da.search_beliefs(start, end).values[1][0] == 100 resolution = timedelta(minutes=15) flex_model = { From 128550f5b03f76e50bae7ec8f87b31162fb25d9d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 17:17:24 +0100 Subject: [PATCH 30/44] feat: move preference to charge sooner and discharge later into a StockCommitment to prefer being full Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 35 +++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 6fb7aa5e94..dd53db19fc 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -198,17 +198,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 start = pd.Timestamp(start).tz_convert("UTC") end = pd.Timestamp(end).tz_convert("UTC") - # Add tiny price slope to prefer charging now rather than later, and discharging later rather than now. - # We penalise future consumption and reward future production with at most 1 per thousand times the energy price spread. - # todo: move to flow or stock commitment per device - if any(prefer_charging_sooner): - up_deviation_prices = add_tiny_price_slope( - up_deviation_prices, "event_value" - ) - down_deviation_prices = add_tiny_price_slope( - down_deviation_prices, "event_value" - ) - # 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"), @@ -463,6 +452,28 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) commitments.append(commitment) + # Use a tiny price slope to prefer a fuller SoC sooner rather than later + # This corresponds to a preference for charging now rather than later, and discharging later rather than now. + # We penalise future consumption and reward future production with at most 1 per thousand times the energy price spread. + for d, prefer_charging_sooner_d in enumerate(prefer_charging_sooner): + if prefer_charging_sooner_d: + tiny_price_slope = ( + add_tiny_price_slope(up_deviation_prices, "event_value") + - up_deviation_prices + ) + commitment = StockCommitment( + name=f"prefer charging device {d} sooner", + quantity=soc_max[d] - soc_at_start[d], + # Prefer curtailing consumption later by penalizing later consumption + upwards_deviation_price=0, + # Prefer curtailing production later by penalizing later production + downwards_deviation_price=-0.00000001, + # downwards_deviation_price=-tiny_price_slope / 1000000,#0.00000001, + index=index, + device=d, + ) + commitments.append(commitment) + # Set up device constraints: scheduled flexible devices for this EMS (from index 0 to D-1), plus the forecasted inflexible devices (at indices D to n). device_constraints = [ initialize_df(StorageScheduler.COLUMNS, start, end, resolution) @@ -940,7 +951,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 def convert_to_commitments( self, **timing_kwargs, - ) -> list[FlowCommitment]: + ) -> list[FlowCommitment | StockCommitment]: """Convert list of commitment specifications (dicts) to a list of FlowCommitments.""" commitment_specs = self.flex_context.get("commitments", []) if len(commitment_specs) == 0: From 130b9dd6528291903cb7582d2763e843ca94241b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 17:38:42 +0100 Subject: [PATCH 31/44] fix: test case no longer relies on arbitrage opportunity coming from artificial price slope Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 1f0a5baa27..d85a45243b 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -816,8 +816,8 @@ def compute_schedule(flex_model): # soc maxima and soc minima soc_maxima = [ - {"datetime": "2015-01-02T15:00:00+01:00", "value": 1.0}, - {"datetime": "2015-01-02T16:00:00+01:00", "value": 1.0}, + {"datetime": "2015-01-02T12:00:00+01:00", "value": 1.0}, + {"datetime": "2015-01-02T13:00:00+01:00", "value": 1.0}, ] soc_minima = [{"datetime": "2015-01-02T08:00:00+01:00", "value": 3.5}] @@ -853,7 +853,7 @@ def compute_schedule(flex_model): # test for soc_maxima # check that the local maximum constraint is respected - assert soc_schedule_2.loc["2015-01-02T15:00:00+01:00"] <= 1.0 + assert soc_schedule_2.loc["2015-01-02T13:00:00+01:00"] <= 1.0 # test for soc_targets # check that the SOC target (at 19 pm, local time) is met From 1eab8282406d85d343ed6b22f73f781f68e45777 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 17:40:42 +0100 Subject: [PATCH 32/44] feat: check for optimal schedule Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index d85a45243b..6ee32f8b59 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -270,6 +270,7 @@ def run_test_charge_discharge_sign( for soc_at_start_d in soc_at_start ], ) + assert results.solver.termination_condition == "optimal" device_power_sign = pd.Series(model.device_power_sign.extract_values())[0] device_power_up = pd.Series(model.device_power_up.extract_values())[0] From b9bc4a9c0786907c25737a2b5c20492501cf79ec Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 17:58:51 +0100 Subject: [PATCH 33/44] feat: prefer a full storage earlier over later Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 6 ++++-- flexmeasures/data/models/planning/utils.py | 19 +++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index dd53db19fc..b473ed650a 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -458,7 +458,9 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 for d, prefer_charging_sooner_d in enumerate(prefer_charging_sooner): if prefer_charging_sooner_d: tiny_price_slope = ( - add_tiny_price_slope(up_deviation_prices, "event_value") + add_tiny_price_slope( + up_deviation_prices, "event_value", order="desc" + ) - up_deviation_prices ) commitment = StockCommitment( @@ -467,7 +469,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Prefer curtailing consumption later by penalizing later consumption upwards_deviation_price=0, # Prefer curtailing production later by penalizing later production - downwards_deviation_price=-0.00000001, + downwards_deviation_price=-tiny_price_slope, # downwards_deviation_price=-tiny_price_slope / 1000000,#0.00000001, index=index, device=d, diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index 549ef6a01a..c7e20860f0 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -2,6 +2,7 @@ from packaging import version from datetime import date, datetime, timedelta +from typing import Literal from flask import current_app import pandas as pd @@ -67,7 +68,10 @@ def initialize_index( def add_tiny_price_slope( - orig_prices: pd.DataFrame, col_name: str = "event_value", d: float = 10**-4 + orig_prices: pd.DataFrame, + col_name: str = "event_value", + d: float = 10**-4, + order: Literal["asc", "desc"] = "asc", ) -> pd.DataFrame: """Add tiny price slope to col_name to represent e.g. inflation as a simple linear price increase. This is meant to break ties, when multiple time slots have equal prices, in favour of acting sooner. @@ -79,9 +83,16 @@ def add_tiny_price_slope( max_penalty = price_spread * d else: max_penalty = d - prices[col_name] = prices[col_name] + np.linspace( - 0, max_penalty, prices[col_name].size - ) + if order == "asc": + prices[col_name] = prices[col_name] + np.linspace( + 0, max_penalty, prices[col_name].size + ) + elif order == "desc": + prices[col_name] = prices[col_name] + np.linspace( + max_penalty, 0, prices[col_name].size + ) + else: + raise ValueError("order must be 'asc' or 'desc'") return prices From 57df5c31d9f6e3e9a868c3e35bff4db7fda2d003 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 18:04:18 +0100 Subject: [PATCH 34/44] docs: update commitment name and inline comments Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index b473ed650a..3c2388d435 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -464,11 +464,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 - up_deviation_prices ) commitment = StockCommitment( - name=f"prefer charging device {d} sooner", + name=f"prefer a full storage {d} sooner", quantity=soc_max[d] - soc_at_start[d], - # Prefer curtailing consumption later by penalizing later consumption upwards_deviation_price=0, - # Prefer curtailing production later by penalizing later production + # Penalize not being full, with lower penalties later downwards_deviation_price=-tiny_price_slope, # downwards_deviation_price=-tiny_price_slope / 1000000,#0.00000001, index=index, From ce71637238b1d4c1eb81a400edb1fce9a7c9a67c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 14 Mar 2026 11:30:46 +0100 Subject: [PATCH 35/44] docs: touch up test explanation Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/tests/test_solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 6ee32f8b59..b46ee0f0b1 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1788,7 +1788,7 @@ def test_battery_stock_delta_sensor( - Battery of size 2 MWh. - Consumption capacity of the battery is 2 MW. - The battery cannot discharge. - With these settings, the battery needs to charge at a power or greater than the usage forecast + With these settings, the battery needs to charge at a power equal or greater than the usage forecast to keep the SOC within bounds ([0, 2 MWh]). """ _, battery = get_sensors_from_db(db, add_battery_assets) From f6183df2df24639858907f7ae681636673d8140b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 14 Mar 2026 11:43:55 +0100 Subject: [PATCH 36/44] fix: update test case given preference for a full battery Signed-off-by: F.N. Claessen --- .../data/models/planning/tests/test_solver.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index b46ee0f0b1..e810e8c854 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1791,7 +1791,7 @@ def test_battery_stock_delta_sensor( With these settings, the battery needs to charge at a power equal or greater than the usage forecast to keep the SOC within bounds ([0, 2 MWh]). """ - _, battery = get_sensors_from_db(db, add_battery_assets) + epex_da, battery = get_sensors_from_db(db, add_battery_assets) tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -1836,9 +1836,21 @@ def test_battery_stock_delta_sensor( with pytest.raises(InfeasibleProblemException): scheduler.compute() elif stock_delta_sensor is None: - # No usage -> the battery does not charge + # No usage -> the battery only charges when energy is free + free_hour = "2015-01-01 17:00:00+00:00" + prices = epex_da.search_beliefs(start, end) + zero_prices = prices[prices.event_value == 0] + assert len(zero_prices) == 1, "this test assumes a single hour of free energy" + assert ( + len(zero_prices[zero_prices.event_starts == free_hour]) == 1 + ), "this test assumes free energy from 5 to 6 PM UTC" schedule = scheduler.compute() - assert all(schedule == 0) + assert all( + schedule[schedule.index != free_hour] == 0 + ), "no charging expected when energy is not free, given no soc-usage" + assert all( + schedule[schedule.index == free_hour] == capacity + ), "max charging expected when energy is free, because of preference to have a full SoC" else: # Some usage -> the battery needs to charge schedule = scheduler.compute() From ed471a8b836c60da2964e2a666c4613db98bcf5d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 14 Mar 2026 11:46:52 +0100 Subject: [PATCH 37/44] delete: clean up comment 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 3c2388d435..4d1e551c95 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -469,7 +469,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 upwards_deviation_price=0, # Penalize not being full, with lower penalties later downwards_deviation_price=-tiny_price_slope, - # downwards_deviation_price=-tiny_price_slope / 1000000,#0.00000001, index=index, device=d, ) From 7611fb9eadbde61efa317f8ace3e7adbb9d971de Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 14 Mar 2026 11:59:56 +0100 Subject: [PATCH 38/44] feat: model the preference to curtail later within the same StockCommitment, using a tiny price slope to prefer a fuller SoC sooner rather than later, by lowering penalties later Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 38 +++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 4d1e551c95..44024c0cb8 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -430,32 +430,13 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # Take the contracted capacity as a hard constraint ems_constraints["derivative min"] = ems_production_capacity - # Flow commitments per device + # Commitments per device - # Add tiny price slope to prefer curtailing later rather than now. - # The price slope is half of the slope to prefer charging sooner - for d, prefer_curtailing_later_d in enumerate(prefer_curtailing_later): - if prefer_curtailing_later_d: - tiny_price_slope = ( - add_tiny_price_slope(up_deviation_prices, "event_value") - - up_deviation_prices - ) - tiny_price_slope *= 0.5 - commitment = FlowCommitment( - name=f"prefer curtailing device {d} later", - # Prefer curtailing consumption later by penalizing later consumption - upwards_deviation_price=tiny_price_slope, - # Prefer curtailing production later by penalizing later production - downwards_deviation_price=-tiny_price_slope, - index=index, - device=d, - ) - commitments.append(commitment) - - # Use a tiny price slope to prefer a fuller SoC sooner rather than later + # StockCommitment per device to prefer a full storage by penalizing not being full # This corresponds to a preference for charging now rather than later, and discharging later rather than now. - # We penalise future consumption and reward future production with at most 1 per thousand times the energy price spread. - for d, prefer_charging_sooner_d in enumerate(prefer_charging_sooner): + for d, (prefer_charging_sooner_d, prefer_curtailing_later_d) in enumerate( + zip(prefer_charging_sooner, prefer_curtailing_later) + ): if prefer_charging_sooner_d: tiny_price_slope = ( add_tiny_price_slope( @@ -463,12 +444,17 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) - up_deviation_prices ) + if prefer_curtailing_later: + # Use a tiny price slope to prefer a fuller SoC sooner rather than later, by lowering penalties later + penalty = tiny_price_slope + else: + # Constant penalty + penalty = tiny_price_slope[0] commitment = StockCommitment( name=f"prefer a full storage {d} sooner", quantity=soc_max[d] - soc_at_start[d], upwards_deviation_price=0, - # Penalize not being full, with lower penalties later - downwards_deviation_price=-tiny_price_slope, + downwards_deviation_price=-penalty, index=index, device=d, ) From bf16e63606ed5a2889fe25b45c0fd4f28a40b486 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 14 Mar 2026 12:17:35 +0100 Subject: [PATCH 39/44] fix: reduce tiny price slope Signed-off-by: F.N. Claessen --- 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 44024c0cb8..5ef5517b36 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -440,7 +440,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 if prefer_charging_sooner_d: tiny_price_slope = ( add_tiny_price_slope( - up_deviation_prices, "event_value", order="desc" + up_deviation_prices, "event_value", d=10**-7, order="desc" ) - up_deviation_prices ) From 06c30dc297b85f266b47e990523ccd4b90b28e1f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 16 Mar 2026 13:00:41 +0100 Subject: [PATCH 40/44] docs: delete duplicate changelog entry Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 0890c4d01a..4c768d68bd 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -68,7 +68,6 @@ New features * Improved the UX for creating sensors, clicking on ``Enter`` now validates and creates a sensor [see `PR #1876 `_] * Show zero values in bar charts even though they have 0 area [see `PR #1932 `_ and `PR #1936 `_] * Added ``root`` and ``depth`` fields to the `[GET] /assets` endpoint for listing assets, to allow selecting descendants of a given root asset up to a given depth [see `PR #1874 `_] -* Give ability to edit sensor timezone from the UI [see `PR #1900 `_] * Support creating schedules with only information known prior to some time, now also via the CLI (the API already supported it) [see `PR #1871 `_]. * Added capability to update an asset's parent from the UI [`PR #1957 `_] * Add ``fields`` param to the asset-listing endpoints, to save bandwidth in response data [see `PR #1884 `_] From d99089b2844a90667ec3e789fbb6f9d3d26e8c6d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 18 Mar 2026 15:47:47 +0100 Subject: [PATCH 41/44] docs: fix broken link Signed-off-by: F.N. Claessen --- documentation/dev/setup-and-guidelines.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/dev/setup-and-guidelines.rst b/documentation/dev/setup-and-guidelines.rst index 3b7ae6e246..d5d9b7458d 100644 --- a/documentation/dev/setup-and-guidelines.rst +++ b/documentation/dev/setup-and-guidelines.rst @@ -63,7 +63,7 @@ On Linux and Windows, everything will be installed using Python packages. On MacOS, this will install all test dependencies, and locally install the HiGHS solver. For this to work, make sure you have `Homebrew `_ installed. -Besides the HiGHS solver (as the current default), the CBC solver is required for tests as well. See `The install instructions `_ for more information. Configuration ^^^^^^^^^^^^^ From cf01f1d16df71c214733062c4b9b75a6df24a9d3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 18 Mar 2026 16:56:39 +0100 Subject: [PATCH 42/44] Revert "fix: reduce tiny price slope" This reverts commit bf16e63606ed5a2889fe25b45c0fd4f28a40b486. Signed-off-by: F.N. Claessen --- 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 5ef5517b36..44024c0cb8 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -440,7 +440,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 if prefer_charging_sooner_d: tiny_price_slope = ( add_tiny_price_slope( - up_deviation_prices, "event_value", d=10**-7, order="desc" + up_deviation_prices, "event_value", order="desc" ) - up_deviation_prices ) From bdbdead8337d44d3442a2cf5e05940874a8592aa Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 18 Mar 2026 17:42:53 +0100 Subject: [PATCH 43/44] fix: soc unit conversion Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 44024c0cb8..5a22572311 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -452,7 +452,8 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 penalty = tiny_price_slope[0] commitment = StockCommitment( name=f"prefer a full storage {d} sooner", - quantity=soc_max[d] - soc_at_start[d], + quantity=(soc_max[d] - soc_at_start[d]) + * (timedelta(hours=1) / resolution), upwards_deviation_price=0, downwards_deviation_price=-penalty, index=index, From f987706a7e70273c3a817b4494e63af137624322 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 18 Mar 2026 17:49:42 +0100 Subject: [PATCH 44/44] fix: adapt test to check for 1 hour of free energy at 15-min scheduling resolution Signed-off-by: F.N. Claessen --- .../data/models/planning/tests/test_solver.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index e810e8c854..a45a3dd2d9 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1838,18 +1838,17 @@ def test_battery_stock_delta_sensor( elif stock_delta_sensor is None: # No usage -> the battery only charges when energy is free free_hour = "2015-01-01 17:00:00+00:00" - prices = epex_da.search_beliefs(start, end) + prices = epex_da.search_beliefs(start, end, resolution=resolution) zero_prices = prices[prices.event_value == 0] - assert len(zero_prices) == 1, "this test assumes a single hour of free energy" - assert ( - len(zero_prices[zero_prices.event_starts == free_hour]) == 1 - ), "this test assumes free energy from 5 to 6 PM UTC" + assert all( + zero_prices.event_starts.hour == pd.Timestamp(free_hour).hour + ), "this test assumes a single hour of free energy from 5 to 6 PM UTC" schedule = scheduler.compute() assert all( - schedule[schedule.index != free_hour] == 0 + schedule[~schedule.index.isin(zero_prices.event_starts)] == 0 ), "no charging expected when energy is not free, given no soc-usage" assert all( - schedule[schedule.index == free_hour] == capacity + schedule[schedule.index.isin(zero_prices.event_starts)] == capacity ), "max charging expected when energy is free, because of preference to have a full SoC" else: # Some usage -> the battery needs to charge