diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index f9c813584..5f7908998 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -52,6 +52,7 @@ def device_scheduler( # noqa C901 derivative equals: exact amount of flow (we do this by clamping derivative min and derivative max) derivative down efficiency: conversion efficiency of flow out of a device (flow out : stock decrease) derivative up efficiency: conversion efficiency of flow into a device (stock increase : flow in) + usage forecast: predefined stock delta to apply to the storage device. Positive quantity cause an increase and negative decrease (stock increase : flow in) EMS constraints are on an EMS level. Handled constraints (listed by column name): derivative max: maximum flow derivative min: minimum flow @@ -225,6 +226,15 @@ def device_derivative_up_efficiency(m, d, j): return 1 return eff + def device_usage_forecast(m, d, j): + try: + usage_forecast = device_constraints[d]["usage forecast"].iloc[j] + except KeyError: + return 0 + if np.isnan(usage_forecast): + return 0 + return usage_forecast + model.up_price = Param(model.c, model.j, initialize=price_up_select) model.down_price = Param(model.c, model.j, initialize=price_down_select) model.commitment_quantity = Param( @@ -247,6 +257,7 @@ def device_derivative_up_efficiency(m, d, j): model.device_derivative_up_efficiency = Param( model.d, model.j, initialize=device_derivative_up_efficiency ) + model.usage_forecast = Param(model.d, model.j, initialize=device_usage_forecast) # Add variables model.ems_power = Var(model.d, model.j, domain=Reals, initialize=0) @@ -270,6 +281,7 @@ def device_bounds(m, d, j): ( 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.usage_forecast[d, k] ) for k in range(0, j + 1) ] diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 522a41d1d..449e91667 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -64,6 +64,7 @@ class MetaStorageScheduler(Scheduler): "derivative min", "derivative down efficiency", "derivative up efficiency", + "usage forecast", ] def compute_schedule(self) -> pd.Series | None: @@ -235,6 +236,26 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 method="upper", ) + usage_forecast = self.flex_model.get("usage_forecast", []) + + all_usage_forecast = [] + + for component in usage_forecast: + all_usage_forecast.append( + get_continous_series_sensor_or_quantity( + quantity_or_sensor=component, + actuator=sensor, + target_unit="MWh", + query_window=(start, end), + resolution=resolution, + beliefs_before=belief_time, + ) + ) + if len(all_usage_forecast) > 0: + all_usage_forecast = pd.concat(all_usage_forecast, axis=1) + + device_constraints[0]["usage forecast"] = all_usage_forecast.sum(1) + # Apply round-trip efficiency evenly to charging and discharging device_constraints[0]["derivative down efficiency"] = ( roundtrip_efficiency**0.5 diff --git a/flexmeasures/data/models/planning/tests/conftest.py b/flexmeasures/data/models/planning/tests/conftest.py index 0f9d50fc0..48a86a920 100644 --- a/flexmeasures/data/models/planning/tests/conftest.py +++ b/flexmeasures/data/models/planning/tests/conftest.py @@ -219,6 +219,39 @@ def process(db, building, setup_sources) -> dict[str, Sensor]: return _process +@pytest.fixture(scope="module") +def add_usage_forecast(db, add_battery_assets, setup_sources) -> Sensor: + """ + Set up a constant usage forecast + """ + + battery = add_battery_assets["Test battery"] + + # 1 days of test data + time_slots = initialize_index( + start=pd.Timestamp("2015-01-01").tz_localize("Europe/Amsterdam"), + end=pd.Timestamp("2015-01-02").tz_localize("Europe/Amsterdam"), + resolution="15T", + ) + + usage_forecast_sensor = Sensor( + name="usage forecast", + unit="MWh", + event_resolution=timedelta(minutes=15), + generic_asset=battery, + ) + db.session.add(usage_forecast_sensor) + db.session.flush() + + usage_forecast = [-battery.get_attribute("capacity_in_mw")] * len(time_slots) + + add_as_beliefs( + db, usage_forecast_sensor, usage_forecast, time_slots, setup_sources["Seita"] + ) + + return usage_forecast_sensor + + def add_as_beliefs(db, sensor, values, time_slots, source): beliefs = [ TimedBelief( diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 48650b067..f3e60d0f6 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1375,3 +1375,61 @@ def test_battery_power_capacity_as_sensor( assert all(device_constraints["derivative min"].values == expected_production) assert all(device_constraints["derivative max"].values == expected_consumption) + + +def test_battery_usage_forecast_sensor(add_battery_assets, add_usage_forecast): + _, battery = get_sensors_from_db(add_battery_assets) + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 1)) + end = tz.localize(datetime(2015, 1, 2)) + resolution = timedelta(minutes=15) + + scheduler: Scheduler = StorageScheduler( + battery, + start, + end, + resolution, + flex_model={ + "soc-max": 2, + "soc-min": 0, + "usage-forecast": [{"sensor": add_usage_forecast.id}], + "roundtrip-efficiency": 1, + "storage-efficiency": 1, + }, + ) + schedule = scheduler.compute() + assert all(schedule == 2) + + +@pytest.mark.parametrize( + "usage_forecast,expected_usage_forecast", + [(["1 MWh", "-1MWh"], 0), (["1 MWh", "1MWh"], 2), (["100 kWh"], 0.1), ([], None)], +) +def test_battery_usage_forecast_quantity( + add_battery_assets, usage_forecast, expected_usage_forecast +): + _, battery = get_sensors_from_db(add_battery_assets) + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 1)) + end = tz.localize(datetime(2015, 1, 2)) + resolution = timedelta(minutes=15) + + scheduler: Scheduler = StorageScheduler( + battery, + start, + end, + resolution, + flex_model={ + "soc-max": 2, + "soc-min": 0, + "usage-forecast": usage_forecast, + "roundtrip-efficiency": 1, + "storage-efficiency": 1, + }, + ) + scheduler_info = scheduler._prepare() + + if expected_usage_forecast is not None: + assert all(scheduler_info[5][0]["usage forecast"] == expected_usage_forecast) + else: + assert all(scheduler_info[5][0]["usage forecast"].isna()) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 3ca42f92d..870e5e4f8 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -110,6 +110,10 @@ class StorageFlexModelSchema(Schema): storage_efficiency = EfficiencyField(data_key="storage-efficiency") prefer_charging_sooner = fields.Bool(data_key="prefer-charging-sooner") + usage_forecast = fields.List( + QuantityOrSensor("MWh"), data_key="usage-forecast", required=False + ) + def __init__(self, start: datetime, sensor: Sensor, *args, **kwargs): """Pass the schedule's start, so we can use it to validate soc-target datetimes.""" self.start = start