cb8f80018061da8dedde2daa2ea75f2e1e2d53e2
93
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b21e229adf |
SAFETY-43 round 2: the cut-out "positive control" was vacuous, and the record
checks / unit (pull_request) Successful in 6m29s
stated the wrong mechanism ⚠️ REVIEW FINDING, BLOCKING, AND IT IS THE EVIDENCE THAT WAS WRONG RATHER THAN THE BEHAVIOUR. Part 8b's cut-out arm claimed an "identical power trace" to the wedged arm and that "what separates them is `account()`". EVERY PART OF THAT WAS FALSE, and the check survived deleting the gate it named. Measured: cut-out arm, account() live named 0/200, distinct B shares: [0] cut-out arm, account() FORCED None named 0/200, distinct B shares: [0] wedged arm, account() live named 200/200, shares 77/125/133/154 W At its own cut-out the pack's usable energy ABOVE that cut-out is zero, so `weights()` answers 0.0, `_fill()`'s preferred pass gives it nothing, the healthy sibling covers the load alone so the last-resort pass never runs, and its share is 0 W ON ALL 200 CYCLES. A unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()` is never consulted. Nothing was identical, "100 % of the ask" was 0/0, and the arm never reached the comparison it was written to grade. WHAT REPLACES IT IS A BETTER FACT, NOT A WEAKER ONE: at its own cut-out OPT-09 STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached. That is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the allocator declines to ask before the detector has to decline to alarm. So the arm is kept and re-pointed, not deleted: * the 0 W share is now ASSERTED (`_sat_last[1][Bee] == 0`), not inferred from the silence - a control that cannot say WHY it is quiet cannot fail; * the arm runs a SECOND time with `account()` DELETED (`no_account=True`) and must still be silent, which is what proves which mechanism does the work; * the account gate's claim is re-pointed at test_delivery.py part 1, where it is graded on a REAL positive 125 W share. ⚠️ AND IT WAS INVISIBLE FOR ONE REASON WORTH KEEPING: that was the ONLY line in part 8b that did not print its shares. `inverter-b+0` would have been on screen from the first run. Every line in the part prints them now. The derated control's vacuity guard also now asserts ITS OWN unit is asked, rather than re-asserting a different run's 200/200 - which is exactly why the old guard could not see this. ALSO, from the same review: N1 app/delivery.py said 0.05 was "twelve times more sensitive" than 0.85. 0.85/0.05 = 17 and no reading of the pair gives 12. An unsupported number in shipped source; replaced with what was actually measured. N2 M14's recorded count was 4 named checks in test_delivery.py and it is 5 - part 1's pre-existing "a shortfall AT the threshold is not one" also goes red. It under-claimed, so it never inflated the evidence, but a recorded figure that does not reproduce is still one. N3 test_delivery.py part 1 printed the alarm sentence with a bare `delinquent(...)["b"]`, which raises KeyError under M12/M13 and ABORTED THE FILE AT PART 1, leaving parts 2-8 ungraded. The direction is safe (an abort is a red; MUTATION.md's defect fabricates only greens) but the recorded coverage read smaller than the truth. It is a `.get` now, and M12/M13's counts are re-run: 7 and 7 named checks, not 5 and 6. CFG-07: the assessment is unchanged (worse by one control cycle, 35.0 -> 40.0 s, deliberately not fixed here) and now says WHEN it has to close - BEFORE INVERTER B IS COMMISSIONED (~2026-09-12), because the band is unreachable at N=1 and commissioning B is when it first becomes reachable - and that CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE, so whoever takes it writes one first. No behaviour change. `delinquent()`'s two arms, the fraction, `min_w` and the N=1 path are all untouched by this commit. 27/27 suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
2271e147c6 |
SAFETY-43: delinquency measured against the ask, not against a flat 500 W
checks / unit (pull_request) Successful in 6m20s
`delivery.delinquent()` named a unit whose `short_by()` gap exceeded an
ABSOLUTE `min_w = Tuning.saturation_w = 500 W`. OPT-09's SoC-aware weights
shrink a low-headroom unit's share, and shrinking the share shrinks the gap, so
a unit standing at +125 W and delivering 0 W - 100 % delinquent - was never
named. Measured end to end on the real loop (test_packshare.py part 8b):
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles
load 1500 W, B wedged at 15 %, OPT-09 weights named on 0/200 cycles
⚠️ The suppressed regime is the quiet one: at a load the healthy sibling covers,
the meter is satisfied, `unmet_w` is 0 and no action-log line fires, so this
alarm is the ONLY instrument that would have told the operator.
WHAT CHANGED, in one line:
named if short_by() > min_w OR short_by() > frac * share
⚠️ `min_w` WAS NOT LOWERED. It exists to stop normal saturation being reported
as delinquency, and lowering it trades a missed alarm for a false one on every
unit at once - SAFETY-41's four-attempt regime. This is a SECOND way to be
named, not a looser first one: all seven of test_delivery.py part 1's existing
fixtures answer identically under both rules, and the absolute arm's suppression
window is still exactly 1000 W / 6500 W, still asserted in part 8b.
THE FRACTION IS DERIVED, 0.85. admin/goodwe:sim/delivery_window.py gained a
`--fractions` axis and grades the constant over the SAME 2640 no-fault runs that
produced `delivery_hold_s`'s 35.0 s floor. Worst continuous legitimate episode,
worst of three families, twenty fractions:
frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
absolute arm alone: 35.0 s (SAFETY-42 §3.3, reproduced in the same runs)
⚠️ NO CLIFF ANYWHERE: 0.45-0.99 is flat within one control cycle, and it is not
monotone - §3.3's own mechanism (c). ⚠️ "Zero false namings" is NOT achievable
at any fraction and the record says so; what is zero is false ALARMS - 90.0 s
worst against a 185 s hold, so nothing comes within 95 s of notifying anybody.
N=1 control 0.0 s at every fraction in all 2640 one-inverter runs; flapping
0.0 s in all 840.
DRIVEN. Part 8b pinned the SUPPRESSED behaviour and said "if it ever goes red
because the alarm fired, read the card that made it fire". It now grades BOTH
rules on one run - `share_frac=None` is SAFETY-42's rule exactly:
OPT-09 weights, absolute arm alone named on 0/200
OPT-09 weights, the shipped rule named on 200/200
POSITIVE CONTROL. Same fixture, same weights, same 125 W share, one legitimate
fact changed: a pack at its own 10 % cut-out is named on 0/200 (account() still
gates it), and a unit delivering HALF its share is named on 0/200.
N=1 BYTE-IDENTICAL, asserted: `plan()` handed a `weights` mapping that raises on
any read still answers identically at a roster of one, and the same mapping
raises at a roster of two; `delinquent()` answers {} on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.
MUTATION, run on fresh :ro trees: M12 (drop the relative arm), M13 (frac -> 1.0)
and M14 (frac -> 0.05) are all red on named checks. The constant cannot move in
either direction unseen.
⚠️ CFG-07 gets ONE CONTROL CYCLE WORSE and is deliberately not fixed here: the
worst legitimate episode goes 35.0 -> 40.0 s, so the schema's `int(30,900)`
false-tripping band widens from [30,35] to [30,40]. CFG-07 should be re-derived
against 40.0 s.
No option value, no schema bound and no default changed. 27/27 suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
57775d4e06 |
TEST-21: delivery_hold_s's floor re-derived, and 30.0 s did not reproduce
No code and no option changes. What changes is a number this repository claimed
about a shipped safety constant.
`delivery_hold_s: 185` shipped resting on "30.0 s, the worst continuous
legitimate episode at the shipped threshold across all 2640 runs", plus a lag
sweep giving 45.0 s and 90.0 s - from a harness `DECISIONS/SAFETY-42` §1
deliberately did NOT commit, because it imports `plantsim` and `frame_check` out
of admin/goodwe. So the floor under a safety constant could not be re-run. That
is PROC-01's shape, with eleven instances in three days.
The harness is committed now, in admin/goodwe as `sim/delivery_window.py`, and
NOT here. `plantsim` and `frame_check` are that repo's; restating them in this
tree is CI-08's coupling, which has broken three times, and the alternative
fate - skipped for want of its imports - is a green that establishes nothing.
There the dependency runs one way: THIS repo's `control`, `arbiter` and the
shipped `delivery.delinquent()` are imported live out of whichever checkout is
under test. Its `--selftest` is now a step in that repo's `checks.yml`, so a
change here that moves `saturation_w`, `max_w`, `delivery_hold_s` or the shape
of `delinquent()` goes red there instead of leaving a stale number here.
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
python3 -B delivery_window.py --selftest --campaign --blag
⚠️ THE HEADLINE DID NOT REPRODUCE: 35.0 s, not 30.0 s. Recorded as measured. The
harness was not adjusted until it agreed - the one correction made during the
rebuild was made AGAINST the doc's number (§1 records it) and made every figure
LARGER.
WHAT REPRODUCED - and it is the part the compatibility promise rests on:
* the N=1 control arm is 0.0 s at EVERY threshold in EVERY run. §3.2's algebra
measured, not argued.
* the flapping family is 0.0 s everywhere in all 840 runs - the family that
defeated SAFETY-30's site window still cannot touch this check.
* 45.0 s at twice A's lag, to the cycle.
WHAT DID NOT:
* the headline, by one control cycle (30.0 -> 35.0 s); the reversal family by
two (20.0 -> 30.0 s).
* the NON-MONOTONE curve. §3.3 finding 3 read 55.0 s at 250 W against 40.0 s
at 100 W as a property of the reduction. This rebuild's curve is monotone.
The mechanism is real and is still the reason not to read either table as a
sensitivity curve, but it is an ARGUMENT now, not a measurement, and it is
written down as weaker.
* the low-threshold rows, badly (40.0 -> 120.0 s at 100 W). Nothing ships
there, but that gap is the honest measure of how much of the original table
was a property of axes nobody wrote down. §3.3 fixes every axis COUNT and
RANGE and no individual value; the rebuild prints its choices above its own
table so the caveat travels with the number.
* the four-times-slower row moved the OTHER way: 75.0 s, not 90.0 s.
185 s IS NOT CHANGED. It is 5.29x the re-measured worst (the record claimed
6.2x), 2.47x the four-times-slower case, 6.2x the firmware's LINK_STALE_S veto
and 4.9x inside the billing quarter. All four bounds clear and none narrowly,
and the ordering of the argument is unchanged. Moving a shipped safety constant
because a margin went from 6.2x to 5.3x while every bound holds would re-open
SAFETY-41's four-attempt false-trip regime for nothing.
⚠️ ONE CONSEQUENCE AN OPERATOR CAN HIT. At 30.0 s the rig's floor coincided with
the firmware's veto and a reader could not tell which was binding. At 35.0 s the
rig's is strictly the binding one - and it is ABOVE the schema's own
`int(30,900)` minimum. `delivery_hold_s: 30` is therefore below a MEASURED
legitimate episode and would report on ordinary saturation. Both translations
now say so. Raising the schema floor is a change to a shipped option's contract
and is left to its own card rather than smuggled in here.
⚠️ THE B-LAG ROWS ARE RELABELLED THROUGHOUT. Inverter B does not physically
exist (~2026-09-12); `plantsim.INVERTER_B` is A COPIED, `measured=False`. Every
two-inverter figure in this record is two copies of A, and the lag sweep is a
SENSITIVITY STUDY ON A HYPOTHESIS ABOUT B, not a measurement of it. The old
wording - "MEASURED, not felt" - overstated exactly that, and "the RAMP does not
move it at all" was too strong: halving it is worth 0 s at A's lag and +5 s at
twice it.
Touched: DECISIONS/SAFETY-42 §1 and §3.3, app/delivery.py's provenance comment,
config.yaml's `delivery_hold_s` block, both translations, CHANGELOG Unreleased.
26/26 suites pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
cab267c4f3 |
OPT-09: the 6500 W window is a bound the ratings never let the site reach
checks / unit (pull_request) Successful in 6m10s
Round-2 non-blocking findings, both fixed. ⚠️ THE WINDOW FIGURE WAS ARITHMETIC NOBODY HAD RUN. `min_w * (w_A + w_B) / w_B` = 6500 W is correct about `_fill()`'s PROPORTIONAL regime and is not a reachable steady state: the healthy pack's own 5000 W rating binds first. SWEPT on part 8b's own fixture, reproduced rather than transcribed: load equal OPT-09 site target A B 1000 199/200 0/200 1080 997 83 2000 199/200 0/200 2170 2003 167 3000 199/200 0/200 3250 3000 250 4000 199/200 4/200 4340 4006 334 4500 199/200 8/200 4890 4514 376 4900 199/200 41/200 5320 4911 409 <- largest suppressed 5000 199/200 195/200 8000 5000 3000 <- A at its rating 6000 199/200 195/200 8000 5000 3000 So the alarm returns BELOW the formula bound, not above it, and the largest site target reached with the wedged unit still under the 500 W threshold is 5320 W - not 6500. The equal-weights "suppressed below 1000 W" bound is unreachable for the same reason: the equal arm names the wedged unit 199/200 at EVERY load swept, 1000 W included. The formula and its check are kept, because the mechanism they state is right and it is the mechanism that generalises to other ratings. What was wrong was the sentence around them, in four places, implying the site passes through the bound. Each now carries the sweep and says the rating binds first. ⚠️ THIS NUMBER CAME FROM THE REVIEW, NOT FROM THE BRANCH, and was adopted in good faith by the implementer. The reviewer found it in round 2 and said so. That is worth recording: a figure arriving from a trusted reviewer got less scrutiny than one written by the author, and PROC-01's eight fabricated numbers in two days is now nine. The rule that catches it is the same either way - run it before you ship it. Also: the DECISIONS header's base sha, stale for the third time ( |
||
|
|
8d83ef0dfb |
OPT-09: the weights DO move on a wedged unit, and that narrows SAFETY-42
checks / unit (pull_request) Successful in 6m11s
B1, the blocking review finding. §5 of the decision said:
"A wedged unit reads a healthy SoC, so no envelope closes and no weight
moves - OPT-09 cannot see it at all."
The first clause is true and is about the ENVELOPE. The second clause is
FALSE, and it was load-bearing for the claim that OPT-09 and SAFETY-42 meet
without overlapping. A weight is a function of a pack's ENERGY HEADROOM, not
of what the pack delivers, so a wedged unit's weight moves whenever its
headroom differs from its sibling's - the ordinary case this card exists to
exploit. And delivery.delinquent() (app/delivery.py:484, threshold at :531)
names a unit only while its short_by() gap exceeds min_w = Tuning.saturation_w
= 500 W in ABSOLUTE WATTS. Shrinking the share shrinks the gap below the
threshold, and the alarm stops firing on a unit still delivering nothing.
So the seam, stated accurately: OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit. That is a DETECTION REGRESSION at
N=2. It is disclosed here, not fixed: making delinquent() relative to the ask
rather than absolute is SAFETY-42's code and a design change, filed as its own
card.
1. THE CORRECTION, in the three places that carried the claim
* DECISIONS/OPT-09-soc-aware-weights.md §5 - rewritten. States the seam
accurately, quotes the sentence it is replacing, carries the measured
rows and the window arithmetic, and names the residual in the same voice
as SAFETY-13's "2 of 272" and SAFETY-42's "the discriminator does not
exist". Also drops "they meet and do not overlap" from the opening.
* app/packshare.py - a new section in the module docstring, immediately
after "weights are a comfort choice for stability and not for delivery",
which is where the over-read starts.
* CHANGELOG.md - a new paragraph in the SAFETY-42 entry, ending on the
operator-facing sentence: a wedged inverter with less energy headroom
than its sibling can now go unreported at ordinary house loads.
test_packshare.py part 8's closing check and its "what this file cannot
see" list repeated the same false clause; both now say the ENVELOPE cannot
reach a wedged unit and point at part 8b for what the WEIGHTS do to it.
2. THE GRADED CHECK - test_packshare.py part 8b, 6 named checks
The reviewer's probe, reproduced: real compute(), real plan(), real
packshare.weights(), real delivery.account(), real delivery.delinquent();
the only model is the plant (A obeys within one cycle inside its rating,
B accepts its share and delivers 0 W). Unit B is WEDGED at 15 % against
its own 10 % floor, so envelope() does not close and account() explains
nothing - a SAFETY-42 true positive by that card's own definition, which
a PRECONDITION check asserts before anything is concluded from it.
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 1500 W, B wedged at 15 %, OPT-09 weights named on 0/200 cycles,
site target +1630 W, shares a+1505, b+125, grid -5 W, named=(nobody)
load 6000 W, B wedged at 15 %, equal weights named on 199/200 cycles
load 6000 W, B wedged at 15 %, OPT-09 weights named on 195/200 cycles
suppression window: equal weights, site target < 1000 W; OPT-09
weights, site target < 6500 W of a 9600 W site
THE CHECKS ASSERT THE CURRENT, UNFLATTERING BEHAVIOUR ON PURPOSE - that
the weighted arm names NOBODY where the equal arm names the wedged unit.
A check asserting the alarm still fires would fail, and papering over it
would be worse than the original defect. The 6000 W row is there so the
part is not pinning "the weighted arm never names anybody": past the
healthy pack's own rating, _fill()'s last-resort pass hands the wedged
unit 3000 W and both arms name it. The window is real and it has an edge.
MUTATION - packshare.weights() keeps its shape and forces every weight to
1.0 (M11 in part 11's table, which now has 11 rows). RUN on a purged tree
with python3 -B, not reasoned about:
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 1500 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 6000 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
site target +4000 W, shares a+2000, b+2000, grid +4000 W, named=[b]
suppression window: equal weights < 1000 W; OPT-09 weights < 1000 W
FAIL AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200 [...]
FAIL ...and the arithmetic behind it, stated as a window [...]
FAIL AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY [...]
test_packshare.py exits 1. Restored: exits 0.
3. THREE PROC-01 PROSE DEFECTS
* DECISIONS/OPT-09-soc-aware-weights.md:3 said "Base: release/1.0 @
464d398". The branch was rebased; git merge-base HEAD
|
||
|
|
f8e501393f |
OPT-09: the weights parameter nothing ever passed, and what it cannot do
`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:
* a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
`bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
pack is not asked to discharge and its share goes to the sibling;
* SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
`_fill()`'s last-resort pass will override the moment the sibling saturates.
Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.
Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.
SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops
the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a
pack that said it could does not - and its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.
Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.
⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
2bb07ccdde |
UX-02: the panel was weaker than the startup gate for one field
checks / unit (pull_request) Successful in 6m9s
Applying review finding N1, which was non-blocking because it fails closed, and
N2, which is a fabricated number.
N1. Clearing the `max_w` box answered HTTP 500 on a non-JSON body, which the
page rendered as "cannot reach the add-on" - untrue, and for the most prominent
limit field's most likely transient state. The cause was a disagreement between
two files that `inverter.py`'s own docstring says to keep in step:
validate.py `_number()` reads "" as "use the default", so rule 9 saw 2000
and was content
inverter.py line 515 did a bare `float(opts.get("max_w", 2000))`, which
raises ValueError on ""
So a shape rule 9 TOLERATED could not be composed at all - the docstring's rule
inverted. The roster path never had this: `_entry_max_w` already guards. Only
the flat single-inverter path did. `_flat_max_w()` now reads empty as the
default exactly as `_number()` does, and unparseable as 0.0 exactly as
`_entry_max_w` does, where rule 9 refuses it by name at line 790.
⚠️ THE FIRST ATTEMPT AT THIS MADE IT WORSE, AND THE PROBE CAUGHT IT. Guarding
`build_roster` in `selfconfig` with `except: return validate(opts), []` turned
the 500 into ZERO PROBLEMS for `max_w: ""` - the validator has nothing to say
about it, so an empty list came back and the page would have ENABLED SAVE. A
crash became a silent acceptance. The guard is kept as a backstop for shapes
neither file anticipates, but it can no longer return an empty list: if the
roster cannot be composed and the validator names no reason, that disagreement
is itself reported.
MEASURED after the fix, `problems_and_ranges` with no HA:
max_w 5000, slew_w 400 0 problems (the live shape)
max_w "", slew_w 400 1 problem rule 10, "against a site rail of
2000 W" - clearing the box FAILS
CLOSED on the live install rather
than silently halving the rail
max_w "", slew_w 0 0 problems accepted at the 2000 default
max_w "abc" 1 problem "max_w 'abc' is not a number"
Before the fix, both "" and "abc" raised ValueError instead.
N2. `translations/en.yaml:7` said "all 71 keys ... so ALL SEVENTY are rendered".
The 70->71 correction had been made in one half of the sentence and not the
other. 59-of-71 required is correct and was re-measured.
Suite: 24 files, all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
fdaf3fb54f |
UX-02: the validator's refusals, next to the field, before the save
checks / unit (pull_request) Successful in 6m17s
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.
WHAT IT ADDS
app/configui.py the field model, built from `GET /addons/self/info`'s own
`schema` + `translations`. No option key, no label, no
rule of its own. UX-01's `Group · ` label prefix becomes a
real section heading, in whichever language resolved.
app/selfconfig.py the Supervisor conversation and the write gate.
app/config_page.py the page. Its longest function decides which HTML input a
type deserves; it contains no rule.
app/web.py five routes. Still the only file a reviewer has to read to
answer "does anything here reach the inverter".
THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
1. A blob the SHIPPED validator refuses is never written - not warned about,
not written. An invalid blob plus a restart is exit 78, and the panel is
gone with the add-on that serves it.
2. A blob that moved underneath is refused, not clobbered. Read hands out a
sha256 of the canonical blob, save hands it back, and the stored blob is
re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
applied to configuration - "a blob reaches the Supervisor only if it was
derived from the blob stored right now" - with the honest weakening stated
in the decision record: a second writer is DETECTED, not made
inexpressible.
3. Every message is the validator's own, byte for byte. test_selfconfig.py
part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
under app/ to show validate.py is the only one that differs, and serves the
real /config/validate route out of that copy in a subprocess. The new
sentence arrives, filed under the key it names.
4. Nothing on the surface commands an inverter or clears a latch. Saving does
not restart: applying costs a control interruption - the wind-down commands
0 W on every inverter - so that is a separate, confirmed button that says
so first.
MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
- the three self-paths are permitted, and `/addons/self/info` is admitted by
the `default` role too while `options` and `restart` rest on the bypass
ALONE. The probe's first draft claimed the role admitted none of the three
and went red.
- `/addons/self/options/validate` is NOT reachable from inside an add-on, so
the only validator this panel can call is ours - which is the one with the
cross-field rules.
- a POST that omits a key does not preserve it: the key reverts to
config.yaml's default. Hence whole-blob read-modify-write.
- the Configuration tab CAN be removed, and doing so drops 0-of-71 options
through and takes the add-on down. The card said it could not be; it stays
by decision, which is a better reason.
DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.
Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
---
⚠️ REBASED ONTO release/1.0 @
|
||
|
|
090a8ecc35 |
SAFETY-42 round 2: pin the two thresholds at a config that is not the shipped one
checks / unit (pull_request) Successful in 6m7s
THE BLOCKER. At the `delinquent()` call site, `self.tuning.saturation_w` -> `250.0` (-19 B) was a survivor of all 23 suites. It is not an equivalent mutant: two copies of inverter A, B wedged, `saturation_w: 1800` (inside its own schema int(100,2000)) and a load of 1600 W put B's shortfall strictly between the literal and the configured value - head names nobody, the mutant names B. At the shipped 500 both name it, which is exactly why every fixture in part 4 was blind: their gaps are all above both thresholds. It matters beyond a mutation score. `delivery.py`'s MIN_W_IS_SATURATION_W note and the `delivery_hold_s` block in config.yaml both promise an operator, in shipped prose, that there is no fourth copy of a threshold to keep in step and no way for the two to drift - raise `saturation_w` and this check moves with you. A literal at the call site removes exactly that promise, silently, and the promise is why the design was chosen over a `delivery_w` key. ASKING THE SAME QUESTION OF EVERY OTHER NUMBER THIS CHECK READS FOUND A SECOND SURVIVOR OF ALL 23 SUITES: `bms_floor=inv.bms_soc_floor` -> `10.0` (-13 B), invisible because config.yaml's own example states 10 % and every fixture in this file used it. Two arms bracket it: a pack at 15 % against its own 25 % cut-out is cold and must stay unnamed (the literal names an empty pack), and a pack at 8 % against its own 5 % cut-out is wedged and must be named (the literal explains the fault away). One arm on each side leaves no constant at all. `delivery_hold_s` and `is_live()`'s window were ALREADY pinned - the fixtures had to move them to run - and `LINK_STALE_S` has no option behind it, so "pinned across a changed config" is not available for it and is not claimed. DECISIONS section 9 enumerates all of it with the deltas and which check kills which. Verified in the CI image, tree mounted :ro, `python3 -B`, `__pycache__` purged and counted before every run (TEST-15): R5 saturation_w -> 250.0 -19 B killed by part 4b(a) R6 bms_soc_floor -> 10.0 -13 B killed by part 4b(b), both arms R7 arm(self.delivery_cfg) -> DeliveryConfig() -1 B killed (already) R8 is_live(now) -> is_live(now, 3600.0) +8 B killed (already) R9 pack cut-out -> conjoined site floor +105 B killed twice head: 23/23 test files PASS, no bytecode written during the run. No shipped behaviour changes: the only edits to app/ are comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
c7eb8c6b97 |
SAFETY-42: close two mutation gaps, and correct what makes N=1 inert
The battery is 12 mutations, one freshly copied tree each, purged, `:ro`,
`python3 -B`, byte deltas measured. First round: 9 red, 3 survivors. All three
are now accounted for and the ledger is in DECISIONS §8.
M5 - `share <= 0` relaxed to `share == 0`, +0 B - WAS A REAL GAP AND THE TWO
OBVIOUS CHECKS DID NOT CLOSE IT. `short_by(-2000, 0) is None` and
`short_by(-2000, -100) is None` both stayed GREEN under the mutant: for a charge
share that is UNDER-delivered `share - measured` is negative and the `gap > 0`
test answers None anyway, so neither input can separate the two spellings. The
one that can is a unit moving MORE than its charge share - -2000 W told,
-3000 W measured, `share - measured` = +1000 W - which the mutant reports as a
shortfall. It is not one; it is something else driving that pack, which is
SAFETY-30's and SAFETY-31's question. That input is now the check, and M5 is RED.
MUTATION.md's third failure mode in miniature: the assertion ran, the sentence
was true, and the data could not reach the failure it named.
M11 - `inv.is_live(now)[0]` replaced by a literal `True`, -15 B - was a WIRING
gap the pure checks structurally could not see. `account(live=False, ...)` was
graded as a FUNCTION; nothing graded that `cycle()` asks the question, because no
fixture had a unit whose writes were being rejected. Such a unit still has its
last ACCEPTED command standing on the wire (`write_setpoint` leaves `last_write`
alone on a rejected write) and still measures nothing, so it looks exactly like a
wedged one to every number this check reads - and reporting it would be the
second, louder alarm about a state the allocator has already handled and logged.
`test_delivery.py` part 6 now builds that unit, with `rejected_since` back-dated
past LINK_STALE_S because the fixture runs in two seconds and the timer is
test_inverter.py's to grade. M11 is RED.
M3 - `len(asked) < 2` relaxed to `< 1`, +0 B - IS STILL A SURVIVOR, and it is an
EQUIVALENT MUTANT. With one asked unit `unaccounted` is a subset of a one-element
set, so it is either empty (`not unaccounted` returns {}) or equal to `asked`
(`len(unaccounted) == len(asked)` returns {}); with zero asked units both
spellings return {} as well. No input separates them, so no check can.
⚠️ AND FINDING IT CORRECTED THE MODULE'S OWN PROSE, WHICH IS THE MORE USEFUL
HALF. `delinquent()`'s docstring claimed `len(asked) < 2` was what makes N=1
inert. It is not - the PROPER-SUBSET rule is, and that line is a fast path and a
signpost. A false attribution of the mechanism in the file's own prose is exactly
what this repository keeps finding, and a mutation that survives is how it
surfaced. The line stays, with the equivalence written down beside it the way
circulation.circulating_w() records its own max(0.0, ...).
Also recorded, rather than left implicit: `account()`'s `parked` branch is graded
as a function but its WIRING is not mutated on its own; part 6's reversed-unit
fixture exercises it end to end, which is weaker, and the ledger says so.
Baseline 23/23 green before and after. app/main.py is byte-identical to the
previous commit - both closures are a test and a comment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
706e625d99 |
SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist
A unit standing at a non-zero share and producing nothing is seen by NOTHING. SAFETY-30's per-unit sign gate needs OPPOSITION on both sides of 250 W and a unit at 0 W is not opposing, it is ABSENT. The site sum still agrees with the meter in direction. Circulating power is exactly zero when one unit does nothing. The only trace left is `reason == "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an operator learns to ignore. It is HW-01's shape, which ran 6 h 25 m with all three layers spectators. ⚠️ THE CARD'S OWN DISCRIMINATOR WAS BUILT FIRST AND FALSIFIED, AND THAT IS THE MAIN CONTENT. SAFETY-42 asks for "saturation persisting past N while |grid error| > X, both derived". Run on the two-master rig it fails in BOTH directions, and no (N, X) fixes either: FALSE POSITIVE `plantsim.Plant.limit()` returns 0 W for a discharge command into a pack at its own floor, and a wedged unit produces 0 W too. Load 2000 W, equal weights, 2000 W per-unit cap, 120 cycles: THE TWO ARMS ARE IDENTICAL ON ALL 120 in grid, site target, every share and every measured watt. Both saturated-freeze on 116 of them, both hold |grid error| over 300 W on all 120 and settle at 1200 W, both read circulating 0.0 W. The predicate's inputs do not differ, so no threshold on them can differ. And "a cold pack at its floor must not alarm" is the card's own AC 2. FALSE NEGATIVE the standing grid error is what a HEALTHY SIBLING ERASES. Load 800 W, same config, B wedged: over the last 30 of 200 cycles the loop is frozen on 30 of 30 while the worst |grid error| is 30 W, with B standing at +770 W and delivering 0 W. Any X above 30 W is silent for the whole run on the exact fault the card describes. WHAT REPLACES IT. Per unit, for a discharge share, `share - measured`; readable because of a reduction that only exists at N>=2: a unit is named while it is short by more than `saturation_w` with NO ACCOUNT for it, and the units in that state are a PROPER SUBSET of the units asked to discharge. THE ACCOUNT keeps the cold pack out and is the only thing that can: the two cases are identical in the power domain, so the discriminator has to be a non-power fact, and there is exactly one - the pack's own SoC against its own `bms_soc_floor`, asked through `peakguard.discharge_permitted()` rather than transcribed. ⚠️ `bms_soc_floor` ALONE, not `pack_floor_pct()`'s conjunction with `peak_soc_floor`: that floor bounds a money-saving discharge only and using it would explain away a wedged unit anywhere between the two. THE PROPER SUBSET keeps a whole-site cause out - a derate this add-on cannot see hits every unit; one box that stopped listening does not - and it is what makes this INERT AT N=1, by algebra rather than by a roster guard. IT REPORTS. IT DOES NOT ACT, and the strongest argument is for acting. Dropping the wedged unit from `live` genuinely recovers a site the shipped path does not: at load 1500 W, kept in the split the site settles at 800 W with a 700 W error and NEVER comes inside 100 W in 150 cycles (the freeze pins the target, so the sibling's SHARE never grows); dropped, it delivers 1500 W from cycle 14. What refuses it is that the only re-allocation this add-on can express is all-or-nothing: a unit derated to 50 % is short by more than saturation_w with nothing to explain it, so this detector names it too, and at load 3000 W dropping it leaves the site delivering 2000 W against 2400 W kept - 400 W WORSE. A partial re-allocation is what would make acting safe and the allocator cannot express it. That is a card. control.py is NOT MODIFIED. The freeze shapes the loop; this reports on it. NUMBERS, MEASURED. The watts side is `Tuning.saturation_w`, read at runtime, so there is no fourth CFG-03 copy and it cannot drift from the number the control law itself calls "not following". `delivery_hold_s: 185` comes off 2640 closed-loop two-inverter runs in which nothing is faulty (6 gains x 5 slews over the whole schema range, across load reversals, a flapping load and ASYMMETRIC SPLITS - a family SAFETY-30's campaign did not have and the one that dominates here) plus 1890 runs at a roster of ONE as the control: threshold reversals flapping weighted N=1 control * 500 W 20.0 s 0.0 s 30.0 s 0.0 s The N=1 column is 0.0 s at EVERY threshold in all 1890 runs, and the flapping column - the family that defeated SAFETY-30's site window - is zero too, because a common stimulus makes both units short at once and the subset stops being proper. ⚠️ The curve is NOT monotone (55.0 s at 250 W against 40.0 s at 100 W): lowering the threshold can turn a proper subset into the whole roster and BREAK the run. ⚠️ THE MARGIN IS MEASURED, NOT FELT, BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST. `plantsim.INVERTER_B` is A COPIED, `measured=False`, so every two-inverter figure above is two copies of A. Sweeping a different second unit: the worst legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and scales with its LAG - 45.0 s at twice A's measured 10 s, 90.0 s at four times it. 185 s is 6.2x the measured worst, 2.06x the four-times-slower case, 6.2x LINK_STALE_S and 4.9x inside the 900 s quarter. Deliberately not 180, which is SAFETY-02's site hold. SAFETY-34. `unit/<name>/delivery_tripped` in the store, one notification id per unit, re-raised on the first cycle of every process, cleared by the Start press. No flat mirror: a rollback lands on a version that drives one actuator, where this check is inert. ⚠️ SCHEMA CHANGE - `delivery_hold_s` joins options: and schema:, so this needs a version bump before it ships. Additive and defaulted, so an existing options blob stays valid. STATED GAPS: charge shares are not graded (no configured charge ceiling exists, so a constant-voltage taper cannot be explained and arming it would fire on every maintenance charge); every unit wedged at once is not caught (the subset is not proper and no instrument here separates that from a plant that cannot). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
f156fadac0 |
SAFETY-13: the bound came from the wrong half of a two-sided measurement
checks / unit (pull_request) Successful in 5m34s
NOTES.md 5.10 measured the 1.78 kW step BOTH ways - "Up: ~18 s from step to deadband. Down: ~23 s" (NOTES.md:1550). SAFETY-39 takes 18 correctly: its bound is a REFUSAL and the looser number refuses less. This branch reused PLANT_SETTLE_S for the opposite job - how long the plant may still be the EXPLANATION for a divergence - where the conservative half is the longer one. ceil(23/2) = 12, not 9, and being short there does not refuse anything, it accuses. The false positive is real and is measured here. One inverter, one compute(), one plant, no second writer anywhere in the harness: the command falls through zero while the pack is still coasting the old way, unasked_power() calls our own power "unasked", the counter reaches 9 while it is still coasting, and the law emits "freeze-released". At the plant's own measured 18 s/23 s shape, over 272 load-step pairs, the 9-cycle bound fires in 62 and the 12-cycle bound in 2. PLANT_SETTLE_DOWN_S = 23.0 is added beside PLANT_SETTLE_S = 18.0, because the plant's settling time is a PAIR and each bound has to say which half it means and why. SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) = 12. It is no longer the reciprocal of SLEW_RAIL_FRAC; that was a coincidence of both reading the same half, and test_control.py now asserts the inequality. _SAT_COUNT_CAP moves with it, 10 -> 13. config.yaml accepts saturation_cycles as int(1,10), so the whole accepted range is now strictly BELOW the bound and the degenerate configuration this branch used to document no longer exists - pinned against config.yaml's own schema line. Three shipped statements were falsified by that measurement and are rewritten, not renumbered - at 12 the residual is smaller, not zero: unasked_power()'s docstring said anything outside the interval "was put there by something other than this loop". It was our own power: eight cycles after a +1990 W command the command is 0 and the pack still reads +522.3 W. control.py said "SO THE BOUND BELONGS TO (b) AND ONLY TO (b)". It fired in case (a). DOCS.md told an operator "if you see freeze-released, look for the second writer" - on a single-inverter install, hunting something that does not exist. Replaced by a section that gives the innocent causes first (a large load or PV swing; a plant slower than the one measured, 8/320 scenarios at 23 s rising to 268/320 at 50 s) and the second writer only when the state STANDS. Round 1's own results are kept and re-graded, not weakened. The release/1.0 sweep had to widen to reach the new bound (3780 cases): 3267 bit-identical, 432 releases, 27 command changes - all still charge starts from prev_w == 0 - and 81 differing in the saturation counter alone, which nothing downstream can read. The card's case releases on the twelfth saturated cycle instead of the ninth and settles at the same -1190 W against the same frozen integrator. Also corrected, because they were claims and did not reproduce: the coverage table's "output freeze" row was 11, and the mutation form its own note describes gives 6 on this tree, 6 on the first SAFETY-13 commit and 2 on release/1.0; the detector row was left at 11 and is 15; the frozen = False footnote was left at 10 and is 13. And section 6 reported 107 625 as "released states" when it is the number of states SWEPT - 67 500 of them release, and both are counted now. Nothing here reached the inverter. 22/22 test files pass in goodwe-addon-ci, tree rsynced to a plain path, mounted :ro, python3 -B, __pycache__ purged and printed first (0 dirs / 0 .pyc; the worktree carries none). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
6c4565659b |
SAFETY-13: the output freeze gets an exit, and only where it needs one
checks / unit (pull_request) Successful in 5m30s
While the loop is frozen the command's magnitude may fall but never rise. At prev_w == 0 there is no magnitude left to fall, so a charge cannot START. That refusal is deliberate and it is kept: the inverter reporting power we did not command means another writer is on the actuator, which is the §1.5 non-goal. What it did not have was an END - nothing cleared it but the other writer leaving. Reproduced on release/1.0 @ |
||
|
|
8e241a3243 |
SAFETY-37: correct a wall-clock tick and a false size claim
checks / unit (pull_request) Successful in 5m32s
Two blocking findings from review, both in prose, both corrected at every site. 1. "trips on tick 7" was not what the fixture prints. It prints 11, five runs out of five, and the developer's own report says 11. ⚠️ But the deeper problem is that the number should never have been quoted: the tick is counted on the WALL CLOCK inside cycle() (hold_s 2 s, ticks 0.20 s apart), so it is load-dependent - 7 is what a box slow enough to stretch each tick to ~0.29 s would print - and nothing pins it. The DECISIONS transcript now says 11 because it is a transcript of a run; the CHANGELOG stops quoting a tick at all and states the checkable thing instead: it trips without the gate and never with it, which part 3 grades. 2. ⚠️ SURVIVOR 2's SIZE CLAIM WAS FALSE FOR ONE OF THE TWO CONSUMERS THIS CARD EXISTS TO PROTECT. It read "one contributing at most 250 W to a loop whose reporting floor is 700 W" at three sites. That bounds the COMMAND. Once the register is frozen its REPORTED value is unrelated to the command - which is exactly HW-01's shape: commanded ~0 W, register stuck on the last large number. Measured in review: a unit frozen at -1200 W beside a healthy +900 W sibling yields circulating_w = 1800 W against the 700 W floor, 2.6x OVER, with no gate armed. So the scoping is TRUE for the per-unit sign check and FALSE for circulation. Corrected to say exactly that, and the circulation consumer's exposure below MOVE_W is now recorded as a KNOWN GAP rather than a covered one. It survived because part 9's S2 check asserts only `not stale` and part 4 pairs the frozen unit with a RESTING sibling (circulating_w([A_FROZEN_W, 0.0]) == 0.0) - neither grades the size sentence. No behaviour change. Full suite: 22 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
6864109a01 |
SAFETY-37: a stale reading is None, and arrival cannot be what says so
checks / unit (pull_request) Successful in 5m34s
The architect pass made SAFETY-37, TEL-06 and TEL-08 one systemic problem - "a stale reading holds its last value and reads healthy" - and this builds the single policy. Both consumers the card names are gated by one change, and neither consumer needed a line: `SignWatchdog.evaluate` already CLEARS its window on a None measurement and `circulating_w` already returns None for a roster with a missing term. Neither could ever receive one. The gate is the piece that was missing BETWEEN the shipped read and the shipped checks. ⚠️ ONE CRITERION IN THE CARD COULD NOT BE IMPLEMENTED AS WRITTEN, AND SAYING SO IS THE MAIN CONTENT. The card says "only arrival separates them". At this layer arrival cannot: `read_inputs()` polls Home Assistant's state cache about once a second and HA answers 200 with a number whether or not the publisher behind the entity is alive, so both members of the pair arrive identically for ever. test_freshness.py part 1a BUILDS the arrival-age gate the card asks for and grades it failing - the age is 0.0 s at every instant of both runs, a threshold that can never fire, which is TEL-06's own defect one layer up. The half of the rule that survives is honoured absolutely: nothing here reads a Home Assistant timestamp, and the check asserts the module contains no `last_updated`. So the criterion became: a reading is stale when it has not moved through a change it was OBLIGED to follow. STALE <=> unchanged >= 35 s AND |standing| >= 250 W AND |standing - standing when the reading last changed| >= 250 W Latched until a different value arrives. `Inverter.batt` becomes None - never a held number - which is a missing input, which §11.1 rule 1 answers with 0 W. WHY A CONJUNCTION AND NOT A THRESHOLD, MEASURED 2026-09-06. Replaying the committed 30-day capture of this house's own 1 W power entity (485,841 samples out of HA's recorder, where a row exists only on a CHANGE, so the gap between rows IS the unchanged duration every consumer saw): the longest legitimate unchanged run is 263.2 s, held at -899.0 W. A false-positive-free threshold would have to exceed that, and 263 s is 3.5x too slow for a consumer that trips at 75 s. There is no admissible threshold. Every one of those long runs is under an UNMOVING load, which is what the travel term keys on. THE BOUND, derived at both ends and graded against the shipped config.yaml. Floor 17.0 s = 6.0 plant settle (NOTES §5.9, measured 2026-08-22) + 10.0 ESPHome publish (firmware/goodwe-master.yaml:181) + 1.0 read period (run_control's asyncio.sleep(1)). Ceiling 75.0 s = min(circulation_hold_s 75, unit_sign_disagree_s 85). Band [34.0, 37.5]; 35 is a CHOICE inside a DERIVED band and the check says so - the first draft claimed the band held one integer and went red on its own first run. MOVE_W is the LARGEST admissible value, unit_sign_disagree_w, because below it the consumer cannot speak at all. SoC is STATED, NOT WIRED, and BOUNDS carries it with wired=False and the reason. Its obligation term is energy through the pack, not command travel - a rested pack holds a percent for hours under a 0 W command - and K needs a usable capacity nobody has measured. That is TEL-08's; the record says so. ⚠️ BEHAVIOUR CHANGES AT THE SHIPPED ROSTER OF ONE, and it is named rather than hidden: a FROZEN register now stops the site where it used to read as a healthy measurement for ever. That is HW-01, which was a one-inverter site. A healthy install is unchanged, asserted over sixty reads of a dithering register under a reversing command. A false verdict fails toward 0 W behind the existing stale_input_s hold and clears itself on the register's next move. ⚠️ BOTH UNITS IN EVERY TWO-INVERTER FIXTURE ARE INVERTER A with different numbers passed in; inverter B does not physically exist (~2026-09-12). MUTATION: 16 mutations against a purged, :ro, python3 -B tree, fingerprinted before and after every revert. One survivor, and it is the finding - dropping the magnitude term left every check green because no fixture separated "the command travelled 250 W" from "the command ended up above 250 W". Closed, and recorded as MUTATION.md habit 17. A second process defect is habit 18: a mutation run that timed out between the edit and the revert left the module mutated on disk and nothing announced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
834e7ca44b |
SAFETY-39: correct the gain figures, which were not the measured ones
checks / unit (pull_request) Successful in 5m15s
Review found validate.py:288-289 carrying numbers that appear nowhere else in the PR's evidence and could not be reproduced: over-absorption "1233 W at slew 1000 and 1344 W at slew 250 ... 1495 W at 0.6, 961 W at 0.3, ~0 W at 0.2". A grep for 1233/1344/1495 found them ONLY in that comment - the DECISIONS record, the PR body, test_control.py's audit table and slew_sim's own output all carried the correct ones. The measured values, from re-running tools/slew_sim.py on the real plant model at the stated conditions (max_w 5000, 2 s tick, 51 export steps 1.0-4.2 kW): slew column 958 (slew 1000) and 1081 (slew 250); gain column 987 (0.6), 876 (0.3), 435 (0.2). ⚠️ "~0 W at 0.2" WAS WRONG IN THE UNSAFE DIRECTION. It invited a future reader to conclude that dropping gain to 0.2 removes over-absorption. It does not: 435 W remains, which is 44 % of the figure at 0.6, not zero. The corrected text says so at the line rather than leaving a round number to be trusted. The conclusion the paragraph draws is unchanged and still holds on the measured figures: over-absorption is flat in slew_w and falls in gain, so `gain` is the constant that would have to move, and SAFETY-39 deliberately does not move it. No behaviour change; comment only. Full suite: 21 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
5d7e275df4 |
SAFETY-39: slew_w is a ratio to the rail, and gain is what is still wrong
checks / unit (pull_request) Successful in 5m16s
`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where "capping how far the loop can jump ahead of the inverter" was true because the rail itself bounded the excursion. The live install went to max_w 5000 and the constant did not move, so the loop crosses its whole output range in 10 s against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five 2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved. REPRODUCED FIRST, and one half of it is not reproducible at all. On plantsim.Plant and on an independent in-repo model: the setpoint stepping a full 1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from export into import. The -5154 W figure is NOT reproducible in either model, because both clamp the plant at its rating - so the card's AC "battery no longer exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met. THE DELIVERABLE IS THE RELATIONSHIP. SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9 slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as integrator_max_w = 0 already means follow max_w. It is the shipped default: 222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. validate.py rule 10 refuses an explicit value faster than that, graded against sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a second inverter moves the bound with no operator action. VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the refusal names the rail, the crossing time, the measurement and what to do. Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating all still start. GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51 export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250) and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W rail in under three ticks whatever the slew is. Two control-law constants moving in one release on a running house makes the next measurement uninterpretable. ⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant did - both stated in the record rather than left to be discovered. Nothing reached the inverter. The live install still runs the interim slew_w 400 applied by hand on 2026-09-05, which rule 10 accepts unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
26c5ee8adc |
TEL-07: a second MQTT device for inverter B, and A keeps the one it has
checks / unit (pull_request) Successful in 4m58s
MQTT discovery announced ONE device and hung every entity off one unique_id prefix, so at two inverters B's per-unit telemetry had nowhere to go. It has somewhere now, and not one entity that already exists moved. ⚠️ THE ASYMMETRY IS DELIBERATE. The first roster entry's per-unit rows are announced on THE DEVICE IT ALREADY HAS; every later entry gets its own device, via_device the controller. "One device per inverter" is tidier and is the change that renames every entity on the live install - and because HA keeps the id it composed at FIRST discovery, a rename produces a NEW entity beside a DEAD one: the history stays behind and every dashboard, template and automation naming it goes on pointing at a sensor that quietly stopped updating, including the three live watchdog automations. The composed ids come out symmetric anyway (sensor.goodwe_inverter_a_battery_power / ..._inverter_b_...), so the tidy design's only real benefit is bought here without its cost. * four per-unit rows - setpoint, battery power, SoC, SAFETY-02 sign latch - announced only from a roster of two. At N=1 nothing is announced and nothing is published: no second device, no renamed entity, no added topic. * grid and p1_sample_age_s stay SITE-scoped and are announced ONCE, at every roster length 1-6. One P1, one grid connection, and p1_sample_age_s is a watchdog input - a second copy is a second answer to "is the meter fresh?". * inverter.roster_names() is the one answer to what the units are called, read by build_roster() and by the publisher, which must know the roster before a Controller exists because announcing happens on the CONNACK. * DECISIONS/TEL-07-second-mqtt-device.md records the two designs side by side. VERIFIED BY DIFFING THE ANNOUNCED PAYLOADS, not by reading the code. The release/1.0 baseline in test_mqtt.py was generated by running that commit's module, not transcribed. Roster of one: 9 topics, 0 missing, 0 changed, 0 added. Roster of two: 0 missing, 0 changed, 8 added. MEASURED, NOT ASSERTED. Sixteen mutations through tools/mutation_run.sh; twelve are in the file as a running table, each named with the property it breaks. Three of those properties exist because the table found their ABSENCE - dropping the unit from the state topic, moving `grid` into the per-unit set, and `units=()` in main()'s publisher call were all GREEN on the first run, as was a roster fallback naming two nameless entries `inverter-a`. Part 9 enumerates what the file still cannot see, with sizes. ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every roster of two here is SIMULATED, from the datasheet and serial in NOTES.md §1.1b. Nothing about B has been measured and no entity described here has been seen on a broker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
19decf433f |
SAFETY-32 round 5: stop closing sites, build an instrument that finds its own scope
checks / unit (pull_request) Successful in 4m59s
Rounds 2, 3 and 4 each closed exactly the survivor they were handed and each time
the reviewer found the same shape one step over: the reduction, the call site, the
sibling argument, and now the sibling MODULE. Round 4's enumeration stopped at the
`Controller` class boundary; round 5's four blockers were reductions in validate.py,
over a list DERIVED from the roster three calls away from it. Closing four more sites
by hand would have produced a fifth rejection, so this round widens the instrument
instead and lets it enumerate.
⚠️ THE BLOCKERS ARE NOT EQUIVALENT MUTANTS - a safety refusal disappears, verified by
calling the shipped validator both ways on the real tree:
roster max_w 5000 (inverter-a) beside 2000 (inverter-b), sign_disagree_w 3000
clean -> 1 problem, "...max_w (inverter-b) is 2000 W, so the
SAFETY-02 sign-disagreement watchdog can never fire"
min(caps[:1]) +4 B -> 0 problems. The site STARTS.
caps_ix[:1] +4 B -> 0 problems.
the SAME roster with the small cap FIRST (2000, 5000)
clean -> 1 problem, naming inverter-a
min(caps[-1:]) +5 B -> 0 problems.
⚠️ AND THE ONE-ROSTER FIXTURE COULD NOT HAVE SEEN THAT. On [5000, 2000] the `[-1:]`
mutant still refuses, because last IS min there. A fixture of N members cannot separate
two reductions that agree on N members - which is why the behavioural half below is a
SET and not a roster.
THE INSTRUMENT (test_peakguard.py part 12j, widened past `Controller`)
Every `.py` under app/ is read off disk and parsed - NO MODULE IS NAMED IN THE CHECK -
and the roster is found by transitive closure from ONE seed, the `inverters` option key:
through returns, through lists ACCUMULATED in a loop over a roster (which is exactly
what `caps`, `roster` and `problems` are, and what round 4's rule could not express),
through attributes and properties, and THROUGH CALL ARGUMENTS, which is how it walks
into peakguard, arbiter, allocator, circulation, maintenance and store without being
told they exist. A slice of a roster stays a roster so a mutation cannot cut the taint;
an index is one unit and does not.
183 roster reductions across 9 modules, out of 23 files parsed:
main 96, allocator 24, validate 19, arbiter 14, circulation 10, maintenance 10,
peakguard 4, inverter 3, store 3. 14 modules reduce over no roster at all.
⚠️ THE HAND-WRITTEN MODULE LIST WOULD HAVE BEEN WRONG IN BOTH DIRECTIONS. It named
watchdog.py, which reduces over no roster at all (SAFETY-30's sign watchdogs are
strictly per-unit), and omitted maintenance.py and store.py, which both do. A vacuous
claim about one module and no claim at all about two others - which is the argument for
computing the scope, made with data.
The pin rediscovers ALL FOUR blockers without being told where to look, and finds a
FIFTH beside them (validate.py:783, rule 5's own message) that no review round named.
Its counterfactual runs at two depths: every enumerated site's expression mutated with
each of the four cuts this card has been defeated by (183 x 4 = 732 spellings, all
reported), and `[:1]` written into app/validate.py at each blocker position with the
WHOLE analysis re-run over the mutated source.
THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE
All 183 sites mutated to `[:1]`, +4 B each, against the full 19-file suite in
goodwe-addon-ci (:ro, -B, fresh rsync per mutant, 0 .pyc before and after - a read-only
mount cannot write one): 183/183 RED. ⚠️ That is TAUTOLOGICAL on its own, because the
pin makes every slice red. So the 51 whose first red was test_peakguard.py were re-run
with the pin NEUTRALISED:
145 of 183 a behavioural fixture separates the site from a slice of itself
38 of 183 ONLY the structural claim. Behaviourally UNGRADED.
All 38 are listed by site with what a `[:1]` there would cost, in the DECISIONS record
and in part 12j itself: 15 are rows and counts on the operator's page, 3 are
`len(targets)` arithmetic beside a write path that IS graded, 6 are allocator internals
ALLOC-01 reaches but not with enough units to separate. NONE of the 38 is closed
behaviourally by this card and none is counted as covered.
THE BEHAVIOURAL HALF, because a shape pin grades an identity and never a meaning
test_validate.py grows a ROSTER SET for the three rules that reduce over
`unit_caps_indexed()`: widths 2, 3, 4, 5 with the one small cap at EVERY position,
14 rosters x 3 rules = 42 runs of the real validator, each asserting the refusal fires
AND names the unit the number came off. Beside it a COMPUTED family table of twelve
candidates - max, first, last, the mean, [:1], [-1:], [:2], [:3], [1:], [:-1], [::2] -
each compared against what the shipped validator actually answered on the same roster.
Only `min` agrees everywhere; a member no roster kills is printed as a NAMED SURVIVOR.
⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
Found by reading the three branches side by side, NOT by the pin - and said that way
because it is the shape of what the pin cannot see. `unit_sign_disagree_w` was graded
against `_number(opts, "max_w", 2000.0)`, three lines under a branch that had already
stopped doing that. Both directions were measured before the fix:
two 2000 W units + a leftover `max_w: 5000` + unit_sign_disagree_w 3000 -> 0 problems.
The site started, the status page said `armed`, and the per-inverter watchdog could
not trip on EITHER unit. Identical sentence to the blocker, one branch over.
two 5000 W units with NO flat key -> REFUSED against a 2000 W default the operator
never wrote, for a threshold both units can reach.
It takes the same `weakest` the branch above takes and names the same unit. At a roster
of one `unit_caps()` is `[max_w]`, so the shipped single-inverter refusal is the same
comparison, the same boundary and the same sentence.
MUTATION PROOF (each: bytecode purged and counted 0 before and 0 after, the edit
re-read FROM DISK with the new text present and the old absent, byte delta printed)
B1 validate min(caps) -> min(caps[:1]) +4 B RED test_validate.py
B2 validate min(caps) -> min(caps[-1:]) +5 B RED test_validate.py
B3 validate caps_ix -> caps_ix[:1] +4 B RED test_validate.py
B4 validate rule 6 min -> min(caps_ix[-1:]) +5 B RED test_validate.py
B5 validate rule 5 msg -> min(caps_ix[:1]) +4 B RED test_validate.py
B6 validate rule 5b msg -> min(caps_ix[-1:]) +5 B RED test_validate.py
B7 CONTROL: weakest -> max(0.0, weakest) +10 B GREEN, as intended
R3a peakguard max(floors) -> min(floors) 0 B RED test_peakguard.py
R3b peakguard max(floors) -> max(floors[-2:]) +5 B RED test_peakguard.py
R3c main call site packs -> self.packs[:2] +4 B RED test_peakguard.py
R4a main pack_floor_soc -> vals[:2] +4 B RED test_peakguard.py
R4b main pack_floor_soc -> vals[-2:] +5 B RED test_peakguard.py
R4c main pack_floor_soc -> vals[:3] +4 B RED test_peakguard.py
R4d main cycle() any(...) -> self.inverters[:1] +4 B RED test_watchdog.py
B1-B6 are caught by the BEHAVIOURAL set in test_validate.py, before the pin is reached.
B7 is a deliberate no-op control: `max(0.0, weakest)` is `weakest` for weakest > 0, and
it stays green, so the battery is not merely red for everything.
R3a is a ZERO-byte delta, so the shipped function was called both ways as well:
packs floors [15.0, 30.0, 20.0], peak_soc_floor 15
binding_soc_floor(...) = 30.0 the 0-byte mutant would answer 15.0 (+15.0 pp)
-> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %.
⚠️ PRODUCTION IS DEGENERATE ON THE FLOOR AXIS. SPIKE-04: pack A and pack B BOTH floor
at 10 %, so a differing floor exists ONLY in the fixtures. That is the argument for
keeping them, not for simplifying them - collapsing them deletes the only data in this
repository that can see the reduction at all. Restated in config.yaml's example, in the
probe and in the fixtures.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every second-unit figure here -
4600 W FROM ITS DATASHEET, 15 % illustrative, the whole roster SET - is SIMULATED and
labelled. Only inverter A's 10 % is read off hardware.
REBASED onto
|
||
|
|
f9e7fbf86d |
SAFETY-32: bms_soc_floor per pack, rule 8 per unit
`bms_soc_floor` describes A BATTERY, not a site. At two packs of different models the cut-outs differ, and one number for both graded the shallower pack against the deeper one's BMS - so a site with cut-outs of 10 % and 30 % under `peak_soc_floor: 20` STARTED, and the guard would command a forced discharge that pack B's BMS, not the operator's floor, is the thing that stops. That is rule 8's own failure mode, reached through rule 8. Scoped to the card's third bullet, per its PM PROOFREAD: the aggregate anti-windup (DEV-02) and per-instance `achievable_w` (CFG-04) were verified as already shipped and are untouched. * config.yaml: `bms_soc_floor: int(0,50)` REQUIRED per `inverters:` entry. Nothing is inherited; the flat key stays the single-inverter shape's own cut-out. A per-entry DEFAULT was declined - a default is one number for every pack, which is this card's defect - and the decision is recorded. * inverter.py: `Inverter.bms_soc_floor`; an entry that states none gets None, not a borrowed number and not 0.0 (the most permissive value the key has). * peakguard.py: `Pack`, `pack_floor_pct()`, `pack_permissions()`, `binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the one place the question is answered. * validate.py: `unit_bms_floors_indexed()` carries the ROSTER index; rule 8 grades EVERY unit and NAMES the one it refuses. Graded with the offender SECOND and with the same two numbers in the other order. * main.py: `Controller.packs`; `decide(soc_floor=...)` is the per-pack reduction of the same option; the floor-limited notification names the pack that stopped the site. `pack_floor_soc` - the emptiest pack, and its None-on-missing behaviour - is untouched. * tools/supervisor_shape_probe.py: RE-RUN and repaired. The required key broke §C and left §D printing PASS on a refusal about a different key. §D is now an isolated counterfactual over an accepted merged baseline, one key varied, asserting the key name. `all cases passed` on 2026.08.0. Stated rather than hidden: under any config rule 8 accepts the per-pack FLOOR reduces to `peak_soc_floor` for every pack. The reduction is written for `unit_caps_indexed()`'s reason and graded on both sides - 1775 rule-8-valid pairs answer the option, the pair rule 8 refuses answers the cut-out. `peak_soc_floor` is still one number for the site, so the "deeper pack needlessly withheld" half is filed as an open question, not closed. Full 19-file suite green. Twelve source mutations through tools/mutation_run.sh: eleven red, one argued survivor. The battery also found `_flat_bms_floor()` defaulting in two places, each covering for the other. No measurement of inverter B appears anywhere: it does not exist yet. Every second-pack figure is simulated and labelled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
905b6fd2b3 |
MAINT-05 fix round 2: the refusal has to survive the NEXT start, and both marker guards have to be visible
checks / unit (pull_request) Successful in 5m14s
REJECTED because the fix round's own load-bearing sentence was FALSE, and the
whole suite was blind to that. `_verify_moves()` produced the red the reviewer
reproduced - that half stood - but DECISIONS §2 claimed "`ok` gates the marker,
so a misdirected migration is not stamped and the next start runs it again ...
a re-run is recoverable". THE REFUSAL LASTED EXACTLY ONE START.
MEASURED, Controller level, ONE persisted store, roster ["garage","shed"], flat
last_completed 2026-08-07, call site still handing `inverter-a` on both starts:
construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent>
construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1
history parked under unit/inverter-a/...; marker stamped after ONE restart.
WHY. `_verify_moves()` graded only `rep.moved`. On start 2 the same wrong call
site finds the four keys already present, so rule 1 routes every one of them to
`rep.kept`, `rep.moved` is EMPTY, the roster loop iterates over nothing,
`misdirected` is empty and `ok` is True. Start 3 then takes the
`from_version >= MAINT_SCHEMA_VERSION` early return FOREVER - including after
the call site is fixed. Loud-and-recoverable became silent-and-permanent after
one restart: the exact state store.py's header says must never happen, reached
by the fix written to prevent it.
WHY THE CHECK MISSED IT - the recurring defect, this time INSIDE the remedy.
Part 9b's recovery check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` - the
CORRECTED name. It measures recovery along the one path where the operator has
ALREADY fixed the bug. Nothing in the 19 files called `migrate_maint_keys()`
twice with the SAME WRONG `unit`. The check was true and passed for the wrong
reason.
WHAT CHANGED
1. app/store.py `_verify_moves()` grades `set(rep.moved) | set(rep.kept)`.
Sound by construction: every entry in both lists is a `unit_key(unit,
legacy)` built from the SAME `unit` argument, so grading `kept` grades the
same namespace decision `moved` does. A correct call site's `kept` entries
sit under a roster name and pass; a wrong one cannot launder its namespace by
having already written it. `migrate_unit_keys()` passes `roster=None` and
returns before the loop, so it is untouched.
2. test_maintenance.py part 9c - THE CHECK THAT WAS MISSING. Two starts, SAME
wrong unit, ONE store: the marker is still withheld on the second, the
refusal names the same roster, the flat history is still there to recover
from, and a third start with the CORRECTED name still gets a run (which a
stamped marker would have made impossible). Its PRECONDITION asserts the
SHAPE of the second report - `moved` empty, `kept` full - so it cannot
quietly revert to grading 9b's path. Counterfactual beside it: the same two
calls with the right name are accepted, then skipped as `already at schema`.
3. test_maintenance.py part 9d - THE OTHER GUARD ON THE SAME LINE, and a
survivor nothing in the repository could see. `if rep.ok:` is
`not lost and not misdirected`; every check that reached the marker did so on
a store whose `kept` was empty, so
if rep.ok: -> if rep.ok or rep.kept: (+12 B)
stamped the marker on a migration that LOST A MONTH OF HISTORY - defeating
the older, settled `lost` guard as well as the new one - and ALL 19 SUITES
WERE GREEN on it (measured at
|
||
|
|
65cddf35f1 |
MAINT-05 fix round: grade the migration against the ROSTER, not against the name it was handed
checks / unit (pull_request) Successful in 4m47s
REJECTED because a survivor undoes AC 5, and it survived for the recurring
reason: two fixture values that must be distinguishable were EQUAL.
THE SURVIVOR. app/main.py:282 mutated from `self.inverters[0].name` to `UNIT_A`
left the full 19-file suite green. `inverter.UNIT_A == "inverter-a"`, and every
two-pack fixture in test_maintenance.py named its first roster entry
"inverter-a" - so the two expressions were the same string in every check that
existed. main.py's own comment made the distinction load-bearing and nothing in
the repository could see whether that comment was true.
THE FAILURE IS REAL. `build_roster()` names a declared roster's entries from the
config and numbers a nameless one POSITIONALLY (`inverter-1`), so "inverter-a"
is not even the fallback. On ["garage","shed"] with a flat last_completed of
2026-08-07 the mutant migrates into unit/inverter-a/, pack 0 reads back None,
and both packs come up believing they have never been recalibrated.
AND IT FAILED SILENTLY, which was the worse half: `_verify_moves()` read back
the keys it had just WRITTEN, under the namespace it had just been HANDED, was
satisfied, and MAINT_SCHEMA_KEY was stamped - so the next start skipped the
migration too. A postcondition that reads back the namespace it was handed
cannot detect being handed the wrong one.
WHAT CHANGED
1. THE POSTCONDITION IS GRADED AGAINST THE ROSTER. `_verify_moves()` takes the
names the MACHINES read and reports a namespace that is not among them as
`misdirected` - a new, differently-named failure from `lost`:
* `lost` is "the store took the write and dropped it" - a storage fault,
with a harmless-default case (a dropped `last_completed: None` loses
nothing, because absent and never-recalibrated are the same statement);
* `misdirected` is "the store did exactly as it was told, by a caller that
was wrong". There is no harmless-default version of it - a write nobody
reads is not information whatever its value - so it fails `ok` alone.
`ok` gates the marker, so a misdirected migration is NOT stamped and the next
start re-runs it. Asymmetric on purpose: a re-run is recoverable (rule 1
makes it a no-op, rule 2 left the flat keys to recover from), a silent skip
is not. Part 9b MEASURES the recovery rather than asserting it.
2. `migrate_maint_keys()` TAKES THE ROSTER IN PLACE OF AN `n_units` COUNT. The
count was `len(self.inverters)` at the only call site, so the roster carries
it, and a count beside the names is a second opinion about how many packs
there are.
3. THE FIXTURES CAN NOW REACH THE FAILURE THEY NAME. Parts 9 and 11 run on
["garage","shed"], each behind a PRECONDITION check that the first name is
not UNIT_A - so an edit that makes them equal again goes red instead of
making the claims vacuous. Part 11's fixture date is relative to `now`, not
a literal, so "history preserved" and "history lost" cannot converge as the
file ages past `interval_days`.
MUTATION LEDGER - all red, purged tree, none at +0 B, full detail on the PR:
M1 main.py inverters[0].name -> UNIT_A -16 B 4 checks red
M2 store.py drop `roster=names` from _verify_moves -14 B 5 checks red
M3 store.py drop the misdirected.append -37 B 5 checks red
M4 store.py ok: drop `and not self.misdirected` -25 B 3 checks red
M5 fixture garage -> inverter-a (the trap itself) +4 B 5 checks red
M6 store.py parts[1] -> parts[-1] +1 B 1 check red
FOUND AND NOT FIXED - `migrate_unit_keys()` HAS THE SAME HOLE AND IT IS A LATCH.
It runs BEFORE `build_roster()` (each Inverter restores its latch in its
constructor), so it is handed the constant UNIT_A with no roster to grade
against. On a declared roster a pre-DEV-02 `sign_tripped: true` lands under
unit/inverter-a/sign_tripped, which no unit reads - and
`_grade_store_migration()`, whose whole job is to re-arm exactly this, is
DEFEATED BY THE KEY THE DEFECT WROTE: its "is anything namespaced latched" test
is satisfied by the misdirected value itself. Measured, with the flat-config
counterfactual, and pinned as a running check (test_maintenance.py part 13)
whose assertions state the WRONG behaviour on purpose. It wants its own card:
the remedy is in inverter.py and SAFETY-02's semantics, and a silent expansion
into a safety latch is how a rejected PR becomes a broken one.
Suite: 19/19 PASS, purged tree, bind-mounted :ro in goodwe-addon-ci.
No hardware was touched; every fixture here is SIMULATED and pack B's 3000 W
remains a marked hypothesis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
1aef19e995 |
MAINT-05: one recalibration per pack, and only one pack moves power at a time
checks / unit (pull_request) Successful in 4m48s
§12's monthly cycle drains a pack to empty and refills it to true its coulomb
counter. There was exactly one `Maintenance` and one un-namespaced history, so
at two packs only the first was ever recalibrated - and nothing prevented the
cycle draining pack A while the loop charged pack B, which is two power-moving
phases on one grid connection pulling against each other.
* ONE `Maintenance` PER ROSTER ENTRY, each with its own schedule, phase and
history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
install's flat history into the first pack's namespace under
`migrate_unit_keys()`'s three rules - shared in code via `_move_flat_keys()`
and `_verify_moves()`, not restated - on its own marker, because unlike
DEV-02's it runs only where the roster has more than one pack. The flat keys
are kept LIVE as the rollback view.
* ONE MAINTENANCE SLOT PER SITE (`maintenance.arbitrate()`). At most one pack
in a power-moving phase - drain, charge AND hold. The holder is DERIVED from
the packs' own stored phases, never persisted, so a restart mid-drain
re-derives it. Several packs due in the same hour: the slot goes to the one
that has gone longest without a completed cycle; roster order breaks a
numeric tie and nothing else. Nothing preempts a cycle in flight.
* THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT. A deferral does not
stamp `last_start_attempt` and does not move the phase, so neither the 12 h
drain nor the 36 h charge timeout is reachable by waiting.
* The claim is filed on the pack whose state machine produced it, and stays
SCOPE_UNIT - ARCH-01's rule.
* `validate.py` rule 6 grades `maintenance_charge_w` against the WEAKEST cap on
the roster now, as its own comment said it must once `maint_unit` stopped
being `inverters[0]`.
* At a roster of one nothing changes, and it is asserted: no new store key, the
migration does not run, the four cycle events are the shipped strings, the
banner is the bare phase.
* RECORDED NOT FIXED: a drain pins its pack while the loop's target is a site
claim, so below the pin the allocator refuses the split and the site parks at
0 W. Pre-existing at
|
||
|
|
ae21b71824 |
Merge release/1.0 into SAFETY-30
checks / unit (pull_request) Successful in 4m39s
CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved as the union of the two sides: - app/main.py: an import-block collision. CFG-04 added validate_unit_ranges to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither side dropped anything, so the resolution keeps both lists whole. - CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept. Both sides also independently rewrote large parts of test_peakguard.py (60 and 47 insertions against the merge base) and git merged them textually, so the full suite was run on the MERGE RESULT rather than inferred from the two parents: 19 passed, 0 failed, twice. One caveat recorded rather than hidden: test_peakguard.py failed once during the first sequential full-suite run and then passed 10/10 in isolation and in two further full runs on this same tree. It passes on both parents too, so this is the pre-existing flakiness CFG-04 already disclosed, not a merge defect - it is timing- or load-sensitive. It sits on the required CI context and can therefore block an unrelated merge at random, which wants a card of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
22c016fadd |
CFG-04 fix round: the probe now measures the key it is cited for
checks / unit (pull_request) Successful in 4m33s
Review blocked this on ONE finding, and it was in the EVIDENCE, not the code.
The design and the shipped behaviour are unchanged.
THE INSTRUMENT DID NOT MEASURE WHAT FOUR DOCUMENTS SAID IT MEASURED
tools/supervisor_shape_probe.py section B validated the RECONSTRUCTED LIVE
options blob - which is missing many required keys that have nothing to do with
this card. Its refusal therefore named whichever of THOSE the validator reached
first: the committed transcript recorded `meter_freeze_s`, the reviewer's run
printed `p1_phase_export_entities`, and a run today printed `sign_disagree_w`.
The string those documents quoted - `Missing option 'inverters' in root` - was
never produced by the instrument.
And section B asserted a bare `not ok`. Any refusal at all satisfied it, so
deleting `inverters: []` from config.yaml left it printing PASS: the probe could
not detect the one regression it is the committed evidence for.
THE UNDERLYING CLAIM IS TRUE AND IS NOW MEASURED PROPERLY
Section B is an ISOLATED COUNTERFACTUAL now: the full shipped `options:` block
(expect accept), and the SAME block with `inverters` removed and nothing else
varying (expect refusal). One key differs, so the refusal has nowhere else to
come from - and the assertion is on the KEY NAME IN THE REFUSAL STRING, so the
line goes red if the refusal is ever about a different key. The probe also exits
non-zero when any case fails; without that a transcript is only evidence to
someone who reads it.
The old live-blob case survives as section B2, relabelled for what it is: an
incomplete reconstruction whose RAW half is refused for unrelated missing keys,
kept because its MERGED half genuinely demonstrates what App.options is.
VERIFIED BY BREAKING IT, WHICH IS THE WHOLE POINT
`inverters: []` deleted from config.yaml, probe re-run against
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0:
== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
FAIL the shipped `options:` block validates AS IT STANDS - ...
Missing option 'inverters' in root in GoodWe RS485 Controller
...
== 2 CASE(S) FAILED ==
exit 1. Key restored, same image, same command: `== all cases passed ==`, exit
0. The transcript in DECISIONS is that restored run, re-run today, not the old
one.
THE FOUR MIS-CITATIONS
DECISIONS/CFG-04-inverter-roster.md section 4's "section B shows both halves"
sentence and its line-103 transcript; CHANGELOG.md; test_inverter.py's part-9
comment and its part-12 mutation row; and the PR description. All now attribute
the string to section B and say plainly what section B2 does not show. The
part-12 row additionally records that the probe was blind to that mutation until
review, because a mutation table that lists a defeat nothing catches is the
failure this repository keeps finding.
MUTATION.md gains it as the sixth instance of "the check's data cannot reach the
failure it names" - the first found OUTSIDE the suite, in a committed evidence
artifact, because nothing in that document had ever been pointed at tools/. Three
habits with it: assert on the CONTENT of a refusal rather than that one happened;
vary ONE thing, from an ACCEPTED baseline; and treat a committed transcript as a
check subject to the mutation battery.
TWO NON-BLOCKING FINDINGS, ALSO FIXED
validate.py rules 5 and 6 NAMED THE WRONG INVERTER. `unit_caps()` SKIPS an
entry with no usable `max_w`, so caps position i stops being roster position i
the moment one entry is malformed - and rule 5 fed `caps.index(weakest)` to
`_unit_label()`, which indexes the roster, while rule 6 paired `caps[0]` with a
hard-wired `_unit_label(opts, 0)`. Right number, another inverter's name: an
operator sent to the wrong device by the message that exists to stop that. New
`unit_caps_indexed()` carries the roster index with the cap. It is latent -
rule 9(d) refuses such a config in the same pass - but that was a property of
today's rule ordering, not of the code, and validate_startup_config() is also
sim/inject.py's entry point. Part 10 gains a check that asserts the fixture
really does desynchronise the two lists, then that both messages name the right
unit, then the counterfactual: the OLD expressions, on that same config, still
evaluate to the wrong names.
DOCS.md called a downgrade "safe". True of the SCHEMA - the older schema drops
the key with a warning - and incomplete about the ADD-ON: rule 9(e) requires the
flat `setpoint_entity` be cleared when a roster is declared, so a config that
downgrades cleanly is by construction one whose flat actuator is blank, and the
older version has nothing to write to. Confirmed by running release/1.0's own
validate_startup_config() on that blob: one refusal, naming setpoint_entity.
Recoverable and loud, but an outage until a field is refilled. The note says so
now, and tells the operator to write the entity down before downgrading.
19/19 green in the goodwe-addon-ci image.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
f000547518 |
SAFETY-30: grade each inverter against its own power, and the site's total against the meter
checks / unit (pull_request) Successful in 4m38s
THE DEFECT, in one line. `inv.sign_wd.evaluate(stood, self.grid, ...)` compared
the command THIS controller put on ONE actuator against what the WHOLE SITE is
exchanging with the network. At a roster of one those are two views of the same
thing - specs.md §6.2's identity - and that is what makes the check sound and
what earned it the best record in the safety table (NOTES.md: 93 % agreement
while healthy against 4 % during runaway, four observed episodes, zero false
positives). At two inverters they are not, and it fails in BOTH directions:
FALSE TRIP inverter A commanded -1500 W while the meter reads +1500 W is
evidence of nothing when inverter B is discharging 3000 W exactly
as told. Nothing is faulty and the highest-value check fires.
MISSED the converse, and it is worse. A unit commanded +1500 W and
MEASURED at -1500 W, beside a sibling at +1500 W, leaves P1
exactly where a healthy site leaves it. The summation destroys the
evidence and no threshold recovers it.
DEV-02 had already shipped the per-INSTANCE half - one SignWatchdog per
inverter, namespaced latch, a trip on B no longer latching A. What was left was
the COMPARISON, and it is two questions rather than one:
PER UNIT this unit's standing command against ITS OWN measured power
(`Inverter.batt`, holding register 0x0518, per-instance since
DEV-02). Conclusion: "the actuator I am driving is not doing what I
told it" - a fact about one device, so the remedy stays the per-unit
`failsafe` SET that arbiter.CLAIM_SCOPE has classified SCOPE_UNIT
from the day it was written. That entry read "the evidence is
site-wide and the remedy is not"; the first half is now simply true.
SITE the SUM of the standing commands against the meter, as a new
`sign_site` SET at P_SAFETY on the site actuator, latched under flat
`site_sign_tripped` / `site_sign_reason`. This is the 2026-08-20
fault day and NO per-unit check can see it, because every inverter
may be faithfully executing its share of a number that is itself
wrong. It stops every inverter and the banner names the site.
WHAT IT IS NOT, said here because two guards that overlap without saying so is
how a false-alarm generator gets built. It is not SAFETY-31: circulation.py
compares the units AGAINST EACH OTHER and catches them pushing power THROUGH
each other, which nets to zero at the meter and about which no single command is
evidence. This compares each unit against ITS OWN COMMAND. They overlap in
exactly one scenario - a unit reversing while its sibling pulls the other way -
and test_watchdog.py asserts on that fixture that both fire and what each
concludes. It is also not the saturation freeze, which is a MAGNITUDE test
(|command - measured| > 500 W) that shapes the loop and fires on a merely
saturated unit; this is a DIRECTION test that latches and parks one.
THE FOUR NUMBERS, RE-DERIVED AT TWO INVERTERS - one carried with a justification
and one refused. 1890 closed-loop runs on admin/goodwe's TEST-19 two-master rig
in which NOTHING is faulty, swept over the whole range config.yaml accepts for
`gain` and `slew_w`, across load reversals with write staggers and flapping
loads, and RUN AT BOTH ROSTERS so the N=1 arm is the control:
unit_sign_disagree_w 250 `saturation_w / 2`. The check gates on both sides,
opposed, so the smallest gap it can see is 500 W -
exactly where compute()'s own anti-windup test
already says the inverter is not following. It
cannot fire on a divergence the control law calls
ordinary tracking. Worst legitimate opposition at
250 W: 10.0 s; the curve is nearly flat, because a
unit's own power chases its own command.
unit_sign_disagree_s 85 8.5x that 10.0 s, 2.8x the firmware's own 30 s
link veto (inverter.LINK_STALE_S), under half the
180 s site hold.
site_sign_disagree_w 300 CARRIED. Its argument is the meter's noise floor
and the 15 W deadband; the roster touches neither,
and the campaign agrees - the gate opens in 994 of
1050 runs at two inverters and 955 of 1050 at one.
site_sign_disagree_s 325 NOT CARRIED, and this is the number the card
exists to not assume. The worst legitimate
crossing at 300 W is 110 s with one inverter and
215 s with TWO, because the command must travel a
rail that is the roster sum at a `slew_w` that is
not - reachable at gain 0.05, slew_w 50 and a
2.2 kW load reversal, all schema-legal. 180 s
would have shipped a false trip. 325 s is 1.51x
the measured worst, inside the 900 s billing
quarter and inside every episode of the fault day.
AT ONE INVERTER NOTHING CHANGES, and "nothing" is asserted rather than assumed.
The two questions are the same sentence there, so only the detector that shipped
is armed: same comparison against the meter, same 300 W / 180 s, same reason
string byte for byte, same namespaced latch and flat rollback mirror, same
`failsafe` claim, same banner, same single status row - and the site detector is
NEVER CALLED, counted on the object over twelve cycles.
VERIFIED BY BREAKING IT. Thirteen mutations on fresh per-mutation trees with no
__pycache__, mounted :ro, with the battery refusing to report at all unless the
baseline is green. Twelve red on the first pass. M4 - the site half armed at N=1
as well - SURVIVED, because the check read `disagree_since is None` and the unit
detector trips first, parks the inverter and leaves the site standing at 0 W:
the assertion ran, the sentence was true, and the data could not reach the
failure it named. Replaced with a call counter on the object; M4 is red now.
Recorded as a survivor in DECISIONS/SAFETY-30-per-unit-sign.md §5, with the
first battery's own RED BASELINE - a shared mutation directory read mid-copy -
discarded rather than reported.
RECORDED, NOT FIXED: the site comparison is defeated by a square-wave load whose
half period is about one control cycle, which holds it opposed for ~1000 s. The
N=1 control arm is what makes that interpretable - it is identical at the roster
this add-on ships, so it is a property of comparing a lagged command against a
meter and has nothing to do with the second inverter. It wants an alternation
test rather than a number, and a card of its own.
SAFETY-24's lo-only asymmetry proof re-run unchanged: 1296 combinations, 0
negative floors, `hi` +inf on every claim, every claim on the SITE actuator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
6b85acb0f1 |
CFG-04: an operator can declare a second inverter, and the shape was measured
checks / unit (pull_request) Successful in 4m30s
DEV-02 made every per-device value per-instance - rating, SoC, watchdogs, heartbeat, store keys - so the code stopped assuming one inverter. The options schema did not. `build_roster()` returned a list of exactly one and said so in its own docstring, and there was no `inverters:` key for an operator to write, so the roster could only ever be a roster of one. THE SHAPE, AND WHY IT WAS MEASURED RATHER THAN CHOSEN config.yaml had no list-of-objects anywhere, and `static_profile` carries a whole tariff structure as a JSON string in a `str?` precisely because a nested schema was doubted. That is a question about somebody else's software, so it was answered by running it: `tools/supervisor_shape_probe.py` (committed) runs Supervisor 2026.08.0's OWN `AppOptions` validator and `SCHEMA_APP_CONFIG`, out of the release image, against this repository's config.yaml. 2026.08.0 is the version the live install reports. The full transcript and the argument are in DECISIONS/CFG-04-inverter-roster.md. It accepts the list, enforces per-entry types, refuses a missing `name` / `setpoint_entity` / `max_w` BY NAME, renders the option in the UI as a repeatable section, and drops the key with a warning under an older schema - so a downgrade is not the forward trap in reverse. ⚠️ AND THE `inverters: []` DEFAULT IS LOAD-BEARING, WHICH IS THE MEASUREMENT THAT MATTERED MOST. Supervisor answers `Missing option 'inverters' in root` for an options blob that has no such key - which every existing install's has, since the key did not exist when they were written. `App.options` is the shipped `options:` block deep-merged with the operator's stored one, so the empty default is the difference between an upgrade nobody notices and an add-on that cannot start with a schema pinned to the version that would fix it. That is docs/deploy-rollback.md §4's trap in the FORWARD direction, found before it was sprung. The merger was checked too: lists override rather than concatenate, so a non-empty default could not have grown a roster behind the operator's back. NOTHING IS INHERITED, WHICH IS DEV-02 RESTATED AS A CONFIG RULE `name`, `setpoint_entity` and `max_w` are required of every entry. An entry that omits `soc_entity` has no SoC sensor; it does not borrow the flat one. A three-line fallback would have rebuilt the sibling-grading defect DEV-02 spent a card removing, in YAML. Defaults still live in exactly one place (CFG-03): there is no per-entry default anywhere, because there is no optional per-entry number. A flat `setpoint_entity` left set beside a declared roster is refused rather than ignored - it names an actuator nothing will write. TWO PERMISSIVE READERS IN SHIPPED CODE, AND THREE MORE IN THE CHECKS validate.py rules 5 and 6 compared their thresholds against the FLAT `max_w`. At a roster of two 2000 W units with `max_w: 5000` left over from a single-inverter install, `sign_disagree_w: 3000` passed rule 5 while SAFETY-02's watchdog could fire on NEITHER unit - rule 5's own failure mode, reached through rule 5. They read `unit_caps()` now, which at one inverter is `[max_w]` exactly. main.py's `run_control()` re-ran the device-rating check behind `if self.o.get("setpoint_entity")` - the flat key, which a roster leaves blank - so on a two-inverter site no inverter's `max_w` was graded against the device it is written to, and the skip was silent. `validate_unit_ranges()` grades each unit against its own ESPHome and runs unconditionally. test_peakguard.py's `read_option()` and `_schema_bound()` and test_inverter.py's `read_schema_max()` matched a STRIPPED line and took the first hit, so the nested per-inverter `max_w:` began outranking the flat one - silently, and correctly, because the two bounds are the same int(100,5000) today. A reader that is right by coincidence is this project's named failure mode. All three are anchored at exactly two spaces now; the file's two halves disambiguate `peak_cap_w` instead of first-match, and an ambiguous key raises rather than picking one. test_tariff.py's copy of the idiom went with them. THE AUDIT IS A CHECK, NOT A PROMISE test_inverter.py part 11 finds every config.yaml-reading function in the suite - transitively, because two of the readers CFG-04 had to change never name the file - EXECUTES each against a synthetic two-inverter config.yaml whose per-inverter numbers differ from its flat ones, and fails if any answers with the per-inverter value. The table is not maintained by hand: the signature decides, so the eleventh reader is named on its first run. config.yaml declares `inverters:` BEFORE `max_w:` on purpose and part 11 asserts that ordering, so the trap stays baited. SAFETY-28's `actuator_writers()` needed no change - it was already a structural, case-folding census - but until now there was no configuration in which two writers on one actuator could be WRITTEN DOWN. There is now, and it is refused. VERIFIED BY BREAKING IT Ten mutations run through tools/mutation_run.sh and observed red, listed in test_inverter.py part 12 with the check that bites each. Two survived the first run and the CHECKS were changed, not the table: the inheritance check had blank flat keys, so "inherited" and "not inherited" were the same empty string; and the `run_control()` check asserted a SOURCE LINE was present, which re-indenting it does not remove. Both are recorded in MUTATION.md's "the check's data cannot reach the failure it names" section, which now carries two more habits: assert on behaviour rather than on source text, and give a check whose failure mode is a hang a deadline. 19/19 green in the goodwe-addon-ci image. The one-inverter path is byte- identical: the whole existing suite runs unmodified against it. Version bumped to 0.6.0 - the Supervisor keys the built image by `version:`, so a schema change without a bump is a schema nobody reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
7ef3d23245 |
SAFETY-31: measure circulating power, which nets to zero at the meter
checks / unit (pull_request) Successful in 4m28s
Inverter A charging at 3 kW while inverter B discharges at 3 kW nets ~0 W at
P1. The site reads idle while both packs are cycled through two round-trip
conversion losses continuously, for no benefit, and the wear is real.
NOTHING EXISTING CAN SEE IT, AND THAT IS STRUCTURAL. Every guard in this
add-on is a consumer of the NET figure at the grid connection: SAFETY-02's
sign watchdog gates on abs(grid_w) > 300 and the gate never opens at a netted
zero; the peak guard and the capacity tariff both bill grid exchange.
Circulating power is precisely the component of per-inverter power that the
sum destroys, so no threshold on any existing detector could reach it.
WHAT ALLOC-01 ALREADY COVERED, AND IS NOT RE-IMPLEMENTED HERE. Its AC 7
shipped the sign-coherence gate (allocator.REFUSE_SIGN, graded by
test_alloc.py part 6): an allocation whose shares carry two signs is REFUSED,
both units named, no shares returned. That is the COMMAND half - the allocator
can no longer command circulating power. This commit is the MEASUREMENT half:
circulation the allocator NEVER COMMANDED.
app/circulation.py the sensor and the trip
main.py site_circulating_w, the evaluation, the claim, the alarm
arbiter.py `circulation` classified SCOPE_SITE, with the argument
store.py a flat, site-level durable latch
mqtt.py sensor.goodwe_circulating_power
config.yaml two options, two schema ranges, CFG-03 discipline
THE SENSOR: sum(|Bi|) - |sum(Bi)|. Zero when the units pull the same way,
2*min(|Ba|,|Bb|) when they oppose. DERIVED FROM MEASURED PER-INVERTER POWER -
Inverter.batt, register 0x0518 - and never from the allocation record. The
cheap version would be simpler and worth nothing: ALLOC-01's gate already
guarantees the record is sign-coherent, so a record-derived sensor reads zero
BY CONSTRUCTION and can only confirm that the allocator agrees with itself,
which is the third failure mode MUTATION.md names. What this catches is a unit
ignoring or lagging its share, a stale share still being executed, a
maintenance or SAFETY-25 pin holding one unit while the loop drives the other,
or HW-01's power stage running its last instruction for 6 h 25 m with the
comms dead and the allocation record impeccable throughout. batt became
per-instance in DEV-02; before that this could not have been written.
VERIFIED BY MAKING ONE UNIT IGNORE ITS SHARE, on the TEST-19 two-master rig:
S = -2500 W shares a = -1250 W b = -1250 W <- record ok, ONE sign, sums
MEASURED A = -1250 W B = +1000 W <- hardware
sensor over the RECORD : 0 W
sensor over MEASURED power : 2000 W -> trips at 190 s
and the card's own scenario, A -3000 W against B +3000 W at zero house load:
the meter reads +0 W, the sensor reads 6000 W, and the shipped SignWatchdog's
predicate is FALSE on 30 of 30 cycles.
WHAT "ACTED ON" MEANS - decided, and recorded in DECISIONS/. The trip files
Claim.set("circulation", P_SAFETY, 0.0) on the SITE actuator: every inverter
to 0 W, latched. HW-01 established that commanding a wedged inverter achieves
nothing, so the remedy is not aimed at it. A circulating loop needs TWO
participants and one of them is THIS CONTROLLER, faithfully driving the
listening sibling into the one that stopped listening. Stopping (1) halves the
loop, which needs no cooperation from the wedged unit, and (2) - the more
important half - makes the residue VISIBLE at the meter instead of cancelled
at it, where every ordinary guard can finally see it. What it cannot do is
stop the wedged unit; the notification says so and names the isolator as the
last actuator. SITE-scoped because the quantity is defined over the roster and
is identically zero for any one unit - there is no fact of the matter about
which of two opposing inverters is the offender, so filing it per-unit would
invent an attribution and could park the only unit still listening.
THE WINDOW IS FROM THE RIG, NOT A ROUND NUMBER (AC 3). 924 closed-loop runs in
which NOTHING is faulty, over both families of legitimate opposition (7
reversals x 5 write staggers; 7 flap periods x 4 amplitudes), swept across the
whole range the schema accepts for gain and slew_w. Worst CONTINUOUS
legitimate opposition by threshold: 505.0 s at 100 W, 40.0 s at 250 W, 10.0 s
at 300 W, 5.0 s at 400 W and above. The knee is 400 W and it is the schema
floor - below it, ordinary operation holds the sensor over the line for
minutes and no window inside the 900 s billing quarter could tell that from a
fault. circulation_w 700 is 1.75x the knee, the headroom bought for inverter
B's ramp and lag being PLACEHOLDERS until SPIKE-04 measures them.
circulation_hold_s 75 clears three bounds: 15x the worst legitimate episode,
2.5x the firmware's own LINK_STALE_S veto, 12x inside the quarter. A momentary
opposition while one unit slews through zero is LEGITIMATE - writes are
serialised, so B's number lands up to a cycle after A's - and 0 of the 924
runs trip.
THE ALARM SURVIVES AN HA RESTART (SAFETY-34). persistent_notification is in
memory, which is exactly how the 2026-09-02 outage went unseen for 6 h 25 m
with the detector correct throughout. The latch is persisted to /data as flat
site-level keys and the notification is RE-RAISED on the first cycle after
every start, through the same code path as a fresh trip - two paths would be
two chances for the restart one to rot unnoticed.
AT N=1 NOTHING CHANGES (AC 5). |B| - |B| is 0 for every reading including one
nobody took, no claim is ever filed, the arbitration is byte-identical, and
the status page says n/a rather than claiming to watch something that cannot
happen.
VERIFIED BY BREAKING IT (AC 4). 18 mutations, all RED on named checks, each on
a freshly copied tree with no __pycache__ mounted :ro. M10 - the sensor
rewritten to read self.allocation.shares - is the headline: it takes AC 2 red.
Three mutations are +0 B, the risky delta, and are marked as such. One
equivalent mutant (the max(0.0,...) guard) is ARGUED rather than claimed:
searched over 700 000 rosters without a negative. One genuine SURVIVOR was
found and closed - publish() turning an unknown into a published 0 W, the
sensor's own None rule defeated one layer out. Full ledger in DECISIONS/.
arbiter.CLAIM_SCOPE gains its ninth source, so test_peakguard.py's 10f and 10g
tripwires and test_arbiter.py's scope table went red on the line the append
was written. Updated deliberately, with the argument, as their own comments
require. Full 19-file suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
753f700539 |
DEV-02: merge release/1.0, and close the two review blockers
checks / unit (pull_request) Successful in 4m15s
PR #43 was rejected on two findings and had gone unmergeable behind SAFETY-28 and the ALLOC-01 fix. This is the merge commit; the conflict resolution and both fixes are below, because a merge that also changes behaviour has to say so. THE MERGE, AND WHAT IT COST =========================== Two conflicts in main.py, both where SAFETY-28 and this card touched the same method: * `write_setpoint()` split into a site entry point plus a per-unit `_write_unit()` here, while SAFETY-28 put its record gate in the old single-unit body. `write_refusal()` now takes the INVERTER it is about. Asking it with `self.unit` - as release/1.0 does - would authorise A's share on B's wire, and would also trip test_inverter.py part 1's AST scan, which goes red on any read of a Controller N=1 view inside Controller. * the SAFETY-28 gate sits above the clamp and below the non-finite coercion, exactly where release/1.0 put it, and is now asked about `inv`. Fallout the merge produced in the checks, re-aimed rather than relaxed: * every fixture that calls `write_setpoint()` DIRECTLY - the clamp probe, the hung-heartbeat experiment and its serialised counterfactual, the split deadline probe - now arms a real allocation record first. test_inverter.py grows an `arm()` built through the shipped `resolve()`, the same shape test_clamp.py's is: the per-unit envelopes are filed as real `supervised` claims and the site target is their sum, so the water-fill has exactly one answer and the fixture cannot quietly become its own allocator. * test_clamp.py's two-unit failsafe fixture assigned `c.units`, which DEV-02 made a derived property with no setter. It appends a real `Inverter`. BLOCKER 1 - AC 4's ARBITER-BOUND EVIDENCE WAS VACUOUS ===================================================== test_peakguard.py claimed "each unit's `supervised` bound is ITS OWN `max_w` - give inverter B a different rating and the claim filed on inverter B moves with it", and compared B's bound against `_b2.max_w`. `second_inverter()` DEFAULTED B's rating to `LIVE_MAX_W`, which is A's. IT COMPARED (-5000, 5000) AGAINST (-5000, 5000): a number against itself, unable to observe the failure it named. Reproduced: filing the per-unit `device`/`supervised` claims from `self.inverters[0]`'s numbers while leaving `actuator=self.actuator_of(..., inv)` alone left ALL 19 FILES GREEN. At a roster of two with A=5000/B=3000 that makes inverter B's planned envelope (-5000, 5000) instead of (-3000, 3000) - a 2000 W over-command the arbiter believes is supervised. It is a DIFFERENT defect from the actuator variant the static routing pin catches, and only the actuator variant was in the mutation table. * `second_inverter()` gives B 3000 W - a hypothesis, marked as one - for every fixture in the file, so no check there can compare B against A by accident. * the claim is asserted against LITERAL numbers rather than against an expression that can read the same object twice, for `supervised` AND for `device` (whose `dev` had the same defect), plus the envelope the ARBITER RESOLVED for B - a claim filed right and resolved wrong is the same defect one layer down. * a PRECONDITION check fails if the two ratings are ever made equal again. * the BOUND mutation is in the table, beside the actuator one, and was observed RED (3 checks in test_peakguard.py) via tools/mutation_run.sh. ⚠️ THIRD INSTANCE THIS SESSION of a check whose DATA cannot reach the failure it names - ARB-03's call-site scan saw 3 of 8 appends, ALLOC-01's replay generator saw 0 escapes where a widened one saw 39,991. MUTATION.md gains a section for it beside the stale-bytecode and masking ones, and the rule is written at the line: ANY CHECK ASSERTING "X DIFFERS FROM Y" MUST BE RUN ONCE WITH X AND Y ACTUALLY MADE DIFFERENT. BLOCKER 2 - THE LATCH WAS SILENTLY DISARMED BY THE ROLLBACK ROUND TRIP ====================================================================== store.py's rule 2 keeps the flat keys "so a rollback still finds its latch". The operator path that rule contemplates is a ROUND TRIP, and this card lost the latch on it: 1. install migrates -> schema_version=1, unit/inverter-a/... = False 2. operator DOWNGRADES; SAFETY-02 trips inside the downgrade window and the old add-on writes only the flat `sign_tripped: true` 3. operator upgrades again -> `rep.ran` is False, so `_grade_store_migration()` returned early, and `Inverter.__init__` read the namespaced key: False Observed: the controller came up with `sign_wd.tripped = False` while /data/state.json said `sign_tripped: true`. That is store.py's own header sentence - "the file still says sign_tripped: true, the banner is green, and the controller resumes driving the wrong way" - rebuilt by the version marker. A firmware version change with a rollback path was performed on this site on 2026-09-02, so this is not hypothetical. Three changes, each graded by a mutation observed RED: * THE OPERATOR'S CLEAR CLEARS BOTH SPELLINGS. That is what removes the "re-arm forever" objection the `rep.ran` gate existed to answer: with the fossil cleared, `flat True + namespaced False` is unambiguously a downgrade-window latch. * A TRIP MIRRORS THE FLAT PAIR. Rule 2 only ever covered the latch present AT MIGRATION TIME - nothing flat was written for a trip taken afterwards, so an operator who downgraded found `sign_tripped: false` and came up DRIVING. store.py claimed the staleness could only fail toward a SPURIOUS latch. It could not; that sentence is corrected rather than left standing. * THE GUARD RUNS ON EVERY START, and asks the STORE - not the roster - whether anything namespaced is latched. A mirrored trip on B must not re-arm a healthy A, and the store is the right place to ask because it holds every `unit/*/sign_tripped` this install has written, including one belonging to a unit that is not in today's roster. Graded by the three-step scenario itself, run end to end in test_inverter.py part 2, with all three preconditions asserted - including that the catching start did NOT migrate anything, which is what used to skip the guard. Part 2's idempotence check now PRESSES the button instead of hand-writing the store, because the state it used to construct is not one the shipped clear path can produce. Part 3 grades the mirror and the clear on a real roster of two. test_watchdog.py's "the flat key is NOT written" assertion is re-aimed rather than deleted: its argument was right about the READ and wrong about the WRITE. Nothing at runtime reads that key, and the one thing that does ignores it whenever any namespaced latch is set - which a sibling's trip always leaves. What its absence actually cost was the rollback. HANDOVER.md and DOCS.md said the flat key "is never read again", which was the instruction an operator clearing a trip by hand would follow. Both corrected: it is a maintained rollback view, it IS read on every start, and clearing a trip by hand means clearing both spellings. MUTATIONS THIS ROUND, each applied on a purged tree via tools/mutation_run.sh and observed RED: * per-unit `device`/`supervised` BOUNDS from `self.inverters[0]`, actuator left correct -> test_peakguard.py 10g * `_grade_store_migration()` gated on `rep.ran` -> test_inverter.py part 2 * the operator's clear leaving the flat keys set -> test_inverter.py part 2/3 * a trip not mirroring the flat pair -> test_inverter.py part 3 * the guard asking only `self.inverters[0]` -> test_inverter.py part 3 19/19 files green on the merged tree. No hardware was touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
e941bf7f62 |
DEV-02: zero every unit's target when the operator stops, not just the site's
checks / unit (pull_request) Successful in 4m12s
A hazard THIS CARD CREATED, found by re-reading the operator paths rather than by a failing check - so a check now exists for it. Before DEV-02 the heartbeat re-emitted `self.target`, so the stop button zeroing that one scalar was enough: the next refresh carried the zero. The heartbeat now re-emits `inv.target` - it has to, or it would put the SITE number on every inverter, which is the ARCH-01 broadcast fault arriving once every `heartbeat_s` on a path with no allocator in it. So a stop that zeroed only `self.target` left each unit's LAST SHARE standing. No cycle has run yet at that moment, so nothing else would have cleared it, and the very next heartbeat - up to `heartbeat_s` later - would put it back on the wire. The operator presses stop, watches the inverter go to zero, and sees it resume seconds later. `command_site_zero()` moves both together and carries the reason. It is one method rather than two lines at three call sites precisely because the two lines have to stay together: shutdown(), the stop button and maint_abort all mean the same thing, and 0 W is the one value that may legitimately be written site-wide. test_inverter.py part 6 presses stop with 995/995 standing and then runs the SHIPPED `send_heartbeats()` before any cycle, which is the racing refresh. The mutation that zeroes the site target alone takes it red. 19 files green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
e306c74cb8 |
DEV-02: per-instance inverter objects, so nothing is graded against a sibling
checks / unit (pull_request) Successful in 4m15s
Every piece of per-device state in the Controller was a scalar, and several of
them are safety inputs. app/inverter.py introduces an `Inverter` that owns its
entities, its rating, its device range, its SignWatchdog, its last_write and
its last_beat, under store keys namespaced by unit name. The Controller holds a
list of them and keeps only what is genuinely site-level: the P1 reading, the
site integrator, and the tariff quarter.
WHAT THE SCALARS WERE SILENTLY ASSERTING
dev_min/dev_max one pair, read from one setpoint_entity. A second
inverter's rating was NEVER READ AT ALL, so its clamp, its
freeze rail and the peak guard's achievable_w were the
first one's numbers.
soc one reading into the maintenance FSM, SAFETY-24's pack
floor and arbitrage's bounds. An average lets the emptier
pack be driven below its own floor.
sign_wd ONE SignWatchdog, whose own docstring says "one instance
per actuator", latched under FLAT store keys - so a trip
caused by B latched A across restarts and one operator
press cleared both.
last_beat one deadline and one serialised hass.call(), so a hung
write to A ate B's 30 s layer-1 budget.
actual_w BECAME SITE LEVEL WHILE THE READINGS BECAME PER-INSTANCE, and those
are opposite directions on purpose. compute()'s anti-windup test is
`abs(prev_w - actual_w) > saturation_w` with prev_w the SITE target, so feeding
it one inverter's power compares a site number against a unit number, trips the
saturation freeze permanently and leaves a standing grid error. Measured on
admin/goodwe's two-master rig (tools/alloc_sim.py --splits, 2 kW load, 40
cycles), worst |delta| between splits at the meter:
gain one unit's power the site sum
0.2 510 W 0 W
0.4 250 W 0 W
0.6 50 W 0 W
That was ALLOC-01's disclosed finding; test_alloc.py part 8 kept its stimulus
and now grades the other row.
THE STORE MIGRATION IS GRADED, NOT MERELY PERFORMED. sign_tripped/sign_reason
move to unit/<name>/... once, verified by READING BACK what was written, and
the version marker is set only when nothing that carried state was lost. The
flat keys stay in the file so a rollback still finds its latch. And __init__
compares the pre-migration flat latch against what unit A actually came up
with: if the latch did not survive it is RE-ARMED from the legacy value and
shouted about. A latch that silently fails to migrate is a disarmed safety
guard on an install whose state file says it is protected - which is strictly
worse than never persisting it, because now there is evidence saying it is
fine. test_inverter.py part 2 breaks the migration and watches the guard fire.
HEARTBEATS ARE INDEPENDENT AND THE BUDGET IS DERIVED. Each inverter owns its
deadline, the refreshes go out concurrently, and each write is bounded by
WRITE_BUDGET_S = 4 s. The inequality is graded rather than asserted: 4 +
heartbeat_s's schema maximum of 25 < 30, validate.FIRMWARE_INGEST_VETO_S.
Verified by injecting a transport that accepts and never answers.
live_units() ANSWERS HONESTLY. A unit whose every write has been rejected for a
whole layer-1 window is not commandable; the allocator drops it, the survivors
carry its share, the action log names it, and the dropped unit is commanded
0 W rather than left standing on its last share. A roster entry with NO
setpoint_entity is deliberately not handled there - that is a configuration
that cannot be delivered, and it reaches main.py's refusal branch, which is how
that branch keeps a live trigger after this card removed its old one.
AT N=1 NOTHING MOVES. The removed scalars survive as read/write properties of
the sole inverter, so every existing replay runs unmodified - and they REFUSE
at N>=2 rather than answering for the first inverter, which is the tripwire on
a consumer that was never made per-instance.
EVIDENCE. 19 files green. test_inverter.py is new (82 checks). Eighteen
mutations were applied to the shipped modules on a purged tree via
tools/mutation_run.sh and each was observed RED. Two of them SURVIVED first
time and the checks were changed rather than the table: the serialised dispatch
and the shared last_beat both passed while part 6 gathered its own writes and
computed its own `due` list. send_heartbeats() was extracted from run_control()
so the shipped dispatch is reachable by a check at all.
INVERTER B STILL DOES NOT EXIST. build_roster() returns exactly one inverter
from the flat options and is the one function CFG-04 changes; every two-unit
figure here is architecture, and the second rating in the checks is marked a
hypothesis.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
591c35ed01 |
SAFETY-28: gate the write path on the allocation record, not on key count
checks / unit (pull_request) Successful in 4m19s
specs §1.5 makes "any configuration in which two writers can reach one
actuator" a non-goal. That non-goal was enforced by counting config keys:
validate.py's ACTUATOR_WRITE_KEYS = ("setpoint_entity",), consumed at rule 1,
with a comment stating the guarantee outright - "there is exactly one such
key, so a second writer is not expressible in the options schema at all".
That was true and it graded THE SHAPE OF THE OPTIONS SCHEMA, not the writes.
Add CFG-04's per-inverter config - a roster of inverters each with its own
setpoint_entity - and there are two real writers with len() of that tuple
still equal to 1. The check stays green while the property it protects is
gone. sim/inject.py's two_writers scenario asserted on the same tuple, so it
went green too. That is this repository's named failure mode: a check that
observes something other than what it claims to observe.
Demonstrated rather than argued. test_validate.py part 2 now constructs the
hazardous per-inverter config, asserts that the OLD proxy is still green on
it, and then asserts the new check refuses it. Against release/1.0's
validator the updated sim scenario reports: "validate_startup_config()
ACCEPTED a configuration in which 3 declarations reach the ONE actuator
number.goodwe_setpoint ... the old proxy reports 1 write key(s) on this same
config and is therefore GREEN on it".
THE WRITE PATH IS NOW GATED ON THE RECORD
=========================================
Controller.write_refusal() asks Allocation.authorises() whether the single
allocator placed exactly this number, for this unit, on this tick.
write_setpoint() refuses anything else. A second writer with arithmetic of
its own cannot satisfy that - its number appears in no record - so it has to
file a Claim and go through resolve(), which is the one allocator. "Exactly
one allocator per shared resource" stops being a convention somebody can
forget and becomes something the write path checks.
The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in place of
the number nothing stands behind. Returning would leave the inverter latched
on its last command with the ESP32 heartbeat still being fed - holding an
unauthorised setpoint quietly, which is the outcome the gate exists to stop.
AC 1 is "refused at the write path, not merely logged", and test_clamp.py
part 7a grades the device's writes, not the log.
The gate sits BELOW the non-finite coercion and ABOVE the clamp. Below,
because a nan is loss of input and rule 1 has already turned it into 0 W.
Above, because the question is who chose this number, and the answer is only
meaningful about the number that was asked for - gating the CLAMPED value
would refuse exactly the cycles SAFETY-03 exists for, since a clamped write
differs from the allocator's share by construction. Mutation M7 moves the
gate below the clamp and turns nine checks red, part 6c among them.
A STALE RECORD CANNOT BE REPLAYED
=================================
Every Allocation now carries the `tick` it was made for. It is handed IN by
the caller - plan(..., tick=) - rather than counted inside the allocator,
because test_alloc.py part 10 grades that the module holds no state at all,
and a serial number incremented in there would be the cached-across-ticks
thing the envelope rule forbids wearing a different hat.
Controller.alloc_tick advances ONCE PER RESOLUTION, not once per loop
iteration, and both halves are load-bearing:
* a new allocation makes the previous record stale immediately, so a record
whose envelopes have moved cannot authorise a write. That is the replay
half (AC 2), graded by capturing a real record, running another real
cycle, putting the old one back and observing refusal.
* the "inputs missing, not yet stale" hold returns BEFORE the allocator
runs, exactly so one dropped meter reading does not zero the inverter,
and the heartbeat re-affirms self.target through this same gate while it
does. A tick incremented per loop iteration would refuse that heartbeat
and turn the hold into the outage it exists to prevent. Graded.
An UNSTAMPED record (tick None) authorises nothing: "I cannot say when I was
made" fails closed rather than reading as tick zero.
THE FAILSAFE CANNOT DEADLOCK BEHIND THE GATE, AND HERE IS WHY
=============================================================
write_setpoint() is also how layer 2 commands 0 W. cycle()'s two refusal
branches substitute a 0 W failsafe and write it like any other cycle;
shutdown() and the operator's stop button do the same. EVERY ONE OF THOSE
RUNS WHILE THE ALLOCATOR HAS REFUSED - ok is False and there are no shares -
so a gate that asked the allocator's permission to command zero would block
the safety write behind the very component whose failure the safety write
answers. A gate that can do that is worse than the hole it closes.
Resolved by authorising 0 W unconditionally, and on the VALUE rather than on
a failsafe=True flag: a flag is an assertion by the caller, which is exactly
what a second writer would pass; the value is a fact about what will be
written. The argument that this is safe and not merely convenient is that the
hazard §1.5 names is N-times loop gain from BROADCAST - handing every inverter
the same target so each supplies it in full (allocator.py's opening argument;
TEST-19 measured a permanent 4000 W p2p limit cycle at gain 1.0 with no guard
firing). At S = 0 the broadcast and the correct allocation COINCIDE: a_i = 1
for every unit gives every unit 0 W, which is exactly its share. There is no
over-command to make at zero. Said at the line, in write_refusal().
test_clamp.py part 7c drives it end to end through the real cycle() and the
real REFUSE_EMPTY branch: the allocator refuses every tick, the device still
sees 0 W, and the action log says ARBITER: REFUSED rather than WRITE REFUSED
so the two failures stay distinguishable to whoever has to debug them.
unmet_w IS STILL NOT ok
=======================
The gate reads ok and the unit's share and never unmet_w. allocator.py:179
says why: an allocation that could not place every watt is still safe to
write, and fusing them would let one saturated inverter park the whole site.
Graded on a record with 3800 W unmet and every share legal - mutation M5 adds
`or self.unmet_w` to the gate and that check goes red.
THE CONFIG CHECK, REPLACED RATHER THAN DELETED
==============================================
actuator_writers() counts WRITERS PER ACTUATOR instead of option keys. It
walks the options TREE rather than looking for a container called `inverters`,
because CFG-04 has not chosen that name yet and a census that guessed wrong
would go green while two writers existed - the same defect one step along.
Entity ids are folded to lower case so a second writer cannot be smuggled in
as Number.X versus number.x.
Two inverters with two entities are ACCEPTED - that is what the per-inverter
roster is for, and a validator that refuses the feature it guards is one
somebody switches off. Two declarations aimed at ONE entity are refused,
naming the actuator and every writer pointed at it. That includes the case an
author will meet first - keeping setpoint_entity as A's and listing A again -
because "they happen to be the same entity, so it is fine" is precisely the
reasoning that makes two writers invisible.
ACTUATOR_WRITE_KEYS survives ONLY as the enumeration a census needs, with its
guarantee comment deleted; nothing asserts on its length any more. Keeping the
name is also what stops admin/goodwe's current two_writers scenario failing on
an AttributeError between the two merges.
WHAT NEITHER CHECK OBSERVES, SAID OUT LOUD
==========================================
The census sees CONFIGURED writers. A second write path added in CODE - a
hass.set_number() call next to some new strategy - declares nothing and is
invisible to it, exactly as it was invisible to the tuple. That half is not
closed at startup and cannot be; it is closed at runtime by the record gate,
which does not care what the options look like or who called the method. The
two checks are deliberately different kinds, and only the runtime one survives
a schema this file has not seen yet.
EVIDENCE
========
* Full 18-file suite green on a purged tree (tools/mutation_run.sh).
* Verified by breaking it - seven mutations, each on a purged tree, each
reverted and re-verified:
M1 remove the gate -> test_clamp red (4 checks)
M2 make the zero exit an amnesty -> test_clamp red (12 checks)
M3 drop the tick comparison -> test_clamp red (5 checks)
M4 delete the census refusal -> test_validate red (3 checks)
M5 fuse unmet_w into ok -> test_clamp red (1 check)
M6 freeze the tick on the live path -> test_clamp red (2 checks)
M7 move the gate below the clamp -> test_clamp red (9 checks)
* AC 5: no existing check was weakened. The two edits to test_clamp.py are
fixtures, not assertions - parts 5 and 6c call write_setpoint() directly
with a number nothing allocated, which is now refused, so they say where
their number came from. Every assertion in both parts is unchanged, and
part 7f asserts that across part 3's sixty real cycles the gate refused
zero writes.
* admin/goodwe's TEST-19 norig scenarios (planner_binding, two_master_plant,
two_master_broadcast) still pass against this tree - they import the
add-on's control law across the repo boundary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3fe3c7acb0 |
ALLOC-01: clamp each envelope end on its own, and widen the replay that missed it
checks / unit (pull_request) Successful in 4m9s
Two blocking findings from the review of
|
||
|
|
186e13ceca |
ALLOC-01: allocate the site grid target, sum-invariant, never broadcast
checks / unit (pull_request) Successful in 4m6s
ARB-03 left `allocate()` as a documented seam that passed through at N<=1 and
DECLINED at N>=2, with the reasoning recorded at the line: the obvious
placeholder - hand the site target to each inverter - IS the ARCH-01 fault, and
"a placeholder that cannot be told apart from the real thing by any guard we
have is worse than no placeholder". This fills the seam.
WHY A BROADCAST IS A FAULT AND NOT AN INEFFICIENCY. The control primitive is
meter emulation: the master reports a FABRICATED grid reading and the inverter
regulates itself to it, so one number means "the battery moves X W" to this
add-on and "the grid is at X W" to the inverter - an identity that holds only at
N=1. Hand two inverters the same target and each supplies it IN FULL, so the
loop gain at the meter becomes N times the per-unit gain. Reproduced on
admin/goodwe's two-master rig against this tree: -400 W export overshoot at
gain 0.6, and at gain 1.0 - which config.yaml accepts as its documented maximum
- a permanent 4000 W peak-to-peak limit cycle, commands alternating
1000/2000/1000/0 W, with NOT ONE GUARD FIRING. The sign watchdog is evaluated on
every cycle and never trips, because the disagreement flips on alternate ticks
and never holds its 180 s window.
WHAT WAS BUILT. `app/allocator.py`, host-side and strictly above the driver
seam. It consumes intervals and a number and nothing else - no claims, no
priorities, and no measurement, because an allocator that read P1 would be a
SECOND integrator on the site measurement.
* ENVELOPE CLAMP, from ARB-03 stage 1's `UnitBounds`, read PER TICK. `plan()`
is a pure function and the module holds no state, so there is nowhere for a
construction-time envelope to be cached.
* WATER-FILL by weight, with the surplus of a full unit moving to the others.
Weights express PREFERENCE, envelopes express PERMISSION: a zero weight is
honoured while another unit has headroom and overridden when it does not,
because the SUM is the safety property and stranding capacity behind a
comfort choice manufactures a standing error at the meter.
* LARGEST-REMAINDER ROUNDING. The target is rounded ONCE, at the top; every
sum below that line is integer arithmetic, so it cannot drift. Measured: a
per-unit round() drifts -125 W over 50 000 ticks at three inverters.
* THE ALLOCATION RECORD, with `unmet_w`, `dropped` and a per-unit reason.
`ok` is one boolean for SAFETY-28 to gate on, and `unmet_w` is deliberately
NOT fused into it - a saturated inverter must not park the whole site.
THE STABILITY PROPERTY IS STRUCTURAL. With sum(a_i) = 1 the gain at the meter is
sum(a_i * g_i), a convex combination of the per-unit gains, so it lies in
[min g_i, max g_i] FOR ANY SPLIT. That is what makes the weights a comfort
choice rather than a tuning parameter, and it is why a bad split cannot
destabilise the site. Graded directly, and measured end to end: 90/10, 50/50 and
100/0 give a bit-identical aggregate trace at the meter, and a gain sweep to 1.0
at every split has a flat tail at all 84 combinations.
THE TWO REFUSALS main.py NOW MAKES, AND WHY THERE ARE TWO. `allocated=False` no
longer means "no allocator"; it means no safe split EXISTS - a mis-routed claim,
an empty envelope set, or a split that could only be made by commanding one pack
to charge while another discharges. Separately, and this guard is the price of
making allocate() succeed rather than something inherited, main.py refuses to
act on a valid split it cannot DELIVER: there is one `setpoint_entity`, so
writing the site target to the one inverter it can reach would over-command that
inverter by exactly the other's share. Commanding a partial site is a worse
instruction than commanding nothing. DEV-02 gives it the second writer.
scope_violations() IS NOW CONSUMED, AND AS A REFUSAL. ARB-03 left it logging
without changing the resolution. A mis-routed claim means the number about to be
divided may itself be wrong, and dividing a number that may be wrong is worse
than not dividing it - the same answer clause 3 already gives a contradiction.
It is NOT promoted into an envelope: a unit-scoped claim that landed on the site
names no unit, so promoting it would mean guessing one, or applying one
inverter's rating to a sibling that does not have it.
AT N<=1 IT IS A PASS-THROUGH, BYTE-IDENTICAL: the share is the site target, as a
float, unrounded and unclamped. All 17 existing suites replay unedited.
test_peakguard.py part 10h and test_arbiter.py part 7 were RE-AIMED, not
deleted. PR #40's reviewer proved that refusal branch was unreachable and
untested once; removing its trigger without giving it a new one would have
rebuilt the hole. Part 10h now drives three real cycles - a deliverable split, a
dead sibling, and every master stale - and grades all three.
KNOWN AND NOT CLOSED HERE: at a roster of two the loop's `actual_w` is one
inverter's power used as a site measurement, because the add-on binds a single
`inverter_power` sensor. That is the SENSING half of the same N=1 identity this
card removes from the COMMANDING half, and it makes the aggregate trace
split-dependent below gain 1.0. Measured with its counterfactual in test_alloc.py
part 8 and tools/alloc_sim.py --splits. It belongs to DEV-02.
Verified: all 18 suites green in the CI image. 16 mutations run through
tools/mutation_run.sh on a purged tree; 13 red, and the 3 survivors are recorded
in test_alloc.py part 12 with the reason each cannot be caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
38f6680639 |
ARB-03: pin all eight actuator call sites statically
checks / unit (pull_request) Successful in 3m45s
The independent review of PR #40 found AC 6 met at three of the eight claim construction sites while the check claimed to cover the class - this repository's named failure mode, a check observing something other than what it claimed, reproduced inside the check written to prevent it. WHAT WAS WRONG. Part 10g's only call-site check was a real two-inverter cycle. That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign watchdog, the guard idle and arbitrage off, so it reaches `device`, `supervised` and `loop` and nothing else - three of the eight direct appends in `Controller.cycle()`. Its own check text nevertheless read "the line a mis-typed `actuator_of(...)` at a call site goes red on", and the PR body's mutation table presented it as covering the class. The reviewer proved the gap on the shipped tree: `actuator_of("maintenance")` -> `actuator_of("peak")` at the maintenance-shaping append left test_arbiter.py, test_peakguard.py and test_arbitrage.py ALL GREEN, while at a roster of two it built a site loop of -1500 W against the guard's site `lo` of 1500 W and the now-misrouted charge-only `hi = 0` - `lo > hi`, arbiter clause 3, 0 W IN THE BILLED QUARTER, SAFETY-25 §0a's fatal case from a one-word typo. The same mutation at the `failsafe` append survived too. The runtime detector does fire - `scope_violations()` names it - but it only logs; nothing graded it. THE FIX, over machinery already present. Part 10f's AST scan already reads each `claims.append(...)` in `Controller.cycle()`. All eight direct appends expose their actuator statically as `actuator=self.actuator_of('<literal>')`, so 10g now requires that literal to EQUAL the source the claim is filed under, read as SOURCE TEXT rather than evaluated - `actuator_of("peak")` at the maintenance append is the same VALUE at the shipped roster of one and a different string here. That pins all eight routing sites, on the line the typo is written, in every configuration and at every roster, exactly the way 10f pins the source set. Two further lines keep the scan from going vacuous: every direct append must be readable in that shape (a computed or hand-written actuator fails there), and the ten appends must still account for 10f's eight sources. The two INDIRECT appends carry no `actuator=` at the call site to mis-type. `peak_claim` and `arb_claim` are routed where their claim is built, and are pinned there - `peakguard.claim()` by the SWEEP_SITE sweep over every load x elapsed x battery x SoC x floor combination, `arbitrage.claim()` by test_arbitrage.py. 10g now says so at the line, and says which three of the eight its dynamic cycle actually sees, rather than rounding up. MEASURED, NOT ARGUED. Each of the eight direct appends mutated in turn to a wrong actuator - device->loop, supervised->peak, failsafe->safety, safety(stopped)->device, safety(inputs missing)->maintenance, maintenance(SET)->loop, maintenance(shaping)->peak, loop->supervised - each on a purged tree via tools/mutation_run.sh over all 17 files. All eight RED, every one of them on the new line. TWO SURVIVING MUTATIONS OF THE SAME CLASS, ALSO CLOSED. * `resolve()` did not have to honour the roster it was given: `if units is None:` -> `if True:` left the whole suite green. Ignoring the argument makes a unit that filed nothing this cycle cease to exist - a site of two reads as a site of one and stage 3 passes the whole grid target through to the inverter that spoke, which is ARCH-01's fault with its own evidence removed. test_arbiter.py part 7 now grades it on the one case that separates the two, a SILENT unit. Mutation RED in test_arbiter.py and test_peakguard.py. * main.py's N>=2 refusal was unreachable and ungraded: `if not resolution.allocated:` -> `if False:` left the whole suite green, so the right instinct was one refactor away from being deleted as dead code. The guard is KEPT - the alternative default is the ARCH-01 fault - and new part 10h grades it on the two-inverter cycle that already existed: the 0 W, the `failsafe` winner, the log line naming the roster size, and the write that follows. Mutation RED. AND THE WORDING CORRECTED. main.py does not "refuse to write" on `allocated=False`. It SUBSTITUTES a 0 W failsafe and then calls `write_setpoint(0.0)` like any other cycle. 0 W is the correct fail-toward-inaction direction and the log text is accurate about what it refuses - to BROADCAST the site target - but "refuses to write" overstates the code. Said as what it does, in the CHANGELOG, at the branch in main.py, and in 10h's own check text. `resolve()`'s docstring now says the roster argument has to be HONOURED, which is not the same as existing. 17/17 green on a purged tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
06f95e0a64 |
ARB-03: restore Claim.actuator and make resolve() three-stage
checks / unit (pull_request) Successful in 3m45s
specs.md:467 declares `actuator: str` as the FIRST field of `Claim`.
app/arbiter.py:55 had no such field. The specification designed
per-actuator claims and the implementation dropped it, so every claim was
implicitly about "the one and only actuator", resolve() returned one scalar
Resolution.target_w, and main.py assigned it to one self.target. THERE WAS
NO SEAM AN ALLOCATOR COULD OCCUPY - and ARCH-01 (specs.md §6.2, 2026-09-02)
decided the site grid target is ALLOCATED across several inverters and never
broadcast. This is a regression fix against the written design; creating that
seam is the whole of the change.
WHY IT MATTERS, IN THE UNITS THE SYSTEM IS BILLED IN. The control primitive
is meter emulation: the master reports a fabricated grid reading and the
inverter regulates itself to it, so one number means "the battery moves X W"
to the add-on and "the grid is at X W" to the inverter - an identity that
holds only at N=1. Broadcast one target to two inverters and each supplies it
in full; the loop gain at the meter becomes N times the per-unit gain, which
at the gain config.yaml:374 accepts is a permanent limit cycle with every
watchdog in §11.2 reporting healthy. And a per-inverter reserve filed as a
site `hi` against the peak guard's site `lo` is `lo > hi`, arbiter clause 3,
0 W IN THE BILLED QUARTER - the parked battery SAFETY-25's memo calls fatal.
THE SPLIT, VERIFIED AT EACH CONSTRUCTION SITE RATHER THAN ACCEPTED FROM THE
SURVEY. Four sources are about the GRID CONNECTION and are SITE claims:
`loop` (compute() regulates grid_w, the P1 measurement, and §6.2's whole
architecture is one integrator on P1 whose output is then split), `peak` (the
15-minute average offtake AT the grid connection, which is what the capacity
tariff bills - one quarter-hour peak per site, not one per pack),
`arbitrage` (buys and sells at the grid against a day-ahead price; the price
is a property of the connection and the energy crosses the meter) and
`safety` (both construction sites read: "stopped" is the operator switching
control off for the site, "inputs missing" is the site's own meter gone -
neither is about a pack, and both must stop every inverter).
Four are about a pack, a device or one write and are PER-INVERTER: `device`
(one inverter's own reported min/max - there is no site rating),
`supervised` (max_w caps what may be WRITTEN to one setpoint entity, §11.3,
so it is scoped like `device` for the same reason), `maintenance` (§12's
recalibration is a pack procedure - drain THIS pack, charge THIS pack,
recalibrate THIS pack's counter) and `failsafe`. `failsafe` is the entry that
needed the reading rather than the survey: SAFETY-02's EVIDENCE is site-wide
(it compares P1 against the standing command) but its CONCLUSION is
"the unit I am driving is pushing power the wrong way" and its remedy is to
park that unit. At N>=2 the same evidence cannot be attributed to a sibling
this controller never commanded, so a site-wide stop would park an inverter
this watchdog never graded. The reasoning for all eight is recorded AT THE
LINE, in arbiter.CLAIM_SCOPE and again at each claims.append in main.py.
WHAT WAS BUILT. resolve() is three stages, each a separate function so a test
can construct a case at any one of them:
pre_resolve() - one inverter's own claims -> that unit's interval. Clauses
1 and 2 transposed: the unit's own winning SET gates its
own limits. The SET's value travels BESIDE the interval,
not inside it - folding a -2500 W drain into
[-2500, -2500] would turn "clamped to max_w" into
"contradicts max_w", a different and wrong answer.
site_resolve() - THE SHIPPED ARBITER, UNCHANGED. Same code, new name.
allocate() - THE SEAM, AND DELIBERATELY NOT AN ALLOCATOR. ALLOC-01
builds it. At N<=1 it passes the target through (§6.2's
identity). At N>=2 it DECLINES - no shares,
allocated=False - and main.py refuses to write rather than
broadcast, because the obvious placeholder (hand the target
to each) is the fault ARCH-01 was filed about and no guard
we have can tell it apart from the real thing.
THE CONTRADICTION IS NOT GUARDED AGAINST, IT CANNOT BE WRITTEN DOWN. The
partition is by `actuator` and it is a partition: a unit's `hi` and the site's
`lo` are never both terms of one `lo > hi` test. Constructed and swept - 40
filings (every per-inverter source x unit x bound, including a unit
contradicting ITSELF at lo 900 > hi 100) against a live site floor, and not
one moves the site target or raises a contradiction. A unit's own impossible
pair parks that unit and says so on its own bounds; it does not park the site.
That is SAFETY-25 §0c's table made mechanical.
AT N=1 THIS IS A PURE REFACTOR, AND THAT IS PROVEN RATHER THAN ASSERTED. With
one inverter the fabricated grid reading and the battery setpoint are the same
number, so the grid connection and the only unit ARE one actuator:
actuator_for() routes every source to the site, no claim is filed on a unit,
stage 2 sees the whole list and answers what the shipped arbiter answered.
All 17 suites replay UNEDITED above the ARB-03 sections and are green on the
same numbers; test_arbiter.py additionally runs 13 claim lists through both
resolve() and site_resolve() and compares target, winner, binder,
contradiction flag, the considered list and the operator line.
EVIDENCE, BY BREAKING EACH THING (tools/mutation_run.sh, purged tree):
* Claim's dataclass default emptied -> RED (test_arbiter)
...and it survived until the check was added: the two helpers pass their
own default down, so the FIELD's default never ran. Now checked directly.
* `peak` reclassified SCOPE_UNIT -> RED (arbiter x4, peakguard x2)
* `maintenance` reclassified SCOPE_SITE -> RED (arbiter x4, peakguard)
* the N=1 identity dropped in actuator_for() -> RED (arbiter, peakguard
incl. the maintenance/peak suppression checks, freeze) - which is the
measurement that says splitting at N=1 would be a behaviour change
* resolve() stops partitioning -> RED (arbiter x4)
* allocate() broadcasts at N>=2 -> RED (arbiter x2)
* stage 1 drops clause-2 gating -> RED (arbiter)
* a call site routes `device` through `loop` -> RED (peakguard 10g)
* SAFETY-25 Option B built as a ninth source -> RED (peakguard 10f, both
halves, plus 10g) - the tripwire still bites
SAFETY-24's asymmetry proof is re-run and holds: 0 negative floors over the
shipped sweep, `hi` still +inf on every claim the guard files, and it now also
asserts every one of them is on the SITE actuator - a floor bound to one
inverter's share would leave the site, the thing the capacity tariff meters,
with no floor at all.
test_peakguard.py part 10f is kept, not adapted around. `source` is still
Claim.set()'s first positional argument precisely so its AST scan keeps
reading it, which is why `actuator` is the LAST field of Claim and not the
first - moving `source` off position 0 would silently blind the tripwire that
guards SAFETY-25's deferred option. The one adaptation is `**kw` on its spy,
so main.py's roster passes through unread rather than being silently dropped.
New part 10g pins CLAIM_SCOPE to the same eight-source set and grades a REAL
two-inverter cycle, which is the only place a mis-typed actuator_of(...) at a
call site is observable - at N=1 every source legitimately routes to the site,
so a swap there is invisible by construction.
No config key, no option, no operator surface, no default moved. Inverter B
does not exist yet and nothing here measures it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
ab6fbe2f9e |
SAFETY-25: record the owner's decision - option a, with alloc-01 as the revisit trigger
checks / unit (pull_request) Successful in 3m43s
The product owner answered SAFETY-25's §1 on 2026-09-02: OPTION A. The BMS cut-out is the DELIBERATE and only backstop for demand-driven discharge on the grid-following loop, and Option B - a loop-side reserve floor - is DEFERRED rather than rejected. Option A is the shipped behaviour, so this is a recording, not a change. `app/` is touched in exactly two places and both are comments that claimed the question was still open (freeze.py's finding, peakguard.py's point 2). No setpoint, no claim, no option, no default moved. The 17-suite run is green on the same numbers as before. WHY DEFERRING IS THE SAFE DIRECTION. Option B's proof - bms_soc_floor < loop_soc_floor <= peak_soc_floor, run through the real arbiter in test_peakguard.py part 10d - has an unstated premise: the reserve predicate and discharge_permitted() read the SAME state of charge. With two packs they read different numbers. The reserve binds on the emptier pack and files hi = 0; the peak guard, reading the other pack or an average, files lo = excess > 0; lo > hi is arbiter clause 3 and the answer is 0 W - the battery parked in the billed quarter, which is the failure the memo itself marks fatal. And no config validator can catch it: it is a runtime divergence between two readings, not a config ordering, so CFG-01's shape of check passes on the exact tree that parks the battery. Option A's cost is energy; Option B's cost built too early is the one quarter of the month that sets the bill. THE REVISIT TRIGGER IS ALLOC-01, recorded in §0c. Under ARCH-01's host-side allocator the two claims stop sharing a list: the loop floor becomes a per-inverter `hi` consumed as an INTERVAL at stage A, while the peak floor stays a site `lo` at stage B. Different claim lists, different stages, structurally unable to cross - a stronger argument than the ordering rule, because it makes the crossing unreachable rather than harmless. That is the shape in which Option B becomes safe to build. WHAT HAS TO BE RE-EARNED AT N=2 is stated plainly in §0d rather than left for someone to discover. SAFETY-27 now names five floors across two packs, so "the SoC" stops being a scalar the config can bound. And part 10f - the pin that made SAFETY-25 mergeable - CANNOT EXPRESS TWO PACKS: its dynamic half is a scalar 0-100 % SoC sweep against a single-series capture, and a two-pack exposure lives in the DIFFERENCE between two readings, which that sweep visits at no resolution. Its static half, the AST scan pinning the eight claim sources, still bites at N=2; the dynamic half goes silent. Merging a two-pack reserve on 10f's current green would be merging on a check that cannot see the failure. The options analysis is kept intact on purpose - §1-§7 stand exactly as written, including §5's "(recommended)" on Option B and §6's recommendation, with a new §0 saying what was decided and §6 marked as answered. A memo that recorded the verdict and deleted the reasoning is the failure this DECISIONS/ directory exists to prevent. Part 10e's check moved with the memo: it used to pin the honest "Status: OPEN" line and now pins that the memo names the option, the date and the trigger AND still carries both options and the recommendation. The tripwire itself stays - a ninth claim source in Controller.cycle() still takes 10f red on the line it is written, and "deferred" is precisely the state in which someone builds Option B by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b68f288fef |
TEST-11: pin the clamp guard to the write it reads
checks / unit (pull_request) Successful in 3m40s
check_output_clamp() sits one line below `await self.write_setpoint(self.target)` in cycle(), and moving it above the write left test_clamp.py, test_freeze.py and test_loop.py ALL GREEN. Measured on the move itself before this commit; measured again after, where test_clamp.py part 6 goes red and the other two are still green - the gap was real and it was only ever in this file. WHAT IS ACTUALLY LOST. The guard has two evidence sources. `self.loop_clamped` is set from the Decision well above the write and survives the move, and on the 10.3 runaway replay it is the source of every engagement the alarm reports (0 write-path engagements against 2 loop-side ones) - which is exactly what makes the move nearly invisible. `self.clamp_last` is assigned by write_setpoint() and by nothing else, is None until it has run, and is what `engaged_write` reads at main.py:856. Moved above the write, that consumer goes quietly false and a DEVICE-bound clamp - max_w above the inverter's own rating, SAFETY-20's install - is reported by nothing. A guard whose second evidence source can be silently disconnected is one refactor from being half a guard. WHAT THE CHECK PINS, AND WHAT IT DELIBERATELY DOES NOT. Not the position: the comment at that line warns against tidying SAFETY-03's ordering and SAFETY-18's into one rule, and a check a rename breaks is worse than none. It pins the CONSEQUENCE - what `self.clamp_last` held each time the guard consulted the watchdog - using a spy subclassed from the shipped ClampWatchdog over a slew-limited ramp where every cycle's command is a different number, so "this cycle's evidence" and "last cycle's" are distinguishable at all. The spy hooks `clamp_wd.note()`, the consumer, rather than the guard's name: that is what keeps a rename legitimate. The same assertions are re-run through a controller whose guard is renamed and whose write lives in a helper, and were verified against main.py itself - renaming the method throughout AND moving both calls into a new `_emit_and_guard()` helper keeps part 6 green; swapping the two calls turns it red on a line that names clamp_last. WHY AC 3 NEEDED A NEW STIMULUS, AND WHY IT IS NOT DRIVEN THROUGH cycle(). Through the real cycle() the write-path half cannot engage: the arbiter files the device range and max_w as P_SAFETY LIMIT claims, so resolution.target_w is already inside both bounds before write_setpoint() re-applies them. That duplication is deliberate defence in depth; part 6 now MEASURES the zero engagements rather than leaving it as a claim in a comment, and then drives the write path directly at max_w 5000 against a +-2500 W device - the wire carries 2500 W, every loop-side flag stays clean, and the alarm fires on clamp_last alone. With the two calls swapped the same stimulus clamps the same value and the alarm is silent, which is the ticket in one pair of runs. main.py gains one comment sentence naming the check, mirroring the one SAFETY-18's block already carries for test_freeze.py part 5. No behaviour changes. Full suite: 17/17 green in goodwe-addon-ci. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d5b1ef19b3 |
CFG-03: give maintenance option defaults one source of truth
checks / unit (pull_request) Successful in 3m44s
Option defaults for the maintenance cycle were declared in three places that
could disagree, and MAINT-04 proved they already did. config.yaml shipped
`maintenance_charge_w: 2000`; main.py carried TWO `opts.get(..., 2500)`
fallbacks; MaintConfig's dataclass carried a third `2500.0`. So an options dict
with the key ABSENT was ACCEPTED by validate.py's rule 6 - which read 2000 -
and then built by main.py at 2500, against a 2000 W ceiling:
options dict with maintenance_charge_w ABSENT, max_w=2000
validate_startup_config -> [] (accepted, no problems)
MaintConfig().charge_w -> 2500.0
arbiter.resolve() -> 0.0 W, contradiction=True
That is the bug rule 6 exists to refuse, passing rule 6. It is unreachable on a
Supervisor install, which always supplies every key from config.yaml, and fully
reachable from sim/inject.py and from the test suite - which is where anyone
debugging maintenance actually works.
MAINT-04 shipped a TRIPWIRE and said so: test_validate.py pinned all three
literals and asserted the consequence through the real arbiter, so the
divergence could not silently widen or be half-closed. Its PASSING CONDITION
WAS THE BUG. Both it and validate.py's comment said to delete each other when
the divergence was closed. It is closed, so both are deleted.
THE DIVERGENCE WAS WIDER THAN ONE KEY. `maintenance_discharge_w` had the
identical three-way split (config.yaml 2000, MaintConfig 2500) and the card
named only the charge side. Closing one and leaving its twin would have
satisfied the numbers and left three sources of truth one line down, which is
the state that produced the bug. Both are closed, and so is the whole family:
all eight `maintenance_*` keys go through the same mechanism.
WHAT THE SINGLE SOURCE IS. config.yaml's `options:` block - it is what the
Supervisor reads and what the operator edits, so it wins by definition. There
is now exactly one in-code copy, `maintenance.MAINT_OPTION_DEFAULTS`, which is
TARIFF-02's `PRICE_OPTION_DEFAULTS` and SAFETY-24's `PEAK_OPTION_DEFAULTS`
pattern applied to the family that named the card - validate.py's rule 8 comment
already called that "CFG-03 read as a rule rather than as a complaint", so this
makes the rule hold where it was first broken rather than inventing a fourth
mechanism for one option family.
A MIRROR RATHER THAN A RUNTIME READ, and the reason is mechanical: the
Dockerfile copies `app/` and `run.sh` into the image and NOT config.yaml. The
running add-on cannot open the file it is configured by, so there is nothing to
read at 03:00 on a client's Pi, and a YAML-subset parser that could fail at
import is a new way for the add-on not to start. What makes the copy safe is
that test_maintenance.py pins every entry by READING the shipped file - drift is
a CI failure, not a comment.
EVERY OTHER SITE NOW DERIVES FROM IT OR HAS CEASED TO EXIST:
* MaintConfig's eight dataclass defaults are indexed out of the mirror. No
numeric literal survives in the class that held the third opinion.
* `MaintConfig.from_options()` is the ONLY place in the add-on that reads a
`maintenance_*` option. main.py's eight inline `opts.get(key, <literal>)`
calls are gone: moving the read next to the mirror means the fallback cannot
be anything but the mirror, because there is no literal in sight of it to
disagree with. It uses `key in opts` rather than truthiness, so a shipped
`false` or `0` is honoured instead of being quietly replaced by the default
- this card's own bug in a different costume.
* main.py's SECOND fallback site - the cheap-window charge floor at the
P_MAINT_SHAPE claim - reads `self.maint.cfg.charge_w` instead of re-opening
the options dict with its own literal. The Controller had already parsed
this once in __init__; asking the object that owns it cannot drift from it,
and the rate the shaping asks for can no longer be a different number from
the rate the state machine drains at on the same install.
* validate.py's rule 6 and its `maintenance_discharge_w` warning take their
fallbacks from the mirror.
WHERE THE RANGE LIVES, STATED AND CHECKED. config.yaml's `schema:` block carries
`maintenance_charge_w: int(500,5000)` - a fourth declaration site of a different
kind. It is a BOUND, not a default, so it is not this bug; but leaving the
question open is how a fifth site arrives. The decision recorded in config.yaml
and in maintenance.py is: bounds live in the schema block ONLY, because the
Supervisor is what enforces them and the Supervisor reads only that file, so a
Python copy of `int(500,5000)` would be a place to disagree and nothing else.
test_maintenance.py checks both halves of that: every mirrored key must have a
schema entry - without one the Supervisor drops the option and the add-on
silently runs on the mirror instead of the operator's value - and every shipped
default must lie INSIDE its own range, since a default the schema rejects is a
config that cannot be re-entered once it is edited. What the schema grammar
cannot express is a relation BETWEEN keys (`maintenance_charge_w` against
`max_w`), which is exactly why rule 6 is written in Python and why it does not
belong in the mirror either.
THE REPLACEMENT CHECKS, AND HOW THEY WERE VERIFIED. test_validate.py's new
section exercises the same four consumers the defect ran through, the same way:
the validator ACCEPTS a dict with every maintenance key missing; a REAL
Controller built from that same dict holds 2000 W (through main.py, not through
a regex over its source); `MaintConfig()` answers 2000 W with no dict at all;
and the real arbiter emits `-2000 W, limited by supervised(max_w)` instead of
`0 W, contradiction=True`. test_maintenance.py pins the mirror to config.yaml in
both directions - equal values, and full coverage of every `maintenance_*` key
the file ships, so a ninth key cannot arrive unmirrored - and asserts the
consumers move as a RELATION rather than against numbers, reading MaintConfig's
defaults back out of `dataclasses.fields()`.
Each new check was verified by BREAKING the thing it watches and observing red,
this project's rule:
1. config.yaml -> 1500, mirror left at 2000: test_maintenance 1 FAILED,
test_validate 4 FAILED.
2. config.yaml AND the mirror -> 1500: green again, and every consumer moved
together - MaintConfig() 1500 W, the Controller 1500 W, and the arbiter
`-1500 W, limited by maintenance(cheap-window charge)` where it had said
`-2000 W, limited by supervised(max_w)`. That is AC4: change the single
source and watch the consumers follow.
3. `charge_w: float = 2500.0` retyped into the dataclass: 2 FAILED in each of
the two files.
4. `opts.get("maintenance_charge_w", 2500)` restored in main.py: 1 FAILED,
naming the file and the literal it found.
5. `maintenance_hold_min` deleted from config.yaml's options block: 3 FAILED.
Full suite 17/17 in goodwe-addon-ci, twice, once under parallel load.
Stale prose that asserted the now-closed divergence as current is corrected in
app/watchdog.py, test_watchdog.py and test_peakguard.py - watchdog.py's example
still uses 1500 W, but because the point of the example is a charge strictly
inside the ceiling, not because 2500 is what ships.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e498dd4c1c |
SAFETY-25: pin the claim-source set, so the tripwire is one
checks / unit (pull_request) Successful in 3m53s
The durability claim this PR shipped was FALSE, and the reviewer of #31 disproved it by building the fix. Part 10 said, in four committed places, that it "goes RED the day a loop-side floor is built, so the exposure cannot change size under a green suite". The reviewer implemented the memo's own recommended Option B in main.py - a `Claim.limit("reserve", P_LOOP, hi=0.0)` filed under a `loop_soc_floor` - and the ENTIRE SUITE STAYED GREEN. Part 10 green. Mutation 13 went on printing "NOTHING PREVENTS IT, ON PURPOSE" and the same 21 % -> 10.0 %, on a tree where something DID prevent it. Pushed to an absurd always-binding 95 % reserve it was caught only by SAFETY-24's pre-existing part 7 wiring checks; part 10 was still green. THE CAUSE IS STRUCTURAL, not a missing assertion. `replay_loop()` hand-builds main.py's claim list as a literal `[supervised, loop, peakguard.claim(...)]`, so it is blind to any claim main.py ADDS - which is exactly where AC 2 would be built. That is this repository's named failure mode: a check that observed something other than what it claimed to observe, shipped as a guarantee inside the document that recommends the change. THE FIX IS 10f: THE CLAIM-SOURCE SET, PINNED, two ways because the two fail differently. * STATICALLY, over `Controller.cycle()`'s own AST. Every `claims.append(...)` in it must be one the scan can read - an unreadable append FAILS rather than counting as nothing, which is how a scan like this goes quietly vacuous - and the sources it can file must be exactly {device, supervised, failsafe, safety, peak, arbitrage, maintenance, loop}. A ninth takes part 10 red on the line it is written, in every configuration, whether or not any fixture here reaches the state that files it. * DYNAMICALLY, through REAL `Controller.cycle()`s with main.py's OWN `resolve` spied - not a list this file built and hoped matched - swept over the WHOLE 0..100 % SoC range in the two states 10a-10c replay. Idle: exactly {device, supervised, loop}. Floor-limited: exactly {device, supervised, loop, peak}. A claim conditioned on a low pack, which is the shape a loop-side floor has, cannot hide from a sweep that visits 0 %. And the fold is checked rather than assumed: main.py's two P_SAFETY rating limits are both symmetric LIMITs, so replay_loop() modelling them as one `supervised` claim at the tighter bound is exact. MEASURED AGAINST THE COUNTEREXAMPLE. Reproduced first on the unfixed tree (green, as reported). On this tree the reviewer's Option B at its recommended default takes part 10 RED on 3 checks, and the 95 % variant on 6. Nine mutations were run in all, each against a freshly copied tree with no `__pycache__` present, mounted `:ro`; all nine red, no survivors, no `+0 B` delta. MUTATION.md records why a second round was needed: the first round's mutations were all aimed at DISARMING the guard, while the claim part 10 actually made was about the guard being FIXED. The four false sentences are replaced with what is now true, and each says WHICH half catches WHICH shape - 10f for an arbiter claim (Options B and C), 10a-10c for a clamp inside control.compute() or the guard (Option D), which the replay drives directly. AC 2 IS STILL OPEN AND NOTHING HERE BUILDS IT. The loop-side floor was a test mutant, reverted before commit. The memo still says "Status: OPEN. This is a memo, not a decision." Also, non-blocking, from the same review: the NOT READY banner had inherited the new scope sentence and become a ~550-character paragraph that the row three lines below repeated verbatim. A checks() row may now carry an optional short `banner` form; the floor-limited row does, and `status_payload()` prefers it. THE ROW IS UNCHANGED - shortening that would have been weakening AC 3 - and rows without the key are unaffected. Graded, and mutated: dropping the short form goes red. Merged origin/release/1.0 first (CI-02), keeping both tickets' CHANGELOG entries. Full suite green: 17/17 in goodwe-addon-ci (test_ci_gate.py needs `git`, which CI-02 added to .gitea/ci/Dockerfile but the local image lacks; run with it installed and it passes). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
182102eeaf |
SAFETY-25: say what peak_soc_floor bounds, and measure what it does not
checks / unit (pull_request) Successful in 3m41s
peak_soc_floor reads like a pack-wide reserve and is not one. The
grid-following loop has no SoC guard of any kind, so on a sustained-load
evening it discharges straight past that number and keeps going until the
pack's own BMS cut-out stops it.
The gap has now been written down four times without being filed -
SAFETY-18's finding (freeze.py:18), SAFETY-24's module note, its PR body,
and its honestly-uncaught 13th mutation - and never once measured. It is
measured here, on the committed 2026-08-20 capture, driving the SHIPPED
control.compute(), peakguard.decide(), peakguard.claim() and the real
arbiter.resolve() in main.py's order:
19:00-22:00 UTC, 830 W mean, minimum 701 W, zero export in the window
reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point
above peak_soc_floor
ends at 10.0 % - the bms_soc_floor cut-out
1525 Wh delivered by the LOOP over 587 cycles
549 cycles below peak_soc_floor, 343 of them still discharging
0 cycles on which the peak guard filed any floor at all
AND IT IS A BEHAVIOUR, NOT A CONFIG VALUE. The same evening ends at
9.9715 % at peak_soc_floor = 5, 11, 20 and 60 - the shipped value and both
ends of the schema range - identical to four decimal places. The option an
operator would reach for cannot move the number by any amount at all. On
the same day's high-load block the two states coexist: 49 cycles reporting
PEAK GUARD FLOOR-LIMITED and, on all 49 of them, the arbiter simultaneously
granting the loop up to 2000 W of discharge.
WHY SAFETY-24 COULD NOT CLOSE IT. The peak guard files a `lo` limit - a
LOWER bound on the setpoint. That asymmetry is exactly what makes it
trustworthy: a reviewer proved across 19,800 combinations that it can never
lower a target, cap a discharge or contradict the arbiter. The same property
makes it structurally incapable of stopping a discharge the loop itself
wants. A floor on the guard cannot become a floor on the pack by being
raised, and the sweep above is that sentence measured.
WHAT THIS COMMIT CHANGES IS THE HONESTY, on the four surfaces an operator
actually reads. That half is required whichever way the open question is
answered, and it is the real misstatement on the live system today:
* config.yaml, beside the option itself - it BOUNDS THE PEAK GUARD ONLY
and is NOT A PACK-WIDE RESERVE, with the replayed evening and the sweep;
* DOCS.md, in the options table and in a new section carrying the figures;
* the STATUS-PAGE ROW for a floor-limited guard. This is the one line a
human reads at the moment the guard stops discharging, and read alone it
says "the battery is being held at peak_soc_floor" - which the replay
shows is false in every cycle that row is rendered;
* the PERSISTENT NOTIFICATION, which outlives the page. It now names what
has stopped (the guard buying a lower bill) and what has not (the pack
being spent).
peakguard.py and freeze.py carry the measurement beside the findings they
already recorded, so the next reader does not have to take the caveat on
trust.
THE PRODUCT DECISION IS NOT TAKEN HERE, and must not be. Whether the loop
gets a reserve floor of its own decides whether this house's battery may
discharge to the BMS floor on a heavy evening; that moves real energy and it
is the owner's call. DECISIONS/SAFETY-25-loop-soc-floor.md is the memo AC 1
asks for: the options, what each costs, what each protects, weighed against
MAINT-01 (a pack repeatedly at the BMS floor is the SoC-drift condition the
maintenance cycle exists to correct, and an over-tight reserve fights it from
the other side) and against the fact that this would be the FIFTH SoC key -
OPT-04's arbitrage_soc_floor fired SAFETY-24's "revisit if a third floor
appears" trigger unremarked, so SAFETY-27's naming decision should land
first. It carries a recommendation and says plainly that it is not a
decision.
It also records a trap found while writing it, and now CHECKED through the
real arbiter rather than described: the obvious shape of a loop-side floor
is a `hi` limit at P_LOOP, and filed while the peak guard is asking for a
positive floor that is `lo > hi` - which arbiter.py resolves to a
CONTRADICTION at 0 W, the battery PARKED in the one quarter of the month
that sets the bill. It is the same crossing-limits hazard
shaping_suspended() already exists to avoid. The ordering
`bms_soc_floor < loop_soc_floor <= peak_soc_floor` removes it provably,
because wherever the reserve binds the guard is already floor-limited and
files lo = 0; both halves are run in part 10d.
test_peakguard.py part 10 is a TRIPWIRE, not a guarantee. It records a
defect and goes RED the day somebody builds a loop-side floor, so the
exposure cannot change size under a green suite and closing it has to be
deliberate. Part 11's mutation 13 stays honestly NOT CAUGHT - nothing
prevents the loop discharging past this floor - but it now reports a number
instead of a sentence.
Verified: 16/16 suites green in the CI image. Eight mutations, each against
a tree copied fresh with __pycache__ excluded and mounted :ro, so no .pyc
existed to go stale (MUTATION.md's structural immunity, the same one it
records for CI); byte deltas +61, -8, -465, -430, -1088, -46, file deleted,
and -95; none is +0 B. All eight go red on the named check:
M1 peakguard.claim() also files hi=floor_w -> 7 red, incl. 10c
M2 control.compute() gain forced to 0 -> 6 red, incl. 10a/10b
M3 scope sentence removed from the status row -> 10e red
M4 scope sentence removed from the notification -> 10e red
M5 scope paragraph removed from config.yaml -> 10e red
M6 scope bullet weakened in DOCS.md -> 10e red
M7 the decision memo deleted -> 10e red
M8 arbiter clamps crossing limits instead of
reporting a contradiction -> 5 red, incl. 10d
AC 2 is left explicitly OPEN, pending the owner's answer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
83911ed637 |
SAFETY-20: measure the freeze rail against the reachable ceiling, not max_w
checks / unit (pull_request) Successful in 3m37s
SAFETY-18's conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the
emitted command is clipped to the setpoint device's own [dev_min, dev_max]
range as well as to max_w - in clamp_to_rating()'s second stage and again as
the arbiter's `device` P_SAFETY limit claim. Measured on the shipped predicate
at the ticket's numbers, max_w 5000 on a +-4700 W inverter:
looks_frozen(unchanged_s=3600, commanded_w=4700, max_w=5000) -> False
The command is pinned at the hardware's rail, the meter has been frozen for an
hour, and term 2 is unsatisfiable by construction because 4700 < 4750. Any
device rating at or below 0.95 * max_w disarms the conjunction outright, and
the status page then printed `armed (... command is over 4750 W)` - a guard
that cannot fire, advertising that it can, which is the failure SAFETY-15,
SAFETY-19 and validate.py rule 5 all exist to prevent.
The rail is now measured against min(max_w, |dev_min|, |dev_max|), through a
new `rail_ceiling_w()` threaded into looks_frozen(), FreezeWatchdog.evaluate()
and both call sites in main.py. An unread rating leaves the old behaviour
exactly, and correctly: hass.limits() returns None whenever ESPHome is not up,
and on that path no device clamp is applied either, so max_w really is the
ceiling. A rating larger than max_w cannot raise the rail - this is a min(),
not a swap.
THE DELIBERATE CHOICE, since SAFETY-03 has landed. The arithmetic was kept
rather than substituting Decision.i_clamped / Decision.clamped. i_clamped is
the integrator bound (control.py:208, `bounded != i_w`), not "the command is
at the device rating", and `clamped` is the max_w output bound, unreachable on
a default install - neither is the device rail. More decisively, both answer
"did the LOOP ask for more than a bound", while the rail term asks "is the
command standing at the ceiling", and those coincide only when the loop is the
claimer that won. A maintenance SET or a peak-guard floor reaches the ceiling
through arbiter LIMIT claims with no clamp engaging at all, so a boolean rail
would go False exactly during a maintenance phase running against a frozen
meter - which freeze.py's own docstring names as the same fault with a worse
actuator behind it. The predicate is also public with two call sites that pass
DIFFERENT command values (self.target, and `standing` = last_write); a
per-cycle boolean cannot be passed per call site, so substituting it would
have silently decided the pre-clamp-versus-wire question that PM recorded as
still open. AC 1 governs, and it is satisfied literally.
The smaller of the two device bounds, not a per-sign pair. A per-sign rail is
tighter and is not wrong, but it is two rails needing two coverage verdicts on
a status row that carries one number, and the only configuration that
distinguishes them is refused at startup anyway. The symmetric ceiling can only
lower the rail, which widens term 2 rather than disarming it; term 1 carries
the whole no-false-positive argument and is untouched.
A ceiling of 0 W is NOT COVERED, not "armed at 0 W". A device that can command
nothing in one direction would make `abs(command) >= 0` true of every command
including a parked one - term 2 degenerately TRUE, collapsing the conjunction
into the bare unchanged_s > N threshold TEL-05 refused for false-tripping at
this controller's own operating point. rail_unreachable() returns the reason,
looks_frozen() stays silent, and the page uses the NOT COVERED row the
unsupported-transport case already has - a second reason to use that surface,
not a new mechanism.
AC 4, decided rather than assumed: NO NEW validate.py RULE. The configuration
is max_w above the device rating, which rule 2 (CFG-01) already refuses on the
identical predicate, re-run by run_control() once the real rating is known:
validate_startup_config({... max_w: 5000}, -4700, 4700)
-> ['max_w is 5000 W but number.sp accepts at most 4700 W - THE DEVICE
WINS ...']
A rail-specific rule would fire on a strict subset of that (0.95 * max_w >
dev_max implies max_w > dev_max) and could never be the rule that stopped a
start. The residue is the state where hass.limits() answered (None, None) -
ESPHome not up at boot, range checks skipped rather than guessed at, rating
never re-read - and there is nothing to refuse there by construction, which is
why the answer is a runtime row. Recorded in validate.py's "deliberately not
refused" list and pinned by four checks in test_validate.py, so the decision's
premise goes red if rule 2 is ever softened back to a warning.
HOW REACHABLE THIS IS, stated rather than glossed. Because rule 2 refuses the
configuration whenever the rating is known, min(max_w, dev_max) IS max_w on
every install that reaches the control loop with a rating it could read, and
this change is arithmetically dead there. That is clamp.py's argument for its
own second stage word for word: the case where it bites is the case where the
startup gate did not run or no longer holds, and a guard that quietly assumes
the gate ran is a guard that reports armed in exactly the state it cannot fire
in.
Verified by breaking it, per this project's rule. Reverting rail_w() to bare
max_w fails nine checks; deleting the NOT COVERED handling fails five. The one
that mattered: shipping the corrected predicate while NOT passing the rating
from check_meter_frozen() left the entire suite green, because nothing graded
the caller - so test_freeze.py part 5b now drives the real cycle() on a +-4700 W
device and asserts on the notification that leaves the process, and the
unwiring mutation fails three checks there. The peak-guard call site's wiring
is still ungraded (behind p1_enabled, needs a fed averager for `frozen` to have
any observable consequence) and that gap is named in the file rather than
implied.
Full suite: 15/15 in goodwe-addon-ci.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
504e8daac3 |
TEST-17: grade the status page's peak row at a stated month, not at today's
checks / unit (pull_request) Successful in 3m35s
release/1.0 went red at 00:00 on 2026-09-01 on a commit nobody had touched. `Controller.checks()` read the capacity-tariff peak with `self.peak.peak()` - no argument - so tariff.py resolved the month from `datetime.now()`, while test_tariff.py section 10 banks its quarter at the fixed fixture 2026-08-30. From 1 September the page was correctly asked for September, correctly answered NO_RECORD, and the row read "no completed quarter recorded yet" - a string containing no "peak", which the section's own filter required. Four checks failed. ISOLATED BY MOVING ONLY THE CLOCK. Same commit, same shipped-deps image, libfaketime: green at 2026-08-30, four failures at 2026-09-01. Identical at |
||
|
|
9234016419 |
Merge release/1.0 (OPT-04) into SAFETY-24, and fix one grader the merge broke
checks / unit (pull_request) Successful in 3m36s
OPT-04 landed after the previous rebase. Conflicts were CHANGELOG.md and one IMPORT LINE in validate.py; both refusal blocks and both option sets survive untouched, and DOCS.md, main.py and config.yaml auto-merged. ⚠️ AND THE MERGE BROKE A CHECK THAT NEITHER CARD COULD HAVE SEEN ALONE. test_peakguard.py graded "the first `.decide(` call in main.py" and asserted there was exactly one. That was true when SAFETY-24 was written; OPT-04 added `arbitrage.decide(...)`, so the merged tree had two and the assertion failed. It failed loudly rather than grading the wrong call - the guard did its job - but it cannot ship red. Fixed by selecting the call by QUALIFIED NAME (`peakguard.decide`) instead of by position, and requiring exactly one of THAT. Other modules may now call their own decide(); this grader can no longer be moved by one of them. Verified still discriminating: a decoy `peakguard.decide` added to main.py turns it red on 7 assertions, and the `maintenance_soc_floor` swap still turns it red on 6. Also folded in the review's wording finding. An assertion read "a CONTROLLER whose SoC entity does not answer hands decide() None" while driving no Controller. The wiring property is stronger than the one claimed, and is now asserted on the SERVICE CALL: a Controller with no SoC never reaches the guard at all, because `inputs_ok` is False and cycle() stops at the safety claim first - so a `float(soc or 100)` defaulting line above the call site cannot disarm the floor. The original decide()-level check is kept, reworded to say what it actually grades. Full suite green on the merged tree: 15 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
bdd7f18a54 |
OPT-04: charge cheap, discharge expensive - and only when it pays
checks / unit (pull_request) Successful in 3m16s
Four cards built the price stack and nothing traded on it. app/arbitrage.py is §10.1's priority 3, and it consumes all four rather than re-deciding anything: whether a trade is permitted at all, on which two hours, against which widened threshold is TariffPlan.arbitrage()'s answer, and that answer cannot be obtained without first saying whether the peak guard is holding. §9.1's "peak shaving outranks arbitrage" is therefore honoured structurally - there is no path to a spread that skips the question - and again in the arbiter, where P_PEAK (50) clamps a claim filed at P_ARBITRAGE (30). A guard that never ran is not a guard that is clear. On meter_source off the peak guard is never evaluated; that third state is carried as None and refuses, because charging at the rail creates the billed quantity while nothing measures the quarter. validate.py refuses that configuration too, along with arbitrage armed with no price source and a SoC floor at or above the ceiling. §17: never export into a negative price. The guard is on the hour being sold into, not on the spread - a day priced -0.30 .. -0.05 clears any threshold and its dearest hour is still negative. Buying in a negative hour stays allowed. resolve_tariff() may open an HTTPS connection and the tick feeds the ESP32 heartbeat, so the fetch runs in its own task at a 30 min cadence and the tick reads an attribute and an age bound. test_arbitrage.py section 8 asserts that structurally on the shipped AST: a fetch put back on cycle() is red. AC4 is NOT met and is not restated - this repository holds no price history. Section 7 replays a synthetic year and asserts the property that does not need real prices: every executed round trip cleared its own widened requirement, and zero discharges landed in the 522 negative-priced hours. At the shipped 0.10 EUR/kWh threshold a realistic Belgian day/night site does not trade at all. Reported, not tuned: that number and FCST-03's widening factors are one product decision and it is the owner's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
a1376e44e4 |
Merge release/1.0 (FCST-03) into SAFETY-24 - CHANGELOG only
checks / unit (pull_request) Successful in 3m28s
FCST-03 landed while this was in review, so both branches had prepended an Unreleased stanza. Only CHANGELOG.md conflicted, exactly as predicted; app/, config.yaml, validate.py and DOCS.md all auto-merged, and this card touches neither price_forecast.py nor static_tariff.py. Both stanzas kept verbatim, SAFETY-24's above FCST-03's - no text from either author edited. Full suite green on the merged tree: 14 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |