diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py
index a31fa6e9cf..969292d707 100644
--- a/flexmeasures/data/models/planning/storage.py
+++ b/flexmeasures/data/models/planning/storage.py
@@ -76,6 +76,79 @@ def compute_schedule(self) -> pd.Series | None:
return self.compute()
+ def _get_commodity_contexts(self) -> dict[str, dict]:
+ """Return commodity-specific flex-contexts.
+
+ Supports the new format:
+
+ "commodities": [
+ {"commodity": "electricity", ...},
+ {"commodity": "gas", ...},
+ ]
+
+ and keeps backwards compatibility with old top-level fields.
+ """
+
+ commodity_contexts = {}
+
+ for commodity_context in self.flex_context.get("commodity_contexts", []):
+ commodity = commodity_context["commodity"]
+ commodity_contexts[commodity] = commodity_context
+
+ # Backwards-compatible electricity defaults from old top-level fields.
+ if "electricity" not in commodity_contexts:
+ commodity_contexts["electricity"] = {
+ "commodity": "electricity",
+ "consumption_price": self.flex_context.get(
+ "consumption_price",
+ self.flex_context.get("consumption_price_sensor"),
+ ),
+ "production_price": self.flex_context.get(
+ "production_price",
+ self.flex_context.get("production_price_sensor"),
+ ),
+ "ems_power_capacity_in_mw": self.flex_context.get(
+ "ems_power_capacity_in_mw"
+ ),
+ "ems_consumption_capacity_in_mw": self.flex_context.get(
+ "ems_consumption_capacity_in_mw"
+ ),
+ "ems_production_capacity_in_mw": self.flex_context.get(
+ "ems_production_capacity_in_mw"
+ ),
+ "ems_consumption_breach_price": self.flex_context.get(
+ "ems_consumption_breach_price"
+ ),
+ "ems_production_breach_price": self.flex_context.get(
+ "ems_production_breach_price"
+ ),
+ "ems_peak_consumption_in_mw": self.flex_context.get(
+ "ems_peak_consumption_in_mw"
+ ),
+ "ems_peak_consumption_price": self.flex_context.get(
+ "ems_peak_consumption_price"
+ ),
+ "ems_peak_production_in_mw": self.flex_context.get(
+ "ems_peak_production_in_mw"
+ ),
+ "ems_peak_production_price": self.flex_context.get(
+ "ems_peak_production_price"
+ ),
+ }
+
+ # Backwards-compatible gas defaults from old `gas-price`.
+ if (
+ self.flex_context.get("gas_price") is not None
+ and "gas" not in commodity_contexts
+ ):
+ commodity_contexts["gas"] = {
+ "commodity": "gas",
+ "consumption_price": self.flex_context.get("gas_price"),
+ "production_price": self.flex_context.get("gas_price"),
+ }
+
+ return commodity_contexts
+
def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
"""This function prepares the required data to compute the schedule:
- price data
@@ -245,96 +318,18 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
]
# Get info from flex-context
- consumption_price_sensor = self.flex_context.get("consumption_price_sensor")
- production_price_sensor = self.flex_context.get("production_price_sensor")
- gas_price_sensor = self.flex_context.get("gas_price_sensor")
-
- consumption_price = self.flex_context.get(
- "consumption_price", consumption_price_sensor
- )
- production_price = self.flex_context.get(
- "production_price", production_price_sensor
- )
- gas_price = self.flex_context.get("gas_price", gas_price_sensor)
- # fallback to using the consumption price, for backwards compatibility
- if production_price is None:
- production_price = consumption_price
inflexible_device_sensors = self.flex_context.get(
"inflexible_device_sensors", []
)
- # Fetch the device's power capacity (required Sensor attribute)
+ # Fetch the device's power capacity required by the device constraints.
power_capacity_in_mw = self._get_device_power_capacity(flex_model, assets)
- gas_deviation_prices = None
- if gas_price is not None:
- gas_deviation_prices = get_continuous_series_sensor_or_quantity(
- variable_quantity=gas_price,
- unit=self.flex_context["shared_currency_unit"] + "/MWh",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- fill_sides=True,
- ).to_frame(name="event_value")
- ensure_prices_are_not_empty(gas_deviation_prices, gas_price)
- gas_deviation_prices = (
- gas_deviation_prices.loc[start : end - resolution]["event_value"]
- * resolution
- / pd.Timedelta("1h")
- )
-
- # Check for known prices or price forecasts
- up_deviation_prices = get_continuous_series_sensor_or_quantity(
- variable_quantity=consumption_price,
- unit=self.flex_context["shared_currency_unit"] + "/MWh",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- fill_sides=True,
- ).to_frame(name="event_value")
- ensure_prices_are_not_empty(up_deviation_prices, consumption_price)
- down_deviation_prices = get_continuous_series_sensor_or_quantity(
- variable_quantity=production_price,
- unit=self.flex_context["shared_currency_unit"] + "/MWh",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- fill_sides=True,
- ).to_frame(name="event_value")
- ensure_prices_are_not_empty(down_deviation_prices, production_price)
-
+ # Convert to UTC before fetching time series.
start = pd.Timestamp(start).tz_convert("UTC")
end = pd.Timestamp(end).tz_convert("UTC")
- # Create Series with EMS capacities
- ems_power_capacity_in_mw = get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get("ems_power_capacity_in_mw"),
- unit="MW",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- resolve_overlaps="min",
- )
- ems_consumption_capacity = get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get("ems_consumption_capacity_in_mw"),
- unit="MW",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- max_value=ems_power_capacity_in_mw,
- resolve_overlaps="min",
- )
- ems_production_capacity = -1 * get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get("ems_production_capacity_in_mw"),
- unit="MW",
- query_window=(start, end),
- resolution=resolution,
- beliefs_before=belief_time,
- max_value=ems_power_capacity_in_mw,
- resolve_overlaps="min",
- )
-
- # Set up commitments to optimise for
+ # Set up commitments to optimise for.
commitments = self.convert_to_commitments(
query_window=(start, end),
resolution=resolution,
@@ -345,18 +340,15 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
index = initialize_index(start, end, resolution)
commitment_quantities = initialize_series(0, start, end, resolution)
- # Convert energy prices to EUR/(deviation of commitment, which is in MW)
- commitment_upwards_deviation_price = (
- up_deviation_prices.loc[start : end - resolution]["event_value"]
- * resolution
- / pd.Timedelta("1h")
- )
- commitment_downwards_deviation_price = (
- down_deviation_prices.loc[start : end - resolution]["event_value"]
- * resolution
- / pd.Timedelta("1h")
- )
-
+ # Keep EMS constraints global only.
+ #
+ # Important:
+ # Do NOT put commodity-specific site-consumption-capacity or
+ # site-production-capacity into ems_constraints, because these constraints
+ # are applied to the sum of all devices in device_scheduler.
+ #
+ # Commodity-specific capacities are modelled below as FlowCommitments
+ # with device=commodity_devices.
ems_constraints = initialize_df(
StorageScheduler.COLUMNS, start, end, resolution
)
@@ -371,25 +363,59 @@ def device_list_series(
commodity = flex_model_d.get("commodity", "electricity")
commodity_to_devices.setdefault(commodity, []).append(d)
+ commodity_contexts = self._get_commodity_contexts()
+ price_frames_by_commodity = {}
+
for commodity, devices in commodity_to_devices.items():
commodity_devices = device_list_series(devices, index)
+ commodity_context = commodity_contexts.get(commodity, {})
- if commodity == "electricity":
- up_price = commitment_upwards_deviation_price
- down_price = commitment_downwards_deviation_price
- elif commodity == "gas":
- if gas_deviation_prices is None:
- raise ValueError(
- "Gas prices are required in the flex-context to set up gas flow commitments."
- )
- up_price = gas_deviation_prices
- down_price = gas_deviation_prices
- else:
+ consumption_price = commodity_context.get("consumption_price")
+ production_price = commodity_context.get("production_price")
+
+ if production_price is None:
+ production_price = consumption_price
+
+ if consumption_price is None:
raise ValueError(
- f"Unsupported commodity {commodity} in flex-model. "
- "Only 'electricity' and 'gas' are supported."
+ f"Missing consumption price for commodity '{commodity}'."
)
+ # Energy prices for this commodity.
+ up_deviation_prices = get_continuous_series_sensor_or_quantity(
+ variable_quantity=consumption_price,
+ unit=self.flex_context["shared_currency_unit"] + "/MWh",
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ fill_sides=True,
+ ).to_frame(name="event_value")
+ ensure_prices_are_not_empty(up_deviation_prices, consumption_price)
+
+ down_deviation_prices = get_continuous_series_sensor_or_quantity(
+ variable_quantity=production_price,
+ unit=self.flex_context["shared_currency_unit"] + "/MWh",
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ fill_sides=True,
+ ).to_frame(name="event_value")
+ ensure_prices_are_not_empty(down_deviation_prices, production_price)
+
+ price_frames_by_commodity[commodity] = up_deviation_prices
+
+ # Convert energy prices to price per MW deviation for one resolution step.
+ up_price = (
+ up_deviation_prices.loc[start : end - resolution]["event_value"]
+ * resolution
+ / pd.Timedelta("1h")
+ )
+ down_price = (
+ down_deviation_prices.loc[start : end - resolution]["event_value"]
+ * resolution
+ / pd.Timedelta("1h")
+ )
+
commitments.append(
FlowCommitment(
name=f"{commodity} net energy",
@@ -403,9 +429,46 @@ def device_list_series(
)
)
- if self.flex_context.get("ems_peak_consumption_price") is not None:
+ # Commodity-specific site capacities.
+ # These are not written into ems_constraints. Instead, they are added as
+ # FlowCommitments that only aggregate the devices of this commodity.
+ ems_power_capacity = get_continuous_series_sensor_or_quantity(
+ variable_quantity=commodity_context.get("ems_power_capacity_in_mw"),
+ unit="MW",
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ resolve_overlaps="min",
+ )
+
+ ems_consumption_capacity = get_continuous_series_sensor_or_quantity(
+ variable_quantity=commodity_context.get(
+ "ems_consumption_capacity_in_mw"
+ ),
+ unit="MW",
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ max_value=ems_power_capacity,
+ resolve_overlaps="min",
+ )
+
+ ems_production_capacity = -1 * get_continuous_series_sensor_or_quantity(
+ variable_quantity=commodity_context.get(
+ "ems_production_capacity_in_mw"
+ ),
+ unit="MW",
+ query_window=(start, end),
+ resolution=resolution,
+ beliefs_before=belief_time,
+ max_value=ems_power_capacity,
+ resolve_overlaps="min",
+ )
+
+ # Commodity-specific peak consumption commitment.
+ if commodity_context.get("ems_peak_consumption_price") is not None:
ems_peak_consumption = get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get(
+ variable_quantity=commodity_context.get(
"ems_peak_consumption_in_mw"
),
unit="MW",
@@ -416,7 +479,7 @@ def device_list_series(
fill_sides=True,
)
ems_peak_consumption_price = get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get(
+ variable_quantity=commodity_context.get(
"ems_peak_consumption_price"
),
unit=self.flex_context["shared_currency_unit"] + "/MW",
@@ -439,9 +502,10 @@ def device_list_series(
)
)
- if self.flex_context.get("ems_peak_production_price") is not None:
+ # Commodity-specific peak production commitment.
+ if commodity_context.get("ems_peak_production_price") is not None:
ems_peak_production = get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get(
+ variable_quantity=commodity_context.get(
"ems_peak_production_in_mw"
),
unit="MW",
@@ -452,7 +516,7 @@ def device_list_series(
fill_sides=True,
)
ems_peak_production_price = get_continuous_series_sensor_or_quantity(
- variable_quantity=self.flex_context.get(
+ variable_quantity=commodity_context.get(
"ems_peak_production_price"
),
unit=self.flex_context["shared_currency_unit"] + "/MW",
@@ -475,13 +539,14 @@ def device_list_series(
)
)
- ems_consumption_breach_price = self.flex_context.get(
+ ems_consumption_breach_price = commodity_context.get(
"ems_consumption_breach_price"
)
- ems_production_breach_price = self.flex_context.get(
+ ems_production_breach_price = commodity_context.get(
"ems_production_breach_price"
)
+ # Commodity-specific site consumption breach.
if ems_consumption_breach_price is not None:
any_ems_consumption_breach_price = (
get_continuous_series_sensor_or_quantity(
@@ -529,10 +594,7 @@ def device_list_series(
)
)
- ems_constraints["derivative max"] = ems_power_capacity_in_mw
- else:
- ems_constraints["derivative max"] = ems_consumption_capacity
-
+ # Commodity-specific site production breach.
if ems_production_breach_price is not None:
any_ems_production_breach_price = (
get_continuous_series_sensor_or_quantity(
@@ -580,10 +642,15 @@ def device_list_series(
)
)
- ems_constraints["derivative min"] = -ems_power_capacity_in_mw
- else:
- ems_constraints["derivative min"] = ems_production_capacity
-
+ # Keep one price frame for later preference logic.
+ # The existing "prefer charging sooner" code uses `up_deviation_prices`.
+ # Prefer electricity prices if available, otherwise use the first commodity price.
+ if "electricity" in price_frames_by_commodity:
+ up_deviation_prices = price_frames_by_commodity["electricity"]
+ elif price_frames_by_commodity:
+ up_deviation_prices = next(iter(price_frames_by_commodity.values()))
+ else:
+ raise ValueError("No commodity prices were available.")
# Commitments per device
# StockCommitment per device to prefer a full storage by penalizing not being full
diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py
index 37f5b156d3..e84361c0eb 100644
--- a/flexmeasures/data/models/planning/tests/test_commitments.py
+++ b/flexmeasures/data/models/planning/tests/test_commitments.py
@@ -871,7 +871,7 @@ def test_two_devices_shared_stock(app, db):
)
-def test_simulation_copy_new(app, db):
+def set_up_simulation_assets_and_sensors(app, db):
# ---- asset types and assets
gas_boiler_type = get_or_create_model(GenericAssetType, name="gas-boiler")
buffer_type = get_or_create_model(GenericAssetType, name="heat-buffer")
@@ -902,14 +902,6 @@ def test_simulation_copy_new(app, db):
db.session.add_all([gas_boiler, heat_buffer, building, electric_heater, site])
db.session.commit()
- # ---- sensors
- start = pd.Timestamp("2026-04-07T00:00:00+01:00")
- end = pd.Timestamp(
- "2026-04-09T06:00:00+01:00"
- ) # Extended to allow discharge target on April 8
- belief_time = pd.Timestamp(
- "2026-04-05T00:00:00+01:00"
- ) # 2 days before start for generous planning horizon
power_resolution = pd.Timedelta("15m")
energy_resolution = pd.Timedelta(0)
@@ -960,6 +952,30 @@ def test_simulation_copy_new(app, db):
event_resolution=energy_resolution, # instantaneous
generic_asset=heat_buffer,
)
+ consumption_price = Sensor(
+ name="consumption price",
+ unit="EUR/MWh",
+ event_resolution=energy_resolution,
+ generic_asset=site,
+ )
+ production_price = Sensor(
+ name="production price",
+ unit="EUR/MWh",
+ event_resolution=energy_resolution,
+ generic_asset=site,
+ )
+ gas_price = Sensor(
+ name="gas price",
+ unit="EUR/MWh",
+ event_resolution=energy_resolution,
+ generic_asset=site,
+ )
+ dynamic_consumption_capacity = Sensor(
+ name="dynamic consumption capacity",
+ unit="kW",
+ event_resolution=power_resolution,
+ generic_asset=site,
+ )
db.session.add_all(
[
@@ -970,16 +986,66 @@ def test_simulation_copy_new(app, db):
building_raw_power,
heater_power,
soc_targets,
+ consumption_price,
+ production_price,
+ gas_price,
+ dynamic_consumption_capacity,
]
)
db.session.commit()
+ return {
+ "site": site,
+ "building": building,
+ "gas_boiler": gas_boiler,
+ "heat_buffer": heat_buffer,
+ "electric_heater": electric_heater,
+ "building_raw_power": building_raw_power,
+ "boiler_power": boiler_power,
+ "tank_power": tank_power,
+ "buffer_soc": buffer_soc,
+ "buffer_soc_usage": buffer_soc_usage,
+ "heater_power": heater_power,
+ "soc_targets": soc_targets,
+ "power_resolution": power_resolution,
+ "energy_resolution": energy_resolution,
+ "consumption_price": consumption_price,
+ "production_price": production_price,
+ "gas_price": gas_price,
+ "dynamic_consumption_capacity": dynamic_consumption_capacity,
+ }
+
+
+def test_simulation_with_dynamic_consumption_capacity(app, db):
+
+ start = pd.Timestamp("2026-04-07T00:00:00+01:00")
+ end = pd.Timestamp(
+ "2026-04-09T06:00:00+01:00"
+ ) # Extended to allow discharge target on April 8
+ belief_time = pd.Timestamp(
+ "2026-04-05T00:00:00+01:00"
+ ) # 2 days before start for generous planning horizon
+
+ setup_data = set_up_simulation_assets_and_sensors(app, db)
+
+ site = setup_data["site"]
+ building_raw_power = setup_data["building_raw_power"]
+ heater_power = setup_data["heater_power"]
+ boiler_power = setup_data["boiler_power"]
+ buffer_soc = setup_data["buffer_soc"]
+ buffer_soc_usage = setup_data["buffer_soc_usage"]
+ consumption_price = setup_data["consumption_price"]
+ gas_price = setup_data["gas_price"]
+ dynamic_consumption_capacity = setup_data["dynamic_consumption_capacity"]
+
import timely_beliefs as tb
from flexmeasures import Source
# add dummy data to building raw power to ensure site-level constraints are respected
building_data = pd.Series(
100.0,
- index=pd.date_range(start, end, freq=power_resolution, name="event_start"),
+ index=pd.date_range(
+ start, end, freq=setup_data["power_resolution"], name="event_start"
+ ),
name="event_value",
).reset_index()
@@ -988,40 +1054,97 @@ def test_simulation_copy_new(app, db):
bdf = tb.BeliefsDataFrame(
building_data,
belief_horizon=-pd.Timedelta(seconds=1) * np.array(range(len(building_data))),
- sensor=building_raw_power,
+ sensor=setup_data["building_raw_power"],
+ source=get_or_create_model(Source, name="Simulation"),
+ )
+ save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False)
+
+ # Dynamic site consumption capacity:
+ # - 1200 * 0.6 = 720 kW from 12:00 to 18:00
+ # - 1200 kW for the rest of the day
+ dynamic_capacity_data = pd.DataFrame(
+ index=pd.date_range(
+ start, end, freq=setup_data["power_resolution"], name="event_start"
+ )
+ ).reset_index()
+
+ # Dynamic electricity and gas prices:
+ # - Electricity is cheaper than gas from 12:00 to 16:00
+ # - Gas is cheaper for the rest of the day
+ price_index = pd.date_range(
+ start,
+ end,
+ freq=setup_data["power_resolution"],
+ name="event_start",
+ )
+
+ electricity_price_data = pd.DataFrame(index=price_index).reset_index()
+ gas_price_data = pd.DataFrame(index=price_index).reset_index()
+
+ # Default prices: gas cheaper than electricity
+ electricity_price_data["event_value"] = 120.0
+ gas_price_data["event_value"] = 90.0
+
+ # From 12:00 until before 16:00, electricity cheaper than gas
+ cheap_electricity_mask = electricity_price_data["event_start"].dt.hour.between(
+ 12, 15
+ )
+
+ electricity_price_data.loc[
+ cheap_electricity_mask,
+ "event_value",
+ ] = 50.0
+
+ gas_price_data.loc[
+ cheap_electricity_mask,
+ "event_value",
+ ] = 150.0
+
+ bdf = tb.BeliefsDataFrame(
+ electricity_price_data,
+ belief_time=belief_time,
+ sensor=setup_data["consumption_price"],
+ source=get_or_create_model(Source, name="Simulation"),
+ )
+ save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False)
+
+ bdf = tb.BeliefsDataFrame(
+ gas_price_data,
+ belief_time=belief_time,
+ sensor=setup_data["gas_price"],
+ source=get_or_create_model(Source, name="Simulation"),
+ )
+ save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False)
+
+ dynamic_capacity_data["event_value"] = 100.0
+
+ dynamic_capacity_data.loc[
+ dynamic_capacity_data["event_start"].dt.hour.between(12, 17),
+ "event_value",
+ ] = (
+ 100.0 * 0.6
+ )
+
+ bdf = tb.BeliefsDataFrame(
+ dynamic_capacity_data,
+ belief_time=belief_time,
+ sensor=setup_data["dynamic_consumption_capacity"],
source=get_or_create_model(Source, name="Simulation"),
)
+
save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False)
- soc_usage["event_value"] = soc_usage["event_value"] * 1.49
+ soc_usage["event_value"] = 100
bdf = tb.BeliefsDataFrame(
soc_usage,
belief_time=belief_time,
- sensor=buffer_soc_usage,
+ sensor=setup_data["buffer_soc_usage"],
source=get_or_create_model(Source, name="Simulation"),
)
save_to_db(bdf, bulk_save_objects=False, save_changed_beliefs_only=False)
flex_model = [
- # {
- # "sensor": pv_power.id,
- # "consumption-capacity": "0 kW",
- # "production-capacity": {"sensor": pv_raw_power.id},
- # "power-capacity": "1 GW",
- # },
- # {
- # "sensor": battery_power.id,
- # "soc-min": 0.0,
- # "soc-max": 100.0,
- # "soc-at-start": 20.0,
- # "power-capacity": "20 kW",
- # "roundtrip-efficiency": 0.9,
- # "soc-targets": [{"datetime": "2026-04-07T20:00:00+01:00", "value": 80.0}],
- # "state-of-charge": {"sensor": battery_soc.id},
- # "commodity": "electricity",
- #
- # },
{
"sensor": heater_power.id,
"state-of-charge": {"sensor": buffer_soc.id},
@@ -1049,21 +1172,42 @@ def test_simulation_copy_new(app, db):
# {"datetime": "2026-04-07T20:00:00+01:00", "value": 700.0},
# ],
"state-of-charge": {"sensor": buffer_soc.id},
- # "soc-usage": [{"sensor": buffer_soc_usage.id}],
+ "soc-usage": [{"sensor": buffer_soc_usage.id}],
"storage-efficiency": 0.9, # todo: does not work yet
# todo: consider assigning this to the heat commodity, maybe we can derive some useful (costs?) KPI from it
},
]
flex_context = {
- "consumption-price": "100 EUR/MWh",
- "production-price": "100 EUR/MWh",
- "gas-price": "150 EUR/MWh",
- "site-power-capacity": "4700 kW",
- "site-consumption-capacity": "4000 kW",
- "site-production-capacity": "100 kW",
- "site-consumption-breach-price": "100000 EUR/kW",
- "site-production-breach-price": "100000 EUR/kW",
+ "commodities": [
+ {
+ "commodity": "electricity",
+ "consumption-price": {
+ "sensor": consumption_price.id,
+ },
+ "production-price": {
+ "sensor": consumption_price.id,
+ },
+ "site-power-capacity": "1900 kW",
+ "site-consumption-capacity": {
+ "sensor": dynamic_consumption_capacity.id,
+ },
+ "site-production-capacity": "100 kW",
+ "site-consumption-breach-price": "100000 EUR/kW",
+ "site-production-breach-price": "100000 EUR/kW",
+ },
+ {
+ "commodity": "gas",
+ "consumption-price": {
+ "sensor": gas_price.id,
+ },
+ "production-price": {
+ "sensor": gas_price.id,
+ },
+ # No electricity dynamic capacity here.
+ "site-consumption-capacity": "100000 kW",
+ },
+ ],
"relax-constraints": True,
"inflexible-device-sensors": [building_raw_power.id],
}
@@ -1072,7 +1216,7 @@ def test_simulation_copy_new(app, db):
asset_or_sensor=site,
start=start,
end=end,
- resolution=power_resolution,
+ resolution=setup_data["power_resolution"],
belief_time=belief_time,
flex_model=flex_model,
flex_context=flex_context,
@@ -1082,5 +1226,118 @@ def test_simulation_copy_new(app, db):
pd.set_option("display.max_rows", None)
schedules = scheduler.compute(skip_validation=True)
- # ---- verify outputs
- print(schedules)
+ heater_schedule = next(
+ schedule["data"]
+ for schedule in schedules
+ if schedule.get("sensor") == heater_power
+ )
+
+ boiler_schedule = next(
+ schedule["data"]
+ for schedule in schedules
+ if schedule.get("sensor") == boiler_power
+ )
+ # The electric heater should only be active in the cheap-electricity window.
+ # In local time, electricity is cheaper from 12:00 to 16:00.
+ # During this period, the dynamic electricity site capacity is only 60 kW.
+ # Therefore, the electric heater is expected to run at 60 kW, not its full
+ # 100 kW device capacity.
+ pd.testing.assert_series_equal(
+ heater_schedule.loc["2026-04-07T11:00:00+00:00":"2026-04-07T14:45:00+00:00"],
+ pd.Series(
+ 60.0,
+ index=pd.date_range(
+ "2026-04-07T11:00:00+00:00",
+ "2026-04-07T14:45:00+00:00",
+ freq="15min",
+ ),
+ dtype="float64",
+ ),
+ check_names=False,
+ obj=(
+ "electric heater dispatch during cheap-electricity window on day 1; "
+ "expected 60 kW because dynamic electricity capacity limits the heater"
+ ),
+ )
+
+ # When electricity is cheaper than gas, the gas boiler should stay off.
+ # The heat demand is then supplied by the electric heater instead.
+ pd.testing.assert_series_equal(
+ boiler_schedule.loc["2026-04-07T11:00:00+00:00":"2026-04-07T14:45:00+00:00"],
+ pd.Series(
+ 0.0,
+ index=pd.date_range(
+ "2026-04-07T11:00:00+00:00",
+ "2026-04-07T14:45:00+00:00",
+ freq="15min",
+ ),
+ dtype="float64",
+ ),
+ check_names=False,
+ obj=(
+ "gas boiler dispatch during cheap-electricity window on day 1; "
+ "expected 0 kW because electricity is cheaper than gas"
+ ),
+ )
+
+ pd.testing.assert_series_equal(
+ heater_schedule.loc["2026-04-08T11:00:00+00:00":"2026-04-08T14:45:00+00:00"],
+ pd.Series(
+ 60.0,
+ index=pd.date_range(
+ "2026-04-08T11:00:00+00:00",
+ "2026-04-08T14:45:00+00:00",
+ freq="15min",
+ ),
+ dtype="float64",
+ ),
+ check_names=False,
+ obj=(
+ "electric heater dispatch during cheap-electricity window on day 2; "
+ "expected 60 kW because dynamic electricity capacity limits the heater"
+ ),
+ )
+
+ pd.testing.assert_series_equal(
+ boiler_schedule.loc["2026-04-08T11:00:00+00:00":"2026-04-08T14:45:00+00:00"],
+ pd.Series(
+ 0.0,
+ index=pd.date_range(
+ "2026-04-08T11:00:00+00:00",
+ "2026-04-08T14:45:00+00:00",
+ freq="15min",
+ ),
+ dtype="float64",
+ ),
+ check_names=False,
+ obj=(
+ "gas boiler dispatch during cheap-electricity window on day 2; "
+ "expected 0 kW because electricity is cheaper than gas"
+ ),
+ )
+
+ # Outside the cheap-electricity window, gas is cheaper than electricity.
+ # Therefore, the gas boiler should become the preferred heat source and run
+ # at full 100 kW capacity, while the electric heater should remain off.
+ assert boiler_schedule.loc["2026-04-07T15:00:00+00:00"] == pytest.approx(
+ 100.0
+ ), "Gas boiler should run at full capacity after the cheap-electricity window on day 1."
+
+ assert heater_schedule.loc["2026-04-07T15:00:00+00:00"] == pytest.approx(
+ 0.0
+ ), "Electric heater should be off after the cheap-electricity window because gas is cheaper."
+
+ assert boiler_schedule.loc["2026-04-08T15:00:00+00:00"] == pytest.approx(
+ 100.0
+ ), "Gas boiler should run at full capacity after the cheap-electricity window on day 2."
+
+ assert heater_schedule.loc["2026-04-08T15:00:00+00:00"] == pytest.approx(
+ 0.0
+ ), "Electric heater should be off after the cheap-electricity window on day 2 because gas is cheaper."
+
+ # Before the first cheap-electricity window, the optimizer uses a partial
+ # 80 kW electric-heater step to prepare the heat buffer. This is part of the
+ # expected optimal schedule and protects against accidental dispatch changes.
+ assert heater_schedule.loc["2026-04-07T08:00:00+00:00"] == pytest.approx(
+ 80.0
+ ), "Electric heater should have one expected partial 80 kW dispatch step before the first cheap-electricity window."
diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py
index 7f1c747a3c..f14f9fe1df 100644
--- a/flexmeasures/data/schemas/scheduling/__init__.py
+++ b/flexmeasures/data/schemas/scheduling/__init__.py
@@ -139,9 +139,100 @@ class DBCommitmentSchema(CommitmentSchema, NoTimeSeriesSpecs):
pass
+class CommodityFlexContextSchema(Schema):
+ commodity = fields.Str(
+ required=True,
+ validate=validate.OneOf(["electricity", "gas"]),
+ data_key="commodity",
+ )
+
+ consumption_price = VariableQuantityField(
+ "/MWh",
+ required=False,
+ data_key="consumption-price",
+ return_magnitude=False,
+ )
+
+ production_price = VariableQuantityField(
+ "/MWh",
+ required=False,
+ data_key="production-price",
+ return_magnitude=False,
+ )
+
+ ems_power_capacity_in_mw = VariableQuantityField(
+ "MW",
+ required=False,
+ data_key="site-power-capacity",
+ value_validator=validate.Range(min=0),
+ )
+
+ ems_consumption_capacity_in_mw = VariableQuantityField(
+ "MW",
+ required=False,
+ data_key="site-consumption-capacity",
+ value_validator=validate.Range(min=0),
+ )
+
+ ems_production_capacity_in_mw = VariableQuantityField(
+ "MW",
+ required=False,
+ data_key="site-production-capacity",
+ value_validator=validate.Range(min=0),
+ )
+
+ ems_consumption_breach_price = VariableQuantityField(
+ "/MW",
+ required=False,
+ data_key="site-consumption-breach-price",
+ value_validator=validate.Range(min=0),
+ )
+
+ ems_production_breach_price = VariableQuantityField(
+ "/MW",
+ required=False,
+ data_key="site-production-breach-price",
+ value_validator=validate.Range(min=0),
+ )
+
+ ems_peak_consumption_in_mw = VariableQuantityField(
+ "MW",
+ required=False,
+ data_key="site-peak-consumption",
+ value_validator=validate.Range(min=0),
+ )
+
+ ems_peak_consumption_price = VariableQuantityField(
+ "/MW",
+ required=False,
+ data_key="site-peak-consumption-price",
+ value_validator=validate.Range(min=0),
+ )
+
+ ems_peak_production_in_mw = VariableQuantityField(
+ "MW",
+ required=False,
+ data_key="site-peak-production",
+ value_validator=validate.Range(min=0),
+ )
+
+ ems_peak_production_price = VariableQuantityField(
+ "/MW",
+ required=False,
+ data_key="site-peak-production-price",
+ value_validator=validate.Range(min=0),
+ )
+
+
class FlexContextSchema(Schema):
"""This schema defines fields that provide context to the portfolio to be optimized."""
+ commodity_contexts = fields.Nested(
+ CommodityFlexContextSchema,
+ data_key="commodities",
+ required=False,
+ many=True,
+ )
# Device commitments
consumption_breach_price = VariableQuantityField(
"/MW",
diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json
index 54efa403e4..363775451c 100644
--- a/flexmeasures/ui/static/openapi-specs.json
+++ b/flexmeasures/ui/static/openapi-specs.json
@@ -3896,6 +3896,33 @@
"openapi": "3.1.2",
"components": {
"schemas": {
+ "CommodityFlexContext": {
+ "type": "object",
+ "properties": {
+ "commodity": {
+ "type": "string",
+ "enum": [
+ "electricity",
+ "gas"
+ ]
+ },
+ "consumption-price": {},
+ "production-price": {},
+ "site-power-capacity": {},
+ "site-consumption-capacity": {},
+ "site-production-capacity": {},
+ "site-consumption-breach-price": {},
+ "site-production-breach-price": {},
+ "site-peak-consumption": {},
+ "site-peak-consumption-price": {},
+ "site-peak-production": {},
+ "site-peak-production-price": {}
+ },
+ "required": [
+ "commodity"
+ ],
+ "additionalProperties": false
+ },
"Quantity": {
"type": "string",
"description": "Quantity string describing a fixed quantity.",
@@ -3974,6 +4001,12 @@
"FlexContextOpenAPISchema": {
"type": "object",
"properties": {
+ "commodities": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CommodityFlexContext"
+ }
+ },
"consumption-breach-price": {
"description": "This penalty value is used to discourage the violation of the consumption-capacity constraint in the flex-model.\nIt effectively treats the capacity as a soft constraint, allowing the scheduler to exceed it when necessary but with a high cost.\nThe scheduler will attempt to minimize this cost.\nIt must use the same currency as the other price settings and cannot be negative.\n",
"example": "10 EUR/kW",