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 ^^^^^^^^^^^^^ diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 13af1e15a4..32a612e8b0 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -288,12 +288,25 @@ 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}") + 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) @@ -386,6 +399,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: @@ -435,12 +452,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 diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index eb3f7704b5..243a696698 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,30 @@ 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. + """Couple EMS flow commitments to device flows, optionally filtered by commodity.""" - - Creates an inequality for one-sided commitments. - - Creates an equality for two-sided commitments and for groups of size 1. - """ - 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." - ) + + # 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 ( - ( - 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): @@ -726,6 +725,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) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 6fb7aa5e94..8f84334181 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, @@ -198,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"), @@ -239,7 +248,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) @@ -258,206 +270,260 @@ 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): + 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." + ) - # 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"{commodity} energy {d}", + quantity=commitment_quantities, + upwards_deviation_price=up_price, + downwards_deviation_price=down_price, + commodity=commodity, index=index, + device=d, + device_group=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) - # Flow commitments per device + # 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) - # 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: + # 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 + + # Commitments per device + + # 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. + 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(up_deviation_prices, "event_value") + add_tiny_price_slope( + up_deviation_prices, "event_value", order="desc" + ) - 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, + 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]) + * (timedelta(hours=1) / resolution), + upwards_deviation_price=0, + downwards_deviation_price=-penalty, index=index, device=d, ) @@ -939,8 +1005,9 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 def convert_to_commitments( self, + flex_model, **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: @@ -976,7 +1043,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 diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 0a0ba8b408..9f2bf36fda 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -1,12 +1,21 @@ import pandas as pd import numpy as np +import pytest -from flexmeasures.data.models.planning import Commitment, StockCommitment, FlowCommitment +from flexmeasures.data.services.utils import get_or_create_model +from flexmeasures.data.models.planning import ( + Commitment, + StockCommitment, + FlowCommitment, +) from flexmeasures.data.models.planning.utils import ( 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(): @@ -118,6 +127,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 +140,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], ) ) @@ -306,3 +317,407 @@ 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) + + assert isinstance(schedules, list) + 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): + """ + 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") + 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", + "production-capacity": "0 kW", + "soc-usage": ["1 kW"], + "soc-min": 0.0, + "soc-max": 0.0, + "soc-at-start": 0.0, + }, + ] + + 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) + 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"] + + 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"] + + # 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']}" + ) + + # 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"] + ), ( + 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}×" + ) diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 919936d315..a45a3dd2d9 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] @@ -816,8 +817,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 +854,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 @@ -1787,10 +1788,10 @@ 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) + 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)) @@ -1835,9 +1836,20 @@ 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, resolution=resolution) + zero_prices = prices[prices.event_value == 0] + 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 == 0) + assert all( + 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.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 schedule = scheduler.compute() @@ -2235,11 +2247,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 = { 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 diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 78d067a858..5463d116e8 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -303,6 +303,13 @@ class FlexContextSchema(Schema): required=False, metadata=metadata.AGGREGATE_POWER.to_dict(), ) + gas_price = VariableQuantityField( + "/MWh", + data_key="gas-price", + required=False, + return_magnitude=False, + metadata=metadata.GAS_PRICE.to_dict(), + ) def set_default_breach_prices( self, data: dict, fields: list[str], price: ur.Quantity @@ -579,6 +586,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]] = { @@ -735,6 +747,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 39204d1bc3..da93b66101 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]_ @@ -184,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. The sensor unit may be an energy unit (e.g. MWh or kWh) or a percentage (%). For sensors with a % unit, the ``soc-max`` flex-model field must be set to a non-zero value to allow converting the energy-based schedule to a percentage.", example={"sensor": 12}, diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 8550cc094c..2775832f68 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", @@ -223,6 +225,13 @@ class StorageFlexModelSchema(Schema): validate=validate.Length(min=1), metadata=metadata.SOC_USAGE.to_dict(), ) + commodity = fields.Str( + required=False, + data_key="commodity", + load_default="electricity", + validate=OneOf(["electricity", "gas"]), + metadata=dict(description="Commodity label for this device/asset."), + ) def __init__( self, @@ -344,6 +353,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.""" @@ -494,6 +508,14 @@ class DBStorageFlexModelSchema(Schema): metadata={"deprecated field": "production_capacity"}, ) + commodity = fields.Str( + required=False, + data_key="commodity", + 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 c95f04cfda..256ee31e64 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4110,6 +4110,13 @@ "sensor": 9 }, "$ref": "#/components/schemas/SensorReference" + }, + "gas-price": { + "description": "The gas price applied to the site's aggregate gas consumption. Can be (a sensor recording) market prices, but also CO\u2082 intensity\u2014whatever fits your optimization problem", + "example": { + "sensor": 6 + }, + "$ref": "#/components/schemas/VariableQuantityOpenAPI" } }, "additionalProperties": false @@ -5496,6 +5503,15 @@ } ], "items": {} + }, + "commodity": { + "type": "string", + "default": "electricity", + "enum": [ + "electricity", + "gas" + ], + "description": "Commodity label for this device/asset." } }, "additionalProperties": false