diff --git a/goodwe_controller/app/clamp.py b/goodwe_controller/app/clamp.py index 4c8c5f1..0cc4af8 100644 --- a/goodwe_controller/app/clamp.py +++ b/goodwe_controller/app/clamp.py @@ -149,27 +149,52 @@ REASON_CODE = "OUTPUT_CLAMP" # # ⚠️ RAISED 30 s -> 150 s on 2026-09-05, and the reason is a REGIME CHANGE, not a # change of mind about the alarm. 30 s was the planning cadence (2.3), chosen -# when a loop-side engagement was a rare event: this module's own note records -# "60 cycles of the §10.3 runaway produce 2 loop-side engagements". On the live -# install it produced 48 IN 25 MINUTES of ordinary operation, because `max_w` is -# 5000 W where the loop's tuning was derived at a 1500 W clamp (SAFETY-39) - so -# the integrator reaches its bound constantly and the notification re-asserted -# every 30 s, all day. An alarm that speaks that often about the normal case is -# how an operator learns to ignore the one that matters, which is the exact -# failure this module's docstring is written against. +# when a loop-side engagement was a rare event: `main.py`'s note at the action +# log records "60 cycles of the §10.3 runaway produce 0 write-path engagements +# and 2 loop-side ones". On the live install the counter reached 48 loop-side +# ENGAGEMENTS in ~25 minutes of ordinary operation - roughly one cycle in six, +# not a pinned rail, but far more often than a guard sized for 2-in-60 assumed - +# and at a 30 s re-assert the owner received a persistent notification about it +# all day. # -# ⚠️ 150 s IS NOT A COMFORT CHOICE, IT IS THE REQUIREMENT'S OWN EDGE, WITH ONE -# STEP OF MARGIN. test_clamp.py part 2 pins the cadence in WALL-CLOCK terms -# rather than against this constant - a loop pinned on the rail must speak at -# least MIN_PER_QUARTER = 5 times inside the 900 s quarter that bills the -# capacity tariff. That caps this constant at 900/5 = 180 s. At 180 s the check -# passes with exactly 5 of a required 5, so any later change to the firing logic -# tips it red; 150 s gives 6. The floor is the thing that must not move. +# ⚠️ THE FIGURE ABOVE IS AN ENGAGEMENT COUNT READ OFF THE LIVE LOG, NOT A +# MEASUREMENT IN THIS REPO, and it is not the alarm count. Do not compare it +# with the 2-in-60 above without noting that one is engagements and the other is +# engagements too, but over a runaway rather than ordinary running. # -# ⚠️ THIS IS A STOP-GAP AND SHOULD BE REVISITED. It reduces how often the alarm -# repeats; it does not make the alarm correct. SAFETY-40 is the wording and the -# i-clamped/clamped split, and SAFETY-39 is the tuning that would stop the loop -# railing in the first place. Neither is done by this line. +# WHY the loop reaches its integrator bound so much more often than the guard's +# author saw: `max_w` on the live install is 5000 W, while the loop's slew was +# derived against a 1500 W clamp - admin/goodwe `NOTES.md` §"Simulated 2 kW load +# step (gain 0.6, clamp 1500)". ⚠️ THAT DERIVATION IS IN THE OTHER REPO; nothing +# in this one corroborates it, so do not go looking here. SAFETY-39 is the ticket +# that re-derives it, and fixing that is what would stop the railing; this +# constant only changes how often the alarm repeats. +# +# ⚠️ 150 s IS THE REQUIREMENT'S EDGE WITH MARGIN, AND THE CHECK NOW ENFORCES THAT +# EDGE. `test_clamp.py` part 2 grades the cadence in wall-clock terms - a pinned +# loop must speak at least MIN_PER_QUARTER = 5 times inside the 900 s quarter +# that bills the capacity tariff - which caps this constant at 900/5 = 180 s. +# Measured on the worst sliding 900 s window: 180 s earns 5 alarms, 181 s earns +# 4. 150 s earns 6. +# +# ⚠️ THAT CHECK USED TO PERMIT 224 s, AND THE FIX SHIPPED WITH THIS COMMIT. Its +# sweep starts at i=0, where the rising edge is free, so it measured one alarm +# more than a real quarter earns. A later editor raising this to 200 s would have +# had a green suite and a requirement quietly unmet. `test_clamp.py` now grades +# floor(QUARTER_S / REASSERT_S) directly. +# +# ⚠️ MIN_PER_QUARTER = 5 IS A TEST-LOCAL INTEGER, not a spec figure - it exists +# only in `test_clamp.py` and the CHANGELOG entry that introduced it. The 900 s +# quarter IS system-wide (`tariff.py`, `freeze.py`, `DOCS.md`). So the 180 s +# ceiling rests on one number a test author chose; if that number is ever +# revisited, this constant moves with it. +# +# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still +# calls the integrator bound a BUG SIGNAL and still pairs a request from one +# moment with a `self.target` read at another - SAFETY-40. Nothing here is +# suppressed: every engagement is still counted, the action-log row is written on +# every engaged cycle regardless, and the rising edge of every episode alarms +# unconditionally, so no alarm is DROPPED - only delayed. REASSERT_S = 150.0 # Consecutive clean cycles before the alarm de-asserts (10.2 hysteresis). diff --git a/goodwe_controller/test_clamp.py b/goodwe_controller/test_clamp.py index 7f5c3dc..49f0022 100644 --- a/goodwe_controller/test_clamp.py +++ b/goodwe_controller/test_clamp.py @@ -239,6 +239,21 @@ print(f" pinned on the rail for {QUARTER_S:.0f} s at 1 s cycles: " check(f"a loop pinned for a whole billing quarter speaks at least " f"{MIN_PER_QUARTER} times (it spoke {alarms})", alarms >= MIN_PER_QUARTER) +# ⚠️ REVIEW-FOUND, SAFETY-41, and it is the SAME DEFECT as the one recorded +# twenty lines above, one notch weaker. The sweep above runs i = 0..899 and the +# rising edge at i=0 is FREE, so it measures `1 + floor(899 / REASSERT_S)` - one +# alarm more than a real quarter earns. Measured on this very check: +# REASSERT_S = 180 -> 5 alarms, green (and 180 is the true edge: on the worst +# REASSERT_S = 224 -> 5 alarms, GREEN sliding 900 s window 180 gives 5 and +# REASSERT_S = 225 -> 4 alarms, red 181 gives 4) +# So the check as written permits 224 s while the REQUIREMENT permits 180 s, and +# anyone raising the constant to 200 s would get a green suite and conclude the +# floor was met when a real quarter would earn 4 alarms. Grade the cadence +# DIRECTLY, so the check enforces the ceiling its own comment claims for it. +check(f"...and the CADENCE itself honours the floor, independent of where the " + f"sweep happens to start: floor({QUARTER_S:.0f}/{REASSERT_S:.0f}) = " + f"{int(QUARTER_S // REASSERT_S)} >= {MIN_PER_QUARTER}", + QUARTER_S // REASSERT_S >= MIN_PER_QUARTER) check(f"...and on no more than {MAX_FRACTION:.0%} of cycles (it was " f"{alarms / QUARTER_S:.1%})", alarms <= MAX_FRACTION * QUARTER_S) check("...while every single cycle is still counted",