diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 823b82b56..6600ad9ec 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -11,6 +11,7 @@ Bugfixes ----------- * Prevent **p**lay/**p**ause/**s**top of replays when editing a text field in the UI [see `PR #1024 `_] +* Skip unit conversion of :abbr:`SoC (state of charge)` related fields that are defined as sensors in a ``flex-model`` (specifically, ``soc-maxima``, ``soc-minima`` and ``soc-targets`` [see `PR #1047 `_] v0.20.0 | March 26, 2024 @@ -128,7 +129,7 @@ New features * Better navigation experience through listings (sensors / assets / users / accounts) in the :abbr:`UI (user interface)`, by heading to the selected entity upon a click (or CTRL + click) anywhere within a row [see `PR #923 `_] * Introduce a breadcrumb to navigate through assets and sensor pages using its child-parent relationship [see `PR #930 `_] * Define device-level power constraints as sensors to create schedules with changing power limits [see `PR #897 `_] -* Allow to provide external storage usage or gain components using the ``soc-usage`` and ``soc-gain`` fields of the `flex-model` [see `PR #906 `_] +* Allow to provide external storage usage or gain components using the ``soc-usage`` and ``soc-gain`` fields of the ``flex-model`` [see `PR #906 `_] * Define time-varying charging and discharging efficiencies as sensors or as constant values which allows to define the :abbr:`COP (coefficient of performance)` [see `PR #933 `_] Infrastructure / Support diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index 064c40b47..bbae464a9 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -1185,11 +1185,77 @@ def capacity_sensors(db, add_battery_assets, setup_sources): ) +@pytest.fixture(scope="module") +def soc_sensors(db, add_battery_assets, setup_sources) -> tuple: + """Add battery sensors for instantaneous soc-maxima (in kWh), soc-maxima (in MWh) and soc-targets (in MWh). + + The SoC values on each sensor linearly increase from 0 to 5 MWh. + """ + battery = add_battery_assets["Test battery with dynamic power capacity"] + + soc_maxima = Sensor( + name="soc_maxima", + generic_asset=battery, + unit="kWh", + event_resolution=timedelta(0), + ) + + soc_minima = Sensor( + name="soc_minima", + generic_asset=battery, + unit="MWh", + event_resolution=timedelta(0), + ) + + soc_targets = Sensor( + name="soc_targets", + generic_asset=battery, + unit="MWh", + event_resolution=timedelta(0), + ) + + db.session.add_all([soc_maxima, soc_minima, soc_targets]) + db.session.flush() + + time_slots = pd.date_range( + datetime(2015, 1, 1, 2), datetime(2015, 1, 2), freq="15T" + ).tz_localize("Europe/Amsterdam") + + values = np.arange(len(time_slots)) / (len(time_slots) - 1) + values = values * 5 + + add_beliefs( + db=db, + sensor=soc_maxima, + time_slots=time_slots, + values=values * 1000, # MWh -> kWh + source=setup_sources["Seita"], + ) + + add_beliefs( + db=db, + sensor=soc_minima, + time_slots=time_slots, + values=values, + source=setup_sources["Seita"], + ) + + add_beliefs( + db=db, + sensor=soc_targets, + time_slots=time_slots, + values=values, + source=setup_sources["Seita"], + ) + + yield soc_maxima, soc_minima, soc_targets, values + + def add_beliefs( db, sensor: Sensor, time_slots: pd.DatetimeIndex, - values: list[int | float], + values: list[int | float] | np.ndarray, source: DataSource, ): beliefs = [ diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index edc3fa561..e436e16a4 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -1968,3 +1968,73 @@ def test_add_storage_constraint_from_sensor( equals[expected_target_start + resolution : expected_target_end] == expected_soc_target_value ) + + +def test_soc_maxima_minima_targets(db, add_battery_assets, soc_sensors): + """ + Check that the SOC maxima, minima and targets can be defined as sensors in the StorageScheduler. + + The SOC is forced to follow a certain trajectory both by means of the SOC target and by setting SOC maxima = SOC minima = SOC targets. + + Moreover, the SOC maxima constraints are defined in MWh to check that the unit conversion works well. + """ + power = add_battery_assets["Test battery with dynamic power capacity"].sensors[0] + epex_da = get_test_sensor(db) + + soc_maxima, soc_minima, soc_targets, values = soc_sensors + + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 1)) + end = tz.localize(datetime(2015, 1, 2)) + resolution = timedelta(minutes=15) + soc_at_start = 0.0 + soc_max = 10 + soc_min = 0 + + flex_model = { + "soc-at-start": soc_at_start, + "soc-max": soc_max, + "soc-min": soc_min, + "power-capacity": "2 MW", + "production-capacity": "2 MW", + "consumption-capacity": "2 MW", + "storage-efficiency": 1, + "charging-efficiency": "100%", + "discharging-efficiency": "100%", + } + + def compute_schedule(flex_model): + scheduler: Scheduler = StorageScheduler( + power, + start, + end, + resolution, + flex_model=flex_model, + flex_context={ + "site-power-capacity": "100 MW", + "production-price-sensor": epex_da.id, + "consumption-price-sensor": epex_da.id, + }, + ) + return scheduler.compute() + + flex_model["soc-targets"] = {"sensor": soc_targets.id} + schedule = compute_schedule(flex_model) + + soc = check_constraints(power, schedule, soc_at_start) + + # soc targets are achieved + assert all(abs(soc[9:].values - values[:-1]) < 1e-5) + + # remove soc-targets and use soc-maxima and soc-minima + del flex_model["soc-targets"] + flex_model["soc-minima"] = {"sensor": soc_minima.id} + flex_model["soc-maxima"] = {"sensor": soc_maxima.id} + schedule = compute_schedule(flex_model) + + soc = check_constraints(power, schedule, soc_at_start) + + # soc-maxima and soc-minima constraints are respected + # this yields the same results as with the SOC targets + # because soc-maxima = soc-minima = soc-targets + assert all(abs(soc[9:].values - values[:-1]) < 1e-5) diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 93fda4c26..58d169c1a 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -194,13 +194,22 @@ def post_load_sequence(self, data: dict, **kwargs) -> dict: data["soc_min"] /= 1000.0 if data.get("soc_max") is not None: data["soc_max"] /= 1000.0 - if data.get("soc_targets"): + if ( + not isinstance(data.get("soc_targets"), Sensor) + and data.get("soc_targets") is not None + ): for target in data["soc_targets"]: target["value"] /= 1000.0 - if data.get("soc_minima"): + if ( + not isinstance(data.get("soc_minima"), Sensor) + and data.get("soc_minima") is not None + ): for minimum in data["soc_minima"]: minimum["value"] /= 1000.0 - if data.get("soc_maxima"): + if ( + not isinstance(data.get("soc_maxima"), Sensor) + and data.get("soc_maxima") is not None + ): for maximum in data["soc_maxima"]: maximum["value"] /= 1000.0 data["soc_unit"] = "MWh"