From 3cea30ba0a525b8949577f7f2a3c2fbde3a5701a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 15 Jul 2026 18:34:57 +0200 Subject: [PATCH 1/3] Fix: couple stock constraints for multi-device batteries whose SoC comes from a sensor In multi-device (list) flex-model mode, soc-at-start is not resolved during deserialization (unlike single-sensor mode's ensure_soc_at_start()). A battery whose only SoC input is a state-of-charge sensor (no explicit soc-at-start) - the production shape of a battery under an apartment - therefore had soc_at_start=None, so the scheduler applied no stock constraints and the device could discharge far more energy than its store held. Resolve a stock's soc-at-start from its (deserialized) state-of-charge sensor or time series in _prepare()'s per-stock loop, on the stock-owning entry only, so shared-stock device entries are unaffected. Resolution is defensive: if no recent SoC value exists, the stock simply keeps no stock constraints (as before) instead of failing the schedule. Signed-off-by: F.N. Claessen Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LQFkeGxDwErydUV5ZmHmki --- documentation/changelog.rst | 1 + flexmeasures/data/models/planning/storage.py | 45 ++++++++ .../data/models/planning/tests/test_solver.py | 103 ++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 74d5fb2df3..ea8b285370 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -48,6 +48,7 @@ Infrastructure / Support Bugfixes ----------- +* Apply stock constraints to a multi-device battery whose starting state of charge is only given by a ``state-of-charge`` sensor (or time series), without an explicit ``soc-at-start``; previously such a device was scheduled without stock limits and could discharge more energy than its store held [see `PR #2322 `_] * Fix column sorting on the assets page, including when combined with the search filter [see `PR #2314 `_] * Fix forecasting with past or future regressors, which raised a ``TypeError`` on pandas 2.2 and higher [see `PR #2303 `_] * Show why a CLI option was rejected (e.g. "No account found with id 9999") instead of only echoing the offending value [see `PR #2303 `_] diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 9a1f18a627..8e6bc24800 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -191,6 +191,18 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 d0 = devices[0] soc_at_start[d0] = stock_model.get("soc_at_start") + # In multi-device mode, the soc-at-start of a stock is not resolved during + # deserialization (unlike single-sensor mode's ensure_soc_at_start()). If the + # stock's owning entry carries a state-of-charge sensor (or time series) but + # no explicit soc-at-start, resolve the starting stock from it here. Without + # this, soc_at_start stays None and the scheduler applies no stock + # constraints, so the device could discharge more energy than its store holds. + if soc_at_start[d0] is None: + resolved_soc_at_start = self._resolve_stock_soc_at_start( + stock_model, sensor=sensors[d0] + ) + if resolved_soc_at_start is not None: + soc_at_start[d0] = resolved_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") @@ -1608,6 +1620,39 @@ def _resolve_soc_at_start_from_state_of_charge( ) return None + def _resolve_stock_soc_at_start( + self, stock_model: dict, sensor: Sensor | None = None + ) -> float | None: + """Resolve a stock's soc-at-start (in MWh) from its (deserialized) state-of-charge. + + Used in multi-device mode, where soc-at-start is not resolved during + deserialization. Operates on the deserialized stock-owning entry, whose + ``state_of_charge`` is a :class:`Sensor`, :class:`SensorReference` or time series. + + Returns None (rather than raising) when no starting stock can be inferred - e.g. + a state-of-charge sensor without a recent value - so that a stock simply keeps no + stock constraints, as before, instead of failing the whole schedule. + + :param stock_model: The deserialized flex-model entry owning the stock's SoC parameters. + :param sensor: The stock's (first) device power sensor, used for the SoC lookup radius. + :returns: Starting stock in MWh, or None if it cannot be inferred. + """ + state_of_charge = stock_model.get("state_of_charge") + try: + if isinstance(state_of_charge, (Sensor, SensorReference)): + return self._resolve_soc_at_start_from_sensor( + state_of_charge, stock_model, sensor + ) + if isinstance(state_of_charge, list): + return self._resolve_soc_at_start_from_time_series( + state_of_charge, sensor + ) + except ValueError: + # No recent state-of-charge value (or no matching time-series segment): + # leave the stock without a resolved starting SoC. + return None + return None + def possibly_extend_end(self, soc_targets, sensor: Sensor = None): """Extend schedule period in case a target exceeds its end. diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 3af8026f6a..753562b58a 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -3959,3 +3959,106 @@ def test_resolve_soc_at_start_from_time_series_prefers_newest_boundary_value( ) == 2 ) + + +def test_multi_device_battery_couples_stock_from_soc_sensor( + db, building, setup_generic_asset_types +): + """Field-shape regression: a battery in a multi-device flex-model, whose only SoC + input is a state-of-charge *sensor* (no explicit ``soc-at-start``), must be scheduled + with stock constraints - it cannot discharge more energy than its store holds. + + This mirrors the production shape of a battery under an apartment: the child asset's + DB flex-model references the battery's power sensor via a nested output reference and + its SoC via a state-of-charge sensor, and provides soc-min/soc-max but not + soc-at-start. The starting SoC is a small 0.05 kWh, so a correctly coupled scheduler + can only discharge ~0.05 kWh over the whole window, not power-capacity every step. + """ + battery_type = setup_generic_asset_types["battery"] + site = _add_parent_site(db, building, "stock coupling test site") + power_sensor, soc_sensor = _add_battery_device( + db, site, battery_type, "stock coupling battery" + ) + # A second device keeps the flex-model a multi-device list (as in the field, where + # several batteries live in the scheduled subtree), instead of collapsing to a + # single-sensor dict. + power_sensor_2, soc_sensor_2 = _add_battery_device( + db, site, battery_type, "stock coupling battery 2" + ) + db.session.commit() + + resolution = timedelta(hours=1) + start = pd.Timestamp("2020-01-01T00:00:00", tz="Europe/Amsterdam") + end = start + 4 * resolution + + # The store starts nearly empty (0.05 kWh), recorded on the state-of-charge sensor. + source = DataSource("soc-coupling-test-source") + db.session.add(source) + db.session.add_all( + [ + TimedBelief( + sensor=soc_sensor, + event_start=as_server_time(start.to_pydatetime()), + event_value=0.05, # kWh + belief_horizon=timedelta(0), + source=source, + ), + TimedBelief( + sensor=soc_sensor_2, + event_start=as_server_time(start.to_pydatetime()), + event_value=0.05, # kWh + belief_horizon=timedelta(0), + source=source, + ), + ] + ) + db.session.commit() + + flex_model = [ + { + # Power sensor referenced via a nested output reference (no top-level sensor). + "consumption": {"sensor": power_sensor.id}, + "state-of-charge": {"sensor": soc_sensor.id}, + "soc-min": "0 kWh", + "soc-max": "10 kWh", + "power-capacity": "2.5 kW", + # Forbid charging, so the only dischargeable energy is the initial store. + "consumption-capacity": "0 kW", + }, + { + "consumption": {"sensor": power_sensor_2.id}, + "state-of-charge": {"sensor": soc_sensor_2.id}, + "soc-min": "0 kWh", + "soc-max": "10 kWh", + "power-capacity": "2.5 kW", + "consumption-capacity": "0 kW", + }, + ] + + scheduler: Scheduler = StorageScheduler( + asset_or_sensor=site, + start=start, + end=end, + resolution=resolution, + flex_model=flex_model, + flex_context={ + # Reward discharging (production) so the scheduler wants to empty the store. + "consumption-price": "1000 EUR/MWh", + "production-price": "1000 EUR/MWh", + "site-power-capacity": "100 kW", + }, + return_multiple=True, + ) + results = scheduler.compute() + + schedule = next( + r["data"] + for r in results + if r.get("name") == "storage_schedule" and r.get("sensor") is power_sensor + ) + # Net discharged energy (kW * 1h = kWh) cannot exceed the ~0.05 kWh in the store. + discharged_kwh = -schedule.clip(upper=0).sum() + assert discharged_kwh <= 0.05 + 1e-3, ( + f"Battery discharged {discharged_kwh:.3f} kWh from a 0.05 kWh store: " + "the stock constraint is not coupled to the device." + ) From 7190504b41976bac966ee315d6ce2621c5a85d17 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 15 Jul 2026 20:02:18 +0200 Subject: [PATCH 2/3] Clean up: changelog reference, percent-based SoC conversion, and logging - The fixed behavior only regressed during v1 development (PR #1946 dropped the per-entry ensure_soc_at_start call that PR #2026 had added to the multi-device branch; v0.33.1 still resolved soc-at-start there), so reference this PR from the multiple-feeders feature entry instead of adding a Bugfixes entry. - Pass the stock entry's soc-max to the percent-conversion helper in the hyphenated pre-deserialization shape it expects; previously a state-of-charge sensor recording percentages could not be converted using the entry's own soc-max (new regression test). - Warn when a stock's starting state of charge cannot be resolved, since the stock is then scheduled without stock constraints. - Reflow comments and docstrings at natural clause boundaries. Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 3 +- flexmeasures/data/models/planning/storage.py | 39 +++++--- .../data/models/planning/tests/test_solver.py | 91 +++++++++++++++++++ 3 files changed, 116 insertions(+), 17 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index ea8b285370..e927bd1336 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -20,7 +20,7 @@ New features * Floor off-clock API datetimes to a non-instantaneous sensor's resolution by default when ingesting sensor data, uploading sensor data, and handling scheduler flex-model timed events; configurable with the ``floor_datetimes_to_resolution`` sensor attribute [see `PR #2146 `_] * Sensor references in flex-model and flex-context support various ways of filtering by source [see `PR #2209 `_] * Let storage scheduling infer missing ``power-capacity`` from directional device capacities before falling back to site capacity, and default the missing opposite capacity to zero when only a non-zero ``consumption-capacity`` or ``production-capacity`` is configured [see `PR #2222 `_] -* Support multiple feeders to a shared storage [see `PR #2001 `_ and `PR #2321 `_] +* Support multiple feeders to a shared storage [see `PR #2001 `_, `PR #2321 `_ and `PR #2322 `_] * The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 `_, `PR #2172 `_, `PR #2235 `_ and `PR #2271 `_] * CLI support for adding/editing account attributes [see `PR #2242 `_] * Improve chart axis domain for event values not around zero, with a per-sub-chart ``y-axis`` option in ``sensors_to_show`` (default ``zero``, which pads the axis out to include zero) that can be set to ``data`` to fit a sub-chart's y-axis to the values shown, to an explicit ``[min, max]`` domain that the axis will cover at least (expanding to fit data beyond it), or to a strict ``{"min": min, "max": max}`` domain that the axis will never exceed (clamping data beyond it, with a warning when that happens), editable from the graph editor [see `PR #2244 `_] @@ -48,7 +48,6 @@ Infrastructure / Support Bugfixes ----------- -* Apply stock constraints to a multi-device battery whose starting state of charge is only given by a ``state-of-charge`` sensor (or time series), without an explicit ``soc-at-start``; previously such a device was scheduled without stock limits and could discharge more energy than its store held [see `PR #2322 `_] * Fix column sorting on the assets page, including when combined with the search filter [see `PR #2314 `_] * Fix forecasting with past or future regressors, which raised a ``TypeError`` on pandas 2.2 and higher [see `PR #2303 `_] * Show why a CLI option was rejected (e.g. "No account found with id 9999") instead of only echoing the offending value [see `PR #2303 `_] diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 8e6bc24800..a50cc7f7cf 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -191,12 +191,11 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 d0 = devices[0] soc_at_start[d0] = stock_model.get("soc_at_start") - # In multi-device mode, the soc-at-start of a stock is not resolved during - # deserialization (unlike single-sensor mode's ensure_soc_at_start()). If the - # stock's owning entry carries a state-of-charge sensor (or time series) but - # no explicit soc-at-start, resolve the starting stock from it here. Without - # this, soc_at_start stays None and the scheduler applies no stock - # constraints, so the device could discharge more energy than its store holds. + # In multi-device mode, the soc-at-start of a stock is not resolved during deserialization + # (unlike single-sensor mode's ensure_soc_at_start()). If the stock's owning entry carries + # a state-of-charge sensor (or time series) but no explicit soc-at-start, resolve the starting + # stock from it here. Without this, soc_at_start stays None and the scheduler applies no + # stock constraints, so the device could discharge more energy than its store holds. if soc_at_start[d0] is None: resolved_soc_at_start = self._resolve_stock_soc_at_start( stock_model, sensor=sensors[d0] @@ -1625,13 +1624,13 @@ def _resolve_stock_soc_at_start( ) -> float | None: """Resolve a stock's soc-at-start (in MWh) from its (deserialized) state-of-charge. - Used in multi-device mode, where soc-at-start is not resolved during - deserialization. Operates on the deserialized stock-owning entry, whose - ``state_of_charge`` is a :class:`Sensor`, :class:`SensorReference` or time series. + Used in multi-device mode, where soc-at-start is not resolved during deserialization. + Operates on the deserialized stock-owning entry, whose ``state_of_charge`` is a + :class:`Sensor`, :class:`SensorReference` or time series. - Returns None (rather than raising) when no starting stock can be inferred - e.g. - a state-of-charge sensor without a recent value - so that a stock simply keeps no - stock constraints, as before, instead of failing the whole schedule. + Returns None (rather than raising) when no starting stock can be inferred - e.g. a + state-of-charge sensor without a recent value - so that a stock simply keeps no stock + constraints, as before, instead of failing the whole schedule. :param stock_model: The deserialized flex-model entry owning the stock's SoC parameters. :param sensor: The stock's (first) device power sensor, used for the SoC lookup radius. @@ -1640,16 +1639,26 @@ def _resolve_stock_soc_at_start( state_of_charge = stock_model.get("state_of_charge") try: if isinstance(state_of_charge, (Sensor, SensorReference)): + # The percent-conversion helpers expect a pre-deserialization (hyphenated) flex model, + # while the stock-owning entry is already deserialized (underscored keys, values in MWh). + percent_conversion_model = { + "soc-max": stock_model.get("soc_max"), + "soc-unit": "MWh", + } return self._resolve_soc_at_start_from_sensor( - state_of_charge, stock_model, sensor + state_of_charge, percent_conversion_model, sensor ) if isinstance(state_of_charge, list): return self._resolve_soc_at_start_from_time_series( state_of_charge, sensor ) - except ValueError: + except ValueError as e: # No recent state-of-charge value (or no matching time-series segment): - # leave the stock without a resolved starting SoC. + # leave the stock without a resolved starting SoC, but do tell, because + # the stock then gets no stock constraints. + current_app.logger.warning( + f"Could not resolve the starting state of charge of the stock described by {stock_model.get('state_of_charge')}: {e}" + ) return None return None diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 753562b58a..c9ef417775 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -4062,3 +4062,94 @@ def test_multi_device_battery_couples_stock_from_soc_sensor( f"Battery discharged {discharged_kwh:.3f} kWh from a 0.05 kWh store: " "the stock constraint is not coupled to the device." ) + + +def test_multi_device_battery_couples_stock_from_percent_soc_sensor( + db, building, setup_generic_asset_types +): + """Same as test_multi_device_battery_couples_stock_from_soc_sensor, but the + state-of-charge sensor records percentages, so resolving the starting stock needs + the entry's own soc-max for the unit conversion (0.5% of 10 kWh = 0.05 kWh).""" + battery_type = setup_generic_asset_types["battery"] + site = _add_parent_site(db, building, "percent stock coupling test site") + power_sensor, _ = _add_battery_device( + db, site, battery_type, "percent stock coupling battery", with_soc_sensor=False + ) + power_sensor_2, _ = _add_battery_device( + db, + site, + battery_type, + "percent stock coupling battery 2", + with_soc_sensor=False, + ) + soc_sensor = Sensor( + name="percent stock coupling soc", + generic_asset=power_sensor.generic_asset, + event_resolution=timedelta(0), + unit="%", + ) + db.session.add(soc_sensor) + db.session.flush() + + resolution = timedelta(hours=1) + start = pd.Timestamp("2020-01-01T00:00:00", tz="Europe/Amsterdam") + end = start + 4 * resolution + + source = DataSource("percent-soc-coupling-test-source") + db.session.add(source) + db.session.add( + TimedBelief( + sensor=soc_sensor, + event_start=as_server_time(start.to_pydatetime()), + event_value=0.5, # % of soc-max (10 kWh) = 0.05 kWh + belief_horizon=timedelta(0), + source=source, + ) + ) + db.session.commit() + + flex_model = [ + { + "sensor": power_sensor.id, + "state-of-charge": {"sensor": soc_sensor.id}, + "soc-min": "0 kWh", + "soc-max": "10 kWh", + "power-capacity": "2.5 kW", + "consumption-capacity": "0 kW", # forbid charging + }, + { + # A second device keeps the flex-model in multi-device mode. + "sensor": power_sensor_2.id, + "soc-at-start": "0 kWh", + "soc-min": "0 kWh", + "soc-max": "10 kWh", + "power-capacity": "2.5 kW", + "consumption-capacity": "0 kW", + }, + ] + + scheduler: Scheduler = StorageScheduler( + asset_or_sensor=site, + start=start, + end=end, + resolution=resolution, + flex_model=flex_model, + flex_context={ + "consumption-price": "1000 EUR/MWh", + "production-price": "1000 EUR/MWh", + "site-power-capacity": "100 kW", + }, + return_multiple=True, + ) + results = scheduler.compute() + + schedule = next( + r["data"] + for r in results + if r.get("name") == "storage_schedule" and r.get("sensor") is power_sensor + ) + discharged_kwh = -schedule.clip(upper=0).sum() + assert discharged_kwh <= 0.05 + 1e-3, ( + f"Battery discharged {discharged_kwh:.3f} kWh from a 0.05 kWh store: " + "the percent-based state of charge was not converted using the entry's soc-max." + ) From 5d688abb7ef2fbe9a0f176aadbf2b62e80133bed Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 15 Jul 2026 20:38:36 +0200 Subject: [PATCH 3/3] Fail the schedule when a given state of charge cannot be resolved In line with single-sensor mode's ensure_soc_at_start() (PR #2026), a stock whose state of charge is given but unresolvable (e.g. a state-of-charge sensor without a recent value) now fails the schedule with a ValueError, instead of silently scheduling the device without stock constraints. Also reflow docstrings and comments to break lines only after punctuation, per .github/instructions/docstrings.instructions.md. Signed-off-by: F.N. Claessen --- flexmeasures/data/models/planning/storage.py | 54 +++++------- .../data/models/planning/tests/test_solver.py | 82 ++++++++++++++++--- 2 files changed, 92 insertions(+), 44 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index a50cc7f7cf..a6686748b8 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -192,10 +192,11 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 soc_at_start[d0] = stock_model.get("soc_at_start") # In multi-device mode, the soc-at-start of a stock is not resolved during deserialization - # (unlike single-sensor mode's ensure_soc_at_start()). If the stock's owning entry carries - # a state-of-charge sensor (or time series) but no explicit soc-at-start, resolve the starting - # stock from it here. Without this, soc_at_start stays None and the scheduler applies no - # stock constraints, so the device could discharge more energy than its store holds. + # (unlike single-sensor mode's ensure_soc_at_start()). + # If the stock's owning entry carries a state-of-charge sensor (or time series) but no explicit soc-at-start, + # resolve the starting stock from it here. + # Without this, soc_at_start stays None and the scheduler applies no stock constraints, + # so the device could discharge more energy than its store holds. if soc_at_start[d0] is None: resolved_soc_at_start = self._resolve_stock_soc_at_start( stock_model, sensor=sensors[d0] @@ -1625,41 +1626,30 @@ def _resolve_stock_soc_at_start( """Resolve a stock's soc-at-start (in MWh) from its (deserialized) state-of-charge. Used in multi-device mode, where soc-at-start is not resolved during deserialization. - Operates on the deserialized stock-owning entry, whose ``state_of_charge`` is a - :class:`Sensor`, :class:`SensorReference` or time series. + Operates on the deserialized stock-owning entry, + whose ``state_of_charge`` is a :class:`Sensor`, :class:`SensorReference` or time series. - Returns None (rather than raising) when no starting stock can be inferred - e.g. a - state-of-charge sensor without a recent value - so that a stock simply keeps no stock - constraints, as before, instead of failing the whole schedule. + In line with single-sensor mode's ``ensure_soc_at_start()``, + a state of charge that is given but cannot be resolved fails the schedule: + a ``ValueError`` is raised, e.g. for a state-of-charge sensor without a recent value. :param stock_model: The deserialized flex-model entry owning the stock's SoC parameters. :param sensor: The stock's (first) device power sensor, used for the SoC lookup radius. - :returns: Starting stock in MWh, or None if it cannot be inferred. + :returns: Starting stock in MWh, or None if the entry defines no state of charge. """ state_of_charge = stock_model.get("state_of_charge") - try: - if isinstance(state_of_charge, (Sensor, SensorReference)): - # The percent-conversion helpers expect a pre-deserialization (hyphenated) flex model, - # while the stock-owning entry is already deserialized (underscored keys, values in MWh). - percent_conversion_model = { - "soc-max": stock_model.get("soc_max"), - "soc-unit": "MWh", - } - return self._resolve_soc_at_start_from_sensor( - state_of_charge, percent_conversion_model, sensor - ) - if isinstance(state_of_charge, list): - return self._resolve_soc_at_start_from_time_series( - state_of_charge, sensor - ) - except ValueError as e: - # No recent state-of-charge value (or no matching time-series segment): - # leave the stock without a resolved starting SoC, but do tell, because - # the stock then gets no stock constraints. - current_app.logger.warning( - f"Could not resolve the starting state of charge of the stock described by {stock_model.get('state_of_charge')}: {e}" + if isinstance(state_of_charge, (Sensor, SensorReference)): + # The percent-conversion helpers expect a pre-deserialization (hyphenated) flex model, + # while the stock-owning entry is already deserialized (underscored keys, values in MWh). + percent_conversion_model = { + "soc-max": stock_model.get("soc_max"), + "soc-unit": "MWh", + } + return self._resolve_soc_at_start_from_sensor( + state_of_charge, percent_conversion_model, sensor ) - return None + if isinstance(state_of_charge, list): + return self._resolve_soc_at_start_from_time_series(state_of_charge, sensor) return None def possibly_extend_end(self, soc_targets, sensor: Sensor = None): diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index c9ef417775..9acf49a6f7 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -3964,15 +3964,16 @@ def test_resolve_soc_at_start_from_time_series_prefers_newest_boundary_value( def test_multi_device_battery_couples_stock_from_soc_sensor( db, building, setup_generic_asset_types ): - """Field-shape regression: a battery in a multi-device flex-model, whose only SoC - input is a state-of-charge *sensor* (no explicit ``soc-at-start``), must be scheduled - with stock constraints - it cannot discharge more energy than its store holds. - - This mirrors the production shape of a battery under an apartment: the child asset's - DB flex-model references the battery's power sensor via a nested output reference and - its SoC via a state-of-charge sensor, and provides soc-min/soc-max but not - soc-at-start. The starting SoC is a small 0.05 kWh, so a correctly coupled scheduler - can only discharge ~0.05 kWh over the whole window, not power-capacity every step. + """Field-shape regression: a battery in a multi-device flex-model, + whose only SoC input is a state-of-charge *sensor* (no explicit ``soc-at-start``), + must be scheduled with stock constraints - it cannot discharge more energy than its store holds. + + This mirrors the production shape of a battery under an apartment: + the child asset's DB flex-model references the battery's power sensor via a nested output reference and its SoC via a state-of-charge sensor, + and provides soc-min/soc-max but not soc-at-start. + The starting SoC is a small 0.05 kWh, + so a correctly coupled scheduler can only discharge ~0.05 kWh over the whole window, + not power-capacity every step. """ battery_type = setup_generic_asset_types["battery"] site = _add_parent_site(db, building, "stock coupling test site") @@ -4067,9 +4068,10 @@ def test_multi_device_battery_couples_stock_from_soc_sensor( def test_multi_device_battery_couples_stock_from_percent_soc_sensor( db, building, setup_generic_asset_types ): - """Same as test_multi_device_battery_couples_stock_from_soc_sensor, but the - state-of-charge sensor records percentages, so resolving the starting stock needs - the entry's own soc-max for the unit conversion (0.5% of 10 kWh = 0.05 kWh).""" + """Same as test_multi_device_battery_couples_stock_from_soc_sensor, + but the state-of-charge sensor records percentages, + so resolving the starting stock needs the entry's own soc-max for the unit conversion (0.5% of 10 kWh = 0.05 kWh). + """ battery_type = setup_generic_asset_types["battery"] site = _add_parent_site(db, building, "percent stock coupling test site") power_sensor, _ = _add_battery_device( @@ -4153,3 +4155,59 @@ def test_multi_device_battery_couples_stock_from_percent_soc_sensor( f"Battery discharged {discharged_kwh:.3f} kWh from a 0.05 kWh store: " "the percent-based state of charge was not converted using the entry's soc-max." ) + + +def test_multi_device_battery_fails_on_unresolvable_soc_sensor( + db, building, setup_generic_asset_types +): + """A state of charge that is given but cannot be resolved fails the schedule, + in line with single-sensor mode: + a device must not silently be scheduled without stock constraints.""" + battery_type = setup_generic_asset_types["battery"] + site = _add_parent_site(db, building, "unresolvable soc test site") + power_sensor, soc_sensor = _add_battery_device( + db, site, battery_type, "unresolvable soc battery" + ) + power_sensor_2, _ = _add_battery_device( + db, site, battery_type, "unresolvable soc battery 2", with_soc_sensor=False + ) + db.session.commit() + + resolution = timedelta(hours=1) + start = pd.Timestamp("2020-01-01T00:00:00", tz="Europe/Amsterdam") + end = start + 4 * resolution + + flex_model = [ + { + # The state-of-charge sensor holds no recent value. + "sensor": power_sensor.id, + "state-of-charge": {"sensor": soc_sensor.id}, + "soc-min": "0 kWh", + "soc-max": "10 kWh", + "power-capacity": "2.5 kW", + }, + { + # A second device keeps the flex-model in multi-device mode. + "sensor": power_sensor_2.id, + "soc-at-start": "0 kWh", + "soc-min": "0 kWh", + "soc-max": "10 kWh", + "power-capacity": "2.5 kW", + }, + ] + + scheduler: Scheduler = StorageScheduler( + asset_or_sensor=site, + start=start, + end=end, + resolution=resolution, + flex_model=flex_model, + flex_context={ + "consumption-price": "1000 EUR/MWh", + "production-price": "1000 EUR/MWh", + "site-power-capacity": "100 kW", + }, + return_multiple=True, + ) + with pytest.raises(ValueError, match="No recent state-of-charge value"): + scheduler.compute()