From b0c2e5977d764ff9d768f6f85a8ddf72583e0711 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 19 Oct 2026 15:30:18 +0200 Subject: [PATCH] SAFETY-119: re-pin test_safety84/87 digests (log only); prove it The re-pinned digests differ only in OUTPUT_CLAMP log rows; every command and claim is the same as without the fix (test_safety119.py part 5). DECISIONS: the delay at a 5 s meter and the open swap case. Co-Authored-By: Claude --- .../SAFETY-119-bound-after-hand-back.md | 36 ++++++++++------ goodwe_controller/test_safety119.py | 42 ++++++++++++++++++- goodwe_controller/test_safety84.py | 18 ++++---- goodwe_controller/test_safety87.py | 12 ++++-- 4 files changed, 83 insertions(+), 25 deletions(-) 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 efc2833..6afdce2 100644 --- a/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md +++ b/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md @@ -5,23 +5,33 @@ **Cause.** When another owner (the peak guard, a maintenance charge) has set the wire and the loop wins again, `cycle()` re-seeds the integrator from that owner's command, and `compute()` adds `gain * error` from a meter that does -not show that command yet. On the TEST-23 rig (SAFETY-118 I-2) the guard's -last-seconds step went from -2000 W to +429 W. The next cycle's ask crossed -the +2500 W rail and beat the guard's floor, so SAFETY-85 rule 1 counted it -as the loop's own bound and raised `goodwe_integrator_bound`. The battery gave -what it was told; the cap held. +not show that command yet. On the TEST-23 rig (SAFETY-118 I-2) the guard +stepped from -2000 W to +429 W with 5 s left. A cycle later the loop's ask +crossed the +2500 W rail and beat the guard's floor, so SAFETY-85 rule 1 +counted it as the loop's own bound and raised `goodwe_integrator_bound`. The +battery gave what it was told, and the cap held. -**Decision.** On the cycles after a hand-back, the loop's integrator bound is -not counted until the plant's settle has passed (`SHORT_CYCLES` cycles and -`SHORT_SPAN_S`, the same window SAFETY-85 grades a battery on). After that it +**Decision.** After a hand-back, the loop's integrator bound is not counted +until the plant's settle has passed (`SHORT_CYCLES` cycles and +`SHORT_SPAN_S`, the window SAFETY-85 grades a battery on). After that it counts as before. An over-rating (`clamped`, a bug) always counts. No command changes, and no text was added or changed. -**Kept.** The loop in charge at its rail warns on the same cycle as before. A -loop still at its rail after the window warns then (about 22 s later): delay, -not loss. The start and a restart after a stop are not hand-backs. +**Kept.** The loop in charge at its rail warns on the same cycle as before. +A loop still at its rail after a hand-back warns once the window has passed: +22 s at a 2 s meter, 55 s at 5 s. Delay, not loss. The start and a restart +after a stop are not hand-backs. + +**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`, +`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ; commands and +claims are the same (`test_safety119.py` part 5). + +**Open.** If another owner and the loop swapped the wire every few cycles, +each swap restarts the window and the loop's bound would not be counted. +A loop at its rail beats a floor or ceiling on its own side, so it keeps the +wire; a swap needs a claim that keeps outranking it. **Not changed.** The kick itself is control behaviour: for a few seconds at a quarter's edge the loop asks up to its rail. In `test_safety119.py` part 1 -that is more discharge than the guard asked at a quarter's end, and about -1700 Ws more charge (2 W on that quarter's average) at the next one's start. +that is more discharge than the guard asked at a quarter's end, and a few +seconds of up to 500 W more charge at the next quarter's start. diff --git a/goodwe_controller/test_safety119.py b/goodwe_controller/test_safety119.py index 85e3e86..f0b8e62 100644 --- a/goodwe_controller/test_safety119.py +++ b/goodwe_controller/test_safety119.py @@ -22,6 +22,8 @@ DECISIONS/SAFETY-119-bound-after-hand-back.md. 3. With a usable forecast: the same commands and the same alarms as without the fix. 4. The rule at its boundaries (`Controller._settling`). +5. test_safety84's and test_safety87's re-pinned digests: the same commands + and claims; only OUTPUT_CLAMP log rows differ. Mutations (MUTATION.md, purged copies) are listed in the pull request. """ @@ -81,7 +83,8 @@ def _power(self, u, lag=1.0): return float(round(a)) -_real_number = rig._Hass.number +_real_number, _real_power = rig._Hass.number, rig._Hass.power +_real_feed = s79._feed async def _number(self, e, invert=False): @@ -338,6 +341,43 @@ _ctl._loop_alarm_input(_Res()) check("a stop clears it: the next start is not a hand-back", (_ctl._wire_lost, _ctl._handback) == (False, None)) +# ============================================================================ # +print("\n5. the pinned digests of test_safety84 and test_safety87: only the " + "log changes") +# Those harnesses run at the 5 s tick with a battery that follows at once. +rig._Hass.power, rig.TICK, s79._feed = _real_power, 5.0, _real_feed +rig._Hass.number = _real_number +import safety87_identity as s87 # noqa: E402 + +RUNS = dict({f"s84 {k}": (lambda v=v: s84.run(**v)) + for k, v in s84.SCENARIOS.items()}, + **{f"s87 {k}": (lambda v=v: s87_run(v)) + for k, v in s87.SCENARIOS.items()}) + + +def s87_run(v): + v = dict(v) + return s84.run(1, dur=s87.DUR, store=s87.store(v.pop("store", None)), **v) + + +_moved, _gone = [], 0 +for k, fn in RUNS.items(): + new, old = fn()[0], no_fix(fn)[0] + same = all(a[:-1] == b[:-1] and a[-1][:4] == b[-1][:4] + for a, b in zip(new, old)) and len(new) == len(old) + logs = {e for r in old for e in [r[-1][5]] if e} - { + e for r in new for e in [r[-1][5]] if e} + _gone += len(logs) + if not same or any("OUTPUT_CLAMP episode" not in e for e in logs): + _moved.append(k) + print(f" {k}: commands and claims " + f"{'the same' if same else 'DIFFER'}, log rows differ {len(logs)}, " + f"digest {s84.digest(new)[:16]}") +check("⭐ in all twelve, every command, loop field, claim, guard decision " + "and phase is the same as without the fix; the only log rows that " + "differ are OUTPUT_CLAMP rows (gone, or a lower count)", + not _moved and _gone > 0) + print() if fails: print(f"FAILED: {len(fails)} check(s)") diff --git a/goodwe_controller/test_safety84.py b/goodwe_controller/test_safety84.py index 9fc4502..875b5d7 100644 --- a/goodwe_controller/test_safety84.py +++ b/goodwe_controller/test_safety84.py @@ -472,19 +472,22 @@ print("\n8. the N=1 and N=2 digests") # charge pin stands. n2_charge and _banked change only the loop's reason and # claim on 2 cycles; n2_charge_dear changes commands on 98 cycles, where A's # 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). PINNED = { "n1_charge": - "0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5", + "19781f90b9751b10994dedeaafb38e42d48ad2551640792e601336b7f9b5bb4f", "n1_charge_dear": - "c25340b56060d4581f7204e200b07e7c8618711be2baca3b1667b7ebeb600dc3", + "eeeb0fa19a6fdc04d44a0838bb2bb911f506a936b0f93d8a6f4d87159053ec74", "n1_charge_banked": - "2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609", + "a51f125a8c1c98fdbb009fce72b6781d20d384bdf0f5ce7bbfe367d86e5477db", "n2_charge": - "b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c", + "e37e98390baa5dad9128991fabf8b170894395fe026d8a33c8a51de7ed0789cc", "n2_charge_dear": - "00a197d89a5dd1e8b3ae6a5d1af66329a4dc4850476569c5931c125fa4d9eaee", + "7727372de423f62d77b4426a7226eae31a30f3bb89a3cf70272cc7c1823d7263", "n2_charge_banked": - "a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f", + "c77e26006c026b953e9c3dcc9a02f3993589f885cb52c7cbceac6bd59b600551", } _now = s84.digests() for k in PINNED: @@ -492,7 +495,8 @@ for k in PINNED: f"{_now[k][:16]}") check("⭐ at N=1 a charge in and out of a cheap window and under a banked " "month peak is byte-identical to 9d5b7d1 (commands, reasons and every " - "claim), and N=2 outside a cheap window matches its SAFETY-85 pin", + "claim; the log as re-pinned by SAFETY-119), and N=2 outside a cheap " + "window matches its SAFETY-85 pin", all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear", "n1_charge_banked", "n2_charge_dear"))) diff --git a/goodwe_controller/test_safety87.py b/goodwe_controller/test_safety87.py index 0f3309d..1de2ebd 100644 --- a/goodwe_controller/test_safety87.py +++ b/goodwe_controller/test_safety87.py @@ -518,17 +518,21 @@ check("...a stopped controller still says so: the peak alarm does not take " # ============================================================================ # 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). PINNED = { # tools/safety87_identity.py on b8ddfd7 "n1_under": "d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f", "n1_crossing": - "97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198", + "20f8e542c1ee3eb3a58c8fd6ba3b3e5b6b1486800cd63b675abc68bc222a172b", "n1_above": - "45726a285aa84f40542d56482652397c3c1e212ba4fe2009f6cc52f537ccad52", + "3a79f1394b2b49fa88f3c054da1d9da450153ad5f0d2a2c2f960d02a7abe2208", "n1_under_banked": "a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b", "n1_charge_low": - "91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32", + "2e1ee6db0d4f9f18836c4cbbe406fddd496656b54b97f329da513a1591f0ce6c", "n1_hold": "0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d", } @@ -536,7 +540,7 @@ _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 " + "log) equals b8ddfd7's (the log as re-pinned by SAFETY-119), under, crossing and above the reserve, in a " "charge and a HOLD, and under a banked peak", _now == PINNED)