diff --git a/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md b/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md index 1ee2f9c..6b83d7b 100644 --- a/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md +++ b/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md @@ -38,8 +38,16 @@ MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 1b5f7c9). T ## Fix-forward (rig FAIL F-1, F-2; `reports/MAINT-28-test-report.md`) The sim had no battery-reading lag, and the first version said the 300 W line is far larger than the lag moves the estimate. That was wrong. The rig publishes a battery's power every 10 s; the meter is newer. After each guard step charge -> give (+500-1000 W) the next meter reading shows the give and the battery reading still shows the charge, so `grid + site_batt_w` drops by the step, under the 5650 W line: 69/69, 132/133, 215/215, 51/51 releases at a median 5430 W with the house at 6450 W or more. Test: `test_maint28b.py`, sim `Lag.batt_period_s`. -**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 13 s after the load (7 s in the test), not 0: the first settled estimate. No cut-back is touched. +**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 4 s after the load (12 s in test_maint28b), not 0: the first settled estimate. No cut-back is touched. -**F-2 and the cap (owner 2026-11-13): while the pause holds, the maintenance pack gives at most what the guard claimed this period.** While the pause holds (N>=2, month aim above the cap), the pack in maintenance CHARGE gets a unit limit `hi` = the largest floor of the guard's claim in this 15-minute period (reset at each period and when the pause ends), at any SoC; 0 W when the guard places its claim on the other packs, i.e. this pack is under its reserve (`peak_guard.held`; the claim itself is site-scoped). Other packs are unchanged (B not in maintenance still gives for the house). Why not `hi` = 0 W: it crosses the guard's claim and resolves to 0 W (house 12 kW, B under its reserve: quarter 6713 W, a new month peak). Why the largest, not the present floor: the guard alone lands 10 W over its aim (SAFETY-126) because its floor drops to 0 W each time the projection reaches the aim, and the pack steps on and off (15 give/idle switches in 45 min, test_maint28 quarter 5960.1 W against 5950 W); holding the largest floor of the period gives test_maint28 5925 W and 0 switches. Sim, 45 min, house 6500 W: A at 49 %: A gives 0.37 kWh (uncapped 2.39), SoC 50.5 -> 48.3 %, charge/give flips 1, give/idle switches 0, quarters 1422 1370 2550 W (all under the aim), charge back 4 s after the load. B at 18.2 % in CHARGE: f3f9483 gave 1.89 kWh to 10.0 % (log "giving nothing for the peak"); now 0.00 kWh, 18.2 %, quarters 5948 and 5952 W: the guard places everything on the other pack, and its own landing error shows (up to +9.5 W, inside SAFETY-126's 10 W; test_maint28b allows that and nothing more). test_safety84 n2_charge_banked: banked quarters 3349 and 3016 W against the aim 3950 W. +**F-2 and the cap.** Owner 2026-11-13 ("1" = cap): while the pause holds, the pack in maintenance CHARGE gives only what the guard claims for the peak, at any SoC; other packs unchanged. Owner 2026-11-13 ("ok max", relayed by the manager): accepted "while the hold is on, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter" (about 0.37 kWh per 45-min peak in the sim; quarters at or under the aim). -Mutants, each red in `test_maint28b.py`: the freshness check dropped (always settled); the cap dropped. +The rule as built (`Controller._m28_share()`): while the pause holds (N>=2, month aim above the cap) the pack gets a unit limit `hi` = the largest part of the guard's floor placed on it in this 15-minute period. Its part in a cycle is the floor less what the other units are placed at in this cycle's split without that limit, so a part the guard's split puts on the other pack is not this pack's. A pack the guard holds at its reserve (`peak_guard.held`) gets 0 W and its part starts again from 0 W. The largest part is kept per unit (`_m28_hi`, keyed by the unit) and dropped in a new period or when the pause ends; only one pack can be in maintenance CHARGE anyway (`maintenance.arbitrate()`, one slot per site). + +Why the largest of the period and not the present part: the guard alone lands the quarter 9.5-10 W over its aim in the sim (SAFETY-126: its own projection ends on 5950.0 W but the meter reading it counts lags the true grid), and with the present part only, test_maint28 part 1 ends at 5960.1 W; the largest of the period lets the loop keep A up and it ends at 5925 W. Why not `hi` = 0 W: with the other pack under its reserve the guard needs this one (test_maint28b, house 12 kW, B at 18 %: A gives 1.07 kWh for the guard). + +Tests (test_maint28b, 13-min load, A 5 kW): A at 49 %: SoC drop 0.64 point (the owner's 0.37 kWh per 45 min is 0.70 point here; the check is <= 0.70). Both packs able, B on Cheap hours: B is placed nothing, A's limit is the period's largest floor (3087 W) and A gives 3081 W. B on Solar first (gives for the house): A's limit 3294 W against a period's largest floor of 6284 W, under it by 500 W or more in 564 of 569 paused cycles; A still gives 3294 W, so the quarter ends 3843 W, far under the aim: that is the cost of the largest-of-period rule when the other pack gives for the house. + +Sim, rig shape, 45 min, house 6500 W: A in CHARGE at 49 %: A gives 0.17 kWh (f3f9483 0.44), SoC 50.5 -> 49.0 %, charge/give flips 1, give/idle switches 3, quarters 5358 1663 1899 2572 4476 W, charge back 4 s after the load; B (Cheap hours, not in maintenance) gives 3.04 kWh for the house (f3f9483 0.32). B at 18.2 % in CHARGE: f3f9483 gave 1.89 kWh to 10.0 %; now 0.00 kWh, 18.2 %, quarters 5948 and 5952 W (the guard alone, on A). test_safety84 n2_charge_banked: banked quarters 3349 and 3217 W against the aim 3950 W. + +Mutants, each red: the freshness check dropped (always settled); the cap dropped; the other units' placement dropped (part = the whole floor); one cap for the site instead of per unit; the 0 W for a held pack dropped (all in `test_maint28b.py`); the period's largest dropped (present part only: `test_maint28.py` part 1 quarter 5960 W). diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index b16f73a..5b749b9 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -29,7 +29,7 @@ from . import shadow from .price import Uncertainty from .tariff import next_quarter from .watchdog import loop_is_following -from .ctl_trade import pack_energy_of +from .ctl_trade import M28_REASON, pack_energy_of from .rows import why_key, why_raised _LOG = logging.getLogger("goodwe") @@ -589,25 +589,20 @@ class CycleMixin: "suspended so the peak claim is not " "contradicted", hint=messages.hint("maint_paused")) - # MAINT-28: while held this pack gives at most the most the - # guard claimed this period (0 W under its reserve: the - # guard places its claim on the other packs). + # MAINT-28: while held this pack gives at most the most of + # the guard's floor placed on it this period + # (`_m28_share()`). # DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md - _q = int(now.timestamp() // 900) - if self._m28_q != _q: - self._m28_q, self._m28_hi = _q, 0.0 - if self.peak_guard is None or \ - inv.name not in self.peak_guard.held: - self._m28_hi = max(self._m28_hi, max( - (c.lo for c in claims - if c.source == peakguard.SOURCE), default=0.0)) shape = ((0.0 if self.peak_guard is not None and inv.name in self.peak_guard.held - else self._m28_hi, "peak hold") + else max([c.lo for c in claims + if c.source == peakguard.SOURCE] + + [self._m28_was(self.actuator_of( + "maintenance", inv), now)], + default=0.0), M28_REASON) if _m28 else None) else: self._maint_shape_suspended = False - self._m28_hi = 0.0 hi = 0.0 reason = "charge-only" if self.cheap: @@ -875,6 +870,9 @@ class CycleMixin: self.loop_clamp_facts = () self._loop_decision = None + # MAINT-28: a paused pack gives at most its largest part of the floor + # in this period. + self._m28_share(claims, now) # SAFETY-28. A new resolution is a new tick, and the previous record is # stale from this line onwards. Incremented BEFORE resolve() so that the # record resolve() builds is stamped with the tick it is for. diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py index e4fb9ce..df0d21a 100644 --- a/goodwe_controller/app/ctl_trade.py +++ b/goodwe_controller/app/ctl_trade.py @@ -10,7 +10,7 @@ from datetime import datetime, timedelta, timezone from . import arbitrage from .arbiter import ( ACTUATOR_SITE, Claim, P_MAINTENANCE, P_MAINT_SHAPE, P_PEAK, SET, - pre_resolve, + pre_resolve, resolve, ) from . import allocator from . import dayplan @@ -53,6 +53,8 @@ HOUSE_PROFILE_KEY = "house_profile" # MAINT-28: a step of a battery's command, in W, after which its reading is not # current until a changed one arrives (`Controller._batts_settled()`). MAINT_STEP_W = 100.0 +# MAINT-28: the reason on a paused pack's cap (`Controller._m28_share()`). +M28_REASON = "peak hold" # OPT-34: the level of a night grid charge that has started, so it does not @@ -740,6 +742,42 @@ class TradeMixin: ok = False return ok + def _m28_share(self, claims, now) -> None: + """MAINT-28: each paused pack's cap (0 W if the guard holds it at its + reserve) set to the largest part of the guard's floor left to it in + this 15-minute period: the floor less what the other units are placed + at in this cycle's split without the cap. Kept per unit in `_m28_hi`, + dropped while the unit is not paused or is held. + DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md""" + floor = max((c.lo for c in claims if c.source == peakguard.SOURCE), + default=0.0) + g = self.peak_guard + held = {self.actuator_of("maintenance", inv) for inv in self.inverters + if g is not None and inv.name in g.held} + q, seen = int(now.timestamp() // 900), set() + for i, c in enumerate(claims): + if c.source != "maintenance" or c.reason != M28_REASON \ + or c.actuator == ACTUATOR_SITE or c.actuator in held: + continue + seen.add(c.actuator) + shares = resolve(claims[:i] + claims[i + 1:], units=self.units, + live=self.live_units(), weights=self.pack_weights, + reserve=self._held_back()).shares + part = max(0.0, floor - sum(float(w) for u, w in shares.items() + if u != c.actuator)) + hi = max(part, self._m28_was(c.actuator, now)) + self._m28_hi[c.actuator] = (q, hi) + claims[i] = dataclasses.replace(c, hi=hi) + # A unit not paused (or held at its reserve) starts again from 0 W. + for u in set(self._m28_hi) - seen: + del self._m28_hi[u] + + def _m28_was(self, u, now) -> float: + """MAINT-28: unit `u`'s cap so far in this 15-minute period, 0 W in + a new one (`_m28_share()`).""" + q, hi = self._m28_hi.get(u, (None, 0.0)) + return hi if q == int(now.timestamp() // 900) else 0.0 + def _reseed_drop(self, live) -> None: """SAFETY-96, N>=2: a battery that drops out of the split (stale link) leaves the loop's start point, so the others take over at one slew step diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index bff2a4d..6939231 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -506,8 +506,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self._peak_needed = False self._maint_shape_suspended = False self._maint_peak_hold = False # MAINT-28 - self._m28_q, self._m28_hi = None, 0.0 # MAINT-28: period, its most guard claim self._m28_step = {} # MAINT-28: unit -> (command, step at) + self._m28_hi = {} # MAINT-28: unit -> (period, cap) # OPT-11: battery modes, off by default (SAFETY-10 section 1). Each # battery's mode and on/off switch live in the store, not in options, # so a change applies at once (`pack_use`, `pack_mode`). diff --git a/goodwe_controller/test_maint28b.py b/goodwe_controller/test_maint28b.py index a5d38de..0b1d3bf 100644 --- a/goodwe_controller/test_maint28b.py +++ b/goodwe_controller/test_maint28b.py @@ -16,10 +16,13 @@ peak forecast like the live sensor, house +-50 W every 5 s, 3000 W then times (base 1b5f7c9: dozens), no release of the pause on an estimate that mixes a new grid with an old battery reading, quarters at or under the aim, A's charge back after the load. -2. F-2 and the cap: while the pause holds, A gives only what the peak guard - claims, at any SoC (never under its reserve, SoC barely drops); with the - other pack under its reserve and a house too big for it, A still gives for - the guard. +2. F-2 and the cap (owner 2026-11-13): while the pause holds, A gives at most + the largest part of the guard's floor placed on it in the 15-min period, + at any SoC (never under its reserve; SoC drop within the owner-accepted + give); with both packs able, never over the period's largest floor, and + when B is placed part of it A's limit is its own part, not the whole floor; + one unit's limit is its own (two packs in CHARGE never share it); with the + other pack under its reserve, A still gives for the guard. 3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing for the house. 4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the @@ -33,6 +36,7 @@ import logging import os import random import sys +from datetime import timedelta HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) @@ -41,7 +45,8 @@ logging.disable(logging.WARNING) import opt11p3_replay as rp # noqa: E402 import opt20_scen as sc # noqa: E402 -from app import ctl_cycle, ctl_trade, freshness, peakguard, tariff # noqa: E402 +from app import (ctl_cycle, ctl_trade, freshness, maintenance, # noqa: E402 + packmode, peakguard, tariff) fails = [] @@ -115,10 +120,22 @@ def scen(*, unit="a", soc=49.0, other=30.0, house=None, house_w=3000.0, def cb(c, h, now, pr): est = (None if c.grid is None or c.site_batt_w is None else float(c.grid) + float(c.site_batt_w)) + g = c.peak_guard + floor = (float(g.floor_w) if g is not None and g.engaged + and g.floor_w is not None else 0.0) + sh = (dict(c.allocation.shares) if c.allocation is not None + and c.allocation.shares else {}) + au = c.actuator_of("maintenance", c.inverters[idx]) + ao = c.actuator_of("maintenance", c.inverters[1 - idx]) rows.append(dict( t=(now - T0).total_seconds(), grid=h.grid(), u=h.plants[unit].p, o=h.plants[o].p, soc=h.plants[unit].soc, soc_o=h.plants[o].soc, hold=bool(getattr(c, "_maint_peak_hold", False)), est=est, + floor=floor, share_u=float(sh.get(au, 0.0)), + hi_u=next((float(pl.hi) for pl in (c.allocation.plans + if c.allocation else ()) + if pl.unit == au), None), + share_o=float(sh.get(ao, 0.0)), house=h.grid() + sum(p.p for p in h.plants.values()))) src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)] @@ -190,7 +207,8 @@ check("after the load A's charge is back within 20 cycles (1 s each: a 10 s " back is not None and back <= 20) # ============================================================================ # -print("\n2. the cap: while the pause holds, A gives only what the guard claims") +print("\n2. the cap: while the pause holds, A gives at most the largest part " + "of the guard's floor placed on it this period") rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0)) show("A 21 %", rows, st) check("A gives only the guard's claim, under 0.15 kWh (f3f9483 0.94)", @@ -200,8 +218,13 @@ check("A never goes under its peak reserve in the peak (rig: to 10 %)", check("every quarter at or under the month aim", quarters_ok(st)) rows, st, c = scen(house=stepped_house(3000.0, 6500.0)) soc_start = max(r["soc"] for r in rows if r["t"] <= PRE + 5.0) -check("A at 49 %: its SoC barely drops (under 1 point; f3f9483 6.1)", - soc_start - min(r["soc"] for r in rows if r["t"] >= PRE) < 1.0) +drop = soc_start - min(r["soc"] for r in rows if r["t"] >= PRE) +print(f" A at 49 %: SoC drop {drop:.2f} point") +# Owner 2026-11-13 accepted about 0.37 kWh per 45-min peak with the cap +# (DECISIONS): 0.37 * 13 / 45 = 0.107 kWh in this load, 0.70 point of +# 15.36 kWh. Measured 0.64. +check("A at 49 %: its SoC drops no more than the owner-accepted give allows " + "(0.70 point in the 13 min; f3f9483 6.1)", drop <= 0.70) rows, st, c = scen(soc=49.0, other=18.2, house=stepped_house(3000.0, 12000.0)) show("A 49 %, B 18 % under its reserve, house 12000 W", rows, st) load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] @@ -213,6 +236,55 @@ sw = sum(1 for x, y in zip(g, g[1:]) if x != y) print(f" give/idle switches of A in the load: {sw}") check("...give/idle switches of A at most 10 in the 13 min", sw <= 10) +def period_max(rows, f): + """Per row: the largest f(row) over the paused rows of its 15-minute + period so far (0 W outside the pause), as the owner rule reads.""" + out, q, m = [], None, 0.0 + for r in rows: + qi = int((T0.timestamp() + r["t"]) // 900) + if qi != q or not r["hold"]: + q, m = qi, 0.0 + if r["hold"]: + m = max(m, f(r)) + out.append(m) + return out + + +for tag, ch, hi_w in (("B on Cheap hours", None, 9000.0), + ("B on Solar first (it gives for the house too)", + {"a": sc.CH_CH["a"], + "b": (True, packmode.SOLAR_FIRST, False)}, + 12000.0)): + rows, st, c = scen(soc=49.0, other=40.0, choice=ch, + house=stepped_house(3000.0, hi_w)) + show(f"A 49 %, B 40 %, both able, {tag}, house {hi_w:.0f} W", rows, st) + mf = period_max(rows, lambda r: r["floor"]) + held_rows = [(r, m) for r, m in zip(rows, mf) + if r["hold"] and r["floor"] > 0.0 and r["hi_u"] is not None] + n = max(1, len(held_rows)) + over = sum(1 for r, m in held_rows if r["share_u"] > m + 2.0) + # the whole floor, as far as A's rating lets it be A's limit + under = sum(1 for r, m in held_rows + if r["hi_u"] < min(m, rp.RATING) - 500.0) + print(f" {len(held_rows)} paused cycles with a guard floor (mean " + f"{sum(r['floor'] for r, _m in held_rows) / n:.0f} W, period's " + f"largest {sum(m for _r, m in held_rows) / n:.0f} W): A's limit " + f"{sum(r['hi_u'] for r, _m in held_rows) / n:.0f} W, A " + f"{sum(r['share_u'] for r, _m in held_rows) / n:.0f} W, B " + f"{sum(r['share_o'] for r, _m in held_rows) / n:.0f} W; A over the " + f"period's largest floor in {over}; A's limit 500 W or more under it" + f" (or A's rating) in {under}") + check(f"{tag}: the pause holds with a guard floor for over 5 min", + len(held_rows) >= 300) + check("...A never gives more than the largest guard floor of the period " + "(owner 2026-11-13)", over == 0) + if ch is not None: + check("...B is placed part of the floor, so A's limit is its own part," + " 500 W or more under the period's largest floor (or A's " + "rating) in 9 of 10 cycles (not the whole floor)", + under >= 0.9 * len(held_rows)) + check("...every quarter at or under the month aim", quarters_ok(st)) + rows, st, c = scen(soc=18.2, other=30.0, house=stepped_house(3000.0, 12000.0)) show("A 18 % under its reserve, B 30 % able, house 12000 W", rows, st) check("the guard places its claim on the other pack: A under its reserve " @@ -328,6 +400,26 @@ check("a move under ctl_trade.MAINT_STEP_W is no step", k2._batts_settled()) a2.cmd = -1500.0 + ctl_trade.MAINT_STEP_W check("...one of ctl_trade.MAINT_STEP_W is", not k2._batts_settled()) +# Per unit: the cap so far lives under each unit's own key, and only one pack +# can be in maintenance CHARGE at all (maintenance.arbitrate(): one slot). +au, ab = (cl.actuator_of("maintenance", i) for i in cl.inverters) +t = T0 + timedelta(seconds=60) +cl._m28_hi = {au: (int(t.timestamp() // 900), 3000.0)} +check("the period's cap is kept per unit: A's 3000 W is not B's", + cl._m28_was(au, t) == 3000.0 and cl._m28_was(ab, t) == 0.0) +check("...and it starts again from 0 W in the next 15-minute period", + cl._m28_was(au, t + timedelta(seconds=900)) == 0.0) + + +class _M: + def __init__(self, name): + self.name, self.phase = name, maintenance.CHARGE + + +check("maintenance.arbitrate(): with two packs in CHARGE only the first keeps " + "the slot", maintenance.arbitrate([_M("a"), _M("b")], t)[1] != "" + and maintenance.arbitrate([_M("a"), _M("b")], t)[0] == "") + print() if fails: print(f"{len(fails)} FAILED:") diff --git a/goodwe_controller/test_packshare.py b/goodwe_controller/test_packshare.py index b4e3893..b0c43a4 100644 --- a/goodwe_controller/test_packshare.py +++ b/goodwe_controller/test_packshare.py @@ -1148,7 +1148,10 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE "could do - and no fixture anywhere could see that, because every check " "calls `resolve()` for itself. A method, not a dict, because the " "weighting is direction-dependent and the direction is stage 2's output", - len(_resolve_calls) == 1 and _w_arg == ["self.pack_weights"]) + # MAINT-28 added a second call (`_m28_share()`'s split without the + # paused pack's cap); it must pass the same method, as the cycle's does. + len(_resolve_calls) >= 1 + and _w_arg == ["self.pack_weights"] * len(_resolve_calls)) # ⚠️ AC 3 SAYS `unmet_w` MUST BE **VISIBLE**, AND `Allocation.explain()` IS ONLY # HALF OF THAT. The other half is main.py putting it in the action log, which no diff --git a/goodwe_controller/test_safety84.py b/goodwe_controller/test_safety84.py index cf4679c..a546491 100644 --- a/goodwe_controller/test_safety84.py +++ b/goodwe_controller/test_safety84.py @@ -485,11 +485,11 @@ print("\n8. the N=1 and N=2 digests") # aim) the maintenance charge no longer restarts at -125 W after each guard # stop (t=1550 s, 64 of 480 rows); the pause is held until the load is gone # (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). MAINT-28 fix-forward -# re-pinned it again: while the pause holds A gives only the guard's claim, so -# rows 288-479 (t = 1550 s on, 192 of 480) differ: a "peak hold" limit claim on -# A, and A's share capped at the guard's claim instead of 2500 W. Its banked -# quarters stay under the aim 3950 W (3349 and 3016 W; f3f9483 3349 and 2527 W), -# graded below. The other five are equal. +# re-pinned it again: while the pause holds A gives at most the largest part +# of the guard's floor placed on it this period, so rows 288-479 (t = 1440 s +# on, 192 of 480) differ: a "peak hold" limit claim on A and A's share. Its +# banked quarters stay under the aim 3950 W (3349 and 3217 W; f3f9483 3349 and +# 2527 W), graded below. The other five are equal. PINNED = { "n1_charge": "00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9", @@ -502,7 +502,7 @@ PINNED = { "n2_charge_dear": "aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db", "n2_charge_banked": - "6b0af4837a71120d50f29ed85058cb676fe6f7a822ccf0d6c566e58217ebc109", + "2da0c3bc9f0ff3857402146efcd6119180a7d4d1cc1297f1c15ec0b8c0104f42", } _nb_rows, _nb = s84.run(**s84.SCENARIOS["n2_charge_banked"]) print(f" n2_charge_banked banked quarters {[round(v, 1) for v in banked(_nb)]}"