From 6872353e2e5296405665911415b5aeaa7df24b4c Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 2 Oct 2026 14:03:54 +0200 Subject: [PATCH 1/7] SAFETY-91: a held battery is drawn only after the others are full At N>=2 a battery at or under its own peak reserve now weighs 0 whenever the site gives (not only for the peak), a draining battery's pin becomes a floor at its drain rate so it rises before the held one gives, and a quarter's edge is graded on the next quarter when its first telegram is late. SAFETY-25 option A stays: the held battery is still drawn once the others are full. N=1 unchanged. Co-Authored-By: Claude Opus 5.5 --- .../DECISIONS/SAFETY-91-held-battery-last.md | 70 ++++ goodwe_controller/DOCS.md | 9 +- goodwe_controller/app/arbiter.py | 6 +- goodwe_controller/app/main.py | 55 ++- goodwe_controller/app/tariff.py | 16 + goodwe_controller/test_modes.py | 25 +- goodwe_controller/test_peakguard.py | 4 + goodwe_controller/test_safety87.py | 3 + goodwe_controller/test_safety91.py | 383 ++++++++++++++++++ goodwe_controller/test_watchdog.py | 5 +- 10 files changed, 564 insertions(+), 12 deletions(-) create mode 100644 goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md create mode 100644 goodwe_controller/test_safety91.py diff --git a/goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md b/goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md new file mode 100644 index 0000000..678d1da --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md @@ -0,0 +1,70 @@ +# SAFETY-91 - a held battery is drawn only after the others are full + +**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A +kept).** Follows SAFETY-87. Tests: `test_safety91.py`. + +**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under +its own peak reserve was held back (weight 0) only on cycles where the peak +guard asked for power. +- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was + near zero the loop set the target, A had its normal weight and B's drain + pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 -> + 10.7 %) while B stayed at 1000 W. In the next quarter A reached its + cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W. +- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block + with no seconds left and asked for nothing for one cycle. The HOLD's + ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W + for 1 s. + +**The fix** (`main.py`, N>=2 only). +- `_held_back()`: the batteries at or under their own reserve while another + can still give (live, not refusing), graded as in `_peak_on_able()`. They + weigh 0 in every split where the site gives, not only for the peak. With + nobody able (all held, or the rest refusing or dropped) nobody is held back + and the split is as before. +- `_drain_rise()`: while a battery is held back, a draining battery's pin + becomes a floor at its drain rate (`lo`), so it rises towards its rating + before the held one gives. Not for a held battery's own drain, not under a + site stop, not for a battery that is not live. +- `tariff.next_quarter()`: past a block's end, before the next block's first + telegram, the guard grades the next quarter, projected from the held + reading (the averager credits it there). Not when that reading is too old + to be credited (`max_hold_s`). + +**What stays.** SAFETY-25 option A: once every able or draining battery is +at full power, the loop still draws a held battery (`allocator._fill()`'s +last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W +after B is full. Nothing is placed on a battery at its cut-out: OPT-09's +pack limit closes it (`hi = 0`), and the drain rise lets B take the rest. + +**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s): + +| case | before | after | +|---|---|---| +| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 | +| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) | +| cycles A asked while B not full | 612 of 900 | 0 | +| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W | +| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 | +| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 | +| N=1, every scenario | | byte-identical | + +**Why N>=2 only.** At N=1 there is no other battery to give first. The edge +roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86 +rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical; +extending it is a one-line change for a later ticket. + +**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is +at its 20 % reserve, so B carries the house and A gives last (100 of 540 +cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the +test grades that. +Two fixtures changed with it: `test_safety87.py` part 10's "without the +raise" run also turns off `_drain_rise()` (else B rises anyway), and +`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give +their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle). + +**Not changed.** The peak guard's own rules (SAFETY-87). A drain with +nobody held stays a pin (SAFETY-78). An Off battery beside a held one +(SAFETY-87 Open). The first cycle after start, before any quarter is +measured, can still put the loop's first step on a held battery in a HOLD. +SAFETY-88, -89, -90, -92, -93, -94. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 2d0964b..4df2125 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -1640,7 +1640,7 @@ help - see "Why the tuning is what it is". | `maintenance_enabled` | false | Enable the monthly cycle | | `maintenance_interval_days` | 28 | Minimum gap between cycles | | `maintenance_start_hour` | 10 | Hour of day a due cycle begins | -| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower | +| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87), and at N≥2 so may the house while another battery is at its peak reserve (SAFETY-91). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower | | `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** | | `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys | | `maintenance_soc_target` | 99 | Charge target | @@ -1667,6 +1667,9 @@ others only add power when the house needs more; none charges while it drains (SAFETY-78). Below the drain rate the rest is exported, as with one battery. If the house would still go over the peak limit, the battery being emptied gives more than its drain rate, with one battery too (SAFETY-83). +A battery at its peak reserve gives last: the others give their full power +first, and the battery being emptied rises above its drain rate before it +(SAFETY-91). In a cheap window the battery being filled charges and the others only add charge; none gives while it charges. The charge is cut to what keeps the 15-minute peak at the limit minus `peak_margin_w` (SAFETY-84). @@ -1809,7 +1812,9 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct. It will discharge straight past this number and keep going until the pack's own BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the capacity-peak guard will not spend the pack for money below 20 %"*, and - **nothing more**. See below for what that costs on a real evening. + **nothing more**. See below for what that costs on a real evening. With two + or more batteries, one under it is used last: only once the others give + their full power (SAFETY-91). - **When it binds you are told**, once per episode, with notification `goodwe_peak_soc_floor` and a red status-page row reading *Battery at its peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will** diff --git a/goodwe_controller/app/arbiter.py b/goodwe_controller/app/arbiter.py index e9136b9..8511be6 100644 --- a/goodwe_controller/app/arbiter.py +++ b/goodwe_controller/app/arbiter.py @@ -737,9 +737,9 @@ 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. + # SAFETY-87/91: units under their peak reserve weigh 0 while the site + # gives, so the others carry the target first. 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() diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index a673fe3..e25cb41 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -91,7 +91,7 @@ from .price import required_spread as price_required_spread from .price import uncertainty_of as price_uncertainty_of from .price_history import PriceHistory from .static_tariff import resolve_tariff, static_status -from .tariff import PeakStore, PeakTracker +from .tariff import PeakStore, PeakTracker, next_quarter from .store import migrate_maint_keys, migrate_unit_keys from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse, validate_startup_config, validate_unit_ranges) @@ -2567,6 +2567,50 @@ class Controller: return frozenset() return frozenset(g.held) + def _held_back(self) -> frozenset: + """SAFETY-91, N>=2: the batteries at or under their own peak reserve + while another can still give (live, not refusing). They weigh 0 in + every split where the site gives, so they give last (SAFETY-25 + option A: still drawn once the others are full).""" + if len(self.inverters) < 2: + return frozenset() + floor = float(self.o.get( + "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) + ok, held = peakguard.reserve_split(self.packs, floor) + live = set(self.live_units()) + if not held or not any( + p in ok and inv.name in live and not (inv.refused or 0) > 0 + for p, inv in zip(self.packs, self.inverters)): + return frozenset() + return frozenset(held) + + def _drain_rise(self, claims, maint_results) -> None: + """SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a + draining battery's pin becomes a floor at its drain rate, so it rises + towards its rating before the held one gives. A site stop still + decides alone. DECISIONS/SAFETY-91-held-battery-last.md.""" + held = self._held_back() + if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_PEAK for c in claims): + return + live = set(self.live_units()) + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if res.phase != DRAIN or not res.owns_setpoint \ + or inv.name in held or u not in live: + continue + b = pre_resolve([c for c in claims if c.actuator == u]) + p = (None if b.contradiction or b.pinned_by != "maintenance" + else allocator.pin_w(b)) + if p is None or not p > 0.0: + continue + for i, c in enumerate(claims): + if c.source == "maintenance" and c.kind == SET \ + and c.actuator == u: + claims[i] = Claim.limit("maintenance", P_MAINTENANCE, + lo=p, reason=c.reason, + actuator=u) + def _peak_over_drain(self, claims, maint_results) -> None: """SAFETY-83: at N=1 a peak floor above the drain rate outranks it. @@ -4082,6 +4126,11 @@ class Controller: # gives for SAFETY-18 on a transport that cannot see `unchanged_s`. self._peak_proj = (None if not self.p1_enabled else self.peak.project(self.p1.averager, now)) + # SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter, + # so a HOLD's ceiling does not lapse for a cycle onto a held battery. + if self._peak_proj is not None and len(self.inverters) > 1: + self._peak_proj = next_quarter(self._peak_proj, + self.p1.averager.max_hold_s) self.peak_guard = None if not self.p1_enabled else peakguard.decide( self._peak_proj, # ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is @@ -4482,6 +4531,8 @@ class Controller: claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi, reason="maintenance hold", actuator=self.actuator_of("hold"))) + # --- SAFETY-91: a drain rises before a held battery gives ------------ + self._drain_rise(claims, maint_results) # --- SAFETY-84: a cheap-window charge is a ceiling over the site ---- charge = self._charge_ceiling(claims) if charge is not None: @@ -4680,7 +4731,7 @@ class Controller: while True: resolution = resolve(claims, units=self.units, live=_live, weights=self.pack_weights, tick=self.alloc_tick, - reserve=self._peak_reserve()) + reserve=self._held_back()) # 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 diff --git a/goodwe_controller/app/tariff.py b/goodwe_controller/app/tariff.py index f0463e0..9849302 100644 --- a/goodwe_controller/app/tariff.py +++ b/goodwe_controller/app/tariff.py @@ -822,6 +822,22 @@ class PeakTracker: held_w=averager.held_net_w) +def next_quarter(p: Projection, max_hold_s: float) -> Projection: + """SAFETY-91: `p` past its block's end, before the next block's first + telegram: the next quarter, projected from the held reading (the averager + credits it there). `p` itself otherwise, or when that reading is too old + to be credited. DECISIONS/SAFETY-91-held-battery-last.md.""" + if p.block_start is None or p.elapsed_s < QUARTER_S \ + or p.elapsed_s * (1.0 - p.covered_frac) > float(max_hold_s): + return p + held = max(p.held_w, 0.0) + return Projection(projected_w=held, elapsed_w=held, + elapsed_s=p.elapsed_s - QUARTER_S, covered_frac=1.0, + complete=True, + block_start=p.block_start + timedelta(seconds=QUARTER_S), + held_w=p.held_w) + + @dataclass(frozen=True) class _ClosedBlock: """A block reconstructed from a checkpoint. Shaped like `p1.QuarterBlock` so diff --git a/goodwe_controller/test_modes.py b/goodwe_controller/test_modes.py index 6e38ed3..ab9b229 100644 --- a/goodwe_controller/test_modes.py +++ b/goodwe_controller/test_modes.py @@ -129,6 +129,19 @@ finally: # cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1. SAFETY78_N2_MAINT = \ "1afcd46b1b987868b110d2b0f4a7555848c9aa4728a836d4a2ce2a2107268497" +# SAFETY-91 changes n2 and n2_off ON PURPOSE: from t=1600 battery A is at its +# 20 % reserve, so B carries the house first and A gives last. With +# `_held_back()` reverted to SAFETY-87's (held only for the peak) both are +# a5dddad's again, which the check below grades. +SAFETY91_N2 = \ + "9535c08f556d6d50bec8fb0872445173480c29b10df1a7c437ef73d6ddb2814d" +_real_hb = app_main.Controller._held_back +app_main.Controller._held_back = lambda self: self._peak_reserve() +try: + _n2_s87 = rig.digest(rig.run(**rig.SCENARIOS["n2"])[0]) +finally: + app_main.Controller._held_back = _real_hb +_S91 = ("n2", "n2_off", "n2_maint") for k in A5DDDAD: print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} " f"{_now[k][:16]}") @@ -136,10 +149,14 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every " "command, reason and allocation record over 540 cycles each is " "byte-identical to a5dddad (house steps, a solar surplus, a battery " "at its cut-out level, at N=2 a refusal with probes, both batteries " - "due for maintenance; n2_maint apart, below), and so is the arbiter " - "on 6000 claim lists", - {k: v for k, v in _now.items() if k != "n2_maint"} - == {k: v for k, v in A5DDDAD.items() if k != "n2_maint"}) + "due for maintenance; n2, n2_off and n2_maint apart, below), and so " + "is the arbiter on 6000 claim lists", + {k: v for k, v in _now.items() if k not in _S91} + == {k: v for k, v in A5DDDAD.items() if k not in _S91}) +check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives " + "last), and SAFETY-87's held rule gives a5dddad's back", + _now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"] + and _n2_s87 == A5DDDAD["n2"]) check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad " "refused on 392 of 540 cycles now runs (test_safety78.py), bounded by " "SAFETY-85", diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index ac02b5f..bc237db 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -3132,6 +3132,10 @@ check("...and between them the nineteen appends account for exactly the " # through this file's reading of main.py. It grades three of the eight appends. # The static pin above is what covers the other five. _b2, _restore_b2 = second_inverter(c_src) +# SAFETY-91: B states a cut-out, as every roster entry must (rule 8). Without +# one it is held back at its reserve and gives last, so A alone would carry +# this cycle's 500 W and the broadcast check below could not see a split. +_b2.bms_soc_floor = c_src.inverters[0].bms_soc_floor _n2_writes_before = len(c_src.hass.per_entity) try: _, _cl2 = cycle_claims(lambda: run_cycle(c_src)) diff --git a/goodwe_controller/test_safety87.py b/goodwe_controller/test_safety87.py index b354775..0f3309d 100644 --- a/goodwe_controller/test_safety87.py +++ b/goodwe_controller/test_safety87.py @@ -559,11 +559,14 @@ def _drain_run(): DR = _drain_run() _real_dor = Controller._drain_over_reserve +_real_rise = Controller._drain_rise # SAFETY-91 would raise B as well Controller._drain_over_reserve = lambda self, claims, results: None +Controller._drain_rise = lambda self, claims, results: None try: DR_OLD = _drain_run() finally: Controller._drain_over_reserve = _real_dor + Controller._drain_rise = _real_rise def _a_for_peak(rows): diff --git a/goodwe_controller/test_safety91.py b/goodwe_controller/test_safety91.py new file mode 100644 index 0000000..c8bff34 --- /dev/null +++ b/goodwe_controller/test_safety91.py @@ -0,0 +1,383 @@ +"""Runnable check for SAFETY-91: a held battery is drawn only after the others +are full. `python3 test_safety91.py` + +At N>=2 a battery at or under its peak reserve was held back only while the +peak guard asked for power. The self-consumption loop drew it at its full +rating while a draining battery stayed at its drain rate (SAFETY-87 rig test +F1), and at a quarter's edge in a HOLD the held battery gave 2500 W for one +cycle (F2). Now a held battery weighs 0 whenever the site gives, a draining +battery rises above its drain rate before it, and the edge is graded on the +next quarter. SAFETY-25 option A stands: once the others are full the loop +may still draw it. Drives the REAL Controller on a lagging battery (10 s, as +on the rig). DECISIONS/SAFETY-91-held-battery-last.md. + +1. B draining, A held, house over B's rating: B rises to full before A gives. +2. A held reaching its cut-out: nothing is placed on it, B gives its rating. +3. HOLD across a quarter edge: the held battery gives nothing at the edge. +4. Idle, A held: B carries the house first; both held or B refusing: as before. +5. The rules at their boundaries (claim lists, N=3, a site stop). +6. next_quarter() at its boundaries. +7. N=1 is untouched. + +Mutations (MUTATION.md, purged copies) are listed in the pull request. +""" + +import logging +import math +import os +import sys +import time +import types +from datetime import timedelta + +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 +from app import peakguard # noqa: E402 +from app.arbiter import (Claim, P_MAINTENANCE, P_SAFETY, # noqa: E402 + resolve) +from app.main import Controller # noqa: E402 +from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402 +from app.p1 import make_sample, split_signed # noqa: E402 +from app.store import unit_key # noqa: E402 +from app.tariff import QUARTER_S, Projection, next_quarter # noqa: E402 + +fails = [] +A, B = "inverter-a", "inverter-b" +FULL = 2500.0 +CUT = 10.0 # bms_soc_floor of both test batteries + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def lagging(fn, lag=10.0, tick=1.0): + """`fn` on plantsim's battery (800 W/s ramp, `lag` s first-order lag), a + 1 s meter tick, and a BMS that stops discharge at its cut-out.""" + real_p, real_t = rig._Hass.power, rig.TICK + + def power(self, u): + st = self.__dict__.setdefault("_plant", {}) + a, tgt, tl = st.get(u, (0.0, 0.0, self.t)) + for _ in range(int(round((self.t - tl) / 0.1))): + d = self.cmd[u] - tgt + tgt += math.copysign(min(abs(d), 80.0), d) + a += (tgt - a) * (1 - math.exp(-0.1 / lag)) + st[u] = (a, tgt, self.t) + return 0.0 if self.soc(u, self.t) <= CUT and a > 0 else float(round(a)) + rig._Hass.power, rig.TICK = power, tick + try: + return fn() + finally: + rig._Hass.power, rig.TICK = real_p, real_t + + +def store(phase_of, n=2): + """`phase_of`: {name: phase}; the others have just done their cycle.""" + s = rig._Store() + for u in (A, B)[:n]: + key = (lambda k: k) if n == 1 else (lambda k: unit_key(u, k)) + if u in phase_of: + s.data[key("phase")] = phase_of[u] + s.data[key("phase_started")] = rig.BASE.isoformat() + else: + s.data[key("last_completed")] = rig.BASE.isoformat() + return s + + +def run(house, phase_of, socs, dur, late=0.0, kwh=15.36, n=2): + """Rows and controller. Levels follow each battery's measured power; the + P1 telegram read at each cycle is `late` s old.""" + soc, last, levels = dict(socs), [0.0], {} + + def before(c, t): + for u in soc: + soc[u] = max(CUT, soc[u] - c.hass.power(u) * (t - last[0]) + / 3600.0 / (kwh * 10.0)) + last[0], levels[t] = t, dict(soc) + imp, exp = split_signed(c.hass.grid()) + c.p1.submit(make_sample("ha_signed", imp, exp, phases=1, + ingest_ts=rig.BASE + timedelta(seconds=t - late), + ingest_mono=time.monotonic())) + rows, c = lagging(lambda: s84.run( + n, load=house if callable(house) else (lambda t: house), cheap=False, + store=store(phase_of, n), soc=lambda u, t: soc[u], before=before, + dur=dur, extra={"maintenance_discharge_w": 1000, + "maintenance_hold_min": 600})) + c.levels = levels + return rows, c + + +def without_fix(fn): + """release/1.0 8c438ab: held only for the peak, a drain stays pinned, the + quarter's edge is not rolled over.""" + real = (Controller._held_back, Controller._drain_rise) + import app.main as m + real_nq = m.next_quarter + Controller._held_back = lambda self: self._peak_reserve() + Controller._drain_rise = lambda self, claims, results: None + m.next_quarter = lambda p, max_hold_s: p + try: + return fn() + finally: + Controller._held_back, Controller._drain_rise = real + m.next_quarter = real_nq + + +def cmd(r): + return dict(r[2]) + + +def seg(rows, lo, hi): + """Mean A and B commands over [lo, hi) s.""" + rs = [r for r in rows if lo <= r[0] < hi] + return (round(sum(cmd(r)["a"] for r in rs) / len(rs)), + round(sum(cmd(r)["b"] for r in rs) / len(rs))) + + +def a_before_b(rows, t0=0.0): + """Cycles where held A is asked to give while B is not at full power.""" + return sum(1 for r in rows if r[0] >= t0 + and cmd(r)["a"] > 0.0 and cmd(r)["b"] < FULL) + + +def unmet(rows, lo, hi): + return sum(1 for r in rows if lo <= r[0] < hi and r[7] and "UNMET" in r[7]) + + +# ============================================================================ # +print("1. B draining at 1000 W, A held at 15 %, house over B's rating") +STEPS = (800.0, 4000.0, 5500.0) + + +def steps(t): + return STEPS[min(int(t // 300), 2)] + + +DR = run(steps, {B: DRAIN}, {"a": 15.0, "b": 60.0}, 900.0) +DR_OLD = without_fix(lambda: run(steps, {B: DRAIN}, {"a": 15.0, "b": 60.0}, + 900.0)) +for tag, (rows, c) in (("after", DR), ("before", DR_OLD)): + print(f" {tag}: house 800 A/B {seg(rows, 60, 300)}, 4000 " + f"{seg(rows, 360, 600)}, 5500 {seg(rows, 660, 900)}; A asked while " + f"B not full: {a_before_b(rows)} cycles; UNMET " + f"{unmet(rows, 0, 900)}") +check("⭐ A (held) is never asked to give while B has room: every cycle that " + "puts power on A has B at its full 2500 W", + a_before_b(DR[0]) == 0 and DR[1].maints[1].phase == DRAIN) +check("...house 4000: B rises from its 1000 W drain rate to 2500 W and A " + "gives only the 1500 W rest (before: A 2500, B 1000)", + seg(DR[0], 360, 600)[1] >= 2480 and seg(DR[0], 360, 600)[0] <= 1520 + and seg(DR_OLD[0], 360, 600)[0] >= 2480 + and seg(DR_OLD[0], 360, 600)[1] <= 1020) +check("...house 800: B still drains at its 1000 W rate (the rest is " + "exported, as before) and A gives nothing", + all(cmd(r)["b"] >= 1000.0 and cmd(r)["a"] == 0.0 + for r in DR[0] if 60 <= r[0] < 300)) +check("...house 5500 (over both ratings): option A stands, A is drawn as a " + "last resort once B is full, and nothing is left UNMET", + seg(DR[0], 660, 900) == (2500, 2500) and unmet(DR[0], 0, 900) == 0) +check("non-vacuous: before the fix A was asked while B had room", + a_before_b(DR_OLD[0]) >= 100) + +# ============================================================================ # +print("\n2. the held battery reaches its cut-out (house 5500)") +CO = run(5500.0, {B: DRAIN}, {"a": 11.0, "b": 60.0}, 1200.0) +CO_OLD = without_fix(lambda: run(5500.0, {B: DRAIN}, {"a": 11.0, "b": 60.0}, + 1200.0)) + + +def after_cut(rows, c): + """Rows once A reads its cut-out (its level follows its power).""" + return [r for r in rows if c.levels[r[0]]["a"] <= CUT] + + +_ac, _ac_old = after_cut(*CO), after_cut(*CO_OLD) +print(f" after A's cut-out: B {seg(_ac, 0, 9e9)[1]} W over {len(_ac)} " + f"cycles (before: {seg(_ac_old, 0, 9e9)[1]} W); A soc " + f"{CO[1].inverters[0].soc:.2f} %") +check("⭐ at its cut-out nothing is placed on A (0 W on every cycle), B " + "gives its full 2500 W and nothing is left UNMET on the empty A", + CO[1].inverters[0].soc <= CUT and len(_ac) >= 100 + and all(cmd(r)["a"] == 0.0 and cmd(r)["b"] == FULL for r in _ac) + and unmet(CO[0], 600, 1200) == 0) +check("non-vacuous: before the fix B stayed pinned under its rating on most " + "of those cycles (the rig saw ~2422 W)", + len(_ac_old) >= 100 + and sum(1 for r in _ac_old if cmd(r)["b"] < FULL) > len(_ac_old) / 2) + +# ============================================================================ # +print("\n3. HOLD across a quarter edge (A full holding, B held at 15 %)") +EDGE = run(5500.0, {A: HOLD}, {"a": 99.0, "b": 15.0}, 1000.0, late=0.5) +EDGE_OLD = without_fix(lambda: run(5500.0, {A: HOLD}, {"a": 99.0, "b": 15.0}, + 1000.0, late=0.5)) + + +def at_edge(rows): + return [(r[0], cmd(r)["a"], cmd(r)["b"]) for r in rows + if QUARTER_S - 1 <= r[0] <= QUARTER_S + 1] + + +print(f" edge after {at_edge(EDGE[0])}, before {at_edge(EDGE_OLD[0])}") +check("⭐ the telegram at the edge comes late (0.5 s): B gives nothing on any " + "cycle (the first cycle, before any quarter is measured, aside), A " + "keeps giving and stays in its HOLD", + all(cmd(r)["b"] == 0.0 for r in EDGE[0] if r[0] > 0) + and all(a > 2000.0 for _t, a, _b in at_edge(EDGE[0])) + and EDGE[1].maints[0].phase == HOLD) +check("non-vacuous: before the fix B gave over 2000 W at the edge (F2)", + any(b > 2000.0 for _t, _a, b in at_edge(EDGE_OLD[0]))) + +# ============================================================================ # +print("\n4. idle, house 2000 W") +ID = run(2000.0, {}, {"a": 15.0, "b": 60.0}, 300.0) +ID_OLD = without_fix(lambda: run(2000.0, {}, {"a": 15.0, "b": 60.0}, 300.0)) +print(f" A held: after {seg(ID[0], 120, 300)}, before " + f"{seg(ID_OLD[0], 120, 300)}") +check("⭐ A held, B able: B carries the house alone; A is asked only on the " + "cycles where the lagging B is already at its full command", + a_before_b(ID[0]) == 0 and seg(ID[0], 120, 300)[0] == 0 + and seg(ID[0], 120, 300)[1] >= 1950) +check("non-vacuous: before the fix the weights gave A a share", + seg(ID_OLD[0], 120, 300)[0] > 50) +BOTH = run(2000.0, {}, {"a": 15.0, "b": 14.0}, 300.0) +BOTH_OLD = without_fix(lambda: run(2000.0, {}, {"a": 15.0, "b": 14.0}, 300.0)) +check("...both held: nothing to give first, the split is as before", + [r[:3] for r in BOTH[0]] == [r[:3] for r in BOTH_OLD[0]]) + + +def held_back(socs, refused=(0, 0), live=(A, B)): + c = ID[1] + for inv, s, r in zip(c.inverters, socs, refused): + inv.soc, inv.refused = s, r + c.live_units = lambda: live + try: + return c._held_back() + finally: + del c.live_units + for inv in c.inverters: + inv.refused = 0 + + +check("...held back only while another can give: A held beside B able; " + "nothing with B refusing to give, B dropped, or both under", + held_back((15.0, 60.0)) == {A} and held_back((15.0, 60.0), (0, 1)) + == frozenset() and held_back((15.0, 60.0), live=(A,)) == frozenset() + and held_back((15.0, 14.0)) == frozenset() + and held_back((15.0, 60.0), (0, -1)) == {A}) + +# ============================================================================ # +print("\n5. the rules at their boundaries") + + +def stub(names, held, live=None): + """A Controller stand-in for `_drain_rise()`, on SIMULATED units.""" + s = types.SimpleNamespace( + inverters=[types.SimpleNamespace(name=n) for n in names], + actuator_of=lambda src, inv=None: inv.name, + live_units=lambda: tuple(live or names), + _held_back=lambda: frozenset(held)) + return s + + +def rise(names, held, target, drains, extra=(), live=None): + """`_drain_rise()` then the split: (claims kinds, shares).""" + cl = [Claim.limit("supervised", P_SAFETY, -FULL, FULL, actuator=n) + for n in names] + cl += [Claim.set("maintenance", P_MAINTENANCE, w, DRAIN, actuator=n) + for n, w in drains.items()] + cl += list(extra) + res = [MaintResult(DRAIN, True, drains[n], False, []) if n in drains + else MaintResult(IDLE, False, None, False, []) for n in names] + Controller._drain_rise(stub(names, held, live), cl, res) + r = resolve(cl + [Claim.set("loop", 10, target)], units=tuple(names), + live=set(live or names), reserve=held) + kinds = {c.actuator: c.kind for c in cl if c.source == "maintenance"} + return kinds, {u: round(v) for u, v in r.shares.items()} + + +_n3 = rise(("x", "y", "z"), {"z"}, 1500.0, {"x": 1000.0}) +print(f" N=3, x draining 1000, y able, z held, 1500 W: {_n3[1]}") +check("⭐ N=3: the draining battery keeps its drain rate as a floor (x >= " + "1000 W) while sharing with the able one; the held one gives nothing", + _n3[0] == {"x": "limit"} and _n3[1]["x"] >= 1000 and _n3[1]["z"] == 0 + and sum(_n3[1].values()) == 1500) +_stop = rise((A, B), {A}, 3000.0, {B: 1000.0}, + (Claim.set("safety", P_SAFETY, 0.0, "stopped"),)) +check("⭐ a site stop decides alone: the drain stays a pin, nothing rises", + _stop[0] == {B: "set"} and _stop[1] == {A: 0, B: 0}) +_hd = rise((A, B), {A}, 5000.0, {A: 1000.0}) +check("...a held battery's own drain stays a pin (it is not raised for the " + "loop): A 1000 W, B 2500 W", + _hd[0] == {A: "set"} and _hd[1] == {A: 1000, B: 2500}) +_nh = rise((A, B), set(), 3000.0, {B: 1000.0}) +check("...with nothing held the drain stays a pin, as before (SAFETY-78)", + _nh[0] == {B: "set"} and _nh[1] == {A: 2000, B: 1000}) +_dead = rise((A, B), {A}, 3000.0, {B: 1000.0}, live=(A,)) +check("...a draining battery that is not live is not touched", + _dead[0] == {B: "set"}) + +# ============================================================================ # +print("\n6. next_quarter()") +T0 = rig.BASE + + +def proj(elapsed, covered=1.0, held=3000.0, start=T0): + return Projection(projected_w=2000.0, elapsed_w=1900.0, elapsed_s=elapsed, + covered_frac=covered, complete=covered >= 1.0, + block_start=start, held_w=held) + + +_in = proj(899.0) +_past = next_quarter(proj(900.4, covered=899.9 / 900.4), 30.0) +check("⭐ inside its block a projection is unchanged (the same object)", + next_quarter(_in, 30.0) is _in) +check("⭐ past its end with a fresh held reading: the next quarter, " + "projected from that reading", + _past.block_start == T0 + timedelta(seconds=QUARTER_S) + and abs(_past.elapsed_s - 0.4) < 1e-9 and _past.projected_w == 3000.0 + and _past.covered_frac == 1.0 + and peakguard.blindness(_past, 30.0, False) is None) +_old = proj(960.0, covered=880.0 / 960.0) +check("...a held reading too old to be credited, no block, or an export: " + "unchanged, or projected at 0 W offtake", + next_quarter(_old, 30.0) is _old + and next_quarter(proj(950.0, start=None), 30.0).block_start is None + and next_quarter(proj(900.5, held=-800.0), 30.0).projected_w == 0.0) + +# ============================================================================ # +print("\n7. N=1") +for _name, _args in (("HOLD, late telegram at the edge", + (5500.0, {A: HOLD}, {"a": 99.0}, 1000.0, 0.5)), + ("drain under the reserve, house 4000", + (4000.0, {A: DRAIN}, {"a": 15.0}, 600.0, 0.5))): + _new = run(*_args, n=1) + _old = without_fix(lambda: run(*_args, n=1)) + print(f" {_name}: A at the edge " + f"{[cmd(r)['a'] for r in _new[0] if 899 <= r[0] <= 901]}") + check(f"⭐ N=1, {_name}: every row (claims, commands, the guard) is " + "identical with and without the fix", + _new[1].maints[0].phase == _args[1][A] and len(_new[0]) > 500 + and _new[0] == _old[0]) +_one = types.SimpleNamespace(inverters=[object()]) +_cl = [Claim.set("maintenance", P_MAINTENANCE, 1000.0, DRAIN)] +check("⭐ at one inverter nothing is held back and no pin moves (the N=1 " + "identity digests in test_safety78/79/83/84/86/87 stay pinned)", + Controller._held_back(_one) == frozenset() + and Controller._drain_rise(types.SimpleNamespace( + inverters=[object()], _held_back=lambda: frozenset()), _cl, + [MaintResult(DRAIN, True, 1000.0, False, [])]) is None + and _cl[0].kind == "set") + +print() +if fails: + print(f"{len(fails)} FAILED: " + ", ".join(fails)) + sys.exit(1) +print("all SAFETY-91 checks passed") diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 7188da8..f186039 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -1631,7 +1631,10 @@ def _sign_site(hass, store=None, **over): _P(), stop=asyncio.Event()) b = Inverter("inverter-b", setpoint_entity="number.sp_b", soc_entity="sensor.soc_b", batt_entity="sensor.batt_b", - max_w=B_RATING_HYPOTHESIS, store=c.store) + max_w=B_RATING_HYPOTHESIS, store=c.store, + # SAFETY-91: a cut-out, as rule 8 requires; without one B is + # held back at its reserve and gets no share to reverse. + bms_soc_floor=c.inverters[0].bms_soc_floor) b.dev_min, b.dev_max = -B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS c.inverters.append(b) c.auto = True -- 2.54.0 From 4cd604afcbc690a23a075502a6301e854679c9a5 Mon Sep 17 00:00:00 2001 From: SAFETY-91 review Date: Fri, 2 Oct 2026 14:54:20 +0200 Subject: [PATCH 2/7] SAFETY-91 review: a held-back battery is released 1 point above its reserve A SoC reading flipping across the reserve swapped ~1000 W between the batteries on each flip (grid +-500 W with unequal lags). Also grades next_quarter()'s staleness boundary (29.5 / 30.5 s). Co-Authored-By: Claude Opus 5.5 --- .../DECISIONS/SAFETY-91-held-battery-last.md | 4 + goodwe_controller/app/main.py | 18 +++- goodwe_controller/test_safety91.py | 91 ++++++++++++++++--- 3 files changed, 98 insertions(+), 15 deletions(-) diff --git a/goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md b/goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md index 678d1da..0ee5826 100644 --- a/goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md +++ b/goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md @@ -22,6 +22,10 @@ guard asked for power. weigh 0 in every split where the site gives, not only for the peak. With nobody able (all held, or the rest refusing or dropped) nobody is held back and the split is as before. + Review: once held back, a battery stays so until its SoC reads more than 1 + point above its reserve. A reading flipping 20/21 % otherwise swapped + ~1000 W between the batteries on each flip (grid +-500 W with A 10 s and + B 3 s lag). The guard's own permission (SAFETY-87) is unchanged. - `_drain_rise()`: while a battery is held back, a draining battery's pin becomes a floor at its drain rate (`lo`), so it rises towards its rating before the held one gives. Not for a held battery's own drain, not under a diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index e25cb41..ecad7fc 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -992,6 +992,8 @@ class Controller: # SAFETY-78: names of draining packs that cannot give power now, so # the "waits" line is logged once, not every cycle. self._drain_waits = set() + # SAFETY-91: last cycle's held-back batteries (`_held_back()`). + self._held_was = frozenset() # Set by amain() once the transport is built, so the status page can show # what only the transport knows (homewizard_local's unchanged_s). Stays # None when P1 is off, or under a transport that has no such counter. @@ -2571,18 +2573,26 @@ class Controller: """SAFETY-91, N>=2: the batteries at or under their own peak reserve while another can still give (live, not refusing). They weigh 0 in every split where the site gives, so they give last (SAFETY-25 - option A: still drawn once the others are full).""" + option A: still drawn once the others are full). One held back + stays so until its SoC reads more than 1 point above its reserve, so + a reading flipping across it does not swap the split each cycle.""" if len(self.inverters) < 2: return frozenset() floor = float(self.o.get( "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) ok, held = peakguard.reserve_split(self.packs, floor) + held = set(held) | { + p.name for p in ok if p.name in self._held_was + and float(p.soc) <= peakguard.pack_floor_pct(p, floor) + 1.0} live = set(self.live_units()) if not held or not any( - p in ok and inv.name in live and not (inv.refused or 0) > 0 + p.name not in held and inv.name in live + and not (inv.refused or 0) > 0 for p, inv in zip(self.packs, self.inverters)): - return frozenset() - return frozenset(held) + self._held_was = frozenset() + else: + self._held_was = frozenset(held) + return self._held_was def _drain_rise(self, claims, maint_results) -> None: """SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a diff --git a/goodwe_controller/test_safety91.py b/goodwe_controller/test_safety91.py index c8bff34..cd7ce12 100644 --- a/goodwe_controller/test_safety91.py +++ b/goodwe_controller/test_safety91.py @@ -14,7 +14,8 @@ on the rig). DECISIONS/SAFETY-91-held-battery-last.md. 1. B draining, A held, house over B's rating: B rises to full before A gives. 2. A held reaching its cut-out: nothing is placed on it, B gives its rating. 3. HOLD across a quarter edge: the held battery gives nothing at the edge. -4. Idle, A held: B carries the house first; both held or B refusing: as before. +4. Idle, A held: B carries the house first; both held or B refusing: as before; + a SoC reading flipping across the reserve does not swap the split. 5. The rules at their boundaries (claim lists, N=3, a site stop). 6. next_quarter() at its boundaries. 7. N=1 is untouched. @@ -58,9 +59,10 @@ def check(name, cond): fails.append(name) -def lagging(fn, lag=10.0, tick=1.0): - """`fn` on plantsim's battery (800 W/s ramp, `lag` s first-order lag), a - 1 s meter tick, and a BMS that stops discharge at its cut-out.""" +def lagging(fn, lag=10.0, tick=1.0, lags=None): + """`fn` on plantsim's battery (800 W/s ramp, `lag` s first-order lag, or + `lags[u]`), a 1 s meter tick, and a BMS that stops discharge at its + cut-out.""" real_p, real_t = rig._Hass.power, rig.TICK def power(self, u): @@ -69,7 +71,7 @@ def lagging(fn, lag=10.0, tick=1.0): for _ in range(int(round((self.t - tl) / 0.1))): d = self.cmd[u] - tgt tgt += math.copysign(min(abs(d), 80.0), d) - a += (tgt - a) * (1 - math.exp(-0.1 / lag)) + a += (tgt - a) * (1 - math.exp(-0.1 / (lags or {}).get(u, lag))) st[u] = (a, tgt, self.t) return 0.0 if self.soc(u, self.t) <= CUT and a > 0 else float(round(a)) rig._Hass.power, rig.TICK = power, tick @@ -92,12 +94,16 @@ def store(phase_of, n=2): return s -def run(house, phase_of, socs, dur, late=0.0, kwh=15.36, n=2): +def run(house, phase_of, socs, dur, late=0.0, kwh=15.36, n=2, read=None, + lags=None): """Rows and controller. Levels follow each battery's measured power; the - P1 telegram read at each cycle is `late` s old.""" + P1 telegram read at each cycle is `late` s old. `read(u, t, level)` is + the SoC the controller reads (default the level).""" soc, last, levels = dict(socs), [0.0], {} + grid = [] def before(c, t): + grid.append((t, c.hass.grid())) for u in soc: soc[u] = max(CUT, soc[u] - c.hass.power(u) * (t - last[0]) / 3600.0 / (kwh * 10.0)) @@ -108,10 +114,12 @@ def run(house, phase_of, socs, dur, late=0.0, kwh=15.36, n=2): ingest_mono=time.monotonic())) rows, c = lagging(lambda: s84.run( n, load=house if callable(house) else (lambda t: house), cheap=False, - store=store(phase_of, n), soc=lambda u, t: soc[u], before=before, - dur=dur, extra={"maintenance_discharge_w": 1000, - "maintenance_hold_min": 600})) - c.levels = levels + store=store(phase_of, n), before=before, dur=dur, + soc=(lambda u, t: soc[u]) if read is None + else (lambda u, t: read(u, t, soc[u])), + extra={"maintenance_discharge_w": 1000, + "maintenance_hold_min": 600}), lags=lags) + c.levels, c.grid = levels, grid return rows, c @@ -273,6 +281,61 @@ check("...held back only while another can give: A held beside B able; " and held_back((15.0, 14.0)) == frozenset() and held_back((15.0, 60.0), (0, -1)) == {A}) +# A's SoC READING flips 20/21 % across its 20 % reserve every 10 s, and B +# follows its command faster (3 s) than A (10 s), as two different inverters. +def _flip(u, t, level): + return (20.0 + int(t // 10) % 2) if u == "a" else level + + +def _a_jumps(rows, t0): + rs = [r for r in rows if r[0] >= t0] + return sum(1 for p, q in zip(rs, rs[1:]) + if abs(cmd(p)["a"] - cmd(q)["a"]) > 300.0) + + +def _grid_rms(c, t0): + g = [w + 10.0 for t, w in c.grid if t >= t0] + return math.sqrt(sum(w * w for w in g) / len(g)) + + +_lags = {"a": 10.0, "b": 3.0} +FL = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 600.0, read=_flip, lags=_lags) +_real_was = Controller.__dict__["_held_back"] + + +def _no_hyst(self): + self._held_was = frozenset() + return _real_was(self) + + +Controller._held_back = _no_hyst +try: + FL_OLD = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 600.0, read=_flip, + lags=_lags) +finally: + Controller._held_back = _real_was +print(f" A's reading flipping 20/21 %: A jumps {_a_jumps(FL[0], 120)}, " + f"grid rms {_grid_rms(FL[1], 120):.0f} W (no hysteresis: " + f"{_a_jumps(FL_OLD[0], 120)}, {_grid_rms(FL_OLD[1], 120):.0f} W)") +check("⭐ a SoC reading flipping across the reserve does not swap the split: " + "A stays held back (0 W) and the grid stays near its target", + _a_jumps(FL[0], 120) == 0 and seg(FL[0], 120, 600)[0] == 0 + and _grid_rms(FL[1], 120) < 30.0) +check("non-vacuous: without the 1-point hysteresis the split swaps on " + "every flip and the grid swings", + _a_jumps(FL_OLD[0], 120) >= 20 and _grid_rms(FL_OLD[1], 120) > 100.0) +REL = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 300.0, + read=lambda u, t, lv: (20.0 if t < 100 else 21.0 if t < 200 + else 22.0) if u == "a" else lv) +check("...held back at 20 %, still at 21 %, released at 22 % (more than 1 " + "point above its reserve)", + seg(REL[0], 40, 100)[0] == 0 and seg(REL[0], 140, 200)[0] == 0 + and seg(REL[0], 240, 300)[0] > 500) +ID[1]._held_was = frozenset({A, B}) +check("...a battery kept held back by the hysteresis is not one that can " + "give: A at 20 % and B kept at 21 % hold nobody back", + held_back((20.0, 21.0)) == frozenset()) + # ============================================================================ # print("\n5. the rules at their boundaries") @@ -346,6 +409,12 @@ check("⭐ past its end with a fresh held reading: the next quarter, " and _past.covered_frac == 1.0 and peakguard.blindness(_past, 30.0, False) is None) _old = proj(960.0, covered=880.0 / 960.0) +_edge29 = proj(901.0, covered=871.5 / 901.0) # 29.5 s uncovered +_edge31 = proj(901.0, covered=870.5 / 901.0) # 30.5 s uncovered +check("...the held reading is credited up to max_hold_s and no further " + "(29.5 s uncovered: rolled; 30.5 s: unchanged)", + next_quarter(_edge29, 30.0) is not _edge29 + and next_quarter(_edge31, 30.0) is _edge31) check("...a held reading too old to be credited, no block, or an export: " "unchanged, or projected at 0 W offtake", next_quarter(_old, 30.0) is _old -- 2.54.0 From 05eb93f139d7a172942475b189227d5dd1612ae5 Mon Sep 17 00:00:00 2001 From: SAFETY-93 dev Date: Fri, 2 Oct 2026 22:38:42 +0200 Subject: [PATCH 3/7] SAFETY-93: the maintenance charge end hands back smoothly, with no grid charging at a high house At N>=2, when a battery's maintenance charge ended, its watts stayed in the loop's start point and landed on the other battery (B 0 -> -1600 W in one cycle, charging from the grid at a 6000 W house). On the cycle a charge ends, the loop now starts from what the others stood at plus that battery's new command. N=1 and all identity digests unchanged. Co-Authored-By: Claude Opus 5.5 --- .../DECISIONS/SAFETY-93-charge-end.md | 52 +++ goodwe_controller/DOCS.md | 2 + goodwe_controller/app/main.py | 33 +- goodwe_controller/test_safety93.py | 369 ++++++++++++++++++ 4 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 goodwe_controller/DECISIONS/SAFETY-93-charge-end.md create mode 100644 goodwe_controller/test_safety93.py diff --git a/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md b/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md new file mode 100644 index 0000000..67653df --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md @@ -0,0 +1,52 @@ +# SAFETY-93 - the maintenance charge end hands back smoothly + +**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`. + +**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when +A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged +from the grid with the house at 6000 W. The HOLD end was already smooth +(SAFETY-85). + +**Cause.** During the charge the site target holds A's charge (SAFETY-84's +`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the +loop starts its next step from last cycle's site target. The slew limit +moves the site total by 400 W (-2000 -> -1600), and the only battery left +to place it on is B. The same happens when the charge times out or the +battery is switched Off (A free, its -2000 W shared out). With the charge +suspended by the guard and A giving under the loop, B took A's share in +one step (675 W). + +**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a +battery leaves CHARGE, the loop's start point (`self.target`, and the +integrator with it) is what the other batteries stood at plus that +battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its +last discharge (a charge stops). The loop then moves from there one slew +step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's +pin, as before; it lowers the grid import. The end is kept until the loop +next runs, so a missed poll on that cycle (the input hold) does not lose it. + +Why a start-point reseed, and not a per-battery slew in the allocator: the +jump comes from one stale number at one edge, the kind of edge SAFETY-73 +(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed. +The change stays at that edge; nothing else at N>=2 moves. + +**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter): + +| case | before | after | +|---|---|---| +| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges | +| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges | +| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 | +| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W | +| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 | +| N=1, both charge ends above | | byte-identical | + +No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and +N=2) are byte-identical to f3e409c; none of their scenarios reaches a +charge end. + +**Not changed.** The peak guard's floor still moves the site in one step +when it engages (a house step, a quarter edge): the guard outranks the slew +(owner order). A HOLD pin still drops the holding battery to 0 W at once +and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping +above the aim) is untouched. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 4df2125..a5b659f 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -1677,6 +1677,8 @@ While a full battery rests after its charge (the hold), it gives just enough to keep the 15-minute peak at that aim if the house needs it; with two batteries the other one gives first. The hold still ends on its clock (SAFETY-86). +When a charge ends, the other batteries take over one slew step a cycle, +and none starts charging from the grid (SAFETY-93). #### Capacity-tariff exposure of the grid-following loop diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index ecad7fc..5f44eca 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -75,8 +75,8 @@ from .hass import HomeAssistant from .inverter import (LINK_STALE_S, UNIT_A, build_roster, roster_entries, roster_names) from .maintenance import ( - DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult, - arbitrate as maint_arbitrate, + CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance, + MaintResult, arbitrate as maint_arbitrate, ) from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON, MqttPublisher, announced_units, mode_configs, @@ -994,6 +994,10 @@ class Controller: self._drain_waits = set() # SAFETY-91: last cycle's held-back batteries (`_held_back()`). self._held_was = frozenset() + # SAFETY-93: the batteries in their maintenance charge last cycle, + # and those whose charge ended since the loop last ran. + self._charging_was = frozenset() + self._charge_ended = frozenset() # Set by amain() once the transport is built, so the status page can show # what only the transport knows (homewizard_local's unchanged_s). Stays # None when P1 is off, or under a transport that has no such counter. @@ -2368,6 +2372,24 @@ class Controller: self.target = want self.i_w = want - self.tuning.gain * (want - float(batt)) + def _reseed_charge_end(self, maint_results) -> None: + """SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop + starts from what the others stood at plus that battery's new command + (its HOLD pin, or its last discharge if it is free; a charge stops). + Else the charge's watts stay in the site target and land on the + others: B 0 -> -1600 W in one cycle. DECISIONS/SAFETY-93-charge-end.md. + """ + ended, self._charge_ended = self._charge_ended, frozenset() + if len(self.inverters) < 2 or not ended: + return + for inv, res in zip(self.inverters, maint_results): + if inv.name not in ended: + continue + now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint + else max(float(inv.target), 0.0)) + self.target += now_w - float(inv.target) + self.i_w = self.target + def _mode_fault_log(self, resolution) -> None: """OPT-11: say once, per battery, that its mode was dropped as a fault. @@ -3581,6 +3603,11 @@ class Controller: self.log_event(msg, hint=messages.hint("maintenance", phase=machine.phase)) maint_results.append(res) + # SAFETY-93: kept until the loop next runs (a missed poll holds). + _charging = frozenset(inv.name for inv, r in zip( + self.inverters, maint_results) if r.phase == CHARGE) + self._charge_ended |= self._charging_was - _charging + self._charging_was = _charging # ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The # deferral is edge-logged (it lasts as long as the other pack's cycle), # so by the time an operator asks "why has pack B not recalibrated" the @@ -4582,6 +4609,8 @@ class Controller: # is what seeds it, and dropping it there restarts the ~70 s cycle. if self.loop_w is not None and self.target != self.loop_w: self._refusal_end("another writer") + # SAFETY-93: a charge that ended leaves the loop's start point. + self._reseed_charge_end(maint_results) probe = self._refusal_probe() if len(self.inverters) > 1: probe = any(_probing.values()) diff --git a/goodwe_controller/test_safety93.py b/goodwe_controller/test_safety93.py new file mode 100644 index 0000000..16f479e --- /dev/null +++ b/goodwe_controller/test_safety93.py @@ -0,0 +1,369 @@ +"""Runnable check for SAFETY-93: the maintenance charge end hands back +smoothly, with no grid charging at a high house. `python3 test_safety93.py` + +At N=2, when battery A's maintenance charge ended, its watts stayed in the +loop's site target and landed on B: B went 0 -> -1600 W in one cycle and +charged from the grid with the house at 6000 W (SAFETY-85 rig test, check 4). +Now the loop starts from what B stood at plus A's new command. Drives the +REAL Controller on a lagging battery (10 s, 2 s meter, as on the rig). +DECISIONS/SAFETY-93-charge-end.md. + +1. The rig case: A charges 2000 W late in a quarter with the house at 6000 W, + then its charge ends (HOLD). +2. The same at a low house. +3. The charge ends another way: a timeout (abort) and the battery switched + Off. +4. The charge ends while the guard has suspended it (A gives under the loop). +5. N=1 is untouched; a missed poll on the end cycle; the rule at its + boundaries. + +Mutations (MUTATION.md, purged copies) are listed in the pull request. +""" + +import hashlib +import logging +import math +import os +import sys +from datetime import timedelta + +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 safety79_identity as s79 # noqa: E402 +import safety84_identity as s84 # noqa: E402 +from app import packmode # noqa: E402 +from app.main import Controller # noqa: E402 +from app.maintenance import (CHARGE_TIMEOUT_MIN, HOLD, IDLE, # noqa: E402 + MaintResult) +from app.store import unit_key # noqa: E402 +from named_absence import first, present # noqa: E402 + +fails = [] +A = "inverter-a" +SLEW = 400.0 # the rig's slew_w +AIM = 3500 - 50 # peak_cap_w - peak_margin_w +OPT0 = dict(s84.OPT) + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def lagging(fn, lag=10.0): + """plantsim's battery (800 W/s ramp, `lag` s first-order lag) at the + live 2 s meter tick (test_safety85.py part 7).""" + real_p, real_t = rig._Hass.power, rig.TICK + + def power(self, u): + st = self.__dict__.setdefault("_plant", {}) + a, tgt, tl = st.get(u, (0.0, 0.0, self.t)) + for _ in range(int(round((self.t - tl) / 0.1))): + d = self.cmd[u] - tgt + tgt += math.copysign(min(abs(d), 80.0), d) + a += (tgt - a) * (1 - math.exp(-0.1 / lag)) + st[u] = (a, tgt, self.t) + return float(round(a)) + rig._Hass.power, rig.TICK = power, 2.0 + try: + return fn() + finally: + rig._Hass.power, rig.TICK = real_p, real_t + + +def run(n, house, end, *, step=None, dur=None, store=None, extra=None, + before=None): + """A charging in a cheap window from the first cycle (quarters start at + 0, 900, 1800 s); the house is 1200 W, then `house` from `step`; A reads + 99 % (its charge target) from `end`. Returns (rows, controller).""" + step = end if step is None else step + s84.OPT = dict(OPT0, peak_cap_w=3500) + feed = s84.feed(True) + + def both(c, t): + if before is not None: + before(c, t) + feed(c, t) + try: + return lagging(lambda: s84.run( + n, load=lambda t: house if t >= step else 1200.0, cheap=True, + store=store or s79.store_charging(n), + soc=lambda u, t: (99.0 if t >= end else 90.0) if u == "a" + else 60.0, dur=dur or end + 300.0, before=both, + extra=dict({"maintenance_hold_min": 600}, **(extra or {})))) + finally: + s84.OPT = OPT0 + + +def cmd(r): + return dict(r[2]) + + +def moves(rows, u, lo, hi): + """The largest change in battery `u`'s command between cycles in + (lo, hi].""" + return max(abs(cmd(b)[u] - cmd(a)[u]) for a, b in zip(rows, rows[1:]) + if lo < b[0] <= hi) + + +def charging_hi(rows, lo, hi, house_at): + """Cycles in [lo, hi] where a battery charges while the house is above + the peak aim.""" + return sum(1 for r in rows if lo <= r[0] <= hi and house_at(r[0]) > AIM + and min(cmd(r).values()) < 0.0) + + +# None on a tree without the fix (f3e409c), where this file runs red. +no_fix = getattr(Controller, "_reseed_charge_end", None) + + +def without(fn): + """Run `fn` with the SAFETY-93 reseed taken out.""" + if no_fix is None: + return fn() + Controller._reseed_charge_end = lambda self, ended: None + try: + return fn() + finally: + Controller._reseed_charge_end = no_fix + + +def phase_at(rows, t): + return first((r[-1][3][0] for r in rows if r[0] == t), f"row {t}") + + +def at(rows, t, u): + """Battery `u`'s command at `t` s.""" + r = first((r for r in rows if r[0] == t), f"row {t}") + return dict(r[2])[u] if present(r) else r + + +# ============================================================================ # +print("1. the rig case: A charges 2000 W late in a quarter, house 6000 W") +# House 1200 W until 1760 s, then 6000 W; the quarter (900-1800 s) has room, +# so the charge stays at 2000 W. A is full at 1780 s. Graded to 1798 s: the +# guard grades a new quarter from 1800 s. +R1 = run(2, 6000.0, 1780.0, step=1760.0) +R1_old = without(lambda: run(2, 6000.0, 1780.0, step=1760.0)) + + +def hi1(t): + return 6000.0 if t >= 1760.0 else 1200.0 + + +for name, (rows, _c) in (("now", R1), ("without the fix", R1_old)): + print(f" {name}: " + ", ".join( + f"{r[0]:.0f}s A {cmd(r)['a']:.0f} B {cmd(r)['b']:.0f}" + for r in rows if 1776 <= r[0] <= 1786)) +_r, _ = R1 +check("the case is the rig's: A charges 2000 W with the house at 6000 W the " + "cycle before its charge ends, then holds", + at(_r, 1778.0, "a") == -2000.0 + and phase_at(_r, 1778.0) == "charge" and phase_at(_r, 1780.0) == HOLD) +check("⭐ no battery charges from the grid after the charge ends (house above " + "the aim)", charging_hi(_r, 1780, 1798, hi1) == 0) +check("⭐ B moves at most one slew step a cycle across the charge end, and A " + "after its HOLD pin", moves(_r, "b", 1776, 1798) <= SLEW + and moves(_r, "a", 1780, 1798) <= SLEW) +check("...and B gives toward the house at once (+400 W at the end cycle)", + at(_r, 1780.0, "b") == SLEW) +check("non-vacuous: without the fix B jumps 0 -> -1600 W and charges from " + "the grid for 4 cycles", moves(R1_old[0], "b", 1776, 1798) >= 1600 + and charging_hi(R1_old[0], 1780, 1798, hi1) >= 4) + +# ============================================================================ # +print("\n2. the same at a low house") +R2 = run(2, 1200.0, 1300.0) +R2_old = without(lambda: run(2, 1200.0, 1300.0)) +print(f" B's largest move: {moves(R2[0], 'b', 1296, 1600):.0f} W, without " + f"the fix {moves(R2_old[0], 'b', 1296, 1600):.0f} W; B's least " + f"command after the end " + f"{min(cmd(r)['b'] for r in R2[0] if r[0] >= 1300):.0f}" + f" W, without {min(cmd(r)['b'] for r in R2_old[0] if r[0] >= 1300):.0f}" + " W") +check("⭐ house 1200 W: B moves at most one slew step a cycle and never " + "charges from the grid after A's charge ends", + moves(R2[0], "b", 1296, 1600) <= SLEW + and min(cmd(r)["b"] for r in R2[0] if r[0] >= 1300) >= 0.0) +check("non-vacuous: without the fix B jumps to -1600 W (2800 W from the grid)", + min(cmd(r)["b"] for r in R2_old[0] if r[0] >= 1300) <= -1600.0) + +# ============================================================================ # +print("\n3. the charge ends another way") + + +def timed_out(end): + """A store whose charge passes CHARGE_TIMEOUT_MIN at `end` s.""" + s = s79.store_charging(2) + s.data[unit_key(A, "phase_started")] = ( + rig.BASE + timedelta(seconds=end - CHARGE_TIMEOUT_MIN * 60 - 1) + ).isoformat() + return s + + +def b_done(): + """A charging; B finished its monthly cycle, so it starts none here.""" + s = s79.store_charging(2) + s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat() + return s + + +def switch_off(at): + """Battery A switched Off at `at` s (OPT-11 modes on).""" + def before(c, t): + if t >= at: + c.store.data[unit_key(A, packmode.USE_KEY)] = False + return before + + +ENDS = { + "timeout": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0, + store=timed_out(1780.0)), + "Off": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0, + store=b_done(), before=switch_off(1780.0), + extra={"battery_modes_enabled": True}), +} +for k, fn in ENDS.items(): + rows, c = fn() + old = without(fn)[0] + print(f" {k}: A {[cmd(r)['a'] for r in rows if 1776 <= r[0] <= 1784]}" + f" B {[cmd(r)['b'] for r in rows if 1776 <= r[0] <= 1784]}; " + f"without the fix B " + f"{[cmd(r)['b'] for r in old if 1776 <= r[0] <= 1784]}") + check(f"{k}: the charge ends at 1780 s", + phase_at(rows, 1778.0) == "charge" + and phase_at(rows, 1780.0) == IDLE) + check(f"⭐ {k}: no battery charges from the grid after it, and B moves at " + f"most one slew step a cycle", + charging_hi(rows, 1780, 1798, hi1) == 0 + and moves(rows, "b", 1776, 1798) <= SLEW) + check(f"non-vacuous ({k}): without the fix B jumps to -1600 W, from " + f"the grid", at(old, 1780.0, "b") == -1600.0 + and charging_hi(old, 1780, 1798, hi1) >= 1) + +# ============================================================================ # +print("\n4. the charge ends while the guard has suspended it") +# House 6000 W from 1500 s: the guard suspends the shaping, the loop has both +# batteries give. A is full at 1530 s; its HOLD pin takes it to 0 W. +R4 = run(2, 6000.0, 1530.0, step=1500.0) +R4_old = without(lambda: run(2, 6000.0, 1530.0, step=1500.0)) +_a4 = at(R4[0], 1528.0, "a") +print(f" A gave {_a4:.0f} W before its HOLD; B's largest move " + f"{moves(R4[0], 'b', 1526, 1798):.0f} W, without the fix " + f"{moves(R4_old[0], 'b', 1526, 1798):.0f} W") +check("A gives under the loop before its charge ends", + present(_a4) and _a4 > SLEW and R4[1].maints[0].phase == HOLD) +check("⭐ B takes A's share at one slew step a cycle (A's own give stops " + "with its HOLD pin, as before)", moves(R4[0], "b", 1526, 1798) <= SLEW + and charging_hi(R4[0], 1530, 1798, lambda t: 6000.0) == 0) +check("non-vacuous: without the fix B jumps more than a slew step", + moves(R4_old[0], "b", 1526, 1798) > SLEW) + +# ============================================================================ # +print("\n5. N=1 is untouched; a missed poll; the rule at its boundaries") + + +def digest(rows): + return hashlib.sha256(repr(rows).encode()).hexdigest() + + +_n1 = [digest(run(1, h, e, step=s)[0]) for h, e, s in + ((6000.0, 1780.0, 1760.0), (1200.0, 1300.0, None))] +_n1_old = [digest(without(lambda: run(1, h, e, step=s))[0]) for h, e, s in + ((6000.0, 1780.0, 1760.0), (1200.0, 1300.0, None))] +check("⭐ N=1, the charge ending at a high and a low house: every row " + "byte-identical with and without the reseed", _n1 == _n1_old) + +if no_fix is None: + print(f"\n{len(fails)} FAILED (no _reseed_charge_end): " + ", ".join(fails)) + sys.exit(1) + + +def missed_poll(at_t): + """Battery B's power reading is missing on the cycle at `at_t` s.""" + def before(c, t): + if not getattr(c.hass, "_s93", False): + read = c.hass.number + + async def number(e, invert=False): + if e == "sensor.batt_b" and c.hass.t == at_t: + return None + return await read(e, invert) + c.hass.number, c.hass._s93 = number, True + return before + + +R6 = run(2, 6000.0, 1780.0, step=1760.0, before=missed_poll(1780.0)) +R6_old = without(lambda: run(2, 6000.0, 1780.0, step=1760.0, + before=missed_poll(1780.0))) +print(f" a missed poll on the end cycle: B " + f"{[cmd(r)['b'] for r in R6[0] if 1778 <= r[0] <= 1786]}, without " + f"the fix {[cmd(r)['b'] for r in R6_old[0] if 1778 <= r[0] <= 1786]}") +check("a missed poll on the cycle the charge ends: the loop holds that cycle", + phase_at(R6[0], 1780.0) == HOLD + and "inputs missing" in str(first(r[6] for r in R6[0] if r[0] == 1780.0))) +check("⭐ ...and the end still reaches the loop on the next cycle: no grid " + "charge (A's charge stands through the held cycle), one slew step a " + "cycle", charging_hi(R6[0], 1782, 1798, hi1) == 0 + and moves(R6[0], "b", 1776, 1798) <= SLEW) +check("non-vacuous: without the fix B charges from the grid", + charging_hi(R6_old[0], 1782, 1798, hi1) >= 1) +_c = R1[1] + + +def reseeded(target, shares, res, ended=frozenset({A})): + """`_reseed_charge_end` on the rig's controller, A's charge ended and + its result `res`: (target, i_w).""" + _c.target, _c.i_w, _c._charge_ended = target, 123.0, ended + for inv in _c.inverters: + inv.target = shares[inv.name[-1]] + _c._reseed_charge_end([res, _free]) + return _c.target, _c.i_w + + +_pin = MaintResult(HOLD, True, 0.0, False, []) +_free = MaintResult(IDLE, False, None, False, []) +check("a HOLD pin: the start is B's command plus the pin, integrator with it", + reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _pin) == (0.0, 0.0) + and reseeded(2770.0, {"a": 1385.0, "b": 1385.0}, _pin) + == (1385.0, 1385.0) and _c._charge_ended == frozenset()) +check("a free battery: its charge leaves the start, its discharge stays", + reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _free) == (0.0, 0.0) + and reseeded(2770.0, {"a": 1385.0, "b": 1385.0}, _free) + == (2770.0, 2770.0)) +check("no charge ended: nothing moves (the integrator either)", + reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _pin, frozenset()) + == (-2000.0, 123.0)) +_n1c = run(1, 6000.0, 1780.0, step=1760.0)[1] +_n1c.target, _n1c.i_w = -2000.0, 5.0 +_n1c.inverters[0].target, _n1c._charge_ended = -2000.0, frozenset({A}) +_n1c._reseed_charge_end([_pin]) +check("N=1: nothing moves", (_n1c.target, _n1c.i_w) == (-2000.0, 5.0)) +_seen = [] + + +def _spy(self, maint_results): + _seen.append(sorted(self._charge_ended)) + return no_fix(self, maint_results) + + +Controller._reseed_charge_end = _spy +try: + run(2, 6000.0, 1780.0, step=1760.0, dur=2400.0, + extra={"maintenance_hold_min": 5}) +finally: + Controller._reseed_charge_end = no_fix +check("it fires once, at the charge end: not at the HOLD end or on any " + "other cycle", [x for x in _seen if x] == [[A]] + and len(_seen) > 300) + +print() +if fails: + print(f"{len(fails)} FAILED: " + ", ".join(fails)) + sys.exit(1) +print("all SAFETY-93 checks passed") -- 2.54.0 From 214051d1a80e3f17b29c4bb02cb77ba008ecb614 Mon Sep 17 00:00:00 2001 From: SAFETY-93 review Date: Sat, 3 Oct 2026 00:10:21 +0200 Subject: [PATCH 4/7] SAFETY-93 review: a charge that ends by a stale link or a refusal hands back smoothly too A battery still in CHARGE but dropped by the allocator (stale link, or a SAFETY-73 refusal contradicting its charge) left its -2000 W in the loop's start point, and B went 0 -> -1600 W in one cycle. The end is now noted after the unit claims are filed: in CHARGE, live and not contradicted. Tests: part 6 (stale link, refusal) and a spy on the guard suspension. Co-Authored-By: Claude Opus 5.5 --- .../DECISIONS/SAFETY-93-charge-end.md | 8 +++ goodwe_controller/app/main.py | 25 ++++++--- goodwe_controller/test_safety93.py | 53 +++++++++++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md b/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md index 67653df..83e65f4 100644 --- a/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md +++ b/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md @@ -25,6 +25,13 @@ step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's pin, as before; it lowers the grid import. The end is kept until the loop next runs, so a missed poll on that cycle (the input hold) does not lose it. +Review: a charge also ends, for the site, when the battery is still in +CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73) +drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a +1200 W house). So the end is noted after the unit claims are filed: a +battery leaves the charge when it leaves CHARGE, is not live, or its own +claims contradict. The guard suspending the charge (SAFETY-84) is no end. + Why a start-point reseed, and not a per-battery slew in the allocator: the jump comes from one stale number at one edge, the kind of edge SAFETY-73 (`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed. @@ -39,6 +46,7 @@ The change stays at that edge; nothing else at N>=2 moves. | charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 | | guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W | | missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 | +| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 | | N=1, both charge ends above | | byte-identical | No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 5f44eca..6aeb8fb 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -994,8 +994,8 @@ class Controller: self._drain_waits = set() # SAFETY-91: last cycle's held-back batteries (`_held_back()`). self._held_was = frozenset() - # SAFETY-93: the batteries in their maintenance charge last cycle, - # and those whose charge ended since the loop last ran. + # SAFETY-93: the batteries placed in their maintenance charge last + # cycle, and those whose charge ended since the loop last ran. self._charging_was = frozenset() self._charge_ended = frozenset() # Set by amain() once the transport is built, so the status page can show @@ -2372,6 +2372,21 @@ class Controller: self.target = want self.i_w = want - self.tuning.gain * (want - float(batt)) + def _charge_track(self, claims, maint_results) -> None: + """SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can + no longer be placed (stale link, refusal). Kept until the loop runs.""" + if len(self.inverters) < 2: + return + live = set(self.live_units()) + now = set() + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if res.phase == CHARGE and u in live and not pre_resolve( + [c for c in claims if c.actuator == u]).contradiction: + now.add(inv.name) + self._charge_ended |= self._charging_was - now + self._charging_was = frozenset(now) + def _reseed_charge_end(self, maint_results) -> None: """SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop starts from what the others stood at plus that battery's new command @@ -3603,11 +3618,6 @@ class Controller: self.log_event(msg, hint=messages.hint("maintenance", phase=machine.phase)) maint_results.append(res) - # SAFETY-93: kept until the loop next runs (a missed poll holds). - _charging = frozenset(inv.name for inv, r in zip( - self.inverters, maint_results) if r.phase == CHARGE) - self._charge_ended |= self._charging_was - _charging - self._charging_was = _charging # ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The # deferral is edge-logged (it lasts as long as the other pack's cycle), # so by the time an operator asks "why has pack B not recalibrated" the @@ -4583,6 +4593,7 @@ class Controller: if self.modes_on: mode_trade = self._mode_trade(claims, now) self._mode_trade_was = mode_trade + self._charge_track(claims, maint_results) if (self.auto or self.shadow) and self.inputs_ok: # ⚠️ prev is the ARBITER's last output, not the loop's own last # wish. If something outranked the loop, that is what the hardware diff --git a/goodwe_controller/test_safety93.py b/goodwe_controller/test_safety93.py index 16f479e..f9599dd 100644 --- a/goodwe_controller/test_safety93.py +++ b/goodwe_controller/test_safety93.py @@ -362,6 +362,59 @@ check("it fires once, at the charge end: not at the HOLD end or on any " "other cycle", [x for x in _seen if x] == [[A]] and len(_seen) > 300) +# ============================================================================ # +print("\n6. A still in CHARGE but no longer placed: stale link, refusal") + + +def link_drop(at): + """A's writes rejected from `at` s, so it is dropped as a stale link.""" + def before(c, t): + a = c.inverters[0] + if t >= at and a.rejected_since is None: + a.rejected_since = rig.BASE + timedelta(seconds=at) + return before + + +def refuses(at): + """A stops charging from `at` s (reads 0 W); SAFETY-73 drops it.""" + def before(c, t): + if not getattr(c.hass, "_s93r", False): + real = c.hass.power + + def power(u): + p = real(u) + return 0.0 if u == "a" and c.hass.t >= at and p < 0 else p + c.hass.power, c.hass._s93r = power, True + return before + + +for k, hook in (("stale link", link_drop), ("refusal", refuses)): + def fn(hook=hook): + return run(2, 1200.0, 9999.0, dur=1500.0, before=hook(1300.0)) + rows, _c6 = fn() + old = without(fn)[0] + drop = first((r[0] for r in rows if r[0] >= 1300 + and "dropped inverter-a" in str(r[7])), "A dropped") + print(f" {k}: A dropped at {drop:.0f} s; B " + f"{[cmd(r)['b'] for r in rows if drop - 2 <= r[0] <= drop + 4]}, " + f"without the fix {[cmd(r)['b'] for r in old if drop - 2 <= r[0] <= drop + 4]}") + check(f"⭐ {k}: A is dropped while still in CHARGE, and B never charges " + f"from the grid and moves at most one slew step a cycle", + phase_at(rows, drop) == "charge" + and min(cmd(r)["b"] for r in rows if r[0] >= 1300) >= 0.0 + and moves(rows, "b", 1296, 1500) <= SLEW) + check(f"non-vacuous ({k}): without the fix B jumps to -1600 W", + min(cmd(r)["b"] for r in old if r[0] >= 1300) <= -1600.0) + +_seen.clear() +Controller._reseed_charge_end = _spy +try: + run(2, 6000.0, 1530.0, step=1500.0) +finally: + Controller._reseed_charge_end = no_fix +check("the guard suspending the charge is no end: it fires once, at the HOLD", + [x for x in _seen if x] == [[A]]) + print() if fails: print(f"{len(fails)} FAILED: " + ", ".join(fails)) -- 2.54.0 From 69dc1f0e7b8c59e31c6d8291d97ae45daf213271 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 3 Oct 2026 09:56:36 +0200 Subject: [PATCH 5/7] CI: bump infra pin to 0c04488 (admin/goodwe#62 FW-05, #63 WITNESS-08) Co-Authored-By: Claude Opus 5.5 --- .gitea/infra-pin | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/infra-pin b/.gitea/infra-pin index 2434e5a..225b483 100644 --- a/.gitea/infra-pin +++ b/.gitea/infra-pin @@ -1,2 +1,2 @@ # The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin"). -d637d0b1b13f1b863dc47e445e2ca4cf4a82fe10 +0c04488a9194039c2bf851b680a8ffc53beff671 -- 2.54.0 From b307db1049c00029e71153737f23a8f7bf6144f1 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 3 Oct 2026 10:30:25 +0200 Subject: [PATCH 6/7] Release 0.14.0: version bump and changelog Two inverters are now supported for maintenance and the peak guard (SAFETY-91, SAFETY-93). No option changes. Co-Authored-By: Claude Opus 5.5 --- goodwe_controller/CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++ goodwe_controller/config.yaml | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index f496b85..6e82a47 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## 0.14.0 + +**A minor bump: new behaviour with two inverters, and no option changes.** +No option was added, removed or renamed, and no default changed (83 +options). Options that start 0.13.0 also start 0.14.0. With one inverter, +control is the same as in 0.13.0. + +⭐ **Two inverters are now supported** for the monthly maintenance (drain, +charge, hold) and the peak guard. To add the second inverter, follow Stage 5 +of `docs/commissioning-b.md` in the `admin/goodwe` repo. Read the known +issues below first: four of them are about two inverters. + +Contents of this release, in merge order since 0.13.0 (`8c438ab`): + +| | | +|---|---| +| `f3e409c` | Two batteries: one at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. at 22 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) | +| `858f77c` | Two batteries: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in 400 W steps ([[SAFETY-93]]) | +| `e51e3e0` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-13]]) | + +### Known issues + +- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]). +- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]). +- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]). +- Two inverters: if one battery's drain stops early (e.g. switched Off), the other can briefly export up to about 1.6 kW ([[SAFETY-95]]). +- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]). +- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]). + +### If you are crossing from a version older than 0.10.0 + +`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the +withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and +`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the +bare options (`jq '.data.options'`) first. + ## 0.13.0 **A minor bump: new behaviour, a new add-on page (Home, Settings, Batteries, diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 973f5a3..3c3104f 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -1,5 +1,5 @@ name: GoodWe RS485 Controller -version: "0.13.0" +version: "0.14.0" slug: goodwe_controller description: >- Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart -- 2.54.0 From f13dab14076a7470460d1a41cbdbf1c832f58ae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:01:44 +0200 Subject: [PATCH 7/7] Release 0.14.0: changelog review fixes Slew step not one house's 400 W; three two-inverter known issues, not four; commissioning reference covers a new install; SoC example holds for decimal sensors; SAFETY-95 states the jump, not the export. Co-Authored-By: Claude Opus 5.5 --- goodwe_controller/CHANGELOG.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 6e82a47..679b372 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -8,16 +8,17 @@ options). Options that start 0.13.0 also start 0.14.0. With one inverter, control is the same as in 0.13.0. ⭐ **Two inverters are now supported** for the monthly maintenance (drain, -charge, hold) and the peak guard. To add the second inverter, follow Stage 5 -of `docs/commissioning-b.md` in the `admin/goodwe` repo. Read the known -issues below first: four of them are about two inverters. +charge, hold) and the peak guard. To add a second inverter, follow +`docs/commissioning-b.md` in the `admin/goodwe` repo. Its Stage 5, where the +add-on takes control of it, needs 0.14.0. Read the known issues below first: +three of them are about two inverters. Contents of this release, in merge order since 0.13.0 (`8c438ab`): | | | |---|---| -| `f3e409c` | Two batteries: one at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. at 22 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) | -| `858f77c` | Two batteries: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in 400 W steps ([[SAFETY-93]]) | +| `f3e409c` | Two inverters: a battery at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. above 21 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) | +| `858f77c` | Two inverters: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in small steps ([[SAFETY-93]]) | | `e51e3e0` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-13]]) | ### Known issues @@ -25,7 +26,7 @@ Contents of this release, in merge order since 0.13.0 (`8c438ab`): - A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]). - A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]). - After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]). -- Two inverters: if one battery's drain stops early (e.g. switched Off), the other can briefly export up to about 1.6 kW ([[SAFETY-95]]). +- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]). - Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]). - Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]). -- 2.54.0