diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py index ed1ddd7240..907a295946 100755 --- a/flexmeasures/cli/data_add.py +++ b/flexmeasures/cli/data_add.py @@ -1216,9 +1216,16 @@ def create_schedule(ctx): "--soc-at-start", "soc_at_start", type=QuantityField("%", validate=validate.Range(min=0, max=1)), - required=True, + required=False, help="State of charge (e.g 32.8%, or 0.328) at the start of the schedule.", ) +@click.option( + "--soc", + "soc", + type=VariableQuantityField("MWh"), + required=False, + help="State of charge (e.g sensor:, or 0.328) at the start of the schedule.", +) @click.option( "--soc-target", "soc_target_strings", @@ -1353,6 +1360,7 @@ def add_schedule_for_storage( # noqa C901 start: datetime, duration: timedelta, soc_at_start: ur.Quantity, + soc: ur.Quantity | Sensor | None, charging_efficiency: ur.Quantity | Sensor | None, discharging_efficiency: ur.Quantity | Sensor | None, soc_gain: ur.Quantity | Sensor | None, @@ -1402,7 +1410,8 @@ def add_schedule_for_storage( # noqa C901 ) raise click.Abort() capacity_str = f"{power_sensor.get_attribute('max_soc_in_mwh')} MWh" - soc_at_start = convert_units(soc_at_start.magnitude, soc_at_start.units, "MWh", capacity=capacity_str) # type: ignore + if soc_at_start is not None: + soc_at_start = convert_units(soc_at_start.magnitude, soc_at_start.units, "MWh", capacity=capacity_str) # type: ignore soc_targets = [] for soc_target_tuple in soc_target_strings: soc_target_value_str, soc_target_datetime_str = soc_target_tuple @@ -1429,6 +1438,7 @@ def add_schedule_for_storage( # noqa C901 belief_time=server_now(), resolution=power_sensor.event_resolution, flex_model={ + "soc": soc, "soc-at-start": soc_at_start, "soc-targets": soc_targets, "soc-min": soc_min, diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index c7795ded2d..9b036357ff 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -831,6 +831,18 @@ def create_test_battery_assets( ), ) db.session.add(test_battery_sensor_kw) + test_battery_sensor_kwh = Sensor( + name="state of charge (Wh)", + generic_asset=test_battery, + event_resolution=timedelta(hours=0), + unit="Wh", + attributes=dict( + daily_seasonality=True, + weekly_seasonality=True, + yearly_seasonality=True, + ), + ) + db.session.add(test_battery_sensor_kwh) test_battery_no_prices = GenericAsset( name="Test battery with no known prices", diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 7bfe547b72..1a06db0e12 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -21,6 +21,7 @@ get_power_values, fallback_charging_policy, get_continuous_series_sensor_or_quantity, + # get_soc_sensor_value, ) from flexmeasures.data.models.planning.exceptions import InfeasibleProblemException from flexmeasures.data.schemas.scheduling.storage import StorageFlexModelSchema @@ -28,6 +29,7 @@ from flexmeasures.utils.time_utils import get_max_planning_horizon from flexmeasures.utils.coding_utils import deprecated from flexmeasures.utils.unit_utils import ur, convert_units +from flexmeasures.utils.calculations import integrate_time_series class MetaStorageScheduler(Scheduler): @@ -79,6 +81,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 sensor = self.sensor soc_at_start = self.flex_model.get("soc_at_start") + soc = self.flex_model.get("soc") soc_targets = self.flex_model.get("soc_targets") soc_min = self.flex_model.get("soc_min") soc_max = self.flex_model.get("soc_max") @@ -101,6 +104,12 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 self.flex_context.get("inflexible_device_sensors") or self.sensor.generic_asset.get_inflexible_device_sensors() ) + soc_sensor = None + if isinstance(soc, Sensor): + soc_sensor = soc + # soc_at_start = get_soc_sensor_value(soc, start) + # elif (isinstance(soc, float) or isinstance(soc, int)) and soc > 0: + # soc_at_start = soc # Check for required Sensor attributes power_capacity_in_mw = self.flex_model.get( @@ -438,6 +447,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 end, resolution, soc_at_start, + soc_sensor, device_constraints, ems_constraints, commitment_quantities, @@ -471,6 +481,16 @@ def deserialize_flex_config(self): # Otherwise, we try to retrieve the current state of charge from the asset (if that is the valid one at the start). # If that doesn't work, we set the starting soc to 0 (some assets don't use the concept of a state of charge, # and without soc targets and limits the starting soc doesn't matter). + + # if "soc" in self.flex_model: + # if ( + # self.flex_model["soc"] is not None + # and self.flex_model["soc-at-start"] is not None + # ): + # raise Exception( + # "Both 'soc-at-start' and 'soc' parameters are provided, however, only one of them is necessary." + # ) + if ( "soc-at-start" not in self.flex_model or self.flex_model["soc-at-start"] is None @@ -485,6 +505,9 @@ def deserialize_flex_config(self): else: self.flex_model["soc-at-start"] = 0 + if self.flex_model.get("soc") is None: + self.flex_model["soc"] = "0 MWh" + self.ensure_soc_min_max() # Now it's time to check if our flex configuration holds up to schemas @@ -600,6 +623,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: end, resolution, soc_at_start, + soc_sensor, device_constraints, ems_constraints, commitment_quantities, @@ -612,19 +636,41 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: sensor, device_constraints[0], start, end, resolution ) storage_schedule = convert_units(storage_schedule, "MW", sensor.unit) + if soc_sensor is not None: + soc_schedule = integrate_time_series( + storage_schedule, + soc_at_start, + down_efficiency=device_constraints[0]["derivative down efficiency"], + up_efficiency=device_constraints[0]["derivative up efficiency"], + storage_efficiency=device_constraints[0]["efficiency"], + ) + soc_schedule = convert_units( + soc_schedule, f"{sensor.unit}h", soc_sensor.unit + ) # Round schedule if self.round_to_decimals: storage_schedule = storage_schedule.round(self.round_to_decimals) + if soc_sensor is not None: + soc_schedule = soc_schedule.round(self.round_to_decimals) if self.return_multiple: - return [ + data_list = [ { "name": "storage_schedule", "sensor": sensor, "data": storage_schedule, } ] + if soc_sensor is not None: + data_list.append( + { + "name": "soc_schedule", + "sensor": soc_sensor, + "data": soc_schedule, + } + ) + return data_list else: return storage_schedule @@ -649,6 +695,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: end, resolution, soc_at_start, + soc_sensor, device_constraints, ems_constraints, commitment_quantities, @@ -670,19 +717,41 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: # Obtain the storage schedule from all device schedules within the EMS storage_schedule = ems_schedule[0] storage_schedule = convert_units(storage_schedule, "MW", sensor.unit) + if soc_sensor is not None: + soc_schedule = integrate_time_series( + storage_schedule, + soc_at_start, + down_efficiency=device_constraints[0]["derivative down efficiency"], + up_efficiency=device_constraints[0]["derivative up efficiency"], + storage_efficiency=device_constraints[0]["efficiency"], + ) + soc_schedule = convert_units( + soc_schedule, f"{sensor.unit}h", soc_sensor.unit + ) # Round schedule if self.round_to_decimals: storage_schedule = storage_schedule.round(self.round_to_decimals) + if soc_sensor is not None: + soc_schedule = soc_schedule.round(self.round_to_decimals) if self.return_multiple: - return [ + data_list = [ { "name": "storage_schedule", "sensor": sensor, "data": storage_schedule, } ] + if soc_sensor is not None: + data_list.append( + { + "name": "soc_schedule", + "sensor": soc_sensor, + "data": soc_schedule, + } + ) + return data_list else: return storage_schedule diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 115f317b15..318a280489 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +from dataclasses import dataclass from datetime import datetime, timedelta import pytest import pytz @@ -71,9 +74,9 @@ def test_battery_solver_day_1( battery_name, db, ): - epex_da, battery = get_sensors_from_db( + battery = get_sensors_from_db( db, add_battery_assets, battery_name=battery_name - ) + ).power tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -123,7 +126,7 @@ def test_battery_solver_day_2( and so we expect the scheduler to only: - completely discharge within the last 8 hours """ - _epex_da, battery = get_sensors_from_db(db, add_battery_assets) + battery = get_sensors_from_db(db, add_battery_assets).power tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 2)) end = tz.localize(datetime(2015, 1, 3)) @@ -223,6 +226,7 @@ def run_test_charge_discharge_sign( end, resolution, soc_at_start, + soc_sensor, device_constraints, ems_constraints, commitment_quantities, @@ -665,7 +669,7 @@ def test_soc_bounds_timeseries(db, add_battery_assets): """ # get the sensors from the database - epex_da, battery = get_sensors_from_db(db, add_battery_assets) + battery = get_sensors_from_db(db, add_battery_assets).power # time parameters tz = pytz.timezone("Europe/Amsterdam") @@ -974,7 +978,7 @@ def test_infeasible_problem_error(db, add_battery_assets): """Try to create a schedule with infeasible constraints. soc-max is 4.5 and soc-target is 8.0""" # get the sensors from the database - _epex_da, battery = get_sensors_from_db(db, add_battery_assets) + battery = get_sensors_from_db(db, add_battery_assets).power # time parameters tz = pytz.timezone("Europe/Amsterdam") @@ -1019,17 +1023,34 @@ def compute_schedule(flex_model): compute_schedule(flex_model) +@dataclass +class BatterySensors: + price: Sensor + power: Sensor + soc: Sensor | None + + def get_sensors_from_db( - db, battery_assets, battery_name="Test battery", power_sensor_name="power" -): + db, + battery_assets, + battery_name="Test battery", + power_sensor_name="power", + soc_sensor_name="state of charge (Wh)", +) -> BatterySensors: # get the sensors from the database epex_da = get_test_sensor(db) - battery = [ + battery_power = [ s for s in battery_assets[battery_name].sensors if s.name == power_sensor_name ][0] - assert battery.get_attribute("market_id") == epex_da.id + if any([s.name == soc_sensor_name for s in battery_assets[battery_name].sensors]): + battery_soc = [ + s for s in battery_assets[battery_name].sensors if s.name == soc_sensor_name + ][0] + else: + battery_soc = None + assert battery_power.get_attribute("market_id") == epex_da.id - return epex_da, battery + return BatterySensors(price=epex_da, power=battery_power, soc=battery_soc) def test_numerical_errors(app_with_each_solver, setup_planning_test_data, db): @@ -1081,6 +1102,7 @@ def test_numerical_errors(app_with_each_solver, setup_planning_test_data, db): end, resolution, soc_at_start, + soc_sensor, device_constraints, ems_constraints, commitment_quantities, @@ -1263,6 +1285,7 @@ def set_if_not_none(dictionary, key, value): end, resolution, soc_at_start, + soc_sensor, device_constraints, ems_constraints, commitment_quantities, @@ -1501,9 +1524,9 @@ def test_battery_power_capacity_as_sensor( expected_site_production, expected_site_consumption, ): - epex_da, battery = get_sensors_from_db( + battery = get_sensors_from_db( db, add_battery_assets, battery_name=battery_name - ) + ).power tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 2)) @@ -1553,8 +1576,8 @@ def test_battery_power_capacity_as_sensor( ) data_to_solver = scheduler._prepare() - device_constraints = data_to_solver[5][0] - ems_constraints = data_to_solver[6] + device_constraints = data_to_solver[6][0] + ems_constraints = data_to_solver[7] assert all(device_constraints["derivative min"].values == expected_production) assert all(device_constraints["derivative max"].values == expected_consumption) @@ -1566,9 +1589,9 @@ def test_battery_bothways_power_capacity_as_sensor( db, add_battery_assets, add_inflexible_device_forecasts, capacity_sensors ): """Check that the charging and discharging power capacities are limited by the power capacity.""" - epex_da, battery = get_sensors_from_db( + battery = get_sensors_from_db( db, add_battery_assets, battery_name="Test battery" - ) + ).power tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 2)) @@ -1593,7 +1616,7 @@ def test_battery_bothways_power_capacity_as_sensor( ) data_to_solver = scheduler._prepare() - device_constraints = data_to_solver[5][0] + device_constraints = data_to_solver[6][0] max_capacity = ( capacity_sensors["power_capacity"] @@ -1608,7 +1631,7 @@ def test_battery_bothways_power_capacity_as_sensor( def get_efficiency_problem_device_constraints( extra_flex_model, efficiency_sensors, add_battery_assets, db ) -> pd.DataFrame: - _, battery = get_sensors_from_db(db, add_battery_assets) + battery = get_sensors_from_db(db, add_battery_assets).power tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -1623,7 +1646,7 @@ def get_efficiency_problem_device_constraints( ) scheduler_data = scheduler._prepare() - return scheduler_data[5][0] + return scheduler_data[6][0] def test_dis_charging_efficiency_as_sensor( @@ -1688,7 +1711,7 @@ def test_battery_stock_delta_sensor( With these settings, the battery needs to charge at a power or greater than the usage forecast to keep the SOC within bounds ([0, 2 MWh]). """ - _, battery = get_sensors_from_db(db, add_battery_assets) + battery = get_sensors_from_db(db, add_battery_assets).power tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -1739,7 +1762,7 @@ def test_battery_stock_delta_quantity( We expect a constant gain/usage to happen in every time period equal to the energy value provided. """ - _, battery = get_sensors_from_db(db, add_battery_assets) + battery = get_sensors_from_db(db, add_battery_assets).power tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -1762,9 +1785,9 @@ def test_battery_stock_delta_quantity( scheduler_info = scheduler._prepare() if expected_delta is not None: - assert all(scheduler_info[5][0]["stock delta"] == expected_delta) + assert all(scheduler_info[6][0]["stock delta"] == expected_delta) else: - assert all(scheduler_info[5][0]["stock delta"].isna()) + assert all(scheduler_info[6][0]["stock delta"].isna()) @pytest.mark.parametrize( @@ -1797,7 +1820,7 @@ def test_battery_efficiency_quantity( case where the efficiency is not defined. """ - _, battery = get_sensors_from_db(db, add_battery_assets) + battery = get_sensors_from_db(db, add_battery_assets).power tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -1817,9 +1840,9 @@ def test_battery_efficiency_quantity( scheduler_info = scheduler._prepare() if efficiency is not None: - assert all(scheduler_info[5][0]["efficiency"] == expected_efficiency) + assert all(scheduler_info[6][0]["efficiency"] == expected_efficiency) else: - assert all(scheduler_info[5][0]["efficiency"].isna()) + assert all(scheduler_info[6][0]["efficiency"].isna()) @pytest.mark.parametrize( @@ -1853,7 +1876,7 @@ def test_battery_storage_efficiency_sensor( It checks if the scheduler correctly handles regular values, values exceeding 100%, negative values, and values with different resolutions compared to the scheduling resolution. """ - _, battery = get_sensors_from_db(db, add_battery_assets) + battery = get_sensors_from_db(db, add_battery_assets).power tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -1876,7 +1899,7 @@ def test_battery_storage_efficiency_sensor( ) scheduler_info = scheduler._prepare() - assert all(scheduler_info[5][0]["efficiency"] == expected_efficiency) + assert all(scheduler_info[6][0]["efficiency"] == expected_efficiency) @pytest.mark.parametrize( @@ -1933,7 +1956,7 @@ def test_add_storage_constraint_from_sensor( """ Test the handling of different values for the target SOC constraints as sensors in the StorageScheduler. """ - _, battery = get_sensors_from_db(db, add_battery_assets) + battery = get_sensors_from_db(db, add_battery_assets).power tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -1955,7 +1978,7 @@ def test_add_storage_constraint_from_sensor( ) scheduler_info = scheduler._prepare() - storage_constraints = scheduler_info[5][0] + storage_constraints = scheduler_info[6][0] expected_target_start = pd.Timedelta(expected_start) + start expected_target_end = pd.Timedelta(expected_end) + start @@ -2075,12 +2098,12 @@ def test_battery_storage_different_units( soc_max = soc_max.to(soc_unit).magnitude soc_at_start = soc_at_start.to(soc_unit).magnitude - epex_da, battery = get_sensors_from_db( + battery = get_sensors_from_db( db, add_battery_assets, battery_name="Test battery", power_sensor_name=power_sensor_name, - ) + ).power tz = pytz.timezone("Europe/Amsterdam") # transition from cheap to expensive (90 -> 100) @@ -2189,12 +2212,14 @@ def test_battery_storage_with_time_series_in_flex_model( soc_max = "1 MWh" soc_at_start = "100 kWh" - epex_da, battery = get_sensors_from_db( + battery_sensors = get_sensors_from_db( db, add_battery_assets, battery_name="Test battery", power_sensor_name="power", ) + battery_power_sensor = battery_sensors.power + battery_soc_sensor = battery_sensors.soc tz = pytz.timezone("Europe/Amsterdam") # transition from cheap to expensive (90 -> 100) @@ -2206,6 +2231,7 @@ def test_battery_storage_with_time_series_in_flex_model( "soc-min": soc_min, "soc-max": soc_max, "soc-at-start": soc_at_start, + "soc": {"sensor": battery_soc_sensor.id}, "roundtrip-efficiency": 1, "storage-efficiency": 1, "power-capacity": "1 MW", @@ -2213,7 +2239,7 @@ def test_battery_storage_with_time_series_in_flex_model( flex_model[ts_field] = ts_specs scheduler: Scheduler = StorageScheduler( - battery, + battery_power_sensor, start, end, resolution, @@ -2221,12 +2247,15 @@ def test_battery_storage_with_time_series_in_flex_model( flex_context={ "site-power-capacity": "1 MW", }, + return_multiple=True, ) - schedule = scheduler.compute() + scheduler_results = scheduler.compute() + schedule = scheduler_results[0]["data"] + soc_schedule = scheduler_results[1]["data"] # Check if constraints were met soc_at_start = ur.Quantity(soc_at_start).to("MWh").magnitude - check_constraints(battery, schedule, soc_at_start) + check_constraints(battery_power_sensor, schedule, soc_at_start) # charge 850 kWh in the cheap price period (100 kWh -> 950kWh) assert schedule[:4].sum() * 0.25 == pytest.approx(0.85) @@ -2236,3 +2265,9 @@ def test_battery_storage_with_time_series_in_flex_model( assert schedule[4:].sum() * 0.25 == pytest.approx(-0.75) else: assert schedule[4:].sum() * 0.25 == pytest.approx(-0.85) + + # Check final state in soc_schedule (given in the unit of the SoC sensor, which is Wh) + if ts_field == "soc-minima": + assert soc_schedule[-1] == 200 * 10**3 + else: + assert soc_schedule[-1] == 100 * 10**3 diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index 5cb8758e69..eca575c412 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -8,6 +8,7 @@ from pandas.tseries.frequencies import to_offset import numpy as np import timely_beliefs as tb +from sqlalchemy import select from flexmeasures.data import db from flexmeasures.data.models.time_series import Sensor, TimedBelief @@ -451,3 +452,14 @@ def nanmin_of_series_and_value(s: pd.Series, value: float | pd.Series) -> pd.Ser # [right]: datetime64[ns, UTC] value = value.tz_convert("UTC") return s.fillna(value).clip(upper=value) + + +def get_soc_sensor_value(sensor: Sensor, start: datetime): + soc_value = db.session.execute( + select(TimedBelief).filter_by(sensor_id=sensor.id, event_start=start) + ).scalar_one_or_none() + if soc_value is not None: + soc_value = soc_value.event_value + else: + soc_value = 0 + return soc_value diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 4b3a5d6128..77e571267a 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -61,6 +61,7 @@ class StorageFlexModelSchema(Schema): return_magnitude=True, data_key="soc-at-start", ) + soc = VariableQuantityField("MWh", data_key="soc", required=False) soc_min = QuantityField( validate=validate.Range(