Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2146>`_]
* Sensor references in flex-model and flex-context support various ways of filtering by source [see `PR #2209 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2222>`_]
* Support multiple feeders to a shared storage [see `PR #2001 <https://www.github.com/FlexMeasures/flexmeasures/pull/2001>`_ and `PR #2321 <https://www.github.com/FlexMeasures/flexmeasures/pull/2321>`_]
* Support multiple feeders to a shared storage [see `PR #2001 <https://www.github.com/FlexMeasures/flexmeasures/pull/2001>`_, `PR #2321 <https://www.github.com/FlexMeasures/flexmeasures/pull/2321>`_ and `PR #2322 <https://www.github.com/FlexMeasures/flexmeasures/pull/2322>`_]
* The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 <https://www.github.com/FlexMeasures/flexmeasures/pull/1946>`_, `PR #2172 <https://www.github.com/FlexMeasures/flexmeasures/pull/2172>`_, `PR #2235 <https://www.github.com/FlexMeasures/flexmeasures/pull/2235>`_ and `PR #2271 <https://www.github.com/FlexMeasures/flexmeasures/pull/2271>`_]
* CLI support for adding/editing account attributes [see `PR #2242 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2244>`_]
Expand Down
44 changes: 44 additions & 0 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -1608,6 +1620,38 @@ 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.

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 the entry defines no state of charge.
"""
state_of_charge = stock_model.get("state_of_charge")
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)
return None

def possibly_extend_end(self, soc_targets, sensor: Sensor = None):
"""Extend schedule period in case a target exceeds its end.

Expand Down
252 changes: 252 additions & 0 deletions flexmeasures/data/models/planning/tests/test_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -3959,3 +3959,255 @@ 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."
)


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."
)


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()
Loading