diff --git a/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md b/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md index 8755c41..f62be34 100644 --- a/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md +++ b/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md @@ -58,6 +58,22 @@ and 15 s the meter's lag puts the highest quarter 20 to 55 W over 5000 W, the same with and without the fix. Quarter-edge case, 15 s meter: the highest quarter is 2327 W to 2680 W, the same with and without the fix. +**Accepted limits (owner, 2026-10-20, with N=8).** The owner accepted N=8 +knowing that: +- at a 2 s meter the quarter-edge false alarm still shows on about 1 of 30 + seeds (one notification in 2 h); +- at a 15 s meter the case above warns in only 1 to 3 of the 7 quarters + (some runs are silent for more than one quarter in a row); +- at a 30 s meter and slower it gives no warning in that case (0 in 2 h, + against 7 and 11 without the fix): a real bound is suppressed there; +- the cap is exceeded at 10 s and slower in that case (and up to 2680 W of + 2500 W at 15 s on the quarter edge), the same with and without the fix. +A budget that follows the cycle length (10 cycles up to 3.5 s, 8 to 11 s, 6 +to 16 s, 4 beyond) met both cases on every swept seed at 1 to 20 s (at +30 s: no false alarm, 3 to 5 of 7 quarters) in the dev round-3 sweep; it was +not adopted. Without a monotonic clock the budget +never refills, so a bound that stays is counted after 8 hidden cycles. + **Kept.** The loop in charge at its rail warns on the same cycle as before. The start and a restart after a stop are not hand-backs. The quarter-edge kick of `test_safety119.py` part 1 stays hidden (0 warnings in 2 h at diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index afc6de1..90276ce 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -301,7 +301,8 @@ REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2 SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S # SAFETY-119: across all hand-backs, the loop's bound is hidden on at most # this many cycles in any ROLLING_S seconds (a budget measured in -# DECISIONS/SAFETY-119-bound-after-hand-back.md). +# DECISIONS/SAFETY-119-bound-after-hand-back.md). Without a monotonic clock +# the budget never refills. ROLLING_BOUND_CYCLES, ROLLING_S = 8, 900.0 @@ -5886,7 +5887,8 @@ class Controller: self._handback = None return False if self.loop_clamped[0] and not self.loop_clamped[2]: - kept = tuple(t for t in self._hidden if not aged(t, ROLLING_S)) + kept = tuple(t for t in self._hidden if now is None or ( + t is not None and now - t < ROLLING_S)) if len(kept) >= ROLLING_BOUND_CYCLES: self._hidden = kept return False diff --git a/goodwe_controller/test_safety119.py b/goodwe_controller/test_safety119.py index 652e217..aac4fd6 100644 --- a/goodwe_controller/test_safety119.py +++ b/goodwe_controller/test_safety119.py @@ -361,11 +361,9 @@ check("...and at 5 s the cap held", # The quarter-edge case (part 1) at the slower meters: still no warning. # The meter's own lag limits the cap there, the same with and without the fix. _qe = {} -for _t, _seeds in ((10.0, ((1, 1), (2, 1), (3, 2))), - (15.0, ((1, 1), (2, 1), (3, 2))), - (2.0, ((8, 1), (9, 2)))): # a budget of 7 warns here +for _t in (2.0, 5.0, 10.0, 15.0): # with part 1: 1, 2, 5, 10, 15 s rig.TICK = _t - for _seed, _dl in _seeds: + for _seed, _dl in ((1, 1), (2, 1), (3, 2)): # the brief's fixed set _qe[_t, _seed] = (run(house(_seed), delay=_dl), no_fix(lambda: run(house(_seed), delay=_dl))) rig.TICK = 1.0 @@ -373,14 +371,15 @@ for _t, _seeds in ((10.0, ((1, 1), (2, 1), (3, 2))), f"{[len(notes(r[0][1])) for k, r in _qe.items() if k[0] == _t]} " f"(without the fix " f"{[len(notes(r[1][1])) for k, r in _qe.items() if k[0] == _t]})") -check("⭐ the quarter-edge case at 10 s and 15 s meters, and at 2 s on two " - "more seeds: no notification", +check("⭐ the quarter-edge case at 2, 5, 10 and 15 s meters, seeds 1 to 3: " + "no notification", all(notes(r[0][1]) == [] and r[0][1].clamp_wd.bound == 0 for r in _qe.values())) check("...every command the same as without the fix; without it the false " - "alarm is there (non-vacuous)", + "alarm is there at each meter (non-vacuous)", all(commands(r[0][0]) == commands(r[1][0]) for r in _qe.values()) - and all(len(notes(r[1][1])) >= 1 for r in _qe.values())) + and all(any(len(notes(_qe[_t, k][1][1])) >= 1 for k in (1, 2, 3)) + for _t in (2.0, 5.0, 10.0, 15.0))) check("the budget is the one the sweep chose (8). Changing it means " "re-running the sweep in DECISIONS/SAFETY-119-bound-after-hand-back.md", app_main.ROLLING_BOUND_CYCLES == 8 and app_main.ROLLING_S == 900.0) @@ -538,6 +537,18 @@ _c.loop_clamped = (True, "", False) check("an over-rating does not use up the budget", _c._settling(True, _t + 5.0) is True and len(_c._hidden) == 1) +# No monotonic clock: the budget cannot refill, so a bound that stays is +# counted after the budget however often the wire changes hands. +_c = _C() +_nc = [] +for _ in range(40): + for _ in range(3): + _c._settling(False, None) + _c.loop_clamped = (True, "", False) + _nc.append(_c._settling(True, None)) +check("no monotonic clock, repeated hand-backs: the budget holds (8 hidden, " + "then every bound counts)", _nc == [True] * M + [False] * (40 - M)) + # An over-rating (a command over `max_w`) is a bug, never settled away. _ctl = _hb[1] _ctl.loop_clamped = (True, "x", True) diff --git a/goodwe_controller/test_safety84.py b/goodwe_controller/test_safety84.py index 875b5d7..e239b37 100644 --- a/goodwe_controller/test_safety84.py +++ b/goodwe_controller/test_safety84.py @@ -474,7 +474,7 @@ print("\n8. the N=1 and N=2 digests") # pin lifts (t=1800) and B no longer jumps 1390 W, and after the surplus step. # SAFETY-119 re-pinned all six: only the log. OUTPUT_CLAMP rows from the # loop's bound just after the guard hands back are gone; every command and -# claim is the same (test_safety119.py part 5). +# claim is the same (test_safety119.py part 6). PINNED = { "n1_charge": "19781f90b9751b10994dedeaafb38e42d48ad2551640792e601336b7f9b5bb4f", diff --git a/goodwe_controller/test_safety87.py b/goodwe_controller/test_safety87.py index 0670f62..6f0229a 100644 --- a/goodwe_controller/test_safety87.py +++ b/goodwe_controller/test_safety87.py @@ -521,7 +521,7 @@ print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7") # SAFETY-119 re-pinned n1_crossing, n1_above and n1_charge_low: only the # log. The loop's bound just after a hand-back is not counted, so OUTPUT_CLAMP # rows are gone or count fewer; every command and claim is the same -# (test_safety119.py part 5). +# (test_safety119.py part 6). PINNED = { # tools/safety87_identity.py on b8ddfd7 "n1_under": "d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",