From d0db5b8c393e1507f969c7ee82a746668ffb157f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:29:30 +0200 Subject: [PATCH] SAFETY-87: peak guard uses every battery that can still give At N>=2 a battery at or under its own peak reserve is held: it gives nothing for the peak (weight 0 while the site gives) and the guard's need is placed on the live, non-refusing batteries above theirs. The guard is floor-limited, and alarms, only when those cannot hold the limit at full power for ALARM_HOLD_S. The peak alarms no longer read "Not ready" or hold the Home page at 0 W, because control keeps running. N=1 is byte-identical. DECISIONS/SAFETY-87-guard-uses-able-batteries.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr --- .../DECISIONS/SAFETY-32-per-pack-bms-floor.md | 2 + .../SAFETY-87-guard-uses-able-batteries.md | 73 +++ goodwe_controller/DOCS.md | 10 +- goodwe_controller/app/arbiter.py | 9 +- goodwe_controller/app/homeview.py | 11 +- goodwe_controller/app/main.py | 120 +++- goodwe_controller/app/messages.py | 25 +- goodwe_controller/app/peakguard.py | 47 +- goodwe_controller/test_peakguard.py | 94 ++-- goodwe_controller/test_safety86.py | 3 +- goodwe_controller/test_safety87.py | 512 ++++++++++++++++++ goodwe_controller/tools/safety87_identity.py | 66 +++ 12 files changed, 901 insertions(+), 71 deletions(-) create mode 100644 goodwe_controller/DECISIONS/SAFETY-87-guard-uses-able-batteries.md create mode 100644 goodwe_controller/test_safety87.py create mode 100644 goodwe_controller/tools/safety87_identity.py diff --git a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md index 2b0cdc9..d9a5dce 100644 --- a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md +++ b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md @@ -69,6 +69,8 @@ which does not run the startup gate) the absence costs a shave and never a pack. ## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors +*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].* + `Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack — **is unchanged**, exactly as the card instructed. What changed is the floor it is graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack diff --git a/goodwe_controller/DECISIONS/SAFETY-87-guard-uses-able-batteries.md b/goodwe_controller/DECISIONS/SAFETY-87-guard-uses-able-batteries.md new file mode 100644 index 0000000..4555605 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-87-guard-uses-able-batteries.md @@ -0,0 +1,73 @@ +# SAFETY-87 - the peak guard uses every battery that can still give + +**Status: DECIDED 2026-10-01 (owner card).** Follows SAFETY-86's "Not +changed" list. Tests: `test_safety87.py`; identity: +`tools/safety87_identity.py`. + +**The defect.** At N>=2 the guard graded the emptiest battery against the +highest floor ([[SAFETY-32]] §3). One battery under `peak_soc_floor` made the +whole site floor-limited: the claim asked for 0 W and the reserve alarm fired, +even with the other battery full. Measured: a maintenance charge (A at 11 %) +raised the alarm 10 times while both batteries gave 2000 W and the quarter +landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at +15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86 +review; rig case 7a: 4563.8 W). + +**The fix.** +- `decide()` and its call are unchanged. At N>=2 `Controller._peak_on_able()` + then grades each battery on its own floor (`peakguard.reserve_split()`). + One at or under it is *held*. The others that are live and not refusing + (SAFETY-73) are *able*. +- A floor-limited decision is re-taken on the able batteries + (`peakguard.place_on_able()`): their own permission (min SoC against max + floor, among them), and a floor of `min(need, able_w)`. `able_w` is their + rating plus the drain a held battery gives anyway (its maintenance pin). +- The held batteries weigh 0 in the split while the site gives + (`arbiter.resolve(reserve=)`), so the floor lands on the able ones. During a + HOLD, a held battery is not counted among the "others" (`_peak_over_hold()`), + so the holding battery covers the need. +- Floor-limited now means: some battery is held and the able ones cannot hold + the aim. The claim still asks them for `able_w`. The alarm fires only if + they miss the real limit at full power for `ALARM_HOLD_S`, the same test as + 'Load too high' (`_reserve_short()`), so a one-cycle miss at a quarter's end + is not an alarm. With nothing able (all held, or the rest refusing or + dropped) it is the old state: 0 W, immediate alarm. +- The alarm and the row name `held`, each battery graded on its own floor. The + old code named batteries against the site's highest floor. +- Page: the two peak rows carry `runs`. A red `runs` row gives the banner its + title without "Not ready", and the Home page is not shown as held at 0 W. + Rows that do stop control still outrank it. + +**How SAFETY-32's concern is still covered.** SAFETY-32 took min(SoC) against +max(floor) so the guard never sizes a forced discharge a battery's own BMS +stops. Now no battery at or under its own floor is asked for the peak: it is +graded on its own floor, and an unread SoC or unknown cut-out counts as held. +The min/max pairing still decides permission, over the able batteries. The +roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's +call-site and reduction checks still bite. What changed is only that one held +battery no longer stops the others. + +**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W): + +| case | before | after | +|---|---|---| +| charge, A 11 %, house 4500 | 3 reserve alarms, quarter 3450 | 0 alarms, 3450 | +| HOLD, A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W, 3 alarms | A 2054 W, B 0 W, 0 alarms, 3450 | +| HOLD, house 6500 | A 0 W, B 2488 W, 4051 W | A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short) | +| HOLD, month peak 4000, house 6000 | A 0 W, B 2488 W, 3551 W, alarms | A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms | +| both under the reserve | alarm | alarm (unchanged) | +| N=1, every scenario | | byte-identical | + +**Open, for the owner.** +- The loop still draws a held battery (SAFETY-25 option A: the reserve binds + the peak guard only). It gives last, after the able ones. So with house 6500 + in the charge case, B alone cannot hold the aim, the alarm fires as + specified, and the loop then lands the quarter on the aim from A. The text + says "cannot keep … alone", not "will go over", so it stays true. +- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11 + keeps A resting and B gives it. When B cannot, A gives first. Making Off give + before a held battery in the first case needs a third allocator tier. +- The notification and row text live in `messages.py`, which has no NL layer + yet (UX-19). The Home page text, which has NL, is unchanged. + +**Not changed.** N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index e4130d4..8fac634 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -3157,6 +3157,10 @@ and what to do: - **A battery cut-out level is not known.** Fill in “Battery cut-out level” for each inverter in the settings. +With several inverters, a battery at its peak reserve does not give for the +peak and the others do. This alarm then comes only when the others cannot +keep the period under the limit alone. Control keeps running meanwhile. + The peak reserve only applies to keeping the peak down. Normal home use can still draw the battery below it, down to the battery cut-out level, where the battery switches itself off. @@ -3252,7 +3256,9 @@ state of charge is unread, or when an inverter's `bms_soc_floor` (label “Battery cut-out level”) is missing, because the reserve is graded against it. Charging from the grid stays blocked meanwhile. With several inverters the notification names the battery at its limit (SAFETY-32), each graded against -its own cut-out level. ⚠️ **The reserve binds the peak guard only +its own cut-out level. That battery is held (weight 0 while the site gives) +and the guard's need goes to the others, up to their rating; the alarm fires +only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). ⚠️ **The reserve binds the peak guard only (SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor` does not protect*. @@ -3390,7 +3396,7 @@ for a new install*. | Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. | | Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. | | 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. | -| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. | +| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. | | Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. | | Inverter control | Whether the inverter can be controlled, and how much it can charge and give. | | *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. | diff --git a/goodwe_controller/app/arbiter.py b/goodwe_controller/app/arbiter.py index 2ec7bee..e9136b9 100644 --- a/goodwe_controller/app/arbiter.py +++ b/goodwe_controller/app/arbiter.py @@ -635,7 +635,7 @@ def site_resolve(claims: list) -> Resolution: def resolve(claims: list, *, units=None, weights=None, live=None, - tick=None) -> Resolution: + tick=None, reserve=()) -> Resolution: """The three stages, in order, over one cycle's claims. `units` is the site's roster of inverters. Left None it is derived from the @@ -737,6 +737,13 @@ def resolve(claims: list, *, units=None, weights=None, live=None, gate, held = _unit_modes(out, site_claims, bounds, live) if callable(weights): weights = weights(out.target_w) + # SAFETY-87: units under their peak reserve weigh 0 while the site gives, + # so the others carry the peak floor. An Off battery gives the peak's + # shortfall, so it goes before them when no other unit is left. + reserve = set(reserve) if out.target_w > 0.0 else set() + if held and reserve and not set(bounds) - held - reserve: + held = set() + held = held | reserve if held: weights = {u: (0.0 if u in held else (1.0 if weights is None else weights.get(u, 1.0))) diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 18d9820..6cfccc7 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -923,24 +923,25 @@ def state_of(c, level, shadow): def build(c, *, level: str, checks: list, now: datetime | None = None, - lang: str = "en") -> dict: + lang: str = "en", held: bool | None = None) -> dict: """The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of the controller.""" _LANG[0] = uitext.lang_of(lang) try: - return _build(c, level=level, checks=checks, now=now) + return _build(c, level=level, checks=checks, now=now, held=held) finally: _LANG[0] = "en" -def _build(c, *, level, checks, now): +def _build(c, *, level, checks, now, held=None): now = now or datetime.now(timezone.utc) shadow = bool(c.shadow) kind = state_of(c, level, shadow) # A red banner means every inverter is at 0 W (the status cascade), so # nothing is planned until it clears. Shadow mode still plans: it is - # what the add-on would do. - held = level == "bad" and not shadow + # what the add-on would do. `held` False: an alarm while control runs + # (SAFETY-87, the peak alarms). + held = level == "bad" and not shadow and held is not False running = (bool(c.auto) or shadow) and not held settled = all(at_zero(inv) for inv in c.inverters) ctl = next((r for r in checks if r.get("id") == "controller"), None) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 3881917..45ceeda 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -936,6 +936,9 @@ class Controller: self._peak_alarm_block = None self._peak_high_since = None self._peak_floor_limited = False + # SAFETY-87: the reserve alarm's hold, as SAFETY-81's above. + self._reserve_batt_prev = None + self._reserve_high_since = None self._maint_shape_suspended = False # OPT-11. Battery modes, off by default so an install that has not # turned them on behaves exactly as before (SAFETY-10 section 1). Each @@ -2514,6 +2517,44 @@ class Controller: self._drain_waits = waits return floor if floor > 0.0 else None + def _peak_on_able(self, decision, maint_results): + """SAFETY-87: at N>=2, `decision` placed on the batteries that can + still give: live, not refusing, above their own reserve. `able_w` is + their rating plus the drain a held battery gives anyway (its + maintenance pin). Unchanged when no battery is held back. + DECISIONS/SAFETY-87-guard-uses-able-batteries.md.""" + if len(self.inverters) < 2: + return decision + floor = float(self.o.get( + "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) + ok, held = peakguard.reserve_split(self.packs, floor) + if not held: + return decision + live = set(self.live_units()) + able = [p for p, inv in zip(self.packs, self.inverters) + if p in ok and inv.name in live and not (inv.refused or 0) > 0] + if not able: + return dataclasses.replace(decision, held=held) + drain = sum(min(float(r.setpoint_w), i.rating_w()) + for i, r in zip(self.inverters, maint_results) + if i.name in held and r.phase == DRAIN + and r.owns_setpoint and (r.setpoint_w or 0.0) > 0.0) + names = {p.name for p in able} + return peakguard.place_on_able( + decision, held, + able_w=drain + sum(i.rating_w() for i in self.inverters + if i.name in names), + soc=min(float(p.soc) for p in able), + soc_floor=peakguard.binding_soc_floor(able, floor)) + + def _peak_reserve(self) -> frozenset: + """SAFETY-87: the batteries that give nothing for the peak this + cycle: under their reserve while the others carry a peak floor.""" + g = self.peak_guard + if g is None or g.able_w is None or not (g.floor_w or 0.0) > 0.0: + return frozenset() + return frozenset(g.held) + def _peak_over_drain(self, claims, maint_results) -> None: """SAFETY-83: at N=1 a peak floor above the drain rate outranks it. @@ -2558,10 +2599,11 @@ class Controller: and c.priority > P_PEAK for c in claims): return live = set(self.live_units()) + reserve = self._peak_reserve() # SAFETY-87: they give nothing others = 0.0 for inv in self.inverters: u = self.actuator_of("maintenance", inv) - if u in hold or u not in live: + if u in hold or u not in live or inv.name in reserve: continue b = pre_resolve([c for c in claims if c.actuator == u]) if b.contradiction: @@ -4021,6 +4063,11 @@ class Controller: dev_min=self.site_dev_bound("dev_min"), dev_max=self.site_dev_bound("dev_max")), ) + # SAFETY-87: at N>=2 a battery at or under its own reserve gives + # nothing for the peak, and the others carry the guard's need. + if self.peak_guard is not None: + self.peak_guard = self._peak_on_able(self.peak_guard, + maint_results) # SITE, and peakguard.claim() says so on the object it builds. The # quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION, # which is what the capacity tariff bills; there is one quarter-hour @@ -4553,7 +4600,8 @@ class Controller: _live = self.live_units() while True: resolution = resolve(claims, units=self.units, live=_live, - weights=self.pack_weights, tick=self.alloc_tick) + weights=self.pack_weights, tick=self.alloc_tick, + reserve=self._peak_reserve()) # SAFETY-73: a probe pin stands only while the resolved site target # asks at least PROBE_W its way. Below that a sibling would carry # the opposite direction (circulation), so the pin goes back to the @@ -5169,6 +5217,8 @@ class Controller: floor-limited the guard asked for NOTHING, because the pack is at or under the EMS floor, or its SoC could not be read. Nothing is being discharged for this quarter at all. + (SAFETY-87, N>=2: or the batteries above their + reserve give all they can and it is not enough.) They cannot both be true: peakguard.decide()'s floor-limited branch returns before the `achievable_w` clamp that sets `saturated`. @@ -5189,6 +5239,10 @@ class Controller: """ g = self.peak_guard limited = bool(g is not None and g.floor_limited) + if limited and (g.floor_w or 0.0) > 0.0: + limited = self._reserve_short(g) + else: + self._reserve_batt_prev = self._reserve_high_since = None was, self._peak_floor_limited = self._peak_floor_limited, limited if not limited or was: return @@ -5211,13 +5265,8 @@ class Controller: # think the pack is AT it. `decide()` guards its own reason string the # same way; a f-string that formats a None here would turn the one # notification that matters into a traceback. - # ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor - # the decision was actually taken with, so this re-grades nothing and - # cannot disagree with the alarm it is printed inside. At a roster of one - # no pack is named - there is exactly one. - _blocked = [name for name, why - in peakguard.pack_permissions(self.packs, g.soc_floor) - if why is not None] + # SAFETY-32/87: which battery is at its reserve, each graded on its own + # floor (`g.held`). At a roster of one no battery is named. # ⚠️ SAFETY-25's sentence (the reserve binds the peak guard only) is # part of messages.peak_reserve() on every branch, the unreadable-SoC # one included. @@ -5226,8 +5275,31 @@ class Controller: g.ceiling_w, soc_unread=g.soc is None, floor_unknown=(g.soc is not None and g.soc_floor is None), - packs=(_blocked if len(self.inverters) > 1 - else ())).data()) + packs=(g.held if len(self.inverters) > 1 + else ()), + others=(g.floor_w or 0.0) > 0.0).data()) + + def _reserve_short(self, g) -> bool: + """SAFETY-87: the batteries that can still give, at full power, miss + the limit (`load_too_high()` on `g.able_w`), for ALARM_HOLD_S, as + SAFETY-81 grades 'Load too high'.""" + p, batt = self._peak_proj, self.site_batt_w + if batt is None: + return self._peak_floor_limited + prev, self._reserve_batt_prev = self._reserve_batt_prev, batt + if prev is not None: + batt = min(float(batt), float(prev)) + if not (p is not None and p.block_start is not None + and peakguard.load_too_high(p, batt_w=batt, + ceiling_w=g.ceiling_w, + achievable_w=g.able_w)): + self._reserve_high_since = None + return False + t = p.block_start + timedelta(seconds=float(p.elapsed_s)) + if self._reserve_high_since is None: + self._reserve_high_since = t + return ((t - self._reserve_high_since).total_seconds() + >= peakguard.ALARM_HOLD_S) async def check_circulating(self) -> None: """SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. @@ -5998,8 +6070,10 @@ class Controller: "text": messages.row_peak_guard_waiting()}) elif _pg.saturated: out.append({"ok": False, "warn": False, "id": "peak_guard", + "runs": True, "text": messages.row_peak_saturated(_pg.ceiling_w)}) - elif _pg.floor_limited: + elif _pg.floor_limited and (not (_pg.floor_w or 0.0) > 0.0 + or self._peak_floor_limited): # SAFETY-24. NOT ok, like saturation: in both states the money # is going to be spent and a human is the only actuator left. A # separate row from saturation because the two ask for opposite @@ -6025,18 +6099,17 @@ class Controller: # UX-08: the same three branches, and the same pack naming, as # check_peak_floor_limited()'s notification. The figures stay in # `_pg.reason`, on the log and the event list. - _blocked = [name for name, why - in peakguard.pack_permissions(self.packs, - _pg.soc_floor) - if why is not None] + # SAFETY-87: `runs`, control goes on (status_payload()). out.append({"ok": False, "warn": False, "id": "peak_guard", + "runs": True, "banner": messages.banner_peak_reserve(), "text": messages.row_peak_reserve( _pg.ceiling_w, soc_unread=_pg.soc is None, floor_unknown=(_pg.soc is not None and _pg.soc_floor is None), - packs=(_blocked if len(self.inverters) > 1 - else ()))}) + packs=(_pg.held if len(self.inverters) > 1 + else ()), + others=(_pg.floor_w or 0.0) > 0.0)}) elif _pg.blind: out.append({"ok": False, "warn": True, "id": "peak_guard", "text": messages.row_peak_guard_blind( @@ -6359,6 +6432,11 @@ class Controller: def status_payload(self, lang: str = "en") -> dict: checks = self.checks() bad = [c for c in checks if not c["ok"] and not c["warn"]] + # SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while + # control goes on, so it neither reads "Not ready" nor holds the page. + runs = [c for c in bad if c.get("runs")] + bad = [c for c in bad if not c.get("runs")] + running_alarm = False # --- SAFETY-10, AND IT IS FIRST IN THE CASCADE ----------------------- # "The battery is NOT being controlled" is the one sentence on this # page that may never be displaced by another condition. A banner that @@ -6411,6 +6489,9 @@ class Controller: level, banner = "bad", messages.banner_not_ready(bad[0]) elif not self.auto: level, banner = "warn", messages.banner_stopped() + elif runs: + level, running_alarm = "bad", True + banner = runs[0].get("banner") or messages.headline(runs[0]["text"]) elif any(i.delivery_wd.tripped for i in self.inverters): # ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE, # WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE. @@ -6532,6 +6613,7 @@ class Controller: decided_at=self.arb_at, cycle_at=self.cycle_at, now=datetime.now(timezone.utc), why=self.why_words)), ] + held = level == "bad" and not running_alarm out = { "banner": banner, "level": level, "rows": rows, "checks": checks, "hint": self.hint, @@ -6546,7 +6628,7 @@ class Controller: # fields are served alone (the page then shows the installer details). try: out.update(homeview.build(self, level=level, checks=checks, - lang=lang)) + lang=lang, held=held)) except Exception: # noqa: BLE001 # Once per process at ERROR: the page polls every 3 s. if not getattr(self, "_home_failed", False): diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index d353b16..b40d730 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -217,7 +217,7 @@ def peak_saturated(ceiling_w, *, shadow: bool = False) -> Notice: def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, - packs=()) -> Notice: + packs=(), others: bool = False) -> Notice: """SAFETY-24/25/32: the peak guard may not draw the battery any lower. The last sentence is SAFETY-25 and must stay: the reserve binds the peak @@ -227,7 +227,8 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, roster means some inverter's cut-out level is missing (the peak reserve itself always has a value) - so the action names the cut-out level. Both are named by their setting labels, "Peak reserve (%)" and "Battery - cut-out level (%)". + cut-out level (%)". `others`: the other batteries give all they can and + it is not enough (SAFETY-87), so the limit MAY be passed. """ if soc_unread: why = "The battery level cannot be read" @@ -241,6 +242,12 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, why = (f"The battery of {names(packs)} is at its peak reserve" if packs else "The battery is at its peak reserve") act = "Switch off large loads, or lower the peak reserve." + if others: + return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE, + f"{why}, and the other batteries cannot keep this " + f"15-minute period under your {watts(ceiling_w)} peak " + f"limit alone. {act} Home use can still draw a battery " + f"below its reserve, down to its cut-out level.") return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE, f"{why}, so this 15-minute period will go over your " f"{watts(ceiling_w)} peak limit and raise this month's peak. " @@ -470,8 +477,9 @@ def row_peak_saturated(ceiling_w) -> str: def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, - packs=()) -> str: - """SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay.""" + packs=(), others: bool = False) -> str: + """SAFETY-24/25/32. The last sentence is SAFETY-25 and must stay. + `others`: as in `peak_reserve()` (SAFETY-87).""" if soc_unread: why = "Battery level cannot be read" elif floor_unknown: @@ -479,6 +487,10 @@ def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, else: why = (f"{names(packs)} battery at its peak reserve" if packs else "Battery at its peak reserve") + if others: + return (f"{why}. The others cannot hold the {watts(ceiling_w)} peak " + f"limit alone. Home use still draws it lower, to the cut-out " + f"level.") return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home " f"use still draws the battery below it, down to the cut-out level.") @@ -1014,6 +1026,9 @@ def status_samples() -> list: ("row_peak_guard_off", AMBER, row_peak_guard_off()), ("row_peak_reserve", RED, row_peak_reserve( 5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])), + ("row_peak_reserve", RED, row_peak_reserve( + 5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"], + others=True)), ("row_peak_reserve", RED, row_peak_reserve( 5000, soc_unread=True, floor_unknown=False)), ("row_peak_reserve", RED, row_peak_reserve( @@ -1116,6 +1131,8 @@ def samples() -> list: peak_reserve(5000, soc_unread=False, floor_unknown=False), peak_reserve(5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"]), + peak_reserve(5000, soc_unread=False, floor_unknown=False, + packs=["inverter-b"], others=True), peak_reserve(5000, soc_unread=True, floor_unknown=False), peak_reserve(5000, soc_unread=False, floor_unknown=True), circulating(["inverter-a"], ["inverter-b"]), diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index 9e4bf65..2ecaae8 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -397,7 +397,7 @@ function, so whichever way the owner answers, this file does not change and does not need re-testing. Nothing here indexes a phase. """ -from dataclasses import dataclass +from dataclasses import dataclass, replace from . import messages from .arbiter import ACTUATOR_SITE, P_PEAK, Claim @@ -490,11 +490,18 @@ class PeakDecision: # floor refused to size it. Mutually exclusive with `saturated` by # construction: this branch asks for nothing, so it cannot have run out of # inverter. `soc_floor_reason` says WHICH of the two ways it bound. + # SAFETY-87: at N>=2 it may ask the batteries above their reserve for + # `able_w`, all they have, when that is not enough (`place_on_able()`). floor_limited: bool = False soc: float | None = None soc_floor: float | None = None soc_floor_reason: str | None = None reason: str = "" + # SAFETY-87, N>=2 only: the batteries at or under their own reserve, + # which give nothing for the peak, and what the others can give (None + # when they are not asked). See `place_on_able()`. + held: tuple = () + able_w: float | None = None def explain(self) -> str: """Idle: the status row's plain words (UX-08). Engaged: `reason`, the @@ -733,6 +740,43 @@ def binding_soc_floor(packs, soc_floor): return max(floors) +def reserve_split(packs, soc_floor) -> tuple[list, tuple]: + """SAFETY-87: `(packs the guard may spend, names of those it may not)`, + each pack graded on its own floor by `pack_permissions()`.""" + perms = pack_permissions(packs, soc_floor) + return ([p for p, (_n, why) in zip(packs, perms) if why is None], + tuple(n for n, why in perms if why is not None)) + + +def place_on_able(decision, held, *, able_w, soc, soc_floor) -> PeakDecision: + """SAFETY-87: `decision` with the batteries in `held` (at or under their + own reserve) giving nothing for the peak. `able_w`, `soc` and `soc_floor` + describe the others, as `decide()`'s arguments do for the roster. A + floor-limited decision is re-taken on them: they are asked for the need + up to `able_w`, and it stays floor-limited only if that is not enough. + `soc` and `soc_floor` keep the roster's verdict, as `decide()` echoed it. + DECISIONS/SAFETY-87-guard-uses-able-batteries.md.""" + held = tuple(held) + if not decision.floor_limited or decision.wanted_w is None \ + or discharge_permitted(soc, soc_floor) is not None: + return replace(decision, held=held) + wanted = float(decision.wanted_w) + floor = min(wanted, float(able_w)) + head = (f"this 15-minute period heads for {decision.projected_w:.0f} W " + f"against a {decision.ceiling_w:.0f} W limit " + f"({decision.ceiling_from}), aiming at {decision.target_w:.0f} W. " + f"At the peak reserve, so giving nothing for the peak: " + f"{messages.names(held)}. The others are asked for {floor:+.0f} W") + if floor >= wanted: + return replace(decision, floor_limited=False, floor_w=floor, + soc_floor_reason=None, held=held, able_w=float(able_w), + reason=f"PEAK_GUARD: {head}") + return replace(decision, floor_w=floor, held=held, able_w=float(able_w), + reason=(f"PEAK_GUARD_FLOOR_LIMITED: {head}, all they can " + f"give, but {wanted:+.0f} W was needed, so this " + f"period may go over the limit")) + + def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc, soc_floor, max_hold_s=None, frozen: bool = False) -> PeakDecision: """The whole guard. Pure: no clock, no I/O, no state carried between calls. @@ -1097,5 +1141,6 @@ def claim(decision: PeakDecision) -> Claim | None: return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w), reason=("blind" if decision.blind else "SoC floor" if decision.floor_limited + and not decision.floor_w else "quarter peak"), actuator=ACTUATOR_SITE) diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 29bae44..ac02b5f 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -109,6 +109,7 @@ import re import shutil import sys import tempfile +import types import time from datetime import datetime, timedelta, timezone from math import inf, isinf @@ -2537,12 +2538,18 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}") # that is going over the ceiling. # UX-08: the banner is the notification's own title, and the row below keeps # the SAFETY-25 sentence. -check("the Not ready banner is a short headline - the notification's title - " - "and NOT the row repeated at the top of the page. A headline that is a " - "paragraph is a headline nobody reads", +# SAFETY-87: control goes on while the guard is floor-limited, so the banner +# does not say "Not ready". This fixture also has a row that does stop control +# (no SoC entity), which outranks it; the peak row alone is graded below. +c25.checks = lambda: rows25 +_pay25 = c25.status_payload() +del c25.checks +print(f" banner, peak row alone: {_pay25['banner']}") +check("the banner is a short headline - the notification's title, without " + "'Not ready' (control goes on) - and NOT the row repeated at the top of " + "the page. A headline that is a paragraph is a headline nobody reads", _pay25["level"] == "bad" - and _pay25["banner"] == (f"{messages.NOT_READY} — " - f"{messages.T_PEAK_RESERVE}") + and _pay25["banner"] == messages.T_PEAK_RESERVE and not _row_scope(_pay25["banner"])) check("...and shortening the banner did NOT shorten the row: the SAFETY-25 " "sentence is still what the checks list carries, which is the surface the " @@ -4139,18 +4146,22 @@ check("⚠️ ...AND THE SAME PAIR SWAPPED GIVES THE SAME SITE FLOOR, so no " and _g_deepa.soc_floor != _g_shallow.soc_floor and [pk.bms_floor for pk in _g_ctl_deepa.packs] == [B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_SIM]) -check("...and it is not just a number in a report - THE SITE'S VERDICT FLIPS. " - "With both packs at 25 % the shallow site is permitted and the guard " - "sizes a positive forced discharge; BOTH deep sites are FLOOR-LIMITED, " - "because 25 % is at or below the 35 % cut-out wherever on the roster it " - "sits. Grading the roster against one position would spend a pack its " - "BMS stops - through `sim/inject.py`, which does not run rule 8 - and " - "this line is what goes red when it does", +# SAFETY-87 changed what the verdict is at N>=2: the pack at or under its own +# floor is HELD (named, gives nothing for the peak) and the others still shave. +check("...and it is not just a number in a report - THE HELD PACK FOLLOWS IT. " + "With both packs at 25 % the shallow site holds nobody back; on BOTH deep " + "sites the pack whose 35 % cut-out is at or above 25 % is held, wherever " + "it sits, gives nothing for the peak (weight 0), and the other pack " + "carries a positive floor (SAFETY-87). Holding a position instead of " + "the pack would spend a pack its BMS stops", _g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0 - and _g_deep.floor_limited is True and _g_deep.floor_w == 0.0 - and _g_deep.soc_floor_reason == FLOOR_AT_SOC_FLOOR - and _g_deepa.floor_limited is True and _g_deepa.floor_w == 0.0 - and _g_deepa.soc_floor_reason == FLOOR_AT_SOC_FLOOR) + and _g_shallow.held == () + and _g_deep.floor_limited is False and _g_deep.floor_w > 0.0 + and _g_deep.held == ("inverter-b",) + and _g_ctl_deep._peak_reserve() == {"inverter-b"} + and _g_deepa.floor_limited is False and _g_deepa.floor_w > 0.0 + and _g_deepa.held == ("inverter-a",) + and _g_ctl_deepa._peak_reserve() == {"inverter-a"}) check("...and the precondition that makes the three runs comparable (habit 1): " "the two deep runs carry THE SAME PAIR of cut-outs in opposite orders, " "those two numbers really are different, and all six packs are at the " @@ -4221,14 +4232,14 @@ check("⚠️ THE POSITIONAL PIN, AND IT NEEDS THREE PACKS TO EXIST. On a roster for r in _g3_runs) and [[pk.bms_floor for pk in r[2].packs] for r in _g3_runs] == [r[1] for r in _g3_runs]) -check("...and the VERDICT flips with it, on all three: 25 % is at or below the " - "35 % cut-out wherever it sits, so every one of these sites is " - "FLOOR-LIMITED and sizes no forced discharge at all - where the " - "all-shallow two-pack baseline above was permitted and sized a positive " - "one. A slice that dropped the deep position would spend, through " - "`sim/inject.py`, a pack its BMS stops", - all(r[3].floor_limited is True and r[3].floor_w == 0.0 - and r[3].soc_floor_reason == FLOOR_AT_SOC_FLOOR for r in _g3_runs) +check("...and the HELD pack follows it, on all three (SAFETY-87): 25 % is at " + "or below the 35 % cut-out wherever it sits, so that pack and only that " + "pack is held and gives nothing for the peak, while the two others carry " + "a positive floor. A slice that dropped the deep position would spend, " + "through `sim/inject.py`, a pack its BMS stops", + all(r[3].floor_limited is False and r[3].floor_w > 0.0 + and r[3].held == (r[2].inverters[r[1].index(_G3_DEEP_SIM)].name,) + and r[2]._peak_reserve() == set(r[3].held) for r in _g3_runs) and _g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0) check("...and the precondition that makes the three comparable (habit 1): every " "pack in all three rosters is at the SAME state of charge, so neither the " @@ -4386,12 +4397,18 @@ async def _notify32(packs_socs, floors): inv_b, _ = second_inverter(ctl) for inv, so, fl in zip(ctl.inverters, packs_socs, floors): inv.soc, inv.bms_soc_floor = so, fl - ctl.peak_guard = PeakDecision( + # SAFETY-87: the full pack refuses to give (SAFETY-73), so no battery + # can carry the peak and the alarm still fires; it names the held one. + inv.refused = 1 if so > SOC_FLOOR else 0 + _idle = [types.SimpleNamespace(phase="idle", owns_setpoint=False, + setpoint_w=None)] * 2 + ctl.peak_guard = ctl._peak_on_able(PeakDecision( engaged=True, floor_w=0.0, ceiling_w=CAP_W, ceiling_from=CEILING_CAP, projected_w=CAP_W + 500.0, excess_w=500.0, wanted_w=500.0, floor_limited=True, soc=min(packs_socs), soc_floor=binding_soc_floor(ctl.packs, SOC_FLOOR), - soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x") + soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x"), + _idle) ctl._peak_floor_limited = False await ctl.check_peak_floor_limited() return hs.last_message("goodwe_peak_soc_floor") @@ -4581,18 +4598,19 @@ check("...and the TWO-pack mirror, which is what production reaches the moment " [r[3].soc for r in _s2_runs] == [_S_EMPTY_SIM] * 2 and [[inv.soc for inv in r[2].inverters] for r in _s2_runs] == [[_S_EMPTY_SIM, _S_FULL_SIM], [_S_FULL_SIM, _S_EMPTY_SIM]]) -check("⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT, which is what makes this a " - "defect rather than a reporting difference. With the emptiest pack at " - "11 % - below the 20 % EMS floor - every one of those five sites is " - "FLOOR-LIMITED and sizes NO forced discharge at all, where the baseline " - "with its emptiest pack at 25 % is permitted and sizes a positive one. A " - "reduction that dropped the empty pack's position would grade this site " - "for PERMISSION against a pack 39 points fuller and spend, through " - "`sim/inject.py`, a pack its own BMS stops", - all(r[3].floor_limited is True and r[3].floor_w == 0.0 +# SAFETY-87: at N>=2 the emptiest pack is HELD and the others still shave. +check("⚠️ ...AND THE HELD PACK FOLLOWS IT, which is what makes this a defect " + "rather than a reporting difference. With the emptiest pack at 11 % - " + "below the 20 % EMS floor - that pack and only that pack is held on " + "every one of those five sites and gives nothing for the peak, while " + "the fuller ones carry a positive floor (SAFETY-87); the baseline holds " + "nobody back. A reduction that dropped the empty pack's position would " + "spend, through `sim/inject.py`, a pack its own BMS stops", + all(r[3].floor_limited is False and r[3].floor_w > 0.0 + and r[3].held == (r[2].inverters[r[1].index(_S_EMPTY_SIM)].name,) for r in _s3_runs + _s2_runs) and _s_ok.floor_limited is False and _s_ok.floor_w > 0.0 - and _s_ok.soc == SOC_BETWEEN) + and _s_ok.held == () and _s_ok.soc == SOC_BETWEEN) check("...and the precondition that makes the six runs comparable (habit 1): " "every pack in every roster carries the SAME cut-out, so " "`binding_soc_floor()` answers the SAME site floor on all of them and the " @@ -5727,8 +5745,8 @@ MUT.append(("main.py grading the site against ONE roster position - " _g_deep.soc_floor == float(B_PACK_FLOOR_DEEP_SIM) and _g_deepa.soc_floor == float(B_PACK_FLOOR_DEEP_SIM) and _g_shallow.soc_floor == float(SOC_FLOOR) - and _g_deep.floor_limited and _g_deepa.floor_limited - and not _g_shallow.floor_limited, + and _g_deep.held == ("inverter-b",) + and _g_deepa.held == ("inverter-a",) and _g_shallow.held == (), "part 12g, REWRITTEN as behaviour: real two-pack cycles, the SAME " "pair of cut-outs carried in both orders, and both the floor " "reaching decide() and the site's verdict follow the ROSTER rather " diff --git a/goodwe_controller/test_safety86.py b/goodwe_controller/test_safety86.py index ce761d0..35a4d4a 100644 --- a/goodwe_controller/test_safety86.py +++ b/goodwe_controller/test_safety86.py @@ -337,7 +337,8 @@ HOLDING = [MaintResult(HOLD, True, 0.0, False, []), def pin_after(floor, *extra, live=(A, B), results=HOLDING, c=_c): fake = types.SimpleNamespace(inverters=c.inverters, actuator_of=c.actuator_of, - live_units=lambda: tuple(live)) + live_units=lambda: tuple(live), + _peak_reserve=frozenset) cl = [Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=A), Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=B), Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator=A), diff --git a/goodwe_controller/test_safety87.py b/goodwe_controller/test_safety87.py new file mode 100644 index 0000000..e410ba2 --- /dev/null +++ b/goodwe_controller/test_safety87.py @@ -0,0 +1,512 @@ +"""Runnable check for SAFETY-87: the peak guard uses every battery that can +still give. `python3 test_safety87.py` + +At N>=2 the guard graded the EMPTIEST battery (SAFETY-32), so one battery under +the peak reserve stopped the guard for all of them: it asked for nothing and +raised the reserve alarm while the other battery was full. Now a battery at or +under its own reserve is HELD (it gives nothing for the peak, weight 0) and +the guard's need is placed on the others; the reserve alarm fires only when +they cannot hold the limit. Drives the REAL Controller (tools/ +safety84_identity.py's harness). DECISIONS/SAFETY-87-guard-uses-able-batteries.md. + +1. Maintenance charge at N=2 (A under the reserve, B able), house over the + aim: no reserve alarm, the quarter on the aim, A gives nothing for the + peak while B can carry it. When B cannot, the alarm fires and names A. +2. HOLD at N=2 (A full holding, B under the reserve): A gives, B gives + nothing, the quarter lands on the aim; at a banked month peak too. +3. Both under the reserve, or the other one refusing: the alarm still fires + and names the right battery. +4. The alarm at the quarter's edge: a one-cycle miss does not fire it. +5. place_on_able() at its boundaries, and the claim it files. +6. The allocator: a held battery gives last, after an Off one. +7. A held battery draining for maintenance counts for its drain. +8. The page: a peak alarm does not say "Not ready" or "0 W until the alarm + clears" while control goes on. +9. N=1 is byte-identical to release/1.0 b8ddfd7 (tools/safety87_identity.py). + +Mutations (MUTATION.md, purged copies) are listed in the pull request. +""" + +import asyncio +import dataclasses +import json +import logging +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(HERE, "tools")) +logging.disable(logging.WARNING) + +import opt11_identity as rig # noqa: E402 +import safety84_identity as s84 # noqa: E402 +import safety87_identity as s87 # noqa: E402 +from app import messages, packmode, peakguard # noqa: E402 +from app.arbiter import (Claim, P_MODE, P_PEAK, P_SAFETY, # noqa: E402 + UnitBounds, resolve) +from app.main import Controller # noqa: E402 +from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402 +from app.store import unit_key # noqa: E402 + +fails = [] +A, B = "inverter-a", "inverter-b" +AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50 +TOL = 10.0 # W over the aim a banked quarter may show (SAFETY-81) + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def store(phase, *, b_off=False): + """Battery A in `phase` from the first cycle (B idle, cycle done).""" + s = rig._Store() + s.data[unit_key(A, "phase")] = phase + s.data[unit_key(A, "phase_started")] = rig.BASE.isoformat() + s.data[unit_key(B, "last_completed")] = rig.BASE.isoformat() + if phase == IDLE: + s.data[unit_key(A, "last_completed")] = rig.BASE.isoformat() + if b_off: + s.data[unit_key(B, packmode.USE_KEY)] = False + return s + + +def run(house, phase, socs, *, cheap=False, n=2, **kw): + """Rows, controller, the notifications raised and one record per cycle: + (floor_limited, held, floor_w, reserve set, alarm latched).""" + notes, seen = [], [] + real_check = Controller.check_peak_floor_limited + + async def spy(self): + await real_check(self) + g = self.peak_guard + seen.append(None if g is None else ( + g.floor_limited, g.held, g.floor_w, self._peak_reserve(), + self._peak_floor_limited)) + + def before(c, t): + if not getattr(c.hass, "_s87", False): + real = c.hass.call + + async def call(*a): + if a[:2] == ("persistent_notification", "create"): + notes.append(a[2]) + return await real(*a) + c.hass.call, c.hass._s87 = call, True + return s84.feed(cheap)(c, t) + + kw.setdefault("dur", 2700.0) + extra = dict({"maintenance_hold_min": 600}, **kw.pop("extra", {})) + Controller.check_peak_floor_limited = spy + try: + rows, c = s84.run( + n, load=house if callable(house) else lambda t: house, + cheap=cheap, store=kw.pop("store", None) or store(phase), + soc=lambda u, t: socs[u], before=before, extra=extra, **kw) + finally: + Controller.check_peak_floor_limited = real_check + return rows, c, notes, seen + + +def without_fix(fn): + """The shipped b8ddfd7 behaviour: no battery is ever held back.""" + real = Controller._peak_on_able + Controller._peak_on_able = lambda self, decision, results: decision + try: + return fn() + finally: + Controller._peak_on_able = real + + +def cmd(r): + return dict(r[2]) + + +def banked(c): + return [b.offtake_avg_w for b in c.p1.blocks] + + +def on_aim(c, aim=AIM): + b = banked(c) + return len(b) >= 2 and all(aim - TOL <= v <= aim + TOL for v in b) + + +def reserve_notes(notes): + return [x for x in notes + if x["notification_id"] == messages.ID_PEAK_RESERVE] + + +def limited_events(c): + return sum(1 for e in c.events if "PEAK_GUARD_FLOOR_LIMITED" in e) + + +def show(tag, rows, c, notes, seen): + a = [cmd(r)["a"] for r in rows] + b = [cmd(r)["b"] for r in rows] + print(f" {tag}: banked {[round(v, 1) for v in banked(c)]} W, mean A " + f"{sum(a) / len(a):.0f} W, B {sum(b) / len(b):.0f} W, reserve " + f"alarms {len(reserve_notes(notes))}, held " + f"{sorted({h for s in seen if s for h in s[1]})}") + + +# ============================================================================ # +print("1. maintenance charge at N=2: A under the reserve, B able") +LOW_A = {"a": 11.0, "b": 60.0} +CH = run(4500.0, "charge", LOW_A, cheap=True) +CH_OLD = without_fix(lambda: run(4500.0, "charge", LOW_A, cheap=True)) +show("house 4500", *CH) +show("house 4500, before the fix", *CH_OLD) +check("⭐ house 4500 W, B can carry the guard alone: no reserve alarm (no " + "notification, no PEAK_GUARD_FLOOR_LIMITED line) and every quarter on " + "the aim", + not reserve_notes(CH[2]) and limited_events(CH[1]) == 0 + and on_aim(CH[1])) +check("...A is held (it gives nothing for the peak) on every cycle the guard " + "asks for power, and the guard asks for a positive floor", + any(s and s[2] and s[2] > 0.0 for s in CH[3]) + and all(s[1] == (A,) and s[3] == {A} for s in CH[3] + if s and s[2] and s[2] > 0.0)) +check("non-vacuous: before the fix the same run raised the reserve alarm " + "(the SAFETY-84 rig test's check 2/3 false alarm)", + len(reserve_notes(CH_OLD[2])) >= 1 and limited_events(CH_OLD[1]) >= 1) +_b_carry = [r for r, s in zip(CH[0], CH[3]) + if s and s[3] == {A} and r[1] <= 2500.0 and r[1] > 0.0] +check("...and while B can carry the site target (at most its 2500 W) A is " + "not asked to give at all (weight 0 behind B)", + len(_b_carry) >= 3 and all(cmd(r)["a"] <= 0.0 for r in _b_carry)) + +CH65 = run(6500.0, "charge", LOW_A, cheap=True) +show("house 6500", *CH65) +_n65 = reserve_notes(CH65[2]) +check("⭐ house 6500 W, B (2500 W) cannot hold the 3450 W aim alone: the " + "reserve alarm fires and names A, the battery under its reserve, and " + "says the others cannot hold the limit alone", + len(_n65) >= 1 + and all("inverter-a is at its peak reserve" in x["message"] + and "inverter-b" not in x["message"] + and "cannot keep this 15-minute period under your 3 500 W peak " + "limit alone" in x["message"] for x in _n65)) +check("...and while it is limited the guard still asks B for everything B " + "has (floor 2500 W), so B is never left idle", + any(s and s[0] and s[2] == 2500.0 for s in CH65[3]) + and all(s[2] == 2500.0 for s in CH65[3] if s and s[0])) + +# ============================================================================ # +print("\n2. HOLD at N=2: A full and holding, B under the reserve") +LOW_B = {"a": 99.0, "b": 15.0} +HD = run(5500.0, HOLD, LOW_B) +HD_OLD = without_fix(lambda: run(5500.0, HOLD, LOW_B)) +show("house 5500", *HD) +show("house 5500, before the fix", *HD_OLD) +check("⭐ house 5500 W: A gives, B gives nothing, every quarter lands on the " + "aim, no reserve alarm, and A stays in its HOLD", + on_aim(HD[1]) and not reserve_notes(HD[2]) + and limited_events(HD[1]) == 0 + and all(cmd(r)["b"] == 0.0 for r in HD[0]) + and max(cmd(r)["a"] for r in HD[0]) > 2000.0 + and HD[1].maints[0].phase == HOLD) +check("non-vacuous: before the fix A sat at 0 W, B (at 15 %) gave through " + "the loop and the reserve alarm fired (SAFETY-86 rig case 7a)", + all(cmd(r)["a"] == 0.0 for r in HD_OLD[0]) + and max(cmd(r)["b"] for r in HD_OLD[0]) > 2000.0 + and len(reserve_notes(HD_OLD[2])) >= 1) + +HD65 = run(6500.0, HOLD, LOW_B) +HD65_OLD = without_fix(lambda: run(6500.0, HOLD, LOW_B)) +show("house 6500", *HD65) +show("house 6500, before the fix", *HD65_OLD) +_n = reserve_notes(HD65[2]) +check("⭐ house 6500 W, A alone (2500 W) cannot hold the aim: A gives all it " + "has, B still gives nothing, and the reserve alarm fires ONCE naming B", + all(cmd(r)["b"] == 0.0 for r in HD65[0]) + and max(cmd(r)["a"] for r in HD65[0]) == 2500.0 + and len(_n) == 1 and "inverter-b is at its peak reserve" in + _n[0]["message"] and "inverter-a" not in _n[0]["message"]) +check("...and the quarter is no worse than before the fix (4051 W in the " + "SAFETY-86 review), with B kept at its reserve", + max(banked(HD65[1])) <= max(banked(HD65_OLD[1])) + 1.0) + +BK = run(6000.0, HOLD, LOW_B, month_peak=4000.0) +show("house 6000, month peak 4000", *BK) +check("⭐ under a banked month peak of 4000 W the aim is 3950 W (peak - " + "margin): A gives, B nothing, every quarter on 3950 W, no alarm", + on_aim(BK[1], 3950.0) and not reserve_notes(BK[2]) + and all(cmd(r)["b"] == 0.0 for r in BK[0])) + +# ============================================================================ # +print("\n3. a real loss of peak limiting still alarms") +BOTH = run(5500.0, IDLE, {"a": 15.0, "b": 12.0}) +show("both under", *BOTH) +_nb = reserve_notes(BOTH[2]) +check("⭐ both batteries under the reserve: the reserve alarm fires and " + "names both, the guard asks for nothing (claim lo 0 W, 'SoC floor')", + len(_nb) >= 1 + and all("inverter-a and inverter-b" in x["message"] for x in _nb) + and all(not s[2] for s in BOTH[3] if s and s[0]) + and any(c[0] == "peak" and c[4] == 0.0 and c[6] == "SoC floor" + for r in BOTH[0] for c in r[-1][0])) + + +_ctl2 = BOTH[1] +for _inv, _s, _r in zip(_ctl2.inverters, (15.0, 60.0), (0, 1)): + _inv.soc, _inv.refused = _s, _r # SAFETY-73: B refuses to give +_ref = _ctl2._peak_on_able(_lim_all := peakguard.PeakDecision( + engaged=True, floor_w=0.0, ceiling_w=3500.0, + ceiling_from=peakguard.CEILING_CAP, projected_w=4000.0, excess_w=550.0, + wanted_w=2000.0, target_w=3450.0, floor_limited=True, soc=15.0, + soc_floor=20.0, soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR, + reason="PEAK_GUARD_FLOOR_LIMITED: x"), + [MaintResult(IDLE, False, None, False, [])] * 2) +check("⭐ A under the reserve and B refusing to give (SAFETY-73): nothing can " + "give for the peak, so it stays limited at 0 W (the alarm fires) and " + "only A is named (test_peakguard.py part 12h drives the notification)", + _ref.floor_limited and _ref.floor_w == 0.0 and _ref.held == (A,) + and _ref.able_w is None and _ctl2._peak_reserve() == frozenset()) +_ctl2.inverters[1].refused = 0 +_ctl2.live_units = lambda: (A,) # B dropped from the split +_drop = _ctl2._peak_on_able(_lim_all, [MaintResult(IDLE, False, None, False, + [])] * 2) +del _ctl2.live_units +check("...and the same with B dropped (not live): nothing can give, the " + "alarm state stands at 0 W, only A named", + _drop.floor_limited and _drop.floor_w == 0.0 and _drop.held == (A,) + and _drop.able_w is None) + + +async def _notify(ctl, socs, cutouts, refused): + """The shipped check_peak_floor_limited() on `ctl`, its notification.""" + sent = [] + + async def call(*a): + sent.append(a) + return True + for inv, so, cut, ref in zip(ctl.inverters, socs, cutouts, refused): + inv.soc, inv.bms_soc_floor, inv.refused = so, cut, ref + real, ctl.hass.call = ctl.hass.call, call + ctl.peak_guard = ctl._peak_on_able(dataclasses.replace( + _lim_all, soc=min(socs), soc_floor=peakguard.binding_soc_floor( + ctl.packs, 20.0)), [MaintResult(IDLE, False, None, False, [])] * 2) + ctl._peak_floor_limited = False + try: + await ctl.check_peak_floor_limited() + finally: + ctl.hass.call = real + for inv in ctl.inverters: + inv.bms_soc_floor, inv.refused = 10, 0 + return [a[2]["message"] for a in sent + if a[2].get("notification_id") == messages.ID_PEAK_RESERVE] + + +# Cut-outs A 10 % / B 35 % (simulated; rule 8 refuses it, sim/inject.py does +# not run rule 8), A at 25 % refusing, B at 30 %. Only B is at its own floor; +# A is above its own but under the site's highest (35 %). +_msg = asyncio.run(_notify(_ctl2, (25.0, 30.0), (10, 35), (1, 0))) +print(f" differing cut-outs: {_msg}") +check("⭐ the alarm names the battery at ITS OWN floor (B, 30 % <= 35 %), not " + "every battery under the site's highest floor (A at 25 % is above its " + "own 20 %)", + len(_msg) == 1 and "inverter-b is at its peak reserve" in _msg[0] + and "inverter-a" not in _msg[0]) + + +# ============================================================================ # +print("\n4. the reserve alarm at the quarter's edge") +def spiky(t): + """A 5000 W house (B alone can hold the aim) with a 5 s spike to + 11000 W every 150 s, which B alone cannot.""" + return 11000.0 if t % 150 < 5 else 5000.0 + + +LOW_A_IDLE = {"a": 15.0, "b": 60.0} +SP = run(spiky, IDLE, LOW_A_IDLE) +_flips = sum(1 for s in SP[3] if s and s[0]) +show("spiky house", *SP) +print(f" {_flips} cycles floor-limited (B alone short during a spike)") +check("⭐ a 5 s spike that B alone cannot cover makes the guard limited for a " + "cycle, and that does not fire the alarm: the miss must hold for " + "ALARM_HOLD_S, as for 'Load too high'. No quarter goes over the aim", + _flips >= 5 and not reserve_notes(SP[2]) + and not any(s[4] for s in SP[3] if s) + and len(banked(SP[1])) >= 2 and max(banked(SP[1])) <= AIM) + + +def ungated(fn): + real = Controller._reserve_short + Controller._reserve_short = lambda self, g: True + try: + return fn() + finally: + Controller._reserve_short = real + + +_ug = ungated(lambda: run(spiky, IDLE, LOW_A_IDLE)) +check("non-vacuous: without the hold the same run raises the alarm", + len(reserve_notes(_ug[2])) >= 1) + +# ============================================================================ # +print("\n5. place_on_able() at its boundaries") +_lim = peakguard.PeakDecision( + engaged=True, floor_w=0.0, ceiling_w=3500.0, + ceiling_from=peakguard.CEILING_CAP, projected_w=4000.0, excess_w=550.0, + wanted_w=2000.0, target_w=3450.0, floor_limited=True, soc=15.0, + soc_floor=20.0, soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR, + reason="PEAK_GUARD_FLOOR_LIMITED: x") + + +def place(d=_lim, held=("b",), able_w=2000.0, soc=60.0, floor=20.0): + return peakguard.place_on_able(d, held, able_w=able_w, soc=soc, + soc_floor=floor) + + +_eq, _short = place(), place(able_w=1999.0) +check("⭐ the others can give exactly the need (2000 W): not limited, floor " + "2000 W, the claim reads 'quarter peak' (the page says 'the peak " + "limit')", + not _eq.floor_limited and _eq.engaged and _eq.floor_w == 2000.0 + and _eq.held == ("b",) and _eq.able_w == 2000.0 + and peakguard.claim(_eq).lo == 2000.0 + and peakguard.claim(_eq).reason == "quarter peak" + and _eq.reason.startswith("PEAK_GUARD: ")) +check("...1 W short: limited, but the claim still asks them for all they " + "have (1999 W), not 0 W", + _short.floor_limited and _short.floor_w == 1999.0 + and peakguard.claim(_short).lo == 1999.0 + and peakguard.claim(_short).reason == "quarter peak" + and _short.reason.startswith("PEAK_GUARD_FLOOR_LIMITED: ")) +check("...the others' own permission is graded: at or under their floor, or " + "unread, nothing is asked (0 W, 'SoC floor')", + all(place(soc=s).floor_w == 0.0 and place(soc=s).floor_limited + and peakguard.claim(place(soc=s)).reason == "SoC floor" + for s in (20.0, 19.0, None))) +check("...the roster's verdict is kept as decide() echoed it (soc 15 %, " + "floor 20 %), and a decision that was not limited is only labelled", + _eq.soc == 15.0 and _eq.soc_floor == 20.0 + and all(place(d=d) == dataclasses.replace(d, held=("b",)) + for d in (dataclasses.replace( + _lim, floor_limited=False, engaged=False, + floor_w=None), + dataclasses.replace(_lim, wanted_w=None)))) +check("...and at N=1 the floor-limited claim is unchanged: 0 W, 'SoC floor'", + peakguard.claim(_lim).lo == 0.0 + and peakguard.claim(_lim).reason == "SoC floor") + +# ============================================================================ # +print("\n6. the allocator: a held battery gives last") + + +def split(target, reserve, modes=(), floor=1500.0): + cl = [Claim.set("loop", 10, target), + Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=A), + Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=B), + Claim.limit("peak", P_PEAK, lo=min(target, floor))] + cl += [Claim.limit("mode", P_MODE, 0.0, 0.0, "off", actuator=u) + for u in modes] + r = resolve(cl, units=(A, B), live=(A, B), weights={A: 1.0, B: 3.0}, + reserve=reserve) + return {u: round(v) for u, v in r.shares.items()} + + +check("⭐ B held: A carries the site target first (B's weight 3.0 ignored) " + "and B only takes what A cannot: 1500 -> A 1500 / B 0, 4000 -> A 2500 " + "/ B 1500", + split(1500.0, {B}) == {A: 1500, B: 0} + and split(4000.0, {B}) == {A: 2500, B: 1500}) +check("...no one held: the weights decide, as before", + split(1500.0, set()) == {A: 375, B: 1125}) +check("...charging is not touched: a held battery may still charge first", + split(-2000.0, {B}) == split(-2000.0, set())) +check("⭐ A Off and B under the reserve, a 3000 W floor B cannot place: the " + "Off battery gives first (2500 W) and B only the last 500 W - not an " + "equal 1500/1500 split", + split(3000.0, {B}, modes=(A,), floor=3000.0) == {A: 2500, B: 500}) +check("...OPEN, recorded: when B alone CAN place the floor, OPT-11 keeps the " + "Off battery resting and B gives it (1500 W). See the DECISIONS entry", + split(1500.0, {B}, modes=(A,)) == {A: 0, B: 1500}) +check("...and with B Off and nobody held, Off still gives way to the other " + "battery, as before (OPT-11)", + split(1500.0, set(), modes=(B,)) == {A: 1500, B: 0}) + +# ============================================================================ # +print("\n7. a held battery draining for maintenance") +_ctl = HD[1] + + +def able_w(results, socs=(15.0, 60.0)): + for inv, s in zip(_ctl.inverters, socs): + inv.soc, inv.refused = s, 0 + d = _ctl._peak_on_able(dataclasses.replace( + _lim, wanted_w=6000.0), results) + return d.able_w, d.held, d.floor_w + + +_idle = [MaintResult(IDLE, False, None, False, [])] * 2 +_drain = [MaintResult(DRAIN, True, 2000.0, False, []), + MaintResult(IDLE, False, None, False, [])] +print(f" A at 15 % idle: {able_w(_idle)}; draining 2000 W: " + f"{able_w(_drain)}") +check("⭐ A under the reserve but draining 2000 W for its monthly cycle: " + "that drain still counts (able 4500 W = B 2500 + 2000), so B is not " + "asked to carry it; idle, only B's 2500 W", + able_w(_idle) == (2500.0, (A,), 2500.0) + and able_w(_drain) == (4500.0, (A,), 4500.0)) + +# ============================================================================ # +print("\n8. the page while a peak alarm stands and control goes on") +_rows65, _c65 = CH65[0], CH65[1] +_c65._peak_floor_limited = True +_c65.peak_guard = dataclasses.replace( + _lim, floor_w=2500.0, held=(A,), able_w=2500.0) +_pay = _c65.status_payload() +_txt = json.dumps(_pay, ensure_ascii=False) +_row = [r for r in _pay["checks"] if r.get("id") == "peak_guard"] +print(f" banner: {_pay['banner']}; row: {_row[0]['text'] if _row else '-'}") +check("⭐ the banner is the alarm's title without 'Not ready', and nothing " + "on the page says the inverters go to 0 W or that an alarm stopped " + "control", + _pay["banner"] == messages.T_PEAK_RESERVE and _pay["level"] == "bad" + and "Not ready" not in _txt and "until the alarm clears" not in _txt + and "An alarm stopped control" not in _txt) +check("...the row names A and says the others cannot hold the limit alone, " + "in three short sentences", + len(_row) == 1 and _row[0]["text"].startswith( + "inverter-a battery at its peak reserve. The others cannot hold " + "the 3 500 W peak limit alone.")) +_c65.auto = False +_stopped = _c65.status_payload() +_c65.auto = True +check("...a stopped controller still says so: the peak alarm does not take " + "the banner from it", _stopped["banner"] != messages.T_PEAK_RESERVE + and _stopped["level"] != "bad") + +# ============================================================================ # +print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7") +PINNED = { # tools/safety87_identity.py on b8ddfd7 + "n1_under": + "d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f", + "n1_crossing": + "97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198", + "n1_above": + "45726a285aa84f40542d56482652397c3c1e212ba4fe2009f6cc52f537ccad52", + "n1_under_banked": + "a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b", + "n1_charge_low": + "91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32", + "n1_hold": + "0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d", +} +_now = s87.digests() +for k in PINNED: + print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}") +check("⭐ every N=1 digest (claims, commands, the guard's decision and the " + "log) equals b8ddfd7's, under, crossing and above the reserve, in a " + "charge and a HOLD, and under a banked peak", + _now == PINNED) + +print() +if fails: + print(f"{len(fails)} FAILED: " + ", ".join(fails)) + sys.exit(1) +print("all SAFETY-87 checks passed") diff --git a/goodwe_controller/tools/safety87_identity.py b/goodwe_controller/tools/safety87_identity.py new file mode 100644 index 0000000..209c2a6 --- /dev/null +++ b/goodwe_controller/tools/safety87_identity.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""SAFETY-87: at one inverter, is the peak guard byte-identical to before? + + python3 tools/safety87_identity.py # prints one digest per scenario + +safety84_identity's harness (the REAL Controller on a P1 meter, every claim +and the guard's decision hashed) with the battery under, crossing and above +the peak reserve, the house over the aim, in a maintenance charge and HOLD, +and under a month peak banked at 4000 W. Uses only the API release/1.0 +b8ddfd7 has, so the same file runs on both trees; test_safety87.py holds this +tree to the digests printed there. +""" + +import os +import sys +from datetime import timedelta + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import opt11_identity as rig # noqa: E402 +import safety84_identity as s84 # noqa: E402 + +DUR = 1800.0 + + +def crossing(u, t): + """30 % falling 1 % a minute to 12 %: crosses the 20 % reserve.""" + return max(12.0, 30.0 - t / 60.0) + + +def store(phase=None): + """In `phase` from the first cycle, or idle with a cycle done yesterday.""" + s = rig._Store() + if phase is None: + s.data["last_completed"] = (rig.BASE - timedelta(days=1)).isoformat() + else: + s.data["phase"], s.data["phase_started"] = phase, rig.BASE.isoformat() + return s + + +SCENARIOS = { + "n1_under": dict(soc=lambda u, t: 15.0, load=lambda t: 5500.0), + "n1_crossing": dict(soc=crossing, load=lambda t: 5000.0), + "n1_above": dict(soc=lambda u, t: 60.0, load=lambda t: 5500.0), + "n1_under_banked": dict(soc=lambda u, t: 15.0, load=lambda t: 6000.0, + month_peak=4000.0), + "n1_charge_low": dict(soc=lambda u, t: 11.0, load=lambda t: 5000.0, + store="charge"), + "n1_hold": dict(soc=lambda u, t: 99.0, load=lambda t: 5500.0, + store="hold", cheap=False, + extra={"maintenance_hold_min": 600}), +} + + +def digests() -> dict: + out = {} + for k, v in SCENARIOS.items(): + v = dict(v) + st = store(v.pop("store", None)) + out[k] = s84.digest(s84.run(1, dur=DUR, store=st, **v)[0]) + return out + + +if __name__ == "__main__": + for k, v in digests().items(): + print(f"{k} {v}")