Compare commits

...
9 Commits
Author SHA1 Message Date
adminandClaude Opus 5 9b270a66a5 CI-06: test_peakguard.py answered two of its checks with the wall clock
checks / unit (pull_request) Successful in 4m56s
`test_peakguard.py` is flaky on the required `checks / unit (pull_request)`
context. Reproduced, measured and removed - not widened, and nothing skipped:
the file still carries all of SAFETY-07 and SAFETY-25, and gains a check.

TWO MECHANISMS, both the control law's own gain `900 / remaining` applied to a
wall-clock gap this file never meant to have.

1. THE CROSS-TIME PROJECTION (the "one cycle of the intent form..." check).
   `_proj3` was a SECOND `PeakTracker.project(b3.p1.averager, datetime.now())`
   taken after the real cycle had already returned, and its result was compared
   against a floor decided from the FIRST projection - while the comment three
   lines above says both forms run "against the same frozen projection".
   `excess = 900 x (projected - ceiling) / remaining` moves at `excess /
   remaining` W/s, so at a 1500 W floor the 1.5 W tolerance bought exactly
   `remaining / 1000` seconds: 333 ms with 880 s left in the quarter and 6 ms
   with 10 s left (measured by bisection, probe in the PR). The gap between the
   two statements measured 12.7-39.5 ms over 26 instrumented runs, so the check
   was decided by WHERE IN THE QUARTER it ran, about `gap_ms / 900` of the time.
   FIX: `_recording_decide` keeps the Projection OBJECT the real cycle graded.
   Both forms are now decided from it, the clock is out of the comparison, and
   the tolerance is EXACT rather than 1.5 W. Measured on a real cycle: the
   delivered form re-decided from that object reproduces the controller's own
   floor to 0.000000000000 W, and the identity holds to 0.000000000000 W.
   It also gains a check - that re-decide now pins `_kw` as main.py's actual
   call rather than an approximation of it.

2. THE FIXTURE'S OWN ROOM (the three `wired_over_limit` checks named on the
   card: "the real cycle files the peak claim...", "...and the number written to
   the inverter is the guard's floor", "the STATUS PAGE row for a floor-limited
   guard..."). `range(0, span + 1, 5)` leaves 0-5 s of the open block uncovered
   by construction, and `excess` multiplies an uncovered second back up by
   `900 / remaining`:

       floor ~ want x (1 - uncovered/900) - net_w x uncovered / remaining

   Measured on this tree, shipped: at 12 s of remaining a fixture that asked for
   a 1500 W floor delivered 613 W (-59 %), and at 2 s the guard did not engage
   at all - which fails all three of those checks together. The four-attempt
   retry cannot help, because four attempts take ~40 ms and all land in the same
   dying quarter.
   FIX: one telegram stamped at `now` (which is what a real meter does and what
   the docstring already claimed), plus FIXTURE_ROOM_S - the helper waits out
   the tail of a quarter it cannot size a fixture in. 24 s is DERIVED from the
   1 % accuracy the helper promises, at this file's smallest fixture and the
   measured 0.1 s worst-case lag, not picked:
       net_w x lag / (remaining x want) <= 0.01 - lag/900
       remaining >= 3500 x 0.1 / (1500 x 0.00989) = 23.6 s
   Same probe after the fix: -0.3 % at 12 s, -0.5 % at 8 s, still engaged at 2 s.

NO TIMEOUT WAS WIDENED, no check skipped, no xfail. One tolerance was made
STRICTER (1.5 W -> exact) because removing the clock made exactness possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 19:40:34 +02:00
admin bbc903b2b4 Merge pull request 'PKG-06: bump 0.6.0 -> 0.7.0, because the schema changed and nobody bumped' (#55) from PKG-06-version-0.7.0 into release/1.0 2026-09-05 19:23:16 +02:00
glenn schrooyenandClaude Opus 5 d8e02ad3f8 PKG-06: bump 0.6.0 -> 0.7.0, because the schema changed and nobody bumped
checks / unit (pull_request) Successful in 5m2s
Six merges landed since 0.6.0 was deployed and the version never moved.
Disk and box both read 0.6.0, so `ha apps update` would have been a
NO-OP: Supervisor keys the built image by `version:`, and the update path
installs `version_latest`, which cannot differ from `version` without a
bump. Found while pre-checking the deploy, not after it silently did
nothing.

⚠️ MINOR RATHER THAN PATCH, BECAUSE THE SCHEMA CHANGED. SAFETY-32 added
`bms_soc_floor: int(0,50)` as a REQUIRED per-entry key on the `inverters`
roster - no trailing `?` - so a config declaring a roster and omitting it
on any entry is refused at startup. Verified against the deployed
2d025ee: the per-entry schema went from 6 keys to 7. Top-level options
and schema are unchanged at 63 each.

An install running `inverters: []` is unaffected, which is every
single-inverter install including the reference one: the flat
`bms_soc_floor` still describes the one pack. But CFG-04 recorded the
rule this follows - "a schema change without a bump is a schema nobody
reads" - and this is exactly that shape.

The CHANGELOG gains a 0.7.0 section listing the six merges by SHA. It
also records, rather than papers over, that 0.6.0 was never sectioned:
its content sits below the new heading because the earlier bump shipped
without cutting a section. Splitting it retroactively would mean guessing
a boundary; the deployed 0.6.0 is 2d025ee and the table says what is new
since it.

Full suite: 21 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 18:38:41 +02:00
admin 4482cd8201 Merge pull request 'SAFETY-41: raise the clamp alarm's re-assert 30 s -> 150 s' (#52) from SAFETY-41-reassert into release/1.0 2026-09-05 18:31:30 +02:00
glenn schrooyenandClaude Opus 5 3527c8a04c SAFETY-41: escalate a sustained engagement, so a runaway is never held
checks / unit (pull_request) Successful in 4m59s
Review found the case I asked it to construct, and it is not a corner
case. The rate limit alone DELAYS A RUNAWAY: a benign one-cycle clamp
that spends the budget, then a continuous §10.3-shaped runaway, held the
runaway's first notification for up to 140 s - one REASSERT_S minus a
cycle. On the live install, where ordinary railing spends the budget
essentially all the time, a runaway arriving during normal operation
would be delayed with near-certainty. That is the "benign noise
suppresses the bug signal" shape this module's docstring is written
against, and the family the first attempt at this card died of.

THE ESCALATION. An episode that has not yet spoken because an EARLIER,
unrelated episode spent the budget speaks anyway once it has engaged
SUSTAINED_CYCLES = 3 times in a row. The discriminator is run length and
the two populations genuinely differ: the live flood was runs of length
ONE (engage, de-assert, engage), while a runaway engages every cycle.

Measured, and it costs nothing it was protecting:
  live scatter flood (305 cycles, 1-in-6)   11 -> 11 notifications
  chatter 1-engaged/3-clean over 8000 s     50 -> 50 notifications
  pinned 900 s quarter (floor is 5)          6 ->  6 alarms
  runaway delay after a benign episode     140 s -> 10 s

⚠️ MY COUNTERFACTUAL CHECK COULD NOT FAIL FOR THE DEFECT IT NAMED, and
review caught that too. It read `ClampWatchdog().note(True, ...) is True`
- a FRESH object, which only exercises the `last_alarm is None` branch,
the trivially-true one. A fresh watchdog has no earlier episode to be
held back BY. It now SPENDS THE BUDGET FIRST: benign clamp, gap past
CLEAR_CYCLES, then a continuous runaway, and asserts the delay is well
under REASSERT_S. That is the third instance in this file of the family
recorded at line 216 and at the 224 s sweep, and it was guarding the most
safety-critical property in the change.

Also pinned: `last_alarm` SURVIVING the de-assert. Review built the
mutation - if the de-assert also refreshed it, chatter collapses from 50
notifications to 1 and the alarm never fires again, with the suite green.
That is the "alarm that never fires" class, a different bug from one that
fires later. Now caught.

Both new checks verified against the mutations they name: removing the
escalation -> 1 FAILED; refreshing last_alarm on de-assert -> 1 FAILED.

Full suite: 21 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 18:13:09 +02:00
glenn schrooyenandClaude Opus 5 8790c4d38f SAFETY-41: rate-limit the RISING EDGE, which is where the flood came from
checks / unit (pull_request) Successful in 5m9s
Review falsified the first version of this change, and the correction is
the substance of it.

Raising REASSERT_S 30 -> 150 removed NONE of the owner's notifications.
The re-assert interval governs only the still-engaged path; the rising
edge fired unconditionally. At the measured ~5 s cycle the live pattern -
48 engagements in ~25 minutes, about one cycle in six - leaves ~25 s of
clean cycles between engagements, past CLEAR_CYCLES x 5 s = 15 s. So the
alarm DE-ASSERTED between almost every engagement and each one returned
as a fresh rising edge. Measured on the shipped class over that window:
48 episodes, 48 notifications at REASSERT_S = 30, and still 48 at 150.

The interval now bounds the NOTIFICATION RATE rather than the repeat rate
within one episode: `last_alarm` survives a de-assert, and a new episode
speaks only if the interval has elapsed. Same window afterwards: 51
episodes, 51 engagements, 11 notifications.

⚠️ NOTHING IS DROPPED, AND THE TWO PROPERTIES THAT MATTER ARE PINNED:
  - a PINNED loop still speaks 6 times in the 900 s billing quarter,
    against a floor of 5 (part 2, unchanged);
  - a rising edge from a COLD watchdog still fires IMMEDIATELY, so a
    runaway beginning from quiet is never held back by a budget spent on
    an earlier, unrelated episode. That check is the counterfactual and it
    is the half that would go red if the limit ignored elapsed silence.
Every engagement is still counted, every episode still increments, and
the action-log row is still written on every engaged cycle. The budget is
on notifications, never on evidence.

The new pin is written in wall-clock seconds and in the shape the field
produced, so it does not move when the constant does. Verified by
reverting the rising edge to unconditional: 2 checks go RED naming it.

Full suite: 21 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 17:06:29 +02:00
glenn schrooyenandClaude Opus 5 1d393ced22 SAFETY-41: correct the comment, and fix the check it cites
Review found two false statements in the comment and one weak check. All
three are fixed here; the constant itself is unchanged at 150 s and was
never in question.

1. "That caps this constant at 900/5 = 180 s" was FALSE ABOUT THE CHECK IT
   CITED. test_clamp.py part 2 sweeps i = 0..899 and the rising edge at
   i=0 is free, so it measured `1 + floor(899/R)` - one alarm more than a
   real quarter earns. Measured: 224 s was GREEN across the whole suite.
   Anyone raising this to 200 s would have had a green board and a
   requirement quietly unmet - the same defect recorded at
   test_clamp.py:216, one notch weaker.

   The check now grades floor(QUARTER_S / REASSERT_S) directly, so it
   enforces the ceiling the comment claims for it. Verified: 150 PASS,
   180 PASS, 181 FAIL, 224 FAIL. 180 is also the true requirement edge -
   on the worst sliding 900 s window 180 earns 5 alarms and 181 earns 4.

2. The "60 cycles of the §10.3 runaway" note was attributed to this module
   and truncated. It is main.py:2479 and reads "60 cycles of the §10.3
   runaway produce 0 write-path engagements and 2 loop-side ones". Quoted
   in full, attributed correctly.

3. "48 engagements in 25 minutes" was left implying a pinned rail. It is
   roughly one cycle in six - real, and far above the 2-in-60 the guard
   was sized for, but not continuous. Restated, and labelled as a live-log
   reading rather than a measurement in this repo.

Also recorded: the 1500 W tuning derivation is in the OTHER repo
(admin/goodwe NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp
1500)"), so this repo cannot corroborate it and a reader should not go
looking here; and MIN_PER_QUARTER = 5 is a test-local integer, not a spec
figure - the 900 s quarter is system-wide but the 5 is not, so the 180 s
ceiling rests on one number a test author chose.

Full suite: 20 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 16:53:46 +02:00
glenn schrooyenandClaude Opus 5 3516947b5e SAFETY-41: raise the clamp alarm's re-assert 30 s -> 150 s
The owner reported the OUTPUT_CLAMP notification as a persistent
annoyance on the live install. It re-asserted every 30 s, all day.

⚠️ THIS IS A REGIME CHANGE, NOT A CHANGE OF MIND ABOUT THE ALARM. 30 s
was chosen when a loop-side engagement was rare - clamp.py's own note
records "60 cycles of the §10.3 runaway produce 2 loop-side engagements".
The live install 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). The integrator therefore reaches its bound constantly and
the alarm narrated it every 30 s. 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 clamp.py's docstring is written against.

⚠️ 150 s IS THE REQUIREMENT'S OWN EDGE WITH ONE STEP OF MARGIN, not a
comfort choice. 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 the constant at 900/5 = 180 s. Measured:
  180 s -> 5 alarms per quarter (exactly the required 5, no margin)
  150 s -> 6 alarms per quarter
  300 s -> 3, and the floor check goes RED
180 s would sit on the boundary, so any later change to the firing logic
would tip it red for a reason unrelated to that change. The FLOOR is the
thing that must not move, and it does not.

⚠️ WHAT THIS DOES NOT DO. It changes how often the alarm repeats. It 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. That is SAFETY-40. And it does not stop the loop railing
in the first place; that is SAFETY-39. This is a stop-gap and says so at
the line.

Nothing was suppressed: every engagement is still counted, the action-log
row is unchanged, and the §10.3 runaway still raises the alarm - an
earlier attempt at this that split the alarm so only `clamped` notified
was REVERTED because test_clamp.py:398 proved it would have disabled
runaway detection entirely (a runaway presents as `i_clamped`, and
control.py:78-81 says `clamped` is unreachable on a default install).

Full suite: 20 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 16:53:46 +02:00
admin 86eaef2517 Merge pull request 'TEL-07: a second MQTT device for inverter B, and A keeps the one it has' (#51) from TEL-07 into release/1.0 2026-09-05 15:51:54 +02:00
5 changed files with 424 additions and 10 deletions
+77
View File
@@ -1,7 +1,84 @@
# Changelog
## 0.7.0
⚠️ **SCHEMA CHANGE, AND IT IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.**
[[SAFETY-32]] adds `bms_soc_floor: int(0,50)` as a **required** per-entry key on
the `inverters` roster - no trailing `?`. A config that declares a roster and
omits it on any entry is REFUSED at startup. An install running `inverters: []`,
which is every single-inverter install including the reference one, is
unaffected: the flat `bms_soc_floor` still describes the one pack.
CFG-04 recorded the rule this follows - the Supervisor keys the built image by
`version:`, so a schema change without a bump is a schema nobody reads.
Contents, in merge order since 0.6.0 (`2d025ee`):
| | |
|---|---|
| `3934ad7` | MAINT-05 - one recalibration per pack, migration graded against the roster |
| `67e649a` | SPIKE-04a - inverter B and pack B recorded, from datasheet and serial |
| `3993f9f` | SAFETY-32 - per-pack `bms_soc_floor`, rule 8 per unit, package-wide roster pin |
| `f72c48d` | UX-01 - add-on configuration in English and Dutch |
| `86eaef2` | TEL-07 - a second MQTT device for inverter B; A's identity byte-identical |
| `4482cd8` | SAFETY-41 - the clamp alarm stops narrating normal anti-windup |
⚠️ **0.6.0 WAS NEVER SECTIONED.** Its content sits below this heading rather than
under one of its own, because the bump shipped without cutting the section. That
is a defect in this file, not in the release: the deployed 0.6.0 is `2d025ee` and
the table above is what is new since it. Splitting it retroactively would mean
guessing a boundary; saying so is more useful than a guess.
## Unreleased
**`test_peakguard.py` answered two of its own checks with the wall clock
(CI-06).** The file sits on the required `checks / unit (pull_request)` context
and had a measured ~20 % flake rate. It cost a wrong merge diagnosis (parents
green, merge red — read as a semantic merge conflict, it was the flake), a false
red on two reviewers' CONTROL runs, and a false "mutation caught" that reached a
merged card in four places. **A flaky check does not waste a re-run; it
manufactures evidence for whichever hypothesis the reader already holds.**
Both causes are the control law's own gain `900 / remaining` applied to a
wall-clock gap the file never intended to have. Neither fix widens anything.
* **The cross-time projection.** The "one cycle of the intent form is exactly
the delivered form plus the command standing on the wire" check decided the
intent form from a **second** `PeakTracker.project(..., datetime.now())` taken
after the real cycle had returned, and compared it against a floor decided
from the **first** projection — three lines under a comment saying both run
"against the same frozen projection". `excess = 900 × (projected − ceiling) /
remaining` moves at `excess / remaining` W/s, so at a 1500 W floor the 1.5 W
tolerance bought exactly **`remaining / 1000` seconds**: 333 ms with 880 s
left in the quarter, **6 ms with 10 s left**. The gap between the two
statements measured **12.7–39.5 ms** over 26 instrumented runs. The check was
decided by *where in the quarter it ran*. It now keeps the `Projection`
**object** the real cycle graded and decides both forms from it — the clock is
out of the comparison and the tolerance is **exact**, not widened. Measured on
a real cycle: re-deciding the delivered form from that object reproduces the
controller's own floor to **0.000000000000 W**.
* **The fixture's own room.** `wired_over_limit()` laid telegrams with
`range(0, span + 1, 5)`, leaving **0–5 s of the open block uncovered by
construction**, and `excess` multiplies an uncovered second back up by
`900 / remaining`. Measured on the shipped file: at **12 s of remaining a
fixture that asked for a 1500 W floor delivered 613 W (−59 %)**, and at 2 s
the guard **did not engage at all** — which fails "the real cycle files the
peak claim", "…and the number written to the inverter is the guard's floor"
and "the STATUS PAGE row for a floor-limited guard" **together**. The
four-attempt retry could not help: four attempts take ~40 ms and all land in
the same dying quarter. Fixed with one telegram stamped at `now` (what a real
meter does, and what the docstring already claimed) plus `FIXTURE_ROOM_S`,
**derived** from the 1 % accuracy the helper promises rather than picked:
`net_w × lag / (remaining × want) ≤ 0.01 − lag/900` → **23.6 s → 24 s**, at
the measured 0.1 s worst-case lag.
**Nothing was skipped, disabled or marked xfail** — the file still carries all
of SAFETY-07 and SAFETY-25's forced-discharge SoC floor, and it gains a check:
the delivered form re-decided from the cycle's own projection must reproduce the
real controller's number to the bit, which pins the test's `decide()` kwargs as
main.py's actual call rather than an approximation of it. **One tolerance got
stricter** (1.5 W → exact), because removing the clock made exactness possible.
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
one `unique_id` prefix, so at two inverters B's per-unit telemetry had nowhere
+135 -5
View File
@@ -145,14 +145,94 @@ from datetime import datetime
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
REASON_CODE = "OUTPUT_CLAMP"
# Re-assert the notification this often while the clamp stays engaged. 30 s is
# the planning cadence (2.3), i.e. slow enough not to flood and fast enough
# that a pinned loop is never quiet for a whole billing quarter.
REASSERT_S = 30.0
# Re-assert the notification this often while the clamp stays engaged.
#
# ⚠️ 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: `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 the owner received a persistent notification about it all day. ⚠️ NOT
# because of the 30 s re-assert - see the block below; those notifications never
# consulted this constant at all.
#
# ⚠️ BOTH FIGURES ARE ENGAGEMENT COUNTS; only the regime differs - 2-in-60 was
# measured over a §10.3 RUNAWAY, 48-in-305 was ordinary running on the live
# install. The 48 is read off the live log, not measured in this repo.
#
# ⚠️ AND RAISING THIS CONSTANT ALONE DID NOT FIX THAT FLOOD - review found it
# and the first version of this change was wrong. At ~5 s cycles a 1-in-6
# engagement leaves ~25 s of clean cycles between engagements, past
# CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED between almost every one
# and came back as a fresh rising edge. The rising edge used to fire
# unconditionally, so those notifications never consulted this constant at all:
# 48 notifications at 30 s, and still 48 at 150 s. The fix was to rate-limit the
# RISING EDGE too and let `last_alarm` survive a de-assert, so this interval
# bounds the notification rate across episodes rather than only within one.
# Measured on that same window afterwards: 51 episodes, 11 notifications.
#
# 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).
CLEAR_CYCLES = 3
# ⚠️ SAFETY-41. Consecutive ENGAGED cycles after which an episode speaks even if
# the notification budget is spent. THE ESCALATION EXISTS BECAUSE THE RATE LIMIT
# ALONE DELAYS A RUNAWAY, and review measured it: a benign one-cycle clamp
# followed by a continuous runaway held the runaway's first notification for up
# to 140 s - one REASSERT_S minus a cycle. On the live install, where ordinary
# railing spends the budget essentially all the time, a runaway arriving during
# normal operation would be delayed with near-certainty. That is the "benign
# noise suppresses the bug signal" shape this module's docstring is written
# against.
#
# THE DISCRIMINATOR IS RUN LENGTH, and the two populations genuinely differ: the
# live flood was runs of length ONE (engage, de-assert, engage), while a §10.3
# runaway engages on EVERY cycle. So a sustained run speaks regardless of budget,
# and scattered chatter does not.
#
# Measured, shipped-without vs shipped-with - it costs nothing it was protecting:
# live scatter flood (305 cycles, 1-in-6) 11 -> 11 notifications
# chatter 1-engaged/3-clean over 8000 s 50 -> 50 notifications
# pinned 900 s quarter (floor is 5) 6 -> 6 alarms
# runaway delay after a benign episode 140 s -> 10 s
SUSTAINED_CYCLES = 3
@dataclass(frozen=True)
class ClampResult:
@@ -258,6 +338,10 @@ class ClampWatchdog:
self.last_alarm: datetime | None = None
self.reason = ""
self.clean_cycles = 0
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
# spoken yet - the two the escalation clause needs.
self.run_len = 0
self.spoken = False
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
"""Advance one cycle. True when the caller should raise the alarm.
@@ -269,26 +353,72 @@ class ClampWatchdog:
self.engagements += 1
self.clean_cycles = 0
self.reason = reason
self.run_len += 1
if not self.active:
self.active = True
self.since = now
self.episodes += 1
self.spoken = False
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
# not used to be. It fired unconditionally, so a condition that
# engages and clears repeatedly raised one notification PER
# EPISODE no matter how fast the episodes came - and
# `REASSERT_S` never entered into it, because it only governs
# the still-engaged path below.
#
# That is the shape the live install actually produced: 48
# engagements in 25 minutes, scattered roughly one cycle in six.
# At a ~5 s cycle the gaps are ~25 s of clean cycles, which
# exceeds CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED
# between almost every engagement and each one came back as a
# fresh rising edge. Measured on this class over that window:
# 48 episodes and 48 notifications at REASSERT_S = 30 - and
# still 48 at REASSERT_S = 150. Raising the re-assert removed
# NONE of them, because none of them came from the re-assert.
#
# So the interval now bounds the NOTIFICATION RATE rather than
# the repeat rate within one episode: `last_alarm` survives a
# de-assert, and a new episode speaks only if the interval has
# elapsed. ⚠️ NOTHING IS DROPPED. Every engagement is still
# counted, every episode still increments, the action-log row is
# still written on every engaged cycle, and a genuine runaway -
# which by definition keeps engaging - is picked up by the
# re-assert path within REASSERT_S. Delay, not loss.
if (self.last_alarm is None
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
self.last_alarm = now
self.spoken = True
return True
return False
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
# budget was spent by an EARLIER, unrelated episode speaks anyway
# once it has engaged SUSTAINED_CYCLES times in a row. A runaway
# engages every cycle and reaches it; the scattered one-cycle
# railing that produced the live flood never does.
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
self.last_alarm = now
self.spoken = True
return True
# Still engaged: re-assert on the slow cadence, not every cycle.
if (self.last_alarm is None
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
self.last_alarm = now
self.spoken = True
return True
return False
self.run_len = 0
if not self.active:
return False
self.clean_cycles += 1
if self.clean_cycles >= CLEAR_CYCLES:
self.active = False
self.since = None
self.last_alarm = None
# ⚠️ `last_alarm` DELIBERATELY SURVIVES THE DE-ASSERT. Clearing it
# here is what made the next rising edge speak immediately however
# soon it arrived, which is the flood described at the rising edge
# above. The episode ends; the notification budget does not reset
# with it.
self.reason = ""
self.clean_cycles = 0
return False
+1 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.6.0"
version: "0.7.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
+103 -1
View File
@@ -78,7 +78,8 @@ import sys
from datetime import datetime, timedelta, timezone
from app.clamp import (
CLEAR_CYCLES, REASON_CODE, REASSERT_S, ClampWatchdog, clamp_to_rating,
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
clamp_to_rating,
is_commandable,
)
from app.allocator import Allocation, UnitPlan, plan
@@ -239,11 +240,112 @@ 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",
pinned.engagements == int(QUARTER_S))
# --- THE SCATTERED REGIME, WHICH IS THE ONE THE OWNER ACTUALLY HIT -----------
# ⚠️ REVIEW-FOUND, SAFETY-41, and it falsified the first version of this card.
# The rising edge used to fire UNCONDITIONALLY, so REASSERT_S governed only the
# still-engaged path. A condition that engages and clears repeatedly therefore
# raised one notification PER EPISODE at any value of the constant.
#
# That is what the live install produced: 48 engagements in ~25 minutes,
# scattered about one cycle in six. At the measured ~5 s cycle the gaps are ~25 s
# of clean cycles, past CLEAR_CYCLES x 5 s = 15 s, so the alarm de-asserted
# between almost every engagement. Raising REASSERT_S 30 -> 150 removed NONE of
# them. The interval now bounds the notification RATE across episodes.
#
# The stimulus is written in wall-clock seconds and in the shape the field
# actually produced, so it does not move when the constant does.
SCATTER_CYCLE_S = 5.0
SCATTER_ONE_IN = 6
SCATTER_CYCLES = 305
scat = ClampWatchdog()
scat_alarms = sum(1 for i in range(SCATTER_CYCLES)
if scat.note(i % SCATTER_ONE_IN == 0, "scattered",
T0 + timedelta(seconds=SCATTER_CYCLE_S * i)))
_scat_span = SCATTER_CYCLE_S * SCATTER_CYCLES
print(f" scattered 1-in-{SCATTER_ONE_IN} over {_scat_span:.0f} s at "
f"{SCATTER_CYCLE_S:.0f} s cycles: {scat.episodes} episodes, "
f"{scat.engagements} engagements, {scat_alarms} alarm(s)")
check(f"a condition that de-asserts between engagements is rate-limited ACROSS "
f"episodes, not once per episode ({scat_alarms} alarms for "
f"{scat.episodes} episodes)",
scat_alarms < scat.episodes)
check(f"...and the rate honours the same interval the pinned case does: at most "
f"ceil({_scat_span:.0f}/{REASSERT_S:.0f}) + 1 = "
f"{int(_scat_span // REASSERT_S) + 1}",
scat_alarms <= int(_scat_span // REASSERT_S) + 1)
check("...while EVERY episode and EVERY engagement is still counted - the "
"budget is on notifications, never on evidence",
scat.episodes == scat.engagements == SCATTER_CYCLES // SCATTER_ONE_IN + 1)
# ⚠️ THE COUNTERFACTUAL, AND THE FIRST VERSION OF IT COULD NOT FAIL FOR THE
# DEFECT IT NAMED. It read `ClampWatchdog().note(True, ...) is True` - a FRESH
# object, so it only ever exercised the `last_alarm is None` branch, which is the
# trivially-true one. A fresh watchdog has no earlier episode to be held back BY.
# That is the third instance in this file of the family recorded at line 216 and
# at the 224 s sweep, and it was guarding the most safety-critical property in
# the change. Review found it.
#
# The stimulus must therefore SPEND THE BUDGET FIRST: a benign one-cycle clamp,
# a gap long enough to de-assert, then a continuous runaway. Without the
# escalation clause the runaway's first notification is held for up to
# REASSERT_S - measured at 140 s before it was added.
_esc = ClampWatchdog()
_esc.note(True, "benign", T0) # spends the budget
for _i in range(1, 1 + CLEAR_CYCLES + 1): # de-assert
_esc.note(False, "", T0 + timedelta(seconds=5.0 * _i))
_RUNAWAY_AT = 5.0 * (CLEAR_CYCLES + 2)
_esc_fires = [5.0 * (CLEAR_CYCLES + 2 + _j)
for _j in range(200)
if _esc.note(True, "runaway",
T0 + timedelta(seconds=5.0 * (CLEAR_CYCLES + 2 + _j)))]
_esc_delay = _esc_fires[0] - _RUNAWAY_AT if _esc_fires else None
print(f" budget spent by a benign episode, then a continuous runaway from "
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={_esc_fires[0]:.0f}s "
f"(delay {_esc_delay:.0f}s)")
check(f"⚠️ a SUSTAINED runaway speaks even when an earlier, unrelated episode "
f"spent the budget - it is not held for REASSERT_S ({_esc_delay:.0f}s "
f"delay, escalation is {SUSTAINED_CYCLES} cycles)",
_esc_delay is not None and _esc_delay < REASSERT_S / 2)
check("...and it speaks at the escalation boundary rather than immediately, "
"which is what keeps the scattered flood suppressed",
_esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
# ⚠️ `last_alarm` SURVIVING THE DE-ASSERT IS NOW LOAD-BEARING, AND NOTHING
# PINNED IT. Review built the mutation: if the de-assert path ALSO refreshed
# `last_alarm = now`, the chatter case collapses from 50 notifications to 1 - the
# alarm never fires again. That is the "alarm that never fires" class, which is a
# different bug from one that fires later, and the suite was green on it.
_starve = ClampWatchdog()
_starve_fires = sum(1 for _i in range(1600)
if _starve.note(_i % 4 == 0, "chatter",
T0 + timedelta(seconds=5.0 * _i)))
print(f" chatter 1-engaged/{CLEAR_CYCLES}-clean over 8000 s: "
f"{_starve.episodes} episodes, {_starve_fires} notification(s)")
check(f"a de-assert does not RESET the notification budget, so repeated chatter "
f"cannot starve the alarm to silence ({_starve_fires} notifications over "
f"8000 s, not 1)",
_starve_fires > 10)
# --- THE HYSTERESIS, PINNED AGAINST THE CHATTER DIRECTION --------------------
# ⚠️ REVIEW-FOUND. This used to issue `range(CLEAR_CYCLES - 1)` clean cycles,
# which at CLEAR_CYCLES = 1 issues ZERO of them and passes while printing
+108 -3
View File
@@ -109,6 +109,7 @@ import re
import shutil
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
from math import inf, isinf
from pathlib import Path
@@ -1062,6 +1063,58 @@ check("...and the status page says NOT COVERED rather than 'idle' or a green "
# block with no seconds remaining - so the whole setup is retried on a fresh
# Controller rather than left to flake. It is bounded at three attempts: a fourth
# would mean something is wrong that is not a boundary.
#
# ⚠️⚠️ CI-06. AND THE QUARTER MUST HAVE ROOM LEFT TO SIZE A FIXTURE IN, WHICH IS
# A SECOND AND MUCH WIDER RACE THAN THE BOUNDARY ONE ABOVE. It is where three of
# this file's four known-flaky checks came from, and it is not a boundary
# effect - it is the control law's own gain applied to this helper's arithmetic.
#
# Every second of the open block the telegrams do NOT cover is credited at the
# HELD value instead of the measured one, so the projection loses `net_w x
# uncovered / 900` W - and `excess = 900 x (projected - ceiling) / remaining`
# multiplies that back up by `900 / remaining`. The floor this helper delivers
# is therefore
#
# floor ~ want x (1 - uncovered/900) - net_w x uncovered / remaining
#
# and the second term is the whole story: at 600 s left one uncovered second
# costs 6 W of a 1500 W floor, at 12 s left it costs 292 W, and at 3 s left it
# takes the projection under the ceiling and THE GUARD DOES NOT ENGAGE AT ALL -
# which fails "the real cycle files the peak claim", "...and the number written
# to the inverter is the guard's floor" and "the STATUS PAGE row for a
# floor-limited guard" together, for no reason except the minute the suite ran.
# Measured on this tree as shipped: at 12 s left the fixture asked for 1500 W
# and delivered 613 W, a -59 % fixture that stayed green and graded a different
# site than the one its text names.
#
# TWO uncovered stretches produced it and both are closed here:
#
# 1. THE TAIL. `range(0, span + 1, 5)` stops at the last 5 s mark at or before
# `now`, so 0-5 s of the block were never covered by construction. One
# final telegram stamped at `now` closes it - which is also what a real
# meter does, and what this helper's own docstring already claims it does.
# 2. THE LAG between that last telegram and `cycle()`'s own `datetime.now()`.
# That one is real work and cannot be removed, only bounded: measured at
# 12-96 ms over 23 fixtures on this tree under contention.
#
# So the ROOM the helper needs is derived from what it promises rather than
# picked. Requiring the delivered floor to be within 1 % of the floor asked for,
# at the shipped ceiling and this file's smallest fixture (`net_w` ~ 3500 W,
# `want` 1500 W), and taking the lag at 0.1 s - past every one of the 23
# measurements:
#
# net_w x lag / (remaining x want) <= 0.01 - lag/900
# remaining >= 3500 x 0.1 / (1500 x 0.00989) = 23.6 s -> FIXTURE_ROOM_S
#
# Below that the helper WAITS OUT THE REST OF THE QUARTER rather than grading a
# fixture the arithmetic cannot deliver. The wait is at most FIXTURE_ROOM_S and
# is taken on about 2.6 % of runs; a suite that silently graded a -59 % fixture
# instead was the alternative. ⚠️ This is not a widened tolerance - the checks
# downstream are unchanged and the 1 % is a requirement on the FIXTURE, not on
# any assertion.
FIXTURE_ROOM_S = 24.0
def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
standing_w=None, soc=55.0, prepare=None):
"""A real Controller driven one real cycle over a real over-limit quarter.
@@ -1081,6 +1134,13 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
computed backwards from `remaining` at the moment of the run.
"""
for attempt in range(4):
# ⚠️ ROOM FIRST, AND BEFORE THE CONTROLLER IS BUILT. See FIXTURE_ROOM_S.
# Retrying does not help here - four attempts take ~40 ms each and all
# four would land in the same dying quarter - so the tail of the block
# is waited out once, which puts the next attempt at ~900 s of room.
_room = QUARTER_S - (datetime.now(timezone.utc).timestamp() % QUARTER_S)
if _room < FIXTURE_ROOM_S:
time.sleep(_room + 0.05)
ctl, hs = built(db if attempt == 0 else Path(f"{db}.{attempt}"), opts,
store=_Store(store) if store is not None else None)
ctl.auto, ctl.soc, ctl.batt = True, soc, batt_w
@@ -1099,6 +1159,13 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
net_w = CAP_W + want_floor_w * remaining / QUARTER_S
for i in range(0, span + 1, 5):
ctl.p1.submit(sample(blk + timedelta(seconds=i), net_w))
# ⚠️ AND ONE TELEGRAM AT `now` ITSELF. The averager credits a sample's
# value over the interval to the NEXT sample, so without this the block
# is covered only to the last 5 s mark and 0-5 s of it are extrapolated
# from the held value. See the header: at 12 s of remaining that alone
# turned a 1500 W fixture into a 613 W one. A real meter does not stop
# sending at the last multiple of five either.
ctl.p1.submit(sample(now, net_w))
# ⚠️ ASSIGNED, not read back from `p1.net_w`. This helper stamps
# `ingest_mono` with an epoch second so the averager (which keys on
# `ingest_ts`) replays correctly, while `net_w` keys on a MONOTONIC age
@@ -1273,6 +1340,23 @@ _shipped_decide = peakguard.decide
def _recording_decide(projection, **kw):
_seen.update(kw)
# ⚠️ CI-06. THE PROJECTION OBJECT ITSELF IS KEPT, AND THAT IS WHAT MAKES THE
# COMPARISON BELOW A COMPARISON RATHER THAN A RACE. The check under
# "THE CONSEQUENCE, ITERATED" says both forms are run "against the same
# frozen projection", and until this line existed that sentence was FALSE:
# the intent form was decided against a SECOND `PeakTracker.project(
# b3.p1.averager, datetime.now())` taken after the cycle had returned, so
# the two numbers came from two different instants of the wall clock. The
# control law is `excess = 900 x (projected - ceiling) / remaining`, whose
# derivative in time is `excess / remaining`, so at a 1500 W floor the two
# instants separated at 1500 / remaining W per second and the check's 1.5 W
# tolerance bought exactly `remaining / 1000` SECONDS - 900 ms at the top of
# a quarter and 6 ms with ten seconds left of it. Measured on this tree, the
# gap between the two statements is 13-40 ms idle and larger under load, so
# the check was answered by WHERE IN THE QUARTER IT RAN about `gap_ms / 900`
# of the time. Keeping the object removes the clock from the comparison
# altogether: the tolerance below is EXACT, not widened.
_seen["projection"] = projection
return _shipped_decide(projection, **kw)
@@ -1294,15 +1378,36 @@ check("B3: main.py sizes the floor from the MEASURED battery power, not from "
# against the same frozen projection - the inverter never follows, so the meter
# never moves - and the two are allowed to separate over time rather than being
# judged at a single point where they need not differ.
_proj3 = PeakTracker.project(b3.p1.averager, datetime.now(timezone.utc))
#
# ⚠️ CI-06. "THE SAME FROZEN PROJECTION" IS NOW THE SAME OBJECT. `_proj3` is the
# `Projection` the real cycle actually graded, captured by `_recording_decide`
# above, and not a second projection of the same averager taken at a later
# `datetime.now()`. Every number below therefore comes from ONE instant, and the
# tolerances that used to absorb the wall clock are gone.
_proj3 = _seen["projection"]
_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W,
max_hold_s=b3.p1.averager.max_hold_s, soc=SOC_OK,
soc_floor=SOC_FLOOR)
_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw)
# The delivered form, re-decided from the SAME projection. It has to reproduce
# the real controller's own floor EXACTLY or `_kw` is not the call main.py
# makes - which is a second thing this now grades and did not before, and the
# one that keeps the identity below honest rather than self-referential.
_delivered = _shipped_decide(_proj3, batt_w=0.0, **_kw)
check("the delivered form re-decided from the cycle's OWN projection is the "
"number the real Controller wrote, to the bit. The kwargs below are "
"main.py's call and not an approximation of it, so the identity that "
"follows is a statement about `decide()` and not about this fixture",
near(_delivered.floor_w, b3.peak_guard.floor_w))
check("one cycle of the intent form is exactly the delivered form plus the "
"command standing on the wire - which is the whole defect in one line: "
"watts that were asked for and never arrived, counted as if they had",
near(_intent.floor_w - b3.peak_guard.floor_w, STANDING, 1.5))
"watts that were asked for and never arrived, counted as if they had. "
"⚠️ EXACT, not within 1.5 W: both forms are decided from one Projection "
"object, so a tolerance here would only be hiding a clock. CI-06 - the "
"1.5 W it used to carry bought `remaining / 1000` seconds and made this "
"line answer by where in the quarter the suite happened to run",
near(_intent.floor_w - _delivered.floor_w, STANDING)
and not _intent.saturated and not _delivered.saturated)
_i_cmd, _d_batt = STANDING, 0.0
_i_track, _d_track, _i_sat_at = [], [], None