SAFETY-119: re-pin test_safety84/87 digests (log only); prove it
checks / unit (pull_request) Successful in 41m47s
infra-pin / fresh (pull_request) Successful in 1s

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 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-19 15:30:18 +02:00
co-authored by Claude
parent c4e15ec4cd
commit b0c2e5977d
4 changed files with 83 additions and 25 deletions
@@ -5,23 +5,33 @@
**Cause.** When another owner (the peak guard, a maintenance charge) has set **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 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 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 not show that command yet. On the TEST-23 rig (SAFETY-118 I-2) the guard
last-seconds step went from -2000 W to +429 W. The next cycle's ask crossed stepped from -2000 W to +429 W with 5 s left. A cycle later the loop's ask
the +2500 W rail and beat the guard's floor, so SAFETY-85 rule 1 counted it crossed the +2500 W rail and beat the guard's floor, so SAFETY-85 rule 1
as the loop's own bound and raised `goodwe_integrator_bound`. The battery gave counted it as the loop's own bound and raised `goodwe_integrator_bound`. The
what it was told; the cap held. battery gave what it was told, and the cap held.
**Decision.** On the cycles after a hand-back, the loop's integrator bound is **Decision.** After a hand-back, the loop's integrator bound is not counted
not counted until the plant's settle has passed (`SHORT_CYCLES` cycles and 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 `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 counts as before. An over-rating (`clamped`, a bug) always counts. No command
changes, and no text was added or changed. changes, and no text was added or changed.
**Kept.** The loop in charge at its rail warns on the same cycle as before. A **Kept.** The loop in charge at its rail warns on the same cycle as before.
loop still at its rail after the window warns then (about 22 s later): delay, A loop still at its rail after a hand-back warns once the window has passed:
not loss. The start and a restart after a stop are not hand-backs. 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 **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 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 that is more discharge than the guard asked at a quarter's end, and a few
1700 Ws more charge (2 W on that quarter's average) at the next one's start. seconds of up to 500 W more charge at the next quarter's start.
+41 -1
View File
@@ -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 3. With a usable forecast: the same commands and the same alarms as without
the fix. the fix.
4. The rule at its boundaries (`Controller._settling`). 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. 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)) 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): 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", check("a stop clears it: the next start is not a hand-back",
(_ctl._wire_lost, _ctl._handback) == (False, None)) (_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() print()
if fails: if fails:
print(f"FAILED: {len(fails)} check(s)") print(f"FAILED: {len(fails)} check(s)")
+11 -7
View File
@@ -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 # 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 # 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. # 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 = { PINNED = {
"n1_charge": "n1_charge":
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5", "19781f90b9751b10994dedeaafb38e42d48ad2551640792e601336b7f9b5bb4f",
"n1_charge_dear": "n1_charge_dear":
"c25340b56060d4581f7204e200b07e7c8618711be2baca3b1667b7ebeb600dc3", "eeeb0fa19a6fdc04d44a0838bb2bb911f506a936b0f93d8a6f4d87159053ec74",
"n1_charge_banked": "n1_charge_banked":
"2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609", "a51f125a8c1c98fdbb009fce72b6781d20d384bdf0f5ce7bbfe367d86e5477db",
"n2_charge": "n2_charge":
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c", "e37e98390baa5dad9128991fabf8b170894395fe026d8a33c8a51de7ed0789cc",
"n2_charge_dear": "n2_charge_dear":
"00a197d89a5dd1e8b3ae6a5d1af66329a4dc4850476569c5931c125fa4d9eaee", "7727372de423f62d77b4426a7226eae31a30f3bb89a3cf70272cc7c1823d7263",
"n2_charge_banked": "n2_charge_banked":
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f", "c77e26006c026b953e9c3dcc9a02f3993589f885cb52c7cbceac6bd59b600551",
} }
_now = s84.digests() _now = s84.digests()
for k in PINNED: for k in PINNED:
@@ -492,7 +495,8 @@ for k in PINNED:
f"{_now[k][:16]}") f"{_now[k][:16]}")
check("⭐ at N=1 a charge in and out of a cheap window and under a banked " 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 " "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", all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
"n1_charge_banked", "n1_charge_banked",
"n2_charge_dear"))) "n2_charge_dear")))
+8 -4
View File
@@ -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") 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 PINNED = { # tools/safety87_identity.py on b8ddfd7
"n1_under": "n1_under":
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f", "d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
"n1_crossing": "n1_crossing":
"97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198", "20f8e542c1ee3eb3a58c8fd6ba3b3e5b6b1486800cd63b675abc68bc222a172b",
"n1_above": "n1_above":
"45726a285aa84f40542d56482652397c3c1e212ba4fe2009f6cc52f537ccad52", "3a79f1394b2b49fa88f3c054da1d9da450153ad5f0d2a2c2f960d02a7abe2208",
"n1_under_banked": "n1_under_banked":
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b", "a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
"n1_charge_low": "n1_charge_low":
"91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32", "2e1ee6db0d4f9f18836c4cbbe406fddd496656b54b97f329da513a1591f0ce6c",
"n1_hold": "n1_hold":
"0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d", "0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d",
} }
@@ -536,7 +540,7 @@ _now = s87.digests()
for k in PINNED: for k in PINNED:
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}") print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
check("⭐ every N=1 digest (claims, commands, the guard's decision and the " 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", "charge and a HOLD, and under a banked peak",
_now == PINNED) _now == PINNED)