Skip to content

Commit 209b518

Browse files
Flix6xclaude
andauthored
Share storage-efficiency per stock group and index solver stock by group (#2325)
* Speed up device_scheduler with a recursive stock balance `_get_stock_change` expressed a device's stock at step j as a running sum over all earlier steps, so the number of model nonzeros grew quadratically with the scheduling horizon (e.g. ~12k nonzeros at 12h, ~349k at 3 days). That dominated solve time on longer horizons. Introduce an explicit `device_stock` variable coupled by a recursive `device_stock_balance` constraint (stock[j] = a*stock[j-1] + b*change[j], with a, b the per-step loss coefficients that apply_stock_changes_and_losses computes). This is mathematically equivalent -- verified identical objectives across horizons and against the planning test suite -- and keeps the number of nonzeros linear in the horizon. Solve time drops ~10x at the 2-day default horizon and ~23x at 3 days. Found while investigating SeitaBV/ems#172. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqGFrHfGrBHAJyjdAyr3y * Reference PR #2282 in the changelog entry Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqGFrHfGrBHAJyjdAyr3y * Address review: apply changelog suggestion and cache stock-group device lists Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix degenerate optimum in alignment regression test after merging main The recursion formulation lands on a cost-equal alternative vertex (A charges 3, B discharges 1 for free under net site metering). Make device B lossy in both directions so the optimum is unique again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Share storage-efficiency per stock group and index solver stock by group (#2324) - storage-efficiency may now be defined on the entry holding a shared stock's SoC parameters (or on exactly one member device) and applies to all members; conflicting definitions fail fast, mirroring how #2321 treats the other SoC parameters. - device_scheduler now models one stock variable and one balance recursion per stock group instead of per device, dropping the redundant secondary recursions, and validates that grouped devices share their storage efficiency and initial stock. - Fix a latent key collision in device_to_group: an ungrouped device whose index equalled a state-of-charge sensor id was silently merged into that stock group, feeding its flows into the wrong stock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add changelog entries for PR #2325 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Enable storage losses in the dynamic-capacity test and consolidate changelog The buffer's storage-efficiency (previously ignored, with a 'does not work yet' todo) is now honored, so enable it at 99% per 15 minutes and update the expected schedules: the heater covers the loss-induced remainder beyond the maxed-out boiler around the clock, and the boiler tops up the buffer in the final hour of the cheap-electricity window. Also fold the two changelog entries into the existing shared-storage feature entry for v1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Attach soft SoC commitments to their stock, not to a device index Enabler for scoping #2194's SoC-constraint relaxation per stock: - StockCommitment gains a 'stock' attribute holding the stock key; the StorageScheduler stamps it on all soft SoC commitments (soc-minima, soc-maxima, prefer-full). - The solver couples stock-scoped commitments to the stock group as a whole (via the group's first device, whose stock is the group's stock), regardless of which device index the commitment names. - DeviceInventory.stock_constraint_device(stock_key) exposes the device that applies a stock's SoC constraints, complementing stock_groups for deserialization-time scoping logic. - The solver's grouped-device efficiency guard now tolerates a missing efficiency column (the default, lossless case), which direct callers of device_scheduler commonly omit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove dead device_stock_commitment_equalities It was never registered as a model constraint; all device-level commitments route through grouped_commitment_equalities. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 59625e3 commit 209b518

7 files changed

Lines changed: 375 additions & 75 deletions

File tree

documentation/changelog.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ New features
2020
* 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>`_]
2121
* 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>`_]
2222
* 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>`_]
23-
* 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>`_]
23+
* 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>`_, `PR #2322 <https://www.github.com/FlexMeasures/flexmeasures/pull/2322>`_ and `PR #2325 <https://www.github.com/FlexMeasures/flexmeasures/pull/2325>`_]
2424
* 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>`_]
2525
* Commodity contexts that omit grid-connection fields (prices and site capacities) now get smart defaults instead of failing or silently leaving the grid unconstrained — for instance, a bare ``{"commodity": "gas"}`` is treated as having no grid connection; see :ref:`commodity_context_defaults` for the full rules [see `PR #2272 <https://www.github.com/FlexMeasures/flexmeasures/pull/2272>`_]
2626
* CLI support for adding/editing account attributes [see `PR #2242 <https://www.github.com/FlexMeasures/flexmeasures/pull/2242>`_]

flexmeasures/data/models/planning/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,10 @@ class Commitment:
352352
upwards_deviation_price: pd.Series = 0
353353
downwards_deviation_price: pd.Series = 0
354354
commodity: str | pd.Series | None = None
355+
#: Stock key of the stock the commitment pertains to (StockCommitments only).
356+
#: When set, the solver couples the commitment to the stock group as a whole,
357+
#: rather than to the device index named by ``device``.
358+
stock: int | None = None
355359

356360
def __post_init__(self):
357361
# device_group is a device→label lookup table, not a time series;
@@ -542,6 +546,9 @@ def to_frame(self) -> pd.DataFrame:
542546
# scalar commodity
543547
df["commodity"] = self.commodity
544548

549+
# stock key (scalar; set on stock-scoped StockCommitments)
550+
df["stock"] = self.stock
551+
545552
return df
546553

547554

flexmeasures/data/models/planning/devices.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,16 @@ def stock_params(self, stock_key: int) -> dict | None:
344344
"""Return the flex-model entry holding the SoC parameters of the given stock."""
345345
return self.stock_entries.get(stock_key)
346346

347+
def stock_constraint_device(self, stock_key: int) -> int | None:
348+
"""Return the device that applies the given stock's SoC constraints.
349+
350+
Hard SoC constraints (and the device-indexed remains of softened ones) land on
351+
the first device of the stock group, whose stock represents the group's stock
352+
in the solver. Use :attr:`stock_groups` to look up all member devices instead.
353+
"""
354+
group_devices = self.stock_groups.get(stock_key)
355+
return group_devices[0] if group_devices else None
356+
347357
@cached_property
348358
def commodity_to_devices(self) -> dict[str, list[int]]:
349359
"""Map each commodity to its device indices, in canonical solver order."""

flexmeasures/data/models/planning/linear_optimization.py

Lines changed: 70 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -133,23 +133,52 @@ def device_scheduler( # noqa C901
133133
# and map stock group -> all member devices (used for stock accumulation).
134134
device_to_group = {}
135135

136+
# Group keys are namespaced strings: a declared stock group's key (a state-of-charge
137+
# sensor id) could otherwise collide with the device index of an ungrouped device,
138+
# silently merging that device into the stock group.
136139
if stock_groups:
137140
for g, devices in stock_groups.items():
138141
for d in devices:
139-
device_to_group[d] = g
140-
# For devices not in any stock group (e.g., inflexible devices),
141-
# map them to themselves so they're treated as individual groups
142-
for d in range(len(device_constraints)):
143-
if d not in device_to_group:
144-
device_to_group[d] = d
145-
else:
146-
for d in range(len(device_constraints)):
147-
device_to_group[d] = d
142+
device_to_group[d] = f"stock:{g}"
143+
# Devices not in any stock group (e.g. inflexible devices) form individual groups.
144+
for d in range(len(device_constraints)):
145+
if d not in device_to_group:
146+
device_to_group[d] = f"device:{d}"
148147

149148
group_to_devices: dict[int, list[int]] = {}
150149
for d, g in device_to_group.items():
151150
group_to_devices.setdefault(g, []).append(d)
152151

152+
# The stock recursion is modelled once per stock group, using the group's shared
153+
# storage efficiency, so devices sharing a stock may not declare different ones.
154+
for g, group_devices in group_to_devices.items():
155+
if len(group_devices) > 1:
156+
# A missing efficiency column means the default (no losses) applies.
157+
group_efficiency = device_constraints[group_devices[0]].get("efficiency")
158+
for d in group_devices[1:]:
159+
efficiency = device_constraints[d].get("efficiency")
160+
if (
161+
(efficiency is None) != (group_efficiency is None)
162+
or efficiency is not None
163+
and not efficiency.equals(group_efficiency)
164+
):
165+
raise ValueError(
166+
f"Devices {group_devices} share stock group {g} but have different"
167+
" storage efficiencies. The storage efficiency is a property of the"
168+
" shared stock, so define it once per stock group."
169+
)
170+
if isinstance(initial_stock, list):
171+
group_initial_stocks = {
172+
initial_stock[d] if d < len(initial_stock) else 0
173+
for d in group_devices
174+
}
175+
if len(group_initial_stocks) > 1:
176+
raise ValueError(
177+
f"Devices {group_devices} share stock group {g} but have different"
178+
" initial stocks. The initial stock is a property of the shared"
179+
" stock, so define it once per stock group."
180+
)
181+
153182
# Move commitments from old structure to new
154183
if commitments is None:
155184
commitments = []
@@ -274,6 +303,18 @@ def convert_commitments_to_subcommitments(
274303
device_group_lookup = {}
275304

276305
for c, df in enumerate(commitments):
306+
# Stock-scoped commitments couple to their stock group as a whole, regardless
307+
# of which device index they name: the group's first device carries the group's
308+
# stock, so a single-member group suffices (also avoiding double-counting the
309+
# shared stock when the commitment names multiple members).
310+
if "stock" in df.columns and pd.notna(df["stock"].iloc[0]):
311+
stock_group_key = f"stock:{int(df['stock'].iloc[0])}"
312+
if stock_group_key in group_to_devices:
313+
device_group_lookup[c] = {
314+
stock_group_key: {group_to_devices[stock_group_key][0]}
315+
}
316+
continue
317+
277318
if "device" not in df.columns:
278319
# EMS-level commitment: no device grouping needed here;
279320
# handled by ems_flow_commitment_equalities.
@@ -541,10 +582,13 @@ def grouped_commitment_equalities(m, c, j, g):
541582
)
542583
model.device_power_up = Var(model.d, model.j, domain=NonNegativeReals, initialize=0)
543584
model.device_power_sign = Var(model.d, model.j, domain=Binary, initialize=0)
544-
# Stock per device per time step, coupled recursively by device_stock_balance.
585+
# Stock per stock group per time step, coupled recursively by group_stock_balance.
545586
# Having it as a variable (rather than a running sum expression) keeps the number
546-
# of model nonzeros linear, rather than quadratic, in the scheduling horizon.
547-
model.device_stock = Var(model.d, model.j, domain=Reals, initialize=0)
587+
# of model nonzeros linear, rather than quadratic, in the scheduling horizon, and
588+
# indexing it by stock group (rather than by device) avoids duplicating the
589+
# recursion for each device sharing a stock.
590+
model.sg = Set(initialize=sorted(group_to_devices), doc="Set of stock groups")
591+
model.group_stock = Var(model.sg, model.j, domain=Reals, initialize=0)
548592
model.commitment_downwards_deviation = Var(
549593
model.c,
550594
domain=NonPositiveReals,
@@ -565,14 +609,13 @@ def _initial_stock_of(d):
565609
return initial_stock[d] if d < len(initial_stock) else 0
566610
return initial_stock
567611

568-
def _stock_change_at(m, d, j):
569-
"""Stock change of device d's stock group during time step j (before losses)."""
570-
devices = group_to_devices[device_to_group[d]]
612+
def _stock_change_at(m, g, j):
613+
"""Stock change of stock group g during time step j (before losses)."""
571614
return sum(
572615
m.device_power_down[dev, j] / m.device_derivative_down_efficiency[dev, j]
573616
+ m.device_power_up[dev, j] * m.device_derivative_up_efficiency[dev, j]
574617
+ m.stock_delta[dev, j]
575-
for dev in devices
618+
for dev in group_to_devices[g]
576619
)
577620

578621
def _loss_coefficients(efficiency: float) -> tuple[float, float]:
@@ -588,20 +631,24 @@ def _loss_coefficients(efficiency: float) -> tuple[float, float]:
588631
return 1.0, 1.0
589632
return efficiency, (efficiency - 1) / math.log(efficiency)
590633

591-
def device_stock_balance(m, d, j):
592-
"""Recursively couple a device's stock to the previous step's stock.
634+
def group_stock_balance(m, g, j):
635+
"""Recursively couple a stock group's stock to the previous step's stock.
593636
594637
Expressing stock[j] as a running sum over all k <= j (as this once did) makes
595638
the number of nonzeros grow quadratically with the scheduling horizon. The
596639
recursion below is equivalent and keeps it linear.
640+
641+
The group's devices share their storage efficiency and initial stock
642+
(validated above), so the first device can represent the group here.
597643
"""
598-
a, b = _loss_coefficients(m.device_efficiency[d, j])
599-
previous = m.device_stock[d, j - 1] if j > 0 else _initial_stock_of(d)
600-
return m.device_stock[d, j] == a * previous + b * _stock_change_at(m, d, j)
644+
d0 = group_to_devices[g][0]
645+
a, b = _loss_coefficients(m.device_efficiency[d0, j])
646+
previous = m.group_stock[g, j - 1] if j > 0 else _initial_stock_of(d0)
647+
return m.group_stock[g, j] == a * previous + b * _stock_change_at(m, g, j)
601648

602649
def _get_stock_change(m, d, j):
603650
"""Stock change of the stock group of device d, from the start until time j."""
604-
return m.device_stock[d, j] - _initial_stock_of(d)
651+
return m.group_stock[device_to_group[d], j] - _initial_stock_of(d)
605652

606653
# Add constraints as a tuple of (lower bound, value, upper bound)
607654
def device_bounds(m, d, j):
@@ -661,34 +708,6 @@ def commitment_down_derivative_sign(m, c):
661708
"""Down deviation active only if sign points down."""
662709
return -m.commitment_downwards_deviation[c] <= Mc * (1 - m.commitment_sign[c])
663710

664-
def device_stock_commitment_equalities(m, c, j, d):
665-
"""Couple device stocks to each commitment."""
666-
if (
667-
"device" not in commitments[c].columns
668-
or (commitments[c]["device"] != d).all()
669-
or m.commitment_quantity[c, j] == -infinity
670-
):
671-
# Commitment c does not concern device d
672-
return Constraint.Skip
673-
674-
# Determine center part of the lhs <= center part <= rhs constraint
675-
center_part = (
676-
m.commitment_quantity[c, j]
677-
+ m.commitment_downwards_deviation[c]
678-
+ m.commitment_upwards_deviation[c]
679-
)
680-
if commitments[c]["class"].apply(lambda cl: cl == StockCommitment).all():
681-
center_part -= _get_stock_change(m, d, j)
682-
elif commitments[c]["class"].apply(lambda cl: cl == FlowCommitment).all():
683-
center_part -= m.ems_power[d, j]
684-
else:
685-
raise NotImplementedError("Unknown commitment class")
686-
return (
687-
0 if "upwards deviation price" in commitments[c].columns else None,
688-
center_part,
689-
0 if "downwards deviation price" in commitments[c].columns else None,
690-
)
691-
692711
def ems_flow_commitment_equalities(m, c, j):
693712
"""Couple EMS flow commitments to device flows, optionally filtered by commodity."""
694713

@@ -728,7 +747,7 @@ def device_derivative_equalities(m, d, j):
728747
model.cjg, rule=grouped_commitment_equalities
729748
)
730749

731-
model.device_stock_balance = Constraint(model.d, model.j, rule=device_stock_balance)
750+
model.group_stock_balance = Constraint(model.sg, model.j, rule=group_stock_balance)
732751
model.device_energy_bounds = Constraint(model.d, model.j, rule=device_bounds)
733752
model.device_power_bounds = Constraint(
734753
model.d, model.j, rule=device_derivative_bounds

flexmeasures/data/models/planning/storage.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,13 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
148148
self.stock_models = inventory.stock_entries
149149
# The stock groups' device indices align with the device models
150150
self.stock_groups = inventory.stock_groups
151+
# Soft SoC constraints are attached to their stock (see StockCommitment.stock),
152+
# so the solver couples them to the stock group rather than to a device index.
153+
device_stock_key = {
154+
d: stock_key
155+
for stock_key, group_devices in self.stock_groups.items()
156+
for d in group_devices
157+
}
151158

152159
# List the asset(s) and sensor(s) being scheduled
153160
sensors: list[Sensor | None] = inventory.power_sensors
@@ -214,10 +221,37 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
214221
prefer_charging_sooner[d0] = stock_model.get("prefer_charging_sooner")
215222
prefer_curtailing_later[d0] = stock_model.get("prefer_curtailing_later")
216223

217-
# todo: move storage-efficiency into a shared parameter for the first device belonging to a shared storage
218224
storage_efficiency = [
219225
flex_model_d.get("storage_efficiency") for flex_model_d in flex_model
220226
]
227+
# The storage efficiency is a property of the stock, not of a connected device:
228+
# for shared stocks, it may be defined on the entry holding the stock's SoC
229+
# parameters or on a single member device, and applies to all members.
230+
for stock_id, stock_devices in self.stock_groups.items():
231+
if len(stock_devices) <= 1:
232+
continue
233+
definitions = []
234+
stock_model = self.stock_models.get(stock_id)
235+
if (
236+
stock_model is not None
237+
and stock_model.get("storage_efficiency") is not None
238+
):
239+
definitions.append(stock_model["storage_efficiency"])
240+
definitions.extend(
241+
storage_efficiency[d]
242+
for d in stock_devices
243+
if storage_efficiency[d] is not None
244+
)
245+
if len(set(map(id, definitions))) > 1:
246+
raise ValueError(
247+
f"Multiple flex-model entries define a storage-efficiency for the same"
248+
f" stock (state-of-charge sensor {stock_id}). The storage efficiency"
249+
f" is a property of the shared stock, so please define it on a single"
250+
f" entry."
251+
)
252+
shared_efficiency = definitions[0] if definitions else None
253+
for d in stock_devices:
254+
storage_efficiency[d] = shared_efficiency
221255
consumption = [flex_model_d.get("consumption") for flex_model_d in flex_model]
222256
production = [flex_model_d.get("production") for flex_model_d in flex_model]
223257
consumption_capacity = [
@@ -650,6 +684,7 @@ def device_list_series(
650684
downwards_deviation_price=-penalty,
651685
index=index,
652686
device=d,
687+
stock=device_stock_key.get(d),
653688
)
654689
commitments.append(commitment)
655690

@@ -745,6 +780,7 @@ def device_list_series(
745780
index=index,
746781
_type="any",
747782
device=d,
783+
stock=device_stock_key.get(d),
748784
)
749785
commitments.append(commitment)
750786

@@ -755,6 +791,7 @@ def device_list_series(
755791
downwards_deviation_price=-all_soc_minima_breach_price,
756792
index=index,
757793
device=d,
794+
stock=device_stock_key.get(d),
758795
)
759796
commitments.append(commitment)
760797

@@ -819,6 +856,7 @@ def device_list_series(
819856
index=index,
820857
_type="any",
821858
device=d,
859+
stock=device_stock_key.get(d),
822860
)
823861
commitments.append(commitment)
824862

@@ -829,6 +867,7 @@ def device_list_series(
829867
upwards_deviation_price=all_soc_maxima_breach_price,
830868
index=index,
831869
device=d,
870+
stock=device_stock_key.get(d),
832871
)
833872
commitments.append(commitment)
834873

0 commit comments

Comments
 (0)