diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 32a612e8b0..e085c976d5 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Iterable from dataclasses import dataclass, field from datetime import datetime, timedelta @@ -52,6 +53,7 @@ class Scheduler: flex_model: list[dict] | dict | None = None flex_context: dict | None = None + stock_groups: dict | None = None fallback_scheduler_class: "Type[Scheduler] | None" = None info: dict | None = None @@ -64,6 +66,41 @@ class Scheduler: return_multiple: bool = False + @staticmethod + def _build_stock_groups(flex_model: list[dict]) -> dict: + """ + Build stock groups where devices sharing the same state-of-charge sensor are grouped together. + """ + groups = defaultdict(list) + soc_usage = defaultdict(list) + + for d, fm in enumerate(flex_model): + if fm.get("sensor") is None: + continue + + soc = fm.get("state_of_charge") + if soc is not None: + if hasattr(soc, "id"): + soc_id = soc.id + elif isinstance(soc, dict) and "sensor" in soc: + sensor = soc["sensor"] + soc_id = sensor.id if hasattr(sensor, "id") else sensor + else: + soc_id = soc + + soc_usage[soc_id].append(d) + + for soc_id, device_list in soc_usage.items(): + groups[soc_id] = device_list + + missing_soc_sensor_i = -len(flex_model) + for d, fm in enumerate(flex_model): + if fm.get("sensor") is not None and fm.get("state_of_charge") is None: + groups[missing_soc_sensor_i].append(d) + missing_soc_sensor_i += 1 + + return dict(groups) + def __init__( self, sensor: Sensor | None = None, # deprecated @@ -202,12 +239,19 @@ def collect_flex_config(self): # Listify the flex-model for the next code block, which actually does the merging with the db_flex_model flex_model = [flex_model] + # Find which asset is relevant for a given device model in the flex-model from the trigger message for flex_model_d in flex_model: asset_id = flex_model_d.get("asset") if asset_id is None: - sensor_id = flex_model_d["sensor"] - sensor = db.session.get(Sensor, sensor_id) - asset_id = sensor.asset_id + sensor_id = flex_model_d.get("sensor") + if sensor_id is not None: + sensor = db.session.get(Sensor, sensor_id) + asset_id = sensor.asset_id + else: + soc_sensor_ref = flex_model_d.get("state-of-charge") + if soc_sensor_ref is not None: + soc_sensor = db.session.get(Sensor, soc_sensor_ref["sensor"]) + asset_id = soc_sensor.asset_id if asset_id in db_flex_model: flex_model_d = {**db_flex_model[asset_id], **flex_model_d} amended_flex_model.append(flex_model_d) diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 0b5d158057..36f3395b34 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -41,6 +41,7 @@ def device_scheduler( # noqa C901 commitment_upwards_deviation_price: list[pd.Series] | list[float] | None = None, commitments: list[pd.DataFrame] | list[Commitment] | None = None, initial_stock: float | list[float] = 0, + stock_groups: dict[int, list[int]] | None = None, ) -> tuple[list[pd.Series], float, SolverResults, ConcreteModel]: """This generic device scheduler is able to handle an EMS with multiple devices, with various types of constraints on the EMS level and on the device level, @@ -100,6 +101,22 @@ def device_scheduler( # noqa C901 resolution = pd.to_timedelta(device_constraints[0].index.freq).to_pytimedelta() end = device_constraints[0].index.to_pydatetime()[-1] + resolution + # map device → stock group + device_to_group = {} + + if stock_groups: + for g, devices in stock_groups.items(): + for d in devices: + device_to_group[d] = g + # For devices not in any stock group (e.g., inflexible devices), + # map them to themselves so they're treated as individual groups + for d in range(len(device_constraints)): + if d not in device_to_group: + device_to_group[d] = d + else: + for d in range(len(device_constraints)): + device_to_group[d] = d + # Move commitments from old structure to new if commitments is None: commitments = [] @@ -484,33 +501,77 @@ def grouped_commitment_equalities(m, c, j, g): ) model.commitment_sign = Var(model.c, domain=Binary, initialize=0) + # def _get_stock_change(m, d, j): + # """Determine final stock change of device d until time j. + # + # Apply conversion efficiencies to conversion from flow to stock change and vice versa, + # and apply storage efficiencies to stock levels from one datetime to the next. + # """ + # if isinstance(initial_stock, list): + # # No initial stock defined for inflexible device + # initial_stock_d = initial_stock[d] if d < len(initial_stock) else 0 + # else: + # initial_stock_d = initial_stock + # + # stock_changes = [ + # ( + # m.device_power_down[d, k] / m.device_derivative_down_efficiency[d, k] + # + m.device_power_up[d, k] * m.device_derivative_up_efficiency[d, k] + # + m.stock_delta[d, k] + # ) + # for k in range(0, j + 1) + # ] + # efficiencies = [m.device_efficiency[d, k] for k in range(0, j + 1)] + # final_stock_change = [ + # stock - initial_stock_d + # for stock in apply_stock_changes_and_losses( + # initial_stock_d, stock_changes, efficiencies + # ) + # ][-1] + # return final_stock_change + def _get_stock_change(m, d, j): - """Determine final stock change of device d until time j. - Apply conversion efficiencies to conversion from flow to stock change and vice versa, - and apply storage efficiencies to stock levels from one datetime to the next. - """ + # determine the stock group of this device + group = device_to_group[d] + + # all devices belonging to this stock + devices = [dev for dev, g in device_to_group.items() if g == group] + + # initial stock if isinstance(initial_stock, list): - # No initial stock defined for inflexible device - initial_stock_d = initial_stock[d] if d < len(initial_stock) else 0 + initial_stock_g = initial_stock[d] if d < len(initial_stock) else 0 else: - initial_stock_d = initial_stock + initial_stock_g = initial_stock + + stock_changes = [] + + for k in range(0, j + 1): + + change = 0 + + for dev in devices: + change += ( + m.device_power_down[dev, k] + / m.device_derivative_down_efficiency[dev, k] + + m.device_power_up[dev, k] + * m.device_derivative_up_efficiency[dev, k] + + m.stock_delta[dev, k] + ) + + stock_changes.append(change) - stock_changes = [ - ( - m.device_power_down[d, k] / m.device_derivative_down_efficiency[d, k] - + m.device_power_up[d, k] * m.device_derivative_up_efficiency[d, k] - + m.stock_delta[d, k] - ) - for k in range(0, j + 1) - ] efficiencies = [m.device_efficiency[d, k] for k in range(0, j + 1)] + final_stock_change = [ - stock - initial_stock_d + stock - initial_stock_g for stock in apply_stock_changes_and_losses( - initial_stock_d, stock_changes, efficiencies + initial_stock_g, + stock_changes, + efficiencies, ) ][-1] + return final_stock_change # Add constraints as a tuple of (lower bound, value, upper bound) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index f3eb590aec..cc998efe33 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -94,13 +94,88 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 resolution = self.resolution belief_time = self.belief_time + # For backwards compatibility with the single asset scheduler + flex_model = self.flex_model.copy() + if not isinstance(flex_model, list): + flex_model = [flex_model] + + # Identify stock models: entries not defining a power sensor, but only a (state-of-charge) sensor + self.stock_models = {} + + device_models = [] # everything except stock models + stock_models = {} # stock models only + + missing_soc_sensor_i = -len(flex_model) + for fm in flex_model: + + # stock model: entry in the flex-model list where the sensor key is the state-of-charge sensor of the device (e.g. a stock) + if fm.get("sensor") is None and (soc_sensor := fm.get("state_of_charge")): + stock_models[ + soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor + ] = fm + continue + + """ + [ + { + "sensor": 1, + "charging-efficiency": 0.9, + "state-of-charge": {"sensor": 2}, + }, + { + "sensor": 3, + "charging-efficiency": 0.9, + "state-of-charge": {"sensor": 2}, + }, + { + "state-of-charge": {"sensor": 2}, + "storage-efficiency": 0.99, + }, + ] + """ + + # Check if this is a stock-only model (no power sensor) + # Stock-only entries have SOC parameters but no power sensor + soc_sensor = fm.get("state_of_charge") + if fm.get("sensor") is None and soc_sensor is not None: + # This is a stock-only entry, add to stock_models only + soc_id = soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor + stock_models[soc_id] = fm + continue + + # device model: entry in the flex-model list where the sensor key is the power sensor of the device (e.g. a feeder) + device_models.append(fm) + + # If this device has state-of-charge parameters (soc-at-start, soc-min, etc.), + # also create a stock model entry so those parameters are properly captured + if soc_sensor is not None: + soc_id = soc_sensor.id if isinstance(soc_sensor, Sensor) else soc_sensor + # Check if there are SOC parameters in this device entry + has_soc_params = any( + param in fm + for param in ["soc_at_start", "soc_min", "soc_max", "soc_targets"] + ) + if has_soc_params: + stock_models[soc_id] = fm + elif fm.get("state_of_charge") is None: + stock_models[missing_soc_sensor_i] = fm + missing_soc_sensor_i += 1 + + flex_model = device_models + self.stock_models = stock_models + self._device_models = ( + device_models # Store filtered model for later use in _build_soc_schedule + ) + + # Rebuild stock_groups using only device_models (which have sensors) + # This ensures the mapping aligns with the device indices + self.stock_groups = self._build_stock_groups(device_models) + # List the asset(s) and sensor(s) being scheduled if self.asset is not None: if not isinstance(self.flex_model, list): self.flex_model = [self.flex_model] - sensors: list[Sensor | None] = [ - flex_model_d.get("sensor") for flex_model_d in self.flex_model - ] + sensors: list[Sensor | None] = [fm.get("sensor") for fm in device_models] assets: list[Asset | None] = [ # noqa: F841 s.asset if s is not None else flex_model_d.get("asset") for s, flex_model_d in zip(sensors, self.flex_model) @@ -118,31 +193,44 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 asset = self.sensor.generic_asset assets = [asset] # noqa: F841 - # For backwards compatibility with the single asset scheduler - flex_model = self.flex_model.copy() - if not isinstance(flex_model, list): - flex_model = [flex_model] + num_flexible_devices = len(device_models) + + soc_at_start = [None] * num_flexible_devices + soc_targets = [None] * num_flexible_devices + soc_min = [None] * num_flexible_devices + soc_max = [None] * num_flexible_devices + soc_minima = [None] * num_flexible_devices + soc_maxima = [None] * num_flexible_devices + soc_gain = [None] * num_flexible_devices + soc_usage = [None] * num_flexible_devices + prefer_charging_sooner = [None] * num_flexible_devices + prefer_curtailing_later = [None] * num_flexible_devices + + # Assign SOC constraints from stock model to the first device in each group + for stock_id, devices in self.stock_groups.items(): + + stock_model = self.stock_models.get(stock_id) + + if stock_model is None: + continue + + d0 = devices[0] - # total number of flexible devices D described in the flex-model - num_flexible_devices = len(flex_model) + soc_at_start[d0] = stock_model.get("soc_at_start") + soc_targets[d0] = stock_model.get("soc_targets") + soc_min[d0] = stock_model.get("soc_min") + soc_max[d0] = stock_model.get("soc_max") + soc_minima[d0] = stock_model.get("soc_minima") + soc_maxima[d0] = stock_model.get("soc_maxima") + soc_gain[d0] = stock_model.get("soc_gain") + soc_usage[d0] = stock_model.get("soc_usage") + prefer_charging_sooner[d0] = stock_model.get("prefer_charging_sooner") + prefer_curtailing_later[d0] = stock_model.get("prefer_curtailing_later") - soc_at_start = [flex_model_d.get("soc_at_start") for flex_model_d in flex_model] - soc_targets = [flex_model_d.get("soc_targets") for flex_model_d in flex_model] - soc_min = [flex_model_d.get("soc_min") for flex_model_d in flex_model] - soc_max = [flex_model_d.get("soc_max") for flex_model_d in flex_model] - soc_minima = [flex_model_d.get("soc_minima") for flex_model_d in flex_model] - soc_maxima = [flex_model_d.get("soc_maxima") for flex_model_d in flex_model] + # todo: move storage-efficiency into a shared parameter for the first device belonging to a shared storage storage_efficiency = [ flex_model_d.get("storage_efficiency") for flex_model_d in flex_model ] - prefer_charging_sooner = [ - flex_model_d.get("prefer_charging_sooner") for flex_model_d in flex_model - ] - prefer_curtailing_later = [ - flex_model_d.get("prefer_curtailing_later") for flex_model_d in flex_model - ] - soc_gain = [flex_model_d.get("soc_gain") for flex_model_d in flex_model] - soc_usage = [flex_model_d.get("soc_usage") for flex_model_d in flex_model] consumption_capacity = [ flex_model_d.get("consumption_capacity") for flex_model_d in flex_model ] @@ -269,6 +357,10 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 / pd.Timedelta("1h") ) + ems_constraints = initialize_df( + StorageScheduler.COLUMNS, start, end, resolution + ) + # Set up commitments DataFrame for d, flex_model_d in enumerate(flex_model): commodity = flex_model_d.get("commodity", "electricity") @@ -383,9 +475,6 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 "ems_production_breach_price" ) - ems_constraints = initialize_df( - StorageScheduler.COLUMNS, start, end, resolution - ) if ems_consumption_breach_price is not None: # Convert to Series @@ -505,6 +594,11 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 for d, (prefer_charging_sooner_d, prefer_curtailing_later_d) in enumerate( zip(prefer_charging_sooner, prefer_curtailing_later) ): + soc_max_d = soc_max[d] + soc_at_start_d = soc_at_start[d] + + if soc_max_d is None or soc_at_start_d is None: + continue if prefer_charging_sooner_d: tiny_price_slope = ( add_tiny_price_slope( @@ -544,6 +638,21 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 ) ) + # # --- apply shared stock groups + # if hasattr(self, "stock_groups") and self.stock_groups: + # for stock_id, devices in self.stock_groups.items(): + # + # if len(devices) <= 1: + # continue + # + # # combine stock delta + # combined_delta = sum( + # device_constraints[d]["stock delta"] for d in devices + # ) + # + # for d in devices: + # device_constraints[d]["stock delta"] = combined_delta + # Create the device constraints for all the flexible devices for d in range(num_flexible_devices): sensor_d = sensors[d] @@ -709,7 +818,15 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # soc-maxima will become a soft constraint (modelled as stock commitments), so remove hard constraint soc_maxima[d] = None - if soc_at_start[d] is not None: + # only apply SOC constraints to the first device of a shared stock + apply_soc_constraints = True + + for stock_id, devices in self.stock_groups.items(): + if d in devices and d != devices[0]: + apply_soc_constraints = False + break + + if soc_at_start[d] is not None and apply_soc_constraints: device_constraints[d] = add_storage_constraints( start, end, @@ -992,6 +1109,40 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 + message ) + # --- apply shared stock groups + # Store original stock_delta values for use in _build_soc_schedule + original_stock_deltas = [ + device_constraints[d]["stock delta"].copy() + for d in range(len(device_constraints)) + ] + + if hasattr(self, "stock_groups") and self.stock_groups: + for stock_id, devices in self.stock_groups.items(): + + if len(devices) <= 1: + continue + + d0 = devices[0] + + # Combine all stock_deltas on the primary device + # This ensures the optimizer sees a single shared stock + combined_delta = sum( + device_constraints[d]["stock delta"] for d in devices + ) + device_constraints[d0]["stock delta"] = combined_delta + + # Secondary devices: zero out stock_delta (it's now in primary) but keep power contribution + for d in devices[1:]: + # Zero out stock_delta since it's now in primary device's combined_delta + device_constraints[d]["stock delta"] = 0 + + # disable stock bounds for secondary devices + device_constraints[d]["equals"] = np.nan + device_constraints[d]["min"] = np.nan + device_constraints[d]["max"] = np.nan + + # Store original stock_deltas for use in _build_soc_schedule + self.original_stock_deltas = original_stock_deltas return ( sensors, start, @@ -1109,9 +1260,21 @@ def deserialize_flex_config(self): self.flex_model ) for d, sensor_flex_model in enumerate(self.flex_model): + soc_sensor_id = ( + sensor_flex_model["sensor_flex_model"] + .get("state-of-charge", {}) + .get("sensor", None) + ) + soc_sensor = None + if soc_sensor_id is not None: + soc_sensor = Sensor.query.filter_by(id=soc_sensor_id).first() self.flex_model[d] = StorageFlexModelSchema( start=self.start, - sensor=sensor_flex_model.get("sensor"), + sensor=( + sensor_flex_model.get("sensor") + if sensor_flex_model.get("sensor") is not None + else soc_sensor + ), default_soc_unit=sensor_flex_model["sensor_flex_model"].get( "soc-unit" ), @@ -1124,6 +1287,7 @@ def deserialize_flex_config(self): soc_targets=self.flex_model[d].get("soc_targets"), sensor=self.flex_model[d]["sensor"], ) + self.stock_groups = self._build_stock_groups(self.flex_model) else: raise TypeError( @@ -1374,62 +1538,117 @@ class StorageScheduler(MetaStorageScheduler): @staticmethod def _build_soc_schedule( flex_model: list[dict], - ems_schedule: pd.DataFrame, + ems_schedule: list[pd.Series], soc_at_start: list[float], device_constraints: list, resolution: timedelta, + stock_groups: dict[int, list[int]], ) -> dict: - """Build the state-of-charge schedule for each device that has a state-of-charge sensor. + """Build the state-of-charge schedule for each stock group. + + Supports both: + - original logic: one device per stock group + - local/shared-stock logic: multiple devices contribute to one shared stock - Converts the integrated power schedule from MWh to the sensor's unit. - For sensors with a '%' unit, the soc-max flex-model field is used as capacity. - If soc-max is missing or zero for a '%' sensor, the schedule is skipped with a warning. + For shared stock groups, each device contribution is integrated separately with + its own efficiencies and stock delta, then summed on top of the shared initial stock. - Note: soc-max is a QuantityField (not a VariableQuantityField), so it is always a float - after deserialization and cannot be a sensor reference. The isinstance guard below is - therefore a defensive check for forward-compatibility. + Converts the integrated stock schedule from MWh to the state-of-charge sensor unit. + For '%' sensors, the soc-max flex-model field is used as capacity. """ soc_schedule = {} - for d, flex_model_d in enumerate(flex_model): - state_of_charge_sensor = flex_model_d.get("state_of_charge", None) + + for stock_id, devices in stock_groups.items(): + if not devices: + continue + + d0 = devices[0] + flex_model_d0 = flex_model[d0] + + state_of_charge_sensor = flex_model_d0.get("state_of_charge") if not isinstance(state_of_charge_sensor, Sensor): continue + + # Build the SoC series for this stock group + if len(devices) > 1: + soc_contributions = [] + reference_index = None + + for d in devices: + contribution = integrate_time_series( + series=ems_schedule[d], + initial_stock=0, + stock_delta=device_constraints[d]["stock delta"] + * resolution + / timedelta(hours=1), + up_efficiency=device_constraints[d]["derivative up efficiency"], + down_efficiency=device_constraints[d][ + "derivative down efficiency" + ], + storage_efficiency=device_constraints[d]["efficiency"] + .astype(float) + .fillna(1), + ) + soc_contributions.append(contribution) + + if reference_index is None: + reference_index = contribution.index + + initial_stock = soc_at_start[d0] if soc_at_start[d0] is not None else 0 + soc = pd.Series( + [ + initial_stock + + sum(contrib.iloc[i] for contrib in soc_contributions) + for i in range(len(soc_contributions[0])) + ], + index=reference_index, + ) + else: + soc = integrate_time_series( + series=ems_schedule[d0], + initial_stock=soc_at_start[d0], + stock_delta=device_constraints[d0]["stock delta"] + * resolution + / timedelta(hours=1), + up_efficiency=device_constraints[d0]["derivative up efficiency"], + down_efficiency=device_constraints[d0][ + "derivative down efficiency" + ], + storage_efficiency=device_constraints[d0]["efficiency"] + .astype(float) + .fillna(1), + ) + + # Convert to sensor unit soc_unit = state_of_charge_sensor.unit capacity = None if soc_unit == "%": - soc_max = flex_model_d.get("soc_max") + soc_max = flex_model_d0.get("soc_max") if isinstance(soc_max, Sensor): raise ValueError( - f"Cannot convert state-of-charge schedule to '%' unit for sensor {state_of_charge_sensor.id}: " - "soc-max as a sensor reference is not supported for '%' unit conversion. " - "Skipping state-of-charge schedule." + f"Cannot convert state-of-charge schedule to '%' unit for sensor " + f"{state_of_charge_sensor.id}: soc-max as a sensor reference is " + "not supported for '%' unit conversion." ) if not soc_max: raise ValueError( - f"Cannot convert state-of-charge schedule to '%' unit for sensor {state_of_charge_sensor.id}: " - "soc-max is missing or zero. Skipping state-of-charge schedule." + f"Cannot convert state-of-charge schedule to '%' unit for sensor " + f"{state_of_charge_sensor.id}: soc-max is missing or zero." ) - capacity = f"{soc_max} MWh" # all flex model fields are in MWh by now + capacity = f"{soc_max} MWh" + soc_schedule[state_of_charge_sensor] = convert_units( - integrate_time_series( - series=ems_schedule[d], - initial_stock=soc_at_start[d], - stock_delta=device_constraints[d]["stock delta"] - * resolution - / timedelta(hours=1), - up_efficiency=device_constraints[d]["derivative up efficiency"], - down_efficiency=device_constraints[d]["derivative down efficiency"], - storage_efficiency=device_constraints[d]["efficiency"] - .astype(float) - .fillna(1), - ), + soc, from_unit="MWh", to_unit=soc_unit, capacity=capacity, ) + return soc_schedule - def compute(self, skip_validation: bool = False) -> SchedulerOutputType: + def compute( # noqa: C901 + self, skip_validation: bool = False + ) -> SchedulerOutputType: """Schedule a battery or Charge Point based directly on the latest beliefs regarding market prices within the specified time window. For the resulting consumption schedule, consumption is defined as positive values. @@ -1448,18 +1667,23 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: commitments, ) = self._prepare(skip_validation=skip_validation) + initial_stock = [0] * len(soc_at_start) + + for stock_id, devices in self.stock_groups.items(): + d0 = devices[0] + s = soc_at_start[d0] + + value = s * (timedelta(hours=1) / resolution) if s is not None else 0 + + for d in devices: + initial_stock[d] = value + ems_schedule, expected_costs, scheduler_results, model = device_scheduler( device_constraints=device_constraints, ems_constraints=ems_constraints, commitments=commitments, - initial_stock=[ - ( - soc_at_start_d * (timedelta(hours=1) / resolution) - if soc_at_start_d is not None - else 0 - ) - for soc_at_start_d in soc_at_start - ], + initial_stock=initial_stock, + stock_groups=self.stock_groups, ) if "infeasible" in (tc := scheduler_results.solver.termination_condition): raise InfeasibleProblemException(tc) @@ -1492,14 +1716,27 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: if sensor is not None } - flex_model = self.flex_model.copy() + # Use the filtered device_models (stored during _prepare) not self.flex_model + # because stock_groups was rebuilt with device indices, not original indices + flex_model_for_soc = getattr(self, "_device_models", None) + if flex_model_for_soc is None: + # Fallback: reconstruct if not available (shouldn't happen in normal flow) + flex_model_for_soc = ( + self.flex_model.copy() + if isinstance(self.flex_model, dict) + else [fm for fm in self.flex_model if fm.get("sensor") is not None] + ) - if not isinstance(self.flex_model, list): - flex_model["sensor"] = sensors[0] - flex_model = [flex_model] + if not isinstance(flex_model_for_soc, list): + flex_model_for_soc = [flex_model_for_soc] soc_schedule = self._build_soc_schedule( - flex_model, ems_schedule, soc_at_start, device_constraints, resolution + flex_model=flex_model_for_soc, + ems_schedule=ems_schedule, + soc_at_start=soc_at_start, + device_constraints=device_constraints, + stock_groups=self.stock_groups, + resolution=resolution, ) # Resample each device schedule to the resolution of the device's power sensor diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 4c92bdbd42..37f5b156d3 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -16,6 +16,7 @@ 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 +from flexmeasures.data.utils import save_to_db def test_multi_feed_device_scheduler_shared_buffer(): @@ -482,7 +483,7 @@ def test_two_flexible_assets_with_commodity(app, db): # Preference costs should reflect this energy ratio battery_total_pref = costs_data["prefer a full storage 0 sooner"] hp_total_pref = costs_data["prefer a full storage 1 sooner"] - assert battery_total_pref == pytest.approx(2 * hp_total_pref, rel=1e-2), ( + assert battery_total_pref == pytest.approx(2 * hp_total_pref, rel=1e-9), ( f"Battery preference costs ({battery_total_pref:.2e}) should be twice the " f"heat pump ({hp_total_pref:.2e}) preference costs, since battery moves more energy (60 kWh vs 30 kWh)" ) @@ -617,10 +618,10 @@ def test_mixed_gas_and_electricity_assets(app, db): costs_data = commitment_costs[0]["data"] - # Battery: 60kWh Δ (20→80) / 0.95 eff × 100 EUR/MWh + discharge loss ≈ 4.32 EUR + # 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"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"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']}" ) @@ -672,3 +673,414 @@ def test_mixed_gas_and_electricity_assets(app, db): f"Battery preference cost should be positive since it can optimize charging timing, " f"got {battery_total_pref:.2e}" ) + + +def test_two_devices_shared_stock(app, db): + """ + Two feeders charging a single storage. + Consider a single battery with two inverters feeding it, and a single state-of-charge sensor for the battery. + - Both inverters can charge the battery, but with different efficiencies. + - The battery has a single state of charge that both inverters affect. + - The scheduler should recognize the shared stock and optimize accordingly, without duplicating baselines or costs. + """ + # ---- time + start = pd.Timestamp("2024-01-01T00:00:00+01:00") + end = pd.Timestamp("2024-01-02T00:00:00+01:00") + power_sensor_resolution = pd.Timedelta("15m") + soc_sensor_resolution = pd.Timedelta(0) + + # ---- assets + battery_type = get_or_create_model(GenericAssetType, name="battery") + inverter_type = get_or_create_model(GenericAssetType, name="inverter") + + battery = GenericAsset(name="battery", generic_asset_type=battery_type) + inverter_1 = GenericAsset(name="inverter 1", generic_asset_type=inverter_type) + inverter_2 = GenericAsset(name="inverter 2", generic_asset_type=inverter_type) + + db.session.add_all([battery, inverter_1, inverter_2]) + db.session.commit() + + power_1 = Sensor( + name="power", + unit="kW", + event_resolution=power_sensor_resolution, + generic_asset=inverter_1, + ) + power_2 = Sensor( + name="power", + unit="kW", + event_resolution=power_sensor_resolution, + generic_asset=inverter_2, + ) + power_3 = Sensor( + name="power", + unit="kW", + event_resolution=power_sensor_resolution, + generic_asset=battery, + ) + + state_of_charge = Sensor( + name="state-of-charge", + unit="kWh", + event_resolution=soc_sensor_resolution, + generic_asset=battery, + ) + + db.session.add_all([power_1, power_2, power_3, state_of_charge]) + db.session.commit() + + # ---- shared stock (both batteries charge from same pool) + flex_model = [ + { + "sensor": power_1.id, + "state-of-charge": {"sensor": state_of_charge.id}, + "power-capacity": "20 kW", + "charging-efficiency": 0.95, + "discharging-efficiency": 0.95, + }, + { + "sensor": power_2.id, + "state-of-charge": {"sensor": state_of_charge.id}, + "power-capacity": "20 kW", + "charging-efficiency": 0.99, + "discharging-efficiency": 0.45, + }, + { + "state-of-charge": {"sensor": state_of_charge.id}, + "soc-at-start": 20.0, + "soc-min": 10, + "soc-max": 200.0, + "soc-targets": [{"datetime": "2024-01-01T12:00:00+01:00", "value": 189.0}], + }, + ] + + flex_context = { + "consumption-price": "100 EUR/MWh", + "production-price": "100 EUR/MWh", + } + + scheduler = StorageScheduler( + asset_or_sensor=battery, + start=start, + end=end, + resolution=power_sensor_resolution, + belief_time=start, + flex_model=flex_model, + flex_context=flex_context, + return_multiple=True, + ) + + schedules = scheduler.compute(skip_validation=True) + + # ---- verify scheduler returned expected outputs + assert isinstance(schedules, list), ( + "Scheduler should return a list of result objects " + "(device schedules, commitment costs, SOC)." + ) + + assert len(schedules) == 4, ( + "Expected 4 outputs: two inverter schedules, one commitment_costs " + "object, and one state_of_charge schedule." + ) + + # ---- extract schedules + storage_schedules = [s for s in schedules if s["name"] == "storage_schedule"] + commitment_costs = [s for s in schedules if s["name"] == "commitment_costs"] + soc_schedule = next(s for s in schedules if s["name"] == "state_of_charge") + + assert len(storage_schedules) == 2, ( + "There should be two storage schedules corresponding to the two " + "inverters feeding the shared battery." + ) + + assert ( + len(commitment_costs) == 1 + ), "Commitment costs should be aggregated into a single result." + + power1_schedule = next(s for s in storage_schedules if s["sensor"] == power_1) + power2_schedule = next(s for s in storage_schedules if s["sensor"] == power_2) + + power1_data = power1_schedule["data"] + power2_data = power2_schedule["data"] + soc_data = soc_schedule["data"] + costs_data = commitment_costs[0]["data"] + + # ---- charging behaviour + assert (power2_data > 0).any(), ( + "The more efficient inverter should charge the battery at least " + "during some periods, showing that the optimizer prefers it." + ) + + assert (power1_data == 0).sum() > len(power1_data) * 0.5, ( + "The less efficient inverter should remain idle for most of the " + "charging window, confirming that efficiency differences influence " + "device selection." + ) + + # ---- discharge behaviour + # Both inverters have zero power in the middle of the horizon + # Charging happens through inverter 2 (more efficient) as soon as possible (full SoC is preferred) + # Discharging happens through inverter 1 (more efficient) as late as possible (full SoC is preferred) + assert ( + power1_data.iloc[0 : int(96 / 2 + 13)] == 0 + ).all(), "Inverter 1 should be idle at the beginning of the scheduling period." + + assert ( + power2_data.iloc[int(96 / 2 - 13) : -1] == 0 + ).all(), "Inverter 2 should be idle at the end of the scheduling period." + + # Verify that inverter 1 actually discharges + assert (power1_data < 0).any(), "Inverter 1 should discharge the battery." + # Verify that inverter 1 never charges + assert not (power1_data > 0).any(), "Inverter 1 should not charge the battery." + + # Verify that inverter 2 actually charges + assert (power2_data > 0).any(), "Inverter 2 should charge the battery." + # Verify that inverter 1 never charges + assert not (power2_data < 0).any(), "Inverter 2 should not discharge the battery." + + # ---- SOC behaviour + assert soc_data.iloc[0] == pytest.approx( + 20.0 + ), "Initial state of charge must match the provided soc-at-start value." + + assert soc_data.max() == pytest.approx(189.0, rel=1e-3), ( + "SOC should rise to exactly 189.0 kWh (the target value), " + "confirming that both inverters contribute to the same shared stock." + ) + + assert soc_data.iloc[-1] == pytest.approx( + 10.0, rel=1e-3 + ), "SOC should decrease to soc-min (10.0) after the target is reached." + + assert ( + soc_data.max() > soc_data.iloc[0] + ), "SOC must increase during the charging phase." + + # ---- energy cost checks + assert costs_data["electricity energy 0"] == pytest.approx(-17.0, rel=1e-2), ( + "Electricity energy 0 corresponds to inverter 1 energy cost. " + "Negative value indicates net production/discharge value: " + "inverter 1 discharges ~340 kWh at 0.95 efficiency = -17 EUR." + ) + + assert costs_data["electricity energy 1"] == pytest.approx(17.07, rel=1e-2), ( + "Electricity energy 1 corresponds to inverter 2 charging cost, " + "which should dominate since it performs most charging: " + "~682.8 kWh at 0.99 efficiency * 100 EUR/MWh ≈ 17.07 EUR." + ) + + +def test_simulation_copy_new(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") + site_type = get_or_create_model(GenericAssetType, name="site") + + site = GenericAsset( + name="Test Site", + generic_asset_type=site_type, + ) + building = GenericAsset( + name="Building", generic_asset_type=site_type, parent_asset_id=site.id + ) + + gas_boiler = GenericAsset( + name="Gas Boiler", generic_asset_type=gas_boiler_type, parent_asset_id=site.id + ) + heat_buffer = GenericAsset( + name="Heat Buffer", generic_asset_type=buffer_type, parent_asset_id=site.id + ) + electric_heater = GenericAsset( + name="Electric Heater", + generic_asset_type=get_or_create_model( + GenericAssetType, name="electric-heater" + ), + parent_asset_id=site.id, + ) + + 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) + + building_raw_power = Sensor( + name="building raw power", + unit="kW", + event_resolution=power_resolution, + generic_asset=building, + ) + + boiler_power = Sensor( + name="boiler power", + unit="kW", + event_resolution=power_resolution, + generic_asset=gas_boiler, + ) + + tank_power = Sensor( + name="heat buffer power", + unit="kW", + event_resolution=power_resolution, + generic_asset=heat_buffer, + ) + + buffer_soc = Sensor( + name="buffer state of charge", + unit="kWh", + event_resolution=energy_resolution, # instantaneous + generic_asset=heat_buffer, + ) + + buffer_soc_usage = Sensor( + name="buffer soc usage", + unit="kW", + event_resolution=power_resolution, + generic_asset=heat_buffer, + ) + + heater_power = Sensor( + name="heater power", + unit="kW", + event_resolution=power_resolution, + generic_asset=electric_heater, + ) + soc_targets = Sensor( + name="buffer soc targets", + unit="kWh", + event_resolution=energy_resolution, # instantaneous + generic_asset=heat_buffer, + ) + + db.session.add_all( + [ + boiler_power, + buffer_soc, + tank_power, + buffer_soc_usage, + building_raw_power, + heater_power, + soc_targets, + ] + ) + db.session.commit() + 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"), + name="event_value", + ).reset_index() + + soc_usage = building_data.copy() + + bdf = tb.BeliefsDataFrame( + building_data, + belief_horizon=-pd.Timedelta(seconds=1) * np.array(range(len(building_data))), + sensor=building_raw_power, + 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 + bdf = tb.BeliefsDataFrame( + soc_usage, + belief_time=belief_time, + sensor=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}, + "power-capacity": "100 kW", + "charging-efficiency": 0.9, + "commodity": "electricity", + "production-capacity": "0 kW", + # "storage-efficiency": 0.9, # todo: workaround does not work yet + }, + { + "sensor": boiler_power.id, + "state-of-charge": {"sensor": buffer_soc.id}, + "power-capacity": "100 kW", + "charging-efficiency": 0.9, + "commodity": "gas", + "production-capacity": "0 kW", + # "storage-efficiency": 0.9, # todo: workaround does not work yet + }, + { + # "sensor": tank_power.id, + "soc-min": 200.0, + "soc-max": 1000.0, + "soc-at-start": 200.0, + # "soc-targets": [ + # {"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}], + "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", + "relax-constraints": True, + "inflexible-device-sensors": [building_raw_power.id], + } + + scheduler = StorageScheduler( + asset_or_sensor=site, + start=start, + end=end, + resolution=power_resolution, + belief_time=belief_time, + flex_model=flex_model, + flex_context=flex_context, + return_multiple=True, + ) + + pd.set_option("display.max_rows", None) + schedules = scheduler.compute(skip_validation=True) + + # ---- verify outputs + print(schedules) diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 5463d116e8..7f1c747a3c 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -904,7 +904,17 @@ def ensure_sensor_or_asset(self, data, **kwargs): and data["sensor"].asset != data["asset"] ): raise ValidationError("Sensor does not belong to asset.") - if "sensor" not in data and "asset" not in data: + # if ( + # "state-of-charge" in data["sensor_flex_model"] + # and "asset" in data + # and data["sensor_flex_model"]["state-of-charge"].asset != data["asset"] + # ): + # raise ValidationError("Sensor does not belong to asset.") + if ( + "sensor" not in data + and "state-of-charge" not in data["sensor_flex_model"] + and "asset" not in data + ): raise ValidationError("Specify either a sensor or an asset.") @pre_load diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 2775832f68..9378955da8 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -226,7 +226,6 @@ class StorageFlexModelSchema(Schema): metadata=metadata.SOC_USAGE.to_dict(), ) commodity = fields.Str( - required=False, data_key="commodity", load_default="electricity", validate=OneOf(["electricity", "gas"]), diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 256ee31e64..54efa403e4 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "0.32.0" + "version": "0.31.0" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.",