Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
090a8ecc35 | ||
|
|
c7eb8c6b97 | ||
|
|
706e625d99 | ||
|
|
0581648c73 | ||
|
|
f156fadac0 | ||
|
|
6c4565659b | ||
|
|
c8dcb38a95 | ||
|
|
8e241a3243 | ||
|
|
6864109a01 | ||
|
|
a4290d39ac | ||
|
|
834e7ca44b | ||
|
|
5d7e275df4 | ||
|
|
24c3d87d60 | ||
|
|
d5b9317d57 | ||
|
|
e2efc765af | ||
|
|
357cb4f07f | ||
|
|
564ba11c11 | ||
|
|
3033420dd7 | ||
|
|
a8bd2b9330 | ||
|
|
9e42cecc24 | ||
|
|
9b270a66a5 | ||
|
|
bbc903b2b4 | ||
|
|
d8e02ad3f8 | ||
|
|
4482cd8201 | ||
|
|
3527c8a04c | ||
|
|
8790c4d38f | ||
|
|
1d393ced22 | ||
|
|
3516947b5e | ||
|
|
86eaef2517 | ||
|
|
26c5ee8adc | ||
|
|
f72c48dbfd |
@@ -1,7 +1,553 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.0
|
||||
|
||||
⚠️ **SCHEMA CHANGE, AND IT IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.**
|
||||
[[SAFETY-32]] adds `bms_soc_floor: int(0,50)` as a **required** per-entry key on
|
||||
the `inverters` roster - no trailing `?`. A config that declares a roster and
|
||||
omits it on any entry is REFUSED at startup. An install running `inverters: []`,
|
||||
which is every single-inverter install including the reference one, is
|
||||
unaffected: the flat `bms_soc_floor` still describes the one pack.
|
||||
|
||||
CFG-04 recorded the rule this follows - the Supervisor keys the built image by
|
||||
`version:`, so a schema change without a bump is a schema nobody reads.
|
||||
|
||||
Contents, in merge order since 0.6.0 (`2d025ee`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `3934ad7` | MAINT-05 - one recalibration per pack, migration graded against the roster |
|
||||
| `67e649a` | SPIKE-04a - inverter B and pack B recorded, from datasheet and serial |
|
||||
| `3993f9f` | SAFETY-32 - per-pack `bms_soc_floor`, rule 8 per unit, package-wide roster pin |
|
||||
| `f72c48d` | UX-01 - add-on configuration in English and Dutch |
|
||||
| `86eaef2` | TEL-07 - a second MQTT device for inverter B; A's identity byte-identical |
|
||||
| `4482cd8` | SAFETY-41 - the clamp alarm stops narrating normal anti-windup |
|
||||
|
||||
⚠️ **0.6.0 WAS NEVER SECTIONED.** Its content sits below this heading rather than
|
||||
under one of its own, because the bump shipped without cutting the section. That
|
||||
is a defect in this file, not in the release: the deployed 0.6.0 is `2d025ee` and
|
||||
the table above is what is new since it. Splitting it retroactively would mean
|
||||
guessing a boundary; saying so is more useful than a guess.
|
||||
|
||||
## Unreleased
|
||||
|
||||
⚠️ **THE OUTPUT FREEZE NOW HAS AN EXIT, AND ONLY WHERE IT CANNOT END ON ITS OWN
|
||||
([[SAFETY-13]]).** 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 - the inverter reporting power we did
|
||||
not command means another writer is on the actuator, the §1.5 non-goal - and it
|
||||
is kept. What it did not have was an END: nothing cleared it but the other
|
||||
writer leaving.
|
||||
|
||||
The exit is now explicit, and *which* freezes get one is the decision rather
|
||||
than *how long*:
|
||||
|
||||
- A divergence a **limit** could have produced - the inverter doing less than
|
||||
the command, in the command's own direction - **clears itself**, because the
|
||||
command is pinned at something the inverter can reach. It gets no exit, and
|
||||
that is release/1.0's behaviour kept unchanged.
|
||||
- A divergence a limit could **not** have produced - the inverter overshooting
|
||||
the command, or producing the opposite sign - may not clear itself at all, so
|
||||
it gets one. It releases once the divergence has outlasted the plant's own
|
||||
measured settling time: `SETTLE_CYCLES` = **12** cycles at the measured tick.
|
||||
|
||||
⚠️ **THE BOUND COMES FROM THE SLOWER OF THE TWO DIRECTIONS THE PLANT WAS
|
||||
MEASURED IN.** `NOTES.md` §5.10 timed the same 1.78 kW step both ways - ~18 s
|
||||
up, ~23 s down - so `PLANT_SETTLE_DOWN_S` (23 s) joins `PLANT_SETTLE_S` (18 s)
|
||||
as a named constant. SAFETY-39 uses 18 correctly, because its bound is a
|
||||
*refusal* and the looser number refuses less; this bound asks how long the plant
|
||||
may still be the *explanation*, and there the longer number is the safe one. A
|
||||
bound of 9 derived from 18 fires the exit on a **single inverter with nothing
|
||||
else on the bus** - measured in the container, 62 of 272 load-step pairs at the
|
||||
plant's own measured shape, against 2 at 12.
|
||||
|
||||
⚠️ **`freeze-released` IS A PLACE TO START LOOKING, NOT A FINDING.** It says the
|
||||
divergence outlasted the plant's measured settling time. It is **not** proof of
|
||||
a second writer: a large load or PV swing can leave our own power coasting
|
||||
outside our own command's interval, and a plant slower than the one that was
|
||||
measured does the same. DOCS.md now gives the innocent causes first and the
|
||||
second writer only when the state *stands*.
|
||||
|
||||
⚠️ **RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP.** The integrator
|
||||
freeze is untouched, so the command can only walk, one slew step per cycle,
|
||||
toward an accumulator that stopped accumulating when the freeze engaged.
|
||||
Measured at the live install's shape (`max_w 5000`, `slew_w 400`,
|
||||
`target_grid_w -10`) with another writer holding the pack at +700 W and 2 kW of
|
||||
export standing: the command holds at 0 W, starts on the twelfth saturated
|
||||
cycle and settles at **-1190 W** against a frozen integrator of -1194 W, not at
|
||||
the 5000 W rail. On `release/1.0` @ `c8dcb38` the same run held **0.0 W on every
|
||||
cycle**, for as long as the other writer stayed.
|
||||
|
||||
⚠️ **THIS IS NOT A REGRESSION FIX AND IT ESCALATES AT TWO INVERTERS.** The
|
||||
unbounded block is `release/1.0` behaviour, confirmed cycle for cycle against
|
||||
SAFETY-04's branch. At a roster of two, `actual_w` is the SITE SUM, so the
|
||||
comparison is a site command against two units' power and one unit still moving
|
||||
after the other has stopped can put the site into the block with no external
|
||||
actor at all. ⚠️ That escalation is read from the code and from single-unit
|
||||
runs; **it is not measured on two inverters.** Inverter B does not physically
|
||||
exist yet and `sim/plantsim.py`'s `INVERTER_B` is `INVERTER_A` copied with
|
||||
`measured=False`, so any two-inverter figure would be two copies of A.
|
||||
|
||||
Graded against `release/1.0` over the 3780-case sweep in `test_control.py`:
|
||||
**3267 cases bit-identical, 432 releases, 27 of those change the command** -
|
||||
every one a charge from `prev_w == 0` that `release/1.0` refused to start - and
|
||||
**81 differ in the saturation counter alone**, because its ceiling moved with
|
||||
the bound from 10 to 13. That ceiling change also removes a degenerate setting:
|
||||
`config.yaml` accepts `saturation_cycles` up to 10, which is now strictly below
|
||||
the release bound, so **no accepted configuration can stop the block engaging**.
|
||||
At the old bound of 9 a setting of 9 or 10 deleted it silently.
|
||||
|
||||
⚠️ **AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME
|
||||
([[SAFETY-42]]) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.**
|
||||
`delivery_hold_s: 185` joins `options:` and `schema:` (`int(30,900)`). The
|
||||
Supervisor keys the built image by `version:`, so this needs a bump before it
|
||||
ships - see the 0.7.0 heading above, which makes the same point about
|
||||
`bms_soc_floor`. The key is ADDITIVE and defaulted, so an existing options blob
|
||||
stays valid; what a missing bump costs is that nobody's Supervisor re-reads the
|
||||
schema.
|
||||
|
||||
**Two or more inverters only, and at one inverter it is inert by construction.**
|
||||
A unit standing at a discharge share it is not producing - short by more than
|
||||
`saturation_w`, with nothing this add-on can read to explain it, while at least
|
||||
one sibling is NOT in that state - is named after `delivery_hold_s`. That fault
|
||||
was invisible to everything: the per-unit sign check needs OPPOSITION and a unit
|
||||
at 0 W is not opposing but ABSENT, the site sum still agrees with the meter in
|
||||
direction, circulating power is exactly zero when one unit does nothing, and the
|
||||
only remaining trace is the saturation freeze - which `clamp.py` itself calls an
|
||||
alarm operators learn to ignore. It is [[HW-01]]'s shape, which ran 6 h 25 m.
|
||||
|
||||
⚠️ **IT REPORTS. IT DOES NOT ACT.** Nothing is commanded differently, the loop is
|
||||
untouched, `control.py` is not modified, and the site goes on running short by
|
||||
that unit's share. Commanding an inverter that has stopped listening achieves
|
||||
nothing (HW-01), and the only re-allocation this add-on can express is
|
||||
all-or-nothing - measured on the rig, dropping a unit derated to 50 % from the
|
||||
split leaves the site **400 W worse** than keeping it, and this detector cannot
|
||||
tell that unit from a wedged one. The status row is a CAUTION, not a failure,
|
||||
because the controller has not stopped.
|
||||
|
||||
⚠️ **THE CARD'S OWN DISCRIMINATOR WAS FALSIFIED, AND THAT IS THE MAIN CONTENT.**
|
||||
SAFETY-42 asked for "saturation persisting past N while |grid error| > X, both
|
||||
derived". Built and run on the two-master rig first, it fails in both
|
||||
directions, and no (N, X) fixes either: a pack at its own floor and a wedged unit
|
||||
are **identical on all 120 cycles** of a run in grid, site target, every share
|
||||
and every measured watt - so the benign case fires - and with a sibling that has
|
||||
headroom the standing grid error collapses to **30 W** while the wedged unit
|
||||
stands at a +770 W share delivering nothing, so the real case does not. What
|
||||
replaces it is a per-unit shortfall plus two reductions the card did not ask for:
|
||||
an ACCOUNT (the pack's own `bms_soc_floor`, the only non-power fact that
|
||||
separates an empty pack from a wedged one) and a PROPER SUBSET of the units asked
|
||||
to discharge (which is what keeps a whole-site derate out, and what makes this
|
||||
inert at N=1 - measured zero in all 1890 one-inverter runs).
|
||||
|
||||
The report **survives a Home Assistant restart** ([[SAFETY-34]]) under
|
||||
`unit/<name>/delivery_tripped`, with one notification id per unit, re-raised on
|
||||
the first cycle of every process and cleared by the same **Start** press as the
|
||||
other latches. **Charge shares are deliberately not graded** - there is no
|
||||
configured charge ceiling to explain a constant-voltage taper with, and arming
|
||||
that direction would fire on every maintenance charge.
|
||||
`DECISIONS/SAFETY-42-unit-delivery.md` carries the campaign (2640 healthy
|
||||
two-inverter runs plus 1890 at one inverter as the control), the
|
||||
inverter-B sensitivity sweep the margin is sized on, and the mutation ledger.
|
||||
|
||||
⚠️ **AND THE TWO THRESHOLDS IT READS ARE NOW GRADED AT A CONFIG THAT IS NOT THE
|
||||
SHIPPED ONE.** `saturation_w` and each unit's `bms_soc_floor` are handed to this
|
||||
check from the running config, and the promise that comes with that - raise
|
||||
`saturation_w` and this check moves with you, there is no fourth copy of a
|
||||
default to keep in step - is only true while the call site passes the
|
||||
**configured** value. A literal equal to the shipped value keeps every fixture
|
||||
green and removes the promise silently, and two such literals were survivors of
|
||||
all 23 suites - measured, not argued - because no fixture that reaches this
|
||||
check had ever run at a `saturation_w` other than the shipped 500 or at a
|
||||
`bms_soc_floor` other than the shipped example's 10. Part 4b of
|
||||
`test_delivery.py` now runs the same fault at a raised `saturation_w` and at two
|
||||
cut-outs that bracket 10 from both sides, and each pair must answer two
|
||||
different ways - so no constant passes. §9 of the decision record enumerates
|
||||
every number this check reads and says which were already pinned this way and
|
||||
which were not.
|
||||
|
||||
⚠️ **A PER-INVERTER POWER READING THAT HAS STOPPED FOLLOWING ITS OWN COMMAND IS
|
||||
NOW `None`, NOT A HELD NUMBER ([[SAFETY-37]]).** `Inverter.batt` (register
|
||||
`0x0518`) goes through a freshness gate: unchanged for >= 35 s *while* the
|
||||
standing command is over 250 W *and* has travelled over 250 W since the reading
|
||||
last moved. A stale reading is a **missing input**, which §11.1 rule 1 already
|
||||
answers with 0 W and keeps commanding.
|
||||
|
||||
**This is a behaviour change at the shipped roster of one, and it is deliberate.**
|
||||
Before it, a frozen power register read as a healthy measurement for ever - which
|
||||
is [[HW-01]], where a value held for 6 h 25 m while the pack drained to its floor
|
||||
and every detector read healthy. A healthy install is byte-identical: sixty reads
|
||||
of a dithering register under a reversing command all reach the consumers
|
||||
unchanged, asserted rather than assumed. A false verdict fails toward 0 W, behind
|
||||
the existing `stale_input_s` hold, and clears itself the moment the register
|
||||
reports a different value.
|
||||
|
||||
At two or more inverters it also removes two false alarms that had no gate at
|
||||
all: [[SAFETY-30]]'s per-unit sign check no longer trips on a frozen reading
|
||||
(measured: it trips without the gate and NEVER with it - the tick index is
|
||||
deliberately not quoted, because it is counted on the wall clock inside
|
||||
`cycle()` and is therefore load-dependent; the checkable statement is the
|
||||
presence or absence of the trip, which part 3 grades), and
|
||||
[[SAFETY-31]]'s circulating-power sensor reports `None` instead of a loop that is
|
||||
not happening.
|
||||
|
||||
**No new configuration key, and no schema change.** The bound is derived from
|
||||
`circulation_hold_s` and `unit_sign_disagree_*`, which the checks read out of
|
||||
`config.yaml` at both ends - so moving either default takes the gate red rather
|
||||
than leaving it silently too slow. `DECISIONS/SAFETY-37-freshness-policy.md`
|
||||
carries the derivation, what [[TEL-06]] and [[TEL-08]] still owe, and three
|
||||
named survivors - including the largest: **this detects a value that STOPPED, not
|
||||
a value that is WRONG.**
|
||||
|
||||
⚠️ **`slew_w` IS A RATIO TO THE RAIL NOW, AND ITS DEFAULT CHANGED FROM 1000 TO 0
|
||||
([[SAFETY-39]]).** `0` does not mean "off" and does not mean "unset" - it means
|
||||
**follow `max_w`**, exactly as `integrator_max_w: 0` already does, and resolves
|
||||
to `max_w / 9`: 222 W at the shipped 2000 W cap, 556 W at the live install's
|
||||
5000, 1067 W at a ~9600 W two-inverter site. The 9 is
|
||||
`PLANT_SETTLE_S / LOOP_TICK_S` - an 18 s measured step response against a 2 s
|
||||
meter tick - so the loop takes the plant's own settling time to cross its whole
|
||||
output range at any rail.
|
||||
|
||||
**An install that has never set `slew_w` will therefore command smaller steps
|
||||
than it did on 0.7.0, and will converge more slowly after a large disturbance.**
|
||||
That is the point rather than a side effect: `slew_w: 1000` was derived against
|
||||
a **1500 W** clamp in 2026-08 and the live install has run it against 5000 since,
|
||||
which crosses the whole range in 10 s against an 18 s plant. Measured on the
|
||||
owner's system 2026-09-05: a 3.4 kW export drove the setpoint 380 -> 5000 W in
|
||||
five 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**.
|
||||
|
||||
An explicit `slew_w` is still accepted and is never silently reduced. What is
|
||||
new is that a value **faster than the rail allows** is now **REFUSED AT STARTUP**
|
||||
(`validate.py` rule 10), graded against the ROSTER SUM - so `slew_w: 1000` with
|
||||
`max_w: 5000` will not start. Set it to `0`, or to any value at or below
|
||||
`max_w / 9`. Slower is always accepted, including the interim 400 the reference
|
||||
install has run since 2026-09-05.
|
||||
|
||||
⚠️ **This does NOT fix the over-absorption, and the record says so at length.**
|
||||
Re-deriving the slew removes the >= 900 W setpoint step; the loop still absorbs
|
||||
~1 kW more than the site needs on a large export step, because that is a `gain`
|
||||
term and `gain: 0.6` was measured and deliberately left alone. See
|
||||
`DECISIONS/SAFETY-39-slew-against-the-rail.md` §6, and do not read this entry as
|
||||
more than it says.
|
||||
|
||||
|
||||
**`test_peakguard.py` answered two of its own checks with the wall clock
|
||||
(CI-06).** The file sits on the required `checks / unit (pull_request)` context
|
||||
and had a measured ~20 % flake rate. It cost a wrong merge diagnosis (parents
|
||||
green, merge red — read as a semantic merge conflict, it was the flake), a false
|
||||
red on two reviewers' CONTROL runs, and a false "mutation caught" that reached a
|
||||
merged card in four places. **A flaky check does not waste a re-run; it
|
||||
manufactures evidence for whichever hypothesis the reader already holds.**
|
||||
|
||||
Both causes are the control law's own gain `900 / remaining` applied to a
|
||||
wall-clock gap the file never intended to have. Neither fix widens anything.
|
||||
|
||||
* **The cross-time projection.** The "one cycle of the intent form is exactly
|
||||
the delivered form plus the command standing on the wire" check decided the
|
||||
intent form from a **second** `PeakTracker.project(..., datetime.now())` taken
|
||||
after the real cycle had returned, and compared it against a floor decided
|
||||
from the **first** projection — three lines under a comment saying both run
|
||||
"against the same frozen projection". `excess = 900 × (projected − ceiling) /
|
||||
remaining` moves at `excess / remaining` W/s, so at a 1500 W floor the 1.5 W
|
||||
tolerance bought exactly **`remaining / 1000` seconds**: 333 ms with 880 s
|
||||
left in the quarter, **6 ms with 10 s left**. The gap between the two
|
||||
statements measured **12.7–39.5 ms** over 26 instrumented runs. The check was
|
||||
decided by *where in the quarter it ran*. It now keeps the `Projection`
|
||||
**object** the real cycle graded and decides both forms from it — the clock is
|
||||
out of the comparison and the tolerance is **exact**, not widened. Measured on
|
||||
a real cycle: re-deciding the delivered form from that object reproduces the
|
||||
controller's own floor to **0.000000000000 W**.
|
||||
* **The fixture's own room.** `wired_over_limit()` laid telegrams with
|
||||
`range(0, span + 1, 5)`, leaving **0–5 s of the open block uncovered by
|
||||
construction**, and `excess` multiplies an uncovered second back up by
|
||||
`900 / remaining`. Measured on the shipped file: at **12 s of remaining a
|
||||
fixture that asked for a 1500 W floor delivered 613 W (−59 %)**, and at 2 s
|
||||
the guard **did not engage at all** — which fails "the real cycle files the
|
||||
peak claim", "…and the number written to the inverter is the guard's floor"
|
||||
and "the STATUS PAGE row for a floor-limited guard" **together**. The
|
||||
four-attempt retry could not help: four attempts take ~40 ms and all land in
|
||||
the same dying quarter. Fixed with one telegram stamped at `now` (what a real
|
||||
meter does, and what the docstring already claimed) plus `FIXTURE_ROOM_S`,
|
||||
**derived** from the 1 % accuracy the helper promises rather than picked:
|
||||
`net_w × lag / (remaining × want) ≤ 0.01 − lag/900` → **23.6 s → 24 s**, at
|
||||
the measured 0.1 s worst-case lag.
|
||||
|
||||
**Nothing was skipped, disabled or marked xfail** — the file still carries all
|
||||
of SAFETY-07 and SAFETY-25's forced-discharge SoC floor, and it gains a check:
|
||||
the delivered form re-decided from the cycle's own projection must reproduce the
|
||||
real controller's number to the bit, which pins the test's `decide()` kwargs as
|
||||
main.py's actual call rather than an approximation of it. **One tolerance got
|
||||
stricter** (1.5 W → exact), because removing the clock made exactness possible.
|
||||
|
||||
**`site_standing` — the one quantity BOTH site guards are asked about — is now
|
||||
pinned by a fixture whose per-unit shares cannot answer for it (SAFETY-38).**
|
||||
`app/main.py`'s `site_standing = float(sum(standing.values()))` is the first
|
||||
argument of [[SAFETY-30]]'s **latching, site-stopping** sign detector *and* the
|
||||
`commanded_w` handed to [[SAFETY-18]]'s `looks_frozen()`. It was pinned by
|
||||
**nothing**, and three mutations of it disarmed both guards with the whole suite
|
||||
green. **This card changes coverage only: not one byte of `app/` moves — the
|
||||
shipped `sum` is correct and must not be "fixed".**
|
||||
|
||||
* **⭐ THE FIXTURE THE SUITE COULD NOT BUILD: per-unit shares EACH BELOW the
|
||||
site gate whose SUM clears it.** Two simulated units rated 220 W and 280 W,
|
||||
both obeying exactly, inverted gain, house importing 2000 W — the site
|
||||
commands **−500 W against a +2500 W meter** and no single share clears the
|
||||
300 W gate. That is the case the site detector exists for, and it is the only
|
||||
arrangement that can separate `sum` from the mean and from the largest share.
|
||||
`test_watchdog.py` part 9a.
|
||||
* **WHY THE EXISTING FIXTURE COULD NOT.** `_sign_site`, the suite's only N≥2
|
||||
site fixture, runs shares of about −2500 W **each** — every one individually
|
||||
over the gate — so sum, mean and largest-share all agree on it. *Its values
|
||||
happen to be equal, and a check comparing two things that happen to be equal
|
||||
cannot see the difference it names.* `MUTATION.md` habit 4, ninth instance,
|
||||
inside the site half of the very card that names it.
|
||||
* **FOUR CANDIDATE REDUCTIONS, FOUR DISTINCT ANSWERS, ONE OVER THE GATE** —
|
||||
`sum` −500, mean −250, `max(key=abs)` −280, `[0]` −220 — and that is asserted
|
||||
as a precondition, not described. The asymmetric ratings are load-bearing:
|
||||
equal shares collapse three of the four onto one number, and the **smaller**
|
||||
unit is first so `[0]` stays distinct from `max(key=abs)`. The **gate is the
|
||||
shipped 300 W**; only the hold is shortened, because the hold is a wall clock.
|
||||
* **⭐ THE SECOND CONSUMER IS COVERED, NOT SCOPED OUT.** The same value is
|
||||
`looks_frozen()`'s `commanded_w` at `main.py:1926`. At the same roster the
|
||||
site rail is 0.95 × 500 = **475 W**, so `sum` = 500 W rails it while 250, 280
|
||||
and 220 do not. Shipped: the peak guard goes blind on SAFETY-18's conjunction
|
||||
and files `floor_w = 0.0`. Under both surviving mutations it is **not blind at
|
||||
all and files no floor**, so the battery may keep importing through a quarter
|
||||
whose billed average is unobservable. `test_watchdog.py` part 9b. The open
|
||||
quarter is deliberately backfilled with the stuck reading so the mutant cannot
|
||||
reach the same action by the *coverage* route and mask the difference.
|
||||
* **AT N=1 NOTHING CHANGES, AND IT IS ASSERTED.** On a real one-inverter
|
||||
controller `standing` has one entry, so `sum`, the mean, `max(key=abs)` and
|
||||
`[0]` are **the same number** — all three mutations are genuinely *equivalent*
|
||||
on a shipped install and no N=1 fixture could ever separate them. Printed and
|
||||
checked, beside the site detector still being absent there. Part 9c.
|
||||
* **⚠️ A CORRECTION, AND IT IS THE MOST USEFUL LINE IN THIS CARD.** An earlier
|
||||
revision of this entry said one of the three — `list(standing.values())[0]` —
|
||||
was **already closed** by [[SAFETY-32]]'s package-wide structural pin, and
|
||||
told future authors not to re-cover it. **That was false.** It had been
|
||||
measured **once**, that run hit `test_peakguard.py`'s [[CI-06]] flake (a
|
||||
measured **~20%** false-red rate), and the red was recorded as a mutation
|
||||
**kill**. Re-measured on `release/1.0` @ `86eaef2` carrying only that edit:
|
||||
the **full 21-file suite is green** and `test_peakguard.py` passes **five for
|
||||
five** in isolation — **the mutation survives.** The reason SAFETY-32 cannot
|
||||
see it is not the shape: part 12j taints names *bound* to roster values and
|
||||
lists *accumulated* in a loop, and `standing` is a dict filled by **subscript
|
||||
assignment**, so it never enters the taint set and `sum(standing.values())` is
|
||||
not a roster site at all. 12j's own enumeration on the mutated tree lists
|
||||
`main.py:1553`, `1555`, `1690` — **not `1599`**. **All three mutations needed
|
||||
this card; all three are now covered by it.** *A single red from
|
||||
`test_peakguard.py` proves nothing — re-run it five times in isolation and
|
||||
name the failing check.*
|
||||
* **⚠️ AND A ZERO-BYTE SURVIVOR ONE LINE BELOW THE ONE THIS CARD PINNED.**
|
||||
Pinning the *value* `site_standing` said nothing about **where it goes**.
|
||||
Swapping the detector's first two arguments —
|
||||
`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`
|
||||
— is **zero bytes** and left the tree fully green, because the check asserted
|
||||
only that `"-500 W"` appeared **somewhere** in the banner. It still does:
|
||||
**in the meter slot.** The swapped banner reads *"commanded +2500 W against a
|
||||
meter reading −500 W"* — a site charging at −500 W into a +2500 W import, told
|
||||
to the operator **exactly backwards**, on the one line somebody woken by a
|
||||
latching site-stopping alarm reads first. The check now asserts the **ordered
|
||||
pair** rather than a substring, and is red on it. **No `app/` change:** the
|
||||
hole was in the test.
|
||||
* **⭐ A SURVIVOR THIS CARD WENT LOOKING FOR AND CLOSED.**
|
||||
`site_standing = float(self.target)` — the arbiter's site *target* instead of
|
||||
the sum of what actually **stands on the wire** — is **−11 bytes** and
|
||||
survived the full suite, because in every fixture that existed the two are the
|
||||
same number. They are not when a device's reported range is narrower than its
|
||||
`max_w`: the `device` claim is filed **per actuator**, so it bounds each
|
||||
unit's share and never the site target. A site asking for −500 W while −250 W
|
||||
stands would then take a **latching site-wide false trip** under its own
|
||||
300 W gate. Closed by part 9a's device-clamped counterfactual.
|
||||
* **⚠️ AND THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER — THE ONE A SHIPPED
|
||||
INSTALL CAN ACTUALLY SEE.** The ordered pair went onto `main.py:1620`, the
|
||||
**site** detector. That detector is armed only at N≥2, and part 9c asserts it
|
||||
is **absent at a roster of one** — so on every install this add-on has, the
|
||||
**per-unit** banner is the only sign-disagreement sentence that can ever be
|
||||
printed, and its call at **`main.py:1560`** was still unpinned. The identical
|
||||
swap there is **0 bytes** and left the tree **21/21 green**, and nothing
|
||||
behavioural could ever have caught it: `disagrees()` is **symmetric** in those
|
||||
two arguments (`commanded_w * against_w < 0`, `abs()` gate on both), so the
|
||||
mutant takes **identical actions on every input**. What moves is the sentence
|
||||
— measured, **9 emitted banner lines invert in 3 distinct sentences**,
|
||||
including the one persisted to `sign_reason` and read back after a restart:
|
||||
*"commanded +1000 W against its own measured power −1000 W"* becomes
|
||||
*"commanded −1000 W against its own measured power +1000 W"*. Closed by the
|
||||
ordered pair on **both** per-unit banners — the N≥2 `its own measured power`
|
||||
one and the **N=1** `a meter reading` one — with both operands read off the
|
||||
**observed cycle** rather than off the arguments the detector was handed, plus
|
||||
a precondition on each that the two operands really are different and opposite
|
||||
in sign. **Red on four named checks, green in the other twenty files. No
|
||||
`app/` change.**
|
||||
* **⭐ AND THE FAMILY IS NOW ENUMERATED BY MACHINE, because it had been
|
||||
enumerated by hand twice and was short both times.**
|
||||
`tools/symmetric_message_scan.py` finds every call whose argument order is
|
||||
load-bearing for a **message** rather than for a decision: it renames each
|
||||
parameter pair, erases every formatted string, canonicalises the commutative
|
||||
operators and iterates to a **fixpoint** (the symmetry is usually one call
|
||||
deep — `evaluate()` is symmetric only *through* `disagrees()`), then
|
||||
classifies each sentence by where it goes. A second census covers sentences
|
||||
the **caller** builds. It found **one open survivor this card does not close
|
||||
and now discloses** — `rail_unreachable()`'s `dev_min`/`dev_max` at
|
||||
`main.py:3208`, **0 bytes, 21/21 green**, a status-page row — and **twelve**
|
||||
unpinned caller-built sentences including SAFETY-31's `circ_reason`, which is
|
||||
the same shape and is also persisted. The list, the pinned status of every
|
||||
member and the instrument's own blind spots are in
|
||||
`DECISIONS/SAFETY-38-site-standing-pin.md` §7.
|
||||
* **⚠️ AND A LEDGER SENTENCE THIS CARD ITSELF GOT WRONG, CORRECTED WITH THE
|
||||
MEASUREMENT.** An earlier revision said *"before part 9 existed, **three** of
|
||||
these left the whole suite green — M1, M2 and M3 — and the remaining seven
|
||||
were caught by SAFETY-30's own N≥2 checks"*. **Eight** did. Re-run one at a
|
||||
time on clean `release/1.0` @ `bbc903b`, fresh copy each, bytecode purged,
|
||||
byte delta and `main.py` fingerprint printed, **full 21-file suite** every
|
||||
time: M1, M2, M3, M4, M7, M8, M9 and M10 are all **21/21 PASS**; only M5 and
|
||||
M6 go red. It is the same species of error this round exists to fix — **a
|
||||
survivor recorded as a kill** — and the file contradicted itself three times
|
||||
while saying it. **And the two that ARE caught make the card's case:** M5
|
||||
(`sum(abs …)`) and M6 (`-sum`) are the only two that reverse the **sign**.
|
||||
Every other entry changes only the **magnitude**, and the old `_sign_site`
|
||||
fixture's ~−2500 W shares put nine different magnitudes on the **same side of
|
||||
the same 320 W gate**. The pre-existing coverage could see a sign and nothing
|
||||
else.
|
||||
* **13 mutations — 11 of that line and its call site, plus 2 zero-byte edits to
|
||||
the arbiter claim wiring — each on a fresh full-repo copy mounted `:ro`,
|
||||
bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the
|
||||
byte delta printed. All 13 red.** Every one was **re-run independently** in
|
||||
this round rather than carried forward from the previous round's report. The
|
||||
claim-wiring pair (`sign_site` and `circulation` moved from site scope to unit
|
||||
scope) closes two gaps a reviewer disclosed — though **`circulation`'s kill
|
||||
belongs entirely to pre-existing checks and is not claimed by this card.**
|
||||
⚠️ One **false red** from `test_peakguard.py` on M10 did not reproduce in four
|
||||
subsequent runs; it is CI-06's known flakiness, no claim here rests on it, and
|
||||
it is recorded rather than quietly dropped — unlike the one that *was*
|
||||
believed, two bullets up. Ledger, and an **enumerated list of what was not
|
||||
mutated**, in `DECISIONS/SAFETY-38-site-standing-pin.md` §4a, §4b and §6a.
|
||||
* **⭐ ROUND 4: THE SCANNER COVERED ONE SYNTACTIC SINK AND CALLED IT THE
|
||||
FAMILY, AND TWO LIVE MEMBERS WERE SITTING IN THE HOLE.** Every one of census
|
||||
B's 16 members was a sentence that is **appended, assigned, or logged**. Not
|
||||
one was **RETURNED** — and census A's return bucket only ever held a callee
|
||||
formatting its own *parameters*, so a method formatting its own **attributes**
|
||||
and handing the string back was invisible to both. That is the whole
|
||||
`describe()` / `explain()` family, which is where this codebase keeps its
|
||||
operator sentences. **Worse than the gap: it was in none of the five blind
|
||||
spots the tool printed about itself.** The tool now scans the return sink
|
||||
(census C), resolving it through the returning method's **call sites** so that
|
||||
a returned *key* — `store.unit_key()`, `mqtt.unit_state_key()` — is not
|
||||
mistaken for a returned *sentence*; it also scores a sentence handed to a
|
||||
constructor as `reason=` or placed under a `"text"` dict key, which added
|
||||
**six more caller-built members** it had been stepping past. Re-run, it
|
||||
**rediscovered both of the members this round was sent to fix without being
|
||||
told where to look**.
|
||||
* **⭐ `ClampResult.describe()` — the clamp alarm the owner has been complaining
|
||||
about all day — had a SECOND independent way to lie, and no `test_*.py`
|
||||
mentioned `describe()` at all.** Transposing `requested_w` and `applied_w` in
|
||||
`clamp.py:253` is a **zero-byte** edit that leaves the guard reporting a
|
||||
setpoint clamped **UPWARD**, in the measured case to +9999 W by a bound whose
|
||||
rating the same sentence gives as 2000 W. It is the §15 action-log row
|
||||
(`main.py:1366`) **and** the ClampWatchdog notification reason
|
||||
(`main.py:2452`). Measured **21/21 green** before; now **red on 4 checks** in
|
||||
`test_clamp.py` part 6d, pinned on the **ordered pair** on the contiguous
|
||||
literal at **both** consumers, with operands read off `ctl.target` and
|
||||
`hass.writes[-1]` — what the loop asked and what the device was sent — rather
|
||||
than off the `ClampResult` the swap lives in.
|
||||
* **⭐ `PeakDecision.explain()` — the status-page banner — the same shape.**
|
||||
`projected_w` ↔ `ceiling_w` at `peakguard.py:493`, **zero bytes**, and the
|
||||
row then reads *"quarter projects 5013 W under a 2300 W ceiling"*: a quarter
|
||||
**2713 W over** its ceiling, on a row whose own words say *idle* and whose own
|
||||
flag says **ok**. `"peak guard idle"` appeared in no `test_*.py`. Measured
|
||||
**21/21 green** before; now **red on 3 checks** in `test_peakguard.py` part 13,
|
||||
pinned on the decision and on the shipped status row at `main.py:3313`.
|
||||
⚠️ **A correction the ticket is owed:** the idle sentence does not reach
|
||||
`main.py:3299/3302` — those are the *floor-limited* branch, where `explain()`
|
||||
returns `self.reason` before the idle literal is built. It reaches
|
||||
**`main.py:3313`** and, on the release edge, `main.py:1951`.
|
||||
* **`test_arbiter.py`'s scope-violation check made a claim WIDER than its
|
||||
assertion** — it said `scope_violations` *"names the source and the actuator
|
||||
it does not belong on"* and asserted `"peak" in scope_errors[0]`, which is
|
||||
still true after the two swap roles. Measured this round: **0 bytes, 21/21
|
||||
green**; now red on 2 checks, pinned on the ordered pair.
|
||||
* **14 more zero-byte mutations measured, and 10 SURVIVORS ARE DISCLOSED RATHER
|
||||
THAN CLOSED**, each with its file fingerprint and its 21/21 verdict, in
|
||||
`DECISIONS/SAFETY-38-site-standing-pin.md` §7e–§7g: **two** in
|
||||
`allocator.py`'s `UnitPlan.explain()`, three across `arbiter.py`'s two
|
||||
`explain()`s, two in `tariff.py`, two in `arbitrage.py`'s trade reasons and
|
||||
one in `main.py`'s SoC-floor notification. The other four of the fourteen are
|
||||
kills: `allocator.py:162` was already caught by `test_alloc.py`, and
|
||||
`clamp.py:253`, `peakguard.py:493` and `arbiter.py:264` are closed here. **An enumerated list with honest
|
||||
gaps beats a fifth round finding a fifth instance.**
|
||||
⚠️ **And the probe that grades those mutations was itself the defect, once.**
|
||||
Its first cut numbered the operands **by order of appearance**, so after a
|
||||
transposition the other leaf took the same number and all thirteen renders came
|
||||
back **byte-identical** — which reads exactly like *the edit is inert*. Keying
|
||||
the value to the **name** fixed it. A measurement comparing the swap against
|
||||
itself, in the instrument this time.
|
||||
* **The tool now states what it does NOT scan**, largest hole first:
|
||||
`%s`-style lazy logging (`_LOG.error("… %s … %s", a, b)` puts the operands in
|
||||
*arguments*, so no census here looks at them and the transposition is the same
|
||||
zero-byte edit — **31 such calls across 8 modules, counted and listed by the
|
||||
tool rather than asserted**), `.format()`/`%`/`+`/`.join()` sentences, a sentence that
|
||||
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
|
||||
sentences, message attributes outside the `reason`-shaped set, and everything
|
||||
outside `app/`. Its five over/under-report gaps stay, and every census row now
|
||||
prints whether the two interpolations **share a format spec** — the difference
|
||||
between a transposition that is silent and one that merely looks broken.
|
||||
|
||||
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
|
||||
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
|
||||
one `unique_id` prefix, so at two inverters B's per-unit telemetry had nowhere
|
||||
to go. It has one now — **without touching a single entity that already
|
||||
exists.**
|
||||
|
||||
* **⭐ A's discovery payloads are byte-identical to `release/1.0`.** Same topics,
|
||||
same `unique_id`s, same payload bytes, same retain flags, in the same order —
|
||||
verified by **diffing the announced payloads**, not by reading the code:
|
||||
`9 topics, 0 missing, 0 changed, 0 added` at a roster of one, and
|
||||
`0 missing, 0 changed, 8 added` at a roster of two. That is what protects the
|
||||
live install's recorded history and the three watchdog automations that name
|
||||
its entities.
|
||||
* **⚠️ THE ASYMMETRY IS DELIBERATE — DO NOT "CLEAN IT UP".** The first roster
|
||||
entry's per-unit rows go 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
|
||||
system — 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 goes on pointing at a sensor that
|
||||
quietly stopped updating. The composed ids come out symmetric anyway
|
||||
(`sensor.goodwe_inverter_a_battery_power` /
|
||||
`sensor.goodwe_inverter_b_battery_power`).
|
||||
* **Four per-unit rows** — setpoint, battery power, SoC and the 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,
|
||||
asserted rather than assumed.
|
||||
* **Site-scoped sensors stay site-scoped.** `grid` and `p1_sample_age_s` are
|
||||
announced **once**, on A's device, at every roster length 1–6. There is one P1
|
||||
and one grid connection, and `p1_sample_age_s` is a **watchdog input** — a
|
||||
second copy is a second answer to "is the meter fresh?", one of which is
|
||||
always the one nobody is looking at. Circulating power, controller status,
|
||||
maintenance phase and the capacity-tariff peak are site for the same reason.
|
||||
* **`inverter.roster_names()`** is now the one answer to "what are the units
|
||||
called", read by `build_roster()` **and** by the publisher — which has to know
|
||||
the roster **before** a `Controller` exists, because announcing happens on the
|
||||
broker's CONNACK.
|
||||
* **`test_mqtt.py`**, a new suite: the recorded `release/1.0` baseline
|
||||
(generated by running that commit's module, not transcribed), the payload
|
||||
diffs, the composed entity ids modelled **independently** of `app/mqtt.py` and
|
||||
anchored on the one id verified against the live system
|
||||
(`sensor.goodwe_p1_sample_age`), the announcement-vs-publication agreement on
|
||||
a real `Controller` with **different** SoC, battery sign and latch state on
|
||||
the two units, and **twelve mutations of the shipped module, all red, each on
|
||||
a named property**.
|
||||
* **MEASURED, NOT ASSERTED.** Three of those properties exist because the
|
||||
mutation 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. So was a roster fallback that named two
|
||||
nameless entries `inverter-a`. Sixteen mutations were run through
|
||||
`tools/mutation_run.sh`; the survivors are enumerated **with their size** in
|
||||
`test_mqtt.py` part 9.
|
||||
* ⚠️ **Inverter B does not physically exist** (~2026-09-12). Every fixture here
|
||||
is **simulated**, from the datasheet and serial in `NOTES.md` §1.1b. Nothing
|
||||
in this card was measured on a second inverter.
|
||||
|
||||
**The add-on configuration screen now speaks English and Dutch instead of raw
|
||||
option keys (UX-01).** There was no `translations/` directory at all, so Home
|
||||
Assistant fell back to rendering the option KEYS — an operator opening
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
# SAFETY-13 — the output freeze gets an exit, and only where it cannot end on its own
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-13`, off `release/1.0` @ `c8dcb38`.
|
||||
**Card:** SAFETY-13 — *"Bound the no-start-while-saturated output block"*
|
||||
([Trello](https://trello.com/c/uZjmuF7B)).
|
||||
**Nothing here reached the inverter.** Every figure below is from the pure control
|
||||
law run in a container against a plant model, on this branch and on a pristine
|
||||
`c8dcb38` for the before-figures. No rig, no ESP32, no Home Assistant.
|
||||
|
||||
---
|
||||
|
||||
## 0. The four things a reader should take away
|
||||
|
||||
1. **The card's headline case is real, and the route to it is not the obvious
|
||||
one.** The block does not engage on the way down into an export; the slew
|
||||
limit moves the command off zero before the counter reaches 3. It engages
|
||||
from the **deadband** — a site resting at its target with another writer on
|
||||
the bus, counting saturated cycles while nothing is wrong — and 0 W is then a
|
||||
fixed point. §2.
|
||||
2. **`prev_w == 0` is not a special case, so the fix is not one either.** The
|
||||
same run pins at −800 W against its own integrator standing at −2388 W, at a
|
||||
non-zero `prev_w`, with
|
||||
the same absence of an exit. Zero is only the instance where the pinned
|
||||
magnitude is the whole of what the battery could have done. §3.
|
||||
3. **The decision is not "how long", it is "which".** A divergence a *limit*
|
||||
could have produced clears itself and gets no exit — `release/1.0` unchanged.
|
||||
A divergence a limit could not have produced may not, and gets one. The
|
||||
predicate is physical, not statistical — and it is **not** a second-writer
|
||||
detector; §4.1 and §8 say what it can and cannot tell apart.
|
||||
4. **Releasing the output freeze does not unfreeze the loop**, and that is what
|
||||
makes the release bounded rather than merely different. §6.
|
||||
5. **The bound comes from the SLOWER half of a two-sided measurement, and the
|
||||
first version of this branch took the faster half.** §8 is the correction and
|
||||
the measurement behind it; it is the reason `SETTLE_CYCLES` is 12 and not 9.
|
||||
|
||||
---
|
||||
|
||||
## 1. What was there, in the terms the code uses
|
||||
|
||||
`control.py`, in `compute()`, after the clamp and the slew limit:
|
||||
|
||||
```python
|
||||
if frozen:
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
```
|
||||
|
||||
"Magnitude may fall, never rise." At `prev_w == 0` the magnitude is zero and
|
||||
`max(target, 0)` forbids any negative command, so a charge cannot **start**.
|
||||
|
||||
The reason is written in the file and it is a good one: `frozen` means the
|
||||
inverter's reported power has diverged from the command by more than
|
||||
`saturation_w` for `saturation_cycles` consecutive cycles, and at a command of
|
||||
zero that means the actuator is producing power nobody in this add-on asked for.
|
||||
Starting to push the other way into it is the configuration §1.5 of the
|
||||
specification rules out — *"any configuration in which two writers can reach one
|
||||
actuator"*.
|
||||
|
||||
**The defect is that it has no end.** Not the error growing, not the integrator
|
||||
moving, not time. `sat_count` stops rising at its cap and the state is a fixed
|
||||
point.
|
||||
|
||||
---
|
||||
|
||||
## 2. Reproducing it — and correcting the route
|
||||
|
||||
Driven at the live install's shape: `max_w 5000`, `slew_w 400`,
|
||||
`target_grid_w -10`, `saturation_w 500`, `saturation_cycles 3`, `gain 0.6`.
|
||||
Another writer holds the pack at **+700 W** whatever is commanded.
|
||||
|
||||
**The route that does NOT reach it.** Start at `prev_w = 0` with 2 kW of export
|
||||
already standing. On `c8dcb38` the loop moves off zero at the slew limit before
|
||||
the counter reaches 3 — commands −400, −800 — so by the time `frozen` is true
|
||||
`prev_w` is −800 and the block that gets applied is `max(target, −800)`. The
|
||||
site charges at 800 W, not 0 W, for as long as the run lasts.
|
||||
|
||||
**The route that does.** The site is *resting*. `grid == target_grid_w`, so the
|
||||
loop is in its deadband and the command is 0 — and `saturated_now` is computed
|
||||
**before** the deadband branch, so the counter climbs anyway. Three cycles later
|
||||
the loop is frozen at `prev_w == 0` with nothing wrong. Then the export arrives:
|
||||
|
||||
| cycle | grid | actual | prev | command | integrator | sat | reason |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 0–1 | −10 | +700 | 0 | 0 | 0 | 1–2 | `deadband` |
|
||||
| 2–5 | −10 | +700 | 0 | **0** | 0 | 3–6 | `saturated-freeze` |
|
||||
| 6 | −2000 | +700 | 0 | **0** | −1194 | 7 | `saturated-freeze` |
|
||||
| 7… | −2000 | +700 | 0 | **0** | −1194 | 8→10 | `saturated-freeze` |
|
||||
|
||||
On `c8dcb38` the command is `0.0` on **every** cycle from there on. The
|
||||
integrator has moved to −1194 W and stopped (its own freeze holds it), the site
|
||||
exports 2 kW, and the battery is idle until the other writer leaves.
|
||||
|
||||
That is the card's sentence — *"the site can sit exporting 2 kW with an idle
|
||||
battery indefinitely"* — reproduced, with the route corrected: it is entered
|
||||
from the **deadband**, not from a correction that ran out of room.
|
||||
|
||||
---
|
||||
|
||||
## 3. …and `prev_w == 0` is not where it stops
|
||||
|
||||
The same tuning, same writer, but the loop starts from zero with the export
|
||||
already standing (the route from §2 that "does not reach it"):
|
||||
|
||||
| | `c8dcb38` |
|
||||
|---|---|
|
||||
| command after the freeze engages | **−800 W**, on every cycle |
|
||||
| the loop's integrator, frozen | −2388 W |
|
||||
| what ends it | nothing |
|
||||
|
||||
So the missing exit is a property of the **output freeze**, not of the zero
|
||||
case. `prev_w == 0` is the instance where the pinned magnitude happens to be
|
||||
zero. A rule written for zero alone would have left −800 W exactly as unbounded
|
||||
as it found it — which is why the change below is written against the freeze and
|
||||
not against a special case of it.
|
||||
|
||||
**And this is the half that escalates at two inverters.** `actual_w` is
|
||||
`site_batt_w`, the SUM (ALLOC-01, DEV-02), so the comparison is a site command
|
||||
against two units' power. One unit still moving after the other has stopped is
|
||||
enough to put the site into the freeze with no external actor at all.
|
||||
|
||||
⚠️ **What that claim is and is not.** The escalation is stated from the code —
|
||||
`prev_w` is `self.target`, the site total, and `actual_w` is the site sum — and
|
||||
from the single-unit runs above. **It is not measured on two inverters.**
|
||||
Inverter B does not physically exist (`NOTES.md` §1.1b: on paper, from datasheet
|
||||
and serial), and `sim/plantsim.py:216` `INVERTER_B` is `INVERTER_A` copied with
|
||||
`measured=False`. Any two-inverter number would be two copies of A.
|
||||
|
||||
---
|
||||
|
||||
## 4. The decision: which freezes get an exit, not how long the block lasts
|
||||
|
||||
Three answers were on the table, and the card named them: lift after N frozen
|
||||
cycles, exempt above some export magnitude, or keep it unbounded and write down
|
||||
the cost.
|
||||
|
||||
**A plain cycle bound was rejected**, because it bounds the wrong thing. The
|
||||
freeze is right to be permanent in one case and cannot be permanent in the
|
||||
other, and a single N treats them alike:
|
||||
|
||||
| the inverter is… | can the freeze clear itself? |
|
||||
|---|---|
|
||||
| doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap, the plant still ramping | **Yes.** The command is pinned at something the inverter can reach, so it catches up, `saturated_now` goes false, and the freeze ends. Asking for more would achieve nothing anyway. |
|
||||
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **No.** Clearing the freeze requires the inverter to track *our* command, and the other writer is precisely what stops that. |
|
||||
|
||||
**An export-magnitude exemption was rejected** for a narrower reason: it makes
|
||||
the block's end depend on how big the other actor's disturbance happens to be,
|
||||
which is not a property of the thing the block is about.
|
||||
|
||||
So the rule implemented is:
|
||||
|
||||
> The output freeze applies as before, **unless** the inverter's reported power
|
||||
> cannot be explained by the command not being met **and** the divergence has
|
||||
> outlasted the plant that would otherwise have explained it.
|
||||
|
||||
### 4.1 The predicate is physical
|
||||
|
||||
`unasked_power(prev_w, actual_w, saturation_w)`. A limit can only make the
|
||||
inverter do **less** than the command, in the command's own direction. It cannot
|
||||
make it overshoot the command and it cannot make it produce the opposite sign.
|
||||
So everything a limit can produce lies in the closed interval spanned by `0` and
|
||||
`prev_w`, widened by `saturation_w` for noise, ramp lag and idle draw.
|
||||
|
||||
⚠️ **What it does NOT establish is that anything outside was put there by
|
||||
something else.** The first version of this branch said exactly that, in the
|
||||
docstring, in `control.py` and in DOCS.md, and it is false: the predicate
|
||||
compares against **one cycle's** command while the pack carries every command
|
||||
before it, so our own coast-down through zero lands outside our own interval.
|
||||
§8 measures it. What the predicate guarantees is only its own first sentence —
|
||||
*this cycle's command does not account for this cycle's reading* — and the time
|
||||
bound below is what turns that into a reason to act.
|
||||
|
||||
| prev | actual | interval | verdict |
|
||||
|---|---|---|---|
|
||||
| +2000 | +700 | [0, 2000] | a limit |
|
||||
| −5000 | −700 | [−5000, 0] | a limit |
|
||||
| 0 | +700 | [−500, +500] | **unasked** |
|
||||
| −800 | +700 | [−1300, +500] | **unasked** |
|
||||
|
||||
⚠️ **At `prev_w == 0` this predicate and the saturation detector are the same
|
||||
test** — asserted in `test_control.py` over 801 readings. That is not a
|
||||
coincidence to tidy away: with nothing commanded there is no interval left for a
|
||||
limit to live in, which is exactly why the zero case is the one that strands.
|
||||
|
||||
⚠️ **It is not a detector and nothing alarms on it.** A stale, wrong-signed or
|
||||
noisy reading lands outside the interval too. All it decides is whether the
|
||||
freeze gets an exit; being wrong about it costs a bounded command (§6).
|
||||
SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on sign, and it is
|
||||
deliberately a different test with a different threshold.
|
||||
|
||||
### 4.2 The bound, and which half of the measurement it comes from
|
||||
|
||||
`SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)` = **12**.
|
||||
|
||||
⚠️ **`PLANT_SETTLE_DOWN_S` is new, and adding it is the point rather than a
|
||||
cost.** `NOTES.md` §5.10 did not measure *a* settling time; it measured the same
|
||||
1.78 kW step in **both directions** — "Up: ~18 s from step to deadband. Down:
|
||||
~23 s from a −1784 W export excursion" (`NOTES.md:1550`). So the plant's
|
||||
settling time is a **pair**, and which half is the safe one depends on which way
|
||||
being wrong hurts:
|
||||
|
||||
| the bound | the question | the safe half | why |
|
||||
|---|---|---|---|
|
||||
| SAFETY-39's `SLEW_RAIL_FRAC` | may this configuration be **refused**? | **18 s**, the shorter | a looser bound refuses fewer working configurations |
|
||||
| this one | may the plant still be the **explanation**? | **23 s**, the longer | a shorter bound stops believing the plant too early, and then it accuses |
|
||||
|
||||
The first version of this branch reused `PLANT_SETTLE_S` and got 9 — three
|
||||
cycles short of the plant's own measured down-ramp, on the side that produces a
|
||||
false positive. §8 is the measurement and the correction.
|
||||
|
||||
⚠️ **It is no longer the reciprocal of `SLEW_RAIL_FRAC`, and it never should
|
||||
have been read as one.** The two were equal only while both were being derived
|
||||
from the same half of the pair, and that coincidence read as a derivation. They
|
||||
are 9 and 12 now; `test_control.py` asserts the inequality so they cannot be
|
||||
quietly re-fused.
|
||||
|
||||
⚠️ **It inherits SAFETY-39's caveat about `LOOP_TICK_S`, in the opposite
|
||||
direction.** A meter **slower** than 2 s makes 12 cycles cover more than 23 s, so
|
||||
the freeze holds longer than the plant needs: that costs energy, never safety. A
|
||||
meter **faster** makes them cover less, so the freeze can release while the
|
||||
plant is still moving — bounded by §6 rather than by luck.
|
||||
|
||||
**`_SAT_COUNT_CAP` moved with it, from 10 to 13**, because it is written as
|
||||
`SETTLE_CYCLES + 1` — a counter capped below the longest bound read from it
|
||||
would silently delete the exit. That has a consequence worth stating on its own:
|
||||
`config.yaml` accepts `saturation_cycles` as `int(1,10)`, so at the old bound of
|
||||
9 a legal setting could sit **above** the exit and delete the block. At 12 the
|
||||
whole accepted range is strictly below the bound, and the degenerate
|
||||
configuration this branch used to document no longer exists. §7.
|
||||
|
||||
---
|
||||
|
||||
## 5. What actually changed, graded against `release/1.0`
|
||||
|
||||
`test_control.py`'s sweep still compares this law against a transcription of
|
||||
`release/1.0` — the transcription was **not** taught the new rule, because an
|
||||
oracle taught the change compares the law against itself. What changed is the
|
||||
assertion: every case must be identical, or be a release with both of its
|
||||
conditions true, or differ **in the saturation counter alone**.
|
||||
|
||||
⚠️ **The sweep had to be widened to keep grading anything.** Its `sat_count`
|
||||
fixtures ran to 9; with the bound at 12 the counter never reached the exit and
|
||||
every release silently vanished from the table, which is what the assertion on
|
||||
the count caught. It runs to 12 now, at 3780 cases.
|
||||
|
||||
| | cases |
|
||||
|---|---|
|
||||
| total | 3780 |
|
||||
| **bit-identical to `release/1.0`** | **3267** |
|
||||
| releases | 432 |
|
||||
| …of those, reason-only (the freeze was not binding) | 405 |
|
||||
| …of those, **the command changes** | **27** |
|
||||
| differ in the **saturation counter alone** | 81 |
|
||||
|
||||
All 27 command changes are at `prev_w == 0`, all with `release/1.0` commanding
|
||||
exactly `0.0 W`, and every new command is **negative** — a charge start that was
|
||||
refused.
|
||||
|
||||
⚠️ **The 81 are the counter's ceiling moving, and they reach nothing.**
|
||||
`_SAT_COUNT_CAP` went from 10 to 13 with the bound, so above `release/1.0`'s own
|
||||
ceiling the two counters differ. Nothing else may differ in those cases, and the
|
||||
sweep asserts that: `frozen` is `sat_count >= saturation_cycles`, `config.yaml`
|
||||
accepts that at most 10, and no other line in `compute()` reads the counter's
|
||||
value — so a counter reading 13 instead of 10 cannot change what is commanded.
|
||||
The 81 all sit at an `sc` above 10, which is a state `release/1.0` could never
|
||||
hold at all.
|
||||
|
||||
The behaviour after the fix, on §2's run: the command holds at 0 W through
|
||||
`sat_count` 3–11, releases on the **twelfth** saturated cycle, and walks
|
||||
0 → −400 → −800 → **−1190 W**, where it stops — the same settling point as at
|
||||
the old bound, three cycles later.
|
||||
|
||||
The genuine-limit case, driven for 30 cycles with the inverter curtailed to
|
||||
±1000 W against a 4 kW import: `freeze-released` never appears and the command
|
||||
stays pinned at +2400 W. Unchanged, deliberately.
|
||||
|
||||
---
|
||||
|
||||
## 6. Why the release cannot become windup (the card's fourth criterion)
|
||||
|
||||
**The integrator freeze is not lifted with the output freeze.** The command is
|
||||
derived from `i_w`, then clamped, then slew-limited from `prev_w` — so a
|
||||
released cycle can only move the command *toward* an accumulator that stopped
|
||||
accumulating when the freeze engaged, by at most one slew step, and never past
|
||||
it. `test_control.py` sweeps 107 625 states, of which **67 500 release**: no
|
||||
released case leaves the interval between the previous command and the frozen
|
||||
integrator, and none moves further than one slew step.
|
||||
|
||||
⚠️ **107 625 was previously reported as the number of released states. It is
|
||||
the number of states swept**, and the assertion only ever graded the subset that
|
||||
released. Both numbers are counted and printed now.
|
||||
|
||||
On §2's run that shows up as the settling point: **−1190 W against a frozen
|
||||
integrator of −1194 W**, not the 5000 W rail. The tech lead's note on SAFETY-04
|
||||
— that the carried `i_w` and `release/1.0`'s recomputed value land on the same
|
||||
slew-limited command — is preserved: the `i_w=None` path is still graded against
|
||||
the transcription, cycle for cycle, everywhere except the 432 releases.
|
||||
|
||||
⚠️ **The first form of this check was wrong and the sweep said so**, which is
|
||||
left in the test file. It asserted `|target| <= |i_w|` on a released cycle and
|
||||
produced `prev −5000, i_w −3000, target −4600`: the command was walking back
|
||||
from −5000 toward the integrator and had not arrived. It was moving the right
|
||||
way and the assertion still failed, because "never exceeds the integrator" is
|
||||
not what no-windup means here.
|
||||
|
||||
---
|
||||
|
||||
## 7. What this does NOT buy, said plainly
|
||||
|
||||
**The site still does not reach its target while another writer is on the bus.**
|
||||
It settles at the frozen integrator, not at the meter's target. What the exit
|
||||
buys is that the battery stops being *idle* while the site exports; it does not
|
||||
buy correct regulation against an actuator somebody else is also writing to, and
|
||||
nothing in a rules engine can. A standing grid error with a second writer
|
||||
present is the symptom of a configuration this add-on refuses to support.
|
||||
|
||||
**`freeze-released` is a place to start looking, not a finding.** It says the
|
||||
divergence outlasted the plant's own measured settling time, and that is all it
|
||||
says — §8. DOCS.md now tells the operator the innocent causes first (a large
|
||||
load or PV swing, a plant slower than the one that was measured) and the second
|
||||
writer only when the state **stands**. It is carried on `Decision.reason`, which
|
||||
nothing after the freeze overwrites. No new alarm was added: SAFETY-41 removed
|
||||
one that narrated normal anti-windup, and this state is not yet known to be
|
||||
worth waking anybody for.
|
||||
|
||||
**No accepted configuration removes the block any more.** `config.yaml` accepts
|
||||
`saturation_cycles` as `int(1,10)`; the bound is 12, so every accepted value is
|
||||
strictly below it and the freeze always engages before its exit is due. This
|
||||
paragraph used to say the opposite — at a bound of 9, settings of 9 and 10
|
||||
deleted the block silently and the branch documented that as a known
|
||||
consequence. Moving the bound closed it by construction. `test_control.py` pins
|
||||
it against `config.yaml`'s own schema line rather than a transcription of it.
|
||||
|
||||
**Nothing was measured on two inverters.** See the warning in §3.
|
||||
|
||||
---
|
||||
|
||||
## 8. ⚠️ ROUND 2: the bound was derived from the wrong half, and it accused
|
||||
|
||||
**What was wrong.** `SETTLE_CYCLES` was `ceil(PLANT_SETTLE_S / LOOP_TICK_S)` = 9.
|
||||
`PLANT_SETTLE_S` is 18 s, the **up**-ramp of `NOTES.md` §5.10's kettle step;
|
||||
the same measurement recorded ~23 s coming back **down** (`NOTES.md:1550`).
|
||||
SAFETY-39 takes 18 correctly — its bound is a refusal, and the looser number
|
||||
refuses less. This bound asks the opposite question, and the looser number stops
|
||||
believing the plant three cycles too early.
|
||||
|
||||
**Why that is not a tuning quibble.** The predicate has no memory. `prev_w` is
|
||||
one cycle's command; the pack carries every command before it. A command that
|
||||
falls **through zero** — the ordinary end of a load or PV step — leaves the pack
|
||||
coasting in the old direction, so the reading has the sign the command *used* to
|
||||
have and lands outside the interval spanned by 0 and the new `prev_w`.
|
||||
`unasked_power()` calls that "unasked". With the bound three cycles short of the
|
||||
plant's own down-ramp, the counter reaches it while the coast-down is still
|
||||
going, and the law emits `freeze-released` on **one inverter with nothing else
|
||||
on the bus.**
|
||||
|
||||
**Measured, in the container, N = 1.** One `compute()`, one plant model, one
|
||||
command; no second writer anywhere in the harness. The plant is a first-order
|
||||
lag whose two time constants come from the two measured settling times
|
||||
(tau = t/3, since a first-order step is 95 % done at 3 tau) with
|
||||
`sim/plantsim.py`'s measured 800 W/s ramp cap. It is a **model**, and it is
|
||||
labelled one wherever it is used.
|
||||
|
||||
| | bound 9 | bound 12 |
|
||||
|---|---|---|
|
||||
| 1280 closed-loop scenarios (a grid of plant pairs, slews, `saturation_cycles`, load steps) | **113 fire** | **24 fire** |
|
||||
| 272 load-step pairs at the measured 18 s / 23 s plant, `slew_w 400` | **62 fire** | **2 fire** |
|
||||
|
||||
The single case now pinned in `test_control.py`: a 4 kW export falling to
|
||||
2.5 kW. At cycle 34 the command is −310 W, the pack is still at −1343.4 W
|
||||
coasting down from −4860 W a few cycles earlier, `unasked_power(−310, −1343.4,
|
||||
500)` is True, `sat_count` is 9, and the law says `freeze-released`. At 12 the
|
||||
same run never releases.
|
||||
|
||||
**What the fix does not fix.** The bound makes the false positive rare on *the
|
||||
plant that was measured*. It cannot make it impossible, because it is a
|
||||
measurement and the plant can be slower than the measurement. Same sweep, bound
|
||||
held at 12, lengthening only the plant's down-ramp: 8 of 320 scenarios at the
|
||||
measured 23 s, 59 of 320 at 34 s, 268 of 320 at 50 s. That residual is why the
|
||||
three statements below were corrected rather than merely re-numbered.
|
||||
|
||||
**Three shipped statements were falsified by this and are now rewritten:**
|
||||
|
||||
1. `unasked_power()`'s docstring — *"Anything outside that interval was put
|
||||
there by something other than this loop."* It was our own power. The
|
||||
docstring now says what the predicate does guarantee (this cycle's command
|
||||
does not account for this cycle's reading) and states the coast-down as its
|
||||
known blind spot.
|
||||
2. `control.py`, in `compute()` — *"SO THE BOUND BELONGS TO (b) AND ONLY TO
|
||||
(b)"*. It fired in case (a). The comment now says the bound is a reason to
|
||||
stop believing (a), not a proof of (b), and that the release is written to be
|
||||
survivable when the inference is wrong.
|
||||
3. **DOCS.md — *"If you see `freeze-released` in the action log, look for the
|
||||
second writer."*** This is the one that reaches a person, and on a
|
||||
single-inverter install it sent them hunting something that does not exist.
|
||||
Replaced by a section that gives the innocent causes first and the second
|
||||
writer only when the state stands.
|
||||
@@ -0,0 +1,376 @@
|
||||
# SAFETY-37 — the freshness policy, and the one sentence in the card that had to be rewritten
|
||||
|
||||
**Status: DECIDED 2026-09-06.** A per-unit reading that has stopped following its
|
||||
own command is reported to its consumers as **`None`**, never as a held number.
|
||||
The verdict is a **conjunction**, not an age threshold, and this document records
|
||||
why an age threshold is not available at this layer — which is the one place the
|
||||
card's wording could not be implemented as written.
|
||||
|
||||
Filed with SAFETY-37. Card `6a9a7eda7932387c3acbd700`, scope widened by the
|
||||
architect pass of 2026-09-06 to carry the single policy for [[TEL-06]] and
|
||||
[[TEL-08]] as well.
|
||||
|
||||
Every figure below was measured or read out of this repository on **2026-09-06**
|
||||
and is re-derived by `test_freshness.py` on every run. Where a number is carried
|
||||
forward from an earlier measurement, the section and the date it was taken are
|
||||
given at the number.
|
||||
|
||||
---
|
||||
|
||||
## 0. The three faces, and which one this card built
|
||||
|
||||
| where | the defect | this card |
|
||||
|---|---|---|
|
||||
| **Layer 1** — `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` | the ingest-age veto tests the **VALUE** of `sensor.goodwe_p1_sample_age`, not its arrival. A dead publisher freezes it at a healthy number and the veto can never fire | **not touched.** It is firmware, behind a flash. §5 says what it owes |
|
||||
| **The add-on** — `app/hass.py:69-82` | returns a held value as a value. Nothing separates "read 40 %" from "still says 40 % because nothing updated it" | **built here** |
|
||||
| **The evidence** — [[HW-01]] | SoC held 40 % for 6 h 25 m while the pack drained to its floor, every detector reading healthy | the case the policy is answerable to |
|
||||
|
||||
The two consumers the card names — SAFETY-30's per-unit sign check and
|
||||
SAFETY-31's circulating-power sensor — both read `Inverter.batt` (register
|
||||
`0x0518`) and **neither had a freshness gate of any kind**. Both already do the
|
||||
right thing with a `None`: `watchdog.SignWatchdog.evaluate` **clears** its window
|
||||
on a `None` measurement, and `circulation.circulating_w` returns `None` for a
|
||||
roster with a missing term, which clears that window too. **Neither could ever
|
||||
receive one.** The gate is the piece that was missing *between* the shipped read
|
||||
and the shipped checks, which is why the change to each consumer is zero lines.
|
||||
|
||||
---
|
||||
|
||||
## 1. ⚠️ The sentence that had to be rewritten, and why saying so is the finding
|
||||
|
||||
The card says: *"A pack legitimately resting at 40 % and a dead sensor reading
|
||||
40 % are the same number; **only arrival separates them**."*
|
||||
|
||||
**For a signal this add-on polls out of Home Assistant's state cache, arrival
|
||||
does not separate them, and cannot.** `read_inputs()` runs about once a second
|
||||
(`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), and while the
|
||||
entity exists Home Assistant answers `200` with a number **whether or not the
|
||||
publisher behind it is alive**. So both members of the pair arrive, identically,
|
||||
for ever. `test_freshness.py` part 1a builds an arrival-age gate and grades it:
|
||||
the age since arrival is `0.0 s` at every instant of **both** runs. A threshold
|
||||
on it is a threshold that can never fire — which is TEL-06's own defect rebuilt
|
||||
one layer up.
|
||||
|
||||
The other half of the card's rule survives intact and is honoured absolutely:
|
||||
|
||||
> ⚠️ Home Assistant's `last_updated` does **not** move when a value is rewritten
|
||||
> unchanged, so it cannot be the source of truth.
|
||||
|
||||
Neither does `last_changed`, and this project measured on the ENV-01 rig (70 s of
|
||||
a frozen meter) and independently against the live house that `last_reported`
|
||||
does not advance on a repeat on either serialiser either —
|
||||
`p1.HomeWizardLocalSource`'s docstring records both. **`app/freshness.py` reads
|
||||
no Home Assistant timestamp at all.** Every clock in the verdict is
|
||||
`time.monotonic()` inside this process, stamped at the instant an arrival carried
|
||||
a different number. That is what "arrival-stamped" can mean here, and it is
|
||||
graded (part 1a asserts the module's body contains no `last_updated`).
|
||||
|
||||
**So the criterion was rewritten rather than approximated:** *a reading is stale
|
||||
when it has not moved through a change it was obliged to follow.* The obligation,
|
||||
not the age, is what separates the pair.
|
||||
|
||||
---
|
||||
|
||||
## 2. The verdict
|
||||
|
||||
```
|
||||
STALE ⟺ unchanged for ≥ 35.0 s
|
||||
AND |standing command now| ≥ 250 W
|
||||
AND |standing command now − standing command when the reading
|
||||
last changed| ≥ 250 W
|
||||
```
|
||||
|
||||
Latched: **only an arrival carrying a different number clears it.**
|
||||
|
||||
`0x0518` is the inverter reporting **itself** — r = 0.998 with battery power
|
||||
against r = 0.09 with the real meter (`goodwe/NOTES.md` §3.3) — and it tracks its
|
||||
own command: command and actual agreeing within 4 W once settled (§5.10,
|
||||
measured 2026-08-22), the register dithering ±1 W while holding (§5.11, same
|
||||
day). A unit told to move 250 W and still answering the same integer watt is not
|
||||
tracking.
|
||||
|
||||
### 2.1 Why a bare "unchanged for N seconds" threshold is not available
|
||||
|
||||
**Measured, not feared.** Replaying the committed 30-day capture of this house's
|
||||
own 1 W-resolution power entity — `scenarios/p1-net-30d-2026-08/`, 485,841
|
||||
samples straight out of Home Assistant's recorder, where a row exists only when
|
||||
the state string **changed**, so the gap between two rows *is* the unchanged
|
||||
duration every consumer saw:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| longest unchanged run over 30 days | **263.2 s** (held −899.0 W, 2026-08-11 17:04) |
|
||||
| runs ≥ 35 s | 10 of 485,840 (0.0021 %) |
|
||||
| runs ≥ 17 s | 386 of 485,840 (0.0794 %) |
|
||||
|
||||
A real power figure at 1 W resolution held **one value for four and a half
|
||||
minutes with nothing whatever wrong**. So an unchanged threshold free of false
|
||||
positives must be over 263 s — and 263 s is **3.5× too slow** to protect a
|
||||
consumer that trips at 75 s. There is no admissible threshold. The conjunction
|
||||
is not a refinement; without it the card has no implementable verdict.
|
||||
|
||||
⚠️ **That is the METER's register, not `0x0518`, and it is used as a surrogate
|
||||
and named as one.** No 30-day capture of the inverter's own power exists — it is
|
||||
not in the recorder export. The single thing this measurement is used for is the
|
||||
statement above: that the legitimate unchanged duration of a power entity in this
|
||||
house is *minutes*. It is not used to set the bound.
|
||||
|
||||
⚠️ **And every one of those long runs happened under an unmoving load** — the
|
||||
worst is 263 s of a steady −899 W export, a house doing one thing. That is why
|
||||
the conjunction keys on command travel: none of them arms it.
|
||||
|
||||
### 2.2 Why the magnitude term exists — a surviving mutation put it there
|
||||
|
||||
Deleting `abs(standing_w) < move_w` from `_grade()` left **every check in the
|
||||
first draft green**, because in all of them a command that had *travelled*
|
||||
250 W had also *ended up* above 250 W. The case that separates them is a site
|
||||
being brought to **rest**: the write path is serialised, so a register can reach
|
||||
its new value on the same read at which the *old* command is still standing —
|
||||
`standing_at_change` then captures 1300 W, the site is commanded to 0 W, and the
|
||||
travel term alone reads 1300 W of unfollowed command for as long as the site
|
||||
rests. It would call a perfectly truthful +5 W reading dead. The magnitude term
|
||||
is what says *a site at rest is not owed movement*, and part 4 now grades it.
|
||||
|
||||
**The hole that term leaves cannot mislead either consumer**, which is why it is
|
||||
the right trade: a unit commanded under 250 W is one `watchdog.disagrees()` is
|
||||
`False` for whatever the measurement says, and a frozen term beside a live unit
|
||||
that is itself resting nets to **zero** circulating power rather than to a false
|
||||
alarm. Both halves are asserted, not argued.
|
||||
|
||||
---
|
||||
|
||||
## 3. The bounds — one per signal, each with its derivation
|
||||
|
||||
⚠️ **One constant for both signals would be useless on SoC or trigger-happy on
|
||||
power**, which is the third clause of the policy. `freshness.BOUNDS` carries them
|
||||
as data with a `wired` column, and `test_freshness.py` part 7 walks the AST of
|
||||
every shipped module to check that column tells the truth.
|
||||
|
||||
### 3.1 `batt` — `0x0518`, S16, 1 W, published every 10 s — **WIRED**
|
||||
|
||||
**Floor, 17.0 s** — the fastest a healthy register can be *obliged* to have
|
||||
answered. Three terms, each read out of this repository:
|
||||
|
||||
| | | source |
|
||||
|---|---|---|
|
||||
| 6.0 s | the plant | `goodwe/NOTES.md` §5.9, measured 2026-08-22 with the poll temporarily at 1 s: 26 W at +1.43 s, 469 W (94 %) at +3.34 s, settled 507 W at +6.05 s |
|
||||
| 10.0 s | the publisher | `firmware/goodwe-master.yaml:181`, `update_interval: 10s` on the `modbus_controller` carrying both `0x0518` and `0x050E` |
|
||||
| 1.0 s | this add-on's read period | `main.run_control()`'s `await asyncio.sleep(1)` |
|
||||
|
||||
**Ceiling, 75.0 s** — the tighter of the two consumers, both read out of the
|
||||
shipped `config.yaml`: `circulation_hold_s` **75** (line 512) and
|
||||
`unit_sign_disagree_s` **85** (line 451). A gate slower than its consumer hands
|
||||
over the frozen number and explains afterwards.
|
||||
|
||||
**Chosen: 35.0 s.** The admissible band `[2 × 17.0, 0.5 × 75.0] = [34.0, 37.5]`
|
||||
contains four integers, and **the band is what is derived — 35 is a choice inside
|
||||
it**, deliberately not 30 (that is `validate.FIRMWARE_INGEST_VETO_S`, and would
|
||||
read as a borrowed constant nobody re-derived) and not 60 (round). Part 6
|
||||
computes both ends **from `config.yaml`** and goes red if an operator default
|
||||
moves: drop `circulation_hold_s` to 60 and this fails.
|
||||
|
||||
**`MOVE_W` = 250 W** is the **largest** admissible value, not a sensitivity
|
||||
preference. The gate must arm everywhere a stale reading could mislead a
|
||||
consumer; the earliest either consumer speaks is `unit_sign_disagree_w`, 250 W
|
||||
(config.yaml:450), because below that `watchdog.disagrees()` is `False` whatever
|
||||
the measurement says. Taking the ceiling of the admissible range minimises
|
||||
spurious arming. It is pinned to `config.yaml` by part 6, not to a literal.
|
||||
|
||||
### 3.2 `soc` — `0x050E`, U16, 1 %, published every 10 s — **STATED, NOT WIRED**
|
||||
|
||||
SoC **cannot use the conjunction above.** A pack resting at its floor holds one
|
||||
percent for hours with nothing wrong — the card's own example — and the standing
|
||||
command says nothing about it, because 0 W is exactly what a rested pack is
|
||||
given. The obliged movement for SoC is **energy, not command travel**: one
|
||||
percent of pack A is ≈ **153.6 Wh** (15.36 kWh nominal, `goodwe/NOTES.md` §1.1b's
|
||||
table — ⚠️ **NOMINAL and FROM DATASHEET**, not a measured usable capacity), so at
|
||||
pack A's 5000 W ceiling one percent takes at least **110 s**, and at a realistic
|
||||
1000 W about **553 s**.
|
||||
|
||||
So the bound is *"SoC unchanged while ∫|battery power| dt since its last change
|
||||
exceeded K × 153.6 Wh"*, and **K cannot be chosen from anything written down
|
||||
today**. Wiring it with a guessed K would be a gate that fires on a rested pack,
|
||||
which is the hazard, not the fix. `Freshness` takes its obligation term from the
|
||||
caller precisely so an energy integral can be handed in where command travel is
|
||||
handed in for `batt` — TEL-08 needs no second mechanism.
|
||||
|
||||
---
|
||||
|
||||
## 4. What changes on a running install
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| healthy register, N=1 | value reaches consumers | **identical** — 60 reads of a dithering register under a reversing command, asserted in part 5 |
|
||||
| healthy register, N≥2 | value reaches consumers | identical |
|
||||
| **frozen register, N=1** | reads as a healthy measurement, for ever | **site stops**: `inputs_ok` False → `stale_input_s` hold → 0 W, and keeps commanding |
|
||||
| **frozen register, N≥2** | per-unit sign check false-trips at 85 s and parks a healthy inverter; circulation sensor reports a loop that is not happening | both consumers get `None`, both windows **clear** |
|
||||
|
||||
⚠️ **The N=1 row is a behaviour change on the roster that is running in a house
|
||||
today, and it is named rather than hidden.** It is also the point of the card:
|
||||
HW-01 was a one-inverter site. The direction it fails in is the safe one — a
|
||||
false verdict commands 0 W and keeps commanding (§11.1 rule 1), behind the
|
||||
existing `stale_input_s` hold, and clears itself on the register's next move.
|
||||
|
||||
The counterfactual is run, not argued. `test_freshness.py` part 3 drives real
|
||||
`cycle()` calls at N=2 with A's register frozen at −1200 W and B's live at
|
||||
+900 W, the command travel produced by the shipped `compute()` and allocator:
|
||||
|
||||
```
|
||||
standing on A: [0, 445, 890, 1335, 1335, ...] (the shipped control law)
|
||||
gated : sign trip tick=None circulating_w=[1800, 1800, 1800, None, None, ...]
|
||||
counterfact : sign trip tick=11 circulating_w=[1800, 1800, 1800, 1800, 1800, ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. What TEL-06 and TEL-08 still owe
|
||||
|
||||
**[[TEL-06]] — layer 1, Blocked, and nothing in this card can reach it.** The
|
||||
ESPHome veto tests the *value* of `sensor.goodwe_p1_sample_age`. This policy
|
||||
lives in the add-on, on the other side of a flash, and cannot help. What TEL-06
|
||||
owes is the same mechanism **inside the firmware**: a `millis()` stamp taken when
|
||||
the setpoint/age entity is *written*, and a veto on `millis() − last_write_ms`
|
||||
rather than on the published number. It is narrowed to that one substitution —
|
||||
the policy question ("arrival, not value") is answered here; the remaining work
|
||||
is a firmware stamp and a flash, which is a supervised human step.
|
||||
|
||||
**[[TEL-08]] — stale SoC.** It owes exactly three things, and no mechanism:
|
||||
1. a **measured** usable capacity for pack A (or a defensible bound on it) — the
|
||||
153.6 Wh per percent above is nominal and from the datasheet;
|
||||
2. the **energy integral** since the reading last changed;
|
||||
3. the constant **K** — how many percent-equivalents of throughput must pass
|
||||
before an unmoved SoC is a fault.
|
||||
|
||||
It should consume `freshness.Freshness` with `BOUNDS["soc"]` filled in, not
|
||||
restate the policy. The row is already in the table with `wired=False` and the
|
||||
reason, and part 7 goes red the day that column stops being true.
|
||||
|
||||
**⚠️ Neither of them, and not this card, closes HW-01's whole class.** See §6.
|
||||
|
||||
---
|
||||
|
||||
## 6. Known survivors, with their size
|
||||
|
||||
Graded in `test_freshness.py` part 9, so they are residuals rather than
|
||||
surprises.
|
||||
|
||||
| # | kind | what survives | size |
|
||||
|---|---|---|---|
|
||||
| 1 | **false positive** | an inverter physically unable to follow — saturating, thermally derating, or a BMS refusing a charge — is declared stale while telling the truth | one spurious **site stop**, behind `stale_input_s`, self-clearing the moment the register moves. It cannot drive the battery anywhere |
|
||||
| 2 | **miss** | a register frozen under a command that never travels 250 W is never detected, for 400 s or for ever | ⚠️ scoped for ONE consumer, NOT both, and the earlier claim here was wrong. TRUE for the per-unit sign check: no such check can speak about that unit (`unit_sign_disagree_w` = 250 W). **FALSE for circulation.** "Contributing at most 250 W" 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: a unit frozen at −1200 W beside a healthy +900 W sibling yields `circulating_w` = **1800 W against the 700 W floor, 2.6× over**, with no gate armed. Found in review; part 9's S2 check asserts only `not stale` and part 4 pairs the frozen unit with a RESTING sibling, so neither graded the sentence |
|
||||
| 3 | **miss — the largest** | a dead publisher whose last values **alternate**, or a cache replaying two old samples, never starts the unchanged clock at all | **this gate detects a value that STOPPED; it cannot detect a value that is WRONG.** Nothing in this add-on can. TEL-06's arrival stamp inside the firmware is the only layer that could |
|
||||
|
||||
Recorded because it looks like a survivor and is not: a publisher failure that
|
||||
freezes **both** registers is caught on both units independently, on each unit's
|
||||
own command travel and each unit's own number.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mutation record — 2026-09-06
|
||||
|
||||
`tools/mutation_run.sh`'s rule, honoured on every run: purge first (count printed,
|
||||
`0` remaining), tree mounted `:ro`, `python3 -B`, fingerprint the tree, **and
|
||||
fingerprint again after the revert**. Clean tree `app/freshness.py`
|
||||
`sha256:f40fa3e8819014ce` / 20,065 B, `app/inverter.py` `7e760c6e8cdfa5b7` /
|
||||
30,778 B, `app/main.py` `7d7ff4ba0697ea64` / 238,535 B. Each mutant graded by
|
||||
`test_freshness.py` + `test_inverter.py`.
|
||||
|
||||
| # | mutation | file | Δ B | result |
|
||||
|---|---|---|---|---|
|
||||
| M0 | none (control) | freshness | +0 | **green** — the harness is not red on everything |
|
||||
| M1 | `reading()` returns `self.last` unconditionally | freshness | −24 | red, 3 named checks |
|
||||
| M2 | drop the command-travel term | freshness | −59 | red |
|
||||
| M3 | drop the magnitude term | freshness | −54 | **SURVIVED**, then red — §2.2. This is the finding |
|
||||
| M4 | latch never holds | freshness | +13 | red, 3 named checks |
|
||||
| M5 | bound 90 s (slower than both consumers) | freshness | **+0** | red |
|
||||
| M6 | bound 10 s (under the physical floor) | freshness | **+0** | red |
|
||||
| M7 | `MOVE_W` 700 W | freshness | **+0** | red, on part 6's `config.yaml` pin |
|
||||
| M8 | the wiring in `_read_unit` removed | main | −5 | red, 3 named checks |
|
||||
| M9 | reset the unchanged clock on every arrival (value-stamped) | freshness | −43 | red |
|
||||
| M10 | `standing_w()` drops `last_write` | inverter | −73 | red, and takes `test_inverter.py` with it |
|
||||
| M11 | `standing_at_change` refreshed on every arrival | freshness | +121 | ⚠️ **DISCOUNTED — the mutant does not parse** |
|
||||
| M11b | the same defect, written parseably | freshness | +105 | red |
|
||||
| M12 | `BOUNDS["soc"].wired = True` — the table lies | freshness | −1 | red |
|
||||
| M13 | the wrong signal submitted (`inv.soc` for `inv.batt`) | main | +4 | red, and `test_inverter.py` too |
|
||||
| M14 | one `Freshness` shared by every unit | inverter | +29 | red |
|
||||
| M15 | latch cleared by a failed read | freshness | +31 | **SURVIVED**, then red — see below |
|
||||
|
||||
⚠️ **M5, M6 and M7 are `+0 B`, which MUTATION.md names as the risky delta.** All
|
||||
three are **reds**, which is the direction stale bytecode cannot fabricate, and
|
||||
every run purged with the count printed and graded a `:ro` tree that physically
|
||||
cannot hold a cache.
|
||||
|
||||
⚠️ **M11 IS NOT A RESULT AND IS NOT COUNTED.** Its edit dedents a statement into
|
||||
the middle of an `if` body, so the module raises `IndentationError` at line 312
|
||||
and both files go red without a single check being evaluated. Verified by
|
||||
`ast.parse` rather than assumed. **M11b** is the same defect written so it
|
||||
parses, and it is a real red.
|
||||
|
||||
### The two survivors, and what each one taught
|
||||
|
||||
**M3, the magnitude term** — see §2.2. Two terms of a conjunction that move
|
||||
together in every fixture are one term as far as the checks are concerned.
|
||||
Recorded as `MUTATION.md` **habit 17**, twelfth instance of "a check whose DATA
|
||||
cannot reach the failure it names".
|
||||
|
||||
**M15, the latch cleared by a failed read** — found only because the sweep went
|
||||
looking for what the checks would NOT see. No fixture had ever injected a read
|
||||
failure *while latched*, and it matters: a stale verdict stops the site, the site
|
||||
is then commanded 0 W, and one unreachable-Home-Assistant read would drop the
|
||||
latch into a state where the conjunction is no longer armed — so the held
|
||||
−1200 W would come back to the consumers on the very next successful read. Part 4
|
||||
now grades exactly that and M15 is red.
|
||||
|
||||
### And one process defect worth more than the ledger
|
||||
|
||||
One mutation's container run overran a two-minute command budget and the wrapper
|
||||
was killed **between the edit and the revert**, leaving `app/freshness.py`
|
||||
mutated on disk. Nothing announced it; the next mutation's harness cheerfully
|
||||
fingerprinted and graded the mutant *plus* a second mutation. It was caught by
|
||||
comparing the tree fingerprint against the recorded clean one, restored, and
|
||||
verified back to byte and `sha256` before anything else ran. Same defect as stale
|
||||
bytecode, different cause. Recorded as `MUTATION.md` **habit 18**: *fingerprint
|
||||
after the revert, not only before the run.*
|
||||
|
||||
### A third, in this file's own fixture
|
||||
|
||||
M2 originally reported "red" with **no named check behind it**. The mutant makes
|
||||
the FRESH run stale, so `test_freshness.py`'s own diagnostic
|
||||
`print(f"… {sorted(set(_fresh_read))}")` raised `TypeError` comparing `None` with
|
||||
a float **before check one ran**. MUTATION.md's reporting rule is *"which suite
|
||||
went red, and on which named check"*, and a print that can crash first destroys
|
||||
exactly that. Sorted with `key=str` now; M2 reports its check.
|
||||
|
||||
### Re-verified at the committed head
|
||||
|
||||
The 16-row sweep above graded `test_freshness.py` as it stood when each mutation
|
||||
ran; the two closures (M3's part-4 check, M15's failed-read check), the `key=str`
|
||||
fix and the wall-clock hardening all landed afterwards. So six mutations were
|
||||
re-run against **the exact tree that is committed** —
|
||||
`test_freshness.py sha256:a62f3e1f1ac856cf`, 44,913 B:
|
||||
|
||||
| # | result at head |
|
||||
|---|---|
|
||||
| M0 control | **green** |
|
||||
| M2 drop the command-travel term | red, on part 1's "FRESH AND UNCHANGED is not stale" |
|
||||
| M3 drop the magnitude term | red, on part 4's magnitude check |
|
||||
| M8 wiring removed | red, on three named checks in parts 2 and 3 |
|
||||
| M11b `standing_at_change` drifts | red, on three named checks in part 1 |
|
||||
| M15 latch cleared by a failed read | red, on part 4's failed-read check |
|
||||
|
||||
The other ten are unaffected by those edits: each is red on a check in parts 1,
|
||||
2, 4, 6, 7 or 9 that none of them touches, and a mutation red on one check
|
||||
cannot be made green by adding another.
|
||||
|
||||
### The wall clock, which part 3 cannot avoid and therefore measures
|
||||
|
||||
The freshness verdict runs on an injected monotonic clock and is deterministic.
|
||||
The sign watchdog inside `cycle()` cannot be injected, so part 3 runs against the
|
||||
real one — which is CI-06's failure mode exactly (`test_peakguard.py` answered
|
||||
two checks with the wall clock, flaked at ~20 %, and cost a wrong merge
|
||||
diagnosis). So the margin is measured and asserted rather than assumed:
|
||||
|
||||
gate closed after 0.61 s of WALL clock, against the 2 s watchdog window (3.3x margin)
|
||||
|
||||
If a loaded machine ever narrows that, the check fails **naming the number**,
|
||||
which is the difference between "the box was slow" and "the code is broken".
|
||||
@@ -0,0 +1,925 @@
|
||||
# SAFETY-38 — pinning `site_standing`, the one quantity both site guards are asked about
|
||||
|
||||
**Status: DECIDED 2026-09-05.** `app/main.py`'s
|
||||
`site_standing = float(sum(standing.values()))` is **correct and is not
|
||||
changed**. What this card adds is the thing it never had: a **behavioural**
|
||||
fixture whose per-unit shares **cannot** answer for the site, so that a change
|
||||
of *meaning* in that reduction goes red instead of green.
|
||||
|
||||
Filed with SAFETY-38, card `6a9ac95f2b78646e5a76d2e9`. It is a **coverage**
|
||||
card: not one byte of `app/` moves.
|
||||
|
||||
---
|
||||
|
||||
## 0. The line, and its two consumers
|
||||
|
||||
```python
|
||||
site_standing = float(sum(standing.values())) # app/main.py:1598
|
||||
```
|
||||
|
||||
`standing` is `{unit name -> the value THIS controller last put on that unit's
|
||||
setpoint entity}` — `inv.last_write`, i.e. the number **after**
|
||||
`clamp_to_rating()` has applied `max_w` and the device's own
|
||||
`[dev_min, dev_max]`. Two safety inputs are derived from it and nothing else in
|
||||
`cycle()` is:
|
||||
|
||||
| consumer | where | what it does with it |
|
||||
|---|---|---|
|
||||
| **[[SAFETY-30]]**'s site sign detector | `self.site_sign_wd.evaluate(site_standing, self.grid, …)` (main.py:1619) | **latches and stops the whole site** when the total command opposes the meter for `site_sign_disagree_s` |
|
||||
| **[[SAFETY-18]]**'s freeze conjunction | `looks_frozen(unchanged_s, site_standing, max_w, …)` inside `peakguard.decide(frozen=…)` (main.py:1926) | takes the peak guard **blind** and files `floor_w = 0.0` — the battery may not import while the billed quarter is unmeasurable |
|
||||
|
||||
Both are P0 paths. **Neither was pinned to the reduction.**
|
||||
|
||||
## 1. What survived, and what the rescope measured
|
||||
|
||||
The card opened with three demonstrated live mutants, all green across the whole
|
||||
suite. Re-measured in round 2 against `release/1.0` @ `86eaef2`:
|
||||
|
||||
| mutation of `site_standing` | on release/1.0 @ 86eaef2 | closed by |
|
||||
|---|---|---|
|
||||
| `list(standing.values())[0]` — one unit's share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||||
| `sum(...) / max(1, len(standing))` — the mean | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||||
| `max(standing.values(), key=abs)` — the largest share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||||
|
||||
### ⚠️ ROUND 1 SAID `[0]` WAS ALREADY CLOSED BY SAFETY-32. THAT WAS FALSE.
|
||||
|
||||
Round 1 of this card recorded `[0]` as **CAUGHT by SAFETY-32's package-wide
|
||||
structural pin**, wrote that claim into four places, and told future authors
|
||||
*"DO NOT RE-COVER THAT ONE HERE"*. It is wrong, and **how** it went wrong is the
|
||||
part worth keeping.
|
||||
|
||||
**How the error was made.** The mutation was measured **once**. That single run
|
||||
happened to hit `test_peakguard.py`'s [[CI-06]] flake — a **measured ~20%**
|
||||
false-red rate — and the red was recorded as a **mutation kill**. Nothing else
|
||||
was checked, because a red is the answer you are hoping for and it did not look
|
||||
like it needed a second run.
|
||||
|
||||
**The correct measurement.** On `release/1.0` @ `86eaef2` carrying **only** the
|
||||
`[0]` edit (+4 bytes, re-read from disk, bytecode purged by
|
||||
`tools/mutation_run.sh`): the **full 21-file suite is green**, and
|
||||
`test_peakguard.py` run **five times in isolation** on that same mutated tree is
|
||||
**PASS PASS PASS PASS PASS**. The mutation **survives**. (The reviewer's own
|
||||
independent re-measurement of the same tree hit the flake once —
|
||||
PASS PASS PASS FAIL PASS — which is the same verdict plus a live demonstration
|
||||
of the mechanism that produced the original error.)
|
||||
|
||||
**Why SAFETY-32 cannot see it, read off 12j's own enumeration rather than
|
||||
argued.** Part 12j taints names *bound* to roster-carrying values and lists
|
||||
*accumulated* in a loop over one. `standing` is neither: it is a dict created
|
||||
empty at `main.py:1554` and filled by **subscript assignment**
|
||||
(`standing[inv.name] = stood`) inside the roster loop, and a subscript store is
|
||||
not a binding 12j follows. So `standing` never enters its taint set and
|
||||
`sum(standing.values())` is **not a roster site** at all. Run 12j against a tree
|
||||
carrying the `[0]` mutation and its enumeration lists `main.py:1553`, `1555` and
|
||||
`1690` — the three `self.inverters` sites bracketing this code — and **not
|
||||
`1599`**. No shape pin reaches this line; only the behavioural fixture below
|
||||
does.
|
||||
|
||||
**The rule this cost.** `test_peakguard.py` flakes at a measured ~20%. A single
|
||||
red from it proves nothing. Re-run it in isolation **at least five times** and
|
||||
**name the failing check** before believing any red from it. This card is the
|
||||
**third** measurement CI-06 has corrupted.
|
||||
|
||||
SAFETY-32's own disclosed blind spot says why the other two were always out of
|
||||
reach:
|
||||
|
||||
> *SEMANTICS. `min` → `max` → the mean are edits invisible to any shape pin,
|
||||
> permanently. Only the behavioural sets can see them.*
|
||||
|
||||
`sum` → mean and `sum` → `max(key=abs)` are **semantic**: identical shape,
|
||||
different meaning. **No shape pin will ever close them.** That disclosure was
|
||||
accurate, and is now confirmed by measurement rather than taken on trust.
|
||||
|
||||
`[0]` is the one that *looks* structural and therefore *looks* covered — and
|
||||
that appearance is exactly what produced the false attribution above. It is
|
||||
out of reach here for a different reason from the other two: not because the
|
||||
shape is invisible, but because **the value never becomes a roster** in the
|
||||
analysis's eyes. All three are closed by the same behavioural fixture, and
|
||||
**all three needed it.**
|
||||
|
||||
## 2. Why the suite could not see it — habit 4, inside the site half of the very card that names it
|
||||
|
||||
`test_watchdog.py`'s `_sign_site` is the **only** N≥2 site fixture in the
|
||||
repository. It runs at `site_sign_disagree_w: 320` with shares of roughly
|
||||
−2500 W **each**. Every one of them is individually far over the gate, so on
|
||||
those numbers
|
||||
|
||||
```
|
||||
sum = −5000 W over mean = −2500 W over max = −2500 W over
|
||||
```
|
||||
|
||||
— **all three reductions agree**, and a check whose two candidate answers happen
|
||||
to be equal cannot observe a change that swaps them. That is `MUTATION.md`'s
|
||||
third failure mode, and this is its ninth instance.
|
||||
|
||||
At a roster of **one** it is not a fixture defect but arithmetic:
|
||||
`sum == mean == max == the one share`, identically, so all three mutations are
|
||||
genuinely **equivalent** on a shipped install. No N=1 fixture can ever separate
|
||||
them; §3c asserts that rather than leaving it to the reader.
|
||||
|
||||
## 3. The fixture, and the numbers it is built on
|
||||
|
||||
Two **simulated** units rated 220 W and 280 W, both obeying exactly, inverted
|
||||
gain, house importing 2000 W. Both stand at their own rails, so `standing` is
|
||||
`{-220, -280}` in roster order and the meter reads +2500 W.
|
||||
|
||||
| reduction | value | vs the SHIPPED 300 W gate |
|
||||
|---|---|---|
|
||||
| `sum` | **−500 W** | **OVER** → trips |
|
||||
| `sum / len` (the mean) | −250 W | under → silent |
|
||||
| `max(…, key=abs)` | −280 W | under → silent |
|
||||
| `list(…)[0]` | −220 W | under → silent |
|
||||
|
||||
**Four distinct magnitudes — 500, 250, 280, 220 — of which exactly one clears
|
||||
the gate.** The asymmetry is load-bearing twice: equal shares would collapse
|
||||
mean, max and first onto one number, and putting the **smaller** unit first is
|
||||
what keeps `[0]` distinct from `max(key=abs)`. The gate is left at its shipped
|
||||
default so the fixture is graded against the magnitude an install runs; only the
|
||||
**hold** is shortened (325 s → 2 s), because the hold is a wall clock and the
|
||||
campaign behind 325 s is measured on the rig, not here.
|
||||
|
||||
⚠️ **220 W and 280 W are SIMULATED ratings chosen for that arithmetic and for
|
||||
nothing else.** They are not measurements. In particular they are **not**
|
||||
inverter B, which does not physically exist; B is a GW5048D-ES, serial
|
||||
`55048ESU22AW0992`, **4600 W from its datasheet** (`goodwe/NOTES.md` §1.1b).
|
||||
|
||||
### 3a. The site sign detector
|
||||
|
||||
Shipped tree: the site latches, commands 0 W on every inverter, and the sentence
|
||||
an operator reads is
|
||||
`SIGN_DISAGREEMENT: commanded -500 W against a meter reading +2500 W …`. No
|
||||
per-unit watchdog trips — every unit is faithfully executing its share, which is
|
||||
the asymmetry the site detector exists for.
|
||||
|
||||
Under the mean and under `max(key=abs)`: **nothing trips, ever.** The reviewer's
|
||||
tree was still commanding −500 W of charge into a +2500 W import at t = 595 s
|
||||
with `following=True`.
|
||||
|
||||
Two counterfactuals sit beside it so the trip is attributable:
|
||||
|
||||
* the **same** inverted-gain site whose shares sum to −250 W — under the gate —
|
||||
does **not** trip, so the trip above is the sum crossing 300 W and not the
|
||||
mere presence of a second inverter;
|
||||
* a site whose **device range is narrower than `max_w`** asks for −500 W while
|
||||
only −250 W stands, and does **not** trip — see §5.
|
||||
|
||||
### 3b. The second consumer, covered rather than scoped out
|
||||
|
||||
`site_standing` is also `looks_frozen()`'s `commanded_w`. The same
|
||||
discrimination applies at a different threshold: the site rail is
|
||||
`RAIL_FRAC * site_max_w` = 0.95 × 500 = **475 W**.
|
||||
|
||||
| reduction | value | vs the 475 W rail |
|
||||
|---|---|---|
|
||||
| `sum` | **500 W** | **RAILED** → `BLIND_FROZEN`, `floor_w = 0.0` |
|
||||
| the mean | 250 W | under → silent |
|
||||
| `max(…, key=abs)` | 280 W | under → silent |
|
||||
| `list(…)[0]` | 220 W | under → silent |
|
||||
|
||||
The sign is flipped from §3a on purpose: the gain is the **shipped positive**
|
||||
one and the frozen meter reads import, so the site *discharges* +500 W against a
|
||||
+2500 W import — the two agree in sign, SAFETY-30 has nothing to say, and this
|
||||
half grades SAFETY-18 alone. Reusing §3a's inverted gain would have the site
|
||||
sign latch park everything at 0 W, and the freeze conjunction could then never
|
||||
be true: a check measuring the *other* guard's remedy.
|
||||
|
||||
⚠️ **The open quarter is backfilled with the same stuck reading, and that is
|
||||
not decoration.** Without it the block is mostly uncovered, `blindness()`
|
||||
answers `BLIND_COVERAGE`, and the mutant reaches *the same action by a different
|
||||
route* — a check on the action alone could not then see the difference it names.
|
||||
Seeded, the measurement is honest:
|
||||
|
||||
* shipped: `blind = BLIND_FROZEN`, `floor_w = 0.0` — no charging while the
|
||||
billed quarter is unobservable;
|
||||
* both mutations: **not blind at all**, `floor_w = None` — the site may go on
|
||||
importing through a quarter whose billed average cannot be measured. That is
|
||||
the capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
|
||||
|
||||
The counterfactual comes from the **same run**: on the earlier cycles the
|
||||
command had not yet reached the rail and the guard did not report SAFETY-18,
|
||||
with the meter equally frozen throughout — so the verdict is produced by the
|
||||
command magnitude `site_standing` carries and not by `unchanged_s` alone. A low
|
||||
gain (0.05, the schema floor) is what buys those below-the-rail cycles.
|
||||
|
||||
### 3c. At N=1, nothing changes — asserted
|
||||
|
||||
A real one-inverter controller is driven through the same frozen-meter
|
||||
scenario. Its `standing` has a single entry, so `sum`, the mean,
|
||||
`max(key=abs)` and `[0]` are printed and asserted to be **one number**; the
|
||||
site sign detector is still absent (part 7 counts the calls on the object); and
|
||||
SAFETY-18 still reaches `BLIND_FROZEN` on that same value. The claim "the
|
||||
shipped install is unaffected" now rests on the roster rather than on the
|
||||
reader.
|
||||
|
||||
## 4. Mutation ledger
|
||||
|
||||
Every run: a **fresh full-repo copy**, `__pycache__` and `*.pyc` excluded,
|
||||
mounted `:ro`, graded by `tools/mutation_run.sh` (which purges, prints the tree
|
||||
fingerprint, and verifies no cache appears during the run). The mutated line was
|
||||
**re-read from disk** after the edit and the byte delta printed. Shipped
|
||||
`app/main.py` is 234 542 bytes at `86eaef2`.
|
||||
|
||||
| # | mutation of `site_standing` | Δ bytes | verdict |
|
||||
|---|---|---|---|
|
||||
| M1 | `sum(...) / max(1, len(standing))` — the mean | +24 | **RED** |
|
||||
| M2 | `max(standing.values(), key=abs)` — largest share | +9 | **RED** |
|
||||
| M3 | `list(standing.values())[0]` — one share | +4 | **RED** — and **survives on release/1.0**, see §1 |
|
||||
| M4 | `min(standing.values(), key=abs)` — smallest share | +9 | **RED** |
|
||||
| M5 | `sum(abs(v) for v in ...)` — magnitude, sign lost | +16 | **RED** |
|
||||
| M6 | `-sum(standing.values())` — sign flipped | +1 | **RED** |
|
||||
| M7 | `float(self.target)` — the arbiter's site target | **−11** | **was a SURVIVOR — now RED**, see §5 |
|
||||
| M8 | `sum(standing.values()) / 2.0` | +6 | **RED** |
|
||||
| M9 | `sum(standing.values()) * 2.0` | +6 | **RED** |
|
||||
| M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** |
|
||||
| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
|
||||
| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c |
|
||||
|
||||
**Every one of the ten `site_standing` entries is red in `test_watchdog.py`.** Re-measured in round 2
|
||||
on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more
|
||||
than the `f72c48d` the round-1 figures were taken on): all ten still red, at the
|
||||
byte deltas in the table, each on a fresh copy with the edit re-read from disk.
|
||||
Adding assertions cannot turn a red green, so those verdicts carry forward to
|
||||
the round-3 tree unchanged.
|
||||
|
||||
### ⚠️ ROUND 2 SAID THREE OF THESE SURVIVED. **EIGHT DID.**
|
||||
|
||||
Round 2 wrote: *"Before part 9 existed, three of these left the whole suite
|
||||
green — M1, M2 and M3 — and the remaining seven were caught by SAFETY-30's own
|
||||
N≥2 checks."* Round 1's wording was vaguer and harmless; naming three made it
|
||||
**specific and wrong**, and it is the same species of error the round was
|
||||
written to fix — **a survivor recorded as a kill**. This file contradicted it
|
||||
three times on its own pages: the ledger row calls M7 *"a SURVIVOR — now RED"*,
|
||||
§5 says M7 survived the whole suite, and `CHANGELOG.md` says so too.
|
||||
|
||||
**Re-measured in round 3, one mutation at a time, on clean `release/1.0` @
|
||||
`bbc903b`** — a fresh copy each, bytecode purged (`0` remaining, printed), the
|
||||
line re-read from disk, byte delta **and** `app/main.py` fingerprint printed,
|
||||
mounted `:ro`, and the **FULL 21-FILE SUITE** run each time rather than
|
||||
`test_watchdog.py` alone. (`release/1.0` has since moved on to `9e42cec`, which
|
||||
merged [[CI-06]]; that commit touches `test_peakguard.py` and `CHANGELOG.md`
|
||||
only, neither of which grades `site_standing`, so the ten verdicts below stand
|
||||
on the tree they were taken on and this branch is rebased onto the newer tip.)
|
||||
|
||||
| # | Δ bytes | fingerprint of `app/main.py` | suite before part 9 |
|
||||
|---|---|---|---|
|
||||
| M1 | +24 | `362374cfb6486378` | **21/21 PASS — SURVIVOR** |
|
||||
| M2 | +9 | `86573e7494f1c933` | **21/21 PASS — SURVIVOR** |
|
||||
| M3 | +4 | `59fa8815e6fda4cf` | **21/21 PASS — SURVIVOR** |
|
||||
| M4 | +9 | `43d852be3037f861` | **21/21 PASS — SURVIVOR** |
|
||||
| M5 | +16 | `58d26de82af29a1a` | **RED** — `test_watchdog.py`, 3 checks |
|
||||
| M6 | +1 | `407ddb5f375cc217` | **RED** — `test_watchdog.py`, 3 checks |
|
||||
| M7 | **−11** | `2a635a4eca0a790d` | **21/21 PASS — SURVIVOR** |
|
||||
| M8 | +6 | `b21eed89ff4c9240` | **21/21 PASS — SURVIVOR** |
|
||||
| M9 | +6 | `0973d3caf680db65` | **21/21 PASS — SURVIVOR** |
|
||||
| M10 | +7 | `0e7b7a4fed7787d0` | **21/21 PASS — SURVIVOR** |
|
||||
|
||||
**So: EIGHT of the ten left the whole suite green, and TWO were caught** — not
|
||||
three and seven, and not the four and six a reviewer proposed either. The
|
||||
correct sentence is: *before part 9 existed, eight of these ten left the whole
|
||||
21-file suite green, and the only two SAFETY-30's own N≥2 checks caught were M5
|
||||
and M6.*
|
||||
|
||||
**⚠️ AND THE TWO THAT WERE CAUGHT ARE CAUGHT FOR A REASON THAT MAKES THE CARD'S
|
||||
CASE RATHER THAN WEAKENING IT.** M5 (`sum(abs(v) …)`) and M6 (`-sum(…)`) are
|
||||
the only two entries in the table that **reverse the SIGN** of `site_standing`.
|
||||
Every other entry changes only its **magnitude** — and `_sign_site`, the suite's
|
||||
only N≥2 site fixture before this card, runs shares of about **−2500 W each**
|
||||
against a **320 W** gate. `sum`, the mean, `max(key=abs)`, `min(key=abs)`,
|
||||
`sorted(...)[-1]`, `[0]`, `self.target`, half the sum and twice the sum all land
|
||||
on **−2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W**: nine
|
||||
different numbers, all on the **same side of the same threshold**. The three
|
||||
checks that fired on M5 and M6 grade *"the site trips and no unit does"*, the
|
||||
claim it trips through, and where the latch is written — none of them grades the
|
||||
**quantity**, and the sign flip reaches them only because it stops the trip
|
||||
happening at all.
|
||||
|
||||
That is the whole thesis of this card measured rather than argued: **the
|
||||
pre-existing coverage could see a sign, and nothing else.** Part 9's fixture —
|
||||
per-unit shares each **below** the gate whose **sum** clears it — is the only
|
||||
arrangement in which the magnitude is load-bearing, and it is what takes the
|
||||
other eight red.
|
||||
|
||||
⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported
|
||||
`FAIL test_peakguard.py`. Re-run in isolation **three times** and once more
|
||||
through `tools/mutation_run.sh` — **four clean passes, zero failures**. The
|
||||
batch script had discarded the failure detail, so the failing check cannot be
|
||||
named here; what can be said is that the red did not reproduce in four attempts
|
||||
and that **no claim in this card rests on it**. `test_peakguard.py` is the file
|
||||
CI-06 records as flaky, and this is the behaviour it records. Believe no red
|
||||
from it without three isolated re-runs.
|
||||
|
||||
⚠️ **ROUND 3 CAN NAME ONE, AND IT IS THE SAME FILE.** Round 3's own batch — the
|
||||
ten full-suite runs above, plus §7's sibling swaps — produced **no** red from
|
||||
`test_peakguard.py` on M10 or on any other ledger entry. It produced exactly one
|
||||
elsewhere, on a **`static_tariff.py`-only** edit (§7b, A4), and this time the
|
||||
failing check *was* captured: *"one cycle of the intent form is exactly the
|
||||
delivered form plus the command standing on the wire"* — which is CI-06's
|
||||
**mechanism 1 by name**, the cross-time projection. `test_peakguard.py` then
|
||||
passed **four for four** in isolation on that same tree. That is the first time
|
||||
this card has been able to put a name to one of these reds, and it is the name
|
||||
CI-06 predicts.
|
||||
|
||||
⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded
|
||||
M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has
|
||||
the correction and the mechanism: `[0]` **survives** a full green 21-file suite
|
||||
on `release/1.0` @ `86eaef2`, because `standing` is a dict filled by subscript
|
||||
assignment and never enters 12j's taint set. **M3 is a real kill by this card's
|
||||
fixture and by nothing else in the repository.** It is claimed here.
|
||||
|
||||
### 4a. ⚠️ The zero-byte survivor one line below the one this card pinned
|
||||
|
||||
Round 1 pinned the **value** `site_standing` and never pinned **where it goes**.
|
||||
Swapping the detector's first two arguments at `main.py:1620` is a **zero-byte**
|
||||
edit — the same characters, reordered — and it left the round-1 tree **21/21
|
||||
green**.
|
||||
|
||||
What it costs is the banner. `SignWatchdog.evaluate()` formats
|
||||
`commanded_w` first and `against_w` second, so under the swap part 9a's own
|
||||
output reads:
|
||||
|
||||
```
|
||||
SIGN_DISAGREEMENT: commanded +2500 W against a meter reading -500 W for 2 s …
|
||||
```
|
||||
|
||||
The site is standing at **−500 W of charge into a +2500 W import** and the
|
||||
**latching, site-stopping** banner tells the operator **the exact opposite** —
|
||||
which instrument read what, inverted. `watchdog.py:658-665` names this failure
|
||||
in its own words: *"a false statement about which instrument produced the
|
||||
evidence, on the one line somebody woken by this alarm reads first."*
|
||||
|
||||
**Why round 1's check could not see it, and this is the general lesson.** It
|
||||
asserted
|
||||
|
||||
```python
|
||||
f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason
|
||||
```
|
||||
|
||||
— that `"-500 W"` appears **somewhere** in the sentence. Under the swap it still
|
||||
does: in the **meter slot**. A substring test cannot reach a failure that is
|
||||
purely **positional**, and round 1's own comment claimed the opposite ("*would
|
||||
put a different number in the store, the action log and the banner, and this is
|
||||
where that shows*"). It shows the **number**, never its **position**. This is
|
||||
MUTATION.md's *"a check whose data cannot reach the failure it names"*, in the
|
||||
card that exists to complain about exactly that.
|
||||
|
||||
**The fix is one line and touches no `app/` code** — assert the **ordered
|
||||
pair**:
|
||||
|
||||
```python
|
||||
f"commanded {sum(_share38):+.0f} W against a meter reading "
|
||||
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason
|
||||
```
|
||||
|
||||
Measured: with that line, M11 is **red in `test_watchdog.py` and green in the
|
||||
other twenty files** — one file, one check.
|
||||
|
||||
### 4b. Claim wiring, re-measured rather than assumed
|
||||
|
||||
Round 1's reviewer disclosed it had **not** mutated the arbiter claim wiring or
|
||||
[[SAFETY-31]]/circulation. Both were mutated in round 2. Both are **zero-byte**
|
||||
edits to `arbiter.CLAIM_SCOPE`:
|
||||
|
||||
| # | mutation | Δ bytes | verdict |
|
||||
|---|---|---|---|
|
||||
| W1 | `"sign_site": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py` (parts 5 **and 9a**), `test_arbiter.py`, `test_peakguard.py` |
|
||||
| W2 | `"circulation": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py`, `test_arbiter.py`, `test_peakguard.py` |
|
||||
|
||||
Part 9a's *"…and it stops the WHOLE site, through the `sign_site` claim by name"*
|
||||
is one of the two checks that kills W1, so this card contributes to that
|
||||
coverage rather than merely inheriting it. **W2 is caught entirely by
|
||||
pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not
|
||||
claim to.
|
||||
|
||||
### 4c. ⚠️ The SAME zero-byte defect one call site over — and it is the one the shipped install can see
|
||||
|
||||
Round 2 closed §4a by asserting the ordered pair on the **site** banner at
|
||||
`main.py:1620`. It did not touch `main.py:1560`, the **per-unit** call, and the
|
||||
identical swap there —
|
||||
|
||||
```python
|
||||
if inv.sign_wd.evaluate(stood, against, self.following, now) # shipped
|
||||
if inv.sign_wd.evaluate(against, stood, self.following, now) # M12
|
||||
```
|
||||
|
||||
— left the round-2 tree **21/21 green**. Measured: `app/main.py` 234 542 bytes
|
||||
before and after, **Δ 0 bytes**, fingerprint `61aa413f…` → `e57e6026…`, bytecode
|
||||
purged, tree mounted `:ro`, the line re-read from disk.
|
||||
|
||||
**Nothing behavioural can catch it, and that is structural rather than
|
||||
unlucky.** `watchdog.disagrees()` is *symmetric* in its first two arguments —
|
||||
`commanded_w * against_w < 0`, with an `abs()` gate on **both** — so the swap
|
||||
cannot change a single trip decision on any input. What it changes is the
|
||||
**sentence**. Measured by diffing the suite's own printed banners between the
|
||||
round-3 tree and the same tree carrying only M12: **9 emitted banner lines
|
||||
invert, in 3 distinct sentences.**
|
||||
|
||||
```
|
||||
shipped: SIGN_DISAGREEMENT: commanded -2000 W against a meter reading +2000 W …
|
||||
M12: SIGN_DISAGREEMENT: commanded +2000 W against a meter reading -2000 W …
|
||||
|
||||
shipped: … commanded +1000 W against its own measured power -1000 W …
|
||||
M12: … commanded -1000 W against its own measured power +1000 W …
|
||||
|
||||
shipped: … commanded -2000 W against a meter reading +4000 W … (the N=1 arm)
|
||||
M12: … commanded +4000 W against a meter reading -2000 W …
|
||||
```
|
||||
|
||||
**⚠️ AND IT IS WORSE THAN THE ONE ROUND 2 CLOSED.** Part 9c asserts the SITE
|
||||
detector is **not armed at a roster of one**, and inverter B does not physically
|
||||
exist. So on the shipped install the **per-unit banner is the only
|
||||
sign-disagreement sentence that can ever be printed** — in the action log, in
|
||||
`_LOG.error`, in `status_payload()`'s headline banner, and in `sign_reason`,
|
||||
which is persisted and read back after the restart the latch is designed to
|
||||
survive. Round 2 pinned the N≥2-only call site and left the shipping one open.
|
||||
|
||||
**Closed by the ORDERED PAIR on both per-unit banners**, with both operands read
|
||||
off the **observed cycle** — `inv.last_write` and `inv.batt` off the objects the
|
||||
plant moved, never off the arguments the detector was handed, which would be
|
||||
comparing the swap against itself:
|
||||
|
||||
* `test_watchdog.py` **part 3**, the N≥2 per-unit trip:
|
||||
`f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W"`,
|
||||
plus the persisted `unit/inverter-b/sign_reason` copy, plus a **precondition**
|
||||
that the two operands are different numbers of opposite sign — without which
|
||||
a transposition would produce the same string and the check would be
|
||||
decoration;
|
||||
* `test_watchdog.py` **part 7**, the **N=1** controller-driven trip:
|
||||
`f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W"`, plus the
|
||||
namespaced key **and** the flat rollback mirror, plus the same precondition.
|
||||
|
||||
**Measured after the fix: M12 is RED on four named checks in
|
||||
`test_watchdog.py` and GREEN in the other twenty files** — one file, four
|
||||
checks. M11 re-measured on the same tree: still red.
|
||||
|
||||
**⚠️ Neither §6a nor MUTATION.md disclosed it.** §6a's "what was NOT closed"
|
||||
enumerated `circ_wd`, the eight other `CLAIM_SCOPE` entries, `actuator_of()`'s
|
||||
body and the `n_units > 1` guard — not this. MUTATION.md's own closing rule, in
|
||||
the file round 2 edited, is *"a variant is not covered because a near-miss of it
|
||||
is."* §7 is the enumeration that would have caught both instances at once.
|
||||
|
||||
## 5. The survivor this card went looking for, and closed
|
||||
|
||||
M7 is not one of the card's three. It was found by asking the question the
|
||||
recurring defect demands — *what mutation would my new checks NOT see?* — and it
|
||||
is the answer: **−11 bytes**, and it survived every check above **and the whole
|
||||
20-file suite**, because in every fixture built so far the arbiter's site target
|
||||
and the sum of what actually stands on the wire are **the same number**.
|
||||
|
||||
They are not the same number when a setpoint device's own reported range is
|
||||
narrower than its `max_w`. The `device` P_SAFETY claim is filed **per actuator**
|
||||
(`actuator_of("device", inv)`), so it bounds each unit's **share** and never the
|
||||
site target, and `clamp_to_rating()` clips again on the way out. On two units
|
||||
rated 220/280 W whose devices report ±120/±130 W, the site asks for **−500 W**
|
||||
and **−250 W** stands.
|
||||
|
||||
Which one the guards mean is already written down, in both of them: SAFETY-02
|
||||
grades *"the command standing on the wire"*, and `looks_frozen()`'s docstring
|
||||
says `commanded_w` is *"the EMITTED setpoint — what the arbiter resolved and
|
||||
`write_setpoint()` put on the wire — not the loop's raw wish"*. [[SAFETY-20]] is
|
||||
the whole card about the difference. Those sentences are now made to bite: on
|
||||
the site target the detector fires on a site standing at −250 W against a
|
||||
+2250 W meter — **under its own 300 W gate** — which is a latching, site-wide
|
||||
false trip on an install whose inverters simply report a narrower range than the
|
||||
operator typed.
|
||||
|
||||
## 6. Honest gaps
|
||||
|
||||
* **A mutation that changes the SIGN CONVENTION of `standing` itself** — i.e. in
|
||||
`main.py`'s `stood = inv.last_write if inv.last_write is not None else
|
||||
inv.target`, not on the reduction line — is **not** in this ledger. It is a
|
||||
different line and a different card; §3a's obedience precondition would see
|
||||
some of it, but that is not a measured claim and is not made here.
|
||||
* **The freeze half is graded at ONE roster length (2) and one rail.** The
|
||||
`RAIL_FRAC` constant, `rail_ceiling_w()`'s device-bound conjunction and
|
||||
`rail_unreachable()` are `test_freeze.py`'s subject and are untouched here.
|
||||
* **The site hold is 2 s, not the shipped 325 s.** The window's derivation is a
|
||||
rig campaign (`DECISIONS/SAFETY-30-per-unit-sign.md` §3B) and this file does
|
||||
not re-grade it. The **gate** is the shipped 300 W, which is the coordinate
|
||||
this card is about.
|
||||
* **Everything about inverter B here is simulated and labelled so.** No number
|
||||
in this document or in the fixture is a measurement of a device that does not
|
||||
yet exist.
|
||||
|
||||
### 6a. Round 2: what was closed, and what was NOT
|
||||
|
||||
Round 1's reviewer disclosed that it had not mutated SAFETY-31/circulation, the
|
||||
arbiter claim wiring, or ledger entries M4/M5/M6/M8/M9/M10. Enumerated, with the
|
||||
gaps left open stated rather than rounded off:
|
||||
|
||||
**Closed in round 2 (measured, on `86eaef2`, bytecode purged, edit re-read from
|
||||
disk, byte delta printed):**
|
||||
|
||||
1. **M11**, the zero-byte argument swap — §4a. Red on one named check.
|
||||
2. **M3**, the `[0]` reduction — re-measured, **survives on release/1.0**, closed
|
||||
by part 9a and 9b. The false attribution corrected in all four places it was
|
||||
committed: this file §1 and §4, `CHANGELOG.md`, and `test_watchdog.py`'s part
|
||||
9 header (which previously *instructed future authors not to re-cover it*).
|
||||
3. **M1, M2, M4, M5, M6, M7, M8, M9, M10** — every ledger entry re-run
|
||||
independently in round 2 rather than carried forward from round 1's report.
|
||||
All ten red; M1/M2/M4 re-run in the exact wrapped form the table records so
|
||||
the byte deltas are the table's own (+24/+9/+9).
|
||||
4. **W1**, `sign_site` claim scope — §4b. Zero bytes, red, part 9a among the
|
||||
killers.
|
||||
5. **W2**, `circulation` claim scope — §4b. Zero bytes, red, but **entirely on
|
||||
pre-existing checks**.
|
||||
|
||||
**NOT closed, and not claimed:**
|
||||
|
||||
* **SAFETY-31's `circ_wd` detector itself** — its threshold, its hold, its
|
||||
`circulating_w()` reduction over the roster. Only its **claim scope** was
|
||||
mutated (W2), and that kill belongs to checks this card did not write.
|
||||
Circulation's own behavioural coverage is `test_watchdog.py` part 4's and is
|
||||
untouched here.
|
||||
* **The other eight `CLAIM_SCOPE` entries** — `loop`, `peak`, `arbitrage`,
|
||||
`safety` (site) and `device`, `supervised`, `failsafe`, `maintenance` (unit).
|
||||
`CLAIM_SCOPE` has ten entries; only the two named above were mutated. `test_peakguard.py` part 10f grades the
|
||||
classification statically; that is a different instrument and this card did
|
||||
not re-measure it.
|
||||
* **`actuator_of()`'s own body** and the `n_units > 1` guard on the `sign_site`
|
||||
append at `main.py:1764` were **not** mutated.
|
||||
* **The ten ledger mutations were re-graded against `test_watchdog.py` only.**
|
||||
The "green in the other twenty files" half of the round-1 claim was
|
||||
re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is
|
||||
otherwise carried forward from round 1's measurement on `f72c48d`. It is a
|
||||
weaker statement than round 1 made and is written down as such.
|
||||
⚠️ **Round 3 closed the other half of this**, but only in the *before*
|
||||
direction: all ten were re-run on clean `release/1.0` @ `bbc903b` under the
|
||||
**full 21-file suite** to establish which of them the pre-existing coverage
|
||||
could see — see §4's ⚠️ paragraph, and the answer was **eight survivors, not
|
||||
three**. The *after* direction — "each of the ten is red in
|
||||
`test_watchdog.py` and green in the other twenty **on the round-3 tree**" —
|
||||
is still measured end-to-end only for **M3, M11 and M12**. For the other
|
||||
seven, "red" carries forward from round 2 (which adding assertions cannot
|
||||
undo) and "green elsewhere" carries forward from round 1.
|
||||
* **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not
|
||||
this card's subject. It is *characterised* here (five isolated runs on the M3
|
||||
tree, all PASS) only far enough to overturn the false kill it produced.
|
||||
|
||||
### 6b. Round 3: what was closed, and what is still open
|
||||
|
||||
**Closed in round 3:**
|
||||
|
||||
1. **M12**, the zero-byte argument swap at the **per-unit** call site
|
||||
`main.py:1560` — §4c. It was a survivor of the round-2 tree at **21/21
|
||||
green**, and it is the **only** sign-disagreement banner a shipped
|
||||
one-inverter install can print. Red on four named checks, green in the other
|
||||
twenty files.
|
||||
2. **The ledger arithmetic in §4**, which round 2 made specific and wrong. See
|
||||
§4's own ⚠️ paragraph: re-measured mutation by mutation on clean
|
||||
`release/1.0` @ `bbc903b`, full 21-file suite each time.
|
||||
3. **The family itself is now enumerated by machine** —
|
||||
`tools/symmetric_message_scan.py`, §7 — rather than by hand for a third time.
|
||||
|
||||
**Open, named, and NOT claimed:**
|
||||
|
||||
* **A3 and B1** — the only two status-page rows in which the freeze detector
|
||||
ever names a device range: the **NOT COVERED** row and the **armed** row's
|
||||
"below max_w because …" clause. `rail_unreachable()`'s `dev_min`/`dev_max` at `main.py:3208` and the
|
||||
literal `f"only {inv.dev_min}...{inv.dev_max} W"` at `main.py:3231`. Both
|
||||
measured: **0 bytes each, 21/21 green each.** Real, open members of the
|
||||
family. They are **status-page rows rather than latching alarms**, and closing
|
||||
them wants a fixture with a device whose two bounds are **asymmetric** — every
|
||||
fixture in this repository uses `±d`, so the transposition is currently
|
||||
invisible by construction — which belongs to `test_freeze.py`'s card, not to
|
||||
this one.
|
||||
* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source
|
||||
level** — the distinguishing literal appears in no `test_*.py` — and **not**
|
||||
mutated. Weaker evidence than A1–A4 and B1, and written down as such
|
||||
— §7d says the same thing with the same list.
|
||||
* **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card
|
||||
closed for SAFETY-02/30 and is *also* persisted and re-read after a restart.
|
||||
§6a already declines to touch `circ_wd`; §7d says what that costs.
|
||||
* Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other
|
||||
`CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard.
|
||||
|
||||
### 6c. Round 4: what was closed, and what is still open
|
||||
|
||||
**The blocker round 3 shipped.** The enumeration read as complete and covered
|
||||
**one syntactic sink**. Every one of census B's 16 members was a sentence that
|
||||
is **appended, assigned, or passed as a log argument** (`problems.append`,
|
||||
`out.append({"text": …})`, `self.reason = …`, `_LOG.info`). **Not one was a
|
||||
sentence that is RETURNED.** Census A has a return bucket, but only for
|
||||
symmetric **parameters**, so a method formatting its **own attributes** and
|
||||
handing the string back was invisible to both — and `describe()` / `explain()` /
|
||||
the reason-builders are exactly where this codebase keeps its operator
|
||||
sentences. Worse than the gap: it was in **none of the five blind spots the tool
|
||||
prints about itself**. A tool that asserts a completeness it does not have is
|
||||
the recurring defect wearing a new hat.
|
||||
|
||||
**Closed in round 4:**
|
||||
|
||||
1. **`clamp.py:253` `ClampResult.describe()`**, pair `requested_w` /
|
||||
`applied_w` — §7e C6. A survivor of the round-3 tree at **21/21 green**,
|
||||
**0 bytes**. It is the §15 action-log row at `main.py:1366` **and** the
|
||||
ClampWatchdog notification reason at `main.py:2452`; **no `test_*.py`
|
||||
mentioned `describe()` at all.** Pinned on the ordered pair, on the
|
||||
contiguous literal, at **both** consumers, with a precondition that the two
|
||||
operands render differently — `test_clamp.py` part 6d. **4 checks red** under
|
||||
the transposition.
|
||||
2. **`peakguard.py:493` `PeakDecision.explain()`**, pair `projected_w` /
|
||||
`ceiling_w` — §7e C7. Same measurement: **0 bytes, 21/21 green**; the string
|
||||
`"peak guard idle"` was in no `test_*.py`. Pinned on the decision and on the
|
||||
shipped status row at `main.py:3313` — `test_peakguard.py` part 13. **3
|
||||
checks red.**
|
||||
3. **`arbiter.py:264` (B12)** — the check whose **claim was wider than its
|
||||
assertion**, carried forward from §7d. Measured this round rather than left
|
||||
at source level: **0 bytes, 21/21 green** before, **`test_arbiter.py` red**
|
||||
after. **2 checks red.**
|
||||
4. **The tool's sinks are now stated rather than implied.** It prints what it
|
||||
scans as a MESSAGE, that it now scans the RETURN sink through the returning
|
||||
method's call sites, and — the part that was missing — **an explicit list of
|
||||
what it does not scan at all**, largest hole first.
|
||||
|
||||
**⚠️ The correction round 4 owes the ticket.** The brief placed
|
||||
`PeakDecision.explain()`'s idle sentence at `main.py:3299/3302`. Those two lines
|
||||
are the **floor-limited** branch, where `explain()` returns `self.reason` before
|
||||
the idle literal is ever built. The idle sentence reaches the page at
|
||||
**`main.py:3313`** and the action log at **`main.py:1951`** (edge-logged, so it
|
||||
is the RELEASE edge that writes it). The pin is on 3313; 1951 is disclosed
|
||||
below, unpinned.
|
||||
|
||||
**Open, named, and measured — see §7f for the ledger:**
|
||||
|
||||
* **TEN further zero-byte survivors** found by the widened scan, each measured
|
||||
at 21/21 green on this tree: **two** in `allocator.py`'s `UnitPlan.explain()`
|
||||
(C1 :154, C2 :160), three in `arbiter.py`'s two `explain()`s (C4a, C4b :344,
|
||||
C5 :392), two in `tariff.py` (C8, C9), two in `arbitrage.py`'s trade reasons
|
||||
(B17, B18) and one in `main.py`'s SoC-floor notification (B19).
|
||||
None is a control decision; every one is a sentence an operator reads.
|
||||
⚠️ **CORRECTED after review, and the error is worth keeping visible.** This
|
||||
bullet first said "Nine" while listing eleven, and the CHANGELOG said eleven
|
||||
while listing eleven — and BOTH counted `allocator.py:162` (C3) among the
|
||||
survivors when §7f records it as a KILL, red in `test_alloc.py`. The
|
||||
authoritative tables in §7e and §7f were right throughout; only the summary
|
||||
sentences a reader actually relies on were wrong. That is this card's own
|
||||
recurring defect in prose rather than in code: a claim wider than the
|
||||
measurement beneath it. Arithmetic, from §7f: 14 measured = 10 surviving +
|
||||
1 already caught (C3) + 3 caught by this round (C6, C7, B12).
|
||||
* **The action-log surface of the peak-guard idle line** (`main.py:1951`) is
|
||||
not pinned. The status row (3313) is, and both come off the same `explain()`,
|
||||
so the *sentence* is closed and the *second call site* is not — which is
|
||||
precisely the distinction §4c was written about, recorded rather than assumed.
|
||||
* Everything §6a and §6b list stays open.
|
||||
|
||||
## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE
|
||||
|
||||
**Why this section exists, and it is the most useful thing in the card.** Round 2
|
||||
pinned the operand order of `SignWatchdog.evaluate()` at `main.py:1620`. Round 3
|
||||
found the **identical zero-byte defect at `main.py:1560`**, one call site over —
|
||||
and that one is worse, because `test_watchdog.py` part 9c asserts the site
|
||||
detector is **never armed at
|
||||
a roster of one**, so on the shipped install the per-unit banner is the **only**
|
||||
sign-disagreement sentence that can ever be printed. Twice in one card the
|
||||
enumeration was written by hand and twice it was short. MUTATION.md habit 12: *a
|
||||
hand-written scope is the same defect as a hand-written fixture.* So the family
|
||||
is now **computed**, by `tools/symmetric_message_scan.py`.
|
||||
|
||||
### 7a. What makes this family invisible, and why it is not a fixture problem
|
||||
|
||||
Every other defect in this document is a fixture problem: *no fixture happens to
|
||||
separate the mutant from the shipped code.* Build the right fixture and it goes
|
||||
red. This one is not that.
|
||||
|
||||
Where a callee is **symmetric** in two arguments, a swap at a call site cannot
|
||||
change **any** action on **any** input. `watchdog.disagrees()` is
|
||||
`commanded_w * against_w < 0` with an `abs()` gate on **both** sides: the
|
||||
product commutes and the two gates are the same gate. There is nothing for a
|
||||
behavioural check to observe, ever — **not "no fixture separates them" but no
|
||||
fixture *can*.** The only surface that moves is the **sentence**, and the only
|
||||
check that can see it is one asserting the **ordered pair** (MUTATION.md habit
|
||||
14) at **that** call site.
|
||||
|
||||
Two consequences the earlier rounds got wrong:
|
||||
|
||||
* **The unit of coverage is the CALL SITE, not the function.** `evaluate()`'s
|
||||
operand order was pinned and the defect still shipped, because the pin was on
|
||||
the *other* call.
|
||||
* **A behavioural suite is not weak evidence here, it is NO evidence.** "21/21
|
||||
green" is what this mutation is *guaranteed* to produce. Measured, twice: M11
|
||||
at `main.py:1620` on the round-1 tree, and M12 at `main.py:1560` on the
|
||||
round-2 tree.
|
||||
|
||||
### 7b. Census A — the callee formats its own parameters
|
||||
|
||||
`tools/symmetric_message_scan.py` renames each parameter pair, erases every
|
||||
formatted string, canonicalises the commutative operators (`*`, `+`, `and`,
|
||||
`or`, `==`, `min`/`max`, iterated tuple literals) and compares the **bodies**;
|
||||
it iterates to a **fixpoint**, because the symmetry is usually one call deep —
|
||||
`evaluate()` is symmetric only *through* `disagrees()`, and a single pass
|
||||
reports an empty family on a tree whose answer is already known. It then
|
||||
classifies each sentence by **where it goes**.
|
||||
|
||||
Fixpoint on this tree: **3 rounds, 17 interchangeable positional and 5 keyword
|
||||
pairs across 14 functions**. 45 parameter pairs reach a sentence; **8** are
|
||||
symmetric — **5** whose sentence goes only to an operator, and **3** whose
|
||||
sentence is the **return value** and therefore has to be classified by hand,
|
||||
because a returned string is sometimes a KEY and sometimes a SENTENCE.
|
||||
|
||||
| # | scan row | pair | called at | swap costs | reaches an operator via | pinned? |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **A1** | `watchdog.py:613` `SignWatchdog.evaluate()` | `(commanded_w, against_w)` | **`main.py:1560`** — per-unit, **and the N=1 detector** | **0 B** | `sign_reason` (persisted), the action log, `_LOG.error`, `status_payload()`'s headline banner | **YES — round 3.** `test_watchdog.py` part 3 (`its own measured power`) and part 7 (`a meter reading`), on the contiguous literal with both operands read off the observed cycle. **Measured: 0 B, red on 4 named checks, green in the other 20 files.** |
|
||||
| **A2** | same row, second call site | `(commanded_w, against_w)` | **`main.py:1620`** — site | **0 B** | `site_sign_reason`, the action log, the status page | **YES — round 2**, part 9a. Re-measured on the round-3 tree: still red. |
|
||||
| **A3** | `freeze.py:405` `FreezeWatchdog.evaluate()` and `freeze.py:311` `rail_unreachable()` — both symmetric through `rail_ceiling_w()`'s `for bound in (dev_min, dev_max)` | `(dev_min, dev_max)` | **`main.py:3208`** (and 1928, 2755, 3196, 3219) | **0 B** | the status page's `freeze detector: NOT COVERED — the setpoint device reports a range of …` row | **NO.** Measured: swapped at `main.py:3208-3209`, **0 bytes**, `main.py` fingerprint `61aa413f…` → `2305b560…`, **21/21 green**. Disclosed in §7d, not closed. ⚠️ `rail_unreachable()` is the row the scan puts in its **RETURN** bucket: its returned string is a **sentence**, not a key, so it is a family member after all — which is exactly why that bucket says *read by hand*. |
|
||||
| **A4** | `static_tariff.py:522` `_raise_gap()` | `(start, end)` | `static_tariff.py:509`, `:513` | **0 B** | the `ProfileInvalid` refusal an operator reads at startup | **YES — pre-existing.** Measured: both call sites swapped, **0 bytes**, `static_tariff.py` fingerprint `caef7fde…` → `50e9a768…`, **red in `test_static_tariff.py` on two named checks** (*"…and names the uncovered range so the operator can fix it"*, *"…and the refusal names the uncovered range"*), because `_hhmm(start)}-{_hhmm(end)` is asserted as the contiguous literal `"22:00-23:00"`. ⚠️ That batch run also reported `FAIL test_peakguard.py` on a `static_tariff.py`-only edit; the failing check is *"one cycle of the intent form is exactly the delivered form plus the command standing on the wire"*, which is [[CI-06]]'s **mechanism 1 by name**, and `test_peakguard.py` passes **four for four** in isolation on that same tree. No claim here rests on it. |
|
||||
| A5–A6 | `static_tariff.py:522` `_raise_gap()` | `(start, weekday)`, `(end, weekday)` | — | — | — | **not real — the scan's one over-report.** It erases the sentence, so it cannot see that `DAY_NAMES[start]` with `start = 1320` raises `IndexError`. A mixed-type pair fails loudly, which is the opposite of this family. |
|
||||
| A7–A8 | `store.py:177` `unit_key()`, `mqtt.py:222` `unit_state_key()` | `(unit, key)`, `(slug, key)` | many | 0 B | — | **not this family.** The formatted string is the **return value and it is a KEY**: a swap renames a store key and an MQTT topic, which is behaviour every ordinary check can see. MAINT-05 is the card about it. |
|
||||
|
||||
### 7c. Census B — the caller builds the sentence
|
||||
|
||||
Census A only sees a callee formatting its **own** parameters, which was gap 2
|
||||
of its own honest-gap list. The other half never crosses a function boundary:
|
||||
an operator sentence built where it is printed, interpolating two values that
|
||||
are **interchangeable by shape**. No predicate is involved at all, so census A's
|
||||
symmetry test has nothing to test. The scan pairs interpolated expressions that
|
||||
are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` /
|
||||
`cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only
|
||||
sentences whose sink is a **message**: an f-string that becomes a URL or a store
|
||||
key is data, and a swap there is an ordinary behavioural change. **16 candidates** in round 3, every one of them a **zero-byte** edit — and **22** after round 4 widened the sink classifier (B17–B22 below):
|
||||
|
||||
| # | site | pair | reaches an operator as | pinned? |
|
||||
|---|---|---|---|---|
|
||||
| **B1** | `main.py:3231` | `inv.dev_min` / `inv.dev_max` | status page, `… accepts only −120…130 W` | **NO — MEASURED.** Transposed in place, **0 bytes**, `main.py` fingerprint `61aa413f…` → `ac36a97b…`, purged, `:ro`, **21/21 green**. The row then tells the operator the device accepts `130…−120 W`. |
|
||||
| **B2** | `main.py:3362` | `inv.dev_min` / `inv.dev_max` | status page, `setpoint … (range −120…130 W)` | **NO** — the literal `setpoint … (range` is in no `test_*.py`. |
|
||||
| **B3** | `main.py:3435` | `sign_wd.cfg.hold_s` / `.cfg.min_w` | status page, `sign watchdog: armed (trips after 180 s opposed, both over 300 W)` | **NO** — `s opposed` is in no `test_*.py`. |
|
||||
| **B4** | `main.py:3460` | `site_sign_wd.cfg.hold_s` / `.min_w` | status page, the site sign row | **NO** — `armed against the sum` is in no `test_*.py`. |
|
||||
| **B5** | `main.py:3491` | `circ_wd.cfg.hold_s` / `.min_w` | status page, the circulation row | **NO** — `circulation watchdog: armed` is in no `test_*.py`. |
|
||||
| **B6** | `circulation.py:317` | `cfg.min_w` / `cfg.hold_s` | **`circ_reason` — persisted, and re-read after a restart** | **NO** — `test_watchdog.py` asserts only `"CIRCULATING_POWER" in reason`. **This is SAFETY-31's twin of the exact defect this card just closed for SAFETY-02/30**, on a string with the same persistence. |
|
||||
| **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. |
|
||||
| **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. |
|
||||
| **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. |
|
||||
| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **ROUND 4: MEASURED, THEN CLOSED.** Round 3 left it at source level; the transposition is **0 bytes**, `arbiter.py` `fdb4ed0520eeb721` → `9111f627ef639ecc`, and it was **21/21 green**. Now red in `test_arbiter.py` on 2 checks. Round 3's reasoning was right: `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. |
|
||||
| **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. |
|
||||
| **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. |
|
||||
| **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. |
|
||||
| B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. |
|
||||
| **B17** | `arbitrage.py:377` | `v.spread_eur_kwh` / `v.required_spread_eur_kwh` | the DISCHARGE decision's `reason` | **ROUND 4, NEW, and MEASURED: 0 bytes, 21/21 green.** Not visible to round 3 at all — the sentence is handed to a constructor as `reason=`, and the sink walker scored only *assignments*. The row then reads *"0.0400 above the cheapest and clear of the 0.1200 this cycle had to cover"* with the two swapped: a trade justified by a spread it did not clear. |
|
||||
| **B18** | `arbitrage.py:393` | same pair, CHARGE branch | the CHARGE decision's `reason` | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** |
|
||||
| **B19** | `main.py:2652` | `g.reason` / `g.soc_floor_reason` | the `goodwe_peak_soc_floor` notification body | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** The alarm then opens with the *reason the floor bound* and closes with the *guard's own conclusion*, i.e. both halves of a two-part explanation in the wrong order, in the one message SAFETY-24/25 exist to send. |
|
||||
| B20–B22 | `main.py:2558`, `main.py:2652` (two more pairs) | `g.reason` / `g.ceiling_w`, `g.ceiling_w` / `g.soc_floor_reason` | the same two notifications | **new in round 4, NOT measured** — mixed types (`str` beside `:.0f`), the same over-report class as B16. |
|
||||
|
||||
**⚠️ Six of these twenty-two were invisible to round 3, and the reason is one
|
||||
line of the sink classifier.** It scored a sentence a MESSAGE when it was
|
||||
appended, logged, raised, or assigned to a `reason`-shaped **name**. This
|
||||
codebase mostly builds operator sentences as `Decision(…, reason=f"…")` and
|
||||
`out.append({"text": f"…"})` — a **keyword argument** and a **dict key**, which
|
||||
that walker stepped straight past. Both are message sinks now.
|
||||
|
||||
### 7d. What this enumeration does NOT close, with sizes
|
||||
|
||||
* **A3 is a measured, open survivor.** `dev_min`/`dev_max` transposed in
|
||||
`rail_unreachable()`'s NOT COVERED row: **0 bytes, 21/21 green.** It is
|
||||
disclosed here rather than fixed, because closing it wants a `test_freeze.py`
|
||||
or status-page fixture with a device whose two bounds are **asymmetric** —
|
||||
every fixture in the repository uses `±d` — and that is a different file's
|
||||
card. It is a **status-page row**, not a latching alarm.
|
||||
* **B1 is a second measured, open survivor.** `inv.dev_min` and `inv.dev_max`
|
||||
transposed in `main.py:3231`'s status row: **0 bytes**, fingerprint
|
||||
`61aa413f…` → `ac36a97b…`, **21/21 green**. The page then tells the operator
|
||||
the device accepts `130…−120 W`. Same disposition as A3: **A3 is the row the
|
||||
freeze detector prints when the rail is UNREACHABLE, B1 the clause the ARMED
|
||||
row prints when the device is what set the rail** — the two rows in which that
|
||||
detector ever names a device range, and neither one's operand order is
|
||||
asserted anywhere.
|
||||
* **Thirteen further census-B sites are unpinned and UNMEASURED.** B2–B14 above
|
||||
are determined at the **source level** — the distinguishing literal appears in
|
||||
no `test_*.py`, or the check that exists asserts a substring wider than its
|
||||
own claim — and were **not** mutated. That is a weaker statement than A1–A4
|
||||
and B1, and is written down as one.
|
||||
* **B12 is a check whose CLAIM is wider than its ASSERTION**, which is habit
|
||||
14's own shape one module over. `test_arbiter.py:452` says
|
||||
`scope_violations` *"names the source and the actuator it does not belong
|
||||
on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Swap `c.source` and
|
||||
`c.actuator` in `arbiter.py:264` and the sentence becomes
|
||||
*"inverter-b: a SITE claim filed on unit 'peak'"* — and the substring is still
|
||||
found. Source-level, not mutated, and not fixed here.
|
||||
* **B6 is the closest thing to a repeat.** SAFETY-31's `circ_reason` is
|
||||
persisted and re-read after a restart, exactly like `sign_reason`, and its
|
||||
two limits are graded by a bare `"CIRCULATING_POWER" in reason`. This card
|
||||
does not fix it; §6a already declines to touch `circ_wd`, and this is the
|
||||
precise shape of what that declining costs.
|
||||
* **The instrument's own blind spots** are printed by the instrument, every
|
||||
run: semantic symmetry it cannot spell (it under-reports), census B pairs
|
||||
that are the same *kind* but not the same *shape*, `%s`-style logging and
|
||||
non-f-string payloads, anything outside `app/`, and the one way it
|
||||
**over-reports** — it erases the sentence, so a swap that would make the
|
||||
formatting itself raise is scored as changing no decision (A5–A6 above).
|
||||
|
||||
### 7e. Census C — the sentence an object returns about itself (round 4)
|
||||
|
||||
**Why there is a third census.** A and B between them covered **one syntactic
|
||||
sink**. Everything either of them reported was a sentence that is appended,
|
||||
assigned, or logged; **none was returned**. Census A's RETURN bucket only ever
|
||||
held a callee formatting its own **parameters**, so a method formatting its own
|
||||
**attributes** and handing the string back fell between them — and that is the
|
||||
whole `describe()` / `explain()` / reason-builder family, which is where the
|
||||
operator sentences in this codebase actually live.
|
||||
|
||||
**The test** is census B's — one f-string, two interpolations **one leaf apart**
|
||||
— run over the RETURN sink, plus one thing B never needed: **a returned string
|
||||
is not yet a message.** `store.unit_key()` and `mqtt.unit_state_key()` return
|
||||
f-strings too, and swapping there renames a store key and an MQTT topic, which
|
||||
every ordinary check can see (MAINT-05). So the sink of a returned sentence is
|
||||
**resolved through its call sites**: it is in this family only when some caller
|
||||
in `app/` hands it to an operator. That resolution matches on the **bare method
|
||||
name**, which over-collects — at least four unrelated classes have an
|
||||
`explain()` — so every verdict is printed **with the sites that produced it**,
|
||||
and a MESSAGE verdict is a prompt to read them, not a proof.
|
||||
|
||||
The scan reports **53 returned pairs, 39 reaching an operator, 16 of those
|
||||
sharing a format spec** (two values rendered alike, so a transposition is silent
|
||||
rather than visibly broken). It **rediscovered both members this round was sent
|
||||
to fix without being told where to look**, which is the only reason to trust the
|
||||
rest of the list.
|
||||
|
||||
| # | site | pair | reaches an operator as | Δ bytes | verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **C6** | `clamp.py:253` `ClampResult.describe()` | `requested_w` / `applied_w` | §15 action log (`main.py:1366`) **and** the ClampWatchdog notification reason (`main.py:2452`) | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_clamp.py` part 6d, 4 checks |
|
||||
| **C7** | `peakguard.py:493` `PeakDecision.explain()` | `projected_w` / `ceiling_w` | status row `main.py:3313`; action log `main.py:1951` on the release edge | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_peakguard.py` part 13, 3 checks |
|
||||
| C1 | `allocator.py:154` `UnitPlan.explain()` | `self.span` / `self.why` | the arbiter/allocator log line | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C2 | `allocator.py:160` | `self.span` / `self.why` | the ENVELOPE VIOLATED line | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C3 | `allocator.py:162` | `self.span` / `self.why` | the ordinary in-envelope line | **0** | **RED** — `test_alloc.py` already asserts that whole literal. The one member of this census the suite could already see, and it says the habit works. |
|
||||
| C4a | `arbiter.py:344` `UnitBounds.explain()` | `self.lo` / `self.hi` | `CONTRADICTION within the unit (device lo=… > peak hi=…)` | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C4b | `arbiter.py:344` | `self.lo_src` / `self.hi_src` | the same sentence, the two SOURCE names | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C5 | `arbiter.py:392` `Resolution.explain()` | `self.winner` / `self.bound_by` | `main.py:2236`'s `ARBITER:` action-log row and `self.reason` at `main.py:2329` | **0** | **SURVIVOR, 21/21 green** — the winner and the limiter swap names, on the line §10.1 exists to make legible |
|
||||
| C8 | `tariff.py:279` `PeakReading.explain()` | `self.month` / `self.detail` | the PEAK UNREADABLE row | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C9 | `tariff.py:528` `Projection.explain()` | `self.projected_w` / `self.elapsed_s` | the projection line | **0** | **SURVIVOR, 21/21 green** — watts printed as seconds and seconds as watts, both `:.0f` |
|
||||
|
||||
**Not in the family, and the scan says why rather than dropping them silently:**
|
||||
`inverter.py:338` `__repr__()` and `static_tariff.py:378` `words` are reported
|
||||
**UNRESOLVED — no call site inside `app/`** (a `__repr__` is called by the
|
||||
interpreter, not by a name the scan can match); `price_forecast.py:421/428` and
|
||||
`tariff.py:214` `month_key()` resolve to **OTHER** — `month_key` is a **store
|
||||
key**, which is MAINT-05's case and a swap there is behaviour every check sees.
|
||||
|
||||
### 7f. Round 4's mutation ledger
|
||||
|
||||
Same protocol as §4: a fresh full-repo copy per mutation, `:ro`, bytecode
|
||||
purged **and the purge count printed** before the edit, the edited file
|
||||
**re-read from disk**, both sides' length and sha256 printed, and the full
|
||||
**21-file** suite via `tools/mutation_run.sh`. Every row is a pure
|
||||
transposition, so **every Δ is 0 bytes** — verified, not assumed: `len(old) ==
|
||||
len(new)` is asserted before the write and `len(after) == len(before)` after it.
|
||||
|
||||
**⚠️ And every row was also required to change the ANSWER.** A byte delta says
|
||||
the file moved; it does not say the sentence did. Each mutated tree is rendered
|
||||
through a scratch probe that evaluates the shipped f-string with **distinct
|
||||
operands** and diffs it against the same render of the clean tree. **The first
|
||||
cut of that probe numbered the operands by ORDER OF APPEARANCE** — so after a
|
||||
transposition the other leaf took the same number and all thirteen diffs came
|
||||
back **empty**, which reads exactly like "the edit is inert". Keying the value
|
||||
to the **name** fixed it. That is the same defect as everything else in this
|
||||
card, in the instrument this time: a measurement comparing the swap against
|
||||
itself.
|
||||
|
||||
| mutation | file, before → after (sha256, 16) | Δ | verdict |
|
||||
|---|---|---|---|
|
||||
| C1 `allocator.py:154` span↔why | `10c1b26ed0099654` → `ac0c174de1ec28ad` | 0 | **SURVIVED 21/21** |
|
||||
| C2 `allocator.py:160` span↔why | `10c1b26ed0099654` → `9b10730f4659befd` | 0 | **SURVIVED 21/21** |
|
||||
| C3 `allocator.py:162` span↔why | `10c1b26ed0099654` → `07097bf5da31f0c6` | 0 | **RED** — `test_alloc.py` |
|
||||
| C4a `arbiter.py:344` lo↔hi | `fdb4ed0520eeb721` → `009820de7ef0eff0` | 0 | **SURVIVED 21/21** |
|
||||
| C4b `arbiter.py:344` lo_src↔hi_src | `fdb4ed0520eeb721` → `04cb8cf69dfc7432` | 0 | **SURVIVED 21/21** |
|
||||
| C5 `arbiter.py:392` winner↔bound_by | `fdb4ed0520eeb721` → `9238c3db920da8ad` | 0 | **SURVIVED 21/21** |
|
||||
| **C6** `clamp.py:253` requested↔applied | `0153133741497fa5` → `47e4e371043e3201` | 0 | **SURVIVED 21/21 before — RED after**, `test_clamp.py`, 4 checks |
|
||||
| **C7** `peakguard.py:493` projected↔ceiling | `d95df61a51716c26` → `2d2ee3d0bcd51251` | 0 | **SURVIVED 21/21 before — RED after**, `test_peakguard.py`, 3 checks |
|
||||
| C8 `tariff.py:279` month↔detail | `82790523dd5227c9` → `4b2147ca17a28425` | 0 | **SURVIVED 21/21** |
|
||||
| C9 `tariff.py:528` projected↔elapsed | `82790523dd5227c9` → `517e9148d26a243a` | 0 | **SURVIVED 21/21** |
|
||||
| B17 `arbitrage.py:377` spread↔required | `67fbf949b2938349` → `cddd8045bdeb5e29` | 0 | **SURVIVED 21/21** |
|
||||
| B18 `arbitrage.py:393` spread↔required | `67fbf949b2938349` → `04a4f3ad3d255de1` | 0 | **SURVIVED 21/21** |
|
||||
| B19 `main.py:2652` reason↔soc_floor_reason | `61aa413fb890433e` → `bbcbb28f476a98d0` | 0 | **SURVIVED 21/21** |
|
||||
| **B12** `arbiter.py:264` source↔actuator | `fdb4ed0520eeb721` → `9111f627ef639ecc` | 0 | **SURVIVED 21/21 before — RED after**, `test_arbiter.py`, 2 checks |
|
||||
|
||||
**14 measured, 12 survived the full suite before this round's checks, 1 was
|
||||
already caught, 3 are caught now.** The tree these were taken on is
|
||||
`SAFETY-38` @ `357cb4f` rebased on `release/1.0` @ `9e42cec`; `app/main.py` is
|
||||
234 542 bytes.
|
||||
|
||||
### 7g. What round 4 does NOT close, with sizes
|
||||
|
||||
* **Eleven measured zero-byte survivors are left open** — C1, C2, C4a, C4b, C5,
|
||||
C8, C9, B17, B18, B19 and the three already open from round 3 (A3, B1, and the
|
||||
B2–B11/B13–B14 source-level set). Each is a sentence an operator reads and
|
||||
none is a control decision. They are disclosed with their measurement rather
|
||||
than pinned because pinning each one wants a fixture in the file that owns the
|
||||
module — `test_alloc.py`, `test_arbiter.py`, `test_tariff.py`,
|
||||
`test_arbitrage.py` — and this card has now spent four rounds inside
|
||||
`test_watchdog.py`'s subject. **An enumerated list with honest gaps beats a
|
||||
fifth round finding a fifth instance**; what would NOT be honest is a fifth
|
||||
round finding one that is **not on this list**.
|
||||
* **`main.py:1951`, the action-log surface of the peak-guard idle line,** is
|
||||
unpinned. The status row off the same `explain()` is pinned; the second call
|
||||
site is not, and §4c is the card about exactly that costing a round.
|
||||
* **The largest remaining hole is not in any census: `%s`-style lazy logging.**
|
||||
`_LOG.error("… %s … %s", a, b)` puts the two operands in *arguments*, not in
|
||||
an f-string, so nothing here looks at them and the transposition is the same
|
||||
zero-byte edit. The tool now says so first in its NOT-SCANNED list, **with
|
||||
the size measured rather than asserted: 31 such calls across 8 modules in
|
||||
`app/`, each one listed**.
|
||||
* **The tool's own blind-spot list was the round-4 blocker and is now two
|
||||
lists.** "What this scans, and what it does not" enumerates its message sinks
|
||||
from the constants themselves and then names, in order, what it never looks at:
|
||||
`%s`-style logging, `.format()`/`%`/`+`/`.join()` sentences, a sentence that
|
||||
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
|
||||
sentences, attributes outside the `reason`-shaped set, and everything outside
|
||||
`app/`. "What it can see but gets wrong" keeps the five gaps round 3 printed,
|
||||
with the over-report gap now backed by a **printed format-spec column** so a
|
||||
reader can tell a silent same-typed transposition from a noisy mixed-type one.
|
||||
@@ -0,0 +1,438 @@
|
||||
# SAFETY-39 — `slew_w` is a ratio to the rail, and `gain` is the constant that is still wrong
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-39`.
|
||||
**Card:** SAFETY-39 — *"`slew_w` was derived at a 1500 W clamp and runs at 5000,
|
||||
so the loop outruns the plant"*.
|
||||
**Nothing here reached the inverter.** The live install still runs the interim
|
||||
`slew_w: 400` applied by hand on 2026-09-05 12:00, which this branch's new rule
|
||||
accepts unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 0. The three things a reader should take away
|
||||
|
||||
1. **The overshoot is reproduced in simulation, and one half of it is not
|
||||
reproducible at all.** The setpoint stepping to the rail and the meter
|
||||
crossing from export into import are both reproduced, on two independent
|
||||
plant models. The **−5154 W battery figure is not reproducible in either**,
|
||||
because both clamp the plant at its rating. An acceptance criterion written
|
||||
on it would pass on the broken configuration too, so none is. §2.
|
||||
2. **The deliverable is the relationship, not the number.** `slew_w: 0` now
|
||||
means *follow the rail* and resolves to `SLEW_RAIL_FRAC * max_w`;
|
||||
`validate.py` rule 10 refuses an explicit value faster than that against the
|
||||
**site** rail. Verified by breaking it. §4, §5.
|
||||
3. ⚠️ **`slew_w` does not own the over-absorption. `gain` does, and `gain` was
|
||||
not changed.** Re-deriving the slew removes the ≥ 900 W step — the symptom
|
||||
that escalated 2 → 1 → 13 → 43 per 30 min on the morning of the excursion —
|
||||
and removes nothing else. Anyone reading this card as "the overshoot is
|
||||
fixed" is reading more than was delivered. §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, stated in the terms the code uses
|
||||
|
||||
`slew_w: 1000` was derived on 2026-08-22 against a **clamp of 1500 W**
|
||||
(goodwe/`NOTES.md`, *"Simulated 2 kW load step (gain 0.6, clamp 1500)"*). Its
|
||||
stated justification:
|
||||
|
||||
> `slew: 1000` is the default because it puts most of the correction in the first
|
||||
> cycle **while still capping how far the loop can jump ahead of the inverter**.
|
||||
|
||||
At 1500 W that sentence is true, and the reason is the rail rather than the
|
||||
number: 1000 W/cycle reaches the rail in two cycles, so the excursion is bounded
|
||||
by 1500 W whatever the loop does.
|
||||
|
||||
The live install then went to `max_w: 5000` and the constant did not move.
|
||||
`DRIVER-07` ("raise actuator power cap to full inverter rating") is still in To
|
||||
Do; the cap was raised ahead of it and `slew_w` was never revisited. **Nothing
|
||||
in the code is broken.** The rail moved and the derivation did not.
|
||||
|
||||
| | derived at | shipped default | live install |
|
||||
|---|---|---|---|
|
||||
| `max_w` | **1500** | 2000 | **5000** |
|
||||
| `slew_w` | 1000 | 1000 | **1000** |
|
||||
|
||||
Measured on the owner's system, 2026-09-05 11:43–11:46:
|
||||
|
||||
```
|
||||
11:43:55 grid -3385 W a real 3.4 kW export
|
||||
11:43:56 SP 380 → 1380 → 2380 → 3380 → 4380 → 5000 +1000 per 2 s tick
|
||||
11:44:47 batt -5154 W ABOVE the 5000 VA rating
|
||||
11:45:55 grid +2831 W flipped to 2.8 kW IMPORT
|
||||
SP 5000 → 2930 → 3840 → 3750 hunting
|
||||
```
|
||||
|
||||
Large (≥ 900 W) setpoint steps per ~30 min that morning: `2 → 1 → 13 → 43`.
|
||||
|
||||
---
|
||||
|
||||
## 2. ⭐ Reproduced first — and what could not be
|
||||
|
||||
`tools/slew_sim.py --repro`, against admin/goodwe's `plantsim.Plant`, the real
|
||||
`compute()`, and the firmware's own encoder on the wire. Gain 0.6, `max_w` 5000,
|
||||
`slew_w` 1000, 2 s tick, one 3385 W export step at t = 10 s:
|
||||
|
||||
```
|
||||
t grid in setpoint battery grid out reason
|
||||
10 -3739 -280 290 -3675 slew-limited
|
||||
12 -3675 -1280 57 -3442 slew-limited
|
||||
14 -3442 -2280 -316 -3069 slew-limited
|
||||
...
|
||||
34 -1012 -5000 -2823 -562 i-clamped ← the rail
|
||||
38 -167 -5000 -3541 156 saturated-freeze ← EXPORT → IMPORT
|
||||
44 562 -4310 -4018 633 saturated-freeze
|
||||
50 544 -3210 -3804 419 tracking
|
||||
```
|
||||
|
||||
Reproduced: the setpoint stepping a full 1000 W per tick to the rail, the loop
|
||||
absorbing ~640 W more than the site needed, and the meter crossing from **3.7 kW
|
||||
of export to +633 W of import** — the loop manufacturing the error it then
|
||||
chases. Over a family of 51 export steps (1.0–4.2 kW × 3 phases), **48 of 51**
|
||||
runs contain a ≥ 900 W setpoint step.
|
||||
|
||||
### ⚠️ What is NOT reproducible, and why no criterion is written on it
|
||||
|
||||
`plantsim.Plant.limit()` clamps the plant at `p_max`. The modelled battery
|
||||
**cannot exceed its rating by one watt, at any tuning, on any step, ever.**
|
||||
Neither can `test_control.py`'s independent model. So:
|
||||
|
||||
* the **−5154 W excursion is not reproducible in simulation at all**, and this
|
||||
is a property of the models rather than of the tuning;
|
||||
* the card's AC *"battery power no longer exceeds `max_w` on a step of the size
|
||||
observed"* is **unfalsifiable in simulation** — it passes identically on the
|
||||
configuration that produced the excursion. It is the one acceptance criterion
|
||||
this card had to **rewrite rather than meet**, per the board's own rule that a
|
||||
third review round means the AC is wrong.
|
||||
|
||||
What replaced it, and what is graded in CI: **the ≥ 900 W step is gone from
|
||||
every member of the family**, and **the meter excursion and the over-absorption
|
||||
are measured and reported rather than claimed to be fixed.**
|
||||
|
||||
### The two models agree
|
||||
|
||||
`test_control.py`'s in-repo first-order lag and admin/goodwe's ramp-plus-lag
|
||||
`plantsim.Plant` are independent, and they agree on every sign and rank:
|
||||
|
||||
| | in-repo model | `plantsim.Plant` |
|
||||
|---|---|---|
|
||||
| ≥ 900 W steps at `slew_w` 1000 | 48/51 | 48/51 |
|
||||
| ≥ 900 W steps at the derived value | 0/51 | 0/51 |
|
||||
| over-absorption, slew 1000 → 250 | 1005 → 1330 W | 958 → 1081 W |
|
||||
| over-absorption, gain 0.6 → 0.2 | 1109 → 393 W | 987 → 435 W |
|
||||
|
||||
---
|
||||
|
||||
## 3. The derivation
|
||||
|
||||
The constraint, in words, and it is the 2026-08-22 sentence made checkable:
|
||||
|
||||
> **The loop must not traverse its full output range faster than the plant takes
|
||||
> to settle.**
|
||||
|
||||
Three measured quantities, and each is named where it came from:
|
||||
|
||||
| | value | where it was measured |
|
||||
|---|---|---|
|
||||
| plant settling time | **18 s** | goodwe/`NOTES.md` §5.10, 2026-08-22 — a 1.78 kW kettle step driven from HA off a plug that meters its own draw. ~18 s up to the deadband, ~23 s down. **18 is taken because it is the shorter and therefore the looser bound**; this is a refusal boundary, and rules 5 and 6 refuse the impossible case only, never "close to the rail". |
|
||||
| control tick | **2 s** | the live install, measured 2026-09-05 — the setpoint stepped +1000 W every 2 s. `run_control()` runs a cycle on a changed meter value or on `cycle_floor_s`, whichever comes first, so the tick is the *meter's* rate. |
|
||||
| output range | `max_w` | `main.py` builds `Tuning(max_w=self.site_max_w)`, the **roster sum**. |
|
||||
|
||||
```
|
||||
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 0.1111 (9 ticks)
|
||||
|
||||
slew_w ≤ SLEW_RAIL_FRAC × max_w
|
||||
```
|
||||
|
||||
| rail | derived `slew_w` | ticks to cross | seconds to cross |
|
||||
|---|---|---|---|
|
||||
| 1500 (where the original was derived) | 167 W | 9 | 18 s |
|
||||
| 2000 (shipped default) | 222 W | 9 | 18 s |
|
||||
| **5000 (the live install)** | **556 W** | 9 | 18 s |
|
||||
| 9600 (with inverter B — **datasheet, not measured**) | 1067 W | 9 | 18 s |
|
||||
|
||||
And the configuration that was measured failing: `slew_w` 1000 at `max_w` 5000
|
||||
crosses the rail in **5.0 ticks = 10 s**, against a plant that takes 18 s.
|
||||
|
||||
### ⚠️ The tick is the weak term and it is not wrong in the safe direction
|
||||
|
||||
A **slower** meter makes this bound conservative — it refuses configurations that
|
||||
would have been fine, which is visible immediately and costs one edit. A meter
|
||||
**faster** than 2 s makes it loose by exactly that ratio and nothing in this
|
||||
add-on would say so. `run_control()`'s `await asyncio.sleep(1)` bounds the tick
|
||||
below at 1 s, so the worst case is a factor of two — but if a faster meter is
|
||||
ever fitted, `LOOP_TICK_S` has to be **re-derived, not merely re-read**. Compare
|
||||
`validate.FIRMWARE_INGEST_VETO_S`, which carries the same kind of copy and can
|
||||
only ever be wrong the other way; that difference is why both numbers are
|
||||
written out in `control.py` rather than their quotient.
|
||||
|
||||
### ⚠️ The rig cannot pick 556 out of the table, and is not asked to
|
||||
|
||||
`tools/slew_sim.py --slew`, worst case over 51 export steps:
|
||||
|
||||
```
|
||||
slew_w 1000 ← 2026-09-05 live pair step 1000 W 48/51 ≥900 W over-absorbed 958 W
|
||||
slew_w 800 step 800 W 0/51 ≥900 W over-absorbed 1107 W
|
||||
slew_w 700 step 700 W 0/51 ≥900 W over-absorbed 1133 W
|
||||
slew_w 600 step 600 W 0/51 ≥900 W over-absorbed 1058 W
|
||||
slew_w 555 ← the derived value step 560 W 0/51 ≥900 W over-absorbed 987 W
|
||||
slew_w 500 step 500 W 0/51 ≥900 W over-absorbed 1029 W
|
||||
slew_w 400 ← the live interim step 400 W 0/51 ≥900 W over-absorbed 1150 W
|
||||
slew_w 300 step 300 W 0/51 ≥900 W over-absorbed 1143 W
|
||||
slew_w 250 step 250 W 0/51 ≥900 W over-absorbed 1081 W
|
||||
```
|
||||
|
||||
The ≥ 900 W step disappears at 800 and stays gone. Below that the table is
|
||||
**flat and slightly non-monotone**, and 800 is *better* than 556 on
|
||||
over-absorption. So the rig does not choose the number — **the constraint
|
||||
chooses it, and the rig says what the constraint does and does not buy.** That
|
||||
is the same relationship the 2026-08-22 table had to its own three columns.
|
||||
|
||||
⚠️ **A single step is not evidence here.** The deadband, the 10 W quantisation
|
||||
and the phase of the step against the tick move the result by hundreds of watts,
|
||||
non-monotonically. The first cut of this derivation was a single step and ranked
|
||||
gain 0.3 as *worse* than 0.6, which the family shows is backwards. Every number
|
||||
above is a worst case over a family, which is how admin/goodwe grades the same
|
||||
kind of quantity (`config.yaml`'s "1890 closed-loop runs", "924 runs").
|
||||
|
||||
---
|
||||
|
||||
## 4. What ships — the relationship, not the number
|
||||
|
||||
**`slew_w: 0` means FOLLOW THE RAIL.** `control.slew_limit_w()` resolves it to
|
||||
`SLEW_RAIL_FRAC * max_w`. It is the shipped default and it is the only value
|
||||
that cannot be left behind when the rail moves. This is the idiom
|
||||
`integrator_max_w: 0` has carried in the same options block since it was added —
|
||||
not a new concept, the same one, applied to the key that needed it.
|
||||
|
||||
An explicit number is still accepted **and is never silently reduced**: the live
|
||||
install runs 400, and silently reducing an operator's stated bound is case (a)
|
||||
in `validate.py`'s own docstring — the defect that whole module exists to
|
||||
prevent.
|
||||
|
||||
**`validate.py` rule 10** refuses an explicit `slew_w` above
|
||||
`SLEW_RAIL_FRAC × site rail`. Graded against **`sum(unit_caps(opts))`**, the
|
||||
roster sum — the same number `Controller.site_max_w` builds — and *not* against
|
||||
the flat `max_w` and *not* against the weakest unit. Rules 5, 6 and 8 take
|
||||
`min()` because each grades one watchdog, one shaping claim, one pack. `slew_w`
|
||||
is not a per-unit quantity at all: `compute()` emits the **site** target and the
|
||||
allocator divides it afterwards.
|
||||
|
||||
⚠️ `config.yaml`'s note on `site_sign_disagree_w` says there is deliberately no
|
||||
rule pairing a site quantity with `max_w`, "because … `validate_startup_config()`
|
||||
is handed options rather than a roster". That objection is to grading a site
|
||||
quantity against the **flat key**, and it is answered rather than ignored:
|
||||
`unit_caps()` already reads the roster the add-on will build, so the roster sum
|
||||
*is* available at startup, and `site_rail_w()` is that number.
|
||||
|
||||
---
|
||||
|
||||
## 5. ⭐ Verified by breaking it
|
||||
|
||||
`test_validate.py`, section 10 — the acceptance criterion written as a test:
|
||||
|
||||
```
|
||||
restoring slew_w 1000 at max_w 5000 -> 1 problem(s)
|
||||
slew_w is 1000 W against a site rail of 5000 W, so the loop crosses its
|
||||
whole output range in 5.0 cycles - 10 s at the 2 s tick this site was
|
||||
measured running - while the plant takes 18 s to settle …
|
||||
```
|
||||
|
||||
and the four things the refusal has to say, each graded separately: which rail
|
||||
it used, how long the crossing takes, which measurement it is graded against,
|
||||
and what to do about it (including `0`, which cannot go stale). Plus:
|
||||
|
||||
* **equality starts** — at exactly the derived value the crossing takes exactly
|
||||
the settling time, which is the constraint met, not violated (rule 6's case,
|
||||
not rule 5's; getting it backwards would refuse the recommended value);
|
||||
* **one watt of slack starts** — a boundary that is not meaningful to one watt
|
||||
must not refuse on one watt;
|
||||
* **the live interim `slew_w: 400` starts**, and so does the schema floor —
|
||||
there is no such thing as too slow here, and refusing it would refuse the
|
||||
mitigation that is holding;
|
||||
* **`slew_w: 0` starts at every rating from 100 W to 15 kW.**
|
||||
|
||||
`test_control.py` grades the same boundary from the law's side, so the rule can
|
||||
only ever refuse what the ratio says is too fast, and the two cannot drift apart.
|
||||
|
||||
---
|
||||
|
||||
## 5b. What the change found in this repository's own tests
|
||||
|
||||
Five files moved, and **none of them moved because a number was rounded
|
||||
differently**. Each is worth reading as a finding.
|
||||
|
||||
**Two fixtures encoded the defect.** `test_inverter.py`'s `BASE_OPTS` paired
|
||||
`slew_w: 1000` with `max_w: 5000` — the 2026-09-05 configuration exactly — and
|
||||
`test_loop.py`'s `BASE` paired 1000 with 2000. Both were typed when those rails
|
||||
were new; nothing in either file had noticed. Rule 10 refused them on its first
|
||||
run, so those fixtures **would not start**. That is what a rule about a
|
||||
*relationship* is for, and it is the clearest evidence the rule is not a
|
||||
formality.
|
||||
|
||||
**Two reproductions depended on a default they never named.**
|
||||
`test_alloc.py`'s `close_loop` and `test_freeze.py`'s "make both terms
|
||||
reachable" case reached `slew_w: 1000` through `Tuning`'s default. ARCH-01's
|
||||
4000 W broadcast limit cycle and SAFETY-18's proof-of-capability alarm would
|
||||
both have gone quiet — reporting a measured finding as *absent* because a
|
||||
default moved underneath them. Both now write the number beside the measurement
|
||||
it belongs to.
|
||||
|
||||
**One harness had a latent off-by-one that only a ramping loop could expose.**
|
||||
`test_watchdog.py` graded SAFETY-02's banner by pairing `c.grid` with
|
||||
`last_write` sampled **after** `cycle()`, while the detector is evaluated
|
||||
**inside** `cycle()` against the *standing* command from the previous one. At
|
||||
`slew_w: 1000` the loop reached its rail in two cycles and consecutive commands
|
||||
were identical, so the wrong pair and the right pair were the same numbers. At a
|
||||
rail-following slew they differ by one step and the check went red. `_drive()`
|
||||
now has a `pre` hook that reads the operands the detector will actually use, and
|
||||
the check reads the **latching** cycle rather than the one before it. The
|
||||
assertion is strictly stronger than it was.
|
||||
|
||||
**One tripwire had two assertions that were exact by coincidence.**
|
||||
`test_peakguard.py` part 10c read `limited_discharging == limited` (49/49) and
|
||||
`min_soc <= bms_soc_floor + 0.05`. At the slower slew the loop drains that pack
|
||||
~82 Wh less over the same two hours and no longer touches the cut-out *inside
|
||||
that window*: 48/49, low-water 10.57 % against a 10 % cut-out, 0 cycles at the
|
||||
BMS limit instead of 190. **The finding is unchanged and is sharper on the
|
||||
measure that carries it** — 162 cycles of granted discharge below the EMS floor,
|
||||
up from 138. The two exact equalities were artefacts of one tuning, the old and
|
||||
new numbers are tabulated in the file beside the checks, and the assertions now
|
||||
grade the substance. ⚠️ *Loosening an assertion to get a branch green is the
|
||||
first thing a reviewer should suspect here; the table is there so the suspicion
|
||||
can be settled rather than argued.*
|
||||
|
||||
---
|
||||
|
||||
## 6. ⚠️ `gain: 0.6` — measured, and deliberately NOT changed
|
||||
|
||||
The card asks whether `gain` still holds at this clamp. **It does not, and here
|
||||
is the measurement.**
|
||||
|
||||
`tools/slew_sim.py --gain`, `slew_w` at the derived 556 W, same 51-step family:
|
||||
|
||||
```
|
||||
gain 0.6 ← shipped over-absorbed 987 W meter driven to +987 W
|
||||
gain 0.5 over-absorbed 941 W
|
||||
gain 0.4 over-absorbed 977 W
|
||||
gain 0.3 over-absorbed 876 W
|
||||
gain 0.2 over-absorbed 435 W
|
||||
gain 0.1 over-absorbed 66 W
|
||||
```
|
||||
|
||||
Against the slew column in §3, which is flat. The mechanism is visible in
|
||||
`control.py` and is structural rather than empirical:
|
||||
|
||||
* **`slew_w` bounds the OUTPUT.**
|
||||
* **The integrator accumulates `gain × error` per cycle** and is bounded only by
|
||||
`integrator_max_w`. At gain 0.6 into a 3.4 kW error it moves **~2040 W per
|
||||
tick** and reaches a 5000 W rail in **under three ticks, whatever `slew_w`
|
||||
is**.
|
||||
|
||||
So the 2026-08-22 derivation's own sentence — *"the gain, not the slew, is the
|
||||
limit by then"* — has become literally true, and `gain` is the term that owns
|
||||
the over-absorption at this rail. It is written about a 1500 W rail, where
|
||||
`gain × 1500 = 900 W` and the clamp caught it in two cycles; at 5000 there is
|
||||
3.3× more room to wind before the clamp binds.
|
||||
|
||||
**Why nothing was changed:**
|
||||
|
||||
* `gain` is a live control-law constant on a running house. Two of them moving
|
||||
in one release makes the next measurement uninterpretable — and the interim
|
||||
`slew_w: 400` has been in service since 2026-09-05 12:00, so there is a clean
|
||||
baseline to measure the next change against and it would be destroyed.
|
||||
* A validator rule pairing `gain` with a rail would be **inventing a
|
||||
relationship nobody has measured.** The bound would have to be
|
||||
`gain × error_max ≤ SLEW_RAIL_FRAC × max_w`, and `error_max` is the
|
||||
disturbance, which is not config and is not bounded.
|
||||
* `integrator_max_w` is **not** the lever it looks like: it bounds the output as
|
||||
well as the accumulator (`target = clamp(i_w, ±max_w)`), which is why setting
|
||||
it to 2000 on the live system on 2026-09-05 capped the inverter to 2000 W of
|
||||
its 5000 and was reverted the same day.
|
||||
|
||||
This is named in `validate.py`'s *"deliberately not refused"* list so that it is
|
||||
a known open item rather than an omission, and it is asserted in
|
||||
`test_control.py` so that nobody can quietly read this card as having fixed it.
|
||||
**It wants its own ticket.**
|
||||
|
||||
---
|
||||
|
||||
## 7. N = 1 → N = 2: the rail is what moved
|
||||
|
||||
⚠️ **Inverter B does not exist on site** (SPIKE-04, ≈ 2026-09-12). Its figures
|
||||
are from its **datasheet and serial**, not a measurement: GW5048D-ES, serial
|
||||
`55048ESU22AW0992`, 4600 W, pack 4 × Dyness DL5.0C = 20.48 kWh, floor 10 %
|
||||
(goodwe/`NOTES.md` §1.1b) — and the 4600 W is printed under *battery input data*,
|
||||
so it may be DC, in which case the AC ceiling is nearer 4324 W.
|
||||
|
||||
⚠️ **And `sim/plantsim.py`'s `INVERTER_B` is inverter A copied**, carrying
|
||||
`measured=False`. **Every two-inverter row below is a result about two copies of
|
||||
A on a 10000 W rail, not about the ~9600 W site B implies.**
|
||||
|
||||
**The site rail becomes `sum(max_w)` ≈ 9600 W while `slew_w` is per-site.** The
|
||||
derived value there is `0.1111 × 9600 = 1067 W` per tick, which at an even split
|
||||
is 533 W per unit against each unit's own 5000/4600 W rating — ARCH-01's
|
||||
split-invariance holding, as its `two_master_gain_sweep` already shows.
|
||||
|
||||
**Does the enforced relationship produce it automatically? Yes.** Rule 10 reads
|
||||
`sum(unit_caps(opts))` and `slew_limit_w()` reads `Tuning.max_w`, which
|
||||
`main.py` sets from `Controller.site_max_w`. Declaring inverter B moves both,
|
||||
with no operator action and no second derivation. That is the property that
|
||||
makes this a fix rather than a re-tune, and `test_validate.py` asserts the
|
||||
9600 W arithmetic directly.
|
||||
|
||||
⚠️ **But the direction of the rule is not the one the ticket assumed, and it
|
||||
must be said.** A rail that **grows loosens** this bound: `slew_w: 1000`
|
||||
crosses 9600 W in 19.2 s and **passes**, while the same 1000 at 5000 W is the
|
||||
configuration measured failing. That is what the constraint says and it is
|
||||
correct — the bound is on how long the crossing *takes*. What the rule protects
|
||||
against is the constant being left behind when the rail moves, in either
|
||||
direction. **It is not a bound on how much power the site may command** (that is
|
||||
each unit's own `max_w` through the arbiter's `supervised` claim) **and it does
|
||||
not bound the over-absorption** (§6).
|
||||
|
||||
⚠️ **The ≥ 900 W symptom goes stale at N = 2 the same way the constant did.**
|
||||
On the two-copies-of-A rows, 48/51 runs contain a ≥ 900 W step *at the derived
|
||||
value*, because at a 10000 W rail the derived step **is** 1111 W. A check
|
||||
written on the absolute 900 W would report the correct tuning as broken.
|
||||
Anything grading this symptom at N ≥ 2 has to grade it as a **fraction of the
|
||||
rail** too — which is this whole card, one layer out.
|
||||
|
||||
---
|
||||
|
||||
## 8. `max_w: 5000` — a recommendation, not a change
|
||||
|
||||
The battery reached **5154 W against a 5000 VA inverter**, so the configured cap
|
||||
left no headroom for the overshoot that the loop was going to produce anyway.
|
||||
Three things are worth writing down and none of them is changed here:
|
||||
|
||||
1. **The cap is the rating, so the overshoot has nowhere to go but past it.**
|
||||
`compute()` bounds the *command* to `±max_w`; nothing bounds the plant's
|
||||
response to a command at the rail, and §6 shows the loop asks for the rail on
|
||||
any large step at gain 0.6. Recommend **`max_w` at 4500–4750 W**, i.e. 90–95 %
|
||||
of the 5000 VA nameplate, leaving 250–500 W for the overshoot the loop still
|
||||
has. This is `freeze.py`'s `RAIL_FRAC` argument applied to the cap itself.
|
||||
2. **The −5154 W may not be an AC figure at all.** `batt_entity` is the pack
|
||||
sensor and `max_w` is graded against `0x0518`, the inverter's **AC** power —
|
||||
`config.yaml`'s roster comment already records exactly this ambiguity for
|
||||
inverter B. If the battery reading is DC, 5154 W DC is ~4900 W AC and the
|
||||
inverter was never over its nameplate. **Nobody has measured which**, and the
|
||||
recommendation above is worth making either way.
|
||||
3. **It is the owner's number.** `translations/en.yaml` marks `max_w` "THIS IS
|
||||
YOUR NUMBER" precisely so it is not confused with a derived constant, and
|
||||
changing it unilaterally in a pull request would undo that distinction.
|
||||
`SAFETY-20`'s `min(max_w, dev_max)` settles the device side once ESPHome has
|
||||
answered; the *headroom* question is a policy choice and belongs to the owner.
|
||||
|
||||
---
|
||||
|
||||
## 9. What was deliberately not done
|
||||
|
||||
* **`saturation_w` and the deadband were not touched.** The card forbids it and
|
||||
it is right to: widening either hides the symptom. The loop is following its
|
||||
command correctly; the command is wrong.
|
||||
* **`gain` was not changed.** §6.
|
||||
* **`max_w` was not changed.** §8.
|
||||
* **`control.py`'s slew *limit* was not clamped to the bound.** An explicit
|
||||
value above it is refused at startup, not quietly reduced — silently reducing
|
||||
an operator's stated bound is case (a) in `validate.py`'s docstring.
|
||||
* **Nothing in admin/goodwe was modified**, and nothing reached the inverter.
|
||||
@@ -0,0 +1,592 @@
|
||||
# SAFETY-42 — a unit that stops delivering its share, the discriminator the card asked for, and why that discriminator does not exist
|
||||
|
||||
**Status: DECIDED 2026-09-06.** A new per-inverter check, `app/delivery.py`,
|
||||
names an inverter that is **standing at a discharge share it is not producing**
|
||||
while at least one sibling is not in that state and nothing this add-on can read
|
||||
explains it. It **reports and does not act**. At a roster of one it is inert by
|
||||
construction.
|
||||
|
||||
⚠️ **This record also FALSIFIES the discriminator the card proposed.** SAFETY-42
|
||||
asked for *"saturation persisting past N while |grid error| stays above X, with N
|
||||
and X derived"*. That predicate was built and run on the rig before anything was
|
||||
written, and it fails in **both** directions — not for want of better N and X,
|
||||
but because of what the quantity is. §2 is the measurement. This is the
|
||||
SAFETY-39 shape: saying the criterion is wrong was worth more than five
|
||||
near-misses at satisfying it.
|
||||
|
||||
Filed with SAFETY-42, card `6a9d498a49082c0086e525c1`.
|
||||
|
||||
---
|
||||
|
||||
## 0. What is uncovered, and why it is structural rather than a threshold
|
||||
|
||||
A unit delivering **0 W against a non-zero share** is seen by nothing:
|
||||
|
||||
| guard | why it is silent |
|
||||
|---|---|
|
||||
| [[SAFETY-30]] per-unit sign | gates on **opposition ≥ 250 W on both sides**. A unit at 0 W never opens it — it is not opposing, it is **absent** |
|
||||
| SAFETY-30 site sign | the sum still **agrees with the meter in direction**; only the magnitude is short |
|
||||
| [[SAFETY-31]] circulation | `Σ\|Bᵢ\| − \|ΣBᵢ\|` is **zero** when one unit does nothing — circulation needs two units pulling *against* each other |
|
||||
| the saturation freeze | **fires** — and `clamp.py`:93-96 says of exactly this: *"A loop can be legally saturated all day — a cold pack, a curtailed inverter, an export limit — so a saturation-shaped alarm is one an operator learns to ignore"* |
|
||||
|
||||
So the one signal that does fire is the one this codebase documents as
|
||||
unreadable, and that documentation is **right**. This card therefore needed a
|
||||
different signal, not a louder one.
|
||||
|
||||
It is [[HW-01]]'s shape. On 2026-09-02 the comms subsystem failed and the power
|
||||
stage went on executing its last instruction for **6 h 25 m** with all three
|
||||
layers spectators. At N=2 the same fault leaves a healthy sibling regulating
|
||||
alone, the site short by up to one unit's share, and nothing saying so.
|
||||
|
||||
## 1. The rig
|
||||
|
||||
Everything in §2 and §3 was measured on `admin/goodwe`'s two-master rig
|
||||
(TEST-19), used **read-only** and run container-free, from a harness in a
|
||||
scratch tree. Real and imported, never transcribed: the control law
|
||||
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
|
||||
`allocator.plan`), the per-unit envelope the shipped `supervised` and `device`
|
||||
claims build, the sign predicate (`watchdog.disagrees`), the circulating sensor
|
||||
(`circulation.circulating_w`), the check under test (`delivery.delinquent`,
|
||||
`short_by`, `account`), the wire (`frame_check.setpoint_atomic`, the firmware's
|
||||
own encoder, in and back out through `plantsim.parse`/`decode`), and the plant
|
||||
(`plantsim.Plant`). The per-unit reading is **holding register 0x0518**, the one
|
||||
the live `batt_entity` binds.
|
||||
|
||||
Modelled, and therefore not evidence about the thing modelled: the master's
|
||||
failsafe machine (absent), and the two fault injections —
|
||||
|
||||
* **wedged** — the write never reaches the plant, so its latched command stands.
|
||||
Started at 0 W, which is *"delivering 0 W against a non-zero share"*.
|
||||
* **at its floor** — the write arrives and `Plant.limit()` zeroes it, because
|
||||
`soc <= soc_min`. This is the benign case the acceptance criterion names.
|
||||
|
||||
### ⚠️ Inverter B does not physically exist
|
||||
|
||||
`plantsim.INVERTER_B` is **inverter A copied**, `measured=False` (NOTES.md
|
||||
§1.1b; the datasheet-and-serial figures 4600 W / 20.48 kWh / floor 10 % are
|
||||
deliberately *not* in the model). **Every two-inverter figure in this document
|
||||
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
|
||||
margin that costs.
|
||||
|
||||
### Reproducing it
|
||||
|
||||
The harness is **not committed here**, for the reason SAFETY-30's and
|
||||
SAFETY-31's were not: it imports `plantsim` and `frame_check`, which live in the
|
||||
other repository and are not present in this one's CI image. What it does is
|
||||
small enough to restate: build two `plantsim.Inverter`s from `INVERTER_A` /
|
||||
`INVERTER_B`; close the loop with the add-on's own `compute()` on the SITE sum
|
||||
(`Controller.site_batt_w`) and `arbiter.allocate_record()` over per-unit
|
||||
envelopes of `±min(max_w, rating)`; put each share on its own wire through
|
||||
`Inverter.command()`, honouring a configurable **write stagger** so B's number
|
||||
can land up to four cycles after A's (the serialised write path); advance
|
||||
`Site.settle()` one control cycle; and record, per unit, the command standing
|
||||
*before* the write and that unit's 0x0518 power. Then grade each cycle with the
|
||||
**shipped** `delivery.delinquent()` and take the **longest run of consecutive
|
||||
cycles** on which it names anybody — not a count of naming samples, because the
|
||||
window is cleared by any cycle that does not.
|
||||
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
|
||||
trees. Run it with `python3 -B` and purge.
|
||||
|
||||
## 2. The card's discriminator, measured — and it does not work
|
||||
|
||||
### 2.1 It fires on the benign case, and no (N, X) can stop it
|
||||
|
||||
`Plant.limit()` returns 0 W for a discharge command into a pack at its own
|
||||
floor. A wedged unit that never receives its command also produces 0 W. The two
|
||||
are not merely similar:
|
||||
|
||||
> **Load 2000 W, equal weights, 2000 W per-unit cap, 120 control cycles.
|
||||
> The two arms are IDENTICAL on all 120 cycles** in (grid, site target, each
|
||||
> unit's share, each unit's measured power), to the last emitted watt. Both
|
||||
> report `reason == "saturated-freeze"` on **116 of the 120** cycles (the other
|
||||
> four are `slew-limited`, before it engages on cycle 4), both hold |grid error|
|
||||
> over 300 W on **all 120** and settle at **1200 W**, and `circulating_w` is
|
||||
> **0.0 W** in both.
|
||||
|
||||
A predicate whose inputs are *saturation duration* and *|grid error|* reads the
|
||||
same number on both arms at every cycle. There is no N and no X. And *"a
|
||||
legitimately saturated site — cold pack at its floor — does **not** alarm"* is
|
||||
this card's own acceptance criterion.
|
||||
|
||||
`test_delivery.py` part 4 asserts that identity on the add-on's own end-to-end
|
||||
fixture, so it goes red if it ever stops being true.
|
||||
|
||||
### 2.2 It is silent on the headline case, because a healthy sibling erases it
|
||||
|
||||
The standing grid error is exactly the thing a working sibling removes.
|
||||
|
||||
> **Load 800 W, equal weights, 2000 W per-unit cap, B wedged, 200 cycles.**
|
||||
> Over the last 30 cycles the loop is saturation-frozen on **30 of 30** — so
|
||||
> "saturation persisting past N" is true for every N — while the worst
|
||||
> **|grid error| in that window is 30 W**. B is standing at a **+770 W**
|
||||
> discharge share and delivering **0 W**.
|
||||
|
||||
Any X above 30 W leaves the card's predicate silent for the whole run, on the
|
||||
exact fault it was written for. The card's own sentence — *"a healthy sibling
|
||||
regulating alone, the site short by up to one unit's rating"* — describes the
|
||||
case, and the grid error is the one number that cannot report it.
|
||||
|
||||
### 2.3 What that leaves
|
||||
|
||||
The derivation instruction stands and is honoured in §3. What could not be
|
||||
derived was a threshold on the wrong quantity. Both failures are written into
|
||||
`delivery.py`'s docstring as well, because the next person to read the card will
|
||||
propose the same predicate.
|
||||
|
||||
## 3. The quantity that does work
|
||||
|
||||
Per unit, for a share that asks it to **discharge**:
|
||||
|
||||
```
|
||||
short_by(i) = share_i − measured_i # watts that did not arrive
|
||||
```
|
||||
|
||||
and the reduction that makes it readable:
|
||||
|
||||
> a unit is **delinquent** while it is short by more than `saturation_w` with
|
||||
> **no account for it**, and the set of units in that state is a **proper
|
||||
> subset** of the units that were asked to discharge.
|
||||
|
||||
### 3.1 The account — the only thing that separates a wedged unit from a cold pack
|
||||
|
||||
§2.1 says there is nothing in the power domain. There is exactly one non-power
|
||||
fact this add-on holds about a pack: **its own state of charge against its own
|
||||
`bms_soc_floor`**, which SAFETY-32 made a required per-entry key. `account()`
|
||||
asks it through **`peakguard.discharge_permitted()`** — public since SAFETY-32
|
||||
precisely so that this question has one answer — and writes its own sentence,
|
||||
because that function's wording names the *peak guard's* floor and would be a
|
||||
false statement here.
|
||||
|
||||
Three things account for a shortfall, and each is a state already being handled:
|
||||
|
||||
| account | why it is not this fault |
|
||||
|---|---|
|
||||
| the unit is not live | `live_units()` has already dropped it from the split and the action log says so — firing inside that is the SAFETY-15/19 failure |
|
||||
| the unit is parked by a sign trip | it is commanded 0 W on purpose; not delivering is compliance |
|
||||
| `discharge_permitted(soc, bms_soc_floor)` | the pack is at or below its own cut-out and physically cannot |
|
||||
|
||||
⚠️ **The floor passed in is `bms_soc_floor` alone, NOT
|
||||
`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`.** That
|
||||
conjunction is right for the peak guard and wrong here: `peak_soc_floor` bounds
|
||||
a money-saving discharge only (SAFETY-25), ordinary self-consumption discharges
|
||||
straight past it, and using it would **explain away** a wedged unit sitting
|
||||
anywhere between the two floors.
|
||||
|
||||
⚠️ **Every unknown accounts for the shortfall**, i.e. fails toward silence —
|
||||
the opposite direction to `discharge_permitted()`'s own caller, and the right
|
||||
one here: there it withholds a discharge, here it withholds an **alarm**. The
|
||||
cost is bounded, because the same absence stops the site through `inputs_ok`
|
||||
(an unreadable SoC) or is refused by the roster schema (an unstated floor).
|
||||
|
||||
### 3.2 The proper subset — what only exists at N ≥ 2
|
||||
|
||||
A derate this add-on cannot see — thermal, grid-code, a DC-side restriction —
|
||||
hits **every** unit that was asked. One box that has stopped listening does not.
|
||||
So if every asked unit is short and unexplained, this module says **nothing**:
|
||||
it has no instrument that tells that from a plant which simply cannot deliver,
|
||||
and reporting it anyway would be the saturation-shaped alarm produced by the
|
||||
guard written to replace it.
|
||||
|
||||
**At N = 1 the subset is never proper, for every input**, so the check is inert
|
||||
there — not by a roster guard, but by the algebra, the same way
|
||||
`circulation.circulating_w()` is identically zero at N ≤ 1. That is AC 5, and
|
||||
§3.3's control arm measures it rather than asserting it.
|
||||
|
||||
### 3.3 The window — the one number this card chose
|
||||
|
||||
**2640 closed-loop runs at a roster of two in which NOTHING is faulty** — every
|
||||
unit executes the share the allocator gave it — over 6 gains × 5 slews, the
|
||||
whole range `config.yaml` accepts for `gain` `float(0.05,1.0)` and `slew_w`
|
||||
`int(0,5000)` (0 = follow the rail), across three families:
|
||||
|
||||
* **1050 load reversals** — 7 sizes (±300 W … ±5000 W) × 5 write staggers;
|
||||
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes;
|
||||
* **750 weighted reversals** — 5 splits (0.5, 0.7, 0.9, 0.99, 0.1) × 5 staggers
|
||||
on a ±2200 W reversal. ⚠️ **This family did not exist in SAFETY-30's campaign
|
||||
and it is the one that dominates here**, for a mechanical reason: an
|
||||
asymmetric split gives one unit a much larger step than its sibling, so the
|
||||
big one is still ramping while the small one has settled — which is precisely
|
||||
the *proper subset* this check keys on.
|
||||
|
||||
Every run is executed a second time at a **roster of one** on the same stimulus.
|
||||
**The N=1 arm is the control**, and it is what separates *"a property of the
|
||||
check"* from *"a property of the second inverter"*.
|
||||
|
||||
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
|
||||
threshold:
|
||||
|
||||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 100 W | 40.0 s (608) | 0.0 s (0) | 75.0 s (576) | **0.0 s (0)** |
|
||||
| 250 W | 55.0 s (490) | 0.0 s (0) | 60.0 s (480) | **0.0 s (0)** |
|
||||
| **500 W** | **20.0 s (206)** | **0.0 s (0)** | **30.0 s (368)** | **0.0 s (0)** |
|
||||
| 700 W | 20.0 s (125) | 0.0 s (0) | 20.0 s (174) | **0.0 s (0)** |
|
||||
| 1000 W | 15.0 s (70) | 0.0 s (0) | 15.0 s (111) | **0.0 s (0)** |
|
||||
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
|
||||
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
|
||||
|
||||
Three findings worth stating rather than leaving in the table:
|
||||
|
||||
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
|
||||
threshold.** That is the compatibility promise measured, not argued.
|
||||
2. **The flapping family is zero everywhere too** — and it is the family that
|
||||
defeated SAFETY-30's site window (980 s at two inverters, 1000 s at one, no
|
||||
window inside the billing quarter surviving it). It cannot touch this check,
|
||||
because a common stimulus makes both units short at the same time and the
|
||||
subset stops being proper. That is the proper-subset rule earning its place
|
||||
on a case it was not designed for.
|
||||
3. **The curve is NOT monotone** — 55.0 s at 250 W against 40.0 s at 100 W —
|
||||
and that is a property of the reduction rather than noise: lowering the
|
||||
threshold puts MORE units into the short set, which can turn a proper subset
|
||||
into the whole roster and **break** the run. Both the numerator and the
|
||||
denominator move. Do not read this table as a sensitivity curve.
|
||||
|
||||
#### The watts side is not a new option — it is `Tuning.saturation_w`
|
||||
|
||||
`main.py` hands the check `self.tuning.saturation_w` (500 W) and there is no
|
||||
`delivery_w` key anywhere. That is a derivation, not a saving:
|
||||
|
||||
* it is already **the gap at which `compute()` itself concludes the inverter is
|
||||
not following** and freezes the integrator. Below it the shipped control law
|
||||
calls the divergence ordinary tracking, and an alarm under a threshold the
|
||||
control law calls normal is an alarm about nothing;
|
||||
* `saturation_cycles` (3) consecutive cycles over that same gap are needed
|
||||
before the freeze engages, so **every cycle this counts is a cycle the freeze
|
||||
has already seen**. This is a reduction over the freeze's input, not a second
|
||||
opinion about it;
|
||||
* CFG-03 already tracks three places a default has to be kept in step. A fourth
|
||||
copy could drift, and the drift that matters has a direction: an operator who
|
||||
raises `saturation_w` has told the loop that a bigger gap is ordinary
|
||||
tracking, and a check that stayed put would then alarm on it.
|
||||
|
||||
**Rejected:** a `delivery_w` option, for the three reasons above. A margin above
|
||||
`saturation_w` (the way `circulation_w` sits 1.75× above its knee) was also
|
||||
rejected: there is no knee here — the table above is not monotone — so a margin
|
||||
would be a preference, and all the margin this check needs is in the window.
|
||||
|
||||
#### `delivery_hold_s: 185` — schema `int(30,900)`
|
||||
|
||||
Four bounds, and the value clears all four.
|
||||
|
||||
* **lower, from the rig — 30.0 s.** The worst continuous legitimate episode at
|
||||
the shipped threshold across all 2640 runs (weighted family; 20.0 s on
|
||||
reversals, 0.0 s on flapping).
|
||||
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
|
||||
executing its share for less than the firmware's own veto window is a *link*
|
||||
fault the add-on already answers by dropping it from the split. Reporting
|
||||
inside that window would be a second, louder alarm about a state already
|
||||
being handled — the SAFETY-15/SAFETY-19 failure exactly.
|
||||
* **margin, from inverter B not existing — and it is MEASURED, not felt.** The
|
||||
campaign's B is A copied, so 30.0 s is two identical machines. Sweeping a
|
||||
*different* second unit through the weighted family (750 runs each):
|
||||
|
||||
| B lag | B ramp | worst legitimate | runs naming |
|
||||
|---:|---:|---:|---:|
|
||||
| 10.0 s (A) | 800 W/s (A) | **30.0 s** | 368/750 |
|
||||
| 20.0 s | 800 W/s | 45.0 s | 469/750 |
|
||||
| 10.0 s | 400 W/s | **30.0 s** | 370/750 |
|
||||
| 20.0 s | 400 W/s | 45.0 s | 469/750 |
|
||||
| 40.0 s | 200 W/s | **90.0 s** | 500/750 |
|
||||
|
||||
**The RAMP does not move it at all and the LAG moves it roughly linearly.**
|
||||
The shortfall lasts as long as the unit's first-order lag holds it more than
|
||||
`saturation_w` below its share; at these step sizes the ramp is never the
|
||||
binding term. So the number to carry margin against is B's lag, and a unit
|
||||
**four times slower than A** produces 90.0 s.
|
||||
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
|
||||
|
||||
**185 s** is thirty-seven control cycles: **6.2×** the measured worst, **2.06×**
|
||||
the four-times-slower case, **6.2×** the firmware veto and **4.9×** inside the
|
||||
quarter. ⚠️ Deliberately **not 180** — that is SAFETY-02's site hold, and a
|
||||
reader must not be able to think this was carried across — and not 150 or 200.
|
||||
|
||||
**What it costs, stated rather than hidden:** up to 185 s of a site running
|
||||
short by one unit's share before anybody is told. Against a fault that ran
|
||||
6 h 25 m unnoticed, and against a guard that takes **no action**, the window
|
||||
buys its margin with reporting latency and nothing else.
|
||||
|
||||
## 4. It reports. It does not act — and the strongest argument is for acting
|
||||
|
||||
Card requirement 3 asks which, and why. The answer is recorded with the
|
||||
evidence **against** it first, because "alarm-only" is the weaker-looking answer.
|
||||
|
||||
**Command it harder — no.** HW-01 settles it: the power stage executed its last
|
||||
instruction for 6 h 25 m through every command this add-on sent. A command is
|
||||
not a remedy for a box that is not listening.
|
||||
|
||||
**Stop the site, as SAFETY-31 does — no**, and the asymmetry with SAFETY-31 is
|
||||
exact. Circulating power is stopped because *this controller is one of the two
|
||||
participants*: removing our half halves the loop and un-hides the residue at a
|
||||
meter that was netting it to zero. Here this controller is **not** a participant,
|
||||
nothing is netting, and the sibling is the only thing still delivering. Parking
|
||||
it takes the site from "short by one unit" to "short by all of them".
|
||||
|
||||
**Re-allocate its share to the sibling — no, and NOT because the loop already
|
||||
does it.** That comfortable answer is false, and the rig says so. Dropping the
|
||||
wedged unit from `live` — the mechanism `live_units()` already uses for a dead
|
||||
link — is a real improvement at loads the site cannot cover on one unit's
|
||||
*share*:
|
||||
|
||||
| B wedged, per-unit cap 2000 W | kept in the split | dropped from the split |
|
||||
|---|---|---|
|
||||
| load 800 W | settles inside 100 W from cycle 6 | from cycle 8 |
|
||||
| load 1500 W | **800 W delivered, 700 W error, NEVER inside 100 W in 150 cycles** | 1500 W delivered, inside 100 W from cycle 14 |
|
||||
| load 2000 W | **800 W delivered, 1200 W error, never settles** | 2000 W delivered, inside 100 W from cycle 8 |
|
||||
|
||||
The mechanism is the saturation freeze doing its job on a site comparison that a
|
||||
non-delivering unit has poisoned: `|site target − site batt|` stays over
|
||||
`saturation_w` forever, so the target cannot rise and the sibling's *share*
|
||||
never grows. That is not three cycles of latency; it is the difference between a
|
||||
site that recovers and one that does not.
|
||||
|
||||
**What refuses it anyway** is that the only re-allocation this add-on can
|
||||
express is **all or nothing**. There is no way to say *"give the sibling the
|
||||
watts that did not arrive and leave this unit the ones that did"*. And a unit
|
||||
delivering **part** of its share is short by more than `saturation_w` too, with
|
||||
nothing here to explain it — so this detector names it, and cannot tell a 50 %
|
||||
derate from a wedged box, because no instrument this add-on has can:
|
||||
|
||||
| B derated to 50 %, load 3000 W, cap 2000 W | site delivers | B contributes | grid error |
|
||||
|---|---:|---:|---:|
|
||||
| kept in the split | 2400 W | 800 W | 600 W |
|
||||
| **dropped** | **2000 W** | **0 W** | **1000 W — 400 W worse** |
|
||||
|
||||
**SAFETY-42 names B in that run.** So the remedy would make the site worse, in a
|
||||
case the detector cannot rule out, on a live control law, driven by a brand-new
|
||||
alarm. A **partial** re-allocation is what would make acting safe, and the
|
||||
allocator has no way to express it today. That is a card, and this section is
|
||||
its evidence.
|
||||
|
||||
`test_delivery.py` part 5 asserts the decision as a **counterfactual** rather
|
||||
than by reading the code: the same fixture run with the report tripping and with
|
||||
it never tripping produces byte-identical grid, site target, shares and measured
|
||||
power on every cycle.
|
||||
|
||||
## 5. Requirement 4: the saturation freeze is untouched
|
||||
|
||||
`control.py` is not modified by this card, at all. The freeze still shapes the
|
||||
loop; this reports on it. `test_delivery.py` part 6 asserts what each
|
||||
neighbouring guard concludes on the same fixture — SAFETY-30 per-unit silent,
|
||||
SAFETY-30 site silent, SAFETY-31 identically zero, the freeze firing at the
|
||||
same counter on **both** arms — which is the honest version of "every existing
|
||||
guard is silent", including the one that is not.
|
||||
|
||||
## 6. Surviving a restart (SAFETY-34)
|
||||
|
||||
`persistent_notification` is **in memory**. Home Assistant restarts, the card is
|
||||
gone, and the fault runs on — precisely how the 2026-09-02 outage went unseen
|
||||
for 6 h 25 m with the detector correct throughout. It matters more here than
|
||||
anywhere else in this add-on, because SAFETY-02 and SAFETY-31 at least leave a
|
||||
**stopped** site for somebody to notice; this one leaves a site that looks like
|
||||
it is working.
|
||||
|
||||
* durable half: `unit/<name>/delivery_tripped` / `delivery_reason`, namespaced
|
||||
per unit for `store.unit_key()`'s reason, written **reason first**.
|
||||
* visible half: `_delivery_announced`, a per-**process** set of unit names,
|
||||
deliberately not persisted. A restart empties the set and keeps the latch, so
|
||||
the alarm is re-raised on the first cycle of every process — **one code path**
|
||||
for a fresh trip and for a restart into an existing one, `check_circulating()`'s
|
||||
argument.
|
||||
* **no flat mirror**, where SAFETY-02 has one. `store.py`'s rule 2 keeps the flat
|
||||
keys so a rollback still finds its latch, and the version being rolled back to
|
||||
drives exactly one actuator — a roster at which this check is inert.
|
||||
* one **notification id per unit**, built from the same `unit_slugs()` TEL-07
|
||||
announces the MQTT entities under. Two wedged inverters must produce two cards,
|
||||
not one replacing the other.
|
||||
* it **latches**, and not for SAFETY-02's reason. Nothing here removes its own
|
||||
evidence, because nothing here changes what is commanded. It latches so that a
|
||||
unit which stopped delivering for minutes and then resumed cannot erase its own
|
||||
record — the intermittent fault is the one an operator most needs told about.
|
||||
Cleared by the same **Start** press as the others.
|
||||
* the status row is `ok: False, warn: True` — this list's **caution**. Every
|
||||
other latched row means the site is at 0 W and a red cross is the truth; this
|
||||
one means the site is still running and quietly short, and a red cross would
|
||||
say the controller had stopped.
|
||||
|
||||
## 7. What this does NOT catch, said plainly
|
||||
|
||||
* **Charge shares are not graded.** The account gate needs a bound this
|
||||
controller reads in the commanded direction. There is one for discharge
|
||||
(`bms_soc_floor`, required of every roster entry since SAFETY-32) and **none**
|
||||
for charge. A pack in constant-voltage taper legitimately takes hundreds of
|
||||
watts less than it is told for tens of minutes, and arming that direction would
|
||||
fire on the taper of every §12 maintenance charge. The same fault in the other
|
||||
direction wants the same treatment; what it needs first is a per-pack charge
|
||||
ceiling this add-on does not have. **That is a card.**
|
||||
* **Every unit wedged at once is not caught.** The subset is not proper, and
|
||||
there is no instrument here that separates that from a plant which simply
|
||||
cannot deliver. The site is short at the meter in that case, which is where the
|
||||
existing guards can see it.
|
||||
* **A pack that refuses above its configured floor is reported.** That is
|
||||
deliberate: either the unit has stopped delivering, or `bms_soc_floor` does not
|
||||
describe that battery. The notification says both, in those words.
|
||||
* **A unit with a share of 0 W is not graded**, and neither is it a peer. With
|
||||
only one unit asked to discharge there is nothing to compare against.
|
||||
|
||||
## 8. Mutation ledger
|
||||
|
||||
Every run on a **freshly copied tree of its own, `__pycache__` purged and the
|
||||
count printed, mounted `:ro`, `python3 -B`** — MUTATION.md's rule, followed
|
||||
because the first SAFETY-30 battery reused one directory, a container mounted it
|
||||
mid-copy, read a missing `config.yaml` as a failing check, and the whole run was
|
||||
discarded. **The baseline is green** (23/23) or nothing below is a result. Byte
|
||||
deltas measured, not estimated; the `+0 B` ones are marked, since a
|
||||
size-preserving edit is what TEST-15 turned into a false survivor once already.
|
||||
|
||||
| # | file | mutation | Δ | result |
|
||||
|---|---|---|---|---|
|
||||
| M1 | `delivery.py` | the SoC account removed — a cold pack at its floor alarms | −31 B | **RED** — `test_delivery.py`. This is the card |
|
||||
| M2 | `delivery.py` | the proper-subset rule removed — a whole-site cause names every unit | −34 B | **RED** |
|
||||
| **M3** | `delivery.py` | **the `len(asked) < 2` fast path relaxed to `< 1`** | **+0 B** | **SURVIVOR — and an EQUIVALENT MUTANT.** See below |
|
||||
| M4 | `delivery.py` | the strict bound relaxed to `>=` — a gap the control law calls tracking is named | +1 B | **RED** |
|
||||
| **M5** | `delivery.py` | `share <= 0` relaxed to `share == 0` — charge shares graded | **+0 B** | **SURVIVOR on the first battery — GAP CLOSED**, then **RED**. See below |
|
||||
| M6 | `delivery.py` | `delivery_hold_s` 185 → a round 300 | **+0 B** | **RED** — the CFG-03 pin |
|
||||
| M7 | `main.py` | the watts threshold no longer `Tuning.saturation_w` (×4) | +6 B | **RED** |
|
||||
| M8 | `main.py` | the latch never persisted — nothing survives a restart | −35 B | **RED** |
|
||||
| M9 | `main.py` | `_delivery_announced` never set — one notification per cycle | −34 B | **RED** |
|
||||
| M10 | `main.py` | `peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor` used instead of the pack's own cut-out | +151 B | **RED** — the between-the-floors fixture |
|
||||
| **M11** | `main.py` | **`inv.is_live(now)[0]` replaced by a literal `True`** | −15 B | **SURVIVOR at review — GAP CLOSED**, then **RED**. See below |
|
||||
| M12 | `inverter.py` | the latch not restored from the store on start-up | −46 B | **RED** |
|
||||
|
||||
**11 of 12 red. One survivor, argued rather than left.**
|
||||
|
||||
### M5 was a real gap, and the two obvious checks did not close it
|
||||
|
||||
`test_delivery.py` already asserted `short_by(-2000, 0) is None` and
|
||||
`short_by(-2000, -100) is None` — and **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 those two inputs cannot separate
|
||||
`share <= 0` from `share == 0`. The one input 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.
|
||||
The check now uses that input and the mutant is red.
|
||||
|
||||
This is 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 was a wiring gap the pure checks structurally could not see
|
||||
|
||||
`account(live=False, …)` was already graded — as a **function**. What was not
|
||||
graded was that `cycle()` actually *asks* `inv.is_live(now)`, and replacing that
|
||||
call with a literal `True` left every check green, because **no fixture had a
|
||||
unit whose writes were being rejected**. A dropped 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 already being handled that this module takes
|
||||
its 30 s floor from. `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) and the mutant is red.
|
||||
|
||||
### M3 is recorded as a survivor because it is one — and it is EQUIVALENT
|
||||
|
||||
`len(asked) < 2` → `len(asked) < 1`. 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 `{}`.
|
||||
There is no third case, and with **zero** asked units both spellings return `{}`
|
||||
as well. **No input separates them**, so no check can, and one written to try
|
||||
would be a check about nothing.
|
||||
|
||||
⚠️ **And finding it corrected the module's own docstring, which is the more
|
||||
useful half.** That paragraph claimed `len(asked) < 2` was *what makes N=1
|
||||
inert*. It is not: the **proper-subset rule** is, and the 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 — it tells a reader at a glance that a one-inverter
|
||||
install never reaches any of this — with the equivalence written down beside it,
|
||||
the way `circulation.circulating_w()` records its own `max(0.0, …)`.
|
||||
|
||||
### Not separately mutated, and said rather than left implicit
|
||||
|
||||
`account()`'s `parked` branch is graded as a function (part 1) but its **wiring**
|
||||
— `parked=inv.sign_wd.tripped` — is not mutated on its own. Part 6's reversed-unit
|
||||
fixture exercises the path end to end and asserts the account it produces, which
|
||||
is weaker than a mutation and is stated as such.
|
||||
|
||||
## 9. Round 2 — the thresholds this check reads, and which of them are pinned at a **changed** config
|
||||
|
||||
Everything in §8 mutated the *logic*. This section asks a narrower question that
|
||||
§8 never asked, and it found two live survivors of the whole 23-file suite:
|
||||
|
||||
> for each number `main.py` hands this reduction, is the check still red when
|
||||
> that number is replaced by a **literal equal to the value the fixtures use**?
|
||||
|
||||
That is not pedantry about style. Two of these numbers are **options**, and
|
||||
`delivery.MIN_W_IS_SATURATION_W` and the `delivery_hold_s` block in `config.yaml`
|
||||
both make a promise about them *in shipped, operator-readable prose*: there is no
|
||||
fourth copy of a default to keep in step, and no way for the two to drift,
|
||||
because an operator who raises `saturation_w` has told the loop that a bigger gap
|
||||
is ordinary tracking. A literal at the call site keeps every fixture green and
|
||||
removes exactly that promise — and the promise is why the design was chosen over
|
||||
a fourth option key.
|
||||
|
||||
### The enumeration
|
||||
|
||||
| # | what the call site reads | mutated to | Δ | before round 2 | now |
|
||||
|---|---|---|---|---|---|
|
||||
| R5 | `self.tuning.saturation_w` | `250.0` | −19 B | **SURVIVED all 23 suites** | red — part 4b(a) |
|
||||
| R6 | `bms_floor=inv.bms_soc_floor` | `10.0` | −13 B | **SURVIVED all 23 suites** | red — part 4b(b) |
|
||||
| R7 | `arm(self.delivery_cfg)` | `arm(DeliveryConfig())` | −1 B | red | red — part 4 already ran at a hold of 1 s, not the shipped 185 s |
|
||||
| R8 | `inv.is_live(now)[0]` | `inv.is_live(now, 3600.0)[0]` | +8 B | red | red — part 6's link-dead fixture |
|
||||
| R9 | `bms_floor=inv.bms_soc_floor` | the **conjoined** floor, `max(pack cut-out, site peak_soc_floor)` | +105 B | red | red — and now red **twice**: the pre-existing between-floors check at shipped floors, and part 4b(b)'s shallow arm at a cut-out that is not shipped |
|
||||
|
||||
**R7 and R8 were already pinned across a changed setting, and R5 and R6 were
|
||||
not.** The difference is entirely in the fixtures, not in the code:
|
||||
`delivery_hold_s` had to be moved off 185 s for any of this file to run inside a
|
||||
two-second test, and part 6 had to back-date `rejected_since` past
|
||||
`LINK_STALE_S` to build a dropped unit at all. Nothing ever forced
|
||||
`saturation_w` off 500 or a `bms_soc_floor` off 10 — so those two sat at the
|
||||
shipped value in every fixture, and a literal equal to the shipped value is
|
||||
invisible to a fixture that only ever uses the shipped value. **That is this
|
||||
repository's recurring defect stated in one line: a fixture whose values cannot
|
||||
reach the failure it names.**
|
||||
|
||||
`LINK_STALE_S` (R8) is the one number here with **no option behind it** — it is a
|
||||
module constant in `inverter.py` and no test reassigns it — so "pinned across a
|
||||
changed config" is not available for it, and is not claimed. What part 6 pins is
|
||||
that *widening* the window changes the answer. Spelling `is_live(now, 30.0)` at
|
||||
the call site would be an equivalent mutant today and would only drift if the
|
||||
constant moved, which is a source change and not a config change.
|
||||
|
||||
### Why R5 and R6 are not equivalent mutants — the inputs that separate them
|
||||
|
||||
Both are separating inputs found by asking where the shipped answer and the
|
||||
literal's answer can differ, and both are configs the shipped schema accepts.
|
||||
|
||||
**R5, watts.** Two copies of inverter A, B wedged, `saturation_w: 1800` (inside
|
||||
its own schema `int(100,2000)`), load 1600 W. B's standing share and its
|
||||
shortfall then land strictly **between** `250.0` and the configured 1800 W:
|
||||
|
||||
* head → `named = {}` — the operator raised the threshold and this check moved
|
||||
with them, which is the whole promise;
|
||||
* R5 → `named = {inverter-b}` — an alarm at a gap the control law has been told
|
||||
is ordinary tracking.
|
||||
|
||||
At the shipped `saturation_w: 500` **both** name it, which is why every fixture
|
||||
in part 4 is blind: their gaps are all above both thresholds.
|
||||
|
||||
**R6, percent.** The literal is invisible because `bms_soc_floor: 10` is what
|
||||
`config.yaml`'s own `inverters:` example states and what every fixture in
|
||||
`test_delivery.py` used. Two arms, on either side of it, both inside the schema
|
||||
`int(0,50)` and both `validate.py` rule-8-valid:
|
||||
|
||||
* a pack at **15 %** against its own **25 %** cut-out is genuinely cold. Head
|
||||
stays silent; R6 grades it against 10 %, finds it "permitted", and **names an
|
||||
empty pack** — the crying-wolf failure this card exists to remove.
|
||||
* a pack at **8 %** against its own **5 %** cut-out is genuinely wedged. Head
|
||||
names it; R6 grades it against 10 %, calls it a cold pack, and **loses the
|
||||
fault**.
|
||||
|
||||
One arm on each side is what leaves no constant at all: a single deeper arm
|
||||
kills `10.0` but not `50.0`, a single shallower arm kills `10.0` but not `0.0`.
|
||||
The two arms are byte-identical in the power domain on every cycle — the
|
||||
configured cut-out is the only thing that differs, which is §3.1's claim made
|
||||
again at cut-outs that are not the shipped one.
|
||||
|
||||
### What is still not pinned this way, said rather than left implicit
|
||||
|
||||
`parked=inv.sign_wd.tripped` and `standing[inv.name]`/`inv.batt` are wiring, not
|
||||
thresholds: there is no "value the fixture happens to use" for a boolean or for a
|
||||
measurement, so this question does not apply to them, and §8's last note already
|
||||
records that the `parked` wiring is graded end-to-end rather than by mutation.
|
||||
@@ -0,0 +1,115 @@
|
||||
# TEL-07 — why inverter A does not get a device of its own, and B does
|
||||
|
||||
**Status: DECIDED 2026-09-05.** MQTT discovery announces **two** devices at a
|
||||
roster of two: the existing one, unchanged, plus one for the second inverter.
|
||||
It does **not** announce one per inverter. This document exists because the
|
||||
symmetrical design is the obvious one, is tidier, and would destroy the live
|
||||
system's recorded history — so the next person to read `app/mqtt.py` needs the
|
||||
reason written down where they will find it before they "fix" it.
|
||||
|
||||
Filed with TEL-07. Card `6a9bfa75f0a3550275ba4888`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The mechanism, in one paragraph
|
||||
|
||||
`app/mqtt.py`'s own note records the finding, tested on HA 2026.8: Home
|
||||
Assistant composes an entity id from the **device name plus the entity name**,
|
||||
and `object_id` in the discovery payload does **not** override it. So the device
|
||||
name is half of every entity id.
|
||||
|
||||
The second half of the mechanism is the half that makes it silent. Home
|
||||
Assistant keeps the entity id it composed at **first** discovery. Re-scoping the
|
||||
device therefore does not rename an entity in place; it creates a **new** entity
|
||||
beside the old one. The recorded history stays on the old id, the old id stops
|
||||
updating, and nothing errors. Every dashboard card, template and automation that
|
||||
names it — including `goodwe_master_watchdog_held_*` and
|
||||
`goodwe_master_parked_at_0_w_*`, which are live — keeps pointing at a sensor
|
||||
that has quietly gone flat.
|
||||
|
||||
`sensor.goodwe_p1_sample_age` is the sharp end of it: the ESP32's stale-input
|
||||
watchdog subscribes to that composed name, and its own comment calls this the
|
||||
failure that "FAILS SILENTLY AND LOOKS CORRECT".
|
||||
|
||||
## 2. The two designs
|
||||
|
||||
| | **one device per inverter** | **what shipped** |
|
||||
|---|---|---|
|
||||
| A's device | new: `inverter-a` | **the one it already has** |
|
||||
| B's device | new: `inverter-b` | new: `goodwe_rs485_controller_inverter_b` |
|
||||
| A's existing entity ids | **all renamed** | **byte-identical** |
|
||||
| A's recorded history | **stranded on dead ids** | kept |
|
||||
| watchdog automations | **silently broken** | untouched |
|
||||
| composed per-unit ids | `sensor.goodwe_inverter_a_*` / `..._b_*` | **the same** |
|
||||
| reads as | symmetrical and tidy | asymmetrical, and needs this file |
|
||||
|
||||
The last two rows are the whole argument. The tidy design's only real benefit is
|
||||
a pair of symmetric, predictable entity ids — and the shipped design produces
|
||||
**exactly the same ids** by putting the unit's name in the *entity* name for the
|
||||
first roster entry and in the *device* name for the others. There is nothing
|
||||
left to buy, and the price was the live system's history.
|
||||
|
||||
## 3. What is per-unit and what is site
|
||||
|
||||
**Per-unit:** setpoint (this unit's share of the site target, the number its own
|
||||
heartbeat re-emits), battery power (`Inverter.batt`, register `0x0518`, this
|
||||
unit's own reading), SoC (`Inverter.soc` — **this** pack; the site row is the
|
||||
*emptiest* pack, which at N≥2 may be the other one), and the SAFETY-02 sign
|
||||
latch, which is one instance per actuator by construction.
|
||||
|
||||
**Site:** `grid` and `p1_sample_age_s`. There is one P1 meter and one grid
|
||||
connection, so a per-inverter copy invents two of a thing there is one of — and
|
||||
`p1_sample_age_s` is a **watchdog input**, so a second copy is a second answer to
|
||||
"is the meter fresh?", one of which is always the one nobody is looking at.
|
||||
Circulating power is site because it *is* a property of the set
|
||||
(`sum(|Bᵢ|) − |sum(Bᵢ)|`); so are the controller status, the maintenance phase
|
||||
and the capacity-tariff peak.
|
||||
|
||||
**Named as absent rather than left looking finished:** the card also lists
|
||||
"inverter AC power" and per-unit heartbeats as per-unit. Neither has a source in
|
||||
the add-on today — nothing reads an AC-power register per unit, and
|
||||
`Inverter.last_beat` is a monotonic loop clock with no wall-clock age published
|
||||
anywhere. Announcing them would create entities nothing ever feeds, which is the
|
||||
hazard the `omit` note in `mqtt.py` exists to refuse. They are gaps.
|
||||
|
||||
## 4. Why a roster of one announces nothing
|
||||
|
||||
Every install running today is a roster of one, and at N=1 the site rows **are**
|
||||
that unit's rows: `battery` is its power, `soc` is its pack. Four extra entities
|
||||
would be four new rows carrying numbers already on the dashboard, on a live
|
||||
system, for no information. `announced_units()` answers `[]` below
|
||||
`PER_UNIT_MIN_ROSTER`, and `Controller.publish()` reads **that same list** —
|
||||
the empty list is the gate on both halves, rather than a predicate written twice
|
||||
that could later disagree.
|
||||
|
||||
## 5. How it was verified, and what that does not cover
|
||||
|
||||
The claim is a **diff of the announced payloads** against `release/1.0`,
|
||||
generated by running that commit's module rather than transcribed from it:
|
||||
`9 topics, 0 missing, 0 changed, 0 added` at a roster of one, and
|
||||
`0 missing, 0 changed, 8 added` at a roster of two. `test_mqtt.py` part 2 shows
|
||||
`_announce()` publishes exactly what is diffed, so the comparison is against the
|
||||
bytes paho is handed.
|
||||
|
||||
Sixteen mutations were run. Twelve are in the file as a running table, each
|
||||
named with the property it breaks. Three properties in that table exist only
|
||||
because the table found their absence — dropping the unit from the state topic,
|
||||
moving `grid` into the per-unit list, and `units=()` in main()'s publisher call
|
||||
were all green on the first run, as was a roster fallback that named two
|
||||
nameless entries `inverter-a`.
|
||||
|
||||
**What it does not cover is listed in `test_mqtt.py` part 9, with sizes.** The
|
||||
largest item: Home Assistant itself is not in the loop. The entity-id
|
||||
composition is a **model**, anchored on exactly one measured id
|
||||
(`sensor.goodwe_p1_sample_age`, verified against the live system 2026-08-26).
|
||||
Closing that needs a broker, an HA and the owner present, and this card is not
|
||||
allowed near the live install.
|
||||
|
||||
## 6. ⚠️ Inverter B does not exist
|
||||
|
||||
Not until ~2026-09-12. Every roster of two in this repository's checks is
|
||||
**simulated**; B's `max_w: 4600` is the datasheet's figure for a GW5048D-ES
|
||||
(serial `55048ESU22AW0992`), recorded in `goodwe/NOTES.md` §1.1b, and nothing
|
||||
about B has ever been measured. No entity described here has been seen on a
|
||||
broker. When the second inverter is installed, the first thing to check is that
|
||||
the eight ids in §1 are still updating — not that the new ones appeared.
|
||||
+238
-2
@@ -166,6 +166,53 @@ in the log rather than silent, but it is an outage until someone puts the
|
||||
single-inverter entity back in that field. **Before downgrading, write down the
|
||||
`setpoint_entity` of the unit you want to keep running.**
|
||||
|
||||
### What appears in Home Assistant at two inverters
|
||||
|
||||
**Nothing you already have changes.** The eight entities this add-on has always
|
||||
published — `Setpoint`, `Grid power`, `Battery power`, `Battery SoC`,
|
||||
`Circulating power`, `Maintenance phase`, `Controller status`, `P1 sample age` —
|
||||
keep their device, their `unique_id`s and therefore their **entity ids and their
|
||||
whole recorded history**. At a roster of one, nothing new is announced at all:
|
||||
no second device, no renamed entity, not one added topic.
|
||||
|
||||
Declare a second inverter and **four rows per unit** appear beside them:
|
||||
|
||||
| per-unit row | what it is |
|
||||
|---|---|
|
||||
| `Setpoint` | this unit's own share of the site target — the number its heartbeat re-emits |
|
||||
| `Battery power` | this unit's own reading, `+` = discharging |
|
||||
| `Battery SoC` | **this** pack. The site `Battery SoC` row is the *emptiest* pack, which may be the other one |
|
||||
| `Sign latch` | this unit's SAFETY-02 latch: `tripped` or `clear` |
|
||||
|
||||
⚠️ **The devices are deliberately asymmetric, and it is not an oversight.** The
|
||||
first inverter's rows are added to the **existing** *GoodWe* device; every later
|
||||
inverter gets its **own** device (*GoodWe Inverter B*), shown in Home Assistant
|
||||
as connected via the controller. Home Assistant composes an entity id from the
|
||||
**device name plus the entity name**, so giving the first inverter a device of
|
||||
its own would rename every entity you already have — and because HA keeps the id
|
||||
it composed at first discovery, you would get a new entity beside a dead one:
|
||||
the history stays on the old id and every dashboard, template and automation
|
||||
naming it silently stops updating. The entity ids come out symmetric anyway —
|
||||
`sensor.goodwe_inverter_a_battery_power` beside
|
||||
`sensor.goodwe_inverter_b_battery_power`.
|
||||
|
||||
**Do not rename the device.** Not in this add-on, and not in Home Assistant's
|
||||
device page. It is half of every entity id, including
|
||||
`sensor.goodwe_p1_sample_age`, which the ESP32's stale-input watchdog subscribes
|
||||
to; a rename disarms that guard by leaving it waiting for a state that never
|
||||
arrives, and it fails silently and looks correct.
|
||||
|
||||
**`Grid power` and `P1 sample age` stay site-scoped and are announced once.**
|
||||
There is one P1 meter and one grid connection; a per-inverter copy would invent
|
||||
two of a thing there is one of, and `P1 sample age` is a safety input. Same for
|
||||
`Circulating power` (it is a property of the *set* of inverters), the controller
|
||||
status, the maintenance phase and the capacity-tariff peak.
|
||||
|
||||
⚠️ **Two roster names that differ only in punctuation or case** — `inverter-b`
|
||||
and `Inverter B` — are two names to the validator and one id once slugged. The
|
||||
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
|
||||
get then depend on roster order. Give your inverters plainly different names.
|
||||
|
||||
### P1 meter ingestion
|
||||
|
||||
`meter_entity` above expects one signed sensor, which usually means a template
|
||||
@@ -248,6 +295,42 @@ the P1 sample already went stale. Size the pair together: the ESP32's own
|
||||
watchdog commands 0 W after ~30 s of silence from this add-on regardless, and
|
||||
that layer is unaffected by either option.
|
||||
|
||||
#### How long a FROZEN inverter power register takes to reach 0 W
|
||||
|
||||
A third clock, and it is a different failure from the one above: the entity is
|
||||
still there and still answering — it has simply stopped changing. Before
|
||||
SAFETY-37 that read as a healthy measurement for ever, which is what happened on
|
||||
2026-09-02 (HW-01): a value held for 6 h 25 m while the pack drained to its
|
||||
floor, and every detector read healthy throughout.
|
||||
|
||||
`Inverter.batt` — the inverter's own AC power, register `0x0518` — is now
|
||||
withheld from the control path when **all three** of these are true:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the reading has not changed for | **35 s** |
|
||||
| **and** the command standing on that inverter is over | **250 W** |
|
||||
| **and** that command has travelled, since the reading last moved, over | **250 W** |
|
||||
|
||||
All three, because a converged site legitimately repeats one power figure for
|
||||
minutes — measured at up to **263 s** over 30 days of this house's own meter —
|
||||
and a site at rest is not owed movement. It is only when the controller has
|
||||
*asked the inverter to move* and the register has not answered that the reading
|
||||
is treated as missing.
|
||||
|
||||
**A withheld reading is a MISSING INPUT**, so it joins the `stale_input_s` clock
|
||||
above: 35 s to withhold, then 15 s of hold, then 0 W. It clears itself the
|
||||
instant the register reports a different value — one watt is enough, and this
|
||||
register dithers ±1 W while holding — so a working inverter clears it on its
|
||||
next 10 s publish. There is **no option to configure**: the 35 s is derived from
|
||||
the ESPHome publish interval and the two watchdog windows it has to stay inside,
|
||||
and the add-on's own checks refuse a value that does not.
|
||||
|
||||
⚠️ **What this does NOT catch: a register whose value is WRONG rather than
|
||||
STOPPED**, including a dead publisher whose last two samples happen to
|
||||
alternate. Detecting that needs an arrival stamp inside the ESP32 firmware
|
||||
itself, which is TEL-06.
|
||||
|
||||
There is **no fallback to an inverter-side power figure**, deliberately. The
|
||||
inverter's own AC power tracks its battery almost perfectly and the real meter
|
||||
hardly at all, so a controller that failed over to it would be regulating
|
||||
@@ -385,12 +468,12 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
|---|---|---|
|
||||
| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning. **Not read when `inverters` is declared** — each entry carries its own, and the site ceiling is their sum |
|
||||
| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** |
|
||||
| `slew_w` | 1000 | Maximum change per cycle |
|
||||
| `slew_w` | 0 | Largest change in one cycle. **0 means follow the rail** - `max_w / 9`, i.e. the loop takes the plant's own measured ~18 s settling time to cross its whole output range at the 2 s tick the live install runs. 222 W at `max_w: 2000`, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. A fixed number is accepted and never silently reduced, but one FASTER than the rail implies is refused at startup (SAFETY-39): `slew_w: 1000` against `max_w: 5000` crossed the rail in 10 s against an 18 s plant, and on 2026-09-05 drove the meter from 3.4 kW of export to 2.8 kW of import |
|
||||
| `deadband_w` | 15 | Ignore errors smaller than this |
|
||||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||||
| `step_w` | 10 | Quantisation |
|
||||
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
||||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1** |
|
||||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1**. Every value this file accepts (1-10) is below the freeze's own release bound of 12 cycles, so no accepted setting can stop the block engaging - see SAFETY-13, below |
|
||||
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
|
||||
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
|
||||
| `cycle_floor_s` | 15 | The longest the loop may go without evaluating. A deadline on the gap between cycles, reset by every cycle — on a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than ~10 s; it must stay under `meter_max_age_s`, past which there is no reading to evaluate anyway |
|
||||
@@ -402,6 +485,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
| `unit_sign_disagree_s` | 85 | **Two or more inverters only.** How long one unit's command and its own power must stay opposed before that unit is parked |
|
||||
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
|
||||
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
|
||||
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
|
||||
|
||||
#### The sign-disagreement failsafe
|
||||
|
||||
@@ -501,6 +585,70 @@ agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
|
||||
10.5 % and 0.0 %. There is no reason to move either value without a capture that
|
||||
says so.
|
||||
|
||||
|
||||
#### ⚠️ A unit that stops DELIVERING is a third fault again (SAFETY-42)
|
||||
|
||||
The two checks above both key on **direction**. A unit sitting at **0 W against
|
||||
a non-zero share** has no direction: it is not pushing the wrong way, it is
|
||||
**absent**. So the per-unit sign gate never opens (it needs opposition on both
|
||||
sides of `unit_sign_disagree_w`), the site sum still agrees with the meter in
|
||||
direction, and circulating power is exactly **zero** — that quantity needs two
|
||||
units pulling *against* each other, and one of these is pulling nowhere.
|
||||
|
||||
The only thing that does fire is the **saturation freeze**, and this add-on's own
|
||||
code calls that an alarm operators learn to ignore, because a loop can be legally
|
||||
saturated all day: a cold pack, a curtailed inverter, an export limit.
|
||||
|
||||
So `delivery_hold_s` grades a different quantity — **how many watts of a
|
||||
discharge share did not arrive** — and makes it readable with a reduction that
|
||||
only exists once there is more than one inverter:
|
||||
|
||||
> a unit is named while it is short of its share by more than `saturation_w`,
|
||||
> **with nothing this add-on can read to explain it**, and the units in that
|
||||
> state are **fewer than the units that were asked to discharge**.
|
||||
|
||||
Both halves matter, and the second is why this cannot fire on a
|
||||
one-inverter install at all:
|
||||
|
||||
- **"nothing explains it"** is what keeps a legitimately empty pack out. A pack
|
||||
at or below its own `bms_soc_floor` cannot discharge, and that is the *only*
|
||||
fact this add-on holds that distinguishes an empty pack from a wedged one —
|
||||
the two are otherwise identical at the unit and at the meter, watt for watt.
|
||||
A unit the add-on has already dropped from the split, or parked by a sign
|
||||
trip, is likewise accounted for.
|
||||
- **"fewer than were asked"** is what keeps a whole-site cause out. A derate this
|
||||
add-on cannot see — thermal, grid-code, DC-side — hits every unit. One box that
|
||||
has stopped listening does not. If *every* unit that was asked is short and
|
||||
unexplained, nothing is reported: there is no instrument here that tells that
|
||||
from a plant which simply cannot deliver.
|
||||
|
||||
**It reports. It does not act.** Commanding an inverter that has stopped
|
||||
listening achieves nothing — that happened on this hardware once for 6 h 25 m —
|
||||
and the only re-allocation this add-on can express is all-or-nothing, which
|
||||
would throw away the output of a unit delivering *part* of its share. So the
|
||||
loop is untouched, the sibling keeps regulating, and the site keeps running
|
||||
**short by that unit's share** until somebody looks. The status page shows it as
|
||||
a caution rather than a failure for exactly that reason: the controller has not
|
||||
stopped.
|
||||
|
||||
The report **survives a Home Assistant restart** (`unit/<name>/delivery_tripped`
|
||||
in the add-on's own state file) and the notification is re-raised on the next
|
||||
start, because a `persistent_notification` is in memory and that is precisely how
|
||||
the 2026-09-02 outage went unseen. It is cleared by pressing **Start**, like the
|
||||
other latches.
|
||||
|
||||
The banner reads **`NOT DELIVERING — <unit> …`** as a *warning*, not a failure,
|
||||
and it **displaces** `Running — holding grid at zero`. That last sentence is a
|
||||
claim the site cannot make while a unit is absent, and a page that goes on
|
||||
reading healthy while the fault runs is the whole shape this check exists to
|
||||
remove.
|
||||
|
||||
**Charge shares are not graded**, and that is a stated gap rather than an
|
||||
oversight: there is no configured charge ceiling anywhere in this add-on, so a
|
||||
pack in constant-voltage taper — which legitimately takes hundreds of watts less
|
||||
than it is told, for tens of minutes — could not be explained, and arming that
|
||||
direction would fire on the taper of every maintenance charge.
|
||||
|
||||
#### Saturation is counted in cycles, not seconds
|
||||
|
||||
The specification states the saturation window as **"> 10 s"**. This add-on counts
|
||||
@@ -545,6 +693,94 @@ correct toward that wrong value instead of holding a stale command. It is bounde
|
||||
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
|
||||
safety property.
|
||||
|
||||
#### When the freeze lets go, and when it deliberately never does (SAFETY-13)
|
||||
|
||||
While the loop is frozen the command's **magnitude may fall but never rise**. That is
|
||||
the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with
|
||||
nothing commanded there is no magnitude left to fall, so a charge cannot begin. That
|
||||
refusal is deliberate. Commanding 0 W while the inverter reports hundreds of watts
|
||||
means something is driving the actuator that this add-on did not ask, and starting to
|
||||
push power the other way into it is how two controllers end up fighting over one
|
||||
actuator — the configuration §1.5 of the specification rules out.
|
||||
|
||||
**What was missing was an end to it.** Nothing cleared the block: not the error growing,
|
||||
not the integrator moving, not time. The exit is now explicit, and *which* freezes get
|
||||
one is the decision:
|
||||
|
||||
| the inverter is… | can it clear itself? | what happens now |
|
||||
|---|---|---|
|
||||
| doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap | **yes.** The command is pinned at something the inverter can reach, so it catches up and the freeze clears | unchanged: frozen for as long as it lasts, with no exit |
|
||||
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **not necessarily.** If another writer is on the actuator, clearing the freeze needs the inverter to track *our* command and the other writer is what stops it, so nothing ends it | the freeze releases once the divergence has outlasted the plant that could otherwise have explained it — see *what `freeze-released` actually tells you*, below, because this row is also where our own ramp lands |
|
||||
|
||||
The second row's threshold is the plant's own measured settling time — **the slower of
|
||||
the two directions it was measured in.** The 2026-08-22 step test timed the same 1.78 kW
|
||||
step both ways: about 18 s to settle going up, about 23 s coming back down. 23 s is the
|
||||
one that belongs here, because the question this bound answers is *how long may the plant
|
||||
still be the explanation*, and the safe answer is to keep believing the plant for as long
|
||||
as the plant has ever been seen to take. At the measured 2 s meter tick that is
|
||||
**12 cycles** (`control.py`, `SETTLE_CYCLES`).
|
||||
|
||||
⚠️ **The first version of this feature used 18 s, and 9 cycles was wrong in the direction
|
||||
that accuses.** Nine cycles is three short of the plant's own down-ramp, so an ordinary
|
||||
load step on a **single** inverter — the command falling through zero while the pack is
|
||||
still coasting the old way — could produce `freeze-released` with nothing else on the bus
|
||||
at all. Driven in the container against a model of the measured plant, one inverter and
|
||||
no second writer anywhere: over 272 load-step pairs the 9-cycle bound produced a release
|
||||
in 62 of them and the shipped 12-cycle bound in 2.
|
||||
|
||||
**Releasing the output freeze does not unfreeze the loop.** The *integrator* freeze is
|
||||
untouched, so the command is free to move only as far as an accumulator that stopped
|
||||
accumulating cycles ago, one slew step per cycle. Measured on the simulator at the live
|
||||
install's shape (`max_w 5000`, `slew_w 400`, `target_grid_w -10`), with another writer
|
||||
holding the pack at +700 W and 2 kW of export standing: the command holds at 0 W while
|
||||
the counter climbs, starts on the **twelfth** saturated cycle, walks 0 → −400 → −800, and
|
||||
settles at **−1190 W** against a frozen integrator of −1194 W — not at the 5000 W rail.
|
||||
Before this change the same run held 0.0 W on every cycle, for as long as the other
|
||||
writer stayed.
|
||||
|
||||
⚠️ **The site still does not reach its target in that state, and it is not supposed
|
||||
to.** What the exit buys is that the battery stops being idle while the site exports;
|
||||
what it does not buy is correct regulation against an actuator somebody else is also
|
||||
writing to. A standing grid error with another writer on the bus is the symptom of a
|
||||
configuration this add-on refuses to support, not a tuning problem.
|
||||
|
||||
#### What `freeze-released` in the action log actually tells you
|
||||
|
||||
**It says: the inverter's power did not match the command for longer than this plant has
|
||||
ever been measured to take to settle.** That is all it says. It is a reason to go
|
||||
looking; it is not a finding, and in particular it is **not** proof that a second writer
|
||||
exists.
|
||||
|
||||
The test behind it compares the reading against **one cycle's** command, and the pack
|
||||
carries more than one cycle of history. So our own power, arriving late, can look like
|
||||
somebody else's:
|
||||
|
||||
- **A big, fast load step is the ordinary innocent cause.** The command falls through
|
||||
zero while the pack is still coasting in the old direction, and for a few cycles the
|
||||
reading has the sign the command *used* to have. Measured, one inverter, nothing else
|
||||
on the bus: eight cycles after a +1990 W command the command was 0 and the pack still
|
||||
read +522.3 W.
|
||||
- **A plant slower than the one that was measured does it too.** The 12-cycle bound is
|
||||
derived from a 23 s down-ramp. A colder or larger pack that takes longer than that will
|
||||
produce releases on its own, and no constant read off the 2026-08-22 measurement can
|
||||
prevent it. In the same container sweep, holding the bound at 12 and lengthening only
|
||||
the plant's down-ramp, single-inverter releases went from 8 of 320 scenarios at the
|
||||
measured 23 s to 59 of 320 at 34 s and 268 of 320 at 50 s.
|
||||
|
||||
**So, in order:** if you see `freeze-released` **once, during or just after a large load
|
||||
or PV swing**, that is most likely the loop's own ramp and needs nothing. If you see it
|
||||
**standing** — cycle after cycle, at a steady load, with a standing grid error the
|
||||
battery is not correcting — then look for the second writer: another controller, a
|
||||
scheduler in the inverter's own app, or an installer tool left connected. That is the
|
||||
configuration §1.5 of the specification rules out, and it is the one this exit exists to
|
||||
stop the loop stalling on forever.
|
||||
|
||||
⚠️ **No `saturation_cycles` you can set removes the block.** This file accepts 1-10, and
|
||||
all of those are below the 12-cycle release bound, so the freeze always engages before
|
||||
its own exit is due. (The first version of this feature had the bound at 9, where a
|
||||
setting of 9 or 10 *did* silently delete the block; moving the bound to 12 closed that
|
||||
by construction rather than by warning about it.)
|
||||
|
||||
#### Why `target_grid_w` is not zero
|
||||
|
||||
The deadband is a one-way ratchet: any resting point inside it holds until
|
||||
|
||||
@@ -135,12 +135,24 @@ manufactures them from a **fixture**: the check runs, the assertion is
|
||||
evaluated, the sentence above it is true — and the numbers it compares are
|
||||
arranged so that the failure it describes could not have changed the answer.
|
||||
|
||||
It is not hypothetical and it is not rare. EIGHT instances in two weeks; four
|
||||
It is not hypothetical and it is not rare. ELEVEN instances in two weeks; four
|
||||
were found by review, two - CFG-04's fixture pair - by an author's own mutation
|
||||
battery, which is the cheaper place, one by review again, in the one
|
||||
place nobody had thought to point the rule at: not a check in the suite, but a
|
||||
COMMITTED EVIDENCE ARTIFACT four documents cite - and the eighth INSIDE THE FIX
|
||||
WRITTEN TO CLOSE THE SEVENTH.
|
||||
COMMITTED EVIDENCE ARTIFACT four documents cite - the eighth INSIDE THE FIX
|
||||
WRITTEN TO CLOSE THE SEVENTH, the ninth inside the SITE HALF of the card
|
||||
that names the rule in its own text - and the TENTH INSIDE THE FIX WRITTEN TO
|
||||
CLOSE THE NINTH, for the second time in this list. See SAFETY-38 below: the
|
||||
check that closed it asserted a NUMBER and not the number's POSITION.
|
||||
|
||||
⚠️ AND THE ELEVENTH IS IN A MEASURING INSTRUMENT RATHER THAN IN A CHECK, which
|
||||
is where the seventh was too. SAFETY-38 round 4's render probe - the thing that
|
||||
decides whether a mutation changed the SENTENCE and not merely the bytes -
|
||||
numbered each interpolated operand BY ORDER OF APPEARANCE. After a transposition
|
||||
the other operand takes the same number, so all thirteen mutant renders came back
|
||||
BYTE-IDENTICAL to the clean tree: the probe compared the swap against itself, and
|
||||
its output read exactly like "the edit is inert". Keying the value to the
|
||||
operand's NAME fixed it. Habit 16.
|
||||
|
||||
⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance,
|
||||
MAINT-05's, is the one that says why: the defect is not carelessness about
|
||||
@@ -234,6 +246,65 @@ same place twice, the check is decoration.
|
||||
assertion cannot fail is exactly the thing this section is about. Two habits
|
||||
follow, and both are habit 5 and 6 below.
|
||||
|
||||
* **SAFETY-38** — `main.py`'s
|
||||
`site_standing = float(sum(standing.values()))`, the first argument of
|
||||
SAFETY-30's latching site sign detector *and* SAFETY-18's `looks_frozen()`
|
||||
input. `test_watchdog.py`'s `_sign_site` is the suite's **only** N≥2 site
|
||||
fixture, and it runs shares of about **−2500 W each** — every one
|
||||
individually over the 320 W gate it is configured with. On numbers like those
|
||||
`sum`, the mean and `max(key=abs)` **all agree**, so the reduction could be
|
||||
swapped for either of the other two and the site guard went **blind with the
|
||||
whole suite green** — still commanding −500 W of charge into a +2500 W import at
|
||||
t = 595 s with `following=True`. At a roster of **one** it is worse and it is
|
||||
not a fixture defect at all: `sum == mean == max == the one share`
|
||||
*identically*, so every such mutation is genuinely **equivalent** there and no
|
||||
N=1 fixture could ever separate them. The fixture that separates them is the
|
||||
one the detector exists for — **per-unit shares EACH BELOW the gate whose SUM
|
||||
clears it** — and it is now `test_watchdog.py` part 9, at asymmetric ratings
|
||||
so that all four candidate reductions give four **distinct** answers.
|
||||
|
||||
⚠️ **AND THE CARD'S OWN SEARCH FOR ITS NEXT SURVIVOR FOUND ONE, WHICH IS THE
|
||||
POINT OF ASKING.** Having built the fixture, the question *what mutation would
|
||||
this NOT see?* produced `float(self.target)` — the arbiter's site target
|
||||
instead of the sum of what stands on the wire — at **−11 B**, green across the
|
||||
whole suite, because every fixture in the repository was built where the two
|
||||
are the same number. They separate only when a device's reported range is
|
||||
narrower than its `max_w`, since the `device` claim is filed **per actuator**
|
||||
and never bounds the site target. Closed in the same commit, by a
|
||||
counterfactual that asks for −500 W while −250 W stands.
|
||||
|
||||
⚠️ **AND THEN THE FIX ITSELF WAS THE TENTH INSTANCE — A SUBSTRING WHERE THE
|
||||
FAILURE WAS POSITIONAL.** The check written to pin the banner asserted
|
||||
`f"{sum(...):.0f} W" in reason`: that **"−500 W" appears somewhere** in the
|
||||
sentence. Swapping the detector's first two arguments
|
||||
(`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`)
|
||||
is a **ZERO-BYTE** edit that leaves both numbers in the string and moves each
|
||||
into the other's slot — so the substring is still found, **in the meter
|
||||
slot**, and the whole suite stayed green while the latching site-stopping
|
||||
banner told the operator which instrument read what **exactly backwards**.
|
||||
The fix is to assert the **ORDERED PAIR**,
|
||||
`f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`.
|
||||
|
||||
⚠️ **AND THE ELEVENTH WAS THE SAME DEFECT ONE CALL SITE OVER — THE ONE A
|
||||
SHIPPED INSTALL CAN ACTUALLY SEE.** Round 2 pinned the operand order of
|
||||
`SignWatchdog.evaluate()` at `main.py:1620`. That call site is the **site**
|
||||
detector, and `test_watchdog.py` part 9c asserts it is **never armed at a
|
||||
roster of one** — which is every install this add-on has. The **per-unit**
|
||||
call at `main.py:1560` was left unpinned, and swapping its first two
|
||||
arguments is the same **ZERO-BYTE** edit: `disagrees()` is symmetric in them
|
||||
(`commanded_w * against_w < 0` with an `abs()` gate on both), so **no trip
|
||||
decision can change** and the full 21-file suite stays green — measured. What
|
||||
moves is five banner lines, including the one persisted to `sign_reason` and
|
||||
re-read after a restart: *"commanded −1000 W against its own measured power
|
||||
+1000 W"*, operands inverted, on the only sign-disagreement sentence a
|
||||
one-inverter install can ever print. Closed in round 3 by the ordered pair on
|
||||
**both** per-unit banners — the N≥2 `its own measured power` one and the N=1
|
||||
`a meter reading` one — red on four named checks, green in the other twenty
|
||||
files. **Habit 15 is what falls out of finding the same shape twice in the
|
||||
same card.**
|
||||
|
||||
The general habits are **14** and **15** below.
|
||||
|
||||
Note what these have in common with a masked rung: the mutation *runs*, and the
|
||||
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
|
||||
can see it, because the code under test really did behave identically on the
|
||||
@@ -363,6 +434,158 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
completeness a reviewer falsifies in ten minutes**, and every round of this
|
||||
card that claimed the latter was rejected for it.
|
||||
|
||||
14. **An `in` test against a formatted sentence grades PRESENCE, never
|
||||
ARRANGEMENT — so assert the ORDERED PAIR.** SAFETY-38 pinned the banner of a
|
||||
latching, site-stopping alarm with
|
||||
`f"{sum(_share38):.0f} W" in ...reason` — that `"-500 W"` appears
|
||||
*somewhere*. Swapping the detector's first two arguments at the call site
|
||||
(`evaluate(site_standing, self.grid, …)` →
|
||||
`evaluate(self.grid, site_standing, …)`) is a **ZERO-BYTE** edit: both
|
||||
numbers are still in the string, each in the other's slot. The substring was
|
||||
still found — **in the meter slot** — the whole suite stayed green, and the
|
||||
banner told the operator which instrument read what **exactly backwards**.
|
||||
Whenever the property you mean is "**this** value went in **this** slot" — a
|
||||
reason string, a log line, a notification, an MQTT payload — assert the
|
||||
surrounding **literal text with the value embedded in its own position**
|
||||
(`f"commanded {x:+.0f} W against a meter reading {y:+.0f} W"`), or assert
|
||||
the fields **before** they are formatted. Two values that both appear in one
|
||||
sentence can always be transposed by an edit that costs **nothing**, and
|
||||
that no byte delta, no fingerprint and no shape pin can see. ⚠️ Note where
|
||||
this one was found: **inside the fix written to close the previous instance
|
||||
of the same family**, which is the second time in this list.
|
||||
|
||||
⚠️ **AND THE FIX FOR IT WAS ONE CALL SITE WIDE, WHICH IS THE HALF WORTH
|
||||
KEEPING.** The ordered pair went onto `main.py:1620`, the **site** detector
|
||||
— armed only at N≥2, and part 9c asserts it is **absent at a roster of
|
||||
one**. The identical swap at `main.py:1560`, the **per-unit** detector, was
|
||||
still **ZERO BYTES**, still changed no decision, still left the 21-file
|
||||
suite green — and it is the **only** sign-disagreement sentence a shipped
|
||||
one-inverter install can ever print: *"commanded −1000 W against its own
|
||||
measured power +1000 W"*, backwards, in the log, on the status page and in
|
||||
`sign_reason` after the restart the latch is designed to survive. Measured
|
||||
size: **main.py:1560, 0 bytes, 21/21 green.** So habit 14 is not "pin the
|
||||
banner", it is **"pin every call site of the banner, and say which one the
|
||||
shipped roster reaches"** — and habit 15 is how you find them all.
|
||||
|
||||
15. **A SYMMETRIC PREDICATE MAKES ARGUMENT ORDER A PROPERTY OF THE MESSAGE
|
||||
ALONE — so ENUMERATE THAT FAMILY BY MACHINE, and grade the CALL SITES, not
|
||||
the function.** Where a callee is symmetric in two arguments, a swap at any
|
||||
call site is invisible to every behavioural check **by construction**: not
|
||||
"no fixture happens to separate them" but *no fixture can*. There is
|
||||
nothing for a mutation battery to find, because the mutant and the shipped
|
||||
tree take identical actions on every input. The only surface that moves is
|
||||
the sentence, and the only check that can see it is one asserting the
|
||||
**ordered pair** (habit 14) at **that** call site.
|
||||
Three consequences, and all three are why this is its own habit:
|
||||
* **The unit of coverage is the CALL SITE.** SAFETY-38 pinned
|
||||
`evaluate()`'s operand order and still shipped the defect, because the
|
||||
pin was on the call two lines below the one that matters. Ask of every
|
||||
banner: *how many places call this, and which does the shipped roster
|
||||
reach?*
|
||||
* **The scope must be COMPUTED** — habit 12's argument, and SAFETY-38 is
|
||||
its proof: the family was enumerated by hand twice and was short both
|
||||
times. `tools/symmetric_message_scan.py` renames each parameter pair,
|
||||
erases every formatted string, canonicalises the commutative operators
|
||||
and iterates to a **fixpoint** over `app/` (the symmetry is usually one
|
||||
call deep — `evaluate()` is symmetric only *through* `disagrees()`), then
|
||||
classifies each sentence by where it GOES. A returned string may be a
|
||||
**key** — swapping `store.unit_key(unit, key)` renames a store key, which
|
||||
is behaviour and is MAINT-05's card — or a **sentence**, which is this
|
||||
family.
|
||||
* **Say what the instrument cannot see, with shapes** (habit 13). That scan
|
||||
under-reports semantic symmetry no canonicaliser on its finite list can
|
||||
spell; its second census, for sentences the CALLER builds, pairs only
|
||||
expressions **one leaf apart** and never sees `%s`-style logging; it
|
||||
covers only `app/`; and it **over-reports in exactly one way** — it
|
||||
erases the sentence, so a swap that would make the FORMATTING itself
|
||||
raise is scored as changing no decision. It prints all of that on every
|
||||
run. The family, every member's call sites, and which of them are pinned
|
||||
are in `DECISIONS/SAFETY-38-site-standing-pin.md` §7 — **with two
|
||||
measured, still-open survivors named there rather than rounded off**,
|
||||
both zero bytes and both 21/21 green.
|
||||
* ⚠️ **AND THE SINK IS HALF THE SCOPE, WHICH IS HOW THE FIRST THREE ROUNDS
|
||||
OF THIS SCAN MISSED THE PLACE THE CODEBASE ACTUALLY KEEPS ITS SENTENCES.**
|
||||
The scan classified a sentence as operator-facing when it was **appended,
|
||||
assigned to a `reason`-shaped name, or logged** — and every one of its 16
|
||||
reported members was one of those. **None was RETURNED.** A method
|
||||
formatting its own **attributes** and handing the string back —
|
||||
`ClampResult.describe()`, `PeakDecision.explain()`, every reason-builder
|
||||
in the tree — was outside all of it, and so were sentences handed to a
|
||||
constructor as `reason=` or placed under a `"text"` dict key. Four
|
||||
zero-byte survivors of a green 21-file suite were sitting there, two of
|
||||
them on the alarm SAFETY-40 and SAFETY-41 are about. **The general form:
|
||||
an enumeration is scoped by a SINK as much as by a PREDICATE, and a sink
|
||||
list is a hand-written list like any other (habit 12).** Ask of any such
|
||||
scan: *which syntactic forms does this call a message, and what does the
|
||||
code under it actually use?*
|
||||
|
||||
16. ⚠️ **AN INSTRUMENT THAT ASSERTS A COMPLETENESS IT DOES NOT HAVE IS WORSE
|
||||
THAN NO INSTRUMENT, AND ITS BLIND-SPOT LIST IS PART OF THE CODE UNDER
|
||||
REVIEW.** `tools/symmetric_message_scan.py` printed five honest gaps and a
|
||||
census count, and a reader — including its author, twice — took the count
|
||||
for the family. The gap that mattered, the RETURN sink, was **in none of the
|
||||
five**. A gap list is not a disclaimer; it is a claim, and it is the claim
|
||||
most likely to be believed because nothing else in the output argues with
|
||||
it. Two consequences:
|
||||
* **State what is NOT scanned, largest hole first, as a list of forms** —
|
||||
not "this under-reports". The tool now names `%s`-style lazy logging
|
||||
(`_LOG.error("… %s … %s", a, b)`, where the operands are *arguments*, so
|
||||
no f-string census can ever see them), `.format()`/`%`/`+`/`.join()`
|
||||
sentences, sentences that leave through a dict or dataclass, `yield`,
|
||||
message attributes outside its own constant set, and everything outside
|
||||
`app/`. Where the list is derived from a constant in the file, it is
|
||||
**printed from that constant**, so the two cannot drift.
|
||||
* **AND THE PROBE THAT GRADES THE MUTATIONS IS SUBJECT TO EVERY HABIT
|
||||
ABOVE.** Round 4's render probe gave each interpolated operand a value by
|
||||
**order of appearance** — so after a transposition the other operand took
|
||||
the same number and **all thirteen mutant renders came back
|
||||
byte-identical**, which reads exactly like *the edit changed nothing*.
|
||||
Keying the value to the operand's **NAME** fixed it. That is habit 4, in
|
||||
the measuring instrument: the fixture's own values were arranged so the
|
||||
failure it was built to see could not change the answer. **Eleventh
|
||||
instance of habit 4 in this file, and the second one in an instrument
|
||||
rather than in a check.**
|
||||
|
||||
17. ⚠️ **TWO TERMS OF A CONJUNCTION THAT MOVE TOGETHER IN EVERY FIXTURE ARE
|
||||
ONE TERM AS FAR AS THE CHECKS ARE CONCERNED - TWELFTH INSTANCE OF HABIT 4,
|
||||
and the third found by an author's own battery rather than by review.**
|
||||
SAFETY-37's freshness gate is `unchanged >= 35 s AND |standing| >= 250 W
|
||||
AND |travel| >= 250 W`. Deleting the MIDDLE term left every check in the
|
||||
first draft green, because in all of them a command that had *travelled*
|
||||
250 W had also *ended up* above 250 W: the fixture never separated the two,
|
||||
so one of them was decoration as far as the suite could tell. The case that
|
||||
separates them is a site being brought to REST - the register reaches its
|
||||
new value on the same read at which the OLD command is still standing, so
|
||||
the travel term alone reads 1300 W of unfollowed command while the site
|
||||
sits truthfully at +5 W. Ask of every conjunction: **is there a fixture in
|
||||
which each term is the ONLY one that is false?** If not, the suite cannot
|
||||
tell an AND from an OR.
|
||||
|
||||
18. ⚠️ **AN UNVERIFIED REVERT IS THE SAME DEFECT AS STALE BYTECODE, AND TWO
|
||||
HARNESSES SHARING ONE BACKUP PATH IS THE WORST VERSION OF IT.** SAFETY-37's
|
||||
sweep hit this twice, in ascending order of nastiness.
|
||||
* A mutation's container run overran a two-minute command budget and the
|
||||
wrapper was killed **between the edit and the `cp` that puts the file
|
||||
back**, leaving `app/freshness.py` mutated on disk. Nothing announced it;
|
||||
the next mutation's harness cheerfully fingerprinted and graded the mutant
|
||||
*plus* a second mutation.
|
||||
* Then two copies of the sweep were started - a scheduling mistake, not a
|
||||
tool defect - and the harness kept its backup at the FIXED path
|
||||
`/tmp/mut.bak`. One copy saved `app/main.py` there; the other restored it
|
||||
**over `app/freshness.py`**. The result was a 238 KB `freshness.py` that
|
||||
still parsed as Python, plus `main.py` left holding a live mutation. No
|
||||
error, no warning, and the only reason it was noticed is that the tree
|
||||
fingerprint did not match the recorded clean one.
|
||||
|
||||
Two rules, and the first is the cheap one:
|
||||
* **Fingerprint after the revert, not only before the run.**
|
||||
`tools/mutation_run.sh` prints the tree it is about to grade for exactly
|
||||
this reason; the revert deserves the same treatment, and a mismatch is a
|
||||
stop-everything result rather than a note.
|
||||
* **A harness's scratch file must be unique per invocation** (`mktemp`), so
|
||||
that two of it cannot silently overwrite each other's work. A fixed path
|
||||
in a tool that edits source is a shared mutable global with a filename.
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
|
||||
@@ -122,6 +122,18 @@ CIRC_OPTION_DEFAULTS = {
|
||||
# 200 W -> 445.0 s 350 W -> 10.0 s 1000 W -> 5.0 s
|
||||
# 250 W -> 40.0 s 400 W -> 5.0 s 1700 W -> 0.0 s
|
||||
#
|
||||
# ⚠️ SAFETY-39 MOVED THAT SCHEMA FLOOR FROM 50 TO 0, AND THE CAMPAIGN ABOVE
|
||||
# WAS NOT RE-RUN. It does not have to be, and here is the argument rather
|
||||
# than the assurance. What these windows are about is how long the command
|
||||
# takes to CROSS ITS RAIL - the swept worst case is the SLOWEST crossing,
|
||||
# which is `slew_w` at its floor, 5000/50 = 100 cycles. `slew_w: 0` means
|
||||
# "follow the rail" and resolves to `SLEW_RAIL_FRAC * max_w`, which is
|
||||
# exactly 9 cycles at EVERY rail by construction. So the new value can never
|
||||
# be the slowest crossing at any `max_w`, and it is strictly inside the
|
||||
# envelope this campaign measured. An explicit number still spans 50..5000
|
||||
# and is unchanged. If `SLEW_RAIL_FRAC` is ever loosened past 1/100, this
|
||||
# paragraph stops being true and the campaign has to be re-run.
|
||||
#
|
||||
# THE KNEE IS AT 400 W and it is sharp. Below ~250 W a flapping load holds
|
||||
# the sensor above the threshold for MINUTES at a time - 505 s at 100 W -
|
||||
# and no window shorter than the 900 s billing quarter could tell that from
|
||||
|
||||
@@ -145,14 +145,94 @@ from datetime import datetime
|
||||
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||
REASON_CODE = "OUTPUT_CLAMP"
|
||||
|
||||
# Re-assert the notification this often while the clamp stays engaged. 30 s is
|
||||
# the planning cadence (2.3), i.e. slow enough not to flood and fast enough
|
||||
# that a pinned loop is never quiet for a whole billing quarter.
|
||||
REASSERT_S = 30.0
|
||||
# Re-assert the notification this often while the clamp stays engaged.
|
||||
#
|
||||
# ⚠️ RAISED 30 s -> 150 s on 2026-09-05, and the reason is a REGIME CHANGE, not a
|
||||
# change of mind about the alarm. 30 s was the planning cadence (2.3), chosen
|
||||
# when a loop-side engagement was a rare event: `main.py`'s note at the action
|
||||
# log records "60 cycles of the §10.3 runaway produce 0 write-path engagements
|
||||
# and 2 loop-side ones". On the live install the counter reached 48 loop-side
|
||||
# ENGAGEMENTS in ~25 minutes of ordinary operation - roughly one cycle in six,
|
||||
# not a pinned rail, but far more often than a guard sized for 2-in-60 assumed -
|
||||
# and the owner received a persistent notification about it all day. ⚠️ NOT
|
||||
# because of the 30 s re-assert - see the block below; those notifications never
|
||||
# consulted this constant at all.
|
||||
#
|
||||
# ⚠️ BOTH FIGURES ARE ENGAGEMENT COUNTS; only the regime differs - 2-in-60 was
|
||||
# measured over a §10.3 RUNAWAY, 48-in-305 was ordinary running on the live
|
||||
# install. The 48 is read off the live log, not measured in this repo.
|
||||
#
|
||||
# ⚠️ AND RAISING THIS CONSTANT ALONE DID NOT FIX THAT FLOOD - review found it
|
||||
# and the first version of this change was wrong. At ~5 s cycles a 1-in-6
|
||||
# engagement leaves ~25 s of clean cycles between engagements, past
|
||||
# CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED between almost every one
|
||||
# and came back as a fresh rising edge. The rising edge used to fire
|
||||
# unconditionally, so those notifications never consulted this constant at all:
|
||||
# 48 notifications at 30 s, and still 48 at 150 s. The fix was to rate-limit the
|
||||
# RISING EDGE too and let `last_alarm` survive a de-assert, so this interval
|
||||
# bounds the notification rate across episodes rather than only within one.
|
||||
# Measured on that same window afterwards: 51 episodes, 11 notifications.
|
||||
#
|
||||
# WHY the loop reaches its integrator bound so much more often than the guard's
|
||||
# author saw: `max_w` on the live install is 5000 W, while the loop's slew was
|
||||
# derived against a 1500 W clamp - admin/goodwe `NOTES.md` §"Simulated 2 kW load
|
||||
# step (gain 0.6, clamp 1500)". ⚠️ THAT DERIVATION IS IN THE OTHER REPO; nothing
|
||||
# in this one corroborates it, so do not go looking here. SAFETY-39 is the ticket
|
||||
# that re-derives it, and fixing that is what would stop the railing; this
|
||||
# constant only changes how often the alarm repeats.
|
||||
#
|
||||
# ⚠️ 150 s IS THE REQUIREMENT'S EDGE WITH MARGIN, AND THE CHECK NOW ENFORCES THAT
|
||||
# EDGE. `test_clamp.py` part 2 grades the cadence in wall-clock terms - a pinned
|
||||
# loop must speak at least MIN_PER_QUARTER = 5 times inside the 900 s quarter
|
||||
# that bills the capacity tariff - which caps this constant at 900/5 = 180 s.
|
||||
# Measured on the worst sliding 900 s window: 180 s earns 5 alarms, 181 s earns
|
||||
# 4. 150 s earns 6.
|
||||
#
|
||||
# ⚠️ THAT CHECK USED TO PERMIT 224 s, AND THE FIX SHIPPED WITH THIS COMMIT. Its
|
||||
# sweep starts at i=0, where the rising edge is free, so it measured one alarm
|
||||
# more than a real quarter earns. A later editor raising this to 200 s would have
|
||||
# had a green suite and a requirement quietly unmet. `test_clamp.py` now grades
|
||||
# floor(QUARTER_S / REASSERT_S) directly.
|
||||
#
|
||||
# ⚠️ MIN_PER_QUARTER = 5 IS A TEST-LOCAL INTEGER, not a spec figure - it exists
|
||||
# only in `test_clamp.py` and the CHANGELOG entry that introduced it. The 900 s
|
||||
# quarter IS system-wide (`tariff.py`, `freeze.py`, `DOCS.md`). So the 180 s
|
||||
# ceiling rests on one number a test author chose; if that number is ever
|
||||
# revisited, this constant moves with it.
|
||||
#
|
||||
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
|
||||
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
|
||||
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
|
||||
# suppressed: every engagement is still counted, the action-log row is written on
|
||||
# every engaged cycle regardless, and the rising edge of every episode alarms
|
||||
# unconditionally, so no alarm is DROPPED - only delayed.
|
||||
REASSERT_S = 150.0
|
||||
|
||||
# Consecutive clean cycles before the alarm de-asserts (10.2 hysteresis).
|
||||
CLEAR_CYCLES = 3
|
||||
|
||||
# ⚠️ SAFETY-41. Consecutive ENGAGED cycles after which an episode speaks even if
|
||||
# the notification budget is spent. THE ESCALATION EXISTS BECAUSE THE RATE LIMIT
|
||||
# ALONE DELAYS A RUNAWAY, and review measured it: a benign one-cycle clamp
|
||||
# followed by a continuous runaway held the runaway's first notification for up
|
||||
# to 140 s - one REASSERT_S minus a cycle. On the live install, where ordinary
|
||||
# railing spends the budget essentially all the time, a runaway arriving during
|
||||
# normal operation would be delayed with near-certainty. That is the "benign
|
||||
# noise suppresses the bug signal" shape this module's docstring is written
|
||||
# against.
|
||||
#
|
||||
# THE DISCRIMINATOR IS RUN LENGTH, and the two populations genuinely differ: the
|
||||
# live flood was runs of length ONE (engage, de-assert, engage), while a §10.3
|
||||
# runaway engages on EVERY cycle. So a sustained run speaks regardless of budget,
|
||||
# and scattered chatter does not.
|
||||
#
|
||||
# Measured, shipped-without vs shipped-with - it costs nothing it was protecting:
|
||||
# live scatter flood (305 cycles, 1-in-6) 11 -> 11 notifications
|
||||
# chatter 1-engaged/3-clean over 8000 s 50 -> 50 notifications
|
||||
# pinned 900 s quarter (floor is 5) 6 -> 6 alarms
|
||||
# runaway delay after a benign episode 140 s -> 10 s
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
@@ -258,6 +338,10 @@ class ClampWatchdog:
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
|
||||
# spoken yet - the two the escalation clause needs.
|
||||
self.run_len = 0
|
||||
self.spoken = False
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
|
||||
"""Advance one cycle. True when the caller should raise the alarm.
|
||||
@@ -269,26 +353,72 @@ class ClampWatchdog:
|
||||
self.engagements += 1
|
||||
self.clean_cycles = 0
|
||||
self.reason = reason
|
||||
self.run_len += 1
|
||||
if not self.active:
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
self.spoken = False
|
||||
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
|
||||
# not used to be. It fired unconditionally, so a condition that
|
||||
# engages and clears repeatedly raised one notification PER
|
||||
# EPISODE no matter how fast the episodes came - and
|
||||
# `REASSERT_S` never entered into it, because it only governs
|
||||
# the still-engaged path below.
|
||||
#
|
||||
# That is the shape the live install actually produced: 48
|
||||
# engagements in 25 minutes, scattered roughly one cycle in six.
|
||||
# At a ~5 s cycle the gaps are ~25 s of clean cycles, which
|
||||
# exceeds CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED
|
||||
# between almost every engagement and each one came back as a
|
||||
# fresh rising edge. Measured on this class over that window:
|
||||
# 48 episodes and 48 notifications at REASSERT_S = 30 - and
|
||||
# still 48 at REASSERT_S = 150. Raising the re-assert removed
|
||||
# NONE of them, because none of them came from the re-assert.
|
||||
#
|
||||
# So the interval now bounds the NOTIFICATION RATE rather than
|
||||
# the repeat rate within one episode: `last_alarm` survives a
|
||||
# de-assert, and a new episode speaks only if the interval has
|
||||
# elapsed. ⚠️ NOTHING IS DROPPED. Every engagement is still
|
||||
# counted, every episode still increments, the action-log row is
|
||||
# still written on every engaged cycle, and a genuine runaway -
|
||||
# which by definition keeps engaging - is picked up by the
|
||||
# re-assert path within REASSERT_S. Delay, not loss.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
|
||||
# budget was spent by an EARLIER, unrelated episode speaks anyway
|
||||
# once it has engaged SUSTAINED_CYCLES times in a row. A runaway
|
||||
# engages every cycle and reaches it; the scattered one-cycle
|
||||
# railing that produced the live flood never does.
|
||||
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
# Still engaged: re-assert on the slow cadence, not every cycle.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
|
||||
self.run_len = 0
|
||||
if not self.active:
|
||||
return False
|
||||
self.clean_cycles += 1
|
||||
if self.clean_cycles >= CLEAR_CYCLES:
|
||||
self.active = False
|
||||
self.since = None
|
||||
self.last_alarm = None
|
||||
# ⚠️ `last_alarm` DELIBERATELY SURVIVES THE DE-ASSERT. Clearing it
|
||||
# here is what made the next rising edge speak immediately however
|
||||
# soon it arrived, which is the flood described at the rising edge
|
||||
# above. The episode ends; the notification budget does not reset
|
||||
# with it.
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
return False
|
||||
|
||||
@@ -16,14 +16,199 @@ the short version is in the comments below. Do not "clean this up" - each rule
|
||||
exists because its absence produced a specific, observed failure.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-39. THE ONE RELATIONSHIP IN THIS FILE THAT IS NOT A NUMBER.
|
||||
#
|
||||
# ⚠️ WHAT WENT WRONG, because the shape of it is the reason this is a ratio and
|
||||
# not a constant. `slew_w: 1000` was derived on 2026-08-22 against a clamp of
|
||||
# 1500 W (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)"),
|
||||
# and its stated justification was that it "puts most of the correction in the
|
||||
# first cycle WHILE STILL CAPPING HOW FAR THE LOOP CAN JUMP AHEAD OF THE
|
||||
# INVERTER". At 1500 W that sentence was true: 1000 W/cycle reaches the rail in
|
||||
# two cycles and the rail itself bounds the excursion.
|
||||
#
|
||||
# The rail then moved to 5000 W on the live install and the constant did not.
|
||||
# 1000 W per 2 s tick is 500 W/s, so the loop traverses its WHOLE OUTPUT RANGE
|
||||
# in 10 s against a plant NOTES.md §5.10 measured at ~18 s up / ~23 s down for a
|
||||
# 1.78 kW step. It commands its entire range before it can observe its answer to
|
||||
# the first step. Measured on the owner's system 2026-09-05 11:43-11:46: a real
|
||||
# 3.4 kW export, the setpoint ramping 380 -> 5000 in five ticks, the battery at
|
||||
# -5154 W against a 5000 VA inverter, and the grid pushed from 3.4 kW of EXPORT
|
||||
# to 2.8 kW of IMPORT - the loop manufactured the error it then chased.
|
||||
#
|
||||
# ⚠️ SO THE FIX IS NOT A NEW NUMBER. A number is what went stale. What is
|
||||
# written here is the RELATIONSHIP the 2026-08-22 sentence was really asserting:
|
||||
#
|
||||
# THE LOOP MUST NOT TRAVERSE ITS FULL OUTPUT RANGE FASTER THAN THE PLANT
|
||||
# TAKES TO SETTLE.
|
||||
#
|
||||
# slew_w <= max_w * LOOP_TICK_S / PLANT_SETTLE_S
|
||||
#
|
||||
# `max_w` here is the SITE rail (main.py builds `Tuning(max_w=site_max_w)`, the
|
||||
# roster sum), so the bound moves with the roster and a second inverter cannot
|
||||
# re-open this by arriving. validate.py rule 10 refuses a configuration that
|
||||
# breaks it; `slew_w = 0` below means "follow the rail" and is the shipped
|
||||
# default, exactly as `integrator_max_w = None` already means "follow max_w".
|
||||
#
|
||||
# ⚠️ BOTH NUMBERS ARE WRITTEN OUT RATHER THAN THEIR QUOTIENT, AND THAT IS NOT
|
||||
# TIDINESS. The quotient is only meaningful in the regime the two were measured
|
||||
# in, and one of them is NOT a property of this code:
|
||||
#
|
||||
# PLANT_SETTLE_S is the inverter and the pack. goodwe/NOTES.md §5.10,
|
||||
# 2026-08-22, a 1.78 kW kettle step driven from HA with a
|
||||
# plug that meters its own draw: ~18 s from the step to the
|
||||
# deadband going up, ~23 s coming down. 18 is taken because
|
||||
# it is the SHORTER of the two and therefore the LOOSER
|
||||
# bound - this is a refusal boundary, and rules 5 and 6 in
|
||||
# validate.py refuse the impossible case only, never "close
|
||||
# to the rail".
|
||||
#
|
||||
# ⚠️ AND THAT CHOICE IS LOCAL TO THIS BOUND. §5.10 MEASURED THE STEP IN BOTH
|
||||
# DIRECTIONS, so "the plant's settling time" is a PAIR and not a number, and
|
||||
# which half is the safe one depends on which way being wrong hurts. Taking
|
||||
# 18 for a REFUSAL is right: the looser number refuses less. Taking 18 for a
|
||||
# question of the form "how long does the plant remain a candidate
|
||||
# explanation" is WRONG, because the looser number stops believing the plant
|
||||
# too early - and there the answer is a false accusation. SAFETY-13 asks that
|
||||
# second question and so reads PLANT_SETTLE_DOWN_S below, deliberately not
|
||||
# this constant. Anything else added here has to say which of the two it
|
||||
# means and why.
|
||||
#
|
||||
# LOOP_TICK_S is the METER, not this loop. `run_control()` runs a cycle on
|
||||
# a changed meter value or on `cycle_floor_s`, whichever comes
|
||||
# first, so the tick is the meter's own update rate. 2.0 s is
|
||||
# what the live install measured on 2026-09-05 (the setpoint
|
||||
# stepped +1000 W every 2 s), and it is the fastest meter this
|
||||
# project has ever run.
|
||||
#
|
||||
# ⚠️ AND THIS ONE IS NOT WRONG IN THE SAFE DIRECTION, WHICH IS WHY IT IS SAID
|
||||
# HERE. A SLOWER meter makes this bound conservative - it refuses some
|
||||
# configurations that would have been fine, which is visible immediately and
|
||||
# costs one edit. A meter FASTER than 2 s makes it loose by exactly that ratio,
|
||||
# and nothing in this add-on would say so. The tick cannot go below 1 s
|
||||
# (`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), so the
|
||||
# worst case is a factor of two - but if a faster meter is ever fitted, this
|
||||
# constant has to be RE-DERIVED and not merely re-read. Compare
|
||||
# validate.FIRMWARE_INGEST_VETO_S, which carries the same kind of copy and can
|
||||
# only ever be wrong the other way.
|
||||
PLANT_SETTLE_S = 18.0
|
||||
LOOP_TICK_S = 2.0
|
||||
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S # 0.111..., i.e. 9 ticks
|
||||
|
||||
# The other half of the same measurement: the descent of the same 1.78 kW step,
|
||||
# ~23 s from a -1784 W export excursion back into the deadband (goodwe/NOTES.md
|
||||
# §5.10, the same run, the same day, the same plug). It is slower than the ramp
|
||||
# up, which is the ordinary asymmetry of a pack that is pushed one way and
|
||||
# coasts the other. Nothing above this line reads it; SAFETY-13 below does.
|
||||
PLANT_SETTLE_DOWN_S = 23.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-13. HOW LONG THE PLANT STAYS A CANDIDATE EXPLANATION.
|
||||
#
|
||||
# The output freeze below stops the command growing while the inverter is not
|
||||
# following. Two completely different things produce that divergence and the
|
||||
# freeze cannot tell them apart on magnitude alone:
|
||||
#
|
||||
# (a) THE PLANT HAS NOT CAUGHT UP YET, or it is at a limit - a cold pack, a
|
||||
# curtailment, an export cap. Asking for more is pointless, and the
|
||||
# freeze is right to hold. It also CLEARS ITSELF: the command is pinned
|
||||
# at something the inverter can do, so the inverter catches up to it and
|
||||
# `saturated_now` goes false.
|
||||
#
|
||||
# ⚠️ AND (a) INCLUDES THE CASE `unasked_power()` BELOW CANNOT SEE AS (a):
|
||||
# the command has fallen THROUGH ZERO and the pack is still coasting in
|
||||
# the old direction, so the reading has the sign the command used to have
|
||||
# and not the one it has now. That is this loop's own power, arriving
|
||||
# late. It is the reason the time bound is what does the work here and
|
||||
# the predicate alone is not enough.
|
||||
#
|
||||
# (b) SOMETHING ELSE IS DRIVING THE ACTUATOR - the §1.5 non-goal, two writers
|
||||
# on one actuator. The freeze CANNOT clear itself here, because clearing
|
||||
# it needs the inverter to track OUR command and the other writer is what
|
||||
# stops it. Nothing ends this but the other writer going away.
|
||||
#
|
||||
# ⚠️ SO THE BOUND IS ONLY EVER A REASON TO STOP BELIEVING (a). It does not
|
||||
# establish (b) and nothing below claims it does: what it says is that the
|
||||
# plant has run out of alibi, and (b) is then the only explanation this loop
|
||||
# has left - not the only one there is. Everything downstream of the release is
|
||||
# written to be safe when that inference is wrong, because sometimes it is.
|
||||
#
|
||||
# Case (a) is bounded by the plant, and the plant was measured:
|
||||
#
|
||||
# SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)
|
||||
#
|
||||
# ⚠️ THE **DOWN** NUMBER, AND THE DIRECTION IS THE WHOLE POINT. §5.10 measured
|
||||
# the same step both ways - 18 s up, 23 s down - and SAFETY-39 takes 18 because
|
||||
# 18 is the LOOSER bound for a REFUSAL. This is the opposite job. Here the
|
||||
# question is "how long may the plant still be the explanation", and the
|
||||
# conservative answer is the LONGER of the two: believe the plant for as long
|
||||
# as the plant has ever been observed to take. Deriving this from 18 gives 9,
|
||||
# which is three cycles SHORT of the plant's own measured down-ramp - and being
|
||||
# short here does not refuse anything, it ACCUSES. Measured, single inverter,
|
||||
# no second writer anywhere, 1280 closed-loop scenarios: at 9 the exit fires in
|
||||
# 113 of them and at 12 in 24. The direction of that error is why the pair is
|
||||
# now two named constants instead of one.
|
||||
#
|
||||
# ⚠️ AND IT IS NO LONGER THE RECIPROCAL OF SLEW_RAIL_FRAC. It was, at 9, and
|
||||
# that coincidence read as a derivation - two different questions that happened
|
||||
# to share an answer while both were being asked of the same half of a two-
|
||||
# sided measurement. They are 9 and 12 now. If they ever coincide again it is
|
||||
# arithmetic, not agreement.
|
||||
#
|
||||
# ⚠️ IT INHERITS SAFETY-39's CAVEAT ABOUT LOOP_TICK_S, in the opposite
|
||||
# direction, which is why it is repeated rather than cross-referenced. A meter
|
||||
# SLOWER than 2 s makes these 12 cycles cover MORE than 23 s, so the freeze
|
||||
# holds longer than the plant needs: that costs energy - see the case in
|
||||
# DOCS.md - and never safety. A meter FASTER than 2 s makes them cover LESS,
|
||||
# so the freeze can release while the plant is genuinely still moving. That one
|
||||
# is bounded twice over and by neither this constant nor luck: the INTEGRATOR
|
||||
# freeze is not released with the output freeze, so the command can only reach
|
||||
# the integrator's frozen value, and the output clamp bounds that in turn.
|
||||
#
|
||||
# ⚠️ AND A PLANT SLOWER THAN THE ONE THAT WAS MEASURED DOES THE SAME THING,
|
||||
# which no constant derived from a measurement can fix. In the same 1280
|
||||
# scenarios, holding the bound at 12 and lengthening only the plant's down-ramp
|
||||
# past the measured 23 s, the exit starts firing again on a single inverter:
|
||||
# 8/320 at 23 s, 59/320 at 34 s, 268/320 at 50 s. A re-measured plant means a
|
||||
# re-derived constant here, exactly as for the two above it.
|
||||
SETTLE_CYCLES = math.ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) # 12
|
||||
|
||||
# The saturation counter's ceiling, one above the longest bound read from it. A
|
||||
# counter capped BELOW that bound would silently make the bound unreachable -
|
||||
# i.e. delete the exit condition - so it is written against SETTLE_CYCLES and
|
||||
# not as a literal, and it moves when SETTLE_CYCLES moves.
|
||||
#
|
||||
# ⚠️ IT WAS 10, WHICH IS ALSO config.yaml's MAXIMUM FOR `saturation_cycles`,
|
||||
# AND THAT COLLISION IS GONE. At a bound of 9 the largest accepted
|
||||
# `saturation_cycles` (10) sat ABOVE the exit, so a legal configuration could
|
||||
# push the freeze's engagement past its own release and the no-start block
|
||||
# stopped existing. At 12 every accepted value 1..10 is strictly below the
|
||||
# bound, so the freeze always engages before its exit is due and no accepted
|
||||
# configuration can delete the block. `test_control.py` pins that at the schema
|
||||
# edge rather than in prose, because it is a property of two files agreeing.
|
||||
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
gain: float = 0.6
|
||||
max_w: float = 2000.0
|
||||
slew_w: float = 1000.0
|
||||
# SAFETY-39. How far the command may move in one cycle. ⚠️ 0 MEANS "FOLLOW
|
||||
# THE RAIL" - `SLEW_RAIL_FRAC * max_w` - and it is the default and the
|
||||
# recommended setting, for the reason written at the top of this file: a
|
||||
# fixed number here is a constant that outlives the rail it was derived
|
||||
# against, and that is the defect measured on the live system on
|
||||
# 2026-09-05. Read `slew_limit_w()`, never this field.
|
||||
#
|
||||
# It stays settable, because slower than the rail implies is a legitimate
|
||||
# and sometimes necessary choice - the live install has run an explicit
|
||||
# 400 W since 2026-09-05 12:00 - and because `slew_w` is an option an
|
||||
# operator already has. FASTER than the rail implies is what validate.py
|
||||
# rule 10 refuses.
|
||||
slew_w: float = 0.0
|
||||
deadband_w: float = 15.0
|
||||
step_w: int = 10
|
||||
saturation_w: float = 500.0
|
||||
@@ -83,6 +268,93 @@ class Decision:
|
||||
clamped: bool = False
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
"""How far the command may move this cycle, in W. Always positive.
|
||||
|
||||
⚠️ THE ONE READER OF `Tuning.slew_w`, and everything else must come through
|
||||
here. `slew_w = 0` means "follow the rail" and resolves to
|
||||
`SLEW_RAIL_FRAC * max_w`, which is the whole of SAFETY-39: the limit is a
|
||||
RATIO to the rail, so raising `max_w` moves it and cannot leave it behind.
|
||||
A caller that read the field directly would see 0 and apply no limit at all
|
||||
- the exact opposite of the setting's meaning - which is why this exists as
|
||||
a function rather than as a comment telling people to remember.
|
||||
|
||||
⚠️ AND IT IS NOT CLAMPED TO THE BOUND. An explicit `slew_w` above the rail
|
||||
fraction is refused at STARTUP by validate.py rule 10, not silently reduced
|
||||
here. Silently reducing it is the failure this whole module's docstring is
|
||||
about - an operator's stated number quietly meaning something else - and
|
||||
validate.py's own docstring calls it case (a).
|
||||
"""
|
||||
if tuning.slew_w:
|
||||
return abs(float(tuning.slew_w))
|
||||
return SLEW_RAIL_FRAC * abs(float(tuning.max_w))
|
||||
|
||||
|
||||
def unasked_power(prev_w: float, actual_w: float, saturation_w: float) -> bool:
|
||||
"""True when `actual_w` cannot be explained by THIS cycle's command not being met.
|
||||
|
||||
SAFETY-13. The one question the saturation magnitude test cannot answer:
|
||||
is the inverter FAILING TO REACH what we asked, or is it doing something
|
||||
the command in front of it does not account for?
|
||||
|
||||
⚠️ THE ANSWER IS PHYSICAL, NOT STATISTICAL, WHICH IS WHY IT IS A PREDICATE
|
||||
AND NOT A THRESHOLD. A limit can only make the inverter do LESS than the
|
||||
command, in the command's own direction. It cannot make it overshoot the
|
||||
command, and it cannot make it produce power of the OPPOSITE sign. So the
|
||||
whole set of readings a limit can produce is the closed interval spanned by
|
||||
0 and `prev_w`, widened by `saturation_w` for measurement noise, ramp lag
|
||||
and the inverter's own idle draw.
|
||||
|
||||
⚠️ WHAT IT DOES NOT SAY - AND AN EARLIER VERSION OF THIS DOCSTRING DID -
|
||||
IS THAT ANYTHING OUTSIDE THE INTERVAL WAS PUT THERE BY SOMETHING OTHER THAN
|
||||
THIS LOOP. THAT IS FALSE AND IT WAS MEASURED FALSE. This predicate has no
|
||||
memory: `prev_w` is one cycle's command, while the plant carries every
|
||||
command before it. Drive ONE inverter, with nothing else on the bus, from a
|
||||
discharge down through zero, and the pack is still coasting in the old
|
||||
direction after the command has crossed. Measured in the container, one
|
||||
`compute()` against one plant: eight cycles after a +1990 W command the
|
||||
command is 0 and the pack still reads +522.3 W, so
|
||||
`unasked_power(0, 522.3, 500)` is True - and every watt of that 522.3 was
|
||||
asked for by this loop. In 1280 single-inverter closed-loop scenarios with
|
||||
no second writer in them at all, the exit built on this predicate fires in
|
||||
24 of them.
|
||||
|
||||
So what it guarantees is exactly the sentence at the top: this cycle's
|
||||
command does not account for this cycle's reading. What it CANNOT do is
|
||||
tell "somebody else is writing" from "we are". That gap is the reason
|
||||
SETTLE_CYCLES is derived from the plant's slower direction, why the caller
|
||||
checks both, and why the reason string it produces is a place to start
|
||||
looking rather than a finding.
|
||||
|
||||
Worked, at `saturation_w = 500`:
|
||||
|
||||
prev +2000, actual +700 -> inside [0, 2000] a limit. False.
|
||||
prev -5000, actual -700 -> inside [-5000, 0] a limit. False.
|
||||
prev 0, actual +700 -> outside [-500, 500] unasked. True.
|
||||
prev -800, actual +700 -> outside [-1300, 500] unasked. True.
|
||||
|
||||
⚠️ AT `prev_w == 0` THIS IS EXACTLY `saturated_now`, and that is not a
|
||||
coincidence to be tidied away - it is the reason the freeze's zero case is
|
||||
the one the card was written about. With nothing commanded there is no
|
||||
interval left for a LIMIT to live in, so at zero this test cannot be
|
||||
answered by "the inverter is not reaching what we asked" at all. It can
|
||||
still be answered by "the inverter has not finished leaving what we asked
|
||||
for LAST time", which is the coast-down above and is why zero needs the
|
||||
time bound as much as any other `prev_w` does.
|
||||
|
||||
⚠️ IT IS NOT A DETECTOR AND MUST NOT BE ALARMED ON. A saturated inverter
|
||||
whose reading is stale, wrong-signed or simply noisy lands outside the
|
||||
interval too, and so does our own ramp, above. All this predicate decides
|
||||
is whether the output freeze gets an EXIT; being wrong about it costs at
|
||||
most a bounded command that the frozen integrator and the output clamp both
|
||||
still bound. SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on
|
||||
sign, and it is deliberately a different test with a different threshold.
|
||||
"""
|
||||
lo = min(0.0, float(prev_w)) - abs(float(saturation_w))
|
||||
hi = max(0.0, float(prev_w)) + abs(float(saturation_w))
|
||||
return not (lo <= float(actual_w) <= hi)
|
||||
|
||||
|
||||
def compute(
|
||||
prev_w: float,
|
||||
grid_w: float,
|
||||
@@ -130,7 +402,7 @@ def compute(
|
||||
# clockless; the remaining ceiling is that saturation_cycles expresses a
|
||||
# bound in seconds rather than an exact number of them.
|
||||
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
||||
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
||||
sat_count = min(sat_count + 1, _SAT_COUNT_CAP) if saturated_now else 0
|
||||
frozen = sat_count >= tuning.saturation_cycles
|
||||
|
||||
# --- deadband ----------------------------------------------------------
|
||||
@@ -231,7 +503,8 @@ def compute(
|
||||
if clamped:
|
||||
reason = "clamped"
|
||||
|
||||
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
|
||||
slew = slew_limit_w(tuning)
|
||||
slewed = max(prev_w - slew, min(prev_w + slew, target))
|
||||
if slewed != target:
|
||||
reason = "slew-limited"
|
||||
target = slewed
|
||||
@@ -243,8 +516,69 @@ def compute(
|
||||
# implementation: commanding 0 while the inverter reports >500 W means
|
||||
# something else is driving the bus, and that is not the moment to
|
||||
# start pushing power the other way.
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
#
|
||||
# --- SAFETY-13: ...AND IT IS NOT THE MOMENT FOREVER EITHER ----------
|
||||
# Everything above stands. What was missing is an END. Held at
|
||||
# `prev_w == 0` this is a refusal to START, and nothing in this
|
||||
# function or above it clears it - not the error growing, not the
|
||||
# integrator moving, not time. Measured on release/1.0 @ c8dcb38, at
|
||||
# the live install's shape (max_w 5000, slew_w 400, target_grid_w -10):
|
||||
# a site resting in the deadband with another writer holding the pack
|
||||
# at +700 W reaches sat_count 3 with the command at 0, and 0 W is then
|
||||
# a FIXED POINT - a 2 kW export arriving afterwards leaves the command
|
||||
# at exactly 0.0 W on every subsequent cycle, with the integrator
|
||||
# standing at -1194 W the whole time.
|
||||
#
|
||||
# ⚠️ AND THE ZERO CASE IS NOT SPECIAL, WHICH IS WHY THE FIX IS NOT
|
||||
# SPECIAL EITHER. The same run pinned at prev_w == -800 W, against its
|
||||
# own integrator standing at -2388 W, for as long as it ran.
|
||||
# `prev_w == 0` is only the instance where the pinned magnitude happens
|
||||
# to be zero - i.e. where the standing cost is the whole of what the
|
||||
# battery could have done.
|
||||
# A rule written for the zero case alone would have left the other
|
||||
# instances exactly as unbounded as it found them.
|
||||
#
|
||||
# ⚠️ WHAT DECIDES IS NOT HOW LONG, IT IS WHETHER THE FREEZE CAN EVER
|
||||
# END ON ITS OWN. See SETTLE_CYCLES and `unasked_power()` at the top:
|
||||
# a divergence a LIMIT could produce clears itself once the inverter
|
||||
# catches up to the pinned command, so it needs no exit and gets none -
|
||||
# unbounded, exactly as shipped. A divergence a limit CANNOT produce
|
||||
# does not clear itself by being waited on, so it gets one.
|
||||
#
|
||||
# ⚠️ AND THAT IS NOT THE SAME SENTENCE AS "THE OTHER WRITER". An
|
||||
# earlier version of this comment said the two conditions below fire
|
||||
# only in case (b); they do not, and the sweep says so. Our own plant
|
||||
# coasting through a command that has just crossed zero satisfies
|
||||
# `unasked_power()` too, because that predicate compares against ONE
|
||||
# cycle's command and the pack remembers more than one - measured, a
|
||||
# single inverter with nothing else on the bus reaching this branch in
|
||||
# 24 of 1280 closed-loop scenarios. `SETTLE_CYCLES` is what keeps that
|
||||
# rare rather than routine (113 of the same 1280 at the old bound of
|
||||
# 9), and it does it by believing the plant for as long as the plant
|
||||
# has ever been measured to take. It is a bound on how long we wait,
|
||||
# not a proof about what we found.
|
||||
#
|
||||
# ⚠️ SO THE RELEASE IS WRITTEN TO BE SURVIVABLE WHEN IT IS WRONG, and
|
||||
# that is the paragraph below, not this one: what a false release costs
|
||||
# is one slew step per cycle toward a frozen integrator. If it ever has
|
||||
# to cost less than that, the thing to change is what happens after the
|
||||
# release, not the confidence of the sentence before it.
|
||||
#
|
||||
# ⚠️ RELEASING IS NOT UNFREEZING. The INTEGRATOR freeze above is not
|
||||
# lifted, and that is what makes this bounded rather than merely
|
||||
# different: the command is free to move, but only as far as an
|
||||
# integrator that stopped accumulating cycles ago, through the same
|
||||
# slew limit as any other cycle. It buys back the standing error, not
|
||||
# the rail.
|
||||
#
|
||||
# `reason` is authoritative here in a way it is not elsewhere in this
|
||||
# function: nothing after this line writes it.
|
||||
if unasked_power(prev_w, actual_w, tuning.saturation_w) \
|
||||
and sat_count >= SETTLE_CYCLES:
|
||||
reason = "freeze-released"
|
||||
else:
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
|
||||
# --- quantise ----------------------------------------------------------
|
||||
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
"""SAFETY-42 - a unit that stops delivering its share, which nothing else can see.
|
||||
|
||||
At one inverter this file is inert by construction (see `delinquent()`), and
|
||||
that is deliberate rather than a limitation: the whole signal it reads is a
|
||||
DIFFERENCE BETWEEN SIBLINGS, and a roster of one has none.
|
||||
|
||||
WHAT IT CATCHES, in one sentence: an inverter that accepts its share and does
|
||||
not produce it, beside a sibling that does - HW-01's shape at N=2, where the
|
||||
2026-09-02 fault wedged inverter A for 6 h 25 m with every layer a spectator.
|
||||
|
||||
WHY EVERY EXISTING GUARD IS SILENT, AND IT IS STRUCTURAL
|
||||
=======================================================
|
||||
SAFETY-30 per unit gates on OPPOSITION: both the command and the unit's own
|
||||
power over `unit_sign_disagree_w` (250 W) with opposite
|
||||
signs. A unit sitting at 0 W never opens that gate. It is
|
||||
not opposing, it is ABSENT.
|
||||
SAFETY-30 site the sum of the commands still AGREES with the meter in
|
||||
direction. Only the magnitude is short, and the site
|
||||
comparison is a sign test.
|
||||
SAFETY-31 `sum(|Bi|) - |sum(Bi)|` is ZERO when one unit does
|
||||
nothing: circulation needs two units pulling AGAINST each
|
||||
other, and one of these is pulling nowhere at all.
|
||||
the saturation freeze fires - and `clamp.py`:93-96 says of exactly this: "A
|
||||
loop can be legally saturated all day - a cold pack, a
|
||||
curtailed inverter, an export limit - so a
|
||||
saturation-shaped alarm is one an operator learns to
|
||||
ignore."
|
||||
|
||||
⚠️ SO THE ONE SIGNAL THAT DOES FIRE IS THE ONE THIS CODEBASE DOCUMENTS AS
|
||||
UNREADABLE, and that documentation is right. This module therefore does not make
|
||||
that signal louder; it computes a different one.
|
||||
|
||||
⚠️ THE ONE PLACE IT OVERLAPS SAFETY-30, AND THE OVERLAP RESOLVES ITSELF. A unit
|
||||
measured at -2000 W against a +2000 W share is OPPOSING - SAFETY-30's fault -
|
||||
and is ALSO short by 4000 W with nothing here to explain it. Both detectors have
|
||||
evidence, and only one alarm results, because SAFETY-30's per-unit hold is the
|
||||
shorter of the two (85 s against 185 s at the shipped defaults): it trips first,
|
||||
files its `failsafe` SET, the share goes to 0 W, and this check's gate closes on
|
||||
`share > 0` while `account()` answers "parked" as well. That is asserted on a
|
||||
reversed-unit fixture in `test_delivery.py` part 6 rather than left to the
|
||||
reader, because two alarms for one fault is how an operator learns to skim.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE CARD'S OWN DISCRIMINATOR WAS MEASURED AND IT DOES NOT WORK - READ THIS FIRST
|
||||
==============================================================================
|
||||
SAFETY-42 proposed: alarm on `saturation persisting past N` while
|
||||
`|grid error| > X`, with N and X derived rather than chosen. That predicate was
|
||||
built and run on the two-master rig before this module was written, and it
|
||||
fails in BOTH directions. Neither failure is a matter of picking better N and X;
|
||||
both are properties of the quantity.
|
||||
|
||||
1. IT FIRES ON EVERY BENIGN SATURATION, and the two cases are not merely
|
||||
similar - they are THE SAME TRACE. `plantsim.Plant.limit()` returns 0 W for
|
||||
a discharge command into a pack at its own floor; a wedged unit that never
|
||||
receives its command also produces 0 W. Run both against the same stimulus
|
||||
(load 2000 W, equal weights, 2000 W per-unit cap) and every emitted watt
|
||||
agrees: over 120 control cycles the two arms are IDENTICAL on all 120 in
|
||||
(grid, site target, each unit's share, each unit's measured power). Both
|
||||
report `reason == "saturated-freeze"` on 116 of those 120 cycles - the
|
||||
other four are `slew-limited`, before the freeze engages on cycle 4 - and
|
||||
both hold |grid error| over 300 W on all 120, settling at 1200 W. A
|
||||
predicate reading only those
|
||||
numbers cannot separate them at ANY (N, X), because nothing in its inputs
|
||||
differs. And "a cold pack at its floor must not alarm" is this card's own
|
||||
acceptance criterion. `test_delivery.py` part 4 asserts that identity on
|
||||
the add-on's own end-to-end fixture, so it goes red if it ever stops being
|
||||
true.
|
||||
|
||||
2. IT IS SILENT ON THE HEADLINE CASE, because the standing grid error is
|
||||
exactly what a HEALTHY SIBLING ERASES. Same rig, load 800 W, equal weights,
|
||||
2000 W per-unit cap, inverter B wedged: over the last 30 of 200 cycles the
|
||||
loop is saturation-frozen on every one of them - so "saturation persisting
|
||||
past N" is true for any N - while the worst |grid error| in that window is
|
||||
30 W. B is standing at a +770 W discharge share and delivering 0 W. Any X
|
||||
above 30 W leaves the card's predicate silent for the whole run, on the
|
||||
exact fault it was written for: a healthy sibling regulating alone with the
|
||||
site short by one unit's share.
|
||||
|
||||
The card's derivation instruction stands and is honoured; what could not be
|
||||
derived was a threshold on the wrong quantity. This is written down rather than
|
||||
quietly replaced, because the next person to read the card will propose it again.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE QUANTITY THAT DOES WORK, AND WHY IT NEEDS TWO INVERTERS
|
||||
===========================================================
|
||||
Per unit, for a share that asks it to DISCHARGE:
|
||||
|
||||
short_by(i) = share_i - measured_i (watts that did not arrive)
|
||||
|
||||
`share_i` is the command STANDING ON THAT UNIT'S WIRE - `Inverter.standing_w()`,
|
||||
the same operand SAFETY-30's per-unit comparison uses - and `measured_i` is that
|
||||
unit's own power, holding register 0x0518, per-instance since DEV-02.
|
||||
|
||||
That number alone is the saturation signal again, per unit. What makes it
|
||||
readable is the reduction over the roster:
|
||||
|
||||
a unit is DELINQUENT while it is short with NO ACCOUNT FOR IT, and the set
|
||||
of units in that state is a PROPER SUBSET of the units that were asked to
|
||||
discharge.
|
||||
|
||||
Both halves are load-bearing:
|
||||
|
||||
* THE ACCOUNT is what keeps the cold pack out, and it is the only thing that
|
||||
can. The two cases are identical in the power domain (see 1 above), so the
|
||||
discriminator has to be a fact this controller reads that is NOT power - and
|
||||
there is exactly one: the pack's own state of charge against its own
|
||||
`bms_soc_floor`. `account()` below asks it through `peakguard.
|
||||
discharge_permitted()`, the predicate SAFETY-32 already made public, rather
|
||||
than transcribing the comparison a second time.
|
||||
* THE PROPER SUBSET is what keeps a site-wide cause out, and it is the half
|
||||
that only exists at N>=2. A derate this add-on cannot see - a thermal
|
||||
limit, a grid-code curtailment, an inverter-side DC restriction - hits the
|
||||
whole roster. One box that has stopped listening does not. If EVERY unit
|
||||
that was asked to deliver is short and unexplained, this module says
|
||||
nothing: it cannot tell that from a plant that simply cannot do it, and
|
||||
saying so anyway is the saturation-shaped alarm all over again.
|
||||
|
||||
⚠️ AT N=1 THE SUBSET IS NEVER PROPER AND THIS IS INERT. One asked unit is either
|
||||
accounted for (nothing to say) or unaccounted for (the whole roster, so nothing
|
||||
to compare it against). `delinquent()` returns `{}` for every input at a roster
|
||||
of one - not by a guard on `len`, but because a proper subset of a one-element
|
||||
set that contains the element is impossible. That is AC 5, and it is asserted
|
||||
rather than assumed in `test_delivery.py` part 1.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
DISCHARGE SHARES ONLY, AND THE COST OF THAT IS STATED RATHER THAN HIDDEN
|
||||
=======================================================================
|
||||
`short_by()` answers None for a charge share. The account gate needs a bound
|
||||
this controller actually reads in the commanded direction, and there is one for
|
||||
discharge - `bms_soc_floor`, required of every roster entry since SAFETY-32 -
|
||||
and NONE for charge. A pack in constant-voltage taper legitimately takes
|
||||
hundreds of watts less than it is told for tens of minutes, and there is no
|
||||
configured ceiling anywhere in this add-on to explain that with. Armed on charge
|
||||
shares this module would alarm on the taper of every §12 maintenance charge,
|
||||
which is the crying-wolf failure it exists to avoid.
|
||||
|
||||
What that costs, said plainly: a unit that stops ABSORBING its share is not
|
||||
caught here. It is the same fault in the other direction and it wants the same
|
||||
treatment; what it needs first is a per-pack charge ceiling this add-on does not
|
||||
have. That is a card, and this paragraph is its evidence.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
IT REPORTS. IT DOES NOT ACT - AND THAT IS A DECISION, NOT AN OMISSION
|
||||
====================================================================
|
||||
Card requirement 3 asks which, and why. Three candidates, and the reasoning is
|
||||
recorded because "alarm-only" is the weaker-looking answer and is still right:
|
||||
|
||||
COMMAND IT HARDER - no. HW-01 settles it: on 2026-09-02 the power stage went
|
||||
on executing its last instruction for 6 h 25 m through every command this
|
||||
add-on sent it. A command is not a remedy for a box that is not listening,
|
||||
and pretending otherwise is theatre.
|
||||
|
||||
RE-ALLOCATE ITS SHARE TO THE SIBLING - no, AND NOT FOR THE COMFORTABLE
|
||||
REASON. The comfortable reason would be that the loop already does it, and
|
||||
the rig says otherwise: with B wedged and equal weights, dropping B from
|
||||
`live` - the mechanism `live_units()` already uses for a dead link - is a
|
||||
real improvement at loads the site cannot cover on one unit's SHARE.
|
||||
Measured, per-unit cap 2000 W, B wedged:
|
||||
|
||||
load 1500 W kept in the split: the site settles at 800 W with a 700 W
|
||||
grid error and NEVER comes inside 100 W in 150 cycles -
|
||||
the saturation freeze pins the site target, so A's share
|
||||
never rises. Dropped: 1500 W delivered, inside 100 W from
|
||||
cycle 14.
|
||||
load 2000 W kept: 800 W delivered, 1200 W error, never settles.
|
||||
Dropped: 2000 W delivered, inside 100 W from cycle 8.
|
||||
|
||||
That is not three cycles of latency; it is the difference between a site
|
||||
that recovers and one that does not. It is the strongest argument on this
|
||||
card and it is stated first, because the answer still has to be no.
|
||||
|
||||
WHAT REFUSES IT is that the only re-allocation this add-on can express is
|
||||
ALL OR NOTHING. `live_units()` drops a unit entirely and the survivors carry
|
||||
the whole target; there is no way to say "give the sibling the watts that
|
||||
did not arrive and leave this unit the ones that did". And a unit delivering
|
||||
PART of its share is short by more than `saturation_w` too, with nothing
|
||||
here to explain it, so this detector names it - it cannot tell a 50 %
|
||||
derate from a wedged box, because neither can any instrument this add-on
|
||||
has. Measured, B derated to 50 %, load 3000 W, per-unit cap 2000 W, and
|
||||
SAFETY-42 names B in that run:
|
||||
|
||||
kept in the split site delivers 2400 W (B contributing 800 W),
|
||||
grid error 600 W
|
||||
dropped site delivers 2000 W (B contributing 0 W),
|
||||
grid error 1000 W - 400 W WORSE
|
||||
|
||||
So the remedy would make the site worse in a case this detector cannot rule
|
||||
out, on a live control law, driven by a brand-new alarm. A partial
|
||||
re-allocation - the sibling gets the undelivered watts without the short
|
||||
unit being commanded to zero - is what would make acting safe, and the
|
||||
allocator has no way to express it today. That is a card, and this block is
|
||||
its evidence.
|
||||
|
||||
STOP THE SITE, as SAFETY-31 does - no, and the asymmetry with SAFETY-31 is
|
||||
exact. Circulating power is stopped because THIS CONTROLLER IS ONE OF THE
|
||||
TWO PARTICIPANTS: removing our half halves the loop and un-hides the residue
|
||||
at a meter that was netting it to zero. Here this controller is not a
|
||||
participant in the fault, nothing is netting, and the sibling is the only
|
||||
thing still delivering. Parking it would take the site from "short by one
|
||||
unit" to "short by all of them" and would remove the evidence.
|
||||
|
||||
So: a durable latch, a named unit, an action-log row, and an operator
|
||||
notification - and the loop is left exactly as it was. Requirement 4 of the card
|
||||
("do not weaken the saturation freeze to make room") is met by not touching
|
||||
`control.py` at all.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
IT SURVIVES A RESTART, WHICH IS SAFETY-34 AND IS WHY THE LATCH EXISTS AT ALL
|
||||
===========================================================================
|
||||
`persistent_notification` is IN MEMORY. Home Assistant restarts and the card is
|
||||
gone with the fault still running, which is precisely how the 2026-09-02 outage
|
||||
went unseen for 6 h 25 m. So the durable half is `unit/<name>/delivery_tripped`
|
||||
and `unit/<name>/delivery_reason` in `/data/state.json` - namespaced per unit,
|
||||
for `store.unit_key()`'s reason - and `main.py` re-raises the notification from
|
||||
it on the first cycle of every process.
|
||||
|
||||
⚠️ AND THAT IS WHY THIS LATCHES THOUGH IT DOES NOT ACT. A detector that cleared
|
||||
itself when the unit resumed would erase its own record of an intermittent
|
||||
fault, which is the one shape an operator most needs to be told about. It is
|
||||
cleared by the same operator press that clears SAFETY-02's and SAFETY-31's -
|
||||
a deliberate human act, after looking at why.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .peakguard import discharge_permitted
|
||||
|
||||
# The reason code. Distinct from every other one in this add-on by construction:
|
||||
# nothing else uses it, and it deliberately does not contain the word
|
||||
# "saturation" - see the module docstring on why a saturation-shaped alarm is
|
||||
# the thing this module must not produce.
|
||||
REASON_CODE = "UNIT_NOT_DELIVERING"
|
||||
|
||||
# CFG-03. THE ONE IN-CODE COPY OF THIS DEFAULT. `config.yaml`'s `options:` block
|
||||
# is the source of truth an operator edits and the Supervisor reads; `schema:`
|
||||
# holds the bounds. This dict exists for the reason
|
||||
# `circulation.CIRC_OPTION_DEFAULTS` and `watchdog.SIGN_OPTION_DEFAULTS` do -
|
||||
# the Dockerfile does not put config.yaml in the image, so the running add-on
|
||||
# cannot read it - and `test_delivery.py` pins the two together by READING
|
||||
# config.yaml, so a drift goes red in CI rather than shipping.
|
||||
#
|
||||
# ⚠️ THERE IS ONLY ONE KEY HERE, AND THE MISSING SECOND ONE IS THE POINT. The
|
||||
# other half of this check is a WATTS threshold, and it is not an option and not
|
||||
# a constant: it is `control.Tuning.saturation_w`, read at runtime. See
|
||||
# `MIN_W_IS_SATURATION_W` below.
|
||||
DELIVERY_OPTION_DEFAULTS = {
|
||||
# --- THE WINDOW, AND IT IS THE ONLY NUMBER THIS CARD CHOSE ------------
|
||||
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT PICKED. 2640 closed-loop
|
||||
# runs at a roster of two in which NOTHING is faulty - every unit executes
|
||||
# the share the allocator gave it - over 6 gains x 5 slews, the whole range
|
||||
# config.yaml accepts for `gain` float(0.05,1.0) and `slew_w` int(0,5000),
|
||||
# across three families of stimulus that can produce a legitimate shortfall:
|
||||
#
|
||||
# 1050 load reversals 7 sizes x 5 write staggers (the serialised write
|
||||
# path landing B's number up to four cycles late)
|
||||
# 840 flapping loads 7 periods x 4 amplitudes
|
||||
# 750 weighted splits 5 weightings x 5 staggers on a +-2200 W reversal
|
||||
#
|
||||
# ⚠️ THE WEIGHTED FAMILY IS NEW HERE AND IT IS THE ONE THAT DOMINATES. An
|
||||
# asymmetric split hands one unit a much larger step than its sibling, so
|
||||
# the big one is still ramping while the small one has settled - which is
|
||||
# exactly the PROPER SUBSET this check keys on. SAFETY-30's campaign had no
|
||||
# such family, so reading its numbers across would have understated this.
|
||||
#
|
||||
# Every run was executed a second time at a roster of ONE, on the same
|
||||
# stimulus, as the control. Worst CONTINUOUS legitimate episode:
|
||||
#
|
||||
# threshold reversals flapping weighted N=1 control (1890 runs)
|
||||
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s
|
||||
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s
|
||||
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s
|
||||
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s
|
||||
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s
|
||||
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s
|
||||
#
|
||||
# * is the shipped threshold, `Tuning.saturation_w`.
|
||||
#
|
||||
# ⚠️ THREE THINGS IN THAT TABLE ARE WORTH MORE THAN THE ROW THEY SIT ON.
|
||||
# (a) The N=1 column is ZERO EVERYWHERE, in all 1890 runs at every
|
||||
# threshold. The compatibility promise is measured, not argued.
|
||||
# (b) The FLAPPING column is zero everywhere too - and that is the family
|
||||
# that defeated SAFETY-30's site window (980 s at N=2, 1000 s at N=1, no
|
||||
# window inside the billing quarter surviving it). It cannot touch this
|
||||
# check, because a common stimulus makes BOTH units short at once and
|
||||
# the subset stops being proper.
|
||||
# (c) THE CURVE IS NOT MONOTONE - 55.0 s at 250 W against 40.0 s at 100 W -
|
||||
# and that is a property of the reduction, not noise: a lower threshold
|
||||
# puts MORE units into the short set, which can turn a proper subset
|
||||
# into the whole roster and BREAK the run. Do not read it as a
|
||||
# sensitivity curve.
|
||||
#
|
||||
# THE BOUNDS THE VALUE ANSWERS TO:
|
||||
#
|
||||
# LOWER, from the rig - 30.0 s, the worst continuous legitimate episode at
|
||||
# the shipped threshold across all 2640 runs.
|
||||
#
|
||||
# LOWER, from the firmware - `inverter.LINK_STALE_S`, 30 s. A unit not
|
||||
# executing its share for less than the firmware's own veto window is a
|
||||
# LINK fault the add-on already answers by dropping it from the split with
|
||||
# a line in the action log. Reporting inside that window would be a
|
||||
# second, louder alarm about a state already being handled - the
|
||||
# SAFETY-15/SAFETY-19 failure exactly.
|
||||
#
|
||||
# MARGIN, from INVERTER B NOT EXISTING, AND IT IS MEASURED RATHER THAN
|
||||
# GUESSED. The campaign's B is A COPIED (`plantsim.INVERTER_B`,
|
||||
# `measured=False`), so the 30.0 s above is two identical machines.
|
||||
# Sweeping a DIFFERENT second unit through the weighted family: 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 lag, 90.0 s at
|
||||
# four times it. That is the sensitivity the margin has to carry until
|
||||
# SPIKE-04 measures the real unit.
|
||||
#
|
||||
# UPPER, from the bill - 900 s, the quarter that sets the capacity tariff.
|
||||
# A site running short of one unit's share must be reported well inside
|
||||
# one.
|
||||
#
|
||||
# 185 s: thirty-seven control cycles. 6.2x the worst legitimate episode this
|
||||
# rig produces, 2.06x the worst a second unit FOUR TIMES slower than A
|
||||
# produces, 6.2x the firmware's own veto window, and 4.9x inside the
|
||||
# quarter. ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
|
||||
# is a different check on a different quantity, so a reader must not be
|
||||
# able to think it was carried across - and not 150 or 200.
|
||||
#
|
||||
# WHAT IT COSTS, stated rather than hidden: up to 185 s of a site running
|
||||
# short by one unit's share before anybody is told. Against a fault that ran
|
||||
# 6 h 25 m unnoticed, and against a guard that TAKES NO ACTION, this window
|
||||
# buys its margin with reporting latency and with nothing else.
|
||||
"delivery_hold_s": 185,
|
||||
}
|
||||
|
||||
|
||||
# ⚠️ WHY THERE IS NO `delivery_w` OPTION, AND WHY THAT IS A DERIVATION RATHER
|
||||
# THAN A SAVING. The watts side of this check asks "how far short is far
|
||||
# enough to be worth saying anything about", and the shipped control law
|
||||
# already answers that question in one number: `Tuning.saturation_w`, 500 W,
|
||||
# the gap at which `compute()` itself concludes THE INVERTER IS NOT FOLLOWING
|
||||
# and freezes the integrator. Below it the loop calls the divergence ordinary
|
||||
# tracking, and an alarm under a threshold the control law calls normal is an
|
||||
# alarm about nothing.
|
||||
#
|
||||
# So `main.py` hands this check `self.tuning.saturation_w` and there is no
|
||||
# fourth copy of a default to keep in step (CFG-03's complaint) and no way for
|
||||
# the two to drift: an operator who raises `saturation_w` has told the loop
|
||||
# that a bigger gap is ordinary tracking, and this check moves with them by
|
||||
# construction rather than by somebody remembering.
|
||||
#
|
||||
# ⚠️ AND THAT SENTENCE IS A CHECKED CLAIM, NOT A DESCRIPTION OF AN INTENTION.
|
||||
# It is only true while the call site passes the CONFIGURED value; a literal
|
||||
# there that happens to equal 500 today keeps every fixture green and quietly
|
||||
# removes the whole guarantee - `250.0` in place of `self.tuning.saturation_w`
|
||||
# was a survivor of all 23 suites, because every fixture's gap was far above
|
||||
# both numbers. test_delivery.py part 4b is what makes it checkable: it runs
|
||||
# the same wedged unit at the shipped `saturation_w` and at a raised one inside
|
||||
# the shipped schema, with a shortfall that falls BETWEEN them, and the two
|
||||
# arms must answer differently. No constant passes both.
|
||||
#
|
||||
# ⚠️ IT IS ALSO WHY THIS CANNOT DOUBLE-REPORT THE SATURATION FREEZE'S OWN
|
||||
# EVENT. `compute()` needs `saturation_cycles` (3) CONSECUTIVE cycles over
|
||||
# that same gap before it freezes anything, so every cycle this module counts
|
||||
# is a cycle the freeze has already seen. This is a reduction over the freeze's
|
||||
# input, not a second opinion about it.
|
||||
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryConfig:
|
||||
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
||||
|
||||
hold_s: float = float(DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitDelivery:
|
||||
"""ONE unit's four facts for this cycle, and nothing else.
|
||||
|
||||
`name` the roster name. An alarm that cannot say which box to look at
|
||||
is the whole reason SAFETY-30 split per-unit from site.
|
||||
`share_w` what is STANDING on this unit's wire - `Inverter.standing_w()`,
|
||||
the same operand SAFETY-30's per-unit comparison takes, after
|
||||
the deadband, the slew limit, quantisation and the arbiter.
|
||||
Not the optimizer's wish: the wire carries the emitted value.
|
||||
`measured_w` this unit's OWN power, holding register 0x0518, + = discharge.
|
||||
None when the reading did not arrive.
|
||||
`accounted` why a shortfall on this unit is already explained, or None if
|
||||
nothing this controller reads explains one. Built by
|
||||
`account()`; it is a field rather than a method because the
|
||||
facts behind it (liveness, the sign latch, SoC) belong to
|
||||
main.py's `Inverter` and this module imports none of it.
|
||||
"""
|
||||
|
||||
name: str
|
||||
share_w: float | None
|
||||
measured_w: float | None
|
||||
accounted: str | None = None
|
||||
|
||||
|
||||
def short_by(share_w, measured_w):
|
||||
"""Watts of a DISCHARGE share that did not arrive, or None if unanswerable.
|
||||
|
||||
Positive means short. Zero and negative are folded into None rather than
|
||||
reported, because "delivered its share" and "delivered more than its share"
|
||||
are both not-this-fault and a caller comparing a float against a threshold
|
||||
should not have to know which.
|
||||
|
||||
⚠️ CHARGE SHARES ANSWER None, AND THAT IS THE STATED LIMIT AT THE TOP OF
|
||||
THIS FILE, not an oversight to be tidied away. There is no configured charge
|
||||
ceiling in this add-on to explain a taper with.
|
||||
|
||||
⚠️ AND A MISSING READING ANSWERS None RATHER THAN "the whole share". Zero
|
||||
would be this check inventing a fault from an absence, which is the shape of
|
||||
the 2026-09-02 outage read the other way round; and the site is already
|
||||
stopped for a missing per-unit reading anyway (`inputs_ok` requires every
|
||||
unit's `batt`, and §11.1 rule 1 commands 0 W without consulting this).
|
||||
"""
|
||||
if share_w is None or measured_w is None:
|
||||
return None
|
||||
share = float(share_w)
|
||||
if share <= 0.0:
|
||||
return None
|
||||
gap = share - float(measured_w)
|
||||
return gap if gap > 0.0 else None
|
||||
|
||||
|
||||
def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None:
|
||||
"""Why a shortfall on this unit is ALREADY EXPLAINED, or None if nothing is.
|
||||
|
||||
Every branch here is a state this add-on can read and already has an answer
|
||||
for, and naming one of them a fault would be raising a second, louder alarm
|
||||
about something already being handled - the SAFETY-15/SAFETY-19 failure that
|
||||
`circulation.py` and `watchdog.py` both take their 30 s lower bound from.
|
||||
|
||||
`live` `Inverter.is_live()`. A unit whose link is stale or whose
|
||||
writes are being rejected has ALREADY been dropped from the
|
||||
split with a line in the action log saying so.
|
||||
`parked` that unit's sign watchdog has latched. It is being commanded
|
||||
0 W on purpose; not delivering is compliance, not a fault.
|
||||
`soc` that pack's measured state of charge, percent. None is not a
|
||||
full battery and is not permission - see below.
|
||||
`bms_floor` that pack's OWN cut-out, `bms_soc_floor`. Required of every
|
||||
roster entry since SAFETY-32, so at the only roster this
|
||||
module is live at it is always configured.
|
||||
|
||||
⚠️ THE SoC BRANCH IS THE WHOLE DISCRIMINATOR AND IT IS ASKED THROUGH
|
||||
`peakguard.discharge_permitted()`, WHICH IS PUBLIC FOR EXACTLY THIS REASON:
|
||||
"a safety predicate transcribed into its own check is one copy plus a future
|
||||
disagreement about what the check means". What is NOT reused is its wording,
|
||||
which is peak-guard flavoured ("the peak guard's SoC floor") and would be a
|
||||
false statement here - this module is not the peak guard and its floor is
|
||||
the pack's own cut-out, not `peak_soc_floor`. One predicate, two sentences.
|
||||
⚠️ AND THE FLOOR PASSED IN IS `bms_soc_floor` ALONE, NOT
|
||||
`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`. That
|
||||
conjunction is right for the peak guard and wrong here: `peak_soc_floor`
|
||||
bounds a money-saving discharge only (SAFETY-25), ordinary self-consumption
|
||||
discharges straight past it, and using it would EXPLAIN AWAY a wedged unit
|
||||
sitting anywhere between the two floors.
|
||||
|
||||
⚠️ EVERY UNKNOWN ACCOUNTS FOR THE SHORTFALL, i.e. FAILS TOWARD SILENCE, and
|
||||
that direction is the opposite of the one `discharge_permitted()` was
|
||||
written for. There it withholds a discharge; here it withholds an ALARM. An
|
||||
unreadable SoC or an unstated floor means this module cannot say whether the
|
||||
pack is empty, and an alarm nobody can justify is the crying-wolf failure
|
||||
this whole file is written against. The cost is bounded: the same absence
|
||||
stops the site through `inputs_ok` (SoC) or is refused at startup by the
|
||||
roster schema (the floor).
|
||||
"""
|
||||
if not live:
|
||||
return ("its link is stale or its writes are being rejected, and the "
|
||||
"allocator has already dropped it from the split")
|
||||
if parked:
|
||||
return "it is latched at 0 W by a sign trip, so it is doing as it is told"
|
||||
why = discharge_permitted(soc, bms_floor)
|
||||
if why is not None:
|
||||
if soc is None:
|
||||
return "its state of charge did not read, so its pack cannot be graded"
|
||||
if bms_floor is None:
|
||||
return ("no bms_soc_floor is configured for it, so there is nothing "
|
||||
"to say whether its pack is empty")
|
||||
return (f"its pack is at {float(soc):.0f} %, at or below its own "
|
||||
f"{float(bms_floor):.0f} % cut-out (bms_soc_floor)")
|
||||
return None
|
||||
|
||||
|
||||
def delinquent(units, min_w: float) -> dict:
|
||||
"""Which units are short with NO ACCOUNT, when the roster disagrees with them.
|
||||
|
||||
`units` one `UnitDelivery` per inverter, in roster order.
|
||||
`min_w` the shortfall that counts - `control.Tuning.saturation_w`. See
|
||||
`MIN_W_IS_SATURATION_W`.
|
||||
|
||||
Returns `{name: sentence}`, empty when there is nothing to say. The sentence
|
||||
is the operator's first line and it carries the peer clause, because "B is
|
||||
not delivering" and "B is not delivering while A is" are different facts and
|
||||
only the second one is this module's conclusion.
|
||||
|
||||
⚠️ THE PROPER-SUBSET RULE IS THE CHECK, NOT A FILTER ON IT. A unit is named
|
||||
only while the units in its state are FEWER than the units that were asked
|
||||
to discharge. Everything asked and nothing explained is a SITE condition -
|
||||
a derate, a curtailment, a grid-code limit, a plant that simply cannot -
|
||||
and this module has no instrument that can tell that from every box being
|
||||
wedged at once. Reporting it anyway would be the saturation-shaped alarm
|
||||
`clamp.py` warns about, produced by the guard written to replace it.
|
||||
|
||||
⚠️ AND THAT IS WHY N=1 IS INERT WITHOUT A ROSTER GUARD. A `if n_units > 1`
|
||||
in the caller would be a second place the same decision lives; the algebra
|
||||
is the guard, the same way `circulation.circulating_w()` is identically zero
|
||||
at N<=1.
|
||||
|
||||
⚠️ BUT THE ALGEBRA THAT DOES IT IS THE PROPER-SUBSET RULE, NOT THE
|
||||
`len(asked) < 2` LINE - AND THIS PARAGRAPH USED TO SAY OTHERWISE. The
|
||||
mutation battery found it: relaxing that line to `len(asked) < 1` is a
|
||||
SURVIVOR at +0 B, and it is an EQUIVALENT MUTANT rather than a gap in the
|
||||
checks. 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 `{}`). There is no third case and
|
||||
no input on which the two spellings differ. The line is a FAST PATH and a
|
||||
signpost, not the mechanism - said here rather than left for the next
|
||||
mutation sweep to rediscover, exactly as `circulation.circulating_w()` says
|
||||
of its own `max(0.0, ...)`. Do not report its survival as a gap in the
|
||||
checks, and do not delete it either: it is what tells a reader at a glance
|
||||
that a one-inverter install never reaches any of this.
|
||||
"""
|
||||
asked = [u for u in units
|
||||
if u.share_w is not None and float(u.share_w) > 0.0]
|
||||
if len(asked) < 2:
|
||||
return {}
|
||||
|
||||
gaps = {}
|
||||
for u in asked:
|
||||
gap = short_by(u.share_w, u.measured_w)
|
||||
if gap is not None and gap > float(min_w):
|
||||
gaps[u.name] = gap
|
||||
|
||||
unaccounted = {u.name: gaps[u.name] for u in asked
|
||||
if u.name in gaps and u.accounted is None}
|
||||
if not unaccounted or len(unaccounted) == len(asked):
|
||||
return {}
|
||||
|
||||
peers = [u.name for u in asked if u.name not in unaccounted]
|
||||
out = {}
|
||||
for u in asked:
|
||||
if u.name not in unaccounted:
|
||||
continue
|
||||
out[u.name] = (
|
||||
f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W "
|
||||
f"and delivering {float(u.measured_w):+.0f} W - "
|
||||
f"{unaccounted[u.name]:.0f} W of its share is not arriving - while "
|
||||
f"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
|
||||
f"that state. Nothing this add-on reads explains it")
|
||||
return out
|
||||
|
||||
|
||||
class DeliveryWatchdog:
|
||||
"""ONE PER INVERTER, evaluated once per control cycle.
|
||||
|
||||
Per unit rather than per site, and that is card requirement 2: the site
|
||||
figure cannot say which box to look at, and naming the box is the whole
|
||||
reason SAFETY-30 split per-unit from site in the first place. It sits beside
|
||||
`Inverter.sign_wd` on the same object, under the same namespaced store keys,
|
||||
for the same reason.
|
||||
|
||||
⚠️ NOT SHARED ACROSS CADENCES, for `SignWatchdog`'s reason: `since` is a
|
||||
wall-clock start time, so two loops at two rates would each reset the
|
||||
other's window.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: DeliveryConfig | None = None, *,
|
||||
tripped: bool = False, reason: str = ""):
|
||||
self.cfg = cfg or DeliveryConfig()
|
||||
self.since: datetime | None = None
|
||||
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED - SAFETY-34, and it is the
|
||||
# requirement the card states in its own words. See the module note.
|
||||
self.tripped = bool(tripped)
|
||||
self.reason = reason if self.tripped else ""
|
||||
|
||||
def arm(self, cfg: DeliveryConfig) -> None:
|
||||
"""Set the window. Nothing else.
|
||||
|
||||
⚠️ IT IS CONFIGURATION, NOT STATE, AND IT DELIBERATELY DOES NOT TOUCH
|
||||
`since`, `tripped` OR `reason` - `SignWatchdog.arm()`'s argument,
|
||||
which was found the expensive way: a detector that cleared its own
|
||||
window when it was told what it was measuring would lose the episode it
|
||||
is in the middle of, every cycle, and would never accumulate a hold.
|
||||
"""
|
||||
self.cfg = cfg
|
||||
|
||||
def evaluate(self, sentence, now: datetime) -> bool:
|
||||
"""Advance one cycle. `sentence` is this unit's entry from `delinquent()`
|
||||
(None when it has none). Returns True while tripped.
|
||||
|
||||
`now` is the caller's clock - `SignWatchdog.evaluate()`'s and
|
||||
`CirculationWatchdog.evaluate()`'s discipline, so this is testable and
|
||||
replayable rather than reading a clock of its own.
|
||||
"""
|
||||
if self.tripped:
|
||||
# Latched. It does not re-evaluate, and unlike SAFETY-02 and
|
||||
# SAFETY-31 that is NOT because the trip removed the evidence -
|
||||
# nothing here changes what is commanded, so the evidence is still
|
||||
# standing. It is because an intermittent unit that resumed must not
|
||||
# erase its own record. See the module note.
|
||||
return True
|
||||
|
||||
if sentence is None:
|
||||
self.since = None
|
||||
return False
|
||||
|
||||
if self.since is None:
|
||||
self.since = now
|
||||
return False
|
||||
|
||||
held = (now - self.since).total_seconds()
|
||||
if held < self.cfg.hold_s:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
self.reason = (f"{sentence} - held for {held:.0f} s continuously "
|
||||
f"(limit: over {self.cfg.hold_s:.0f} s)")
|
||||
return True
|
||||
|
||||
def held_s(self, now: datetime) -> float:
|
||||
"""How long the current shortfall has stood, for the status page.
|
||||
|
||||
Takes the clock rather than reading one, for `SignWatchdog.held_s()`'s
|
||||
reason: an unclocked function is one nothing can test, which is exactly
|
||||
what happened to that one.
|
||||
"""
|
||||
if self.since is None:
|
||||
return 0.0
|
||||
return (now - self.since).total_seconds()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.since = None
|
||||
self.tripped = False
|
||||
self.reason = ""
|
||||
@@ -0,0 +1,385 @@
|
||||
"""SAFETY-37 - a reading that holds its last value and reads healthy.
|
||||
|
||||
THE DEFECT, in one sentence: `hass.number()` returns a held value as a value,
|
||||
and nothing anywhere in this add-on distinguishes "the inverter reports 40 %"
|
||||
from "the entity still says 40 % because nothing has updated it since Tuesday".
|
||||
|
||||
The architect pass of 2026-09-06 put three cards on one mechanism. This module
|
||||
is that mechanism; the other two are its consumers.
|
||||
|
||||
LAYER 1 `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` - the
|
||||
(TEL-06) ingest-age veto tests the VALUE of `sensor.goodwe_p1_sample_age`.
|
||||
A dead publisher leaves the entity frozen at a healthy number and
|
||||
the veto can never fire. NOT FIXED HERE - it is firmware, it is on
|
||||
the other side of a flash, and nothing in this module can reach
|
||||
it. What it owes is written at the bottom of this docstring.
|
||||
THIS ADD-ON `app/hass.py:69-82`, consumed by SAFETY-30's per-unit sign check
|
||||
(SAFETY-37) and SAFETY-31's circulating-power sensor. Both read `Inverter.batt`
|
||||
(register 0x0518) and NEITHER had a freshness gate of any kind.
|
||||
THE EVIDENCE HW-01, 2026-09-02: SoC held 40 % for 6 h 25 m while the pack
|
||||
drained to its floor. Every detector read healthy throughout.
|
||||
|
||||
⚠️ AT N>=2 EVERY ONE OF THOSE READINGS DOUBLES, which is why this card is the
|
||||
one that had to build it: the per-unit sign check and the circulation sensor
|
||||
only exist at N>=2, and both are fed straight from `Inverter.batt`.
|
||||
|
||||
|
||||
WHAT CANNOT BE THE SOURCE OF TRUTH, AND IT IS NOT A DETAIL
|
||||
==========================================================
|
||||
Home Assistant's `last_updated` DOES NOT MOVE when a value is rewritten
|
||||
unchanged. Neither does `last_changed`, and this project measured that
|
||||
`last_reported` does not advance on a repeat on either serialiser either - see
|
||||
`p1.HomeWizardLocalSource`'s docstring, which records the ENV-01 rig measurement
|
||||
over 70 s of a frozen meter and an independent one against the live house.
|
||||
|
||||
So a value-side timestamp out of Home Assistant is a "time since the number
|
||||
changed" sensor wearing an arrival sensor's name. Thresholding it is how a
|
||||
detector ends up unable to fire, which is TEL-06's defect exactly. That trap
|
||||
produced two false diagnoses on 2026-09-05, both by a reader who already knew
|
||||
about it, which is why it is written here in full rather than referenced.
|
||||
|
||||
⚠️ AND THE CONVERSE TRAP IS THE ONE THIS MODULE HAD TO SOLVE. The add-on polls
|
||||
Home Assistant's state cache roughly once a second (`run_control()`'s
|
||||
`await asyncio.sleep(1)` bounds the loop body and `read_inputs()` runs every
|
||||
pass). While the entity exists, HA answers 200 with a number whether or not the
|
||||
publisher behind it is alive. So AN ARRIVAL AT THE ADD-ON IS NOT AN ARRIVAL FROM
|
||||
THE INVERTER, and an arrival age alone cannot see HW-01 at all.
|
||||
|
||||
What IS arrival-stamped here, and what makes this module honest, is the clock
|
||||
the verdict is measured on: `changed_mono` is the MONOTONIC INSTANT OF THE
|
||||
ARRIVAL THAT LAST CARRIED A DIFFERENT NUMBER. Every term below is a fact about
|
||||
arrivals this process observed, on its own clock. Nothing is read out of a Home
|
||||
Assistant timestamp, and nothing would change if Home Assistant stopped
|
||||
publishing timestamps altogether.
|
||||
|
||||
|
||||
THE VERDICT, AND WHY IT IS A CONJUNCTION
|
||||
========================================
|
||||
A bare "unchanged for N seconds" threshold is not available, and that is
|
||||
MEASURED rather than feared. Over the committed 30-day capture of this house's
|
||||
own 1 W-resolution power entity - `scenarios/p1-net-30d-2026-08/`, 485,841
|
||||
samples straight out of Home Assistant's recorder, where a row exists only when
|
||||
the state string CHANGED, so the gap between two rows IS the unchanged duration
|
||||
every consumer saw:
|
||||
|
||||
longest unchanged run, 30 days 263.2 s (held -899.0 W, 2026-08-11 17:04)
|
||||
runs >= 35 s 10 of 485,840 (0.0021 %)
|
||||
runs >= 17 s 386 of 485,840 (0.0794 %)
|
||||
|
||||
Measured on 2026-09-06 by replaying that capture. A real power figure at 1 W
|
||||
resolution held ONE value for four and a half minutes with nothing whatever
|
||||
wrong. ⚠️ That is the METER's register, not 0x0518, and it is named as a
|
||||
surrogate rather than passed off as the thing: no 30-day capture of 0x0518
|
||||
exists, because that register is not in the recorder export. What it does
|
||||
establish - and this is all it is used for - is that the LEGITIMATE unchanged
|
||||
duration of a power entity in this house is minutes, so an unchanged threshold
|
||||
alone must be minutes, and minutes is three times too slow to protect consumers
|
||||
that trip at 75 s and 85 s. The conjunction is not a refinement; without it
|
||||
there is no admissible threshold at all.
|
||||
|
||||
⚠️ AND EVERY ONE OF THOSE LONG RUNS HAPPENED UNDER AN UNMOVING LOAD. That is
|
||||
the way out, and it is the same shape `freeze.looks_frozen()` already uses for
|
||||
the meter (unchanged AND the command pinned at the rail). A reading is stale
|
||||
when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
|
||||
|
||||
STALE <=> unchanged for >= UNCHANGED_S
|
||||
AND |standing command now| >= MOVE_W
|
||||
AND |standing command now - standing command when the reading
|
||||
last changed| >= MOVE_W
|
||||
|
||||
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
|
||||
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
|
||||
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
|
||||
register dithering +/-1 W while holding (§5.11, same day). A unit told to move
|
||||
250 W and still answering the same integer watt cannot be tracking.
|
||||
|
||||
|
||||
THE BOUNDS - ONE PER SIGNAL, EACH WITH ITS DERIVATION
|
||||
=====================================================
|
||||
⚠️ ONE CONSTANT FOR BOTH SIGNALS WOULD BE USELESS ON SoC OR TRIGGER-HAPPY ON
|
||||
POWER, which is the third clause of the policy. They are separated here, and
|
||||
only one of them is WIRED - see `BOUNDS` below and the note on SoC.
|
||||
|
||||
`batt` / 0x0518, S16, 1 W, published every 10 s
|
||||
----------------------------------------------
|
||||
LOWER BOUND, 17.0 s, the fastest a healthy register can be OBLIGED to have
|
||||
answered. Three terms, each read out of this repository on 2026-09-06:
|
||||
|
||||
6.0 s the plant. goodwe/NOTES.md §5.9, measured 2026-08-22 with the poll
|
||||
temporarily at 1 s and a 0 -> +500 W step: 26 W at +1.43 s, 469 W
|
||||
(94 %) at +3.34 s, settled 507 W at +6.05 s.
|
||||
10.0 s the publisher. `firmware/goodwe-master.yaml:181`,
|
||||
`update_interval: 10s` on the `modbus_controller` that carries both
|
||||
0x0518 and 0x050E. A command that moves right after a poll is not
|
||||
visible until the next one.
|
||||
1.0 s this add-on's own read period - `run_control()`'s
|
||||
`await asyncio.sleep(1)`, which bounds the loop body that calls
|
||||
`read_inputs()`.
|
||||
|
||||
UPPER BOUND, 75.0 s, the tighter of the two consumers this card must protect,
|
||||
both read from `config.yaml` on 2026-09-06:
|
||||
|
||||
75 s `circulation_hold_s` (config.yaml:512) - SAFETY-31 trips here.
|
||||
85 s `unit_sign_disagree_s` (config.yaml:451) - SAFETY-30's per-unit sign
|
||||
check trips here.
|
||||
|
||||
A gate slower than either is a gate that hands its consumer the frozen number
|
||||
and then explains afterwards. ⚠️ AND THAT INEQUALITY IS GRADED, NOT ASSERTED -
|
||||
`test_freshness.py` part 6 reads BOTH ends out of the shipped `config.yaml`
|
||||
and goes red if an operator's default moves under this bound.
|
||||
|
||||
35.0 s IS THE CHOSEN VALUE, and the admissible band is narrow enough that it
|
||||
is nearly forced: at least 2x the 17.0 s floor (34.0) and at most half the
|
||||
75.0 s consumer window (37.5) leaves [34.0, 37.5]. 35 is the integer in it.
|
||||
⚠️ DELIBERATELY NOT 30 AND NOT 60. 30 is `validate.FIRMWARE_INGEST_VETO_S` and
|
||||
would read as a borrowed constant nobody re-derived; 60 is round, and a round
|
||||
number here would be a guess wearing a measurement's clothes.
|
||||
|
||||
WHAT IT COSTS, stated rather than absorbed: a frozen 0x0518 on a unit that is
|
||||
never asked to move 250 W is not detected by this module at all.
|
||||
|
||||
⚠️ THAT COST IS BOUNDED FOR ONE CONSUMER AND NOT FOR THE OTHER, and an earlier
|
||||
version of this note claimed both. TRUE for the per-unit sign check: no such
|
||||
check can speak about that unit either, since `unit_sign_disagree_w` is 250 W.
|
||||
FALSE for circulation. "Contributing at most 250 W" bounds the COMMAND - but a
|
||||
frozen register's REPORTED value is unrelated to its command, and that is
|
||||
precisely 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 gives `circulating_w` = 1800 W against a 700 W floor, 2.6x over, with
|
||||
no gate armed. So this gate is scoped to where the SIGN consumer can be misled;
|
||||
the circulation consumer keeps an unbounded exposure below MOVE_W and that is
|
||||
a known, stated gap rather than a covered one.
|
||||
|
||||
`soc` / 0x050E, U16, 1 %, published every 10 s - STATED, NOT WIRED
|
||||
-----------------------------------------------------------------
|
||||
⚠️ THIS ROW IS THE POLICY'S ANSWER FOR TEL-08 AND IT IS NOT CONNECTED TO
|
||||
ANYTHING. It is here because the third clause of the policy is "state each
|
||||
bound and its derivation", and because a reader who finds only `batt` wired
|
||||
would reasonably conclude SoC was forgotten. It was not; it needs a
|
||||
corroborator this card cannot derive.
|
||||
|
||||
SoC CANNOT USE THE CONJUNCTION ABOVE. A pack resting at its floor holds one
|
||||
percent for hours with nothing wrong - the card's own example - and the
|
||||
standing command says nothing about it, because a command of 0 W is exactly
|
||||
what a rested pack is given. The physically obliged movement for SoC is
|
||||
ENERGY, not command travel: one percent of pack A is about 153.6 Wh (15.36 kWh
|
||||
nominal, goodwe/NOTES.md §1.1b's table - ⚠️ NOMINAL and FROM DATASHEET, not a
|
||||
measured usable capacity), so at pack A's 5000 W ceiling one percent takes at
|
||||
least 110 s and at a realistic 1000 W about 553 s. The bound TEL-08 needs is
|
||||
therefore "SoC unchanged while the integral of |battery power| since its last
|
||||
change exceeded K x 153.6 Wh", and K cannot be chosen from anything written
|
||||
down today: nobody has measured this pack's usable capacity, its BMS reporting
|
||||
granularity, or how far its reported percent lags true state of charge.
|
||||
|
||||
So TEL-08 owes: that capacity measurement (or a defensible bound on it), the
|
||||
integrator, and K. It does NOT owe a second freshness mechanism - `Freshness`
|
||||
below takes its obligation term from the caller precisely so that an energy
|
||||
integral can be handed in where command travel is handed in here.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
# --- the three terms of the lower bound, each read out of this repository ----
|
||||
# See the module docstring for where each one is measured. They are separate
|
||||
# names rather than a literal 17.0 so that a reader who changes the firmware's
|
||||
# `update_interval` can find this file by grepping for the number they moved.
|
||||
PLANT_SETTLE_S = 6.0 # goodwe/NOTES.md §5.9, measured 2026-08-22
|
||||
PUBLISH_PERIOD_S = 10.0 # firmware/goodwe-master.yaml:181
|
||||
READ_PERIOD_S = 1.0 # main.run_control()'s `await asyncio.sleep(1)`
|
||||
BATT_OBLIGED_S = PLANT_SETTLE_S + PUBLISH_PERIOD_S + READ_PERIOD_S # 17.0
|
||||
|
||||
# The unchanged bound for register 0x0518. See the derivation above; the band is
|
||||
# [2 * BATT_OBLIGED_S, 0.5 * circulation_hold_s] = [34.0, 37.5].
|
||||
BATT_UNCHANGED_S = 35.0
|
||||
|
||||
# How far the standing command must have travelled for the register to be
|
||||
# OBLIGED to have followed it.
|
||||
#
|
||||
# ⚠️ IT IS THE LARGEST ADMISSIBLE VALUE, NOT A SENSITIVITY PREFERENCE, and that
|
||||
# is what makes it a derivation. The gate has to arm everywhere a stale reading
|
||||
# could mislead a consumer; the earliest either consumer speaks is
|
||||
# `unit_sign_disagree_w`, 250 W (config.yaml:450), because below that
|
||||
# `watchdog.disagrees()` is False whatever the measurement says. Taking the
|
||||
# ceiling of the admissible range is what minimises spurious arming. A smaller
|
||||
# number would arm the gate in states where nothing downstream can be misled,
|
||||
# and every such arming is a spurious site stop bought for nothing.
|
||||
#
|
||||
# ⚠️ NOT A CONFIG KEY. CFG-03 tracks the places a default has to be kept in
|
||||
# step and this does not earn another one - it is derived from a default
|
||||
# `config.yaml` already carries, and `test_freshness.py` part 6 READS that file
|
||||
# and goes red if the two drift.
|
||||
MOVE_W = 250.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bound:
|
||||
"""One signal's freshness bound, and whether anything consumes it."""
|
||||
|
||||
signal: str
|
||||
unchanged_s: float
|
||||
move_w: float
|
||||
wired: bool
|
||||
derivation: str
|
||||
|
||||
|
||||
# ⚠️ THE POLICY AS DATA, AND THE `wired` COLUMN IS LOAD-BEARING. A reader must be
|
||||
# able to tell, without reading main.py, which of these bounds is actually
|
||||
# gating a consumer today. `test_freshness.py` part 7 greps the shipped app/ for
|
||||
# each row and refuses a row that claims to be wired and is not - and a row that
|
||||
# claims not to be and is.
|
||||
BOUNDS = {
|
||||
"batt": Bound(
|
||||
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
|
||||
unchanged_s=BATT_UNCHANGED_S,
|
||||
move_w=MOVE_W,
|
||||
wired=True,
|
||||
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
|
||||
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]"),
|
||||
"soc": Bound(
|
||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||
unchanged_s=float("inf"),
|
||||
move_w=float("inf"),
|
||||
wired=False,
|
||||
derivation="TEL-08. Command travel is the WRONG obligation term for "
|
||||
"SoC - a rested pack holds a percent for hours under a 0 W "
|
||||
"command. The term is energy: >= K x 153.6 Wh through the "
|
||||
"pack since the reading last changed. K needs a measured "
|
||||
"usable capacity nobody has taken."),
|
||||
}
|
||||
|
||||
|
||||
class Freshness:
|
||||
"""One signal's arrival record, and the stale verdict it supports.
|
||||
|
||||
⚠️ ARRIVAL-DRIVEN THROUGHOUT. Every clock reading this object ever makes is
|
||||
handed to `submit()` by the caller at the moment a value arrived; nothing
|
||||
here reads a clock of its own and nothing here reads a Home Assistant
|
||||
timestamp. That is what makes the verdict replayable - `test_freshness.py`
|
||||
drives real seconds through it without sleeping - and it is the difference
|
||||
between this and the value-stamped veto TEL-06 is blocked on.
|
||||
|
||||
⚠️ THE VERDICT LATCHES, AND WITHOUT THAT IT OSCILLATES AT ABOUT 1 Hz.
|
||||
Declaring the reading stale makes `Controller.inputs_ok` False, which
|
||||
commands 0 W (§11.1 rule 1), which drops the standing command below
|
||||
`MOVE_W`, which un-arms the conjunction, which would return the held number
|
||||
to the consumers - and round again every read. So the ONLY thing that clears
|
||||
the latch is an arrival carrying a DIFFERENT number, which is exactly the
|
||||
evidence the verdict was about. A register that is working clears it on its
|
||||
next 10 s publish; a register that is not, does not.
|
||||
"""
|
||||
|
||||
def __init__(self, bound: Bound):
|
||||
self.bound = bound
|
||||
# The last number that ARRIVED, or None if the most recent read did not
|
||||
# produce one. This is what `reading()` hands back when the gate is open,
|
||||
# so a read failure is still an immediate None exactly as it is today.
|
||||
self.last = None
|
||||
# The number the reading is currently HOLDING, and the monotonic instant
|
||||
# of the arrival that last carried a different one. `held` survives a
|
||||
# failed read; the unchanged clock is about the value, not about our
|
||||
# ability to fetch it.
|
||||
self.held = None
|
||||
self.changed_mono = None
|
||||
# The standing command at that same instant. The conjunction compares
|
||||
# against THIS, not against the previous cycle's command: what matters
|
||||
# is whether the register has followed anything since it last moved.
|
||||
self.standing_at_change = None
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.arrivals = 0
|
||||
|
||||
def submit(self, value, standing_w, now_mono) -> None:
|
||||
"""One READ of this signal. `value` None means nothing arrived.
|
||||
|
||||
`value` the number `hass.number()` produced, or None.
|
||||
`standing_w` what this controller currently has standing on this unit's
|
||||
wire, signed, + = discharge. None when nothing is known.
|
||||
`now_mono` the caller's monotonic clock, in seconds.
|
||||
|
||||
⚠️ A FAILED READ IS NOT AN ARRIVAL AND IS NOT AN AGE EITHER. It clears
|
||||
`last` immediately - which is byte-for-byte what shipping code does
|
||||
today, a missing reading being a missing reading - and it leaves the
|
||||
unchanged clock running, because the value has not changed and our
|
||||
inability to fetch it is not evidence that it has.
|
||||
"""
|
||||
if value is None:
|
||||
self.last = None
|
||||
return
|
||||
value = float(value)
|
||||
self.arrivals += 1
|
||||
if self.changed_mono is None or value != self.held:
|
||||
self.held = value
|
||||
self.changed_mono = float(now_mono)
|
||||
self.standing_at_change = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
# Movement is the ONLY thing that clears the latch. See the class
|
||||
# docstring on the 1 Hz oscillation this prevents.
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.last = value
|
||||
self._grade(standing_w, now_mono)
|
||||
|
||||
def _grade(self, standing_w, now_mono) -> None:
|
||||
"""The conjunction. Sets the latch; never clears it."""
|
||||
if self.stale or self.changed_mono is None:
|
||||
return
|
||||
held_s = self.unchanged_s(now_mono)
|
||||
if held_s < self.bound.unchanged_s:
|
||||
return
|
||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
|
||||
# always answers a float, so neither branch is reachable from the wired
|
||||
# consumer today. They are here for TEL-08, whose obligation term is an
|
||||
# energy integral that has nothing to say until it has a window - and
|
||||
# "nothing to say" must withhold the verdict, not manufacture one.
|
||||
if standing_w is None or self.standing_at_change is None:
|
||||
return
|
||||
standing_w = float(standing_w)
|
||||
if abs(standing_w) < self.bound.move_w:
|
||||
return
|
||||
moved_w = abs(standing_w - self.standing_at_change)
|
||||
if moved_w < self.bound.move_w:
|
||||
return
|
||||
self.stale = True
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
|
||||
f"while the standing command moved {moved_w:.0f} W to "
|
||||
f"{standing_w:+.0f} W - the reading is not following, so it is "
|
||||
f"reported as MISSING rather than as a measurement (limits: "
|
||||
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
|
||||
f"{self.bound.move_w:.0f} W and moved by over "
|
||||
f"{self.bound.move_w:.0f} W)")
|
||||
|
||||
def unchanged_s(self, now_mono) -> float | None:
|
||||
"""Seconds since an arrival carried a different number. Status page."""
|
||||
if self.changed_mono is None:
|
||||
return None
|
||||
return max(0.0, float(now_mono) - self.changed_mono)
|
||||
|
||||
def reading(self):
|
||||
"""What the consumers get: the value, or None when it cannot be trusted.
|
||||
|
||||
⚠️ `None`, NEVER A HELD NUMBER, and never a zero. Both consumers already
|
||||
do the right thing with None and neither could ever receive one:
|
||||
`watchdog.SignWatchdog.evaluate` CLEARS its window on a None measurement
|
||||
rather than accumulating it, and `circulation.circulating_w` returns None
|
||||
for a roster with a missing term, which clears the circulation window
|
||||
too. This module is the thing that was missing between them.
|
||||
"""
|
||||
return None if self.stale else self.last
|
||||
|
||||
def note(self, unit_name: str) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
return f"{unit_name}: {self.reason}" if self.stale else ""
|
||||
|
||||
|
||||
def monotonic() -> float:
|
||||
"""The clock this module's callers use, in one place so tests can find it.
|
||||
|
||||
⚠️ `time.monotonic()`, not the wall clock, for `p1.P1Sample.age_s`'s reason:
|
||||
an NTP step on a Raspberry Pi that has just found the network is routinely
|
||||
minutes, and a freshness verdict computed across one would either fake a
|
||||
stale reading or, worse, hide a real one.
|
||||
"""
|
||||
return time.monotonic()
|
||||
@@ -122,6 +122,10 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
from .delivery import DeliveryConfig, DeliveryWatchdog
|
||||
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
|
||||
# freshness.py imports nothing from this package, so the direction is one-way.
|
||||
from .freshness import BOUNDS, Freshness
|
||||
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
||||
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
||||
# shipped default and a literal here would be the fourth defaults site that
|
||||
@@ -163,6 +167,13 @@ UNIT_A = "inverter-a"
|
||||
# migration mechanical rather than transcribed: `store.migrate_unit_keys()`
|
||||
# reads this same mapping, so a key renamed here cannot silently stop being
|
||||
# migrated. See store.py.
|
||||
# ⚠️ SAFETY-42's PAIR IS DELIBERATELY NOT IN THIS TUPLE, AND THE ABSENCE IS THE
|
||||
# STATEMENT. The values here are the PRE-DEV-02 FLAT key names that
|
||||
# `store.migrate_unit_keys()` moves under a unit; `delivery_tripped` /
|
||||
# `delivery_reason` never existed flat, because the check that writes them
|
||||
# cannot fire at a roster of one (see delivery.py) and a one-inverter install is
|
||||
# the only install that ever had flat keys. Adding them here would make the
|
||||
# migration look for keys that cannot exist and would report them as absent.
|
||||
UNIT_STORE_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
|
||||
@@ -181,7 +192,8 @@ class Inverter:
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
bms_soc_floor=None, store=None, sign_cfg=None):
|
||||
bms_soc_floor=None, store=None, sign_cfg=None,
|
||||
delivery_cfg=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
@@ -213,7 +225,22 @@ class Inverter:
|
||||
|
||||
# --- this unit's own measurements ------------------------------------
|
||||
self.soc = None
|
||||
# ⚠️ SAFETY-37: `batt` IS NOW THE GATED READING, NOT THE RAW ONE. It is
|
||||
# None whenever `batt_fresh` says the register is not following its own
|
||||
# command - see freshness.py - and a None here is a MISSING input, which
|
||||
# §11.1 rule 1 answers with 0 W. The raw number that arrived is on
|
||||
# `batt_fresh.last`; read that only for diagnostics, never for control.
|
||||
self.batt = None
|
||||
# SAFETY-37. One arrival record per SIGNAL per UNIT. Per unit for
|
||||
# DEV-02's reason - a freshness verdict about B is not evidence about A,
|
||||
# and at N>=2 a shared record would be the flat-scalar defect rebuilt
|
||||
# inside the gate that exists to protect the per-unit checks.
|
||||
#
|
||||
# ⚠️ ONLY `batt` IS WIRED. `BOUNDS["soc"]` exists and is deliberately not
|
||||
# constructed here: SoC's obligation term is energy through the pack, not
|
||||
# command travel, and the constant it needs has not been measured. That
|
||||
# is TEL-08's, and freshness.py's docstring says exactly what it owes.
|
||||
self.batt_fresh = Freshness(BOUNDS["batt"])
|
||||
|
||||
# --- what this unit was last told, and what it last accepted ---------
|
||||
# `target` is THIS unit's share of the site target from the last
|
||||
@@ -256,6 +283,25 @@ class Inverter:
|
||||
tripped=bool(data.get(self.key("sign_tripped"), False)),
|
||||
reason=str(data.get(self.key("sign_reason"), "") or ""))
|
||||
|
||||
# --- SAFETY-42, one instance per unit, under NAMESPACED keys ---------
|
||||
# ⚠️ PER UNIT BECAUSE THE CONCLUSION IS ABOUT A UNIT. "This box is not
|
||||
# producing what it was told, while its sibling is" names a device, and
|
||||
# naming the device is card requirement 2 - the site figure cannot say
|
||||
# which one to look at, which is the whole reason SAFETY-30 split
|
||||
# per-unit from site. The site-level REDUCTION that decides whether a
|
||||
# unit qualifies lives in `delivery.delinquent()` and is computed once
|
||||
# per cycle in `Controller.cycle()`; what is per-unit here is the
|
||||
# WINDOW and the LATCH.
|
||||
#
|
||||
# ⚠️ AND IT IS RESTORED FROM THIS UNIT'S OWN KEYS, LIKE `sign_wd`. A
|
||||
# latch held only in memory is not a latch: Home Assistant restarts, the
|
||||
# `persistent_notification` goes with it, and the fault runs on unseen.
|
||||
# That is SAFETY-34, and it is how the 2026-09-02 outage lasted 6 h 25 m.
|
||||
self.delivery_wd = DeliveryWatchdog(
|
||||
delivery_cfg or DeliveryConfig(),
|
||||
tripped=bool(data.get(self.key("delivery_tripped"), False)),
|
||||
reason=str(data.get(self.key("delivery_reason"), "") or ""))
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
||||
@@ -266,6 +312,24 @@ class Inverter:
|
||||
self.store.set(self.key(name), value)
|
||||
|
||||
# -- the numbers other modules ask this object for -----------------------
|
||||
def standing_w(self) -> float:
|
||||
"""What this controller has standing on THIS unit's wire right now.
|
||||
|
||||
⚠️ ONE DEFINITION, TWO CALLERS, and it was a transcription before
|
||||
SAFETY-37. `cycle()` computes this for the sign watchdog and
|
||||
`_read_unit()` needs the identical number for the freshness conjunction;
|
||||
two spellings of "what is standing" would be two answers to the question
|
||||
the gate and the check it protects are both asking, and the copy that
|
||||
drifts is always the one further from the wire.
|
||||
|
||||
`last_write` is the value after the device clamp and confirmed accepted;
|
||||
`target` covers the case where nothing has been written yet and,
|
||||
deliberately, the case where the last write was REJECTED - the inverter
|
||||
is then still latched on `last_write`, which is what `last_write` holds.
|
||||
"""
|
||||
return float(self.last_write if self.last_write is not None
|
||||
else self.target)
|
||||
|
||||
def rating_w(self) -> float:
|
||||
"""The largest magnitude this unit can be commanded, in W.
|
||||
|
||||
@@ -319,6 +383,18 @@ class Inverter:
|
||||
f"(limit {float(stale_s):.0f} s)")
|
||||
return True, ""
|
||||
|
||||
def stale_note(self) -> str:
|
||||
"""SAFETY-37's one line for the status page. Empty when nothing to say.
|
||||
|
||||
Separate from `link_note()` because they are about different halves of
|
||||
the device and want opposite remedies: liveness is about the WRITE path
|
||||
and its answer is to drop this unit and let the survivors carry it,
|
||||
while a stale MEASUREMENT is about the read path and its answer is to
|
||||
stop the site (`inputs_ok`). Folding one into the other is how a
|
||||
measurement fault ends up handing a sibling extra power.
|
||||
"""
|
||||
return self.batt_fresh.note(self.name)
|
||||
|
||||
def link_note(self, now: datetime) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
if not self.has_writer():
|
||||
@@ -357,7 +433,39 @@ def roster_entries(opts: dict) -> list:
|
||||
return list(entries)
|
||||
|
||||
|
||||
def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
def roster_names(opts: dict) -> list:
|
||||
"""The unit names, in roster order. THE one answer, for two callers.
|
||||
|
||||
⚠️ IT EXISTS BECAUSE MQTT DISCOVERY CANNOT WAIT FOR A ROSTER. TEL-07 gives
|
||||
every unit past the first its own MQTT device, and `MqttPublisher` is
|
||||
constructed in `main()` BEFORE `Controller.__init__` builds the roster -
|
||||
announcing happens on the broker's CONNACK, which can land first. Building a
|
||||
second roster there would construct a second set of `Inverter`s off the same
|
||||
Store; transcribing the naming rule there would be a third spelling of it.
|
||||
So the naming rule lives here, `build_roster()` reads it, and the two cannot
|
||||
drift.
|
||||
|
||||
⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries both
|
||||
called `inverter-a` would share one Store namespace, so one unit's SAFETY-02
|
||||
latch would be the other's - a fault on B latching A is the exact failure
|
||||
DEV-02's namespaced keys removed. Rule 9 refuses a nameless entry outright;
|
||||
this only decides what the refusal is allowed to call it in the meantime.
|
||||
|
||||
An EMPTY `inverters:` is the flat-key shape and its one unit is `UNIT_A`,
|
||||
which is what every install running today reads.
|
||||
"""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
return [UNIT_A]
|
||||
names = []
|
||||
for i, entry in enumerate(entries):
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
names.append(str(entry.get("name") or "").strip() or f"inverter-{i + 1}")
|
||||
return names
|
||||
|
||||
|
||||
def build_roster(opts: dict, store, sign_cfg=None,
|
||||
delivery_cfg=None) -> list:
|
||||
"""The site's inverters, in roster order.
|
||||
|
||||
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
|
||||
@@ -411,18 +519,18 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
# mirror exactly as validate.py rule 8 defaults it, so an install
|
||||
# that never set the key reads the same 10 % in both places.
|
||||
bms_soc_floor=_flat_bms_floor(opts),
|
||||
store=store, sign_cfg=sign_cfg)]
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)]
|
||||
|
||||
roster = []
|
||||
for i, entry in enumerate(entries):
|
||||
names = roster_names(opts)
|
||||
# ⚠️ NAMES COME FROM `roster_names()`, NOT FROM A SECOND READING OF THE
|
||||
# ENTRIES. TEL-07 announces one MQTT device and one `unique_id` per unit off
|
||||
# these names, and it has to build them BEFORE a `Controller` exists - so a
|
||||
# second spelling of the fallback here would be an entity announced under a
|
||||
# name no unit answers to. `zip` pairs them positionally, which is what
|
||||
# `roster_names()` is defined to be.
|
||||
for entry, name in zip(entries, names):
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
# ⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries
|
||||
# both called `inverter-a` would share one Store namespace, so one
|
||||
# unit's SAFETY-02 latch would be the other's - a fault on B latching A
|
||||
# is the exact failure DEV-02's namespaced keys removed. Rule 9 refuses
|
||||
# a nameless entry outright; this only decides what the refusal is
|
||||
# allowed to call it in the meantime.
|
||||
name = str(entry.get("name") or "").strip() or f"inverter-{i + 1}"
|
||||
roster.append(Inverter(
|
||||
name,
|
||||
setpoint_entity=entry.get("setpoint_entity", ""),
|
||||
@@ -438,7 +546,7 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
# SAFETY-32's own defect written in three lines - the shallower pack
|
||||
# graded against the deeper pack's BMS.
|
||||
bms_soc_floor=_entry_bms_floor(entry),
|
||||
store=store, sign_cfg=sign_cfg))
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg))
|
||||
return roster
|
||||
|
||||
|
||||
|
||||
+427
-12
@@ -38,6 +38,10 @@ from .arbiter import (
|
||||
Claim, Resolution, actuator_for, resolve,
|
||||
)
|
||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .delivery import (
|
||||
DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery,
|
||||
account as delivery_account, delinquent,
|
||||
)
|
||||
from .circulation import (
|
||||
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
|
||||
circulating_w, opposing,
|
||||
@@ -45,6 +49,7 @@ from .circulation import (
|
||||
from .clamp import (
|
||||
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
|
||||
)
|
||||
from . import freshness
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
)
|
||||
@@ -53,11 +58,12 @@ from .hass import HomeAssistant
|
||||
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
|
||||
# window an operator would look up here is the one the roster actually uses.
|
||||
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
||||
roster_entries)
|
||||
roster_entries, roster_names)
|
||||
from .maintenance import (
|
||||
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
|
||||
)
|
||||
from .mqtt import MqttPublisher
|
||||
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
|
||||
unit_state_key)
|
||||
from . import peakguard
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
@@ -214,8 +220,17 @@ class Controller:
|
||||
hold_s=float(opts.get("site_sign_disagree_s",
|
||||
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])),
|
||||
)
|
||||
# SAFETY-42. THE WINDOW A UNIT MAY BE SHORT OF ITS SHARE BEFORE IT IS
|
||||
# NAMED. There is no second key: the WATTS half of that check is
|
||||
# `Tuning.saturation_w`, read at runtime rather than copied - see
|
||||
# `delivery.MIN_W_IS_SATURATION_W`, and CFG-03 on why a fourth copy of a
|
||||
# default is worth avoiding.
|
||||
self.delivery_cfg = delivery_cfg = DeliveryConfig(
|
||||
hold_s=float(opts.get("delivery_hold_s",
|
||||
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
|
||||
)
|
||||
self.store_migration = migrate_unit_keys(store, UNIT_A)
|
||||
self.inverters = build_roster(opts, store, sign_cfg)
|
||||
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
|
||||
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
|
||||
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
|
||||
# of `soc_entity`, `batt_entity`, `batt_invert` or `max_w`, and an
|
||||
@@ -246,7 +261,13 @@ class Controller:
|
||||
# binds each unit's own share through its own `supervised` claim, so
|
||||
# nothing here can over-command a device.
|
||||
max_w=self.site_max_w,
|
||||
slew_w=float(opts.get("slew_w", 1000)),
|
||||
# ⚠️ SAFETY-39: THE FALLBACK IS 0 AND 0 MEANS "FOLLOW THE RAIL",
|
||||
# which is what config.yaml ships and what `Tuning`'s own default
|
||||
# is. It used to be 1000 - a third copy of a constant that had
|
||||
# already gone stale against the rail in the other two - and rule
|
||||
# 6's comment in validate.py documents what that costs when the
|
||||
# copies disagree. `control.slew_limit_w()` is the one reader.
|
||||
slew_w=float(opts.get("slew_w", 0) or 0),
|
||||
deadband_w=float(opts.get("deadband_w", 15)),
|
||||
target_grid_w=float(opts.get("target_grid_w", 0)),
|
||||
step_w=int(opts.get("step_w", 10)),
|
||||
@@ -357,6 +378,32 @@ class Controller:
|
||||
"opposed the meter.", inv.name,
|
||||
inv.sign_wd.reason or "(reason not recorded)")
|
||||
|
||||
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
|
||||
# ⚠️ A LATCH THAT DOES NOT ACT, WHICH IS THE ONE ASYMMETRY WITH EVERY
|
||||
# OTHER RESTORED LATCH ON THIS OBJECT, AND IT IS DELIBERATE. SAFETY-02's
|
||||
# and SAFETY-31's latches survive a restart because they command 0 W and
|
||||
# an add-on that came back armed would drive straight into the fault
|
||||
# again. This one commands nothing at all: it is restored so that the
|
||||
# ALARM survives, which is the whole of SAFETY-34 and is exactly how the
|
||||
# 2026-09-02 outage went unseen for 6 h 25 m with the detector correct
|
||||
# the entire time. `delivery.py` carries the argument for reporting
|
||||
# rather than acting, with the rig measurements on both sides of it.
|
||||
#
|
||||
# `_delivery_announced` is the per-PROCESS half - see `_circ_announced`,
|
||||
# which is the same pattern for the same reason. The store says "this
|
||||
# unit is tripped"; the flag says "this process has put that in front of
|
||||
# a human". A restart clears the flag and keeps the latch.
|
||||
self._delivery_announced = set()
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
_LOG.error(
|
||||
"SAFETY-42 was TRIPPED on %s before this restart and stays "
|
||||
"tripped: %s. NOTHING IS BEING COMMANDED DIFFERENTLY - this "
|
||||
"guard reports and does not act - so if that inverter is "
|
||||
"still not delivering, the site is still short by its "
|
||||
"share. Restarting does not clear this.",
|
||||
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
|
||||
|
||||
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
||||
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
|
||||
# The per-unit watchdogs above ask "is THIS actuator doing what I told
|
||||
@@ -1102,8 +1149,18 @@ class Controller:
|
||||
for inv in self.inverters)
|
||||
|
||||
# -- io ------------------------------------------------------------------
|
||||
async def read_inputs(self) -> None:
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
|
||||
`now_mono` the monotonic clock the per-unit freshness records are
|
||||
stamped on. Defaulted rather than read inside `_read_unit()`
|
||||
so that ONE instant covers every unit in a gather - two
|
||||
units stamped from two clock reads would carry a freshness
|
||||
skew nobody could reproduce - and so that a check can drive
|
||||
real seconds through the gate without sleeping through them.
|
||||
"""
|
||||
o = self.o
|
||||
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
|
||||
if self.p1_enabled:
|
||||
# ⚠️ P1 is the only authoritative measurement of what the utility
|
||||
# sees (§5.1). When it is stale this is None, which falls into the
|
||||
@@ -1123,7 +1180,8 @@ class Controller:
|
||||
# names for the WRITE path, one function up. Within a unit the order is
|
||||
# unchanged (SoC, then battery power), so at the shipped roster of one
|
||||
# this is the same two calls in the same order.
|
||||
await asyncio.gather(*(self._read_unit(inv) for inv in self.inverters))
|
||||
await asyncio.gather(*(self._read_unit(inv, now_mono)
|
||||
for inv in self.inverters))
|
||||
if o.get("peak_forecast_entity"):
|
||||
raw = await self.hass.number(o["peak_forecast_entity"])
|
||||
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
|
||||
@@ -1169,10 +1227,41 @@ class Controller:
|
||||
elif self.inputs_bad_since is None:
|
||||
self.inputs_bad_since = datetime.now(timezone.utc)
|
||||
|
||||
async def _read_unit(self, inv) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities."""
|
||||
async def _read_unit(self, inv, now_mono: float) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities.
|
||||
|
||||
⚠️ SAFETY-37: THE POWER READING GOES THROUGH THE FRESHNESS GATE AND THE
|
||||
SoC READING DOES NOT, AND THE ASYMMETRY IS DELIBERATE. `batt` feeds the
|
||||
two consumers this card is about - SAFETY-30's per-unit sign check and
|
||||
SAFETY-31's circulating-power sensor - and its obligation term is
|
||||
command travel, which this method already has. SoC's obligation term is
|
||||
ENERGY through the pack and the constant it needs has never been
|
||||
measured on this site; wiring it here with a guessed constant would be a
|
||||
gate that fires on a rested pack, which is TEL-08's whole hazard.
|
||||
`freshness.BOUNDS["soc"]` states the bound and what TEL-08 owes.
|
||||
|
||||
⚠️ THE STANDING COMMAND IS ASKED OF THE UNIT, NOT RECOMPUTED. `cycle()`
|
||||
grades the sign watchdog against `inv.standing_w()` and this gate arms
|
||||
off the same method, because the gate exists to protect that check and a
|
||||
second spelling of "what is standing" would let the two disagree about
|
||||
the number they are both reasoning about.
|
||||
"""
|
||||
inv.soc = await self.hass.number(inv.soc_entity)
|
||||
inv.batt = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
raw = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
was_stale = inv.batt_fresh.stale
|
||||
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
||||
# ⚠️ `inv.batt` IS THE GATED VALUE. A stale register is a MISSING input,
|
||||
# not a measurement - `inputs_ok` sees the None, the site holds for
|
||||
# `stale_input_s` and then commands 0 W and keeps commanding, which is
|
||||
# §11.1 rule 1 and is already what a missing reading does here.
|
||||
inv.batt = inv.batt_fresh.reading()
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
|
||||
_LOG.error("SAFETY-37: %s battery-power register is not following "
|
||||
"its own command - %s. Treating it as MISSING, which "
|
||||
"commands 0 W. It clears itself the moment the register "
|
||||
"reports a different value.",
|
||||
inv.name, inv.batt_fresh.reason)
|
||||
|
||||
def write_refusal(self, value: float, inv=None) -> str | None:
|
||||
"""Why `value` may NOT be written to `inv` right now, or None if it may.
|
||||
@@ -1462,9 +1551,20 @@ class Controller:
|
||||
return tuple(live)
|
||||
|
||||
def liveness_notes(self) -> list:
|
||||
"""Why each unit is or is not commandable, for the status page."""
|
||||
"""Why each unit is or is not commandable, for the status page.
|
||||
|
||||
⚠️ SAFETY-37's STALE-READING LINE RIDES HERE TOO, AND IT IS A DIFFERENT
|
||||
FAULT WITH A DIFFERENT REMEDY. A unit that is not commandable is dropped
|
||||
and its share is carried by the survivors; a unit whose power register
|
||||
has stopped following its command stops the SITE. They share this list
|
||||
because they share one place on the status page - the operator asking
|
||||
"why is nothing happening" needs both - and nothing downstream reads it.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
return [n for n in (inv.link_note(now) for inv in self.inverters) if n]
|
||||
return [n for n in
|
||||
[x for inv in self.inverters
|
||||
for x in (inv.link_note(now), inv.stale_note())]
|
||||
if n]
|
||||
|
||||
async def cycle(self) -> None:
|
||||
"""Gather claims, resolve precedence, write the winner.
|
||||
@@ -1553,7 +1653,10 @@ class Controller:
|
||||
standing = {}
|
||||
for inv in self.inverters:
|
||||
was_tripped = inv.sign_wd.tripped
|
||||
stood = inv.last_write if inv.last_write is not None else inv.target
|
||||
# ⚠️ `Inverter.standing_w()`, NOT THE EXPRESSION IT REPLACES.
|
||||
# SAFETY-37's freshness gate arms off the same method, and this used
|
||||
# to be the only spelling of it. See that method's docstring.
|
||||
stood = inv.standing_w()
|
||||
standing[inv.name] = stood
|
||||
against = self.grid if n_units <= 1 else inv.batt
|
||||
if inv.sign_wd.evaluate(stood, against, self.following, now) \
|
||||
@@ -1674,6 +1777,117 @@ class Controller:
|
||||
"add-on restart AND a Home Assistant restart; restarting will "
|
||||
"not clear it.", self.circ_wd.reason)
|
||||
|
||||
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON
|
||||
# Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but
|
||||
# not yet stale" hold below. A run of held cycles would otherwise leave
|
||||
# each unit's `since` untouched across the gap and its window would
|
||||
# resume from a start time that predates it - the ordering bug the sign
|
||||
# watchdog's own note describes, in a third detector.
|
||||
#
|
||||
# ⚠️ THE OPERANDS ARE `standing` AND `inv.batt`, THE SAME PAIR SAFETY-30
|
||||
# GRADES, AND THE CONCLUSION IS A DIFFERENT ONE. SAFETY-30 asks whether
|
||||
# they OPPOSE; this asks whether the second one ARRIVED. A unit sitting
|
||||
# at 0 W against a +2000 W share opposes nothing - it is not pushing the
|
||||
# wrong way, it is absent - so the sign gate never opens, the site sum
|
||||
# still agrees with the meter in direction, circulating power is
|
||||
# identically zero, and the only trace left is `reason ==
|
||||
# "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an
|
||||
# operator learns to ignore. That is this card.
|
||||
#
|
||||
# ⚠️ AND `delinquent()` IS CALLED ONCE, OVER THE WHOLE ROSTER, BECAUSE
|
||||
# THE CHECK IS A REDUCTION ACROSS UNITS AND NOT A PER-UNIT PREDICATE.
|
||||
# What separates a wedged box from a legitimately saturated site is that
|
||||
# the site's shortfall is a PROPER SUBSET of the units asked to deliver.
|
||||
# Called per unit it would be a shortfall test, which is the saturation
|
||||
# signal again. delivery.py carries the measurement: a pack at its own
|
||||
# cut-out and a wedged unit produce IDENTICAL traces at the unit and at
|
||||
# the meter, so nothing computed from one unit alone can tell them
|
||||
# apart. At a roster of one the reduction is empty for every input.
|
||||
#
|
||||
# ⚠️ EVERY NUMBER BELOW IS THE CONFIGURED ONE, NEVER A LITERAL THAT
|
||||
# HAPPENS TO EQUAL IT TODAY, AND THAT IS A SHIPPED PROMISE RATHER THAN
|
||||
# A STYLE RULE. `delivery.MIN_W_IS_SATURATION_W` and config.yaml's
|
||||
# `delivery_hold_s` block both tell an operator, in prose, that there is
|
||||
# no fourth copy of a threshold to keep in step and no way for the two
|
||||
# to drift - an operator who raises `saturation_w` has told the loop a
|
||||
# bigger gap is ordinary tracking, and this check moves with them BY
|
||||
# CONSTRUCTION. A literal here breaks exactly that promise, silently.
|
||||
#
|
||||
# Both of these were once literals that no check in this repository
|
||||
# could see, because every fixture sat at the shipped value:
|
||||
#
|
||||
# self.tuning.saturation_w -> 250.0 (-19 B) - a survivor of all
|
||||
# 23 suites, because part 4's
|
||||
# gaps are far above BOTH
|
||||
# bms_floor=inv.bms_soc_floor -> 10.0 (-13 B) - a survivor of all
|
||||
# 23 suites, because every
|
||||
# fixture's cut-out WAS 10 %
|
||||
#
|
||||
# test_delivery.py part 4b is what stands between this call and both of
|
||||
# them: it grades this reduction at a `saturation_w` and at two
|
||||
# `bms_soc_floor`s that are NOT the shipped ones, and its arms are
|
||||
# answered opposite ways at two configured values, so no constant can
|
||||
# pass. Change an argument here to a number and read that part before
|
||||
# you decide the red is wrong.
|
||||
for inv in self.inverters:
|
||||
# ⚠️ `self.delivery_cfg`, NOT `DeliveryConfig()`. Same promise: the
|
||||
# window is `delivery_hold_s`, and part 4b's neighbours already
|
||||
# grade this one at a hold that is not the shipped 185 s.
|
||||
inv.delivery_wd.arm(self.delivery_cfg)
|
||||
_delinquent = delinquent(
|
||||
[UnitDelivery(
|
||||
inv.name, standing[inv.name], inv.batt,
|
||||
delivery_account(
|
||||
# ⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is
|
||||
# `LINK_STALE_S` - the SAME call `live_units()` makes for
|
||||
# the allocator. It has to be: what this branch means is
|
||||
# "the allocator has already dropped this unit and said so",
|
||||
# and a different window here would be this check answering
|
||||
# a question about a roster the allocator did not build.
|
||||
# It is the ONE number here with no option behind it, so
|
||||
# "graded at a changed config" is not available for it;
|
||||
# what part 6 grades instead is that WIDENING the window
|
||||
# changes the answer.
|
||||
live=inv.is_live(now)[0],
|
||||
parked=inv.sign_wd.tripped,
|
||||
# ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not
|
||||
# the site's `peak_soc_floor` either - that one bounds a
|
||||
# money-saving discharge only, and grading a pack against it
|
||||
# explains a wedged unit away in a band an ordinary install
|
||||
# has ten points of.
|
||||
soc=inv.soc, bms_floor=inv.bms_soc_floor))
|
||||
for inv in self.inverters],
|
||||
self.tuning.saturation_w)
|
||||
for inv in self.inverters:
|
||||
was_short = inv.delivery_wd.tripped
|
||||
if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \
|
||||
and not was_short:
|
||||
# REASON FIRST, FLAG SECOND, and each `set` writes the file -
|
||||
# the same atomicity ordering the three trips above use: a crash
|
||||
# between the two must not persist a latch with no explanation
|
||||
# for the operator who has to decide about clearing it.
|
||||
#
|
||||
# ⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02
|
||||
# HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback
|
||||
# still finds its latch", and the version being rolled back TO
|
||||
# drives exactly ONE actuator - a roster at which this check is
|
||||
# inert by construction. A flat mirror would hand a downgraded
|
||||
# add-on a latch taken on evidence about a roster it cannot have.
|
||||
inv.store_set("delivery_reason", inv.delivery_wd.reason)
|
||||
inv.store_set("delivery_tripped", True)
|
||||
self._delivery_announced.discard(inv.name)
|
||||
self.log_event(f"NOT DELIVERING {inv.name}: "
|
||||
+ inv.delivery_wd.reason)
|
||||
_LOG.error(
|
||||
"SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED "
|
||||
"DIFFERENTLY: this guard reports and does not act, because "
|
||||
"commanding a unit that is not listening achieves nothing "
|
||||
"(HW-01) and the only re-allocation this add-on can express "
|
||||
"would throw away a partially-delivering unit's remaining "
|
||||
"output. The site is short by that unit's share until "
|
||||
"somebody looks at it. This survives an add-on AND a Home "
|
||||
"Assistant restart.", inv.name, inv.delivery_wd.reason)
|
||||
|
||||
stale_after = float(self.o.get("stale_input_s", 15))
|
||||
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
|
||||
# does not become a fault; a LATCHED inverter must still reach the claim
|
||||
@@ -2394,6 +2608,15 @@ class Controller:
|
||||
# fresh trip instead of getting its own.
|
||||
await self.check_circulating()
|
||||
|
||||
# --- SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's
|
||||
# It grades `inv.delivery_wd.tripped`, which was advanced near the top
|
||||
# of this method beside SAFETY-02 and SAFETY-31 and which nothing since
|
||||
# has touched. Called on EVERY cycle rather than only on the cycle that
|
||||
# trips, because its second job is re-raising the alarm after a restart
|
||||
# into an existing trip - see its docstring on why that shares one code
|
||||
# path with the fresh trip instead of getting its own.
|
||||
await self.check_not_delivering()
|
||||
|
||||
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
|
||||
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
|
||||
# method assigned above and nothing since has touched.
|
||||
@@ -2731,6 +2954,85 @@ class Controller:
|
||||
f"know why two inverters were opposing."),
|
||||
})
|
||||
|
||||
async def check_not_delivering(self) -> None:
|
||||
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
|
||||
|
||||
⚠️ THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm.
|
||||
`unit/<name>/delivery_tripped` in `/data/state.json` survives everything,
|
||||
but nothing looks at a JSON file at 3 a.m. What the operator sees is a
|
||||
`persistent_notification`, and THAT IS IN MEMORY: a Home Assistant
|
||||
restart deletes it while the fault runs on. That is precisely how the
|
||||
2026-09-02 outage went unseen for 6 h 25 m with the detector correct the
|
||||
whole time (SAFETY-34), and it matters more here than anywhere else in
|
||||
this file, because SAFETY-02 and SAFETY-31 at least leave a stopped site
|
||||
for somebody to notice. This one leaves a site that looks like it is
|
||||
working and is quietly delivering one unit less than it has.
|
||||
|
||||
So the durable half and the visible half are joined HERE, and the join
|
||||
is `_delivery_announced`: a per-PROCESS set of unit names, deliberately
|
||||
not persisted. The latch says "this unit is tripped"; membership says
|
||||
"this process has put that in front of a human". A restart empties the
|
||||
set and keeps the latch, so the notification is RE-RAISED on the first
|
||||
cycle after every add-on start - which is also the first cycle after a
|
||||
Home Assistant restart, because the add-on is restarted with it.
|
||||
|
||||
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s
|
||||
argument. A fresh trip and a restart into an existing trip raise the
|
||||
alarm through these same lines; two paths would be two chances for the
|
||||
restart one to rot unnoticed, and the restart one is the case nobody
|
||||
exercises by hand.
|
||||
|
||||
⚠️ AND ONE NOTIFICATION ID PER UNIT. Every other alarm in this file has
|
||||
a single fixed id because its subject is the site; this one's subject is
|
||||
a named box, and two wedged inverters must produce two cards rather than
|
||||
one replacing the other. The id is built from the same `unit_slugs()`
|
||||
TEL-07 announces the MQTT entities under, so the card and the entity an
|
||||
operator correlates it with carry the same token.
|
||||
"""
|
||||
names = self.units
|
||||
slugs = unit_slugs(names)
|
||||
for inv, slug in zip(self.inverters, slugs):
|
||||
if not inv.delivery_wd.tripped or inv.name in self._delivery_announced:
|
||||
continue
|
||||
# Added BEFORE the await, for `check_circulating()`'s reason:
|
||||
# `hass.call()` can raise or hang, and a retry every cycle for a
|
||||
# broker that is down would be an alarm storm on the event bus. The
|
||||
# latch is already durable and the log line already fired; this
|
||||
# surface is best-effort by design.
|
||||
self._delivery_announced.add(inv.name)
|
||||
peers = ", ".join(n for n in names if n != inv.name) or "no sibling"
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": f"goodwe_not_delivering_{slug}",
|
||||
"title": f"GoodWe: {inv.name} is not delivering its share",
|
||||
"message": (
|
||||
f"{inv.delivery_wd.reason}\n\nWHY NOTHING ELSE SAID SO. A "
|
||||
f"unit sitting at 0 W against a non-zero share is not "
|
||||
f"pushing the wrong way, it is ABSENT: the per-unit sign "
|
||||
f"check needs opposition on both sides of "
|
||||
f"{self.unit_sign_cfg.min_w:.0f} W and never opens, the "
|
||||
f"site sum still agrees with the meter in "
|
||||
f"direction, and circulating power is exactly zero when one "
|
||||
f"unit does nothing. The only other trace is a saturation "
|
||||
f"freeze, which this add-on's own code calls an alarm "
|
||||
f"operators learn to ignore.\n\nNOTHING HAS BEEN COMMANDED "
|
||||
f"DIFFERENTLY. This guard reports and does not act. "
|
||||
f"Commanding an inverter that has stopped listening "
|
||||
f"achieves nothing - that has happened on this hardware, "
|
||||
f"once for 6 h 25 m - and the only re-allocation this "
|
||||
f"add-on can express is all-or-nothing, which would throw "
|
||||
f"away the output of a unit that is delivering PART of its "
|
||||
f"share. So {peers} is still regulating and the site is "
|
||||
f"short by up to {inv.rating_w():.0f} W until somebody "
|
||||
f"looks at {inv.name}.\n\nWHAT TO CHECK, in order: is "
|
||||
f"{inv.name} answering on RS485 at all; is its pack colder "
|
||||
f"or emptier than its configured bms_soc_floor says (if it "
|
||||
f"is, that setting does not describe this battery and this "
|
||||
f"alarm is telling you so); and is anything else writing to "
|
||||
f"it.\n\nThis alarm survives a Home Assistant restart. "
|
||||
f"Restarting will not clear it; start control from the "
|
||||
f"add-on UI once you know why."),
|
||||
})
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
|
||||
@@ -3073,6 +3375,28 @@ class Controller:
|
||||
if self.p1_enabled:
|
||||
# Recomputed here, once a second, on purpose - see P1Ingest.
|
||||
values["p1_age"] = round(self.p1.published_age_s, 1)
|
||||
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE.
|
||||
#
|
||||
# ⚠️ THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY.
|
||||
# `announced_units()` answers `[]` at a roster of one, `zip` then yields
|
||||
# nothing, and the values dict comes out byte-identical to what shipped -
|
||||
# so the condition that decides what is ANNOUNCED is the same expression
|
||||
# that decides what is PUBLISHED. Written as a second predicate, the two
|
||||
# could disagree, and both directions are bad: an announced entity
|
||||
# nothing feeds (the hazard `omit` exists for) or, at N=1, a published
|
||||
# topic nothing announced, which AC 4 forbids outright.
|
||||
#
|
||||
# ⚠️ AND THE SITE ROWS ABOVE DO NOT MOVE. `battery` stays the SUM and
|
||||
# `soc` stays the EMPTIEST pack; these are additions beside them, not a
|
||||
# re-pointing of them, because those two ids are on the live install's
|
||||
# dashboards and in its automations.
|
||||
names = announced_units([inv.name for inv in self.inverters])
|
||||
for inv, slug in zip(self.inverters, unit_slugs(names)):
|
||||
values[unit_state_key(slug, "setpoint")] = inv.target
|
||||
values[unit_state_key(slug, "battery")] = inv.batt
|
||||
values[unit_state_key(slug, "soc")] = inv.soc
|
||||
values[unit_state_key(slug, "sign_latch")] = (
|
||||
"tripped" if inv.sign_wd.tripped else "clear")
|
||||
self.mqtt.publish(values)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
@@ -3471,6 +3795,49 @@ class Controller:
|
||||
+ (f" - now {_circ:.0f} W" if _circ else "")
|
||||
+ (f", opposed for {held:.0f} s" if held else "")})
|
||||
|
||||
# --- SAFETY-42 -------------------------------------------------------
|
||||
# ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE ONE PLACE THE
|
||||
# "reports, does not act" DECISION IS VISIBLE TO AN OPERATOR. Every
|
||||
# other latched row in this list means the site is at 0 W and a red
|
||||
# cross is the truth. This one means the site is STILL RUNNING and is
|
||||
# quietly short by one unit's share; rendering it as a hard failure
|
||||
# would say the controller had stopped, which it has not. It is not "ok"
|
||||
# either - `ok: False` with `warn: True` is this list's caution, and a
|
||||
# caution is what a fault nobody is acting on actually is.
|
||||
if len(self.inverters) > 1:
|
||||
for inv in self.inverters:
|
||||
tag = f" ({inv.name})"
|
||||
if inv.delivery_wd.tripped:
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"NOT DELIVERING{tag} - "
|
||||
+ inv.delivery_wd.reason
|
||||
+ ". Nothing has been commanded differently and "
|
||||
"the rest of the roster is still regulating, "
|
||||
"so the site is short by this unit's share. "
|
||||
"Starting control clears this row; restarting "
|
||||
"the add-on does NOT, and neither does "
|
||||
"restarting Home Assistant."})
|
||||
else:
|
||||
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"delivery watchdog{tag}: armed (reports after "
|
||||
f"{inv.delivery_wd.cfg.hold_s:.0f} s short of "
|
||||
f"its share by over "
|
||||
f"{self.tuning.saturation_w:.0f} W, with a "
|
||||
f"sibling that is not)"
|
||||
+ (f" - short for {_dheld:.0f} s now"
|
||||
if _dheld else "")})
|
||||
else:
|
||||
# AC 5, and the same honesty `circulating power: n/a` uses one block
|
||||
# up. At one inverter the reduction this check is has no proper
|
||||
# subset to find, for EVERY input, so "armed" would be a claim to be
|
||||
# watching something that cannot happen.
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": "delivery watchdog: n/a at one inverter - a unit "
|
||||
"short of its share is indistinguishable from a "
|
||||
"plant that cannot deliver until there is a "
|
||||
"sibling to compare it with"})
|
||||
|
||||
if not o.get("estop_fitted", False):
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": "no e-stop fitted: if this host dies the battery stays "
|
||||
@@ -3530,6 +3897,26 @@ class Controller:
|
||||
level, banner = "bad", f"NOT READY — {bad[0].get('banner') or bad[0]['text']}"
|
||||
elif not self.auto:
|
||||
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
||||
elif any(i.delivery_wd.tripped for i in self.inverters):
|
||||
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
||||
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
|
||||
# Every `bad` banner above means the site is at 0 W; this one means
|
||||
# the site is STILL RUNNING and quietly short by one unit's share,
|
||||
# and a red headline would say the controller had stopped.
|
||||
#
|
||||
# ⚠️ BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT
|
||||
# IS NOT COSMETIC. That sentence is a claim the site cannot make
|
||||
# while a unit is absent - the loop is saturated and the meter is
|
||||
# short - and "the page reads healthy while the fault runs" is
|
||||
# precisely the shape this whole card exists to remove. It sits
|
||||
# BELOW "Stopped" because a stopped site is the more urgent fact and
|
||||
# the banner carries one sentence.
|
||||
_nd = ", ".join(i.name for i in self.inverters
|
||||
if i.delivery_wd.tripped)
|
||||
level, banner = "warn", (
|
||||
f"NOT DELIVERING — {_nd} is standing at a share it is not "
|
||||
f"producing. The site is still running and short by it; "
|
||||
f"nothing has been commanded differently.")
|
||||
elif self.maint.phase != IDLE:
|
||||
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
|
||||
# of one this is the string it has always been, character for
|
||||
@@ -3680,6 +4067,27 @@ class Controller:
|
||||
self.store.set("circ_reason", "")
|
||||
self.log_event("circulating-power trip cleared by operator")
|
||||
self._circ_announced = False
|
||||
# SAFETY-42, cleared by the SAME press, and for a reason that is
|
||||
# NOT the one above. The other three latches are cleared here
|
||||
# because their trip removed its own evidence - a site commanding
|
||||
# 0 W cannot oppose anything - so only a human can say the fault
|
||||
# is gone. This one commands nothing, so its evidence is still
|
||||
# standing and it could in principle clear itself. It does not:
|
||||
# a unit that stopped delivering for minutes and then resumed is
|
||||
# an INTERMITTENT fault, and a detector that erased its own
|
||||
# record of that would be the least useful thing in this file.
|
||||
# ⚠️ AND `_delivery_announced` GOES WITH IT, like
|
||||
# `_circ_announced`: leaving a name in it through a clear would
|
||||
# mean the NEXT trip on that unit latched and raised no
|
||||
# notification at all.
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
self.log_event(f"not-delivering report on {inv.name} "
|
||||
f"cleared by operator")
|
||||
inv.delivery_wd.reset()
|
||||
inv.store_set("delivery_tripped", False)
|
||||
inv.store_set("delivery_reason", "")
|
||||
self._delivery_announced = set()
|
||||
if not self.auto:
|
||||
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
|
||||
# Zeroing `self.target` alone would leave each unit's last share
|
||||
@@ -3779,6 +4187,13 @@ async def amain() -> None:
|
||||
broker.get("username") if broker else None,
|
||||
broker.get("password") if broker else None,
|
||||
omit=() if is_enabled(opts) else ("p1_age",),
|
||||
# TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here
|
||||
# would construct a SECOND set of `Inverter`s off the same
|
||||
# Store, minutes before `Controller.__init__` constructs the
|
||||
# real ones; `roster_names()` is the naming rule on its own and
|
||||
# is what `build_roster()` itself reads. At a roster of one this
|
||||
# announces nothing new - see `announced_units()`.
|
||||
units=roster_names(opts),
|
||||
)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)
|
||||
|
||||
+232
-24
@@ -90,14 +90,239 @@ SENSORS = [
|
||||
BASE = "goodwe_ctl"
|
||||
AVAILABILITY = f"{BASE}/availability"
|
||||
|
||||
# --- TEL-07: a SECOND device, for inverter B, and NOT one per inverter -------
|
||||
#
|
||||
# ⚠️ READ THE DEVICE-NAME NOTE ABOVE BEFORE CHANGING ANYTHING HERE. The device
|
||||
# name is half of every entity_id, so re-scoping `DEVICE` renames every entity
|
||||
# the live install already has - and HA keeps the id it composed at FIRST
|
||||
# discovery, so what a rename actually produces is a NEW entity beside a dead
|
||||
# one: the history stays behind on the old id, and every dashboard, template
|
||||
# and automation that names it (including `goodwe_master_watchdog_held_*` and
|
||||
# `goodwe_master_parked_at_0_w_*`) keeps pointing at the entity that stopped
|
||||
# updating. That is silent. So:
|
||||
#
|
||||
# `DEVICE` IS NOT TOUCHED AND ITS ENTITIES ARE NOT TOUCHED. It is the SITE
|
||||
# device - the meter's view and the controller's own state - and at the
|
||||
# shipped roster of one it is also, in effect, inverter A's.
|
||||
#
|
||||
# THE FIRST ROSTER ENTRY GETS NO NEW DEVICE. Its per-unit rows are announced
|
||||
# ON THE DEVICE IT ALREADY HAS, under new `unique_id`s and new names. Nothing
|
||||
# existing is renamed, so nothing existing loses its history.
|
||||
#
|
||||
# EVERY LATER ENTRY GETS ITS OWN DEVICE (`via_device` the site one).
|
||||
#
|
||||
# ⚠️ THE ASYMMETRY IS THE POINT, AND IT WILL LOOK LIKE AN OVERSIGHT. "One
|
||||
# device per inverter", with A as `inverter-a` and B as `inverter-b`, is tidy,
|
||||
# symmetrical, and is the change that destroys the live system's recorded
|
||||
# history. DO NOT "CLEAN THIS UP". The composed entity_ids come out symmetric
|
||||
# anyway - `sensor.goodwe_inverter_a_battery_power` and
|
||||
# `sensor.goodwe_inverter_b_battery_power` - which is the whole benefit of the
|
||||
# tidy version, without the cost.
|
||||
#
|
||||
# WHAT IS PER-UNIT AND WHAT IS SITE, and why the split is where it is:
|
||||
#
|
||||
# PER-UNIT setpoint (this unit's share of the site target, the number its
|
||||
# own heartbeat re-emits), battery power (`inv.batt`, this unit's
|
||||
# own meter reading), SoC (`inv.soc`, THIS pack - the site row is
|
||||
# the EMPTIEST pack, which at N>=2 may be the other one), and the
|
||||
# SAFETY-02 sign latch, which is per actuator by construction.
|
||||
#
|
||||
# SITE `grid` and `p1_age`. There is ONE P1 meter and ONE grid
|
||||
# connection; announcing them per inverter would invent two of a
|
||||
# thing there is one of. `p1_sample_age_s` is additionally a
|
||||
# WATCHDOG INPUT (see `omit` below and the firmware note above), so
|
||||
# a second copy of it is a second copy of a safety signal - two
|
||||
# answers to "is the meter fresh?", one of which is always the one
|
||||
# nobody is looking at. Also site: circulating power (it is
|
||||
# `sum(|Bi|) - |sum(Bi)|`, a property of the SET), the controller
|
||||
# status, the maintenance phase, and the capacity-tariff peak.
|
||||
#
|
||||
# NOT ANNOUNCED, AND SAID OUT LOUD RATHER THAN LEFT LOOKING FINISHED: the
|
||||
# card also lists "inverter AC power" and per-unit HEARTBEATS as per-unit.
|
||||
# Neither has a source in this add-on today - nothing reads an AC-power
|
||||
# register per unit, and `inv.last_beat` is a monotonic loop clock with no
|
||||
# wall-clock age published anywhere. Announcing them would create entities
|
||||
# nothing ever feeds, which is exactly what the `omit` note below refuses to
|
||||
# do for `p1_sample_age_s`. They are gaps, not omissions.
|
||||
UNIT_SENSORS = [
|
||||
("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),
|
||||
("battery", "battery_power", "Battery power", "W", "power", "measurement", None),
|
||||
("soc", "battery_soc", "Battery SoC", "%", "battery", "measurement", None),
|
||||
("sign_latch", "sign_latch", "Sign latch", None, None, None, "mdi:flash-alert"),
|
||||
]
|
||||
|
||||
# ⚠️ A ROSTER OF ONE ANNOUNCES NOTHING NEW - not a device, not an entity, not a
|
||||
# topic. Every install running today is a roster of one, and "the upgrade added
|
||||
# four entities nobody asked for" is a change to a live system's entity list
|
||||
# for no information: at N=1 the site rows ARE this unit's rows, exactly.
|
||||
PER_UNIT_MIN_ROSTER = 2
|
||||
|
||||
|
||||
def _slug(text) -> str:
|
||||
"""Home Assistant's own object_id spelling: lowercase, `_`, no runs.
|
||||
|
||||
⚠️ ASCII-ONLY ON PURPOSE. `str.isalnum()` is true for 'é' and for '٤', and
|
||||
HA transliterates those rather than keeping them, so treating them as
|
||||
alphanumeric here would produce an id this module claims and HA does not.
|
||||
They become separators, which is conservative in the right direction: two
|
||||
names that differ ONLY outside ASCII collide, and `unit_slugs()` below is
|
||||
what stops a collision becoming two entities with one `unique_id`.
|
||||
"""
|
||||
out = []
|
||||
for ch in str(text).strip().lower():
|
||||
out.append(ch if (ch.isascii() and ch.isalnum()) else "_")
|
||||
slug = "".join(out)
|
||||
while "__" in slug:
|
||||
slug = slug.replace("__", "_")
|
||||
return slug.strip("_")
|
||||
|
||||
|
||||
def _title(name) -> str:
|
||||
"""`inverter-b` -> `Inverter B`. The half of the entity_id HA composes."""
|
||||
words = str(name).replace("-", " ").replace("_", " ").split()
|
||||
return " ".join(w[:1].upper() + w[1:] for w in words) or str(name)
|
||||
|
||||
|
||||
def unit_slugs(units) -> list:
|
||||
"""One id-safe slug per roster entry, in roster order, ALL DISTINCT.
|
||||
|
||||
⚠️ THE DE-COLLISION IS NOT DECORATION. `validate.py` rule 9(c) refuses two
|
||||
entries with the SAME name; it does not refuse `inverter-b` beside
|
||||
`Inverter B`, and those two slug to one string. Two entities published with
|
||||
one `unique_id` is not an error in HA - the second one is DROPPED, so one
|
||||
inverter's telemetry silently stops existing. The suffix is the roster
|
||||
INDEX, so it is stable for a given roster and does not depend on what the
|
||||
other unit is called.
|
||||
"""
|
||||
slugs, seen = [], set()
|
||||
for i, name in enumerate(units):
|
||||
slug = _slug(name) or f"inverter_{i + 1}"
|
||||
while slug in seen:
|
||||
slug = f"{slug}_{i + 1}"
|
||||
seen.add(slug)
|
||||
slugs.append(slug)
|
||||
return slugs
|
||||
|
||||
|
||||
def announced_units(units) -> list:
|
||||
"""The roster, in order - or EMPTY below `PER_UNIT_MIN_ROSTER`.
|
||||
|
||||
⚠️ ONE ANSWER TO "WHICH UNITS GET PER-UNIT ENTITIES", read by the
|
||||
announcement here AND by `Controller.publish()` in main.py. Two spellings of
|
||||
the same predicate is how an entity gets announced that nothing publishes
|
||||
to, or published to on a topic nothing announced - and at N=1 the second
|
||||
would be an added topic, which AC 4 forbids outright.
|
||||
"""
|
||||
names = [str(u) for u in units]
|
||||
if len(names) < PER_UNIT_MIN_ROSTER:
|
||||
return []
|
||||
return names
|
||||
|
||||
|
||||
def unit_state_key(slug: str, key: str) -> str:
|
||||
"""The `publish()` key - i.e. the state topic under `BASE` - for one unit.
|
||||
|
||||
Written once, here, because main.py builds it and this module announces it;
|
||||
a second transcription is a topic announced and never fed.
|
||||
"""
|
||||
return f"unit/{slug}/{key}"
|
||||
|
||||
|
||||
def unit_device(name, slug: str) -> dict:
|
||||
"""B's OWN device. ⚠️ Never returned for the first roster entry."""
|
||||
return {
|
||||
"identifiers": [f"{DEVICE['identifiers'][0]}_{slug}"],
|
||||
"name": f"{DEVICE['name']} {_title(name)}",
|
||||
"manufacturer": DEVICE["manufacturer"],
|
||||
"model": DEVICE["model"],
|
||||
# Hangs B under the controller in HA's device tree rather than beside
|
||||
# it. Cosmetic, and the one line that says in the UI what the asymmetry
|
||||
# above means: there is one controller, and it drives both.
|
||||
"via_device": DEVICE["identifiers"][0],
|
||||
}
|
||||
|
||||
|
||||
def _payload(name, object_id, unique_id, state_topic, device,
|
||||
unit, dev_class, state_class, icon) -> dict:
|
||||
"""One discovery payload.
|
||||
|
||||
⚠️ KEY ORDER IS PART OF THE OUTPUT. `json.dumps` writes keys in insertion
|
||||
order, so this dict's construction order IS the bytes on the wire, and AC 1
|
||||
is a BYTE comparison against what `release/1.0` publishes. Do not reorder.
|
||||
"""
|
||||
cfg = {
|
||||
"name": name,
|
||||
"object_id": object_id,
|
||||
"unique_id": unique_id,
|
||||
"state_topic": state_topic,
|
||||
"availability_topic": AVAILABILITY,
|
||||
"device": device,
|
||||
}
|
||||
if unit:
|
||||
cfg["unit_of_measurement"] = unit
|
||||
if dev_class:
|
||||
cfg["device_class"] = dev_class
|
||||
if state_class:
|
||||
cfg["state_class"] = state_class
|
||||
if icon:
|
||||
cfg["icon"] = icon
|
||||
return cfg
|
||||
|
||||
|
||||
def discovery_payloads(units=(), omit=()) -> list:
|
||||
"""Every `(topic, payload)` this add-on announces, in announcement order.
|
||||
|
||||
Split out of `_announce()` so that the acceptance criterion can be graded as
|
||||
a DIFF OF THE ANNOUNCED PAYLOADS against `release/1.0`, rather than as a
|
||||
reading of the code. `_announce()` publishes exactly this list and nothing
|
||||
else, which is itself checked.
|
||||
"""
|
||||
omit = set(omit)
|
||||
out = []
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
||||
if key in omit:
|
||||
continue
|
||||
out.append((
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
|
||||
DEVICE, unit, dev_class, state_class, icon)))
|
||||
names = announced_units(units)
|
||||
slugs = unit_slugs(names)
|
||||
site_slug = _slug(DEVICE["name"])
|
||||
for i, (name, slug) in enumerate(zip(names, slugs)):
|
||||
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
|
||||
# keeps the device it already has and carries its name in the ENTITY
|
||||
# name instead; every later one gets its own device and does not.
|
||||
# Either way HA composes device-name + entity-name into the same
|
||||
# string, which is why `object_id` below can be written once for both.
|
||||
device = DEVICE if i == 0 else unit_device(name, slug)
|
||||
prefix = f"{_title(name)} " if i == 0 else ""
|
||||
for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS:
|
||||
unique_id = f"{BASE}_unit_{slug}_{key}"
|
||||
out.append((
|
||||
f"homeassistant/sensor/{unique_id}/config",
|
||||
_payload(prefix + label,
|
||||
f"{site_slug}_{slug}_{oid}",
|
||||
unique_id,
|
||||
f"{BASE}/{unit_state_key(slug, key)}",
|
||||
device, unit, dev_class, state_class, icon)))
|
||||
return out
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
def __init__(self, host, port, username=None, password=None, omit=()):
|
||||
def __init__(self, host, port, username=None, password=None, omit=(),
|
||||
units=()):
|
||||
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
|
||||
# sensor that nothing will ever publish to is not harmless here:
|
||||
# p1_sample_age_s is a watchdog input, and an entity that exists but is
|
||||
# never fed is a worse signal than one that does not exist at all.
|
||||
self.omit = set(omit)
|
||||
# TEL-07. The roster's NAMES, in roster order - `inverter.roster_names()`,
|
||||
# not a second reading of the option. Defaults to empty, which
|
||||
# `announced_units()` treats exactly as a roster of one: nothing
|
||||
# per-unit is announced, so every caller that has not been taught about
|
||||
# the roster keeps publishing what it published before.
|
||||
self.units = [str(u) for u in units]
|
||||
self.enabled = mqtt is not None and bool(host)
|
||||
self.client = None
|
||||
if not self.enabled:
|
||||
@@ -130,29 +355,12 @@ class MqttPublisher:
|
||||
self.enabled = False
|
||||
|
||||
def _announce(self) -> None:
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
||||
if key in self.omit:
|
||||
continue
|
||||
cfg = {
|
||||
"name": name,
|
||||
"object_id": object_id,
|
||||
"unique_id": f"{BASE}_{key}",
|
||||
"state_topic": f"{BASE}/{key}",
|
||||
"availability_topic": AVAILABILITY,
|
||||
"device": DEVICE,
|
||||
}
|
||||
if unit:
|
||||
cfg["unit_of_measurement"] = unit
|
||||
if dev_class:
|
||||
cfg["device_class"] = dev_class
|
||||
if state_class:
|
||||
cfg["state_class"] = state_class
|
||||
if icon:
|
||||
cfg["icon"] = icon
|
||||
self.client.publish(
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
json.dumps(cfg), retain=True,
|
||||
)
|
||||
# ⚠️ NO PAYLOAD IS BUILT HERE. `discovery_payloads()` is the whole
|
||||
# announcement, so the check that diffs it against `release/1.0` is
|
||||
# diffing what actually goes on the wire - and a payload built here
|
||||
# instead would be one this add-on publishes and no check has ever seen.
|
||||
for topic, cfg in discovery_payloads(self.units, self.omit):
|
||||
self.client.publish(topic, json.dumps(cfg), retain=True)
|
||||
self.client.publish(AVAILABILITY, "online", retain=True)
|
||||
|
||||
def publish(self, values: dict) -> None:
|
||||
|
||||
@@ -183,6 +183,60 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
the per-unit form and main.py runs it from `run_control()`, once every
|
||||
unit's own ESPHome has replied.
|
||||
|
||||
10. `slew_w` above the fraction of the SITE RAIL the plant can follow. [(a)]
|
||||
SAFETY-39, and the first rule here whose subject is a RELATIONSHIP rather
|
||||
than a threshold. `slew_w` bounds how far the site command may move in one
|
||||
cycle; `control.SLEW_RAIL_FRAC` is `LOOP_TICK_S / PLANT_SETTLE_S`, so
|
||||
`SLEW_RAIL_FRAC * rail` is the largest step at which the loop still takes
|
||||
the plant's own measured settling time to traverse its whole output range.
|
||||
Above it the controller commands its entire range before it can observe
|
||||
its answer to the first step, and the stated limit - "the command moves at
|
||||
most `slew_w` per cycle, so it cannot jump ahead of the inverter" - is
|
||||
untrue: it jumps ahead by construction.
|
||||
|
||||
⚠️ THIS IS (a) AND NOT (b), and the distinction is what makes it a refusal
|
||||
rather than a warning. Nothing goes inert. What happens is that a bound
|
||||
the operator set means something other than what it says, and the layer
|
||||
that overrules it is the PLANT - which has no status row, publishes
|
||||
nothing, and cannot be read off any page in this add-on. Measured on the
|
||||
owner's system 2026-09-05 11:43-11:46 at `max_w: 5000`, `slew_w: 1000`:
|
||||
setpoint 380 -> 5000 in five 2 s ticks, battery -5154 W against a 5000 VA
|
||||
inverter, and the grid driven from 3.4 kW of EXPORT to 2.8 kW of IMPORT.
|
||||
Large (>= 900 W) setpoint steps per 30 min climbed 2 -> 1 -> 13 -> 43 that
|
||||
morning. Nothing in the add-on reported anything wrong, because nothing
|
||||
was: the loop followed its command exactly, and the command was wrong.
|
||||
|
||||
⚠️ IT IS GRADED AGAINST THE ROSTER SUM, NOT THE FLAT `max_w` AND NOT THE
|
||||
WEAKEST UNIT, and this is the one rule in this file for which that is the
|
||||
right rail. Rules 5, 6 and 8 are all about ONE unit - one watchdog, one
|
||||
shaping claim, one pack - so they take `min()` over `unit_caps()`.
|
||||
`slew_w` is not per unit: `main.py` builds `Tuning(max_w=self.site_max_w)`
|
||||
and `compute()` produces the SITE target, which the allocator then splits.
|
||||
So the range this bound is about is the sum, `Controller.site_max_w`, and
|
||||
`site_rail_w()` below is that same number computed from options.
|
||||
|
||||
⚠️ AND THE `site_sign_disagree_w` NOTE IN config.yaml IS NOT A PRECEDENT
|
||||
AGAINST THIS. That note says there is deliberately no rule pairing
|
||||
`site_sign_disagree_w` with `max_w`, "because the bound on the SITE command
|
||||
is `Controller.site_max_w`, the ROSTER SUM, and `validate_startup_config()`
|
||||
is handed options rather than a roster. A rule written against max_w would
|
||||
refuse a perfectly reachable two-inverter configuration." That objection is
|
||||
to grading a site quantity against the FLAT key. It is answered rather than
|
||||
ignored: `unit_caps()` already reads the roster the add-on will build, so
|
||||
the roster sum IS available here, and this rule takes it.
|
||||
|
||||
⚠️ THE DIRECTION OF THIS RULE, SAID PLAINLY, BECAUSE IT IS NOT THE
|
||||
DIRECTION THE TICKET ASSUMED. A rail that GROWS loosens this bound: at a
|
||||
9600 W two-inverter rail, `slew_w: 1000` traverses the rail in 19.2 s and
|
||||
passes, while at 5000 W the same 1000 W is the configuration measured
|
||||
above. That is what the constraint says and it is correct - the bound is on
|
||||
traverse TIME, and a bigger rail at the same step size takes longer to
|
||||
cross. What this rule therefore protects against is the constant being left
|
||||
behind when the rail moves EITHER way. It is NOT a bound on how much power
|
||||
the site may command, and it does NOT bound the over-absorption: see the
|
||||
note below on `gain`, which is the term that owns that and which SAFETY-39
|
||||
measured and did not change.
|
||||
|
||||
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
|
||||
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
||||
@@ -226,6 +280,34 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
is answered. Compare rule 5, which CAN be settled at startup because both
|
||||
`sign_disagree_w` and `max_w` are config and are both known at boot.
|
||||
|
||||
- `gain` at any value the schema accepts, INCLUDING against a large rail.
|
||||
SAFETY-39 measured this and deliberately left it alone, which is worth more
|
||||
than the rule would have been. On admin/goodwe's rig, over a family of 51
|
||||
export steps from 1.0 to 4.2 kW at `max_w: 5000` and a 2 s tick, the
|
||||
over-absorption - peak battery power beyond what the site actually needed -
|
||||
is FLAT in `slew_w`: 958 W at slew 1000 and 1081 W at slew 250. It falls
|
||||
in `gain`: 987 W at 0.6, 876 W at 0.3, 435 W at 0.2 - a real reduction, but
|
||||
NOT to zero, and the 0.2 figure is the one worth not rounding away. The
|
||||
reason is structural and is visible in control.py: `slew_w` bounds the
|
||||
OUTPUT, while the integrator accumulates `gain * error` per cycle and is
|
||||
bounded only by `integrator_max_w`. At gain 0.6 into a 3.4 kW error the
|
||||
integrator moves ~2040 W per tick and reaches a 5000 W rail in under three
|
||||
ticks whatever `slew_w` is.
|
||||
So the 2026-08-22 derivation's own sentence - "the gain, not the slew, is
|
||||
the limit by then" - is now literally true, and `gain` is the constant that
|
||||
needs re-deriving next. It is NOT refused and NOT changed here: it is a
|
||||
live control-law constant on a running house, changing it in the same pull
|
||||
request as `slew_w` would make the next measurement uninterpretable, and a
|
||||
validator rule pairing `gain` with a rail would be inventing a relationship
|
||||
nobody has measured. Named here so it is a known open item rather than an
|
||||
omission. See DECISIONS/SAFETY-39-slew-against-the-rail.md.
|
||||
|
||||
- `slew_w` BELOW the rail fraction, at any value the schema accepts. Slower
|
||||
than the plant is always safe and is sometimes necessary - the live install
|
||||
has run an explicit 400 W against a 5000 W rail since 2026-09-05 12:00,
|
||||
which is 12.5 ticks to traverse the rail against the 9 this rule requires.
|
||||
Refusing "too slow" would refuse the mitigation that is holding.
|
||||
|
||||
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
|
||||
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
|
||||
every ordering of the two is a legitimate site configuration. Refusing on
|
||||
@@ -235,6 +317,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
import logging
|
||||
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .inverter import roster_entries
|
||||
from .maintenance import MAINT_OPTION_DEFAULTS
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
@@ -392,6 +475,27 @@ def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
|
||||
return caps
|
||||
|
||||
|
||||
def site_rail_w(opts: dict) -> float | None:
|
||||
"""The SITE rail: what the whole site may be commanded, in W. Rule 10's rail.
|
||||
|
||||
⚠️ THE SUM, NOT THE MINIMUM, AND NOT THE FLAT KEY - and it is the only place
|
||||
in this file that wants a sum. Rules 5, 6 and 8 grade a threshold against
|
||||
ONE unit (one watchdog, one shaping claim, one pack) and take `min()` over
|
||||
`unit_caps()`. `slew_w` is not a per-unit quantity at all: `main.py` builds
|
||||
`Tuning(max_w=self.site_max_w)` from the roster sum and `compute()` emits
|
||||
the SITE target, which the allocator then divides. So the output range
|
||||
`slew_w` is a rate across is `Controller.site_max_w`, and this is that same
|
||||
number read out of options rather than off a built roster.
|
||||
|
||||
At the shipped roster of one this is `unit_caps()`'s single entry, which is
|
||||
`max_w` exactly - byte for byte the number the single-inverter shape has
|
||||
always meant. None when no entry carries a usable cap, which rule 9 has
|
||||
already refused by the time rule 10 runs.
|
||||
"""
|
||||
caps = unit_caps(opts)
|
||||
return float(sum(caps)) if caps else None
|
||||
|
||||
|
||||
def unit_bms_floors_indexed(opts: dict) -> list[tuple[int, object, object]]:
|
||||
"""SAFETY-32. Each pack's OWN `bms_soc_floor`, carrying its ROSTER INDEX.
|
||||
|
||||
@@ -935,6 +1039,62 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
f"two numbers. Lower maintenance_charge_w to {maint_cap:.0f} W or "
|
||||
f"less, or raise max_w to at least {charge_w:.0f} W.")
|
||||
|
||||
# --- 10. a slew rate the plant can actually follow -----------------------
|
||||
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
|
||||
# threshold, and the whole point of writing it as one is that the number it
|
||||
# bounds must not be able to outlive the rail again. `control.py`'s header
|
||||
# carries the derivation; the short version is that `slew_w: 1000` was
|
||||
# derived against a 1500 W clamp and the live install raised the clamp to
|
||||
# 5000 without revisiting it, so the loop traversed its whole output range
|
||||
# in 10 s against a plant measured at ~18 s.
|
||||
#
|
||||
# ⚠️ `slew_w: 0` IS NOT GRADED AND MUST NOT BE. Zero means "follow the
|
||||
# rail" - `control.slew_limit_w()` resolves it to `SLEW_RAIL_FRAC * max_w`,
|
||||
# which satisfies this rule by construction at every rail there is. It is
|
||||
# the shipped default, and a rule that refused it would refuse the fix.
|
||||
#
|
||||
# ⚠️ STRICTLY `>`, MATCHING RULE 6 RATHER THAN RULE 5. Equality is the
|
||||
# derived value itself: at `slew_w == SLEW_RAIL_FRAC * rail` the loop takes
|
||||
# exactly `PLANT_SETTLE_S` to cross its range, which is the constraint
|
||||
# satisfied, not violated. Refusing it would refuse the value this rule
|
||||
# exists to recommend - and `slew_limit_w()` produces exactly that value
|
||||
# for every default install, so `>=` would refuse the shipped default
|
||||
# through a float comparison. A tolerance is applied for the same reason:
|
||||
# `SLEW_RAIL_FRAC * 5000` is 555.55..., and an operator who writes the 556
|
||||
# a status page rounded to is one watt over a boundary that is not
|
||||
# meaningful to one watt.
|
||||
slew_w = _number(opts, "slew_w", 0.0)
|
||||
rail = site_rail_w(opts)
|
||||
if slew_w is None:
|
||||
problems.append(f"slew_w {opts.get('slew_w')!r} is not a number")
|
||||
elif slew_w < 0:
|
||||
problems.append(
|
||||
f"slew_w is {slew_w:.0f} W and must not be negative - it is a "
|
||||
"magnitude, and 0 means 'follow max_w' (the recommended setting).")
|
||||
elif slew_w and rail is not None and rail > 0:
|
||||
bound = SLEW_RAIL_FRAC * rail
|
||||
if slew_w > bound + 1.0:
|
||||
ticks = rail / slew_w
|
||||
problems.append(
|
||||
f"slew_w is {slew_w:.0f} W against a site rail of {rail:.0f} W, "
|
||||
f"so the loop crosses its whole output range in {ticks:.1f} "
|
||||
f"cycles - {ticks * LOOP_TICK_S:.0f} s at the {LOOP_TICK_S:.0f} s "
|
||||
f"tick this site was measured running - while the plant takes "
|
||||
f"{PLANT_SETTLE_S:.0f} s to settle (goodwe/NOTES.md §5.10, a "
|
||||
"1.78 kW step). The controller would command its entire range "
|
||||
"before it could observe its answer to the first step, which is "
|
||||
"not a slow loop, it is a loop that MANUFACTURES the error it "
|
||||
"then chases: measured on this system 2026-09-05, a 3.4 kW "
|
||||
"export drove the setpoint 380 -> 5000 W in five 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. The stated limit - the "
|
||||
"command moves at most slew_w per cycle, so it cannot jump "
|
||||
"ahead of the inverter - is untrue at this rail; THE PLANT "
|
||||
f"WINS, and it has no status row. Set slew_w to 0, which means "
|
||||
f"'follow the rail' and resolves to {bound:.0f} W here and "
|
||||
"moves on its own if the rail does, or write a value at or "
|
||||
f"below {bound:.0f} W. Lower is always accepted.")
|
||||
|
||||
# --- 7. two price sources on one site ------------------------------------
|
||||
problems.extend(_price_problems(opts))
|
||||
|
||||
|
||||
@@ -331,6 +331,18 @@ SIGN_OPTION_DEFAULTS = {
|
||||
# 150 W -> 15.0 s 300 W -> 10.0 s 1000 W -> 0.0 s
|
||||
# 200 W -> 15.0 s 500 W -> 5.0 s
|
||||
#
|
||||
# ⚠️ SAFETY-39 MOVED THAT SCHEMA FLOOR FROM 50 TO 0, AND THE CAMPAIGN ABOVE
|
||||
# WAS NOT RE-RUN. It does not have to be, and here is the argument rather
|
||||
# than the assurance. What these windows are about is how long the command
|
||||
# takes to CROSS ITS RAIL - the swept worst case is the SLOWEST crossing,
|
||||
# which is `slew_w` at its floor, 5000/50 = 100 cycles. `slew_w: 0` means
|
||||
# "follow the rail" and resolves to `SLEW_RAIL_FRAC * max_w`, which is
|
||||
# exactly 9 cycles at EVERY rail by construction. So the new value can never
|
||||
# be the slowest crossing at any `max_w`, and it is strictly inside the
|
||||
# envelope this campaign measured. An explicit number still spans 50..5000
|
||||
# and is unchanged. If `SLEW_RAIL_FRAC` is ever loosened past 1/100, this
|
||||
# paragraph stops being true and the campaign has to be re-run.
|
||||
#
|
||||
# ⚠️ AND THE CURVE IS FLAT, WHICH IS THE FINDING THAT DECIDED THE SHAPE OF
|
||||
# THIS PAIR. Unlike the site comparison - where the meter is driven by the
|
||||
# LOAD and a flapping load holds it opposed for minutes - a unit's own power
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.6.0"
|
||||
version: "0.7.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -511,12 +511,91 @@ options:
|
||||
circulation_w: 700
|
||||
circulation_hold_s: 75
|
||||
|
||||
# ⚠️ CFG-03. THIS LINE IS THE SOURCE OF TRUTH FOR THIS DEFAULT, and
|
||||
# `app/delivery.py`'s `DELIVERY_OPTION_DEFAULTS` is the ONE in-code copy -
|
||||
# the Dockerfile does not put this file in the image, so the running add-on
|
||||
# cannot read it. test_delivery.py pins the copy by READING this line, so a
|
||||
# drift goes red in CI rather than shipping. Two places - options:, schema: -
|
||||
# plus that one dict, and no fourth.
|
||||
#
|
||||
# ⚠️ THERE IS NO `delivery_w` TO GO WITH IT, AND THAT IS DELIBERATE. The watts
|
||||
# half of this check is `saturation_w`, the option two blocks down, read
|
||||
# directly: it is already the gap at which the control law itself concludes
|
||||
# the inverter is not following, and a second key would be a fourth place a
|
||||
# threshold has to be kept in step and a way for the two to disagree. So
|
||||
# RAISING `saturation_w` raises this check with it, and test_delivery.py part
|
||||
# 4b holds that promise by running the same fault at two different
|
||||
# `saturation_w` values and requiring two different answers - a check that
|
||||
# only ever ran at 500 could not tell this wiring from a hard-coded number.
|
||||
#
|
||||
# How long ONE inverter may stand at a discharge share it is not producing -
|
||||
# short by more than `saturation_w` - while at least one sibling is NOT in
|
||||
# that state, before the add-on names it. It REPORTS: nothing is commanded
|
||||
# differently, the loop is untouched, and the site goes on running short by
|
||||
# that unit's share until somebody looks. app/delivery.py carries why, with
|
||||
# the rig measurements on both sides of the decision.
|
||||
#
|
||||
# Ignored at one inverter, where the check is inert by construction: with no
|
||||
# sibling, "this box cannot deliver" and "this plant cannot deliver" are the
|
||||
# same observation.
|
||||
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 2640 closed-loop runs
|
||||
# at a roster of two in which nothing was faulty, over 6 gains x 5 slews - the
|
||||
# whole range this schema accepts - across load reversals, a flapping load and
|
||||
# ASYMMETRIC SPLITS, plus 1890 runs at a roster of ONE as the control. At the
|
||||
# shipped threshold the worst CONTINUOUS legitimate episode is 30.0 s (the
|
||||
# weighted family; 20.0 s on reversals, 0.0 s on flapping, and 0.0 s at every
|
||||
# threshold in all 1890 one-inverter runs). 185 s is 6.2x that.
|
||||
#
|
||||
# ⚠️ AND THE MARGIN IS SIZED ON A MEASUREMENT, NOT ON A FEELING, BECAUSE
|
||||
# INVERTER B DOES NOT PHYSICALLY EXIST. The rig's B is A copied. Sweeping a
|
||||
# DIFFERENT second unit through that campaign: the worst legitimate episode is
|
||||
# insensitive to B's ramp (30.0 s at half A's ramp) and scales with its LAG -
|
||||
# 45.0 s at twice A's measured 10 s lag, 90.0 s at four times it. 185 s is
|
||||
# 2.06x even the four-times-slower case.
|
||||
#
|
||||
# DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this is not it -
|
||||
# and not 150 or 200. 37 control cycles.
|
||||
delivery_hold_s: 185
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
auto_start: false
|
||||
gain: 0.6
|
||||
slew_w: 1000
|
||||
|
||||
# ⚠️ SAFETY-39. 0 IS NOT "UNSET" AND IT IS NOT "NO LIMIT" - IT IS "FOLLOW THE
|
||||
# RAIL", and it is the shipped value. `control.slew_limit_w()` resolves it to
|
||||
# `SLEW_RAIL_FRAC * max_w`, which is 222 W against the 2000 W default here,
|
||||
# 556 W against the live install's 5000 W, and 1067 W against the ~9600 W a
|
||||
# second inverter makes of the site. `integrator_max_w: 0` two lines down has
|
||||
# meant exactly this since it was added; this key now means it too.
|
||||
#
|
||||
# ⚠️ WHY IT IS A RATIO AND NOT A NUMBER, WHICH IS THE WHOLE OF THIS CARD. It
|
||||
# WAS a number: 1000, derived 2026-08-22 against a clamp of 1500 W
|
||||
# (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)") on the
|
||||
# grounds that it caps "how far the loop can jump ahead of the inverter". The
|
||||
# rail then went to 5000 on the live install and the constant did not move, so
|
||||
# 1000 W per 2 s tick crossed the whole output range in 10 s against a plant
|
||||
# NOTES.md §5.10 measured at ~18 s up / ~23 s down. Measured on the owner's
|
||||
# system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000
|
||||
# in five 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. Large (>= 900 W) steps per 30 min
|
||||
# went 2 -> 1 -> 13 -> 43 that morning. Nothing was broken; the rail moved.
|
||||
#
|
||||
# An explicit number is still accepted and is not clamped - the live install
|
||||
# has run 400 since 2026-09-05 12:00, which is slower than the rail implies
|
||||
# and is fine. What validate.py rule 10 refuses is a number FASTER than the
|
||||
# rail implies, because at that setting the stated limit is not the limit and
|
||||
# the layer that overrules it is the plant, which has no status row.
|
||||
#
|
||||
# ⚠️ AND THE COST IS REAL AND IS NOT HIDDEN: following the rail is slower to
|
||||
# converge than 1000 was. Measured over 51 export steps on the in-repo model
|
||||
# (test_control.py) the largest tick step falls from 1000 W to 556 W and the
|
||||
# >= 900 W step disappears from every run - but the OVER-ABSORPTION does not
|
||||
# move, because that is a `gain` term and not a slew term. See
|
||||
# DECISIONS/SAFETY-39-slew-against-the-rail.md, which says so at length rather
|
||||
# than letting this card be read as more than it is.
|
||||
slew_w: 0
|
||||
deadband_w: 15
|
||||
target_grid_w: -10
|
||||
step_w: 10
|
||||
@@ -739,12 +818,31 @@ schema:
|
||||
# loop must be caught and acted on well inside one.
|
||||
circulation_hold_s: int(30,900)
|
||||
|
||||
# SAFETY-42. The floor of 30 is `inverter.LINK_STALE_S`, for
|
||||
# `circulation_hold_s`'s reason and the same one SAFETY-30's per-unit window
|
||||
# takes it from: below the firmware's own veto window a unit not executing its
|
||||
# share is a LINK fault the add-on already answers by dropping it from the
|
||||
# split and saying so in the action log, and reporting inside that window
|
||||
# would be a second, louder alarm about a state already being handled. The
|
||||
# ceiling of 900 is the quarter that bills the capacity tariff - a site
|
||||
# running short of one unit's share must be reported well inside one.
|
||||
delivery_hold_s: int(30,900)
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
auto_start: bool
|
||||
gain: float(0.05,1.0)
|
||||
slew_w: int(50,5000)
|
||||
|
||||
# ⚠️ THE FLOOR IS 0, NOT 50, AND 0 IS THE SHIPPED VALUE - see the options block
|
||||
# above. It means "follow max_w", so the range starts at the setting that
|
||||
# cannot go stale rather than at the smallest number somebody could type.
|
||||
# ⚠️ THE CEILING IS STILL 5000 AND IS STILL NOT THE BOUND THAT MATTERS. The
|
||||
# binding one is `SLEW_RAIL_FRAC * max_w`, a relation BETWEEN keys, and this
|
||||
# grammar cannot express a relation - which is the same reason
|
||||
# `maintenance_charge_w`'s ceiling is not its real bound either (see the note
|
||||
# above it). validate.py rule 10 is where it is written.
|
||||
slew_w: int(0,5000)
|
||||
deadband_w: int(0,500)
|
||||
target_grid_w: float(-200,200)
|
||||
step_w: int(1,100)
|
||||
|
||||
@@ -580,7 +580,16 @@ def close_loop(gain, split, cycles=200, load_w=2000.0, mode="alloc",
|
||||
below. "site" is the sum of the units' power; "unit" is inverter A's alone,
|
||||
which is what the shipped add-on's single `inverter_power` sensor gives it.
|
||||
"""
|
||||
tuning = Tuning(gain=gain)
|
||||
# ⚠️ `slew_w` IS PINNED HERE AND WAS NOT BEFORE, AND THAT IS SAFETY-39.
|
||||
# ARCH-01's 4000 W peak-to-peak broadcast limit cycle was measured at the
|
||||
# shipped pair of the day - `max_w: 2000` with `slew_w: 1000` - and this
|
||||
# function reached that 1000 through `Tuning`'s DEFAULT. SAFETY-39 made that
|
||||
# default 0, meaning "follow the rail" (`SLEW_RAIL_FRAC * max_w`, 222 W at
|
||||
# this rail), so the same call would now sweep a DIFFERENT law and report
|
||||
# ARCH-01's measurement as absent. A reproduction that silently re-tunes
|
||||
# itself when a default moves is not a reproduction. The number is written
|
||||
# here, next to the measurement it belongs to.
|
||||
tuning = Tuning(gain=gain, slew_w=1000.0)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
power = [0.0, 0.0]
|
||||
grid = load_w
|
||||
|
||||
@@ -449,9 +449,32 @@ mis_peak = SITE_SIDE[:1] + [Claim.limit("peak", P_PEAK, lo=1500.0,
|
||||
r_mis = resolve(mis_peak)
|
||||
print(f" peak floor misfiled on inverter-b: {r_mis.explain()} "
|
||||
f"scope_errors={r_mis.scope_errors}")
|
||||
check("the mis-routing is CAUGHT: scope_violations names the source and the "
|
||||
"actuator it does not belong on", len(r_mis.scope_errors) == 1
|
||||
and "peak" in r_mis.scope_errors[0])
|
||||
# ⚠️ THE ORDERED PAIR, AND THE CHECK THAT USED TO BE HERE WAS WIDER THAN ITS
|
||||
# OWN CLAIM. It said `scope_violations` "names the source and the actuator it
|
||||
# does not belong on" and then asserted `"peak" in r_mis.scope_errors[0]`.
|
||||
# `arbiter.py:264` builds that sentence as
|
||||
# `f"{c.source}: a SITE claim filed on unit {c.actuator!r} - …"`; transpose the
|
||||
# two - zero bytes, no decision changes, SAFETY-38 census B row B12 - and it
|
||||
# reads "inverter-b: a SITE claim filed on unit 'peak'", naming the misfiled
|
||||
# claim as the actuator and the actuator as the claim. `"peak" in …` is STILL
|
||||
# TRUE under that, so the old line graded neither half of what it claimed.
|
||||
# The operands are read off the CLAIM this test built, not off the message.
|
||||
_mis_c = mis_peak[-1]
|
||||
check("PRECONDITION: the misfiled claim's source and actuator are DIFFERENT "
|
||||
"STRINGS, so a transposition genuinely produces a different sentence",
|
||||
_mis_c.source != _mis_c.actuator and _mis_c.source == "peak"
|
||||
and _mis_c.actuator == "inverter-b")
|
||||
check("the mis-routing is CAUGHT, and scope_violations names the source FIRST "
|
||||
"and the actuator it does not belong on SECOND - the ORDERED PAIR on the "
|
||||
"contiguous literal, because a substring test finds either name under a "
|
||||
"transposition that swaps their roles",
|
||||
len(r_mis.scope_errors) == 1
|
||||
and r_mis.scope_errors[0].startswith(
|
||||
f"{_mis_c.source}: a SITE claim filed on unit {_mis_c.actuator!r}"))
|
||||
check("...and the TRANSPOSED sentence is not what it says, so the check above "
|
||||
"discriminates rather than decorates",
|
||||
f"{_mis_c.actuator}: a SITE claim filed on unit {_mis_c.source!r}"
|
||||
not in r_mis.scope_errors[0])
|
||||
check("...and the reason it must be caught, measured rather than argued: with "
|
||||
"the floor on a unit the SITE has no floor, so the loop charges 1500 W "
|
||||
"into the quarter the guard was trying to shave",
|
||||
|
||||
@@ -78,7 +78,8 @@ import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, ClampWatchdog, clamp_to_rating,
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
|
||||
clamp_to_rating,
|
||||
is_commandable,
|
||||
)
|
||||
from app.allocator import Allocation, UnitPlan, plan
|
||||
@@ -239,11 +240,112 @@ print(f" pinned on the rail for {QUARTER_S:.0f} s at 1 s cycles: "
|
||||
check(f"a loop pinned for a whole billing quarter speaks at least "
|
||||
f"{MIN_PER_QUARTER} times (it spoke {alarms})",
|
||||
alarms >= MIN_PER_QUARTER)
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it is the SAME DEFECT as the one recorded
|
||||
# twenty lines above, one notch weaker. The sweep above runs i = 0..899 and the
|
||||
# rising edge at i=0 is FREE, so it measures `1 + floor(899 / REASSERT_S)` - one
|
||||
# alarm more than a real quarter earns. Measured on this very check:
|
||||
# REASSERT_S = 180 -> 5 alarms, green (and 180 is the true edge: on the worst
|
||||
# REASSERT_S = 224 -> 5 alarms, GREEN sliding 900 s window 180 gives 5 and
|
||||
# REASSERT_S = 225 -> 4 alarms, red 181 gives 4)
|
||||
# So the check as written permits 224 s while the REQUIREMENT permits 180 s, and
|
||||
# anyone raising the constant to 200 s would get a green suite and conclude the
|
||||
# floor was met when a real quarter would earn 4 alarms. Grade the cadence
|
||||
# DIRECTLY, so the check enforces the ceiling its own comment claims for it.
|
||||
check(f"...and the CADENCE itself honours the floor, independent of where the "
|
||||
f"sweep happens to start: floor({QUARTER_S:.0f}/{REASSERT_S:.0f}) = "
|
||||
f"{int(QUARTER_S // REASSERT_S)} >= {MIN_PER_QUARTER}",
|
||||
QUARTER_S // REASSERT_S >= MIN_PER_QUARTER)
|
||||
check(f"...and on no more than {MAX_FRACTION:.0%} of cycles (it was "
|
||||
f"{alarms / QUARTER_S:.1%})", alarms <= MAX_FRACTION * QUARTER_S)
|
||||
check("...while every single cycle is still counted",
|
||||
pinned.engagements == int(QUARTER_S))
|
||||
|
||||
# --- THE SCATTERED REGIME, WHICH IS THE ONE THE OWNER ACTUALLY HIT -----------
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it falsified the first version of this card.
|
||||
# The rising edge used to fire UNCONDITIONALLY, so REASSERT_S governed only the
|
||||
# still-engaged path. A condition that engages and clears repeatedly therefore
|
||||
# raised one notification PER EPISODE at any value of the constant.
|
||||
#
|
||||
# That is what the live install produced: 48 engagements in ~25 minutes,
|
||||
# scattered about one cycle in six. At the measured ~5 s cycle the gaps are ~25 s
|
||||
# of clean cycles, past CLEAR_CYCLES x 5 s = 15 s, so the alarm de-asserted
|
||||
# between almost every engagement. Raising REASSERT_S 30 -> 150 removed NONE of
|
||||
# them. The interval now bounds the notification RATE across episodes.
|
||||
#
|
||||
# The stimulus is written in wall-clock seconds and in the shape the field
|
||||
# actually produced, so it does not move when the constant does.
|
||||
SCATTER_CYCLE_S = 5.0
|
||||
SCATTER_ONE_IN = 6
|
||||
SCATTER_CYCLES = 305
|
||||
|
||||
scat = ClampWatchdog()
|
||||
scat_alarms = sum(1 for i in range(SCATTER_CYCLES)
|
||||
if scat.note(i % SCATTER_ONE_IN == 0, "scattered",
|
||||
T0 + timedelta(seconds=SCATTER_CYCLE_S * i)))
|
||||
_scat_span = SCATTER_CYCLE_S * SCATTER_CYCLES
|
||||
print(f" scattered 1-in-{SCATTER_ONE_IN} over {_scat_span:.0f} s at "
|
||||
f"{SCATTER_CYCLE_S:.0f} s cycles: {scat.episodes} episodes, "
|
||||
f"{scat.engagements} engagements, {scat_alarms} alarm(s)")
|
||||
check(f"a condition that de-asserts between engagements is rate-limited ACROSS "
|
||||
f"episodes, not once per episode ({scat_alarms} alarms for "
|
||||
f"{scat.episodes} episodes)",
|
||||
scat_alarms < scat.episodes)
|
||||
check(f"...and the rate honours the same interval the pinned case does: at most "
|
||||
f"ceil({_scat_span:.0f}/{REASSERT_S:.0f}) + 1 = "
|
||||
f"{int(_scat_span // REASSERT_S) + 1}",
|
||||
scat_alarms <= int(_scat_span // REASSERT_S) + 1)
|
||||
check("...while EVERY episode and EVERY engagement is still counted - the "
|
||||
"budget is on notifications, never on evidence",
|
||||
scat.episodes == scat.engagements == SCATTER_CYCLES // SCATTER_ONE_IN + 1)
|
||||
# ⚠️ THE COUNTERFACTUAL, AND THE FIRST VERSION OF IT COULD NOT FAIL FOR THE
|
||||
# DEFECT IT NAMED. It read `ClampWatchdog().note(True, ...) is True` - a FRESH
|
||||
# object, so it only ever exercised the `last_alarm is None` branch, which is the
|
||||
# trivially-true one. A fresh watchdog has no earlier episode to be held back BY.
|
||||
# That is the third instance in this file of the family recorded at line 216 and
|
||||
# at the 224 s sweep, and it was guarding the most safety-critical property in
|
||||
# the change. Review found it.
|
||||
#
|
||||
# The stimulus must therefore SPEND THE BUDGET FIRST: a benign one-cycle clamp,
|
||||
# a gap long enough to de-assert, then a continuous runaway. Without the
|
||||
# escalation clause the runaway's first notification is held for up to
|
||||
# REASSERT_S - measured at 140 s before it was added.
|
||||
_esc = ClampWatchdog()
|
||||
_esc.note(True, "benign", T0) # spends the budget
|
||||
for _i in range(1, 1 + CLEAR_CYCLES + 1): # de-assert
|
||||
_esc.note(False, "", T0 + timedelta(seconds=5.0 * _i))
|
||||
_RUNAWAY_AT = 5.0 * (CLEAR_CYCLES + 2)
|
||||
_esc_fires = [5.0 * (CLEAR_CYCLES + 2 + _j)
|
||||
for _j in range(200)
|
||||
if _esc.note(True, "runaway",
|
||||
T0 + timedelta(seconds=5.0 * (CLEAR_CYCLES + 2 + _j)))]
|
||||
_esc_delay = _esc_fires[0] - _RUNAWAY_AT if _esc_fires else None
|
||||
print(f" budget spent by a benign episode, then a continuous runaway from "
|
||||
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={_esc_fires[0]:.0f}s "
|
||||
f"(delay {_esc_delay:.0f}s)")
|
||||
check(f"⚠️ a SUSTAINED runaway speaks even when an earlier, unrelated episode "
|
||||
f"spent the budget - it is not held for REASSERT_S ({_esc_delay:.0f}s "
|
||||
f"delay, escalation is {SUSTAINED_CYCLES} cycles)",
|
||||
_esc_delay is not None and _esc_delay < REASSERT_S / 2)
|
||||
check("...and it speaks at the escalation boundary rather than immediately, "
|
||||
"which is what keeps the scattered flood suppressed",
|
||||
_esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
|
||||
|
||||
# ⚠️ `last_alarm` SURVIVING THE DE-ASSERT IS NOW LOAD-BEARING, AND NOTHING
|
||||
# PINNED IT. Review built the mutation: if the de-assert path ALSO refreshed
|
||||
# `last_alarm = now`, the chatter case collapses from 50 notifications to 1 - the
|
||||
# alarm never fires again. That is the "alarm that never fires" class, which is a
|
||||
# different bug from one that fires later, and the suite was green on it.
|
||||
_starve = ClampWatchdog()
|
||||
_starve_fires = sum(1 for _i in range(1600)
|
||||
if _starve.note(_i % 4 == 0, "chatter",
|
||||
T0 + timedelta(seconds=5.0 * _i)))
|
||||
print(f" chatter 1-engaged/{CLEAR_CYCLES}-clean over 8000 s: "
|
||||
f"{_starve.episodes} episodes, {_starve_fires} notification(s)")
|
||||
check(f"a de-assert does not RESET the notification budget, so repeated chatter "
|
||||
f"cannot starve the alarm to silence ({_starve_fires} notifications over "
|
||||
f"8000 s, not 1)",
|
||||
_starve_fires > 10)
|
||||
|
||||
# --- THE HYSTERESIS, PINNED AGAINST THE CHATTER DIRECTION --------------------
|
||||
# ⚠️ REVIEW-FOUND. This used to issue `range(CLEAR_CYCLES - 1)` clean cycles,
|
||||
# which at CLEAR_CYCLES = 1 issues ZERO of them and passes while printing
|
||||
@@ -898,6 +1000,105 @@ check("...and main.py:856's consumer is the line that goes quietly false",
|
||||
bool(ctl_w.clamp_last and ctl_w.clamp_last.engaged))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 6d. SAFETY-38 round 4 - THE ORDERED PAIR INSIDE `ClampResult.describe()`
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THIS IS THE ALARM THE OWNER HAS BEEN COMPLAINING ABOUT ALL DAY, AND UNTIL
|
||||
# THIS BLOCK NO test_*.py MENTIONED `describe()` AT ALL. `clamp.py:253` builds
|
||||
# the whole §15 action-log row and the whole ClampWatchdog notification reason
|
||||
# out of its own attributes:
|
||||
#
|
||||
# f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W was
|
||||
# clamped to {self.applied_w:+.0f} W by the {self.bound} bound
|
||||
# (rating {self.limit_w:.0f} W) - something upstream is winding"
|
||||
#
|
||||
# Transpose `requested_w` and `applied_w` and the guard reports clamping a
|
||||
# setpoint UPWARD - here, to +9999 W by a bound whose rating the SAME SENTENCE
|
||||
# gives as 2000 W. MEASURED on this tree: a zero-byte edit (22 507 bytes before,
|
||||
# 22 507 after, sha256 0153133741497fa5 -> 47e4e371043e3201), the emitted
|
||||
# sentence changes, and the full 21-file suite stayed GREEN. Nothing decides on
|
||||
# the order, so nothing behavioural can ever see it - which is why the check
|
||||
# below has to be on the ORDERED PAIR and on the CONTIGUOUS LITERAL. A substring
|
||||
# test for either number passes under the swap; both numbers are still there.
|
||||
#
|
||||
# ⚠️ AND THE OPERANDS ARE READ OFF THE PLANT, NOT OFF `clamp_last`. `ctl.target`
|
||||
# is what the loop asked for and `hass.writes[-1]` is what the device was
|
||||
# actually sent; neither is a `ClampResult` field. Grading the sentence against
|
||||
# `result.requested_w` would compare the swap against itself - DEV-02's defect,
|
||||
# and the same one that made the first cut of this card's own render probe emit
|
||||
# thirteen empty diffs.
|
||||
|
||||
|
||||
async def _sentence(max_w, dev, wanted):
|
||||
"""One real write over `max_w`, then the guard, on a real Controller."""
|
||||
hass = _Hass(0.0)
|
||||
c = Controller({"setpoint_entity": "number.sp", "meter_source": "off",
|
||||
"max_w": max_w},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -dev, dev
|
||||
c.target = wanted
|
||||
arm(c, wanted)
|
||||
await c.write_setpoint(wanted)
|
||||
await c.check_output_clamp()
|
||||
return hass, c
|
||||
|
||||
|
||||
hass_x, ctl_x = _sentence_run = asyncio.run(_sentence(2000.0, 5000.0, 9999.0))
|
||||
notes_x = clamp_notes(hass_x)
|
||||
_asked_x = ctl_x.target # what the loop asked for
|
||||
_sent_x = hass_x.writes[-1] # what the device was actually sent
|
||||
_rating_x = ctl_x.inverters[0].max_w # the bound the sentence names
|
||||
_rows_x = [e for e in ctl_x.events if REASON_CODE in e]
|
||||
print(f" max_w {_rating_x:.0f} on a +-5000 W device, asked for "
|
||||
f"{_asked_x:+.0f} W: the device saw {_sent_x:+.0f} W")
|
||||
print(f" action log: {_rows_x[0] if _rows_x else '(none)'}")
|
||||
check("PRECONDITION: the write really clamped on the MAX_W bound, and the two "
|
||||
"operands are DIFFERENT NUMBERS that RENDER differently - equal ones "
|
||||
"would make the transposed sentence identical and every check below "
|
||||
"vacuous. The asked-for magnitude is also strictly ABOVE the rating "
|
||||
"while the sent one is not, which is what makes the swapped sentence "
|
||||
"self-contradictory rather than merely wrong",
|
||||
ctl_x.clamp_last is not None and ctl_x.clamp_last.engaged
|
||||
and ctl_x.clamp_last.bound == "max_w"
|
||||
and _asked_x != _sent_x
|
||||
and f"{_asked_x:+.0f}" != f"{_sent_x:+.0f}"
|
||||
and abs(_sent_x) <= _rating_x < abs(_asked_x))
|
||||
check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
|
||||
"contiguous literal names the setpoint the loop COMPUTED first and the "
|
||||
"one that REACHED THE WIRE second. Under the transposition this row says "
|
||||
"the loop asked for +2000 W and the guard clamped it UP to +9999 W - "
|
||||
"past the 2000 W rating printed four words later - which is the alarm "
|
||||
"reading backwards on the one line an operator is woken by",
|
||||
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in e for e in _rows_x))
|
||||
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION REASON (main.py:2452), "
|
||||
"which is a SECOND surface off the same sentence and has to be pinned "
|
||||
"separately - SAFETY-38 round 3's whole lesson was that pinning one call "
|
||||
"site leaves the other shipping",
|
||||
bool(notes_x)
|
||||
and f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in notes_x[0]["message"])
|
||||
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
|
||||
"checks above discriminate rather than decorate",
|
||||
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" in e for e in _rows_x)
|
||||
and f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" not in notes_x[0]["message"])
|
||||
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
|
||||
# unit of coverage is the call site, and `bound` is the only thing that differs
|
||||
# between the two rows an operator can receive.
|
||||
_asked_w, _sent_w = ctl_w.target, hass_w.writes[-1]
|
||||
check("...and the same ordered pair holds on the DEVICE-bound row, where the "
|
||||
"loop asked for +3000 W and the device took +2500 W. PRECONDITION: those "
|
||||
"two also render differently",
|
||||
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
|
||||
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
|
||||
and f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in notes_w[0]["message"])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("7. SAFETY-28: the write path is gated on the allocation record")
|
||||
|
||||
@@ -17,9 +17,20 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import sys
|
||||
|
||||
from app.control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from app.control import (LOOP_TICK_S, PLANT_SETTLE_DOWN_S, PLANT_SETTLE_S,
|
||||
SETTLE_CYCLES, SLEW_RAIL_FRAC, Tuning, compute,
|
||||
slew_limit_w, unasked_power,
|
||||
maintenance_charge_floor, peak_at_risk)
|
||||
|
||||
T = Tuning()
|
||||
# ⚠️ SAFETY-39: `slew_w` IS EXPLICIT HERE NOW, AND IT IS release/1.0's VALUE.
|
||||
# `Tuning()`'s default became 0 - "follow the rail", `SLEW_RAIL_FRAC * max_w`,
|
||||
# 222 W against this 2000 W rail - so a bare `Tuning()` here would silently
|
||||
# re-tune every assertion below that the slew limit touches, and several of them
|
||||
# assert on a number the slew produces. The pair written here (max_w 2000,
|
||||
# slew_w 1000) is exactly what release/1.0 shipped, which is what most of this
|
||||
# file is a regression test against. The new default gets its own section at the
|
||||
# end, where it is the subject rather than a hidden parameter.
|
||||
T = Tuning(slew_w=1000.0)
|
||||
fails = []
|
||||
|
||||
|
||||
@@ -49,6 +60,41 @@ def check(name, cond):
|
||||
# A comment cannot go stale-green, because it never claims to be running.
|
||||
#
|
||||
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
|
||||
# ⚠️ THE SAFETY-13 ROWS WERE RE-MEASURED IN ROUND 2 AND THREE OF THEM WERE
|
||||
# WRONG AS FIRST WRITTEN. Every figure below marked (S13) was taken by deleting
|
||||
# the mechanism, purging __pycache__, running this file in the CI image and
|
||||
# counting `FAIL` lines, on THIS tree; the release/1.0 comparison figures were
|
||||
# taken the same way on a pristine c8dcb38 worktree.
|
||||
#
|
||||
# - `output freeze` was written as 11. The mutation form its own note
|
||||
# describes - the whole `target = min(...)/max(...)` line removed, `reason`
|
||||
# left alone - gives 6 on this tree AND 6 on the first SAFETY-13 commit,
|
||||
# against 2 on release/1.0. 11 is not reproducible by that form, or by
|
||||
# removing the line and its `reason` (4), or by swapping min/max (12). The
|
||||
# row is 6.
|
||||
# - `saturation detector, saturated_now = False` was left at 11 while the
|
||||
# SAFETY-13 section added checks that grade it. It is 15.
|
||||
# - the footnote's `frozen = False` figure was left at 10 for the same
|
||||
# reason. It is 13.
|
||||
#
|
||||
# The rows below `output freeze` are this ticket's own mechanisms: removing the
|
||||
# EXIT restores release/1.0's unbounded freeze; making `unasked_power()` return
|
||||
# True gives an exit to the limit case as well, which is the half of the
|
||||
# decision that has to stay unbounded; deriving SETTLE_CYCLES from
|
||||
# PLANT_SETTLE_S (18 s, the UP ramp) instead of PLANT_SETTLE_DOWN_S (23 s) is
|
||||
# the round-1 defect itself; and pinning `_SAT_COUNT_CAP` back at release/1.0's
|
||||
# literal 10 caps the counter below the bound and deletes the exit silently.
|
||||
#
|
||||
# ⚠️ ONLY THE ROWS THIS BRANCH MOVES WERE RE-MEASURED. Everything not marked
|
||||
# (S13) still carries 389d9ec's figures and was not re-run here.
|
||||
#
|
||||
# ⚠️ THE SLEW ROWS WERE RE-MEASURED AT SAFETY-39 AND ONE OF THEM MOVED: the
|
||||
# slew limit was 4 and is now 5, because the SAFETY-39 section grades it too.
|
||||
# The new row is the RAIL DEFAULT specifically - `slew_limit_w()` reduced to
|
||||
# `return abs(float(tuning.slew_w))`, which leaves the limit intact and only
|
||||
# removes the 0-means-the-rail resolution. Three checks notice, which is over
|
||||
# the invariant's floor of two. Both figures were reproduced by deleting the
|
||||
# mechanism, running this file and counting, on a tree with __pycache__ purged.
|
||||
#
|
||||
# mechanism in compute() checks that fail when deleted
|
||||
# ------------------------------------------ -----------------------------
|
||||
@@ -56,11 +102,16 @@ def check(name, cond):
|
||||
# integrator clamp (AC 1) 6
|
||||
# integrator bound follows max_w 4
|
||||
# output clamp 3
|
||||
# slew limit 4
|
||||
# output freeze 2
|
||||
# slew limit 5
|
||||
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
|
||||
# output freeze (S13) 6
|
||||
# the output freeze's EXIT (S13), freeze made unconditional again 4
|
||||
# `unasked_power()` discriminates, replaced by `return True` (S13) 6
|
||||
# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9
|
||||
# `_SAT_COUNT_CAP` pinned back at release/1.0's 10 (S13) 6
|
||||
# deadband 5
|
||||
# quantisation 2
|
||||
# saturation detector, `saturated_now = False` 11
|
||||
# saturation detector, `saturated_now = False` (S13) 15
|
||||
# saturation duration (AC 2), fires instantly 2
|
||||
# sat counter reset on a good cycle 6
|
||||
# target_grid_w bias 3
|
||||
@@ -68,7 +119,8 @@ def check(name, cond):
|
||||
#
|
||||
# The detector figure is for the `saturated_now = False` form specifically;
|
||||
# disabling it further down as `frozen = False` is a weaker mutation and gives
|
||||
# 10. Reproduce the same form or the number will not match.
|
||||
# 13 on this tree (10 on release/1.0). Reproduce the same form or the number
|
||||
# will not match.
|
||||
#
|
||||
# ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in
|
||||
# for the mechanism under test. Deleting the integrator freeze once failed
|
||||
@@ -264,8 +316,15 @@ check("frozen at i_w=0.0: an export push moves the integrator",
|
||||
# hundreds of watts means something else is driving the bus. Asserted so that
|
||||
# nobody "fixes" it by accident - the integrator moving is what this ticket
|
||||
# owns, the command rule belongs to the output freeze.
|
||||
#
|
||||
# ⚠️ SAFETY-13 KEPT THIS ASSERTION RATHER THAN INVERTING IT, and the fixture is
|
||||
# why it survived unchanged: `sat_count=3` becomes 4, which is below
|
||||
# SETTLE_CYCLES, so this is still inside the window in which the block holds.
|
||||
# SAFETY-13 gave the block an EXIT, not a different opinion about its first
|
||||
# cycles - see the SAFETY-13 section below, where the same state is driven past
|
||||
# the bound and the release is asserted on the other side of it.
|
||||
check("frozen at i_w=0.0: the output freeze still blocks a charge from 0 W",
|
||||
ze.target_w == 0.0)
|
||||
ze.target_w == 0.0 and ze.sat_count < SETTLE_CYCLES)
|
||||
# Where prev_w is already charging the output freeze does NOT block, and there
|
||||
# the difference reaches the wire: held at 0.0 the integrator abandons the
|
||||
# charge mid-export.
|
||||
@@ -328,11 +387,360 @@ def unwind(t):
|
||||
return prev
|
||||
|
||||
|
||||
tight, loose = unwind(Tuning(max_w=2000)), unwind(Tuning(max_w=2000, integrator_max_w=3000))
|
||||
# ⚠️ BOTH FIXTURES CARRY release/1.0's `slew_w` EXPLICITLY (SAFETY-39). The
|
||||
# subject here is `integrator_max_w`, and the figure the check names - 1000 W on
|
||||
# the first cycle after the drop - is `max_w - slew_w`, so it moves with the slew
|
||||
# limit and would stop being the number DOCS.md quotes. Pinning it keeps this a
|
||||
# test about integrator headroom instead of a test about two mechanisms at once;
|
||||
# the shipped default's own unwind is measured in the SAFETY-39 section below.
|
||||
tight = unwind(Tuning(max_w=2000, slew_w=1000.0))
|
||||
loose = unwind(Tuning(max_w=2000, slew_w=1000.0, integrator_max_w=3000))
|
||||
check(f"after saturation ends the command is {tight:.0f} W (<= 1000)", tight <= 1000)
|
||||
check(f"headroom above max_w makes that worse ({loose:.0f} W) - hence the default",
|
||||
loose > tight)
|
||||
|
||||
print("SAFETY-13: the output freeze has an exit, and only where it needs one")
|
||||
|
||||
# THE SHAPE OF THIS SECTION. release/1.0's output freeze holds the command's
|
||||
# magnitude down while the inverter is not following, and nothing ends it. At
|
||||
# `prev_w == 0` the held magnitude is ZERO, so it reads as "refuses to start a
|
||||
# charge"; that is the case the card was written about, but it is one instance
|
||||
# of a rule that is unbounded at every prev_w. SAFETY-13 bounds it exactly where
|
||||
# it CANNOT end on its own - see `unasked_power()` - and nowhere else.
|
||||
|
||||
# --- 1. the predicate, which is the whole of the decision -------------------
|
||||
# A limit can only make the inverter do LESS than the command, in the command's
|
||||
# own direction, so everything a limit can produce lies in the interval spanned
|
||||
# by 0 and prev_w widened by saturation_w. These are the four cases in the
|
||||
# docstring, run rather than described.
|
||||
check("limit: 700 W under a +2000 W command is explainable",
|
||||
not unasked_power(2000.0, 700.0, 500.0))
|
||||
check("limit: -700 W under a -5000 W command is explainable",
|
||||
not unasked_power(-5000.0, -700.0, 500.0))
|
||||
check("unasked: +700 W under a 0 W command is not",
|
||||
unasked_power(0.0, 700.0, 500.0))
|
||||
check("unasked: +700 W under a -800 W command is not",
|
||||
unasked_power(-800.0, 700.0, 500.0))
|
||||
# The band edges belong to the limit side, because saturation_w is the same
|
||||
# tolerance the detector uses - a reading the detector calls "following" must
|
||||
# never be called "another writer".
|
||||
check("the saturation band is inclusive on both edges",
|
||||
not unasked_power(0.0, 500.0, 500.0)
|
||||
and not unasked_power(0.0, -500.0, 500.0)
|
||||
and unasked_power(0.0, 500.01, 500.0))
|
||||
# ⚠️ AT prev_w == 0 THE PREDICATE AND THE DETECTOR ARE THE SAME TEST, which is
|
||||
# why the zero case is the one that strands: with nothing commanded there is no
|
||||
# interval for a limit to live in.
|
||||
same = all(unasked_power(0.0, a, 500.0) == (abs(a) > 500.0)
|
||||
for a in [x * 5.0 for x in range(-400, 401)])
|
||||
check("at prev_w == 0 unasked_power IS the saturation test, over 801 readings",
|
||||
same)
|
||||
|
||||
# --- 2. the bound is derived, and from WHICH HALF of the measurement --------
|
||||
# ⚠️ THE DIRECTION IS THE ASSERTION. goodwe/NOTES.md 5.10 measured the same
|
||||
# 1.78 kW step both ways: ~18 s up, ~23 s down. SAFETY-39 takes the SHORTER
|
||||
# one because its bound is a REFUSAL and the looser number refuses less. This
|
||||
# bound asks the opposite question - how long the plant may still be the
|
||||
# explanation for a divergence - and for that the conservative half is the
|
||||
# LONGER one. The first version of this file derived it from 18, got 9, and
|
||||
# that is three cycles short of the plant's own measured down-ramp, in the
|
||||
# direction that ACCUSES rather than refuses (section 8 below drives it).
|
||||
check(f"SETTLE_CYCLES is ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) ="
|
||||
f" {SETTLE_CYCLES}",
|
||||
SETTLE_CYCLES == 12
|
||||
and SETTLE_CYCLES * LOOP_TICK_S >= PLANT_SETTLE_DOWN_S
|
||||
and (SETTLE_CYCLES - 1) * LOOP_TICK_S < PLANT_SETTLE_DOWN_S)
|
||||
check("...from the LONGER of the two measured settle times, so it is strictly"
|
||||
" above what the shorter one would have given",
|
||||
PLANT_SETTLE_DOWN_S > PLANT_SETTLE_S
|
||||
and SETTLE_CYCLES > -(-PLANT_SETTLE_S // LOOP_TICK_S))
|
||||
# ...and it is NOT SLEW_RAIL_FRAC's reciprocal any more. It was, while both
|
||||
# were being derived from the same half of the pair, and that coincidence read
|
||||
# as a derivation. Asserted as an INEQUALITY so the two cannot silently be
|
||||
# re-fused into one number again.
|
||||
check("the slew's rail crossing and this bound are now different numbers",
|
||||
SETTLE_CYCLES > 1.0 / SLEW_RAIL_FRAC)
|
||||
# The counter has to be able to REACH the bound or the exit does not exist.
|
||||
# Driven, not read off the constant: run a saturated state and see how high the
|
||||
# counter actually goes.
|
||||
c, sc = 0, 0
|
||||
while c < 50:
|
||||
sc = compute(0.0, -2000.0, 900.0, T, sc).sat_count
|
||||
c += 1
|
||||
check(f"the saturation counter reaches {sc}, which is above the bound",
|
||||
sc >= SETTLE_CYCLES)
|
||||
|
||||
# --- 3. prev_w == 0: blocked below the bound, released above it -------------
|
||||
# The same state either side of the bound. This is the pair the card asks for.
|
||||
blk = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 2, i_w=0.0)
|
||||
check("prev_w=0: below the bound the charge is still refused",
|
||||
blk.target_w == 0.0 and blk.reason == "saturated-freeze"
|
||||
and blk.sat_count == SETTLE_CYCLES - 1)
|
||||
rel = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 1, i_w=0.0)
|
||||
check(f"prev_w=0: at the bound it is released to {rel.target_w:.0f} W",
|
||||
rel.target_w < 0.0 and rel.reason == "freeze-released"
|
||||
and rel.sat_count == SETTLE_CYCLES)
|
||||
# ...and the released command is the SLEW step from 0, not the rail. A release
|
||||
# that jumped to the integrator in one cycle would be the windup this ticket
|
||||
# was told to avoid.
|
||||
check(f"the released command is exactly one slew step"
|
||||
f" ({slew_limit_w(T):.0f} W)",
|
||||
abs(rel.target_w) == slew_limit_w(T))
|
||||
# The mirror. Nothing here is sign-specific, so the discharge side must behave
|
||||
# identically - every freeze rule in this file has a mirror, because the one
|
||||
# that did not is the defect that got through review.
|
||||
mblk = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 2, i_w=0.0)
|
||||
mrel = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 1, i_w=0.0)
|
||||
check("prev_w=0, discharging: same block, same release, mirrored",
|
||||
mblk.reason == "saturated-freeze" and mrel.reason == "freeze-released"
|
||||
and mrel.target_w == slew_limit_w(T))
|
||||
|
||||
# --- 4. non-zero prev_w, and the two halves are NOT the same --------------
|
||||
# (a) A divergence a LIMIT could have produced is never released, however long
|
||||
# it stands. This is release/1.0's behaviour and it is kept deliberately: the
|
||||
# freeze clears itself here, because the command is pinned at something the
|
||||
# inverter can do.
|
||||
TL = Tuning(max_w=5000.0, slew_w=400.0, target_grid_w=-10.0)
|
||||
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
|
||||
for _ in range(30):
|
||||
d = compute(prev, 4000.0, max(-1000.0, min(1000.0, prev)), TL, sat, i_w)
|
||||
prev, i_w, sat, _r = d.target_w, d.i_w, d.sat_count, reasons.add(d.reason)
|
||||
check("non-zero prev_w, a curtailed inverter: never released over 30 cycles",
|
||||
"freeze-released" not in reasons and prev == 2400.0)
|
||||
# (b) A divergence a limit could NOT have produced is released at the bound,
|
||||
# at a non-zero prev_w exactly as at zero. release/1.0 pinned this run at
|
||||
# -800 W, against its own integrator standing at -2388 W, for as long as it ran.
|
||||
prev, i_w, sat, rows = 0.0, 0.0, 0, []
|
||||
for _ in range(20):
|
||||
d = compute(prev, -2000.0, 700.0, TL, sat, i_w)
|
||||
rows.append((prev, d.target_w, d.i_w, d.reason))
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
pinned = [r for r in rows if r[3] == "saturated-freeze"]
|
||||
check(f"non-zero prev_w, another writer: pinned at {pinned[0][1]:.0f} W...",
|
||||
pinned and all(r[1] == -800.0 for r in pinned))
|
||||
check(f"...then released, settling at {prev:.0f} W", prev == -2390.0)
|
||||
|
||||
# --- 5. THE CARD'S OWN CASE, DRIVEN END TO END -----------------------------
|
||||
# The live install's shape (max_w 5000, slew_w 400, target_grid_w -10). The site
|
||||
# rests in the deadband with another writer holding the pack at +700 W - so the
|
||||
# saturation counter climbs while the command is 0 and nothing is wrong - and
|
||||
# then 2 kW of export arrives. On release/1.0 @ c8dcb38 the command is 0.0 W on
|
||||
# every cycle from there on: 0 W is a FIXED POINT, and the export is given away
|
||||
# for as long as the other writer stays.
|
||||
prev, i_w, sat, cmds = 0.0, 0.0, 0, []
|
||||
for c in range(24):
|
||||
d = compute(prev, -10.0 if c < 6 else -2000.0, 700.0, TL, sat, i_w)
|
||||
cmds.append(d.target_w)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
check("the card's case: the block does hold at first, at 0 W",
|
||||
cmds[6] == 0.0 and cmds[7] == 0.0)
|
||||
started = [i for i, c in enumerate(cmds) if c != 0.0]
|
||||
check(f"the card's case: the charge starts at cycle {started[0]}"
|
||||
f" instead of never", bool(started))
|
||||
# ⚠️ AND IT STOPS AT THE FROZEN INTEGRATOR, NOT AT THE RAIL. This is AC 4: the
|
||||
# integrator moved while the output was blocked, and the release must not turn
|
||||
# that divergence into windup. It cannot, because releasing the OUTPUT freeze
|
||||
# does not release the INTEGRATOR freeze - so the command can only walk, one
|
||||
# slew step per cycle, to a value the integrator stopped accumulating at.
|
||||
check(f"the card's case: it settles at {prev:.0f} W, at the frozen integrator "
|
||||
f"({i_w:.0f} W) and not the {TL.max_w:.0f} W rail",
|
||||
prev == -1190.0 and abs(prev) <= abs(i_w) and abs(prev) < TL.max_w)
|
||||
steps = [abs(cmds[i] - cmds[i - 1]) for i in range(1, len(cmds))]
|
||||
check(f"the card's case: no cycle moves more than the slew limit "
|
||||
f"(worst {max(steps):.0f} W of {slew_limit_w(TL):.0f})",
|
||||
max(steps) <= slew_limit_w(TL))
|
||||
|
||||
# --- 6. AC 4, AS A PROPERTY RATHER THAN A TRAJECTORY -----------------------
|
||||
# ⚠️ THE FIRST FORM OF THIS CHECK WAS WRONG AND THE SWEEP SAID SO, which is
|
||||
# worth leaving in the file. It asserted `|target| <= |i_w|` on a released
|
||||
# cycle, and the sweep produced prev_w -5000, i_w -3000, target -4600: the
|
||||
# command was walking BACK from -5000 toward the integrator and had not
|
||||
# arrived. It was moving the right way and the assertion still failed, because
|
||||
# "never exceeds the integrator" is not what no-windup means here.
|
||||
#
|
||||
# What it means: a released cycle can only move the command TOWARD the
|
||||
# integrator, by at most one slew step, and never past it. So the divergence
|
||||
# that built up while the output was blocked cannot arrive as a jump - it is
|
||||
# spent one slew step at a time, against an integrator that stopped
|
||||
# accumulating when the freeze engaged and is not released with the output.
|
||||
# ⚠️ AND THE COUNT BELOW IS THE RELEASED STATES, NOT THE SWEPT ONES. The first
|
||||
# version of this check named 41*25*21*5 - the size of the sweep - while
|
||||
# grading only the subset that actually releases, so the number in the message
|
||||
# was not the number the assertion was about. It is counted now.
|
||||
step = float(T.step_w)
|
||||
swept, past, jumped = 0, [], []
|
||||
n_released = 0
|
||||
for prev in [x * 250.0 for x in range(-20, 21)]:
|
||||
for grid in [x * 500.0 for x in range(-12, 13)]:
|
||||
for actual in [x * 500.0 for x in range(-10, 11)]:
|
||||
for i0 in (-3000.0, -500.0, 0.0, 500.0, 3000.0):
|
||||
swept += 1
|
||||
d = compute(prev, grid, actual, TL, SETTLE_CYCLES, i0)
|
||||
if d.reason != "freeze-released":
|
||||
continue
|
||||
n_released += 1
|
||||
lo, hi = min(prev, d.i_w), max(prev, d.i_w)
|
||||
if not (lo - step <= d.target_w <= hi + step):
|
||||
past.append((prev, grid, actual, i0, d.target_w, d.i_w))
|
||||
if abs(d.target_w - prev) > slew_limit_w(TL) + step:
|
||||
jumped.append((prev, grid, actual, i0, d.target_w))
|
||||
check(f"of {swept} states swept, the {n_released} that release keep the command"
|
||||
f" between the previous command and the frozen integrator"
|
||||
+ (f" (e.g. {past[0]})" if past else ""), not past and n_released > 0)
|
||||
check("...and no released cycle moves further than one slew step"
|
||||
+ (f" (e.g. {jumped[0]})" if jumped else ""), not jumped)
|
||||
|
||||
# --- 7. NO ACCEPTED CONFIGURATION CAN DELETE THE BLOCK ---------------------
|
||||
# ⚠️ THIS CHECK REPLACES ITS OWN OPPOSITE, AND THE REASON IS WORTH KEEPING.
|
||||
# While the bound was 9, `saturation_cycles` - which config.yaml accepts up to
|
||||
# 10 - could be set AT OR ABOVE it, and then the freeze did not engage until
|
||||
# its own exit was already due: the no-start block silently stopped existing,
|
||||
# and this file asserted that as a known consequence. Moving the bound to 12
|
||||
# put the whole accepted range strictly below it, so the degenerate
|
||||
# configuration is gone rather than documented.
|
||||
#
|
||||
# It is pinned against config.yaml ITSELF and not a transcription, because the
|
||||
# property is "two files agree" and a transcription is what lets them drift.
|
||||
# Regex, not PyYAML: the add-on image ships no PyYAML (test_validate.py says
|
||||
# the same thing at more length).
|
||||
import os # noqa: E402
|
||||
import re # noqa: E402
|
||||
|
||||
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||
with open(_CFG, encoding="utf-8") as _fh:
|
||||
_m = re.search(r"^ saturation_cycles: int\((\d+),(\d+)\)\s*$",
|
||||
_fh.read(), re.M)
|
||||
check("config.yaml's saturation_cycles range really was read", _m is not None)
|
||||
_sc_lo, _sc_hi = int(_m.group(1)), int(_m.group(2))
|
||||
check(f"every accepted saturation_cycles ({_sc_lo}..{_sc_hi}) is strictly"
|
||||
f" below the bound ({SETTLE_CYCLES}), so no accepted configuration can"
|
||||
f" push the freeze's engagement past its own exit",
|
||||
_sc_hi < SETTLE_CYCLES)
|
||||
# Driven at the schema's edge rather than argued: the block still engages, and
|
||||
# it engages before the exit is due.
|
||||
TN = Tuning(slew_w=1000.0, saturation_cycles=_sc_hi)
|
||||
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
|
||||
first_freeze = None
|
||||
for _ in range(20):
|
||||
d = compute(prev, -2000.0, 700.0, TN, sat, i_w)
|
||||
reasons.add(d.reason)
|
||||
if d.reason == "saturated-freeze" and first_freeze is None:
|
||||
first_freeze = d.sat_count
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
check(f"at the largest accepted saturation_cycles ({_sc_hi}) the block still"
|
||||
f" engages, at sat_count {first_freeze}, below the bound",
|
||||
"saturated-freeze" in reasons and first_freeze is not None
|
||||
and first_freeze < SETTLE_CYCLES)
|
||||
|
||||
# --- 8. THE BOUND'S DIRECTION, DRIVEN ON ONE INVERTER ----------------------
|
||||
# ⚠️ WHY THIS SECTION EXISTS. The exit's two conditions are read as "another
|
||||
# writer is on the actuator". They are not that, and the first shipped form of
|
||||
# this file said they were. `unasked_power()` compares a reading against ONE
|
||||
# cycle's command while the pack carries every command before it, so a command
|
||||
# that has fallen THROUGH ZERO - or simply fallen faster than the pack coasts -
|
||||
# leaves our own power outside our own interval. With the bound derived from
|
||||
# the 18 s UP measurement that is not a corner case: it is what a normal load
|
||||
# step does.
|
||||
#
|
||||
# The plant here is a MODEL and is labelled one. It is a first-order lag whose
|
||||
# two time constants come from the two measured settling times (a first-order
|
||||
# step is 95 % done at 3 tau, so tau = t / 3), with the ramp cap
|
||||
# sim/plantsim.py measured on inverter A. It is not the rig and it is not the
|
||||
# house; what it is for is that the two settling times ENTER the model in the
|
||||
# same direction they enter the constant, so the check moves if either does.
|
||||
#
|
||||
# ⚠️ AND THERE IS NO SECOND WRITER IN THIS SECTION. One `compute()`, one
|
||||
# plant, one command. Every `freeze-released` below is therefore false.
|
||||
import math # noqa: E402
|
||||
|
||||
|
||||
class _Plant:
|
||||
"""One inverter, obeying our command and nothing else. A MODEL."""
|
||||
|
||||
RAMP_W_S = 800.0 # sim/plantsim.py INVERTER_A, measured=True
|
||||
|
||||
def __init__(self, up_s, down_s):
|
||||
self.a_up = 1.0 - math.exp(-LOOP_TICK_S / (up_s / 3.0))
|
||||
self.a_dn = 1.0 - math.exp(-LOOP_TICK_S / (down_s / 3.0))
|
||||
self.w = 0.0
|
||||
|
||||
def tick(self, cmd):
|
||||
a = self.a_up if abs(cmd) > abs(self.w) else self.a_dn
|
||||
cap = self.RAMP_W_S * LOOP_TICK_S
|
||||
self.w += max(-cap, min(cap, a * (cmd - self.w)))
|
||||
|
||||
|
||||
from app import control as _control # noqa: E402
|
||||
|
||||
TS8 = Tuning(max_w=5000.0, slew_w=400.0, target_grid_w=-10.0,
|
||||
saturation_w=500.0, saturation_cycles=3)
|
||||
|
||||
|
||||
def _drive(bound, load1, load2, cycles=60, switch=25):
|
||||
"""Run one closed loop at an arbitrary bound. -> (release cycles, rows)."""
|
||||
was_b, was_c = _control.SETTLE_CYCLES, _control._SAT_COUNT_CAP
|
||||
# ⚠️ BOTH, OR THE PROBE LIES: `_SAT_COUNT_CAP` is what `compute()` caps the
|
||||
# counter with, so moving the bound without it caps the counter BELOW the
|
||||
# bound and the exit quietly becomes unreachable - which reads as "no false
|
||||
# positives". That is the hazard `_SAT_COUNT_CAP`'s own comment names.
|
||||
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = bound, bound + 1
|
||||
try:
|
||||
p = _Plant(PLANT_SETTLE_S, PLANT_SETTLE_DOWN_S)
|
||||
prev, i_w, sat, rows = 0.0, 0.0, 0, []
|
||||
for c in range(cycles):
|
||||
load = load1 if c < switch else load2
|
||||
d = compute(prev, load - p.w, p.w, TS8, sat, i_w)
|
||||
rows.append((c, prev, p.w, d.target_w, d.sat_count, d.reason))
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
p.tick(prev)
|
||||
return [r for r in rows if r[5] == "freeze-released"], rows
|
||||
finally:
|
||||
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = was_b, was_c
|
||||
|
||||
|
||||
_SHORT = int(-(-PLANT_SETTLE_S // LOOP_TICK_S)) # what 18 s would give: 9
|
||||
|
||||
# A 4 kW export falling to 2.5 kW. Nothing exotic; no second writer.
|
||||
_bad, _rows = _drive(_SHORT, -4000.0, -2500.0)
|
||||
check(f"a bound derived from the SHORTER measurement ({_SHORT} cycles) fires"
|
||||
f" the exit on ONE inverter with nothing else on the bus"
|
||||
+ (f" - {len(_bad)} cycle(s), first at cycle {_bad[0][0]}" if _bad else ""),
|
||||
bool(_bad))
|
||||
if _bad:
|
||||
_c, _prev, _act, _tgt, _sat, _ = _bad[0]
|
||||
check(f"...and what it called unasked is our own coast-down: command"
|
||||
f" {_prev:.0f} W, pack still at {_act:.1f} W, and the pack was"
|
||||
f" commanded {min(r[1] for r in _rows[:_c]):.0f} W a few cycles"
|
||||
f" earlier",
|
||||
unasked_power(_prev, _act, TS8.saturation_w)
|
||||
and abs(_act) > abs(_prev)
|
||||
and min(r[1] for r in _rows[:_c]) < _act)
|
||||
_good, _ = _drive(SETTLE_CYCLES, -4000.0, -2500.0)
|
||||
check(f"the shipped bound ({SETTLE_CYCLES} cycles, the LONGER measurement)"
|
||||
f" does not fire on the same run", not _good)
|
||||
# ...and not on any of these, either. The whole grid of load steps the same
|
||||
# plant can be given, at the tuning every headline figure in this ticket is
|
||||
# quoted from.
|
||||
_L = [x * 500.0 for x in range(-8, 9)]
|
||||
_fired = {}
|
||||
for _b in (_SHORT, SETTLE_CYCLES):
|
||||
_fired[_b] = sum(1 for _a in _L for _z in _L
|
||||
if _a != _z and _drive(_b, _a, _z)[0])
|
||||
_pairs = len(_L) * (len(_L) - 1)
|
||||
check(f"over {_pairs} load-step pairs on the measured plant, one inverter and"
|
||||
f" no second writer: the short bound fires in {_fired[_SHORT]} of them,"
|
||||
f" the shipped bound in {_fired[SETTLE_CYCLES]}",
|
||||
_fired[SETTLE_CYCLES] < _fired[_SHORT])
|
||||
# ⚠️ AND THE SHIPPED FIGURE IS NOT ZERO IN GENERAL. It is small here because
|
||||
# the plant in this model is the one that was measured. A plant slower than
|
||||
# that brings the false release back, and no constant derived from a
|
||||
# measurement can fix that - it is why DOCS.md tells the operator what
|
||||
# `freeze-released` is evidence OF, rather than what it proves.
|
||||
|
||||
print("SAFETY-04: the i_w=None path is still release/1.0, exactly")
|
||||
|
||||
|
||||
@@ -369,23 +777,113 @@ def legacy(prev, grid, actual, t, sat_count):
|
||||
# "i-clamped", because the truncation happens on the integrator before the
|
||||
# command is derived from it. Aliased here rather than papered over - if any
|
||||
# OTHER reason ever diverges, this check goes red.
|
||||
# ⚠️ SAFETY-13 IS A DELIBERATE DIVERGENCE FROM `legacy()`, AND THE SWEEP NOW
|
||||
# GRADES THE DIVERGENCE INSTEAD OF FORBIDDING IT. `legacy()` above stays a
|
||||
# transcription of release/1.0 - it is not edited to agree, for the reason the
|
||||
# note on `slew_w` gives: an oracle taught the new rule compares the new law
|
||||
# against itself. What changed is the assertion. Every case must either be
|
||||
# IDENTICAL to release/1.0, or be a case where release/1.0 said
|
||||
# "saturated-freeze" and this law says "freeze-released" with the two conditions
|
||||
# that permit it both true. Anything else is a regression, and the counts below
|
||||
# say how much of the sweep each answer covers.
|
||||
ALIAS = {"i-clamped": "clamped"}
|
||||
# ⚠️ SAFETY-13 ROUND 2 MOVED THE COUNTER'S CEILING, SO THE SWEEP NOW HAS A
|
||||
# THIRD ANSWER. `_SAT_COUNT_CAP` is `SETTLE_CYCLES + 1`, which was 10 - the
|
||||
# same literal release/1.0 used - and is now 13. Above release/1.0's own
|
||||
# ceiling the two counters therefore differ, and NOTHING ELSE MAY: `frozen` is
|
||||
# `sat_count >= saturation_cycles` and config.yaml accepts that at most 10, so
|
||||
# a counter reading 13 instead of 10 cannot change it, and no other line in
|
||||
# `compute()` reads the counter's value. That is asserted rather than asserted-
|
||||
# by-omission, in `cap_diffs` below.
|
||||
#
|
||||
# ⚠️ AND THE SWEEP HAS TO REACH THE BOUND OR IT GRADES NOTHING. `sc` runs to 12
|
||||
# now; at 9 the counter tops out one short of the exit and every release
|
||||
# silently disappears from this table, which is exactly what happened when the
|
||||
# bound moved and is why the count is asserted and not merely printed. 12 is a
|
||||
# state release/1.0 could never hold - its counter stopped at 10 - so
|
||||
# `legacy()` is being fed one input outside its own reachable domain, on
|
||||
# purpose, and `cap_diffs` is where that shows up.
|
||||
LEGACY_SAT_CAP = 10 # release/1.0's literal, transcribed in legacy()
|
||||
diffs = []
|
||||
released = []
|
||||
value_diffs = []
|
||||
cap_diffs = []
|
||||
seen = set()
|
||||
for tune in (Tuning(), Tuning(target_grid_w=-10.0), Tuning(max_w=5000, slew_w=5000)):
|
||||
# ⚠️ `slew_w` IS WRITTEN OUT IN ALL THREE (SAFETY-39). `legacy()` below is a
|
||||
# TRANSCRIPTION of release/1.0 and reads `t.slew_w` directly, as release/1.0
|
||||
# did; the new law reads `slew_limit_w(t)`, which treats 0 as 'follow the
|
||||
# rail'. The two agree for every non-zero value and differ at 0 BY DESIGN, so
|
||||
# a fixture that left the field at its new default would report the design as
|
||||
# a regression. Fixing it in `legacy()` instead would make this sweep compare
|
||||
# the new law against itself, which is the one thing it must never do.
|
||||
for tune in (Tuning(slew_w=1000.0), Tuning(slew_w=1000.0, target_grid_w=-10.0),
|
||||
Tuning(max_w=5000, slew_w=5000)):
|
||||
for prev in (-2000.0, -500.0, -100.0, 0.0, 100.0, 500.0, 2000.0):
|
||||
for grid in (-6000.0, -1000.0, -500.0, -14.0, 0.0, 14.0, 500.0, 1000.0, 6000.0):
|
||||
for actual in (-2000.0, 0.0, 600.0, 2000.0):
|
||||
for sc in (0, 2, 3, 9):
|
||||
for sc in (0, 2, 3, 9, 12):
|
||||
d = compute(prev, grid, actual, tune, sc) # i_w defaults to None
|
||||
seen.add(d.reason)
|
||||
got = (d.target_w, d.sat_count, d.frozen,
|
||||
ALIAS.get(d.reason, d.reason))
|
||||
if got != legacy(prev, grid, actual, tune, sc):
|
||||
diffs.append((prev, grid, actual, sc, got,
|
||||
legacy(prev, grid, actual, tune, sc)))
|
||||
check(f"i_w=None reproduces release/1.0 over {3*7*9*4*4} cases, reason included"
|
||||
+ (f" (first diff {diffs[0]})" if diffs else ""), not diffs)
|
||||
was = legacy(prev, grid, actual, tune, sc)
|
||||
if got == was:
|
||||
continue
|
||||
# everything that reaches the wire, without the counter
|
||||
wire_same = (got[0], got[2], got[3]) == (was[0], was[2], was[3])
|
||||
if wire_same:
|
||||
# the ceiling moved and nothing else did. Only legal
|
||||
# above release/1.0's own cap, and only with both
|
||||
# counters standing at their own ceilings.
|
||||
if (sc >= LEGACY_SAT_CAP
|
||||
and was[1] == LEGACY_SAT_CAP
|
||||
and got[1] == _control._SAT_COUNT_CAP):
|
||||
cap_diffs.append((prev, grid, actual, sc, got[1], was[1]))
|
||||
continue
|
||||
diffs.append((prev, grid, actual, sc, got, was))
|
||||
continue
|
||||
explained = (
|
||||
d.reason == "freeze-released"
|
||||
and was[3] == "saturated-freeze"
|
||||
and d.sat_count >= SETTLE_CYCLES
|
||||
and unasked_power(prev, actual, tune.saturation_w))
|
||||
if not explained:
|
||||
diffs.append((prev, grid, actual, sc, got, was))
|
||||
continue
|
||||
released.append((prev, grid, actual, sc))
|
||||
if got[0] != was[0]:
|
||||
value_diffs.append((prev, got[0], was[0]))
|
||||
TOTAL = 3 * 7 * 9 * 4 * 5
|
||||
check(f"i_w=None reproduces release/1.0 over {TOTAL} cases except where"
|
||||
f" SAFETY-13 releases the freeze, reason included"
|
||||
+ (f" (first unexplained diff {diffs[0]})" if diffs else ""), not diffs)
|
||||
# ⚠️ EXACT COUNTS, NOT INEQUALITIES. A weaker form of this line ("some
|
||||
# releases happened") was written while the bound was moving, and the coverage
|
||||
# audit caught it immediately: a `unasked_power()` that stops discriminating
|
||||
# went from 6 checks to 5. The three numbers partition the sweep, so any one of
|
||||
# them moving is a change somebody has to explain.
|
||||
check(f"{TOTAL - len(released) - len(cap_diffs)} of {TOTAL} cases are"
|
||||
f" bit-identical to release/1.0; {len(released)} are releases;"
|
||||
f" {len(cap_diffs)} differ in the saturation counter alone",
|
||||
len(released) == 432 and len(cap_diffs) == 81
|
||||
and TOTAL - len(released) - len(cap_diffs) == 3267)
|
||||
# ⚠️ THE COUNTER-ONLY DIFFERENCES ARE NOT A LOOPHOLE, so they are pinned twice:
|
||||
# they may only occur at an `sc` above release/1.0's ceiling, and the largest
|
||||
# `saturation_cycles` config.yaml accepts is below BOTH ceilings, so no
|
||||
# accepted configuration can see the difference at all.
|
||||
check(f"the {len(cap_diffs)} counter-only differences all sit above"
|
||||
f" release/1.0's cap of {LEGACY_SAT_CAP}, where no accepted"
|
||||
f" saturation_cycles can read them",
|
||||
all(r[3] >= LEGACY_SAT_CAP for r in cap_diffs) and _sc_hi <= LEGACY_SAT_CAP)
|
||||
# ⚠️ AND THE RELEASES ARE MOSTLY A LABEL. Only these change what reaches the
|
||||
# wire; the rest report "freeze-released" on a command the freeze was not
|
||||
# binding on anyway. Every one of them is a charge that release/1.0 refused to
|
||||
# start from exactly 0 W, which is the ticket, stated as a count.
|
||||
check(f"{len(value_diffs)} of those {len(released)} change the command,"
|
||||
f" and every one is a charge start from prev_w == 0 that was refused",
|
||||
len(value_diffs) == 27
|
||||
and all(p == 0.0 and new_ < 0.0 and old_ == 0.0
|
||||
for p, new_, old_ in value_diffs))
|
||||
|
||||
# ...and the rename is not a quiet deletion: the signal SAFETY-03 alarms on has
|
||||
# to actually occur in that sweep, or its hook is dead.
|
||||
@@ -443,6 +941,205 @@ check("worst billed rest point falls from 15 W to under 5 W",
|
||||
compute(500.0, 4.9, 500.0, TB).reason == "deadband"
|
||||
and compute(500.0, 5.0, 500.0, TB).reason != "deadband")
|
||||
|
||||
print("SAFETY-39: the slew limit is a RATIO to the rail, not a number")
|
||||
# ⚠️ WHAT WENT WRONG, IN ONE LINE, BECAUSE THE SHAPE IS THE POINT. `slew_w:
|
||||
# 1000` was derived on 2026-08-22 against a clamp of 1500 W and its stated
|
||||
# justification was that it caps "how far the loop can jump ahead of the
|
||||
# inverter". The live install then ran max_w 5000 with the same 1000, and on
|
||||
# 2026-09-05 11:43-11:46 a real 3.4 kW export drove the setpoint 380 -> 5000 W
|
||||
# in five 2 s ticks, the battery to -5154 W against a 5000 VA inverter, and the
|
||||
# grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken. The rail
|
||||
# moved and the constant did not.
|
||||
#
|
||||
# So what is graded below is the RELATIONSHIP and not a value: at every rail,
|
||||
# the loop takes the plant's own settling time to cross its whole output range.
|
||||
# A check that pinned "555 W" would go stale the same way the comment did.
|
||||
print(f" SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = {SLEW_RAIL_FRAC:.4f}"
|
||||
f" ({1 / SLEW_RAIL_FRAC:.0f} ticks to cross the rail)")
|
||||
check("0 means FOLLOW THE RAIL and resolves to SLEW_RAIL_FRAC * max_w",
|
||||
slew_limit_w(Tuning(max_w=5000.0, slew_w=0.0)) == SLEW_RAIL_FRAC * 5000.0)
|
||||
check("an explicit value is passed through untouched - it is the operator's "
|
||||
"number and nothing here silently reduces it",
|
||||
slew_limit_w(Tuning(max_w=5000.0, slew_w=400.0)) == 400.0)
|
||||
check("the shipped Tuning default is 0, i.e. the rail, so the law carries no "
|
||||
"second constant to go stale beside config.yaml",
|
||||
Tuning().slew_w == 0.0)
|
||||
|
||||
# ⭐ THE RELATIONSHIP ITSELF, over every rail this add-on can be built at: the
|
||||
# single-inverter schema range (100..5000), the live install (5000), and the
|
||||
# roster sums a second inverter produces - 9600 with inverter B's datasheet
|
||||
# figure, 10000 with two of A. ⚠️ 9600 IS NOT A MEASUREMENT: inverter B does not
|
||||
# exist on site yet (SPIKE-04, ~2026-09-12) and 4600 W is its datasheet and
|
||||
# serial, per goodwe/NOTES.md §1.1b. It is here because the ARITHMETIC is what
|
||||
# is being graded, and the arithmetic does not care.
|
||||
_rails = [100.0, 500.0, 1500.0, 2000.0, 5000.0, 9600.0, 10000.0]
|
||||
_ticks = {r: r / slew_limit_w(Tuning(max_w=r)) for r in _rails}
|
||||
print(" ticks to cross the rail at the default, by rail: "
|
||||
+ ", ".join(f"{r:.0f}W={t:.1f}" for r, t in _ticks.items()))
|
||||
check("⭐ AT EVERY RAIL THE DEFAULT TAKES THE SAME NUMBER OF TICKS TO CROSS IT - "
|
||||
"which is PLANT_SETTLE_S at the measured tick. This is the whole of "
|
||||
"SAFETY-39: raising max_w moves the slew limit with it, so the constant "
|
||||
"cannot be left behind by the rail again",
|
||||
all(abs(t - PLANT_SETTLE_S / LOOP_TICK_S) < 1e-9 for t in _ticks.values()))
|
||||
check("...and that is the same as saying the crossing takes the plant's own "
|
||||
f"measured settling time ({PLANT_SETTLE_S:.0f} s) at the "
|
||||
f"{LOOP_TICK_S:.0f} s tick the live install was measured running",
|
||||
all(abs(t * LOOP_TICK_S - PLANT_SETTLE_S) < 1e-9 for t in _ticks.values()))
|
||||
|
||||
# ⭐ VERIFIED BY BREAKING IT. The configuration that produced the 2026-09-05
|
||||
# excursion, put through the same arithmetic: it crosses the rail in 10 s
|
||||
# against a plant that takes 18 s. This is the number validate.py rule 10
|
||||
# refuses on, and it is asserted HERE too so that the law and the gate cannot
|
||||
# drift apart - the rule can only ever refuse what this ratio says is too fast.
|
||||
_broken = Tuning(max_w=5000.0, slew_w=1000.0)
|
||||
_broken_s = 5000.0 / slew_limit_w(_broken) * LOOP_TICK_S
|
||||
print(f" the 2026-09-05 configuration (max_w 5000, slew_w 1000) crosses its "
|
||||
f"rail in {_broken_s:.0f} s against a plant measured at {PLANT_SETTLE_S:.0f} s")
|
||||
check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by "
|
||||
"the ratio rather than by anybody's opinion - test_validate.py's rule 10 "
|
||||
"section refuses exactly this pair",
|
||||
_broken_s < PLANT_SETTLE_S
|
||||
and slew_limit_w(_broken) > SLEW_RAIL_FRAC * _broken.max_w)
|
||||
|
||||
# THE CLOSED LOOP, AND WHAT THIS PLANT IS AND IS NOT.
|
||||
#
|
||||
# ⚠️ THE PLANT BELOW IS MODELLED HERE AND IS NOT THE RIG. admin/goodwe's
|
||||
# `plantsim.Plant` is the battery model this project grades control changes
|
||||
# against, and this repository's CI cannot reach it - that repository is private
|
||||
# and this job's token is minted for this one (see .gitea/workflows/checks.yml).
|
||||
# So the rig evidence lives in `tools/slew_sim.py`, is quoted in the pull
|
||||
# request and in DECISIONS/SAFETY-39-slew-against-the-rail.md, and what runs
|
||||
# HERE is a first-order lag with the time constant that reproduces §5.10's
|
||||
# measured ~18 s to the deadband. test_alloc.py makes the same split for the
|
||||
# same reason and says so in the same words.
|
||||
#
|
||||
# ⚠️ AND IT CANNOT SHOW A BATTERY ABOVE ITS RATING, WHICH IS SAID BEFORE THE
|
||||
# RESULT RATHER THAN AFTER IT. Neither this model nor `plantsim.Plant` lets the
|
||||
# plant exceed `p_max`; both clamp. The -5154 W against a 5000 VA inverter that
|
||||
# the ticket reports is therefore NOT REPRODUCIBLE IN SIMULATION AT ALL, by
|
||||
# construction, and no acceptance criterion here is written on it. What IS
|
||||
# reproducible, and is what is graded, is the loop OVER-ABSORBING and driving
|
||||
# the meter from export into import - the error it then chases.
|
||||
_TAU_S = 6.0 # first-order lag; with the 2 s tick it reaches the
|
||||
# deadband ~18 s after a step, which is §5.10's figure
|
||||
|
||||
|
||||
def _step_run(tuning, export_w, load_w=350.0, phase=5, cycles=90,
|
||||
dt=LOOP_TICK_S):
|
||||
"""One export step onto a converged loop.
|
||||
|
||||
-> (worst import at the meter, peak battery beyond what the site NEEDED,
|
||||
largest setpoint change in one tick).
|
||||
"""
|
||||
import math
|
||||
prev, i_w, sat, actual, pv = 0.0, None, 0, 0.0, 0.0
|
||||
a = 1.0 - math.exp(-dt / _TAU_S)
|
||||
worst_import, peak, biggest, last = -1e9, 0.0, 0.0, None
|
||||
for c in range(cycles):
|
||||
if c == phase:
|
||||
pv = export_w + load_w
|
||||
grid = load_w - pv - actual
|
||||
d = compute(prev, grid, actual, tuning, sat, i_w)
|
||||
if c >= phase and last is not None:
|
||||
biggest = max(biggest, abs(d.target_w - last))
|
||||
last = d.target_w
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
# the plant: a first-order lag, clamped at the rating like the real one
|
||||
actual += (max(-tuning.max_w, min(tuning.max_w, prev)) - actual) * a
|
||||
if c >= phase:
|
||||
worst_import = max(worst_import, load_w - pv - actual)
|
||||
peak = max(peak, abs(actual))
|
||||
return worst_import, peak - export_w, biggest
|
||||
|
||||
|
||||
def _family(tuning):
|
||||
"""The worst case over 51 export steps, 1.0-4.2 kW, at three phases.
|
||||
|
||||
⚠️ A FAMILY AND NOT ONE STEP, AND THAT IS A FINDING RATHER THAN CAUTION.
|
||||
A single run of this loop is not evidence: the deadband, the 10 W
|
||||
quantisation and the phase of the step against the tick move the result by
|
||||
hundreds of watts, non-monotonically, so two adjacent slews can rank either
|
||||
way on one step and the opposite way on the next. Every number below is a
|
||||
worst case over the family, which is how admin/goodwe grades the same
|
||||
quantities (see `config.yaml`'s "1890 closed-loop runs" and "924 runs").
|
||||
"""
|
||||
worst = dict(imp=-1e9, over=-1e9, step=0.0, big=0, runs=0)
|
||||
for export_w in range(1000, 4300, 200):
|
||||
for phase in (5, 6, 7):
|
||||
imp, over, step = _step_run(tuning, float(export_w), phase=phase)
|
||||
worst["runs"] += 1
|
||||
worst["imp"] = max(worst["imp"], imp)
|
||||
worst["over"] = max(worst["over"], over)
|
||||
worst["step"] = max(worst["step"], step)
|
||||
worst["big"] += step >= 900.0
|
||||
return worst
|
||||
|
||||
|
||||
_LIVE = Tuning(max_w=5000.0, slew_w=1000.0) # what ran on 2026-09-05
|
||||
_RAIL = Tuning(max_w=5000.0) # slew_w 0 - follow the rail
|
||||
_INTERIM = Tuning(max_w=5000.0, slew_w=400.0) # applied live 2026-09-05 12:00
|
||||
_SLOWER = Tuning(max_w=5000.0, slew_w=250.0)
|
||||
_GAIN03 = Tuning(max_w=5000.0, gain=0.3)
|
||||
_GAIN02 = Tuning(max_w=5000.0, gain=0.2)
|
||||
_R = {k: _family(t) for k, t in
|
||||
(("live", _LIVE), ("rail", _RAIL), ("interim", _INTERIM),
|
||||
("slower", _SLOWER), ("gain03", _GAIN03), ("gain02", _GAIN02))}
|
||||
print(f" worst case over {_R['live']['runs']} export steps at max_w 5000, "
|
||||
f"{LOOP_TICK_S:.0f} s tick:")
|
||||
for _k, _lab in (("live", "gain 0.6 slew_w 1000 (the 2026-09-05 live pair)"),
|
||||
("rail", "gain 0.6 slew_w 0 (follow the rail)"),
|
||||
("interim", "gain 0.6 slew_w 400 (the live interim)"),
|
||||
("slower", "gain 0.6 slew_w 250"),
|
||||
("gain03", "gain 0.3 slew_w 0"),
|
||||
("gain02", "gain 0.2 slew_w 0")):
|
||||
_w = _R[_k]
|
||||
print(f" {_lab:<48} largest tick step {_w['step']:5.0f} W, "
|
||||
f"{_w['big']:2}/{_w['runs']} runs step >= 900 W, "
|
||||
f"over-absorbed {_w['over']:5.0f} W, meter driven to {_w['imp']:+6.0f} W")
|
||||
|
||||
# ⭐ THE DEFECT, REPRODUCED BEFORE ANYTHING IS CHANGED. The live pair steps the
|
||||
# setpoint by a full 1000 W on nearly every member of the family and drives the
|
||||
# meter from EXPORT into IMPORT - the loop manufacturing the error it then
|
||||
# chases. That is the 2026-09-05 observation and it is what makes this a control
|
||||
# fault rather than a slow loop.
|
||||
check("⭐ REPRODUCED: at max_w 5000 with slew_w 1000 the setpoint steps a full "
|
||||
"1000 W per tick on nearly every step in the family, and the meter is "
|
||||
"driven from export into import",
|
||||
_R["live"]["step"] >= 900.0
|
||||
and _R["live"]["big"] > 0.9 * _R["live"]["runs"]
|
||||
and _R["live"]["imp"] > 200.0)
|
||||
check("⭐ FIXED, ON THE THING THE SLEW ACTUALLY OWNS: following the rail removes "
|
||||
"the >= 900 W step from EVERY member of the family - which is the symptom "
|
||||
"that escalated 2 -> 1 -> 13 -> 43 per 30 min on the morning of the "
|
||||
"2026-09-05 excursion",
|
||||
_R["live"]["big"] > 0 and _R["rail"]["big"] == 0
|
||||
and _R["interim"]["big"] == 0)
|
||||
|
||||
# ⚠️ AND WHAT IT DOES NOT BUY, MEASURED, ASSERTED, AND SAID BEFORE ANYBODY CAN
|
||||
# READ MORE INTO THIS CARD THAN IT DELIVERED. `slew_w` bounds the OUTPUT; the
|
||||
# integrator accumulates `gain * error` per cycle and is bounded only by
|
||||
# `integrator_max_w`. At gain 0.6 into a 3.4 kW error the integrator moves
|
||||
# ~2000 W per tick and reaches a 5000 W rail in under three ticks WHATEVER
|
||||
# `slew_w` is. So the over-absorption is a GAIN term, and slowing the slew does
|
||||
# not reduce it - on this model it gets slightly WORSE, because the command
|
||||
# spends longer away from where it needs to be while the integrator keeps
|
||||
# winding. admin/goodwe's rig, a different plant model, agrees: over-absorption
|
||||
# 990 W at slew 1000 and 1086 W at slew 250, against 1010 W at gain 0.6 and
|
||||
# 863 W at gain 0.3 (tools/slew_sim.py).
|
||||
#
|
||||
# `gain` is therefore the next constant to re-derive. SAFETY-39 measured it and
|
||||
# CHANGED NOTHING: it is a live control-law constant on a running house, and two
|
||||
# of them moving in one release makes the next measurement uninterpretable.
|
||||
check("⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION, AND THIS CHECK IS HERE SO "
|
||||
"THAT NOBODY READS SAFETY-39 AS 'THE OVERSHOOT IS FIXED'. Quartering the "
|
||||
"slew from 1000 to 250 does not reduce the over-absorption at all",
|
||||
_R["slower"]["over"] >= _R["live"]["over"])
|
||||
check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at "
|
||||
"the same slew. That is the measurement behind 'gain is the next constant "
|
||||
"to re-derive', and it is a measurement rather than an opinion",
|
||||
_R["gain03"]["over"] < _R["rail"]["over"]
|
||||
and _R["gain02"]["over"] < _R["gain03"]["over"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -144,9 +144,17 @@ def check(name, cond):
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def tuning(max_w=LIVE_INSTALL_MAX_W):
|
||||
"""The shipped defaults from config.yaml, at the live install's max_w."""
|
||||
return Tuning(gain=0.6, max_w=max_w, slew_w=1000, deadband_w=15,
|
||||
def tuning(max_w=LIVE_INSTALL_MAX_W, slew_w=0):
|
||||
"""The shipped defaults from config.yaml, at the live install's max_w.
|
||||
|
||||
⚠️ `slew_w` IS 0 - "FOLLOW THE RAIL" - AND IS A PARAMETER (SAFETY-39). 0 is
|
||||
what config.yaml ships and resolves to `SLEW_RAIL_FRAC * max_w`, so it moves
|
||||
with `max_w` instead of being a number typed beside it. It has to be
|
||||
overridable because one case below picks an ABSURDLY SMALL rail on purpose,
|
||||
and at that rail a rail-following slew is 2 W per cycle - which would make
|
||||
the slew, rather than the thing under test, the reason nothing fires.
|
||||
"""
|
||||
return Tuning(gain=0.6, max_w=max_w, slew_w=slew_w, deadband_w=15,
|
||||
target_grid_w=-10, step_w=10, saturation_w=500,
|
||||
saturation_cycles=3)
|
||||
|
||||
@@ -327,10 +335,10 @@ check("the capture loaded", len(rows) > 1000)
|
||||
# covers both kWh registers, so a real `unchanged_s` on this data can only be
|
||||
# SHORTER than what is reconstructed here - this replay is the pessimistic
|
||||
# version of the converged case, not a flattering one.
|
||||
def replay(hold_s, max_w, cycle_floor_s=15.0):
|
||||
def replay(hold_s, max_w, cycle_floor_s=15.0, slew_w=0):
|
||||
"""Drive the real control law and the real detector over the capture."""
|
||||
wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s))
|
||||
t = tuning(max_w)
|
||||
t = tuning(max_w, slew_w)
|
||||
target, i_w, sat = 0.0, 0.0, 0
|
||||
prev_pwr, changed_at = None, rows[0]["t"]
|
||||
last_cycle = None
|
||||
@@ -456,7 +464,15 @@ check("...and the shipped law really does park the command there",
|
||||
# converged capture is silent" is satisfied by a detector that is wired to
|
||||
# nothing. Drop the hold to 1 s and the rail to nothing, and the SAME replay
|
||||
# must alarm - so the silence above is the data's doing, not the harness's.
|
||||
wd_hot, _, _, fired_hot = replay(1.0, 20.0)
|
||||
# ⚠️ AND THE SLEW IS PINNED FOR THIS CASE ONLY, WHICH IS THE OTHER HALF OF
|
||||
# MAKING BOTH TERMS REACHABLE (SAFETY-39). The rail here is 20 W, chosen absurdly
|
||||
# small so the rail term cannot be what holds the alarm off. At the shipped
|
||||
# `slew_w: 0` the limit follows that rail too - `SLEW_RAIL_FRAC * 20` is 2.2 W
|
||||
# per cycle - so the command could not reach 19 W inside the capture and the
|
||||
# replay would go silent for a reason that has nothing to do with the detector.
|
||||
# That is exactly the vacuous pass this case exists to rule out, arriving
|
||||
# through the case itself, so the slew is written out here and nowhere else.
|
||||
wd_hot, _, _, fired_hot = replay(1.0, 20.0, slew_w=20.0)
|
||||
check("...and the same replay DOES alarm once both terms are made reachable",
|
||||
fired_hot is not None and wd_hot.episodes > 0)
|
||||
|
||||
@@ -699,7 +715,7 @@ def _opts(**over):
|
||||
o = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": LIVE_INSTALL_MAX_W, "gain": 0.6,
|
||||
"slew_w": 1000, "deadband_w": SHIPPED_DEADBAND_W,
|
||||
"slew_w": 0, "deadband_w": SHIPPED_DEADBAND_W,
|
||||
"target_grid_w": -10, "step_w": 10, "heartbeat_s": 10,
|
||||
"stale_input_s": STALE_INPUT_S}
|
||||
o.update(over)
|
||||
|
||||
@@ -0,0 +1,879 @@
|
||||
"""Runnable check for SAFETY-37's freshness policy. `python3 test_freshness.py`
|
||||
|
||||
THE SUBJECT OF THIS FILE IS THE FIXTURE, NOT THE CODE. A pack legitimately
|
||||
resting at 40 % and a dead sensor reading 40 % ARE THE SAME NUMBER. A check that
|
||||
cannot separate "fresh and unchanged" from "stale" cannot see the failure it
|
||||
names, and this repository has recorded that defect - "a check whose DATA cannot
|
||||
reach the failure it names" - eleven times. So part 1 builds the separation and
|
||||
ASSERTS THE VALUE STREAMS ARE BIT-IDENTICAL across the pair; everything after it
|
||||
is downstream of that pair being real.
|
||||
|
||||
⚠️ AND THE CARD'S WORDING HAD TO BE SHARPENED TO BUILD IT, WHICH IS SAID HERE
|
||||
RATHER THAN QUIETLY WORKED AROUND. The card says "only arrival separates them".
|
||||
For a signal this add-on polls out of Home Assistant's state cache, ARRIVAL
|
||||
CANNOT SEPARATE THEM: `read_inputs()` runs about once a second and Home
|
||||
Assistant answers 200 with a number whether or not the publisher behind the
|
||||
entity is alive, so both members of the pair arrive, identically, forever.
|
||||
Part 1a demonstrates exactly that - an arrival-age gate is graded and FAILS to
|
||||
tell the pair apart - and it is the reason the shipped verdict is a conjunction
|
||||
with an OBLIGATION term rather than an age threshold. Every clock in that
|
||||
verdict is still an arrival clock: nothing reads a Home Assistant timestamp,
|
||||
which is the half of "arrival-stamped" that TEL-06 is blocked on and that this
|
||||
module does honour.
|
||||
|
||||
1. THE PAIR. Fresh-and-unchanged vs stale, on identical value streams, on
|
||||
the policy object itself. Plus 1a, the arrival-age gate that cannot.
|
||||
2. THE WIRING. The same pair driven through the REAL `Controller.read_inputs`
|
||||
at a roster of TWO, with the two units carrying DIFFERENT power values.
|
||||
3. THE CONSUMERS REFUSE, and the counterfactual is run beside them: the
|
||||
per-unit sign check and the circulating-power sensor, with the gate and
|
||||
with the gate removed, through real `cycle()` calls.
|
||||
4. THE LATCH, and the ~1 Hz oscillation it exists to prevent.
|
||||
5. N=1. Nothing changes on a healthy install - asserted on the shipped read
|
||||
path, not assumed - and the one thing that DOES change is named.
|
||||
6. THE BOUNDS, graded against the shipped `config.yaml` at both ends.
|
||||
7. THE POLICY TABLE tells the truth about what is wired.
|
||||
8. THE MEASUREMENT the bound rests on, replayed from the committed capture.
|
||||
9. KNOWN SURVIVORS, graded so they are residuals rather than surprises.
|
||||
|
||||
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12; goodwe/NOTES.md §1.1b).
|
||||
Every two-unit case below is two copies of inverter A's behaviour with
|
||||
DIFFERENT numbers passed in, and no number here is a measured parameter for a
|
||||
second device.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ. Read MUTATION.md
|
||||
before you report a survivor.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import glob
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app import freshness as F
|
||||
from app.circulation import circulating_w
|
||||
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness
|
||||
from app.inverter import Inverter
|
||||
from app.main import Controller
|
||||
from app.store import DEFAULTS
|
||||
from app.watchdog import SignWatchdog, SignWatchdogConfig, disagrees
|
||||
|
||||
fails = []
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
class MemStore:
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
def set_time(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Hass:
|
||||
"""A Home Assistant that answers per ENTITY and RECORDS what it served.
|
||||
|
||||
⚠️ PER ENTITY, AND THE RECORD IS LOAD-BEARING. A stub that answered one
|
||||
number whatever it was asked could not tell "B was read from B's sensor"
|
||||
from "B was handed A's reading" - and the served-value log is what lets
|
||||
part 1 assert that the fresh run and the stale run saw the SAME numbers.
|
||||
"""
|
||||
|
||||
def __init__(self, numbers=None, limits=None):
|
||||
self.numbers = dict(numbers or {})
|
||||
self.limits_by = dict(limits or {})
|
||||
self.served = [] # (entity, value) in call order
|
||||
self.writes = []
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = self.numbers.get(entity)
|
||||
self.served.append((entity, v))
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((entity, value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
A_RATING = 5000.0
|
||||
# ⚠️ ILLUSTRATIVE, AND NOT B'S DATASHEET FIGURES. SPIKE-04a established B as a
|
||||
# GW5048D-ES rated 4600 W with a 10 % floor (goodwe/NOTES.md §1.1b), and this
|
||||
# fixture deliberately does not use them: 5000/4600 is a 400 W gap, narrower
|
||||
# than a slew step, and two equal-ish numbers cannot show a check that read the
|
||||
# wrong unit's. Same reasoning as test_inverter.py's `B_RATING_HYPOTHESIS`.
|
||||
B_RATING_SIM = 3000.0
|
||||
A_FLOOR, B_FLOOR_SIM = 10, 15
|
||||
|
||||
ROSTER = [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.sp_a",
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
||||
"max_w": A_RATING, "bms_soc_floor": A_FLOOR},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
|
||||
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
|
||||
"max_w": B_RATING_SIM, "bms_soc_floor": B_FLOOR_SIM},
|
||||
]
|
||||
|
||||
# ⚠️ A AND B CARRY DIFFERENT POWER VALUES IN EVERY FIXTURE BELOW, and the card
|
||||
# names this as the trap it expects: "if both read the same, the test cannot
|
||||
# distinguish a frozen reading from a live one and will pass against the broken
|
||||
# code". SAFETY-30's fix round walked into exactly that with two watchdog holds
|
||||
# both set to 1.
|
||||
A_FROZEN_W = -1200.0 # inverter A's register, stuck on a charge figure
|
||||
B_LIVE_W = 900.0 # inverter B's register, moving, and NOT A's number
|
||||
|
||||
BASE = dict(meter_source="off", meter_entity="sensor.grid", setpoint_entity="",
|
||||
soc_entity="", batt_entity="", max_w=A_RATING, gain=0.6, slew_w=0,
|
||||
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
|
||||
stale_input_s=15, inverters=ROSTER)
|
||||
|
||||
|
||||
def site(opts=None, numbers=None):
|
||||
"""A real two-inverter Controller off the shipped `build_roster()`."""
|
||||
hass = Hass(dict({"sensor.grid": 0.0,
|
||||
"sensor.soc_a": 50.0, "sensor.batt_a": A_FROZEN_W,
|
||||
"sensor.soc_b": 55.0, "sensor.batt_b": B_LIVE_W},
|
||||
**(numbers or {})),
|
||||
{"number.sp_a": (-A_RATING, A_RATING, 1.0),
|
||||
"number.sp_b": (-B_RATING_SIM, B_RATING_SIM, 1.0)})
|
||||
c = Controller(dict(BASE, **(opts or {})), hass, MemStore(), Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
|
||||
c.inverters[1].dev_min, c.inverters[1].dev_max = -B_RATING_SIM, B_RATING_SIM
|
||||
return c, hass
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("1. THE PAIR: 'fresh and unchanged' vs 'stale', on IDENTICAL value streams")
|
||||
|
||||
# The two runs below feed the policy object the SAME forty readings of the same
|
||||
# number at the same instants. The ONLY difference is what the controller was
|
||||
# asking the inverter to do while those readings arrived.
|
||||
#
|
||||
# FRESH the site is converged. The command sits where it is, the register
|
||||
# answers the same watt, and nothing is wrong. This is the case the
|
||||
# 30-day capture in part 8 measures at up to 263.2 s.
|
||||
# STALE the site reversed. The command travelled 2500 W and the register did
|
||||
# not move by one watt in 40 s, which is 2.4x the fastest it could
|
||||
# possibly be obliged to answer in.
|
||||
STREAM = [(float(t), A_FROZEN_W) for t in range(0, 40)]
|
||||
|
||||
|
||||
def drive(bound, stream, standing_of):
|
||||
"""Feed a Freshness one arrival per (t, value). Returns (obj, served, verdicts)."""
|
||||
f = Freshness(bound)
|
||||
served, verdicts = [], []
|
||||
for t, v in stream:
|
||||
f.submit(v, standing_of(t), t)
|
||||
served.append(v)
|
||||
verdicts.append(f.reading())
|
||||
return f, served, verdicts
|
||||
|
||||
|
||||
_fresh, _fresh_served, _fresh_read = drive(
|
||||
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W)
|
||||
# The command travels from -1200 W to +1300 W at t=1 and stays there: a site
|
||||
# turnaround, which is the ordinary stimulus SAFETY-31's own campaign sweeps.
|
||||
_stale, _stale_served, _stale_read = drive(
|
||||
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W if t < 1 else 1300.0)
|
||||
|
||||
print(f" served (both runs): {_fresh_served[:3]} ... x{len(_fresh_served)}")
|
||||
# ⚠️ `key=str`, AND IT IS NOT COSMETIC. Without it this line raises TypeError
|
||||
# comparing None with a float the moment a mutation makes the FRESH run stale -
|
||||
# which is what M2 (dropping the command-travel term) did: the file died on a
|
||||
# DIAGNOSTIC PRINT before check one, so the harness reported "red" with no named
|
||||
# check behind it. MUTATION.md's reporting rule is "which suite went red, and on
|
||||
# WHICH NAMED CHECK"; a print that can crash first destroys exactly that.
|
||||
print(f" fresh verdicts: unique={sorted(set(_fresh_read), key=str)}")
|
||||
print(f" stale verdicts: first None at t="
|
||||
f"{next((i for i, v in enumerate(_stale_read) if v is None), None)}")
|
||||
|
||||
check("⚠️ THE FIXTURE'S OWN PRECONDITION: the two runs saw a BIT-IDENTICAL "
|
||||
"value stream. If this ever stops being true the pair below is comparing "
|
||||
"two different experiments and proves nothing about freshness",
|
||||
_fresh_served == _stale_served
|
||||
and len({v for v in _fresh_served}) == 1)
|
||||
check("FRESH AND UNCHANGED is not stale. Forty identical readings over 39 s "
|
||||
"with the command standing still - a converged site - and the reading "
|
||||
"reaches its consumers untouched",
|
||||
not _fresh.stale and _fresh_read == [A_FROZEN_W] * len(STREAM))
|
||||
check("⚠️ STALE IS. The same forty readings, the same instants, the same "
|
||||
"numbers - and the command moved 2500 W underneath them. The reading "
|
||||
"becomes None to its consumers, which is the whole card",
|
||||
_stale.stale and _stale_read[-1] is None)
|
||||
check("...and it becomes None at the BOUND, not before: 35.0 s of unchanged "
|
||||
"arrivals, so the last fresh verdict is at t=34 and the first None at "
|
||||
"t=35",
|
||||
_stale_read[34] == A_FROZEN_W and _stale_read[35] is None
|
||||
and BATT_UNCHANGED_S == 35.0)
|
||||
check("...and NEVER a held number and never a zero - `reading()` has exactly "
|
||||
"two shapes, the arrived value or None",
|
||||
set(type(v) for v in _stale_read) == {float, type(None)}
|
||||
and 0.0 not in [v for v in _stale_read if v is not None])
|
||||
check("...and the reason names the three terms an operator has to check, with "
|
||||
"the numbers that fired it",
|
||||
"STALE_READING" in _stale.reason and "2500 W" in _stale.reason
|
||||
and "unchanged for 35 s" in _stale.reason)
|
||||
|
||||
# --- 1a. the gate the card asked for first, graded and found unable ----------
|
||||
# An arrival-age gate is what "arrival-stamped" reads as on first hearing. It is
|
||||
# built here and run against the SAME pair, because a criterion that cannot be
|
||||
# satisfied is worth more said out loud than approximated in silence.
|
||||
_arrival_ages = []
|
||||
_af = Freshness(BOUNDS["batt"])
|
||||
for t, v in STREAM:
|
||||
_af.submit(v, A_FROZEN_W, t)
|
||||
# Every read produced a number, so on both members of the pair the age
|
||||
# since the last ARRIVAL is 0.0 at every instant. There is nothing to
|
||||
# threshold.
|
||||
_arrival_ages.append(0.0 if _af.last is not None else None)
|
||||
check("⚠️ AN ARRIVAL-AGE GATE CANNOT TELL THE PAIR APART, and that is why the "
|
||||
"shipped verdict is not one. Home Assistant answered every read with a "
|
||||
"number in BOTH runs, so the age since arrival is 0.0 s at every instant "
|
||||
"of both - a threshold on it is a threshold that can never fire, which "
|
||||
"is TEL-06's defect rebuilt in this repository",
|
||||
set(_arrival_ages) == {0.0} and _af.arrivals == len(STREAM))
|
||||
check("...while the arrival record IS what the shipped verdict is measured on: "
|
||||
"`unchanged_s` is the gap between arrivals that carried different "
|
||||
"numbers, on this process's own monotonic clock, and no Home Assistant "
|
||||
"timestamp is read anywhere in the module",
|
||||
_stale.unchanged_s(39.0) == 39.0
|
||||
and "last_updated" not in io.open(HERE / "app" / "freshness.py",
|
||||
encoding="utf-8").read().split(
|
||||
'"""', 2)[2])
|
||||
check("...and a read that produced NOTHING is still an immediate None, exactly "
|
||||
"as it is today - a failed read is not an arrival and is not an age",
|
||||
Freshness(BOUNDS["batt"]).reading() is None
|
||||
and (lambda f: (f.submit(5.0, 0.0, 0.0), f.submit(None, 0.0, 1.0),
|
||||
f.reading() is None and f.held == 5.0)[-1])(
|
||||
Freshness(BOUNDS["batt"])))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("2. THE WIRING: the same pair through the REAL read path, at a roster of 2")
|
||||
|
||||
|
||||
async def _reads(c, hass, plan):
|
||||
"""Drive real `read_inputs()` calls. `plan` is (t, standing_a, batt_a)."""
|
||||
out = []
|
||||
for t, standing_a, batt_a in plan:
|
||||
hass.numbers["sensor.batt_a"] = batt_a
|
||||
# `last_write` is what the write path sets and what `standing_w()`
|
||||
# reads; setting it here is standing in for a write, not for the gate.
|
||||
c.inverters[0].last_write = standing_a
|
||||
await c.read_inputs(now_mono=t)
|
||||
out.append((t, c.inverters[0].batt, c.inverters[1].batt))
|
||||
return out
|
||||
|
||||
|
||||
_PLAN_FRESH = [(float(t), A_FROZEN_W, A_FROZEN_W) for t in range(0, 40)]
|
||||
_PLAN_STALE = [(float(t), A_FROZEN_W if t < 1 else 1300.0, A_FROZEN_W)
|
||||
for t in range(0, 40)]
|
||||
|
||||
_cf, _hf = site()
|
||||
_of = run(_reads(_cf, _hf, _PLAN_FRESH))
|
||||
_cs, _hs = site()
|
||||
_os = run(_reads(_cs, _hs, _PLAN_STALE))
|
||||
|
||||
print(f" fresh run: A={sorted({r[1] for r in _of})} B={sorted({r[2] for r in _of})}")
|
||||
print(f" stale run: A={sorted({r[1] for r in _os}, key=str)} "
|
||||
f"B={sorted({r[2] for r in _os})}")
|
||||
|
||||
check("PRECONDITION: both runs were served the same per-entity numbers, so the "
|
||||
"difference below is the wiring and not the stimulus",
|
||||
[x for x in _hf.served if x[0] == "sensor.batt_a"]
|
||||
== [x for x in _hs.served if x[0] == "sensor.batt_a"])
|
||||
check("⚠️ THE TWO UNITS CARRY DIFFERENT POWER VALUES - A at -1200 W, B at "
|
||||
"+900 W. A fixture whose units read the same number cannot show a gate "
|
||||
"that answered for the wrong unit, which is the trap the card names",
|
||||
A_FROZEN_W != B_LIVE_W and all(r[2] == B_LIVE_W for r in _of))
|
||||
check("THROUGH THE REAL PATH: a converged site keeps its reading",
|
||||
all(r[1] == A_FROZEN_W for r in _of))
|
||||
check("⚠️ THROUGH THE REAL PATH: a frozen register becomes `Inverter.batt is "
|
||||
"None`. Not a held number, not a zero - the same None a missing entity "
|
||||
"produces, which is the state §11.1 rule 1 already answers with 0 W",
|
||||
_os[34][1] == A_FROZEN_W and _os[35][1] is None
|
||||
and all(r[1] is None for r in _os[35:]))
|
||||
check("...and ONLY the frozen unit's. B's own reading is untouched, which is "
|
||||
"the per-unit half: a freshness verdict about A is not evidence about B",
|
||||
all(r[2] == B_LIVE_W for r in _os) and _cs.inverters[1].batt == B_LIVE_W
|
||||
and not _cs.inverters[1].batt_fresh.stale)
|
||||
check("...and `inputs_ok` goes False, so the site stops rather than regulating "
|
||||
"against a number nobody measured",
|
||||
_cs.inputs_ok is False and _cf.inputs_ok is True)
|
||||
check("...and the operator gets a line saying which unit and why, on the same "
|
||||
"status list the liveness notes use",
|
||||
any("STALE_READING" in n and "inverter-a" in n
|
||||
for n in _cs.liveness_notes())
|
||||
and _cf.liveness_notes() == [])
|
||||
check("...and the raw number is still recorded for diagnostics, so the "
|
||||
"operator can see WHAT it was stuck on",
|
||||
_cs.inverters[0].batt_fresh.last == A_FROZEN_W)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("3. THE CONSUMERS REFUSE - and the counterfactual runs beside them")
|
||||
|
||||
# Both consumers already do the right thing with a None. Neither could ever
|
||||
# receive one before this card, which is the entire exposure: the gate is the
|
||||
# thing that was missing BETWEEN the shipped read and the shipped check.
|
||||
_cfg3 = SignWatchdogConfig(min_w=250.0, hold_s=85.0)
|
||||
check("PRECONDITION on the shipped predicate: a None measurement does not "
|
||||
"disagree with anything, so it CLEARS the sign window",
|
||||
disagrees(1300.0, None, True, _cfg3) is False
|
||||
and disagrees(1300.0, A_FROZEN_W, True, _cfg3) is True)
|
||||
check("PRECONDITION on the shipped sensor: a roster with a missing term is "
|
||||
"None, and None clears the circulation window rather than accumulating",
|
||||
circulating_w([1300.0, None]) is None
|
||||
and circulating_w([A_FROZEN_W, B_LIVE_W]) == 2 * min(
|
||||
abs(A_FROZEN_W), abs(B_LIVE_W)))
|
||||
|
||||
|
||||
async def _cycles(gated, ticks=25, step_mono=12.0, wall=0.20):
|
||||
"""Real cycles at N=2 with A's register frozen and the LOOP driving.
|
||||
|
||||
⚠️ THE COMMAND TRAVEL IS PRODUCED BY THE SHIPPED CONTROL LAW, NOT BY THE
|
||||
FIXTURE. The meter is pinned at +3000 W of import, so `compute()` ramps the
|
||||
site into discharge at its own slew rate through the real allocator and the
|
||||
real write path; nothing here assigns `last_write`. A fixture that set the
|
||||
standing command by hand would be feeding the gate its own arming condition,
|
||||
which is the shape of check this repository keeps finding.
|
||||
|
||||
`gated=False` is THE COUNTERFACTUAL - the shipped behaviour before this
|
||||
card, rebuilt by handing the consumers the raw held number the gate
|
||||
withheld. Same cycles, same stimulus, so the difference between the two
|
||||
columns is the gate and nothing else.
|
||||
|
||||
⚠️ TWO CLOCKS, DELIBERATELY, AND THE WALL-CLOCK ONE IS THE FLAKE RISK.
|
||||
The freshness verdict runs on the INJECTED monotonic clock (12 s a tick, so
|
||||
the 35 s bound is crossed on tick 3, deterministically, however loaded the
|
||||
machine is). The sign watchdog runs on the WALL clock inside `cycle()`,
|
||||
which cannot be injected - so `unit_sign_disagree_s` is set to 2 s and the
|
||||
ticks are 0.20 s apart, putting the gate's close at ~0.6 s against a 2 s
|
||||
window. That ORDER is the field's - 35 s < 75 s < 85 s by construction, part
|
||||
6 - and the gate is given no head start beyond it.
|
||||
|
||||
⚠️ AND THE MARGIN IS MEASURED AND ASSERTED RATHER THAN ASSUMED (CI-06).
|
||||
`test_peakguard.py` answered two of its own checks with the wall clock, flaked
|
||||
at ~20 %, and cost a wrong merge diagnosis - a flaky check does not waste a
|
||||
re-run, it manufactures evidence for whichever hypothesis the reader already
|
||||
holds. So this returns the wall clock at which the gate closed, and the check
|
||||
below fails with THAT NUMBER if the box was too slow, rather than reporting a
|
||||
trip that looks like a code defect.
|
||||
"""
|
||||
c, hass = site({"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 2,
|
||||
"sign_disagree_w": 250, "sign_disagree_s": 2,
|
||||
"site_sign_disagree_w": 250, "site_sign_disagree_s": 2,
|
||||
"auto_start": True},
|
||||
{"sensor.grid": 3000.0})
|
||||
c.auto = True
|
||||
circ, tripped_at, standing = [], None, []
|
||||
t_start, t_gate = time.monotonic(), None
|
||||
for k in range(ticks):
|
||||
# A's register is FROZEN on the charge figure it stopped at; B's is
|
||||
# live and is a DIFFERENT number. Neither is touched by the loop.
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W
|
||||
hass.numbers["sensor.batt_b"] = B_LIVE_W
|
||||
await c.read_inputs(now_mono=k * step_mono)
|
||||
if not gated:
|
||||
for inv in c.inverters:
|
||||
inv.batt = inv.batt_fresh.last
|
||||
if t_gate is None and c.inverters[0].batt_fresh.stale:
|
||||
t_gate = time.monotonic() - t_start
|
||||
circ.append(c.site_circulating_w)
|
||||
standing.append(c.inverters[0].standing_w())
|
||||
await c.cycle()
|
||||
if c.inverters[0].sign_wd.tripped and tripped_at is None:
|
||||
tripped_at = k
|
||||
await asyncio.sleep(wall)
|
||||
return c, circ, tripped_at, standing, t_gate
|
||||
|
||||
|
||||
_cg, _circg, _tripg, _standg, _tgate = run(_cycles(True))
|
||||
_cu, _circu, _tripu, _standu, _ = run(_cycles(False))
|
||||
_WD_S = 2.0 # `unit_sign_disagree_s` as this fixture sets it, above
|
||||
print(f" standing on A (gated): {[round(v) for v in _standg]}")
|
||||
print(f" gated : sign trip tick={_tripg} circ={_circg[:6]}...")
|
||||
print(f" gate closed after {_tgate:.2f} s of WALL clock, against the "
|
||||
f"{_WD_S:.0f} s watchdog window ({_WD_S / _tgate:.1f}x margin)")
|
||||
print(f" counterfact: sign trip tick={_tripu} circ={_circu[:6]}...")
|
||||
|
||||
check("⚠️ PRECONDITION ON THE WALL CLOCK, AND IT IS A CHECK RATHER THAN AN "
|
||||
"ASSUMPTION (CI-06). The gate closes on the INJECTED clock, the watchdog "
|
||||
"on the real one, so this fixture only means something while the gate "
|
||||
f"closes well inside the window: it closed at {_tgate:.2f} s against "
|
||||
f"{_WD_S:.0f} s. If this fails the machine was slow, not the code - and "
|
||||
"saying so here is what stops that being read as a defect",
|
||||
_tgate is not None and _tgate < 0.5 * _WD_S)
|
||||
check("PRECONDITION: the SHIPPED control law really did travel the command - "
|
||||
"the gate's arming term is produced by `compute()` and the allocator, "
|
||||
"not written into the fixture",
|
||||
max(abs(v) for v in _standu) > MOVE_W
|
||||
and abs(_standu[3] - _standu[0]) > MOVE_W)
|
||||
|
||||
check("⚠️ COUNTERFACTUAL FIRST - the stimulus is REAL. Without the gate, the "
|
||||
"frozen register false-trips SAFETY-30's per-unit sign check and parks a "
|
||||
"healthy inverter. That is the fault this card was filed for",
|
||||
_tripu is not None and _cu.inverters[0].sign_wd.tripped
|
||||
and "SIGN_DISAGREEMENT" in _cu.inverters[0].sign_wd.reason)
|
||||
check("⚠️ AND WITH THE GATE IT DOES NOT TRIP. The reading is withheld before "
|
||||
"the watchdog's window can accumulate, so the check refuses to conclude "
|
||||
"rather than concluding wrongly - which is what a stale reading deserves",
|
||||
_tripg is None and not _cg.inverters[0].sign_wd.tripped)
|
||||
check("...and inverter B was not parked either way - the verdict is per unit",
|
||||
not _cg.inverters[1].sign_wd.tripped
|
||||
and not _cu.inverters[1].sign_wd.tripped)
|
||||
check("⚠️ COUNTERFACTUAL: without the gate the circulating-power sensor "
|
||||
"reports a loop that is not happening, computed from one frozen term and "
|
||||
"one live one",
|
||||
all(v == 2 * min(abs(A_FROZEN_W), abs(B_LIVE_W)) for v in _circu)
|
||||
and _circu[0] == 1800.0)
|
||||
check("⚠️ AND WITH THE GATE IT REPORTS `None` - an unknown, which CLEARS the "
|
||||
"circulation window instead of accumulating it. Both consumers gated, "
|
||||
"from one place, by one mechanism",
|
||||
_circg[0] == 1800.0 and _circg[-1] is None
|
||||
and all(v is None for v in _circg[_circg.index(None):]))
|
||||
check("...and the circulation watchdog did not latch on the stale evidence",
|
||||
not _cg.circ_wd.tripped and _cg.circ_wd.over_since is None)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("4. THE LATCH, and the ~1 Hz oscillation it exists to prevent")
|
||||
|
||||
# Declaring the reading stale makes inputs_ok False, which commands 0 W, which
|
||||
# drops the standing command below MOVE_W, which un-arms the conjunction. Without
|
||||
# a latch the held number would come straight back to the consumers, and the
|
||||
# whole thing would flip once per read for as long as the register stayed dead.
|
||||
_lat = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_lat.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
_was = _lat.stale
|
||||
_after_zero = []
|
||||
for t in range(40, 50):
|
||||
_lat.submit(A_FROZEN_W, 0.0, float(t)) # the site has been stopped
|
||||
_after_zero.append(_lat.reading())
|
||||
check("PRECONDITION: it is stale before the site is stopped",
|
||||
_was and _lat.reading() is None)
|
||||
check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W. The conjunction is no "
|
||||
"longer armed - |standing| is 0 W, below the 250 W term - so an unlatched "
|
||||
"verdict would hand the held -1200 W straight back and the site would "
|
||||
"restart into it, then stop, then restart, once per read",
|
||||
all(v is None for v in _after_zero))
|
||||
# ⚠️ AND A FAILED READ MUST NOT CLEAR IT EITHER. This check exists because the
|
||||
# mutation that added `self.stale = False` to `submit()`'s None branch SURVIVED
|
||||
# the whole sweep - no fixture had ever injected a read failure while latched.
|
||||
# It matters: a stale verdict stops the site, the site is then commanded 0 W, and
|
||||
# a single unreachable-Home-Assistant read would drop the latch into a state
|
||||
# where the conjunction is no longer armed - so the held -1200 W would come back
|
||||
# to the consumers on the very next successful read.
|
||||
_lat_fail = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_lat_fail.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
_lat_fail.submit(None, 0.0, 40.0) # Home Assistant did not answer
|
||||
_after_fail = _lat_fail.reading()
|
||||
_lat_fail.submit(A_FROZEN_W, 0.0, 41.0) # it answers again, same number,
|
||||
# site now resting at 0 W
|
||||
check("⚠️ A FAILED READ DOES NOT CLEAR THE LATCH - found by a SURVIVING "
|
||||
"mutation, not by design. Our inability to fetch a value is not evidence "
|
||||
"that the value moved, and a stale verdict that a single unreachable read "
|
||||
"can drop is one a stopped site would drop every time",
|
||||
_after_fail is None and _lat_fail.stale
|
||||
and _lat_fail.reading() is None)
|
||||
|
||||
check("⚠️ AND ONLY MOVEMENT CLEARS IT - a different number arriving, which is "
|
||||
"exactly the evidence the verdict was about. Not a timer, not a restart, "
|
||||
"not the operator's start button",
|
||||
(lambda f: (f.submit(A_FROZEN_W + 1.0, 0.0, 60.0),
|
||||
(not f.stale) and f.reading() == A_FROZEN_W + 1.0)[-1])(_lat))
|
||||
# ⚠️ THE MAGNITUDE TERM OF THE CONJUNCTION, AND THIS CHECK EXISTS BECAUSE A
|
||||
# MUTATION SURVIVED WITHOUT IT. Deleting `abs(standing_w) < move_w` from
|
||||
# `_grade()` left every other check in this file green, because in all of them a
|
||||
# command that has TRAVELLED 250 W has also ENDED UP above 250 W. The case that
|
||||
# separates them is a site being brought to REST.
|
||||
_rest = Freshness(BOUNDS["batt"])
|
||||
# The register reaches +5 W on the same read at which the old +1300 W command is
|
||||
# still standing - the write path is serialised, so the register can catch up
|
||||
# BEFORE the command settles - and then the site is commanded to 0 W and the
|
||||
# register sits at +5 W, correctly, for a minute.
|
||||
_rest.submit(1290.0, 1300.0, 0.0)
|
||||
_rest.submit(5.0, 1300.0, 1.0) # standing_at_change captures 1300 W
|
||||
for t in range(2, 60):
|
||||
_rest.submit(5.0, 0.0, float(t)) # site at rest; register truthfully +5 W
|
||||
check("⚠️ THE MAGNITUDE TERM IS LOAD-BEARING, and it took a surviving mutation "
|
||||
"to show it. A register that reached +5 W while the old +1300 W command "
|
||||
"was still standing has a `standing_at_change` of 1300 W, so the TRAVEL "
|
||||
"term alone reads 1300 W of unfollowed command for the whole minute the "
|
||||
"site rests - and calls a perfectly truthful reading dead. Requiring "
|
||||
"|standing| >= 250 W as well is what says 'a site at rest is not owed "
|
||||
"movement'",
|
||||
not _rest.stale and _rest.reading() == 5.0)
|
||||
check("...and the hole that term leaves CANNOT MISLEAD EITHER CONSUMER, which "
|
||||
"is why it is the right trade: a unit commanded under 250 W is one "
|
||||
"`watchdog.disagrees()` is False for whatever the measurement says, and a "
|
||||
"frozen term beside a live unit that is itself resting nets to zero "
|
||||
"circulating power rather than to a false alarm",
|
||||
disagrees(0.0, A_FROZEN_W, True, _cfg3) is False
|
||||
and circulating_w([A_FROZEN_W, 0.0]) == 0.0)
|
||||
|
||||
check("...and one watt is enough, because 0x0518 is a 1 W register that "
|
||||
"dithers +/-1 W while holding (goodwe/NOTES.md §5.11, measured "
|
||||
"2026-08-22) - a working register clears this on its next 10 s publish",
|
||||
_lat.held == A_FROZEN_W + 1.0 and not _lat.stale)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("5. N=1: nothing changes on a healthy install, and what DOES is named")
|
||||
|
||||
_ONE = dict(BASE, inverters=[], setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
||||
bms_soc_floor=A_FLOOR)
|
||||
|
||||
|
||||
def one_site():
|
||||
hass = Hass({"sensor.grid": 0.0, "sensor.soc_a": 50.0,
|
||||
"sensor.batt_a": A_FROZEN_W},
|
||||
{"number.sp_a": (-A_RATING, A_RATING, 1.0)})
|
||||
c = Controller(dict(_ONE), hass, MemStore(), Pub(), stop=asyncio.Event())
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
|
||||
return c, hass
|
||||
|
||||
|
||||
async def _one_healthy():
|
||||
c, hass = one_site()
|
||||
seen = []
|
||||
for t in range(0, 60):
|
||||
# A healthy register under a moving command: it dithers, so it is never
|
||||
# the same integer watt twice running.
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W + (t % 3)
|
||||
c.inverters[0].last_write = 1300.0 if t % 2 else -1200.0
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
seen.append(c.inverters[0].batt)
|
||||
return c, seen
|
||||
|
||||
|
||||
_c1, _seen1 = run(_one_healthy())
|
||||
check("⚠️ AC: AT N=1 NOTHING CHANGES ON A HEALTHY INSTALL, and it is asserted "
|
||||
"rather than assumed. Sixty reads of a dithering register under a command "
|
||||
"reversing every second: every value reaches the consumers unmodified, "
|
||||
"the gate never arms, and the status page says nothing new",
|
||||
_seen1 == [A_FROZEN_W + (t % 3) for t in range(60)]
|
||||
and not _c1.inverters[0].batt_fresh.stale
|
||||
and _c1.liveness_notes() == [] and _c1.inputs_ok is True)
|
||||
check("...and the shipped roster really is one unit, so this is the install "
|
||||
"that is running in a house today and not a fixture shaped like it",
|
||||
len(_c1.inverters) == 1 and _c1.inverters[0].name == "inverter-a")
|
||||
|
||||
|
||||
async def _one_frozen():
|
||||
c, hass = one_site()
|
||||
for t in range(0, 40):
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W
|
||||
c.inverters[0].last_write = A_FROZEN_W if t < 1 else 1300.0
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
return c
|
||||
|
||||
|
||||
_c1f = run(_one_frozen())
|
||||
check("⚠️ AND WHAT DOES CHANGE AT N=1 IS NAMED RATHER THAN HIDDEN: a FROZEN "
|
||||
"register now stops the site, where before it read as a healthy "
|
||||
"measurement. That is a behaviour change on the shipped roster and it is "
|
||||
"the point of the card - HW-01 was a one-inverter site, and the pack "
|
||||
"drained to its floor with every detector reading healthy for 6 h 25 m",
|
||||
_c1f.inverters[0].batt is None and _c1f.inputs_ok is False)
|
||||
check("...and the direction it fails in is the safe one: a false verdict "
|
||||
"commands 0 W and keeps commanding (§11.1 rule 1), behind the existing "
|
||||
"`stale_input_s` hold, and clears itself on the register's next move",
|
||||
float(_c1f.o.get("stale_input_s")) > 0
|
||||
and _c1f.inverters[0].batt_fresh.stale)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("6. THE BOUNDS, graded against the SHIPPED config.yaml at BOTH ends")
|
||||
|
||||
|
||||
def option_default(key):
|
||||
"""`key`'s default out of config.yaml's `options:` block, at two spaces."""
|
||||
body = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
body = body[body.index("\noptions:"):body.index("\nschema:")]
|
||||
for line in body.splitlines():
|
||||
if line.startswith(f" {key}:") and not line[2:3].isspace():
|
||||
return float(line.split(":", 1)[1].strip())
|
||||
return None
|
||||
|
||||
|
||||
_circ_hold = option_default("circulation_hold_s")
|
||||
_unit_hold = option_default("unit_sign_disagree_s")
|
||||
_unit_min_w = option_default("unit_sign_disagree_w")
|
||||
_ceiling = min(_circ_hold, _unit_hold)
|
||||
print(f" floor {BATT_OBLIGED_S:.1f} s = settle 6.0 + publish 10.0 + read 1.0")
|
||||
print(f" bound {BATT_UNCHANGED_S:.1f} s")
|
||||
print(f" ceil {_ceiling:.1f} s = min(circulation_hold_s {_circ_hold:.0f}, "
|
||||
f"unit_sign_disagree_s {_unit_hold:.0f})")
|
||||
|
||||
check("PRECONDITION: both consumer windows were actually found in config.yaml, "
|
||||
"so a rename cannot turn this part into a check of two Nones",
|
||||
_circ_hold == 75.0 and _unit_hold == 85.0 and _unit_min_w == 250.0)
|
||||
check("⚠️ THE BOUND IS AT LEAST 2x THE PHYSICAL FLOOR. Below 17.0 s a healthy "
|
||||
"register has not yet been OBLIGED to answer - 6.0 s of plant "
|
||||
"(NOTES §5.9), one 10 s ESPHome publish (firmware/goodwe-master.yaml:181) "
|
||||
"and one 1 s add-on read - so a shorter bound trips on a working inverter",
|
||||
BATT_UNCHANGED_S >= 2 * BATT_OBLIGED_S and BATT_OBLIGED_S == 17.0)
|
||||
check("⚠️ AND AT MOST HALF THE TIGHTER CONSUMER WINDOW. A gate slower than its "
|
||||
"consumer hands over the frozen number and explains afterwards. THIS IS "
|
||||
"THE CHECK THAT BITES IF AN OPERATOR'S DEFAULT MOVES: drop "
|
||||
"`circulation_hold_s` to 60 in config.yaml and this goes red",
|
||||
BATT_UNCHANGED_S <= 0.5 * _ceiling)
|
||||
# ⚠️ THE FIRST DRAFT OF THIS CHECK CLAIMED THE BAND CONTAINED EXACTLY ONE
|
||||
# INTEGER AND IT DOES NOT - it contains 34, 35, 36 and 37, and the check went
|
||||
# red on its first run and is written the honest way round here. The band is
|
||||
# what is derived; the value inside it is a choice, and the only defensible
|
||||
# claims about the choice are that it IS inside and that it is not a number
|
||||
# borrowed from somewhere else.
|
||||
check("...and the admissible band computed from config.yaml is [34.0, 37.5], "
|
||||
"which contains four integers. 35 is a CHOICE inside a DERIVED band, not "
|
||||
"a derived number, and it is deliberately not 30 - which is "
|
||||
"FIRMWARE_INGEST_VETO_S and would read as a borrowed constant - or 60, "
|
||||
"which is round",
|
||||
[n for n in range(0, 200)
|
||||
if 2 * BATT_OBLIGED_S <= n <= 0.5 * _ceiling] == [34, 35, 36, 37]
|
||||
and BATT_UNCHANGED_S not in (30.0, 60.0))
|
||||
check("⚠️ AND `MOVE_W` IS PINNED TO config.yaml TOO. It is the largest value "
|
||||
"that still arms the gate everywhere the per-unit sign check can speak; "
|
||||
"raise `unit_sign_disagree_w` and the gate would stop arming in states "
|
||||
"that check still fires in, which this catches",
|
||||
MOVE_W == _unit_min_w)
|
||||
check("...and the module's three floor terms are the numbers this repository "
|
||||
"actually carries, not a transcription of them: the ESPHome publish "
|
||||
"interval is read back out of the shipped firmware",
|
||||
"update_interval: 10s" in io.open(
|
||||
HERE.parent / "firmware" / "goodwe-master.yaml",
|
||||
encoding="utf-8").read()
|
||||
and F.PUBLISH_PERIOD_S == 10.0 and F.READ_PERIOD_S == 1.0
|
||||
and F.PLANT_SETTLE_S == 6.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("7. THE POLICY TABLE tells the truth about what is WIRED")
|
||||
|
||||
def bounds_users(key):
|
||||
"""Modules that SUBSCRIPT `BOUNDS[key]` in CODE - parsed, not grepped.
|
||||
|
||||
⚠️ THE FIRST DRAFT GREPPED FOR THE STRING AND ANSWERED WRONG, because both
|
||||
`inverter.py` and `main.py` NAME `BOUNDS["soc"]` in a comment explaining why
|
||||
it is not wired. A text search cannot tell a mention from a use, and this
|
||||
part's whole job is to tell them apart - so it walks the AST instead.
|
||||
"""
|
||||
out = []
|
||||
for path in sorted((HERE / "app").glob("*.py")):
|
||||
if path.name == "freshness.py":
|
||||
continue
|
||||
tree = ast.parse(io.open(path, encoding="utf-8").read())
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Subscript)
|
||||
and isinstance(node.slice, ast.Constant)
|
||||
and node.slice.value == key
|
||||
and ((isinstance(node.value, ast.Name)
|
||||
and node.value.id == "BOUNDS")
|
||||
or (isinstance(node.value, ast.Attribute)
|
||||
and node.value.attr == "BOUNDS"))):
|
||||
out.append(path.name)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
_uses_batt = bounds_users("batt")
|
||||
_uses_soc = bounds_users("soc")
|
||||
for _k, _b in BOUNDS.items():
|
||||
print(f" {_k:5} wired={_b.wired!s:5} unchanged={_b.unchanged_s:>6} "
|
||||
f"move={_b.move_w:>6} {_b.signal}")
|
||||
print(f" BOUNDS['batt'] subscripted in CODE by: {_uses_batt}")
|
||||
print(f" BOUNDS['soc'] subscripted in CODE by: {_uses_soc or 'nothing'}")
|
||||
|
||||
check("⚠️ `wired=True` MEANS SOMETHING CONSUMES IT. A policy table nobody "
|
||||
"checks is a comment; this is the row `batt` claims and the module that "
|
||||
"claims it",
|
||||
BOUNDS["batt"].wired is True and _uses_batt == ["inverter.py"])
|
||||
check("⚠️ AND `wired=False` MEANS NOTHING DOES. SoC is TEL-08's and it is "
|
||||
"STATED here rather than wired, because its obligation term is energy "
|
||||
"through the pack and the constant that needs has never been measured on "
|
||||
"this site. A guessed constant would fire on a pack resting at its floor",
|
||||
BOUNDS["soc"].wired is False and _uses_soc == [])
|
||||
check("...and the SoC row says what TEL-08 owes, in the row itself, so the "
|
||||
"next reader does not have to reconstruct why it is unwired",
|
||||
"energy" in BOUNDS["soc"].derivation
|
||||
and "153.6 Wh" in BOUNDS["soc"].derivation
|
||||
and "TEL-08" in BOUNDS["soc"].derivation)
|
||||
check("...and every wired bound is a real number - an infinite bound is how a "
|
||||
"row silently becomes decorative",
|
||||
all(b.unchanged_s < float("inf") and b.move_w < float("inf")
|
||||
for b in BOUNDS.values() if b.wired))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("8. THE MEASUREMENT the bound rests on, replayed from the committed data")
|
||||
|
||||
# ⚠️ THIS IS THE PART THAT STOPS THE DERIVATION BECOMING A STORY. The docstring
|
||||
# in freshness.py quotes three numbers off this capture; they are recomputed
|
||||
# here from the committed files, so a figure that drifts from its evidence goes
|
||||
# red instead of being re-quoted forever.
|
||||
#
|
||||
# ⚠️ AND IT IS THE METER'S REGISTER, NOT 0x0518. No 30-day capture of the
|
||||
# inverter's own power exists. What this establishes is the only thing it is
|
||||
# used for: that the legitimate unchanged duration of a 1 W power entity in this
|
||||
# house is MINUTES, so an unchanged threshold alone has no admissible value.
|
||||
_cap = sorted(glob.glob(str(HERE / "scenarios" / "p1-net-30d-2026-08"
|
||||
/ "p1-net-*.jsonl.gz")))
|
||||
_runs, _prev = [], None
|
||||
_t0 = time.monotonic()
|
||||
for _f in _cap:
|
||||
with gzip.open(_f, "rt") as _fh:
|
||||
for _line in _fh:
|
||||
_r = json.loads(_line)
|
||||
_t = datetime.fromisoformat(_r["t"]).timestamp()
|
||||
if _prev is not None:
|
||||
_runs.append((_t - _prev, _pw))
|
||||
_prev, _pw = _t, float(_r["w"])
|
||||
_worst = max(_runs)[0] if _runs else 0.0
|
||||
_over_bound = sum(1 for d, _ in _runs if d >= BATT_UNCHANGED_S)
|
||||
print(f" {len(_cap)} days, {len(_runs) + 1} samples, "
|
||||
f"{time.monotonic() - _t0:.1f}s to replay")
|
||||
print(f" longest unchanged run {_worst:.1f} s; "
|
||||
f"{_over_bound} runs >= {BATT_UNCHANGED_S:.0f} s")
|
||||
|
||||
check("PRECONDITION: the committed capture is all thirty days, so this is not "
|
||||
"a claim about whatever files happened to be present",
|
||||
len(_cap) == 30 and len(_runs) + 1 == 485841)
|
||||
check("⚠️ A REAL 1 W POWER ENTITY HELD ONE VALUE FOR 263.2 s WITH NOTHING "
|
||||
"WRONG. That single measurement is why the bound is a conjunction and "
|
||||
"not a threshold: no unchanged threshold under 263 s is free of false "
|
||||
"positives, and 263 s is 3.5x too slow to protect a 75 s consumer",
|
||||
abs(_worst - 263.2) < 0.1 and _worst > _ceiling)
|
||||
check("...and the two figures freshness.py's docstring quotes off this capture "
|
||||
"are the ones it produces today",
|
||||
_over_bound == 10
|
||||
and sum(1 for d, _ in _runs if d >= BATT_OBLIGED_S) == 386)
|
||||
check("⚠️ AND THE LONG RUNS ARE ALL UNDER AN UNMOVING LOAD, which is what the "
|
||||
"conjunction keys on. The worst one is 263 s of a steady -899 W export - "
|
||||
"a house doing one thing - so a gate that also requires the COMMAND to "
|
||||
"have travelled 250 W is not armed by any of them",
|
||||
max(_runs)[1] == -899.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("9. KNOWN SURVIVORS - graded, so they are residuals and not surprises")
|
||||
|
||||
# ⚠️ THE REPO'S RECURRING DEFECT IS A CHECK WHOSE DATA CANNOT REACH THE FAILURE
|
||||
# IT NAMES. The honest end of that habit is to build the mutations this file
|
||||
# would NOT see and record them. Each one below is a real hole in the shipped
|
||||
# gate, demonstrated rather than described.
|
||||
|
||||
# S1. A register pinned by the INVERTER's own limit, not by a fault. If the
|
||||
# device is derating and cannot follow a command that ASKS FOR MORE, the
|
||||
# register can legitimately hold while the command travels.
|
||||
_s1 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_s1.submit(2503.0, 2650.0 if t < 1 else 4600.0, float(t))
|
||||
check("⚠️ SURVIVOR 1, FALSE POSITIVE: a saturated or derating inverter pinned "
|
||||
"at 2503 W while the command climbs to 4600 W is declared stale. The "
|
||||
"register is telling the truth and the gate calls it dead",
|
||||
_s1.stale)
|
||||
check("...and its SIZE is bounded and its direction is the safe one: the "
|
||||
"verdict is a site stop, behind the `stale_input_s` hold, and the moment "
|
||||
"the command comes back down the register moves and the latch clears. "
|
||||
"Cost = one spurious stop; it cannot drive the battery anywhere",
|
||||
(lambda f: (f.submit(2400.0, 0.0, 60.0), not f.stale)[-1])(_s1))
|
||||
|
||||
# S2. A unit whose command never travels 250 W. HW-01's own shape, at a
|
||||
# smaller scale, and the gate is deliberately not armed for it.
|
||||
_s2 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 400):
|
||||
_s2.submit(A_FROZEN_W, -1200.0 if t < 1 else -1400.0, float(t))
|
||||
check("⚠️ SURVIVOR 2, MISS: a register frozen under a command that only ever "
|
||||
"moves 200 W is NEVER detected, for 400 s or forever. This is scoped, "
|
||||
"not overlooked - such a unit is one no per-unit sign check can speak "
|
||||
"about (`unit_sign_disagree_w` is 250 W) and one contributing at most "
|
||||
"250 W to a loop whose reporting floor is 700 W",
|
||||
not _s2.stale and _s2.reading() == A_FROZEN_W)
|
||||
|
||||
# S3. A register that dithers on dead data. A publisher that keeps republishing
|
||||
# a stale WINDOW of values rather than one value defeats this entirely.
|
||||
_s3 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 400):
|
||||
_s3.submit(A_FROZEN_W + (t % 2), 1300.0, float(t))
|
||||
check("⚠️ SURVIVOR 3, MISS, AND IT IS THE LARGEST ONE: a dead publisher whose "
|
||||
"last value happens to ALTERNATE - or a cache replaying two old samples - "
|
||||
"never triggers the unchanged clock at all. This gate detects a value "
|
||||
"that STOPPED; it cannot detect a value that is WRONG. Nothing in this "
|
||||
"add-on can, and TEL-06's arrival stamp inside the firmware is the only "
|
||||
"layer that could",
|
||||
not _s3.stale and _s3.reading() is not None)
|
||||
|
||||
# S4. The gate is per unit, so a whole-site publisher failure that freezes both
|
||||
# registers is caught twice rather than missed - recorded because a reader
|
||||
# might expect the opposite.
|
||||
_s4a, _s4b = Freshness(BOUNDS["batt"]), Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_s4a.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
_s4b.submit(B_LIVE_W, B_LIVE_W if t < 1 else -1300.0, float(t))
|
||||
check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
|
||||
"that freezes BOTH registers is caught on both units independently, on "
|
||||
"each unit's own command travel and each unit's own number",
|
||||
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -215,8 +215,17 @@ B_BMS_FLOOR_SIM = 15
|
||||
|
||||
A_RATING = 5000.0
|
||||
|
||||
# ⚠️ `slew_w` IS 0 HERE, AND IT USED TO BE 1000 - SAFETY-39, AND THE WAY THIS
|
||||
# FIXTURE FOUND OUT IS THE ARGUMENT FOR THE RULE. This dict pairs `slew_w: 1000`
|
||||
# with `max_w: 5000` because both were typed when the flat 5000 W install was
|
||||
# new, and that pair is EXACTLY the configuration measured on the owner's system
|
||||
# on 2026-09-05 driving the meter from 3.4 kW of export to 2.8 kW of import. The
|
||||
# fixture was not wrong when it was written and nothing in this file noticed it
|
||||
# had become so; validate.py rule 10 refused it the day it existed, which is
|
||||
# what a rule about a relationship is for. 0 means "follow the rail"
|
||||
# (`control.SLEW_RAIL_FRAC * max_w`) and is what config.yaml ships.
|
||||
BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid",
|
||||
max_w=A_RATING, gain=0.6, slew_w=1000, deadband_w=15,
|
||||
max_w=A_RATING, gain=0.6, slew_w=0, deadband_w=15,
|
||||
target_grid_w=0, step_w=10, heartbeat_s=10, stale_input_s=15)
|
||||
|
||||
|
||||
@@ -1902,8 +1911,18 @@ _AUDITED = {
|
||||
("test_maintenance.py", "shipped_default"),
|
||||
("test_maintenance.py", "schema_range"),
|
||||
("test_freeze.py", "_cfg_default"),
|
||||
# SAFETY-37. Found by this very check on its first run - `option_default()`
|
||||
# reads the two consumer windows the freshness bound is derived from, so it
|
||||
# is a key-taking reader and belongs here rather than outside the audit.
|
||||
("test_freshness.py", "option_default"),
|
||||
("test_price.py", "shipped"),
|
||||
("test_tariff.py", "shipped_option"),
|
||||
# SAFETY-42. `read_option()` there takes a key and reads config.yaml, so the
|
||||
# signature puts it in this table whether anybody remembered it or not -
|
||||
# which is what this table is for. It answers the SITE value because it is
|
||||
# anchored at exactly two spaces; a loose-whitespace pattern would have
|
||||
# answered a per-inverter `max_w`, which is the defect below.
|
||||
("test_delivery.py", "read_option"),
|
||||
}
|
||||
# Helpers the audited readers call. Not called with a key themselves, so there
|
||||
# is nothing to feed them; they are exec'd so their callers work.
|
||||
|
||||
@@ -94,7 +94,7 @@ class _Pub:
|
||||
|
||||
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 1000,
|
||||
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 0,
|
||||
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
|
||||
"heartbeat_s": 10, "stale_input_s": 15}
|
||||
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
"""Runnable check for TEL-07 - the SECOND MQTT device, and A's history.
|
||||
`python3 test_mqtt.py`
|
||||
|
||||
WHAT THIS FILE IS FOR, in one sentence: to prove by DIFFING THE ANNOUNCED
|
||||
PAYLOADS - not by reading the code - that giving inverter B its own MQTT device
|
||||
changes nothing about inverter A's.
|
||||
|
||||
⚠️ WHY THAT IS THE WHOLE CARD. `app/mqtt.py`'s own note records the finding:
|
||||
Home Assistant composes an entity_id from the DEVICE name plus the ENTITY name,
|
||||
and `object_id` in the discovery payload did NOT override it (tested on HA
|
||||
2026.8). So re-scoping the device renames every entity - and because HA keeps
|
||||
the id it composed at FIRST discovery, what a rename actually produces is a NEW
|
||||
entity beside a DEAD one. The recorded history stays behind on the old id, and
|
||||
every dashboard, template and automation that names it - including the three
|
||||
live watchdog automations `goodwe_master_watchdog_held_*` and
|
||||
`goodwe_master_parked_at_0_w_*` - goes on pointing at an entity that has quietly
|
||||
stopped updating. Nothing errors. That is the failure this file exists to make
|
||||
impossible to ship.
|
||||
|
||||
⚠️ AND THE TEMPTING DESIGN IS THE DESTRUCTIVE ONE. "One device per inverter" -
|
||||
A becomes `inverter-a`, B becomes `inverter-b` - is symmetrical and tidy and is
|
||||
exactly the change described above. A KEEPS THE DEVICE IT ALREADY HAS; only B
|
||||
gets a new one. Part 8 below writes that tidy version and shows it going red.
|
||||
|
||||
THE SHAPE OF THE EVIDENCE. Part 1 records what `release/1.0` actually publishes -
|
||||
generated by running THAT commit's module, not transcribed - and every later
|
||||
part is a diff against it, reported as `N topics, M missing, C changed, A added`.
|
||||
|
||||
Part 1 The recorded baseline, and the diff at a roster of one and of none.
|
||||
Part 2 `_announce()` publishes EXACTLY `discovery_payloads()` and nothing
|
||||
else, so parts 1 and 3-8 are diffing what goes on the wire.
|
||||
Part 3 A roster of two: A's nine messages byte-identical, eight added.
|
||||
Part 4 Zero overlap between A's entities and B's - topics, unique_ids,
|
||||
object_ids, state topics, and devices.
|
||||
Part 5 Site-scoped sensors stay site-scoped, at every roster length 1-6.
|
||||
Part 6 The composed entity_ids, modelled INDEPENDENTLY of `app/mqtt.py`, and
|
||||
anchored on the one composed id that was verified against the live
|
||||
system (`sensor.goodwe_p1_sample_age`, mqtt.py's own note).
|
||||
Part 7 ⚠️ THE ANNOUNCEMENT AND THE PUBLICATION AGREE, on a real `Controller`,
|
||||
with the two units carrying DIFFERENT numbers - so a swap is visible.
|
||||
Part 8 ⚠️ VERIFIED BY BREAKING IT. Twelve mutations of the shipped module,
|
||||
re-executed from mutated source, each one named with the property it
|
||||
breaks. A property nothing breaks is a property nothing is watching.
|
||||
Part 9 What these checks CANNOT see, enumerated and sized.
|
||||
|
||||
⚠️ THE FIXTURE GIVES A AND B DIFFERENT NAMES, DIFFERENT RATINGS, DIFFERENT SoC,
|
||||
DIFFERENT BATTERY POWER AND DIFFERENT LATCH STATES, ON PURPOSE. A fixture whose
|
||||
two units are equal cannot observe a bug that swaps them - MUTATION.md habit 4,
|
||||
and the defect this repository has now shipped six cards in a row.
|
||||
|
||||
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every number attributed
|
||||
to it below is SIMULATED, from the datasheet and serial recorded in NOTES.md
|
||||
§1.1b (GW5048D-ES, serial 55048ESU22AW0992, max_w 4600 W). Nothing here is
|
||||
measured, and nothing here should ever be quoted as if it were.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ. Read MUTATION.md.
|
||||
Part 8 sidesteps the whole problem by exec'ing mutated SOURCE into a fresh
|
||||
namespace, so no `.pyc` is involved at any point.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app import mqtt
|
||||
from app.inverter import build_roster, roster_names
|
||||
from app.main import Controller
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
MQTT_SRC = (HERE / "app" / "mqtt.py").read_text(encoding="utf-8")
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The recorded baseline. ⚠️ GENERATED, NOT TRANSCRIBED. Reproduce it with:
|
||||
#
|
||||
# git show release/1.0:goodwe_controller/app/mqtt.py > /tmp/mqtt_base.py
|
||||
# python3 - <<'EOF'
|
||||
# import sys; sys.path.insert(0, "/tmp"); import mqtt_base as M
|
||||
# class F:
|
||||
# def __init__(self): self.m = []
|
||||
# def publish(self, t, p=None, retain=False): self.m.append((t, p, retain))
|
||||
# p = M.MqttPublisher.__new__(M.MqttPublisher)
|
||||
# p.omit, p.client, p.enabled = set(), F(), True
|
||||
# p._announce()
|
||||
# for row in p.client.m: print(row)
|
||||
# EOF
|
||||
#
|
||||
# A transcription would be a check that grades this branch against MY MEMORY of
|
||||
# release/1.0 rather than against release/1.0.
|
||||
# ---------------------------------------------------------------------------
|
||||
_DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", '
|
||||
'"manufacturer": "GoodWe (via RS485 meter emulation)", '
|
||||
'"model": "ES/BP series"}')
|
||||
|
||||
BASELINE = [
|
||||
('homeassistant/sensor/goodwe_ctl_setpoint/config',
|
||||
'{"name": "Setpoint", "object_id": "goodwe_setpoint", "unique_id": "goodwe_ctl_setpoint", '
|
||||
'"state_topic": "goodwe_ctl/setpoint", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_grid/config',
|
||||
'{"name": "Grid power", "object_id": "goodwe_grid_power", "unique_id": "goodwe_ctl_grid", '
|
||||
'"state_topic": "goodwe_ctl/grid", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_battery/config',
|
||||
'{"name": "Battery power", "object_id": "goodwe_battery_power", "unique_id": "goodwe_ctl_battery", '
|
||||
'"state_topic": "goodwe_ctl/battery", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_soc/config',
|
||||
'{"name": "Battery SoC", "object_id": "goodwe_battery_soc", "unique_id": "goodwe_ctl_soc", '
|
||||
'"state_topic": "goodwe_ctl/soc", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "%", "device_class": "battery", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_circulation/config',
|
||||
'{"name": "Circulating power", "object_id": "goodwe_circulating_power", '
|
||||
'"unique_id": "goodwe_ctl_circulation", "state_topic": "goodwe_ctl/circulation", '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", '
|
||||
'"icon": "mdi:sync-alert"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_phase/config',
|
||||
'{"name": "Maintenance phase", "object_id": "goodwe_maintenance_phase", '
|
||||
'"unique_id": "goodwe_ctl_phase", "state_topic": "goodwe_ctl/phase", '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"icon": "mdi:battery-sync"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_status/config',
|
||||
'{"name": "Controller status", "object_id": "goodwe_controller_status", '
|
||||
'"unique_id": "goodwe_ctl_status", "state_topic": "goodwe_ctl/status", '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"icon": "mdi:heart-pulse"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_p1_age/config',
|
||||
'{"name": "P1 sample age", "object_id": "p1_sample_age_s", "unique_id": "goodwe_ctl_p1_age", '
|
||||
'"state_topic": "goodwe_ctl/p1_age", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "s", "device_class": "duration", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('goodwe_ctl/availability', 'online', True),
|
||||
]
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Captures what `_announce()` actually hands paho."""
|
||||
|
||||
def __init__(self):
|
||||
self.msgs = []
|
||||
|
||||
def publish(self, topic, payload=None, retain=False):
|
||||
self.msgs.append((topic, payload, retain))
|
||||
|
||||
|
||||
def announce(units=(), omit=(), module=mqtt):
|
||||
"""The messages `_announce()` publishes, through the SHIPPED method.
|
||||
|
||||
⚠️ Not `discovery_payloads()` directly. Part 2 proves the two agree; every
|
||||
other part goes through this one, so what is diffed is what paho is told.
|
||||
"""
|
||||
pub = module.MqttPublisher.__new__(module.MqttPublisher)
|
||||
pub.omit = set(omit)
|
||||
pub.units = [str(u) for u in units]
|
||||
pub.enabled = True
|
||||
pub.client = FakeClient()
|
||||
pub._announce()
|
||||
return pub.client.msgs
|
||||
|
||||
|
||||
def diff(got, want=BASELINE):
|
||||
"""`(missing, changed, added)` between two announcement lists, by topic."""
|
||||
g, w = dict((t, (p, r)) for t, p, r in got), dict((t, (p, r)) for t, p, r in want)
|
||||
missing = [t for t in w if t not in g]
|
||||
changed = [t for t in w if t in g and g[t] != w[t]]
|
||||
added = [t for t in g if t not in w]
|
||||
return sorted(missing), sorted(changed), sorted(added)
|
||||
|
||||
|
||||
def report(label, got, want=BASELINE):
|
||||
m, c, a = diff(got, want)
|
||||
print(f" {label}: {len(want)} topics, {len(m)} missing, {len(c)} changed, "
|
||||
f"{len(a)} added")
|
||||
for t in m:
|
||||
print(f" MISSING {t}")
|
||||
for t in c:
|
||||
print(f" CHANGED {t}")
|
||||
for t in a:
|
||||
print(f" ADDED {t}")
|
||||
return m, c, a
|
||||
|
||||
|
||||
def cfgs(msgs):
|
||||
"""The discovery payloads, parsed, without the availability message."""
|
||||
return [json.loads(p) for t, p, _ in msgs if t.startswith("homeassistant/")]
|
||||
|
||||
|
||||
# The roster this card exists for. ⚠️ SIMULATED: inverter B does not physically
|
||||
# exist. Its rating is the datasheet's (NOTES.md §1.1b, GW5048D-ES, serial
|
||||
# 55048ESU22AW0992) and has never been measured.
|
||||
OPTS2 = {
|
||||
"meter_source": "off",
|
||||
"inverters": [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.sp_a",
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
||||
"max_w": 2000, "bms_soc_floor": 10},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
|
||||
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
|
||||
"max_w": 4600, "bms_soc_floor": 10},
|
||||
],
|
||||
}
|
||||
NAMES2 = roster_names(OPTS2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n1. the recorded baseline, and a roster of one")
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC 1 and AC 4. ⚠️ THE COMPARISON IS BYTE-FOR-BYTE ON THE PAYLOAD STRING, not
|
||||
# on a parsed dict. `json.dumps` writes keys in insertion order, so a reordered
|
||||
# payload is a different retained message on the broker even though the parsed
|
||||
# dicts compare equal - and a reordered payload is a re-announcement of every
|
||||
# entity on a live install.
|
||||
m, c, a = report("roster of one (`inverters:` empty, i.e. every install today)",
|
||||
announce(units=roster_names({})))
|
||||
check("⭐ AT THE SHIPPED ROSTER OF ONE THE ANNOUNCEMENT IS BYTE-IDENTICAL TO "
|
||||
"release/1.0: same topics, same unique_ids, same payload bytes, same "
|
||||
"retain flags, in the same order - no second device, no renamed entity, "
|
||||
"no added topic",
|
||||
(m, c, a) == ([], [], []) and announce(units=roster_names({})) == BASELINE)
|
||||
|
||||
check("...and the roster-of-one path is the one every install actually reads: "
|
||||
"an empty `inverters:` names exactly one unit",
|
||||
roster_names({}) == ["inverter-a"])
|
||||
|
||||
check("...and a publisher told NOTHING about the roster announces the same "
|
||||
"thing, so a caller that has not been taught about units cannot change "
|
||||
"what a live install sees",
|
||||
announce(units=()) == BASELINE)
|
||||
|
||||
m, c, a = report("roster of one, P1 off (`omit=('p1_age',)`)",
|
||||
announce(units=roster_names({}), omit=("p1_age",)),
|
||||
[row for row in BASELINE if "p1_age" not in row[0]])
|
||||
check("...and the `omit` path is unchanged too: with P1 ingestion off the "
|
||||
"watchdog input is still not announced, and nothing else moved",
|
||||
(m, c, a) == ([], [], []))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n2. what is diffed is what goes on the wire")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ WITHOUT THIS PART, PARTS 1 AND 3-8 GRADE A FUNCTION THE ADD-ON MIGHT NOT
|
||||
# USE. `_announce()` used to build its payloads inline; if it still did, every
|
||||
# diff here would be a diff of a second implementation that happens to sit in
|
||||
# the same file. This is the join.
|
||||
_direct = [(t, json.dumps(cfg), True)
|
||||
for t, cfg in mqtt.discovery_payloads(NAMES2)] + \
|
||||
[(mqtt.AVAILABILITY, "online", True)]
|
||||
check("`_announce()` publishes EXACTLY `discovery_payloads()`, in order, with "
|
||||
"retain set, plus the availability message - so every diff in this file "
|
||||
"is a diff of the bytes paho is handed",
|
||||
announce(units=NAMES2) == _direct)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n3. a roster of two: A untouched, B added")
|
||||
# ---------------------------------------------------------------------------
|
||||
TWO = announce(units=NAMES2)
|
||||
m, c, a = report("roster of two (inverter-a + inverter-b, SIMULATED)", TWO)
|
||||
check("⭐ AC 1: AT A ROSTER OF TWO, EVERY ONE OF A's NINE MESSAGES IS STILL "
|
||||
"BYTE-IDENTICAL - nothing missing, nothing changed. This is the criterion "
|
||||
"the live install's recorded history depends on, and it is a diff of the "
|
||||
"payloads, not a reading of the code",
|
||||
(m, c) == ([], []))
|
||||
check("...and the eight additions are all B's and A's own per-unit rows, and "
|
||||
"all of them are NEW topics", len(a) == 8)
|
||||
check("...and A's nine messages are still the FIRST nine, in the same order, "
|
||||
"so a broker replaying retained messages sees them unchanged",
|
||||
TWO[:len(BASELINE) - 1] == BASELINE[:-1])
|
||||
|
||||
site_ids = {c_["unique_id"] for c_ in cfgs(BASELINE)}
|
||||
unit_cfgs = [c_ for c_ in cfgs(TWO) if c_["unique_id"] not in site_ids]
|
||||
a_cfgs = [c_ for c_ in unit_cfgs if "inverter_a" in c_["unique_id"]]
|
||||
b_cfgs = [c_ for c_ in unit_cfgs if "inverter_b" in c_["unique_id"]]
|
||||
check("A's per-unit rows stay on THE DEVICE A ALREADY HAS - no new device is "
|
||||
"created for the first roster entry, which is the whole asymmetry",
|
||||
len(a_cfgs) == 4
|
||||
and all(c_["device"] == mqtt.DEVICE for c_ in a_cfgs))
|
||||
check("⭐ AC 2: B's rows are on B's OWN device, with its own identifiers",
|
||||
len(b_cfgs) == 4
|
||||
and all(c_["device"]["identifiers"] == ["goodwe_rs485_controller_inverter_b"]
|
||||
for c_ in b_cfgs))
|
||||
check("...and B's device hangs off the controller's via `via_device`, so HA "
|
||||
"shows one controller driving two inverters rather than two strangers",
|
||||
all(c_["device"].get("via_device") == mqtt.DEVICE["identifiers"][0]
|
||||
for c_ in b_cfgs))
|
||||
check("...and B's device carries the SAME manufacturer and model as the site "
|
||||
"device - it is the same installation, not a second product",
|
||||
all(c_["device"]["manufacturer"] == mqtt.DEVICE["manufacturer"]
|
||||
and c_["device"]["model"] == mqtt.DEVICE["model"] for c_ in b_cfgs))
|
||||
|
||||
# A regression pin on the NEW payloads. ⚠️ THIS IS NOT EVIDENCE OF CORRECTNESS -
|
||||
# it is generated from this branch, so it can only say "these bytes have not
|
||||
# changed since the PR was reviewed". It exists because B's payloads have no
|
||||
# released version to be diffed against, and a later edit that quietly changes
|
||||
# a per-unit `device_class` or state topic should have to be deliberate.
|
||||
_pin = hashlib.sha256(
|
||||
json.dumps(unit_cfgs, sort_keys=False).encode()).hexdigest()[:16]
|
||||
print(" the eight per-unit payloads, in announcement order:")
|
||||
for c_ in unit_cfgs:
|
||||
print(f" {c_['unique_id']:<40} {c_['object_id']:<34} "
|
||||
f"{c_['state_topic']}")
|
||||
print(f" regression pin (sha256 of the eight payloads): {_pin}")
|
||||
check("the per-unit payloads are the ones this PR was reviewed with "
|
||||
"(REGRESSION PIN, not a correctness claim - see the comment)",
|
||||
_pin == "eb5022b0455158d7")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n4. zero overlap between A's entities and B's")
|
||||
# ---------------------------------------------------------------------------
|
||||
def field(rows, key):
|
||||
return [r[key] for r in rows]
|
||||
|
||||
|
||||
for key in ("unique_id", "object_id", "state_topic"):
|
||||
av, bv = set(field(a_cfgs, key)), set(field(b_cfgs, key))
|
||||
sv = set(field(cfgs(BASELINE), key))
|
||||
check(f"⭐ AC 2: no `{key}` is shared between A's entities and B's, and "
|
||||
f"none of B's collides with a SITE entity that already exists",
|
||||
len(av) == 4 and len(bv) == 4 and not (av & bv) and not (bv & sv)
|
||||
and not (av & sv))
|
||||
|
||||
all_ids = field(cfgs(TWO), "unique_id")
|
||||
check("...and across the WHOLE announcement every unique_id is distinct - an "
|
||||
"HA discovery collision is not an error, it DROPS the second entity, so "
|
||||
"one inverter's telemetry would silently stop existing",
|
||||
len(all_ids) == len(set(all_ids)) == 16)
|
||||
|
||||
all_topics = [t for t, _, _ in TWO]
|
||||
check("...and so is every topic", len(all_topics) == len(set(all_topics)) == 17)
|
||||
|
||||
# ⚠️ THE SLUG COLLISION rule 9(c) DOES NOT REFUSE. `inverter-b` and `Inverter B`
|
||||
# are two different names by the validator's test and one string once slugged.
|
||||
COLLIDE = announce(units=["inverter-b", "Inverter B"])
|
||||
col_ids = field(cfgs(COLLIDE), "unique_id")
|
||||
print(" two names that slug alike ('inverter-b', 'Inverter B') ->",
|
||||
[c_["unique_id"] for c_ in cfgs(COLLIDE) if c_["unique_id"] not in site_ids])
|
||||
check("two roster names that differ only in case and punctuation - which "
|
||||
"`validate.py` rule 9(c) accepts, because it refuses only EXACT "
|
||||
"duplicates - still get distinct unique_ids",
|
||||
len(col_ids) == len(set(col_ids)))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n5. site-scoped sensors stay site-scoped")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ `p1_sample_age_s` IS A WATCHDOG INPUT (mqtt.py's `omit` note and the ESP32
|
||||
# firmware's ingest guard). A second copy of it is a second answer to "is the
|
||||
# meter fresh?", one of which is always the one nobody is looking at. `grid` is
|
||||
# the same argument without the safety edge: there is ONE grid connection.
|
||||
SITE_ONLY = ("goodwe_ctl_grid", "goodwe_ctl_p1_age", "goodwe_ctl_circulation",
|
||||
"goodwe_ctl_phase", "goodwe_ctl_status")
|
||||
rows = []
|
||||
for n in range(1, 7):
|
||||
names = [f"inverter-{chr(ord('a') + i)}" for i in range(n)]
|
||||
ids = field(cfgs(announce(units=names)), "unique_id")
|
||||
rows.append((n, [ids.count(s) for s in SITE_ONLY], len(ids)))
|
||||
print(f" roster of {n}: {len(ids)} entities, "
|
||||
+ ", ".join(f"{s}x{ids.count(s)}" for s in SITE_ONLY))
|
||||
check("⭐ AC 3: `grid` and `p1_sample_age_s` are announced EXACTLY ONCE at every "
|
||||
"roster length from 1 to 6 - announcing them per inverter would invent "
|
||||
"two of a thing there is one of, and would duplicate a safety signal",
|
||||
all(counts == [1] * len(SITE_ONLY) for _n, counts, _t in rows))
|
||||
check("...and the site set does not grow with the roster while the per-unit "
|
||||
"set does: 8 site entities always, 4 more per unit past the first",
|
||||
[t for _n, _c, t in rows] == [8, 16, 20, 24, 28, 32])
|
||||
|
||||
site_devices = {json.dumps(c_["device"], sort_keys=True)
|
||||
for c_ in cfgs(announce(units=NAMES2))
|
||||
if c_["unique_id"] in site_ids}
|
||||
check("...and every site entity is still on the SITE device, at a roster of two",
|
||||
site_devices == {json.dumps(mqtt.DEVICE, sort_keys=True)})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n6. the composed entity_ids")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ THIS SLUGIFIER IS DELIBERATELY NOT `mqtt._slug`. Importing the module's own
|
||||
# spelling would make the model and the subject move together, so a mutation in
|
||||
# the slugifier would change both sides equally and be invisible - "a check
|
||||
# whose data cannot reach the failure it names", written in one import.
|
||||
def compose(device_name, entity_name):
|
||||
"""HA's entity_id for a discovery payload, per mqtt.py's own note."""
|
||||
s = re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
|
||||
return "sensor." + s.strip("_")
|
||||
|
||||
|
||||
# The ONE anchor there is: mqtt.py records that the live entity is
|
||||
# `sensor.goodwe_p1_sample_age`, verified against the live system 2026-08-26,
|
||||
# and that the firmware subscribes to that composed name. If this model does not
|
||||
# reproduce that id, the model is wrong and every claim below it is worthless.
|
||||
p1 = [c_ for c_ in cfgs(BASELINE) if c_["unique_id"] == "goodwe_ctl_p1_age"][0]
|
||||
check("the model reproduces the ONE composed entity_id that was verified "
|
||||
"against the live system - `sensor.goodwe_p1_sample_age`, which the ESP32 "
|
||||
"ingest guard subscribes to - from the device name and the entity name",
|
||||
compose(mqtt.DEVICE["name"], p1["name"]) == "sensor.goodwe_p1_sample_age")
|
||||
check("...and that id is NOT the `object_id` in the payload "
|
||||
"(`p1_sample_age_s`), which is the whole reason the device name is "
|
||||
"load-bearing and mqtt.py says so in capitals",
|
||||
p1["object_id"] != "goodwe_p1_sample_age")
|
||||
|
||||
composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(TWO)]
|
||||
print(" every composed entity_id at a roster of two:")
|
||||
for c_, eid in zip(cfgs(TWO), composed):
|
||||
print(f" {eid:<46} <- device {c_['device']['name']!r} + "
|
||||
f"name {c_['name']!r}")
|
||||
check("⭐ every composed entity_id at a roster of two is DISTINCT: no per-unit "
|
||||
"entity can land on top of a site entity that already has history",
|
||||
len(composed) == len(set(composed)) == 16)
|
||||
|
||||
base_composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(BASELINE)]
|
||||
check("...and the eight ids that exist today are unchanged by adding B, which "
|
||||
"is AC 1 restated in the units HA actually uses",
|
||||
composed[:8] == base_composed)
|
||||
|
||||
check("...and the per-unit ids come out SYMMETRIC even though the devices are "
|
||||
"not - sensor.goodwe_inverter_a_battery_power beside "
|
||||
"sensor.goodwe_inverter_b_battery_power - which is the entire benefit of "
|
||||
"the 'one device per inverter' design, without its cost",
|
||||
"sensor.goodwe_inverter_a_battery_power" in composed
|
||||
and "sensor.goodwe_inverter_b_battery_power" in composed)
|
||||
|
||||
check("...and for every per-unit entity `object_id` equals the composed id, so "
|
||||
"the two mechanisms agree and an HA that DID honour object_id would "
|
||||
"produce the same ids as one that does not",
|
||||
all("sensor." + c_["object_id"] == compose(c_["device"]["name"], c_["name"])
|
||||
for c_ in unit_cfgs))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n7. the announcement and the publication agree")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ THE HAZARD mqtt.py's `omit` NOTE NAMES: an entity that exists and is never
|
||||
# fed is a worse signal than one that does not exist. So the set of per-unit
|
||||
# topics ANNOUNCED and the set `Controller.publish()` actually writes have to be
|
||||
# the same set - and, because a matching set of KEYS is not a matching set of
|
||||
# VALUES, the two units carry DIFFERENT numbers here so that a swap is red.
|
||||
class _Store:
|
||||
data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
|
||||
class _Pub:
|
||||
def __init__(self):
|
||||
self.last = {}
|
||||
self.units = []
|
||||
|
||||
def publish(self, values):
|
||||
self.last = dict(values)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def controlled(opts, soc, batt, tripped):
|
||||
"""A real Controller with per-unit readings planted. SIMULATED values."""
|
||||
# ⚠️ `stop` is a REQUIRED keyword (CFG-01): a Controller that cannot stop
|
||||
# itself used to be the default one. These cases only call publish(), so any
|
||||
# Event will do - but they may not skip it.
|
||||
pub = _Pub()
|
||||
ctl = Controller(dict(opts), None, _Store(), pub, stop=asyncio.Event())
|
||||
for inv, s, b, t in zip(ctl.inverters, soc, batt, tripped):
|
||||
inv.soc, inv.batt, inv.target = s, b, -1.0 * b
|
||||
inv.sign_wd.tripped = t
|
||||
ctl.publish()
|
||||
return ctl, pub.last
|
||||
|
||||
|
||||
# A 41 / 88 %, A discharging 1200 W, B charging 300 W, and B latched, not A.
|
||||
# Every one of those six numbers differs from its sibling, which is what makes
|
||||
# the assertions below able to see a swap.
|
||||
ctl2, vals2 = controlled(OPTS2, [41.0, 88.0], [1200.0, -300.0], [False, True])
|
||||
announced_unit_topics = {c_["state_topic"] for c_ in unit_cfgs}
|
||||
published_unit_topics = {f"{mqtt.BASE}/{k}" for k in vals2 if k.startswith("unit/")}
|
||||
print(f" announced per-unit topics {len(announced_unit_topics)}, "
|
||||
f"published {len(published_unit_topics)}, "
|
||||
f"announced-but-never-fed {sorted(announced_unit_topics - published_unit_topics)}, "
|
||||
f"fed-but-never-announced {sorted(published_unit_topics - announced_unit_topics)}")
|
||||
check("⭐ every per-unit entity announced is one the controller actually "
|
||||
"publishes to, and every per-unit topic published is one that was "
|
||||
"announced - no entity that nothing feeds, no topic nothing declared",
|
||||
announced_unit_topics == published_unit_topics)
|
||||
|
||||
check("⭐ AND THE VALUES LAND ON THE RIGHT UNIT: A's 41 % on A's topic and B's "
|
||||
"88 % on B's, not the other way round",
|
||||
vals2["unit/inverter_a/soc"] == 41.0
|
||||
and vals2["unit/inverter_b/soc"] == 88.0)
|
||||
check("...and the same for battery power, whose SIGNS differ between the two "
|
||||
"units here (A discharging, B charging) so a swap cannot hide in a "
|
||||
"magnitude",
|
||||
vals2["unit/inverter_a/battery"] == 1200.0
|
||||
and vals2["unit/inverter_b/battery"] == -300.0)
|
||||
check("...and the SAFETY-02 sign latch is per unit: B is latched and A is not, "
|
||||
"and the two rows say so separately",
|
||||
vals2["unit/inverter_a/sign_latch"] == "clear"
|
||||
and vals2["unit/inverter_b/sign_latch"] == "tripped")
|
||||
check("...and each unit's setpoint row is its OWN share, not the site target",
|
||||
vals2["unit/inverter_a/setpoint"] == -1200.0
|
||||
and vals2["unit/inverter_b/setpoint"] == 300.0)
|
||||
|
||||
check("...and the SITE rows still mean what they meant: `soc` is the EMPTIEST "
|
||||
"pack (41 %, not 88 and not the mean) and `battery` is the SUM (900 W), "
|
||||
"so this card added rows beside them and did not re-point them",
|
||||
vals2["soc"] == 41.0 and round(vals2["battery"]) == 900)
|
||||
|
||||
_, vals1 = controlled({"meter_source": "off", "setpoint_entity": "number.sp"},
|
||||
[55.0], [700.0], [False])
|
||||
print(f" roster of one publishes keys: {sorted(vals1)}")
|
||||
check("⭐ AC 4 on the PUBLISH side: at a roster of one not one `unit/...` topic "
|
||||
"is written either, so the live install's broker sees no new topic at all",
|
||||
not [k for k in vals1 if k.startswith("unit/")])
|
||||
|
||||
# --- 7b. the wiring, which nothing above reaches -----------------------------
|
||||
# ⚠️ FOUND BY MUTATION, NOT BY DESIGN. `units=()` in main()'s `MqttPublisher(...)`
|
||||
# call survived every check in this file: parts 1-7 build the publisher
|
||||
# themselves, so a controller publishing eight per-unit topics beside a
|
||||
# publisher announcing none of them was GREEN. That is this repository's
|
||||
# recurring defect - a check whose data cannot reach the failure it names - in
|
||||
# the one line that joins the two halves together.
|
||||
check("`roster_names()` and `build_roster()` give the same names in the same "
|
||||
"order, on every shape - the flat keys, a declared roster, a nameless "
|
||||
"entry and a non-dict entry - so the names the publisher is handed are "
|
||||
"the names the units answer to",
|
||||
all([inv.name for inv in build_roster(o, None)] == roster_names(o)
|
||||
for o in ({}, {"setpoint_entity": "number.sp"}, OPTS2,
|
||||
{"inverters": [{}, {}]},
|
||||
{"inverters": [{"name": "roof west"}, "junk"]})))
|
||||
check("...and a nameless entry gets a POSITIONAL name, not `inverter-a` twice: "
|
||||
"two units under one name share one Store namespace, so one unit's "
|
||||
"SAFETY-02 latch would be the other's",
|
||||
roster_names({"inverters": [{}, {}]}) == ["inverter-1", "inverter-2"])
|
||||
|
||||
_call = [n for n in ast.walk(ast.parse((HERE / "app" / "main.py").read_text()))
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == "MqttPublisher"]
|
||||
_kw = [k for c_ in _call for k in c_.keywords if k.arg == "units"]
|
||||
print(f" main.py builds MqttPublisher {len(_call)} time(s); "
|
||||
f"units= is " + (ast.unparse(_kw[0].value) if _kw else "ABSENT"))
|
||||
check("⚠️ A SOURCE READ, AND THE WEAKEST CHECK IN THIS FILE - said so rather "
|
||||
"than dressed up. main()'s one `MqttPublisher(...)` hands it "
|
||||
"`roster_names(opts)`; nothing else here can reach that line, because "
|
||||
"main() opens a broker, a web server and a control loop",
|
||||
len(_call) == 1 and len(_kw) == 1
|
||||
and ast.unparse(_kw[0].value) == "roster_names(opts)")
|
||||
|
||||
# --- 7c. a name that is not ASCII -------------------------------------------
|
||||
# HA transliterates before it slugs; this module cannot, so it treats anything
|
||||
# outside ASCII as a separator. Either way the id must be an id.
|
||||
_odd = announce(units=["caf\u00e9-a", "caf\u00e9-b"])
|
||||
_odd_unit = [c_ for c_ in cfgs(_odd) if c_["unique_id"] not in site_ids]
|
||||
print(" a roster named with non-ASCII ->",
|
||||
sorted({c_["unique_id"] for c_ in _odd_unit}))
|
||||
check("every unique_id, object_id and state topic this module produces is "
|
||||
"lowercase ASCII, even from a roster name that is not - an id HA would "
|
||||
"not have composed is an entity nothing can subscribe to",
|
||||
_odd_unit
|
||||
and all(re.fullmatch(r"[a-z0-9_]+", c_[k])
|
||||
for c_ in _odd_unit for k in ("unique_id", "object_id"))
|
||||
and all(re.fullmatch(r"[a-z0-9_/]+", c_["state_topic"])
|
||||
for c_ in _odd_unit))
|
||||
|
||||
# The topics the SHIPPED controller actually feeds at a roster of two. Part 8
|
||||
# grades every mutant announcement against this, which is what turns "the
|
||||
# announcement changed" into "the announcement now claims an entity nothing
|
||||
# will ever publish to".
|
||||
FED = set(published_unit_topics)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n8. verified by breaking it")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ MUTATED SOURCE, EXEC'D INTO A FRESH NAMESPACE. No file is edited and no
|
||||
# `.pyc` is consulted, so TEST-15's stale-bytecode failure cannot produce a
|
||||
# false survivor here. `app/mqtt.py` imports nothing from this package, which is
|
||||
# what makes exec'ing it standalone honest rather than a stub.
|
||||
def load(pairs):
|
||||
"""`app/mqtt.py` with `pairs` applied, executed into a fresh module.
|
||||
|
||||
⚠️ EVERY ANCHOR MUST OCCUR EXACTLY ONCE. A mutation whose anchor has drifted
|
||||
out of the file would otherwise apply to nothing and be reported as a
|
||||
survivor - a false red on the instrument rather than on the module.
|
||||
"""
|
||||
src = MQTT_SRC
|
||||
for old, new in pairs:
|
||||
if src.count(old) != 1:
|
||||
raise AssertionError(f"mutation anchor not unique: {old!r}")
|
||||
src = src.replace(old, new)
|
||||
mod = type(sys)("mqtt_mutant")
|
||||
exec(compile(src, "<mutant>", "exec"), mod.__dict__)
|
||||
return mod
|
||||
|
||||
|
||||
def properties(mod):
|
||||
"""Every claim this file makes, as booleans, over ONE module."""
|
||||
out = {}
|
||||
try:
|
||||
base = announce(units=["inverter-a"], module=mod)
|
||||
two = announce(units=["inverter-a", "inverter-b"], module=mod)
|
||||
none = announce(units=(), module=mod)
|
||||
col = announce(units=["inverter-b", "Inverter B"], module=mod)
|
||||
tc = cfgs(two)
|
||||
uc = [c_ for c_ in tc if c_["unique_id"] not in site_ids]
|
||||
bc = [c_ for c_ in uc if "inverter_b" in c_["unique_id"]]
|
||||
ac = [c_ for c_ in uc if "inverter_a" in c_["unique_id"]]
|
||||
comp = [compose(c_["device"]["name"], c_["name"]) for c_ in tc]
|
||||
out["A byte-identical at N=1"] = base == BASELINE
|
||||
out["A byte-identical at N=2"] = diff(two)[:2] == ([], [])
|
||||
out["N=1 adds no topic"] = len(base) == len(none) == len(BASELINE)
|
||||
out["unique_ids all distinct"] = (
|
||||
len(set(c_["unique_id"] for c_ in tc)) == len(tc))
|
||||
out["slug-alike names still distinct"] = (
|
||||
len(set(c_["unique_id"] for c_ in cfgs(col))) == len(cfgs(col)))
|
||||
out["no composed entity_id collision"] = len(set(comp)) == len(comp)
|
||||
out["object_id == composed id"] = all(
|
||||
"sensor." + c_["object_id"] == compose(c_["device"]["name"], c_["name"])
|
||||
for c_ in uc)
|
||||
out["A keeps the device it has"] = bool(ac) and all(
|
||||
c_["device"] == mqtt.DEVICE for c_ in ac)
|
||||
out["B has its own device"] = bool(bc) and all(
|
||||
c_["device"]["identifiers"] != mqtt.DEVICE["identifiers"] for c_ in bc)
|
||||
out["B is via_device the site"] = bool(bc) and all(
|
||||
c_["device"].get("via_device") == mqtt.DEVICE["identifiers"][0]
|
||||
for c_ in bc)
|
||||
out["site sensors announced once"] = all(
|
||||
[c_["unique_id"] for c_ in cfgs(announce(units=[f"i{j}" for j in range(n)],
|
||||
module=mod))].count(s) == 1
|
||||
for n in (1, 2, 3) for s in SITE_ONLY)
|
||||
# ⚠️ TWO PROPERTIES THAT WERE MISSING, AND THE INSTRUMENT FOUND THEIR
|
||||
# ABSENCE ITSELF: on the first run of this table, "the unit is dropped
|
||||
# from the state topic" and "`grid` is moved to the per-unit set" were
|
||||
# both GREEN. The first collapses A's and B's four topics onto one set
|
||||
# of four, so both inverters read one number; the second announces a
|
||||
# per-inverter grid sensor, which is the exact thing AC 3 forbids. The
|
||||
# ids stayed distinct through both, so every property above missed them.
|
||||
out["per-unit state topics all distinct"] = (
|
||||
len({c_["state_topic"] for c_ in uc}) == len(uc))
|
||||
# ⚠️ GRADED AGAINST THE SHIPPED `Controller.publish()`, on purpose. An
|
||||
# announcement is only honest if something feeds it - mqtt.py's `omit`
|
||||
# note - so a per-unit entity the controller will never publish to is a
|
||||
# defect however tidy its id looks. (A rename applied to BOTH halves at
|
||||
# once is invisible here and is disclosed in part 9; a rename applied to
|
||||
# one half is what actually happens, and is red.)
|
||||
out["every announced per-unit topic is fed"] = (
|
||||
{c_["state_topic"] for c_ in uc} == FED)
|
||||
except Exception as err: # a mutation that CRASHES proves nothing; say so
|
||||
out["did not raise"] = False
|
||||
out["__error__"] = repr(err)[:70]
|
||||
return out
|
||||
|
||||
|
||||
SHIPPED = properties(mqtt)
|
||||
check("the shipped module satisfies every property this instrument grades - "
|
||||
"so a red below is the mutation and not the instrument",
|
||||
all(v is True for k, v in SHIPPED.items()))
|
||||
|
||||
MUTATIONS = [
|
||||
("⭐ RE-SCOPE A's DEVICE NAME (the destructive change, AC 5)",
|
||||
[('"name": "GoodWe",', '"name": "GoodWe Inverter A",')]),
|
||||
("⭐ RE-SCOPE A's DEVICE IDENTIFIERS",
|
||||
[('"identifiers": ["goodwe_rs485_controller"],',
|
||||
'"identifiers": ["goodwe_inverter_a"],')]),
|
||||
("⭐ THE TEMPTING TIDY DESIGN: one device per inverter, A included",
|
||||
[("device = DEVICE if i == 0 else unit_device(name, slug)",
|
||||
"device = unit_device(name, slug)"),
|
||||
('prefix = f"{_title(name)} " if i == 0 else ""', 'prefix = ""')]),
|
||||
("B's rows land on A's device instead of B's own",
|
||||
[("device = DEVICE if i == 0 else unit_device(name, slug)",
|
||||
"device = DEVICE")]),
|
||||
("A's per-unit entity names lose the unit prefix",
|
||||
[('prefix = f"{_title(name)} " if i == 0 else ""', 'prefix = ""')]),
|
||||
("per-unit entities announced at a roster of ONE",
|
||||
[("PER_UNIT_MIN_ROSTER = 2", "PER_UNIT_MIN_ROSTER = 1")]),
|
||||
("the unit is dropped from the unique_id",
|
||||
[('unique_id = f"{BASE}_unit_{slug}_{key}"',
|
||||
'unique_id = f"{BASE}_unit_{key}"')]),
|
||||
("the unit is dropped from the state topic",
|
||||
[('return f"unit/{slug}/{key}"', 'return f"unit/{key}"')]),
|
||||
("the slug de-collision is removed (rule 9(c) does not cover this)",
|
||||
[("while slug in seen:", "while False and slug in seen:")]),
|
||||
("every unit takes the FIRST unit's slug",
|
||||
[("for i, (name, slug) in enumerate(zip(names, slugs)):",
|
||||
"for i, (name, slug) in enumerate(zip(names, [slugs[0]] * len(slugs))):")]),
|
||||
("`grid` is moved to the per-unit set (site sensor announced per inverter)",
|
||||
[(' ("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),\n',
|
||||
' ("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),\n'
|
||||
' ("grid", "grid_power", "Grid power", "W", "power", "measurement", None),\n')]),
|
||||
("B's device drops `via_device`",
|
||||
[(' "via_device": DEVICE["identifiers"][0],\n', "")]),
|
||||
]
|
||||
|
||||
survivors = []
|
||||
for label, pairs in MUTATIONS:
|
||||
got = properties(load(pairs))
|
||||
broke = sorted(k for k, v in SHIPPED.items()
|
||||
if v is True and got.get(k) is not True)
|
||||
if not broke:
|
||||
survivors.append(label)
|
||||
print(f" {'RED ' if broke else 'GREEN'} {label}")
|
||||
if broke:
|
||||
print(f" breaks: {', '.join(broke)}")
|
||||
elif got.get("__error__"):
|
||||
print(f" raised: {got['__error__']}")
|
||||
check(f"⭐ ALL {len(MUTATIONS)} MUTATIONS ARE RED, each on a NAMED property: "
|
||||
"re-scoping A's device, the tidy one-device-per-inverter design, B "
|
||||
"landing on A's device, per-unit entities at a roster of one, a unit "
|
||||
"dropped from an id or a topic, two units sharing a slug, and a site "
|
||||
"sensor announced per inverter. A mutation nothing catches is a claim "
|
||||
"nothing is watching",
|
||||
not survivors)
|
||||
if survivors:
|
||||
print(" SURVIVED:", "; ".join(survivors))
|
||||
|
||||
# ⚠️ AC 6, THE FIXTURE'S OWN NON-VACUITY. If A and B were given the same name
|
||||
# the whole of part 4 would still be green and would be observing nothing.
|
||||
same = announce(units=["inverter-a", "inverter-a"])
|
||||
swapped = announce(units=["inverter-b", "inverter-a"])
|
||||
check("...and the fixture's two units are genuinely distinguishable: swapping "
|
||||
"their order changes the announcement, and a roster of two IDENTICAL "
|
||||
"names - which rule 9(c) refuses, and which this file therefore never "
|
||||
"relies on - would still not collide",
|
||||
swapped != TWO
|
||||
and len({c_["unique_id"] for c_ in cfgs(same)}) == len(cfgs(same)))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n9. what this file CANNOT see, enumerated and sized")
|
||||
# ---------------------------------------------------------------------------
|
||||
for line in [
|
||||
"1. HA ITSELF IS NOT IN THE LOOP. `compose()` in part 6 is a MODEL of how "
|
||||
"HA 2026.8 builds an entity_id, anchored on exactly ONE measured id "
|
||||
"(sensor.goodwe_p1_sample_age, mqtt.py's note, verified 2026-08-26). If HA "
|
||||
"changes that rule, or if it differs for a device with `via_device`, every "
|
||||
"id claim here moves with it and this file stays green. SIZE: the 8 "
|
||||
"per-unit ids at N=2. It cannot be closed from this repository - it needs a "
|
||||
"broker, an HA, and the owner present.",
|
||||
"2. THE TICKET AND THE MODULE DISAGREE ABOUT THE LIVE IDs. The card quotes "
|
||||
"`sensor.goodwe_rs485_controller_goodwe_battery_soc`; mqtt.py records "
|
||||
"`sensor.goodwe_p1_sample_age`. Those cannot both come from one composition "
|
||||
"rule - the first is the id an EARLIER device name composed, and HA keeps "
|
||||
"whatever it composed at first discovery. This card does not need to "
|
||||
"resolve it, because it renames no device and no entity, and the byte-diff "
|
||||
"protects both spellings equally. SIZE: unresolved, 8 live entity ids; it "
|
||||
"wants one look at the live registry, which this branch must not take.",
|
||||
"3. THE PER-UNIT PAYLOADS HAVE NO RELEASED BASELINE. A's are diffed against "
|
||||
"release/1.0; B's can only be diffed against this branch (the sha256 pin in "
|
||||
"part 3). A wrong `device_class` or unit on a per-unit sensor is therefore "
|
||||
"caught as a CHANGE from review, not as an error. SIZE: 4 sensors x 4 "
|
||||
"optional fields.",
|
||||
"4. NO CHECK HERE RUNS A BROKER. `_announce()` is graded through a fake "
|
||||
"paho client, so QoS, retain semantics on a real broker, and what HA does "
|
||||
"with a retained config for a device it has never seen are all out of "
|
||||
"scope. SIZE: the whole transport.",
|
||||
"5. THE SLUG DE-COLLISION IS ORDER-DEPENDENT. `inverter-b` before "
|
||||
"`Inverter B` gives the SECOND one the `_2` suffix; reordering the roster "
|
||||
"swaps which unit's ids move. Rule 9(c) does not refuse slug-alike names, "
|
||||
"so this is reachable from a config an operator can write. SIZE: 4 ids on "
|
||||
"one unit, and only when two names slug alike. Filed as a follow-up rather "
|
||||
"than fixed in validate.py here, because a new refusal at startup on a "
|
||||
"LIVE install is a bigger change than this card is allowed to be.",
|
||||
"7. PART 8 MUTATES `app/mqtt.py` ONLY. `Controller.publish()`'s four "
|
||||
"per-unit lines are graded by part 7 - with different SoC, different "
|
||||
"battery sign and different latch states on the two units, so a swap or a "
|
||||
"mis-paired slug is red - but no automated table mutates main.py here. "
|
||||
"Those mutations were run by hand through `tools/mutation_run.sh` and are "
|
||||
"recorded in the PR. SIZE: 4 lines. Also invisible: a rename of "
|
||||
"`unit_state_key()` applied to BOTH halves at once, which is harmless "
|
||||
"(the topics are internal) but would move every per-unit state topic "
|
||||
"silently.",
|
||||
"6. `inverter AC power` AND PER-UNIT HEARTBEAT AGE ARE NOT ANNOUNCED. The "
|
||||
"card lists both as per-unit; neither has a source in the add-on today "
|
||||
"(nothing reads an AC-power register per unit, and `inv.last_beat` is a "
|
||||
"monotonic loop clock). Announcing them would create entities nothing "
|
||||
"feeds. SIZE: 2 sensors x N units, deliberately absent, named in the PR.",
|
||||
]:
|
||||
print(" " + line)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -109,6 +109,7 @@ import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import inf, isinf
|
||||
from pathlib import Path
|
||||
@@ -1006,7 +1007,7 @@ def built(db_path, opts=None, store=None):
|
||||
c = Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_source": "ha_signed",
|
||||
"p1_net_entity": "sensor.net", "meter_phases": 1,
|
||||
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15,
|
||||
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 0, "deadband_w": 15,
|
||||
"target_grid_w": 0, "step_w": 10, "heartbeat_s": 10,
|
||||
"stale_input_s": 15}, **(opts or {})),
|
||||
hass, store or _Store(), _Pub(), stop=asyncio.Event(),
|
||||
@@ -1062,6 +1063,58 @@ check("...and the status page says NOT COVERED rather than 'idle' or a green "
|
||||
# block with no seconds remaining - so the whole setup is retried on a fresh
|
||||
# Controller rather than left to flake. It is bounded at three attempts: a fourth
|
||||
# would mean something is wrong that is not a boundary.
|
||||
#
|
||||
# ⚠️⚠️ CI-06. AND THE QUARTER MUST HAVE ROOM LEFT TO SIZE A FIXTURE IN, WHICH IS
|
||||
# A SECOND AND MUCH WIDER RACE THAN THE BOUNDARY ONE ABOVE. It is where three of
|
||||
# this file's four known-flaky checks came from, and it is not a boundary
|
||||
# effect - it is the control law's own gain applied to this helper's arithmetic.
|
||||
#
|
||||
# Every second of the open block the telegrams do NOT cover is credited at the
|
||||
# HELD value instead of the measured one, so the projection loses `net_w x
|
||||
# uncovered / 900` W - and `excess = 900 x (projected - ceiling) / remaining`
|
||||
# multiplies that back up by `900 / remaining`. The floor this helper delivers
|
||||
# is therefore
|
||||
#
|
||||
# floor ~ want x (1 - uncovered/900) - net_w x uncovered / remaining
|
||||
#
|
||||
# and the second term is the whole story: at 600 s left one uncovered second
|
||||
# costs 6 W of a 1500 W floor, at 12 s left it costs 292 W, and at 3 s left it
|
||||
# takes the projection under the ceiling and THE GUARD DOES NOT ENGAGE AT ALL -
|
||||
# which fails "the real cycle files the peak claim", "...and the number written
|
||||
# to the inverter is the guard's floor" and "the STATUS PAGE row for a
|
||||
# floor-limited guard" together, for no reason except the minute the suite ran.
|
||||
# Measured on this tree as shipped: at 12 s left the fixture asked for 1500 W
|
||||
# and delivered 613 W, a -59 % fixture that stayed green and graded a different
|
||||
# site than the one its text names.
|
||||
#
|
||||
# TWO uncovered stretches produced it and both are closed here:
|
||||
#
|
||||
# 1. THE TAIL. `range(0, span + 1, 5)` stops at the last 5 s mark at or before
|
||||
# `now`, so 0-5 s of the block were never covered by construction. One
|
||||
# final telegram stamped at `now` closes it - which is also what a real
|
||||
# meter does, and what this helper's own docstring already claims it does.
|
||||
# 2. THE LAG between that last telegram and `cycle()`'s own `datetime.now()`.
|
||||
# That one is real work and cannot be removed, only bounded: measured at
|
||||
# 12-96 ms over 23 fixtures on this tree under contention.
|
||||
#
|
||||
# So the ROOM the helper needs is derived from what it promises rather than
|
||||
# picked. Requiring the delivered floor to be within 1 % of the floor asked for,
|
||||
# at the shipped ceiling and this file's smallest fixture (`net_w` ~ 3500 W,
|
||||
# `want` 1500 W), and taking the lag at 0.1 s - past every one of the 23
|
||||
# measurements:
|
||||
#
|
||||
# net_w x lag / (remaining x want) <= 0.01 - lag/900
|
||||
# remaining >= 3500 x 0.1 / (1500 x 0.00989) = 23.6 s -> FIXTURE_ROOM_S
|
||||
#
|
||||
# Below that the helper WAITS OUT THE REST OF THE QUARTER rather than grading a
|
||||
# fixture the arithmetic cannot deliver. The wait is at most FIXTURE_ROOM_S and
|
||||
# is taken on about 2.6 % of runs; a suite that silently graded a -59 % fixture
|
||||
# instead was the alternative. ⚠️ This is not a widened tolerance - the checks
|
||||
# downstream are unchanged and the 1 % is a requirement on the FIXTURE, not on
|
||||
# any assertion.
|
||||
FIXTURE_ROOM_S = 24.0
|
||||
|
||||
|
||||
def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
standing_w=None, soc=55.0, prepare=None):
|
||||
"""A real Controller driven one real cycle over a real over-limit quarter.
|
||||
@@ -1081,6 +1134,13 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
computed backwards from `remaining` at the moment of the run.
|
||||
"""
|
||||
for attempt in range(4):
|
||||
# ⚠️ ROOM FIRST, AND BEFORE THE CONTROLLER IS BUILT. See FIXTURE_ROOM_S.
|
||||
# Retrying does not help here - four attempts take ~40 ms each and all
|
||||
# four would land in the same dying quarter - so the tail of the block
|
||||
# is waited out once, which puts the next attempt at ~900 s of room.
|
||||
_room = QUARTER_S - (datetime.now(timezone.utc).timestamp() % QUARTER_S)
|
||||
if _room < FIXTURE_ROOM_S:
|
||||
time.sleep(_room + 0.05)
|
||||
ctl, hs = built(db if attempt == 0 else Path(f"{db}.{attempt}"), opts,
|
||||
store=_Store(store) if store is not None else None)
|
||||
ctl.auto, ctl.soc, ctl.batt = True, soc, batt_w
|
||||
@@ -1099,6 +1159,13 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
net_w = CAP_W + want_floor_w * remaining / QUARTER_S
|
||||
for i in range(0, span + 1, 5):
|
||||
ctl.p1.submit(sample(blk + timedelta(seconds=i), net_w))
|
||||
# ⚠️ AND ONE TELEGRAM AT `now` ITSELF. The averager credits a sample's
|
||||
# value over the interval to the NEXT sample, so without this the block
|
||||
# is covered only to the last 5 s mark and 0-5 s of it are extrapolated
|
||||
# from the held value. See the header: at 12 s of remaining that alone
|
||||
# turned a 1500 W fixture into a 613 W one. A real meter does not stop
|
||||
# sending at the last multiple of five either.
|
||||
ctl.p1.submit(sample(now, net_w))
|
||||
# ⚠️ ASSIGNED, not read back from `p1.net_w`. This helper stamps
|
||||
# `ingest_mono` with an epoch second so the averager (which keys on
|
||||
# `ingest_ts`) replays correctly, while `net_w` keys on a MONOTONIC age
|
||||
@@ -1273,6 +1340,23 @@ _shipped_decide = peakguard.decide
|
||||
|
||||
def _recording_decide(projection, **kw):
|
||||
_seen.update(kw)
|
||||
# ⚠️ CI-06. THE PROJECTION OBJECT ITSELF IS KEPT, AND THAT IS WHAT MAKES THE
|
||||
# COMPARISON BELOW A COMPARISON RATHER THAN A RACE. The check under
|
||||
# "THE CONSEQUENCE, ITERATED" says both forms are run "against the same
|
||||
# frozen projection", and until this line existed that sentence was FALSE:
|
||||
# the intent form was decided against a SECOND `PeakTracker.project(
|
||||
# b3.p1.averager, datetime.now())` taken after the cycle had returned, so
|
||||
# the two numbers came from two different instants of the wall clock. The
|
||||
# control law is `excess = 900 x (projected - ceiling) / remaining`, whose
|
||||
# derivative in time is `excess / remaining`, so at a 1500 W floor the two
|
||||
# instants separated at 1500 / remaining W per second and the check's 1.5 W
|
||||
# tolerance bought exactly `remaining / 1000` SECONDS - 900 ms at the top of
|
||||
# a quarter and 6 ms with ten seconds left of it. Measured on this tree, the
|
||||
# gap between the two statements is 13-40 ms idle and larger under load, so
|
||||
# the check was answered by WHERE IN THE QUARTER IT RAN about `gap_ms / 900`
|
||||
# of the time. Keeping the object removes the clock from the comparison
|
||||
# altogether: the tolerance below is EXACT, not widened.
|
||||
_seen["projection"] = projection
|
||||
return _shipped_decide(projection, **kw)
|
||||
|
||||
|
||||
@@ -1294,15 +1378,36 @@ check("B3: main.py sizes the floor from the MEASURED battery power, not from "
|
||||
# against the same frozen projection - the inverter never follows, so the meter
|
||||
# never moves - and the two are allowed to separate over time rather than being
|
||||
# judged at a single point where they need not differ.
|
||||
_proj3 = PeakTracker.project(b3.p1.averager, datetime.now(timezone.utc))
|
||||
#
|
||||
# ⚠️ CI-06. "THE SAME FROZEN PROJECTION" IS NOW THE SAME OBJECT. `_proj3` is the
|
||||
# `Projection` the real cycle actually graded, captured by `_recording_decide`
|
||||
# above, and not a second projection of the same averager taken at a later
|
||||
# `datetime.now()`. Every number below therefore comes from ONE instant, and the
|
||||
# tolerances that used to absorb the wall clock are gone.
|
||||
_proj3 = _seen["projection"]
|
||||
_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W,
|
||||
max_hold_s=b3.p1.averager.max_hold_s, soc=SOC_OK,
|
||||
soc_floor=SOC_FLOOR)
|
||||
_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw)
|
||||
# The delivered form, re-decided from the SAME projection. It has to reproduce
|
||||
# the real controller's own floor EXACTLY or `_kw` is not the call main.py
|
||||
# makes - which is a second thing this now grades and did not before, and the
|
||||
# one that keeps the identity below honest rather than self-referential.
|
||||
_delivered = _shipped_decide(_proj3, batt_w=0.0, **_kw)
|
||||
check("the delivered form re-decided from the cycle's OWN projection is the "
|
||||
"number the real Controller wrote, to the bit. The kwargs below are "
|
||||
"main.py's call and not an approximation of it, so the identity that "
|
||||
"follows is a statement about `decide()` and not about this fixture",
|
||||
near(_delivered.floor_w, b3.peak_guard.floor_w))
|
||||
check("one cycle of the intent form is exactly the delivered form plus the "
|
||||
"command standing on the wire - which is the whole defect in one line: "
|
||||
"watts that were asked for and never arrived, counted as if they had",
|
||||
near(_intent.floor_w - b3.peak_guard.floor_w, STANDING, 1.5))
|
||||
"watts that were asked for and never arrived, counted as if they had. "
|
||||
"⚠️ EXACT, not within 1.5 W: both forms are decided from one Projection "
|
||||
"object, so a tolerance here would only be hiding a clock. CI-06 - the "
|
||||
"1.5 W it used to carry bought `remaining / 1000` seconds and made this "
|
||||
"line answer by where in the quarter the suite happened to run",
|
||||
near(_intent.floor_w - _delivered.floor_w, STANDING)
|
||||
and not _intent.saturated and not _delivered.saturated)
|
||||
|
||||
_i_cmd, _d_batt = STANDING, 0.0
|
||||
_i_track, _d_track, _i_sat_at = [], [], None
|
||||
@@ -2215,22 +2320,64 @@ print(f" the same day's high-load block "
|
||||
print(f" {HOT_R['limited']} cycles reporting PEAK GUARD FLOOR-LIMITED, "
|
||||
f"{HOT_R['limited_discharging']} of them with the loop simultaneously "
|
||||
f"granted discharge (max {HOT_R['max_granted']:.0f} W)")
|
||||
check("THE ASYMMETRY, ON REAL DATA. On every cycle where the guard reports "
|
||||
print(f" {HOT_R['below']} cycles below the {SOC_FLOOR:.0f} % EMS floor, "
|
||||
f"{HOT_R['below_discharging']} of them discharging; low-water "
|
||||
f"{HOT_R['min_soc']:.2f} % against a {BMS_FLOOR:.0f} % BMS cut-out")
|
||||
# ⚠️ THESE TWO CHECKS WERE RE-WRITTEN AT SAFETY-39 AND THE OLD NUMBERS ARE
|
||||
# PRINTED HERE RATHER THAN DELETED, BECAUSE "AN ASSERTION WAS LOOSENED TO GET A
|
||||
# BRANCH GREEN" IS THE FIRST THING A REVIEWER SHOULD SUSPECT AND IT HAS TO BE
|
||||
# ANSWERABLE. They read `limited_discharging == limited` and `min_soc <=
|
||||
# bms_soc_floor + 0.05`, and both held at `slew_w: 1000`. SAFETY-39 re-derived
|
||||
# `slew_w` as a ratio to the rail (222 W at this 2000 W cap), and on this same
|
||||
# two-hour replay:
|
||||
#
|
||||
# slew_w 1000 slew_w 0 (the rail)
|
||||
# limited 49 49
|
||||
# limited_discharging 49 48
|
||||
# low-water SoC 9.98 % 10.57 %
|
||||
# cycles at the BMS cut 190 0
|
||||
# energy out 1247 Wh 1165 Wh
|
||||
# below / discharging 406 / 138 406 / 162
|
||||
#
|
||||
# So the loop drains this pack ~82 Wh more slowly and no longer reaches the
|
||||
# cut-out INSIDE THIS WINDOW. The FINDING is untouched and is in fact sharper on
|
||||
# the measure that carries it - 162 cycles of granted discharge below the floor,
|
||||
# up from 138. What moved is two numbers that happened to be exact at one
|
||||
# tuning: one cycle of loop spin-up, and whether a two-hour window is long
|
||||
# enough to touch a cut-out the loop is now approaching more slowly.
|
||||
#
|
||||
# The checks below therefore assert the SUBSTANCE and not the coincidence: that
|
||||
# the two states occur together on all but at most one cycle, and that the pack
|
||||
# still ends up past the EMS floor and within a point of its own cut-out with no
|
||||
# EMS-side bound anywhere in the path. If a future change makes either of these
|
||||
# fail, that IS a finding - do not widen them again.
|
||||
check("THE ASYMMETRY, ON REAL DATA. On the cycles where the guard reports "
|
||||
"FLOOR-LIMITED - 'commanding no forced discharge' on the status page and "
|
||||
"in the operator's notification - the arbiter is at the same instant "
|
||||
"granting the LOOP a discharge. A `lo` limit cannot cap a discharge, so "
|
||||
"the guard's floor and the pack emptying are not alternatives; they "
|
||||
"happen together",
|
||||
"happen together. ⚠️ AT MOST ONE CYCLE MAY DIFFER, and that one is the "
|
||||
"loop's cold start from 0 W - a tolerance of one, not a tolerance",
|
||||
HOT_R["limited"] > 10
|
||||
and HOT_R["limited_discharging"] == HOT_R["limited"]
|
||||
and HOT_R["limited_discharging"] >= HOT_R["limited"] - 1
|
||||
and HOT_R["max_granted"] > 0.0)
|
||||
check("...and the loop is granted a discharge on hundreds of cycles with the "
|
||||
"pack ALREADY below the EMS floor, which is the same asymmetry counted "
|
||||
"over the whole window rather than over the floor-limited subset - the "
|
||||
"measure the finding actually rests on, and it does not depend on one "
|
||||
"cycle of spin-up",
|
||||
HOT_R["below"] > 100 and HOT_R["below_discharging"] > 100)
|
||||
check("...which is why SAFETY-24's floor could not be widened into this one, "
|
||||
"and why the fix is not a bigger number. Recorded in "
|
||||
"and why the fix is not a bigger number: the pack still ends the window "
|
||||
"PAST the EMS floor and within one point of its own BMS cut-out, with no "
|
||||
"EMS-side bound anywhere in the path. Recorded in "
|
||||
"DECISIONS/SAFETY-25-loop-soc-floor.md, where AC 2 was DECIDED on "
|
||||
"2026-09-02: Option A, the BMS stays the only backstop, and the "
|
||||
"loop-side reserve is DEFERRED to ALLOC-01 - so this replay is not a "
|
||||
"pending exposure but the RECORDED behaviour, and part 10 stays as its "
|
||||
"tripwire", HOT_R["min_soc"] <= BMS_FLOOR + 0.05)
|
||||
"tripwire",
|
||||
HOT_R["min_soc"] < SOC_FLOOR
|
||||
and HOT_R["min_soc"] <= BMS_FLOOR + 1.0)
|
||||
|
||||
# --- 10d. the trap in the obvious fix, checked rather than asserted ---------
|
||||
# ⚠️ THE MEMO'S CENTRAL ENGINEERING CLAIM, RUN THROUGH THE REAL ARBITER. The
|
||||
@@ -5669,6 +5816,86 @@ check("...and so is the last one, which is not a defeat at all but the EDGE OF "
|
||||
not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("13. SAFETY-38 round 4: the ORDERED PAIR inside PeakDecision.explain()")
|
||||
|
||||
# ⚠️ THE STRING "peak guard idle" APPEARED IN NO test_*.py UNTIL THIS PART.
|
||||
# `peakguard.py:493` formats its OWN ATTRIBUTES and RETURNS the sentence:
|
||||
#
|
||||
# f"peak guard idle - quarter projects {self.projected_w:.0f} W under a "
|
||||
# f"{self.ceiling_w:.0f} W ceiling ({self.ceiling_from})"
|
||||
#
|
||||
# Transpose the two and this fixture's row reads "projects 5013 W under a
|
||||
# 2300 W ceiling" - a quarter projected 2713 W OVER the ceiling, printed on a
|
||||
# row whose own words say the guard is IDLE and whose own flag says ok.
|
||||
# MEASURED on this tree: zero-byte edit (50 032 bytes
|
||||
# before, 50 032 after, sha256 d95df61a51716c26 -> 2d2ee3d0bcd51251), the
|
||||
# emitted sentence changes, and the full 21-file suite stayed GREEN.
|
||||
#
|
||||
# The sentence was invisible to SAFETY-38's scanner for two rounds because both
|
||||
# censuses scored one syntactic sink - APPEND, ASSIGN, LOG ARG - and this one is
|
||||
# RETURNED. `tools/symmetric_message_scan.py` census C now scans the return sink
|
||||
# and resolves it through the method's call sites; it rediscovered this pair and
|
||||
# clamp.py's on its own, which is the only reason to trust the rest of its list.
|
||||
#
|
||||
# ⚠️ THE OPERANDS COME OFF THE PROJECTION AND OFF `quarter_ceiling_w()`, NOT OFF
|
||||
# THE DECISION. `d.projected_w` and `d.ceiling_w` are the very fields the swap
|
||||
# exchanges, so grading the sentence against them would compare the swap against
|
||||
# itself and pass under it.
|
||||
_S38_PROJ = PeakTracker.project(averager_at(CAP_W - 1200.0, 450.0),
|
||||
B + timedelta(seconds=450))
|
||||
_S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
|
||||
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
_S38_LITERAL = (f"quarter projects {_S38_PROJ.projected_w:.0f} W under a "
|
||||
f"{_S38_CEIL_W:.0f} W ceiling")
|
||||
_S38_SWAPPED = (f"quarter projects {_S38_CEIL_W:.0f} W under a "
|
||||
f"{_S38_PROJ.projected_w:.0f} W ceiling")
|
||||
print(f" projection {_S38_PROJ.projected_w:.0f} W against a "
|
||||
f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})")
|
||||
print(f" {_S38_IDLE.explain()!r}")
|
||||
check("PRECONDITION: the guard is IDLE with a projection to print, the ceiling "
|
||||
"came from the MONTH PEAK rather than the cap, and the two operands are "
|
||||
"DIFFERENT NUMBERS that RENDER differently. Equal operands would make the "
|
||||
"transposed sentence identical and every check below decoration",
|
||||
not _S38_IDLE.engaged and _S38_IDLE.projected_w is not None
|
||||
and _S38_CEIL_FROM == CEILING_MONTH_PEAK
|
||||
and _S38_PROJ.projected_w != _S38_CEIL_W
|
||||
and f"{_S38_PROJ.projected_w:.0f}" != f"{_S38_CEIL_W:.0f}"
|
||||
and _S38_PROJ.projected_w < _S38_CEIL_W)
|
||||
check("⚠️ THE ORDERED PAIR ON THE CONTIGUOUS LITERAL. The PROJECTION is named "
|
||||
"first and the CEILING second; a substring test for either number passes "
|
||||
"under the transposition because both numbers are still in the sentence, "
|
||||
"so only the ordered pair can tell the two slots apart (MUTATION.md "
|
||||
"habit 14)", _S38_LITERAL in _S38_IDLE.explain())
|
||||
check("...and the TRANSPOSED sentence is NOT what it says, so the check above "
|
||||
"discriminates rather than decorates",
|
||||
_S38_SWAPPED not in _S38_IDLE.explain())
|
||||
check("...and the row still names WHERE the ceiling came from, which is the "
|
||||
"difference between a cap an operator set and a peak the month already "
|
||||
"banked", f"ceiling ({CEILING_MONTH_PEAK})" in _S38_IDLE.explain())
|
||||
|
||||
# THE SHIPPED CONSUMER. `explain()` is not read by anybody until main.py prints
|
||||
# it, and round 3's lesson is that the pin belongs on the CALL SITE: the status
|
||||
# row at main.py:3313 is what a browser shows, and the action-log line at
|
||||
# main.py:1951 is what the release edge writes.
|
||||
_c38, _h38 = built(TMP / "s38pin.db")
|
||||
_c38.peak_guard = _S38_IDLE
|
||||
_rows38 = [r for r in _c38.checks() if "peak guard" in r["text"]]
|
||||
print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)"))
|
||||
check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read "
|
||||
"off the shipped `checks()` rather than off `explain()` a second time. "
|
||||
"This is the surface the owner actually looks at",
|
||||
len(_rows38) == 1 and _S38_LITERAL in _rows38[0]["text"]
|
||||
and _S38_SWAPPED not in _rows38[0]["text"])
|
||||
check("...and the row is green and un-warned, which is what makes the "
|
||||
"transposed version dangerous rather than merely untidy: an OK row "
|
||||
"carrying a projection 2713 W OVER its own ceiling is the one an "
|
||||
"operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"]
|
||||
and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0)
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
|
||||
@@ -130,10 +130,23 @@ DERIVED = {
|
||||
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
|
||||
"circulation_w": ("SAFETY-31", "924"),
|
||||
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
|
||||
# SAFETY-42. Two needles rather than one: the campaign it was derived from,
|
||||
# and the word that says what it does NOT do. An operator who reads
|
||||
# "not-delivering report" and assumes the add-on has handled it is the
|
||||
# failure this alarm's whole wording is written against.
|
||||
"delivery_hold_s": ("SAFETY-42", "DERIVED FROM", "REPORTS ONLY"),
|
||||
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
|
||||
"bms_soc_floor": ("0x0560", "MEASURED"),
|
||||
"integrator_max_w": ("MEASURED", "Do not raise it"),
|
||||
"gain": ("MEASURED ON", "do not raise it"),
|
||||
# SAFETY-39. `slew_w` joins the table because it stopped being an
|
||||
# operator's convenience and became a DERIVED value the moment it was
|
||||
# tied to the rating: 0 resolves to about a ninth of `max_w`, from an
|
||||
# ~18 s measured step response. It is the one entry here whose value is
|
||||
# a RATIO, so the description has to say what it follows as well as
|
||||
# where the ratio came from - a citation alone would leave an operator
|
||||
# reading "0" on the tab with no idea it is not "off".
|
||||
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
|
||||
}
|
||||
_uncited = []
|
||||
for key, needles in sorted(DERIVED.items()):
|
||||
|
||||
@@ -18,9 +18,10 @@ import asyncio
|
||||
import sys
|
||||
|
||||
from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
|
||||
from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC
|
||||
from app.validate import (
|
||||
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
|
||||
actuator_writers, refuse, validate_startup_config,
|
||||
actuator_writers, refuse, site_rail_w, validate_startup_config,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -457,6 +458,149 @@ check("arbiter: the drain SET is clamped, not contradicted",
|
||||
check("...and the clamp names itself, which is why it is not refused",
|
||||
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("10. a slew rate the plant can actually follow (SAFETY-39)")
|
||||
# ⚠️ THIS RULE GRADES A RELATIONSHIP, WHICH IS THE POINT OF IT. Every other rule
|
||||
# in this file compares a configured number against another configured number
|
||||
# and refuses one ordering. This one refuses a RATIO: `slew_w` may not exceed
|
||||
# `SLEW_RAIL_FRAC * rail`, so the same `slew_w` is fine at one rating and
|
||||
# refused at another, and raising the rating moves the bound with it instead of
|
||||
# leaving the constant behind. That is the failure the ticket is about -
|
||||
# `slew_w: 1000` was derived at a 1500 W clamp, the live install went to 5000,
|
||||
# and nothing anywhere said the derivation no longer held.
|
||||
print(f" SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = "
|
||||
f"{LOOP_TICK_S:.0f} s / {PLANT_SETTLE_S:.0f} s = {SLEW_RAIL_FRAC:.4f}")
|
||||
|
||||
_R5000 = base(max_w=5000, maintenance_charge_w=2000, maintenance_discharge_w=2000)
|
||||
check("the rail this rule grades is the SITE rail, and at the shipped roster of "
|
||||
"one it is `max_w` exactly - byte for byte the number the single-inverter "
|
||||
"shape has always meant",
|
||||
site_rail_w(_R5000) == 5000.0)
|
||||
|
||||
# ⭐ VERIFIED BY BREAKING IT. This is the acceptance criterion stated as a test:
|
||||
# put the 2026-09-05 pair back and the gate must go red. A rule that could not
|
||||
# be made to fire on the configuration it was written for is a rule nobody has
|
||||
# checked.
|
||||
_broken = validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000)
|
||||
print(f" restoring slew_w 1000 at max_w 5000 -> {len(_broken)} problem(s)")
|
||||
if _broken:
|
||||
print(f" {_broken[0][:160]}...")
|
||||
check("⭐ RESTORING THE 2026-09-05 PAIR - slew_w 1000 at max_w 5000 - IS REFUSED. "
|
||||
"It crosses the whole output range in 10 s against a plant measured at "
|
||||
"18 s, which is not a fast loop, it is a loop that commands its entire "
|
||||
"range before it can observe its answer to the first step",
|
||||
len(_broken) == 1 and why(_broken, "slew_w is 1000 W"))
|
||||
check("...and the refusal says WHICH RAIL it graded against and HOW LONG the "
|
||||
"crossing takes, because 'slew_w is too high' sends an operator to lower "
|
||||
"a number when raising max_w or setting 0 are both correct answers too",
|
||||
why(_broken, "site rail of 5000 W")
|
||||
and why(_broken, "crosses its whole output range in 5.0 cycles"))
|
||||
check("...and it names the plant measurement it is graded against, so the "
|
||||
"number is traceable to a step response rather than to a preference",
|
||||
why(_broken, "goodwe/NOTES.md §5.10"))
|
||||
check("...and it says what to DO, including the setting that cannot go stale",
|
||||
why(_broken, "Set slew_w to 0") and why(_broken, "556 W"))
|
||||
|
||||
# THE BOUNDARY. Equality is the DERIVED VALUE, not a violation - at exactly
|
||||
# `SLEW_RAIL_FRAC * rail` the loop takes precisely the plant's settling time to
|
||||
# cross its range, which is the constraint met. Rule 6 permits equality for the
|
||||
# same shape of reason and rule 5 refuses it for the opposite one; this is
|
||||
# rule 6's case, and getting it backwards would refuse the value this rule
|
||||
# exists to recommend.
|
||||
_bound = SLEW_RAIL_FRAC * 5000.0
|
||||
check(f"at exactly the derived value ({_bound:.0f} W) the config STARTS - "
|
||||
"equality is the constraint satisfied, not violated",
|
||||
validate_startup_config(dict(_R5000, slew_w=int(_bound)), -5000, 5000) == [])
|
||||
check("...and one watt of slack above it starts too, because a boundary that "
|
||||
"is not meaningful to one watt must not refuse on one watt - an operator "
|
||||
"reading 556 off a status page that rounded it is not misconfigured",
|
||||
validate_startup_config(dict(_R5000, slew_w=556), -5000, 5000) == [])
|
||||
check("...and well above it does not",
|
||||
validate_startup_config(dict(_R5000, slew_w=600), -5000, 5000) != [])
|
||||
|
||||
# SLOWER IS ALWAYS ACCEPTED, and the live install is the reason it has to be.
|
||||
check("the interim that is holding on the live system - slew_w 400 against "
|
||||
"max_w 5000, applied 2026-09-05 12:00 - STARTS. It is 12.5 ticks to cross "
|
||||
"the rail against the 9 this rule requires, and refusing 'too slow' would "
|
||||
"refuse the mitigation",
|
||||
validate_startup_config(dict(_R5000, slew_w=400), -5000, 5000) == [])
|
||||
check("...and so does the schema floor at the biggest rail, because there is no "
|
||||
"such thing as too slow here",
|
||||
validate_startup_config(dict(_R5000, slew_w=50), -5000, 5000) == [])
|
||||
|
||||
# ⚠️ 0 IS NOT GRADED AND MUST NOT BE. It means "follow the rail" and satisfies
|
||||
# this rule by construction at every rail there is; a rule that refused it would
|
||||
# refuse the fix. Checked at three ratings so that "it passes" cannot be an
|
||||
# accident of the default one.
|
||||
# ⚠️ NO SLEW PROBLEM AT ANY RATING, rather than NO PROBLEMS AT ALL. At the
|
||||
# schema floor of 100 W rules 5 and 5b fire on the shipped sign thresholds,
|
||||
# which is correct and is not this rule's business - asserting an empty list
|
||||
# would make this check fail for a reason it is not about, and somebody would
|
||||
# then "fix" it by moving a sign threshold.
|
||||
_zero_problems = {m: [p for p in validate_startup_config(
|
||||
base(max_w=m, slew_w=0,
|
||||
maintenance_charge_w=min(2000, m),
|
||||
maintenance_discharge_w=min(2000, m)),
|
||||
-abs(m), abs(m)) if "slew_w" in p]
|
||||
for m in (100, 500, 2000, 5000, 15000)}
|
||||
check("slew_w 0 - FOLLOW THE RAIL - is never refused at any rating, which it "
|
||||
"must not be, because it is the shipped value and it satisfies this rule "
|
||||
f"by construction at every rail there is: {_zero_problems}",
|
||||
not any(_zero_problems.values()))
|
||||
check("a negative slew_w is refused as nonsense rather than treated as 0 - it "
|
||||
"is a magnitude, and 0 already has a meaning here",
|
||||
why(validate_startup_config(dict(_R5000, slew_w=-400), -5000, 5000),
|
||||
"must not be negative"))
|
||||
|
||||
# ⭐ THE ROSTER, WHICH IS THE WHOLE REASON THIS GRADES A SUM. `slew_w` is not a
|
||||
# per-unit quantity: main.py builds `Tuning(max_w=self.site_max_w)` and
|
||||
# `compute()` emits the SITE target. So the rail is the roster sum, and adding
|
||||
# an inverter moves this bound with no operator action and no second derivation.
|
||||
#
|
||||
# ⚠️ INVERTER B'S 4600 W IS ITS DATASHEET AND SERIAL, NOT A MEASUREMENT, AND
|
||||
# INVERTER B IS NOT ON SITE (SPIKE-04, ~2026-09-12; goodwe/NOTES.md §1.1b). It
|
||||
# is used here because what is graded is the ARITHMETIC OF THE RULE, which does
|
||||
# not depend on the number being measured - not because 9600 W is a fact about
|
||||
# this house yet.
|
||||
def _roster(a=5000, b=4600, **over):
|
||||
o = base(setpoint_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.a", "max_w": a,
|
||||
"bms_soc_floor": 10, "soc_entity": "sensor.sa", "batt_entity": "sensor.ba"},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": b,
|
||||
"bms_soc_floor": 10, "soc_entity": "sensor.sb", "batt_entity": "sensor.bb"}])
|
||||
o.pop("max_w"); o.pop("soc_entity"); o.pop("batt_entity")
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
_site = site_rail_w(_roster())
|
||||
print(f" roster 5000 + 4600 (B FROM DATASHEET, NOT MEASURED) -> site rail "
|
||||
f"{_site:.0f} W, bound {SLEW_RAIL_FRAC * _site:.0f} W")
|
||||
check("the rail is the roster SUM and not the weakest unit, which is what "
|
||||
"separates this rule from 5, 6 and 8 - those grade one watchdog, one "
|
||||
"shaping claim and one pack, and this grades the range one SITE COMMAND "
|
||||
"is a rate across",
|
||||
_site == 9600.0)
|
||||
check("⭐ AND THE BOUND MOVES ON ITS OWN WHEN THE ROSTER DOES: the derived value "
|
||||
"at a 9600 W site is 1067 W with nobody re-deriving anything, which is "
|
||||
"the property that makes this a fix rather than a re-tune",
|
||||
abs(SLEW_RAIL_FRAC * _site - 9600.0 / 9.0) < 1e-9)
|
||||
check("...so the same explicit slew_w that is REFUSED at a 5000 W rail is "
|
||||
"ACCEPTED at 9600 - the bound is on how long the crossing TAKES, and a "
|
||||
"bigger rail at the same step size takes longer to cross. ⚠️ This is the "
|
||||
"direction of the rule stated as a test, because it is not the direction "
|
||||
"the ticket assumed: a growing rail LOOSENS this bound. What the rule "
|
||||
"protects against is the constant being left behind when the rail moves; "
|
||||
"it is not a bound on how much the site may command, and it does not "
|
||||
"bound the over-absorption, which is a `gain` term SAFETY-39 measured "
|
||||
"and deliberately did not change",
|
||||
validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000) != []
|
||||
and validate_startup_config(_roster(slew_w=1000)) == [])
|
||||
check("...and a slew that outruns even the doubled rail is still refused there, "
|
||||
"so the roster path is not simply un-graded",
|
||||
why(validate_startup_config(_roster(slew_w=2000)),
|
||||
"site rail of 9600 W"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
||||
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
|
||||
@@ -470,12 +614,21 @@ import re # noqa: E402
|
||||
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||
with open(_CFG, encoding="utf-8") as _fh:
|
||||
_txt = _fh.read()
|
||||
# ⚠️ `slew_w` JOINED THIS SET AT SAFETY-39 AND IT IS THE INTERESTING MEMBER.
|
||||
# The others are refused when they are too big for `max_w`; this one is refused
|
||||
# when it is too big for `max_w` AS A RATIO, so shipping the wrong number here
|
||||
# would be shipping the exact defect the card is about with the gate that
|
||||
# catches it standing right beside it. Reading it out of config.yaml is what
|
||||
# makes "the shipped value is 0" a fact rather than a claim.
|
||||
_shipped = {k: float(v) for k, v in re.findall(
|
||||
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
|
||||
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||
check("config.yaml really was read (all four defaults found)",
|
||||
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w"
|
||||
r"|slew_w): (-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||
check("config.yaml really was read (all five defaults found)",
|
||||
set(_shipped) == {"max_w", "maintenance_charge_w",
|
||||
"maintenance_discharge_w", "sign_disagree_w"})
|
||||
"maintenance_discharge_w", "sign_disagree_w", "slew_w"})
|
||||
check(f"...and the shipped slew_w is 0, i.e. FOLLOW THE RAIL - the one value "
|
||||
f"that cannot be left behind when max_w moves (got {_shipped.get('slew_w')})",
|
||||
_shipped.get("slew_w") == 0.0)
|
||||
check(f"the shipped defaults are accepted {_shipped}",
|
||||
validate_startup_config(base(**_shipped), -5000, 5000) == [])
|
||||
check("...and the shipped charge rate is at or under the shipped ceiling",
|
||||
|
||||
@@ -1393,13 +1393,29 @@ def _sign_site(hass, store=None, **over):
|
||||
return c
|
||||
|
||||
|
||||
async def _drive(c, n=14, dt=0.2, watch=None):
|
||||
async def _drive(c, n=14, dt=0.2, watch=None, pre=None):
|
||||
"""Drive `n` real cycles. `watch` samples AFTER each cycle, `pre` BEFORE it.
|
||||
|
||||
⚠️ `pre` EXISTS BECAUSE `watch` ALONE CANNOT SEE WHAT A DETECTOR WAS HANDED,
|
||||
AND SAFETY-39 IS HOW THAT WAS FOUND. `cycle()` evaluates the sign watchdogs
|
||||
against THE STANDING COMMAND - the write from the PREVIOUS cycle - and then
|
||||
issues a new one. So `watch`'s `last_write` is the command AFTER the
|
||||
detector ran, and pairing it with `c.grid` builds a pair the detector never
|
||||
saw. While `slew_w` was 1000 the loop was at its rail within two cycles and
|
||||
consecutive commands were IDENTICAL, so the off-by-one was invisible; at a
|
||||
rail-following slew the loop ramps and the two numbers differ by one step.
|
||||
That is a fault in this harness, not in the code it grades - and a check
|
||||
that compares an operator-facing sentence against the wrong cycle is
|
||||
precisely the "the data could not reach the failure the text names" defect
|
||||
MUTATION.md keeps finding. `pre` reads the operands the detector will use.
|
||||
"""
|
||||
seen = []
|
||||
for _ in range(n):
|
||||
await c.read_inputs()
|
||||
before = {} if pre is None else pre(c)
|
||||
await c.cycle()
|
||||
if watch is not None:
|
||||
seen.append(watch(c))
|
||||
seen.append({**before, **watch(c)})
|
||||
await asyncio.sleep(dt)
|
||||
return seen
|
||||
|
||||
@@ -1508,6 +1524,50 @@ check("⚠️ AC 1. THE REVERSED UNIT TRIPS AND ITS SIBLING DOES NOT. The eviden
|
||||
check("...and the reason names the instrument, so the operator is sent to the "
|
||||
"right one", AGAINST_OWN_POWER in _b30.sign_wd.reason)
|
||||
print(f" {_b30.sign_wd.reason!r}")
|
||||
# ⚠️ THE LAST CYCLE BEFORE B LATCHED, NAMED RATHER THAN INDEXED - part 5's
|
||||
# lesson and 9a's. After the trip B's share is 0 W and its measured power
|
||||
# follows it down, so a fixed index reads the REMEDY instead of the evidence.
|
||||
# Both operands are read off THIS OBSERVED CYCLE - `last_write` and `batt` off
|
||||
# the objects - and not off the fixture's constants, so the check cannot be
|
||||
# satisfied by two numbers the fixture happens to hold anyway (habit 2).
|
||||
_preb30 = [x for x in _s30 if not x["b"]]
|
||||
_midb30 = _preb30[-1]
|
||||
print(f" last cycle before B latched: B stood "
|
||||
f"{_midb30['stood'][1]:+.0f} W, B measured {_midb30['meas'][1]:+.0f} W")
|
||||
check("⚠️ ...AND THE ORDERED PAIR, on the ONE SIGN-DISAGREEMENT SENTENCE A "
|
||||
"SHIPPED INSTALL CAN EVER PRINT. Part 9a pins the SITE banner's operand "
|
||||
"order at main.py:1620; that call site is armed only at N>=2 and part 9c "
|
||||
"asserts it is ABSENT at a roster of one, so on the shipped install the "
|
||||
"PER-UNIT banner is the only one there is - and its call site is a "
|
||||
"different line. Swapping the first two arguments at main.py:1560 - "
|
||||
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` - is "
|
||||
"a ZERO-BYTE edit that changes NO trip decision, because `disagrees()` "
|
||||
"is symmetric in those two arguments (`commanded_w * against_w < 0` with "
|
||||
"an abs() gate on both). Nothing behavioural can see it and the full "
|
||||
"21-file suite stayed green. What it changes is the SENTENCE: 'commanded "
|
||||
"-1000 W against its own measured power +1000 W', the exact opposite of "
|
||||
"which instrument read what, on the line somebody woken by this alarm "
|
||||
"reads first - and it is persisted to `sign_reason` and re-read after a "
|
||||
"restart. Graded on the CONTIGUOUS LITERAL with each value in its own "
|
||||
"slot, per MUTATION.md habit 14: an `in` test on a number grades "
|
||||
"PRESENCE, never ARRANGEMENT",
|
||||
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
|
||||
f"{_midb30['meas'][1]:+.0f} W" in _b30.sign_wd.reason)
|
||||
check("PRECONDITION for the line above, and without it that check is "
|
||||
"decoration: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
|
||||
"SIGN, so transposing them really does produce a different string. A "
|
||||
"cycle where B's share and B's measured power were equal, or where "
|
||||
"either was 0 W, would make the swapped sentence identical to the "
|
||||
"correct one and the assertion could not reach the failure it names",
|
||||
_midb30["stood"][1] != _midb30["meas"][1]
|
||||
and _midb30["stood"][1] * _midb30["meas"][1] < 0
|
||||
and f"{_midb30['stood'][1]:+.0f}" != f"{_midb30['meas'][1]:+.0f}")
|
||||
check("...and the SAME ordered pair is what an operator finds AFTER A RESTART, "
|
||||
"because `sign_reason` is the persisted copy of this string. The swap "
|
||||
"survives the container restart the same way the latch does",
|
||||
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
|
||||
f"{_midb30['meas'][1]:+.0f} W"
|
||||
in str(_c30.store.data.get(unit_key("inverter-b", "sign_reason"), "")))
|
||||
# ⚠️ READ OFF THE ALLOCATION RECORD, NOT OFF `Controller.reason`. At a roster
|
||||
# of two `reason` is the SITE resolution's line - "loop -> 2000 W" - because
|
||||
# B's failsafe is a UNIT claim resolved on B's own actuator and never competes
|
||||
@@ -1764,11 +1824,27 @@ async def _n1_sign():
|
||||
return _orig_eval(*a, **kw)
|
||||
|
||||
c.site_sign_wd.evaluate = _spy
|
||||
await _drive(c, n=12, dt=0.2)
|
||||
return c, _n1_calls
|
||||
# ⚠️ THE WATCH READS THE CONTROLLER'S OWN STATE, NOT `_spy`'s ARGUMENTS.
|
||||
# `_n1_calls` records what was HANDED to a detector, so grading the banner
|
||||
# against it would compare the swap against itself - the DEV-02 defect.
|
||||
# `last_write` and `c.grid` are read off the objects the plant moved.
|
||||
# ⚠️ `stood` COMES FROM `pre`, i.e. BEFORE `cycle()` RUNS (SAFETY-39). It is
|
||||
# the STANDING command the sign watchdog is evaluated against; taking it
|
||||
# after the cycle would pair this cycle's meter with next cycle's command
|
||||
# and grade the banner against a pair that never existed. See `_drive`.
|
||||
# `n` is 24 rather than 12 because at the shipped rail-following `slew_w`
|
||||
# the loop needs nine cycles to reach a 2000 W rail instead of two.
|
||||
seen = await _drive(c, n=24, dt=0.2, pre=lambda c: dict(
|
||||
stood=(c.inverters[0].last_write
|
||||
if c.inverters[0].last_write is not None
|
||||
else c.inverters[0].target)),
|
||||
watch=lambda c: dict(
|
||||
grid=c.grid,
|
||||
tripped=c.inverters[0].sign_wd.tripped))
|
||||
return c, _n1_calls, seen
|
||||
|
||||
|
||||
_c1s, _n1_site_calls = asyncio.run(_n1_sign())
|
||||
_c1s, _n1_site_calls, _seen1s = asyncio.run(_n1_sign())
|
||||
_inv1 = _c1s.inverters[0]
|
||||
print(f" roster={_c1s.units} armed against {_inv1.sign_wd.against!r} "
|
||||
f"at {_inv1.sign_wd.cfg.min_w:.0f} W / {_inv1.sign_wd.cfg.hold_s:.0f} s")
|
||||
@@ -1786,6 +1862,55 @@ check("...the reason sentence still says 'a meter reading', word for word - it "
|
||||
"is what an operator reads, what the action log carries and what is "
|
||||
"persisted, so it is graded on the literal",
|
||||
"against a meter reading" in _inv1.sign_wd.reason)
|
||||
# THE CYCLE THE LATCH HAPPENED ON, named rather than indexed: afterwards the
|
||||
# failsafe stands 0 W and the plant's meter relaxes, so a fixed index reads the
|
||||
# remedy. Both operands come off the controller, not off the fixture's options.
|
||||
#
|
||||
# ⚠️ IT IS THE FIRST *TRIPPED* SAMPLE, AND IT USED TO BE THE LAST UN-TRIPPED ONE
|
||||
# (SAFETY-39). The detector is evaluated INSIDE `cycle()` against the STANDING
|
||||
# command - the write from the previous cycle - and against the meter read at
|
||||
# the top of THIS one, and it latches on the cycle whose pair crosses the hold.
|
||||
# So the operands the sentence quotes belong to the LATCHING cycle, not to the
|
||||
# one before it. `stood` is now sampled by `_drive`'s `pre` hook, before
|
||||
# `cycle()` overwrites it with the failsafe's 0 W, which is what makes that
|
||||
# cycle readable at all - and it is why the old form had to reach back a cycle.
|
||||
# The two forms agreed while `slew_w` was 1000 and the loop sat at its rail with
|
||||
# consecutive commands IDENTICAL; at a rail-following slew the loop ramps and
|
||||
# they differ by exactly one step, which is how this was found.
|
||||
_first1s = [x for x in _seen1s if x["tripped"]]
|
||||
_mid1s = _first1s[0]
|
||||
print(f" the cycle the latch happened on: stood {_mid1s['stood']:+.0f} W, "
|
||||
f"meter {_mid1s['grid']:+.0f} W")
|
||||
print(f" {_inv1.sign_wd.reason!r}")
|
||||
check("⚠️ ...AND THE ORDERED PAIR ON THE SHIPPING CALL SITE. This is the SAME "
|
||||
"zero-byte defect part 9a closed at main.py:1620, one call site over at "
|
||||
"main.py:1560 - and this is the one a one-inverter install actually "
|
||||
"reaches, since the site detector is never armed here (asserted below). "
|
||||
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` "
|
||||
"changes no trip decision - `disagrees()` is symmetric in those two "
|
||||
"arguments - and inverts the banner to 'commanded +4000 W against a "
|
||||
"meter reading -2000 W': the loop is standing at -2000 W of charge INTO "
|
||||
"a +4000 W import and the latching banner says the opposite. The "
|
||||
"substring check above passes under the swap, because both numbers are "
|
||||
"still in the sentence; only the ORDERED PAIR can tell the slots apart "
|
||||
"(MUTATION.md habit 14)",
|
||||
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W" in _inv1.sign_wd.reason)
|
||||
check("PRECONDITION: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
|
||||
"SIGN on that cycle, so a transposition genuinely produces a different "
|
||||
"string. Equal or zero operands would make the swapped banner identical "
|
||||
"and the check above vacuous",
|
||||
_mid1s["stood"] != _mid1s["grid"]
|
||||
and _mid1s["stood"] * _mid1s["grid"] < 0
|
||||
and f"{_mid1s['stood']:+.0f}" != f"{_mid1s['grid']:+.0f}")
|
||||
check("...and the persisted copy carries the same ordered pair, under BOTH the "
|
||||
"namespaced key and the flat rollback mirror - which is what an operator "
|
||||
"reads after the restart the latch is designed to survive",
|
||||
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W" in str(_c1s.store.data.get(REASON_KEY, ""))
|
||||
and f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W"
|
||||
in str(_c1s.store.data.get("sign_reason", "")))
|
||||
check("...the latch is under the same namespaced key with the same flat "
|
||||
"rollback mirror, and SAFETY-30's site pair is untouched at its default. "
|
||||
"A one-inverter install's state file is byte-identical to what it was",
|
||||
@@ -1993,6 +2118,571 @@ check("...and the counterfactual again: the same site with nothing latched "
|
||||
not _bn_n["banner"].startswith("FAILSAFE")
|
||||
and _bn_n["banner"] != _bn_b["banner"])
|
||||
|
||||
|
||||
print()
|
||||
print("9. SAFETY-38: `site_standing` - the ONE quantity BOTH site guards are")
|
||||
print(" asked about - pinned by shares that CANNOT answer for it")
|
||||
# ⚠️ WHAT THIS SECTION PINS, AND WHY NOTHING ABOVE IT COULD.
|
||||
# `main.py`'s `site_standing = float(sum(standing.values()))` is the first
|
||||
# argument of SAFETY-30's site sign detector AND the `commanded_w` handed to
|
||||
# SAFETY-18's `looks_frozen()`. It was pinned by NOTHING. THREE mutations of it
|
||||
# were demonstrated live against a full green suite, and ALL THREE ARE COVERED
|
||||
# HERE:
|
||||
#
|
||||
# sum(...) / max(1, len(standing)) the MEAN
|
||||
# max(standing.values(), key=abs) the LARGEST SHARE
|
||||
# list(standing.values())[0] the FIRST SHARE
|
||||
#
|
||||
# ⚠️ ROUND 1 OF THIS CARD SAID THE THIRD ONE WAS ALREADY CLOSED BY SAFETY-32's
|
||||
# PACKAGE-WIDE STRUCTURAL PIN AND TOLD FUTURE AUTHORS "DO NOT RE-COVER THAT ONE
|
||||
# HERE". THAT WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART. It was
|
||||
# measured ONCE; that single run happened to hit test_peakguard.py's ~20%
|
||||
# CI-06 flake, the red was recorded as a mutation KILL, and the claim was then
|
||||
# written into four places. RE-MEASURED IN ROUND 2, on release/1.0 @ 86eaef2
|
||||
# carrying ONLY the `[0]` edit (+4 bytes, re-read from disk): the full suite is
|
||||
# 21/21 GREEN, and test_peakguard.py run FIVE times in isolation on that same
|
||||
# mutated tree is PASS PASS PASS PASS PASS. The mutation SURVIVES. (The
|
||||
# reviewer's own independent re-measurement of the same tree caught the flake
|
||||
# once - PASS PASS PASS FAIL PASS - which is the same verdict: a survivor, plus
|
||||
# a demonstration of the flake that produced the original error.) A SINGLE RED
|
||||
# FROM test_peakguard.py PROVES NOTHING - re-run it at least five times and NAME
|
||||
# the failing check before believing it. That rule is CI-06's, and this card is
|
||||
# the third measurement it has corrupted.
|
||||
#
|
||||
# THE MECHANISM, confirmed by reading 12j's own enumeration rather than by
|
||||
# argument. SAFETY-32's part 12j taints names BOUND to roster-carrying values
|
||||
# and lists ACCUMULATED in a loop over one. `standing` is neither: it is a dict
|
||||
# created empty at main.py:1554 and filled by SUBSCRIPT assignment
|
||||
# (`standing[inv.name] = stood`) inside the roster loop, and a subscript store
|
||||
# is not a binding 12j follows. So `standing` never enters its taint set at all,
|
||||
# and `sum(standing.values())` is not a roster site. Run 12j on a tree carrying
|
||||
# the `[0]` mutation and it enumerates main.py:1553, 1555 and 1690 - the three
|
||||
# `self.inverters` sites around this code - and NOT 1599. A structural pin
|
||||
# cannot see this line. Only the behavioural fixture below can.
|
||||
#
|
||||
# Identical shape, different meaning - which is precisely the blind spot
|
||||
# SAFETY-32 disclosed in its own words ("SEMANTICS. `min` -> `max` -> the mean
|
||||
# are edits invisible to any shape pin, permanently"). No shape pin will ever
|
||||
# close them. Only a behavioural fixture can, and this is it.
|
||||
#
|
||||
# ⚠️ WHY PART 5 ABOVE CANNOT SEE THEM, AND IT IS THIS REPOSITORY'S RECURRING
|
||||
# DEFECT INSIDE THE SITE HALF OF THE VERY CARD THAT NAMES IT. `_sign_site` is
|
||||
# the only N>=2 site fixture in the suite and it runs at
|
||||
# `site_sign_disagree_w: 320` with shares of about -2500 W EACH - every one of
|
||||
# them individually far OVER the gate. On values like those the sum, the mean
|
||||
# and the largest share all clear it, so all three reductions agree, and a
|
||||
# check whose two candidate answers happen to be EQUAL cannot observe a change
|
||||
# that swaps them. MUTATION.md habit 4. At a roster of ONE it is worse: there
|
||||
# `sum == mean == max == the one share` identically, so every mutation is
|
||||
# EQUIVALENT there - see 9c, where that identity is asserted rather than
|
||||
# assumed.
|
||||
#
|
||||
# ⚠️ SO THE NUMBERS BELOW ARE CHOSEN TO DISCRIMINATE, AND HERE IS THE
|
||||
# ARITHMETIC THEY ARE CHOSEN FOR. Two simulated units rated 220 W and 280 W
|
||||
# stand at their own rails, so `standing` is {-220, -280} in roster order:
|
||||
#
|
||||
# sum -500 W |500| > 300 OVER the gate -> TRIPS
|
||||
# mean -250 W |250| < 300 under it -> silent
|
||||
# max(..., key=abs) -280 W |280| < 300 under it -> silent
|
||||
# list(...)[0] -220 W |220| < 300 under it -> silent
|
||||
#
|
||||
# FOUR DISTINCT MAGNITUDES - 500, 250, 280, 220 - of which exactly ONE clears
|
||||
# the SHIPPED 300 W gate. The asymmetry is load-bearing twice over: equal
|
||||
# shares would collapse mean, max and first onto one number and the fixture
|
||||
# could not tell them apart, and putting the SMALLER unit first is what keeps
|
||||
# `[0]` distinct from `max(key=abs)`.
|
||||
#
|
||||
# ⚠️ THE RATINGS ARE SIMULATED AND ARE NOT ANY REAL INVERTER'S. 220 W and 280 W
|
||||
# are chosen for the arithmetic in that table and for nothing else. In
|
||||
# particular they are NOT inverter B, which does not physically exist; B's
|
||||
# rating is 4600 W FROM ITS DATASHEET (goodwe/NOTES.md 1.1b) and no number in
|
||||
# this section is a measurement of anything.
|
||||
#
|
||||
# ⚠️ THE GATE IS THE SHIPPED ONE AND THE HOLD IS NOT. `site_sign_disagree_w` is
|
||||
# left at its default so this fixture is graded against the magnitude an
|
||||
# install actually runs; `site_sign_disagree_s` is shortened from 325 s to 2 s
|
||||
# because the hold is a wall clock and the campaign behind 325 s is measured on
|
||||
# the rig, not here. The gate is the coordinate this section is about.
|
||||
from app import peakguard as _pg38 # noqa: E402
|
||||
from app.freeze import rail_w as _rail_w38 # noqa: E402
|
||||
from app.p1 import P1Sample as _P1Sample38 # noqa: E402
|
||||
import time as _time38 # noqa: E402
|
||||
|
||||
_S38_A_W, _S38_B_W = 220.0, 280.0 # SIMULATED. See the note above.
|
||||
_S38_LOAD_W = 2000.0
|
||||
_S38_FROZEN_METER_W = 2500.0
|
||||
|
||||
|
||||
def _s38_roster(*ratings):
|
||||
"""A CFG-04 `inverters:` roster, which is the shape an N>=2 install runs.
|
||||
|
||||
⚠️ NOT `_sign_site`'s "build one from the flat keys and append a second".
|
||||
`Controller.__init__` builds `Tuning` FROM the roster - `max_w=site_max_w` -
|
||||
so a unit appended afterwards leaves the site loop bounded at ONE unit's
|
||||
rating. That is harmless where part 3 uses it and fatal here: the whole
|
||||
fixture is a site command standing at the ROSTER's rail.
|
||||
"""
|
||||
return [{"name": name, "setpoint_entity": f"number.sp_{tag}",
|
||||
"soc_entity": f"sensor.soc_{tag}", "batt_entity": f"sensor.batt_{tag}",
|
||||
"max_w": w, "bms_soc_floor": 10}
|
||||
for name, tag, w in zip(("inverter-a", "inverter-b", "inverter-c"),
|
||||
("a", "b", "c"), ratings)]
|
||||
|
||||
|
||||
class _S38Plant:
|
||||
"""Units that obey INSTANTLY and a meter that is `load - sum(measured)`.
|
||||
|
||||
The same modelling choice part 3 makes and for the same reason: an instant
|
||||
plant removes the one mechanism that could make an obedient unit look
|
||||
disobedient, so what remains to be graded is the ATTRIBUTION.
|
||||
"""
|
||||
|
||||
def __init__(self, load_w=_S38_LOAD_W):
|
||||
self.load_w = float(load_w)
|
||||
self.writes = []
|
||||
self.calls = []
|
||||
self.ctrl = None
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
self.calls.append((domain, service, data))
|
||||
|
||||
async def limits(self, entity):
|
||||
return None
|
||||
|
||||
def measured(self):
|
||||
if self.ctrl is None:
|
||||
return []
|
||||
return [float((i.last_write if i.last_write is not None else i.target) or 0.0)
|
||||
for i in self.ctrl.inverters]
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
m = self.measured()
|
||||
if entity == "sensor.grid":
|
||||
return self.load_w - sum(m)
|
||||
for i, tag in enumerate(("a", "b", "c")):
|
||||
if entity == f"sensor.soc_{tag}":
|
||||
return 50.0
|
||||
if entity == f"sensor.batt_{tag}":
|
||||
return m[i] if i < len(m) else None
|
||||
return None
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((entity, float(value)))
|
||||
return True
|
||||
|
||||
|
||||
def _s38_ctrl(hass, ratings, store=None, **over):
|
||||
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
|
||||
"inverters": _s38_roster(*ratings),
|
||||
"gain": -0.6,
|
||||
# The per-unit pair, distinct from the site pair in BOTH
|
||||
# coordinates, for part 3's reason: an equality below must be able
|
||||
# to fail on the magnitude alone or on the hold alone.
|
||||
"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 1,
|
||||
# ⚠️ `site_sign_disagree_w` DELIBERATELY ABSENT - the shipped 300 W
|
||||
# default is the gate this whole section is graded against.
|
||||
"site_sign_disagree_s": 2,
|
||||
# SAFETY-31 is given a long window so the SITE is not stopped by
|
||||
# circulation while this section grades the sign attribution. It is
|
||||
# isolation, not a claim that SAFETY-31 is silent here.
|
||||
"circulation_hold_s": 3600, "auto_start": True}
|
||||
opts.update(over)
|
||||
c = Controller(opts, hass, store if store is not None else _MemStore(),
|
||||
_P(), stop=asyncio.Event())
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
|
||||
c.auto = True
|
||||
hass.ctrl = c
|
||||
return c
|
||||
|
||||
|
||||
def _s38_stood(c):
|
||||
return [inv.last_write if inv.last_write is not None else inv.target
|
||||
for inv in c.inverters]
|
||||
|
||||
|
||||
print()
|
||||
print("9a. the SITE sign detector: per-unit shares EACH BELOW the gate whose")
|
||||
print(" SUM clears it - the case the site detector exists for")
|
||||
|
||||
|
||||
async def _s38_site():
|
||||
hass = _S38Plant()
|
||||
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
|
||||
seen = []
|
||||
for _ in range(20):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
seen.append(dict(grid=c.grid, stood=_s38_stood(c),
|
||||
meas=[i.batt for i in c.inverters],
|
||||
site=c.site_sign_wd.tripped,
|
||||
units=[i.sign_wd.tripped for i in c.inverters],
|
||||
following=c.following))
|
||||
await asyncio.sleep(0.2)
|
||||
return c, seen
|
||||
|
||||
|
||||
_c38, _s38seen = asyncio.run(_s38_site())
|
||||
# The last sample BEFORE the trip, named rather than indexed - part 5's lesson.
|
||||
# After the trip every command is 0 W, so a fixed index reads the REMEDY.
|
||||
_pre38 = [x for x in _s38seen if not x["site"]]
|
||||
_mid38 = _pre38[-1]
|
||||
_GATE38 = _c38.site_sign_wd.cfg.min_w
|
||||
_share38 = list(_mid38["stood"])
|
||||
_red38 = {"sum": sum(_share38),
|
||||
"mean": sum(_share38) / max(1, len(_share38)),
|
||||
"max(key=abs)": max(_share38, key=abs),
|
||||
"[0]": _share38[0]}
|
||||
print(f" last cycle before the trip: stood "
|
||||
f"{[round(x) for x in _share38]} measured "
|
||||
f"{[round(x) for x in _mid38['meas']]} meter {_mid38['grid']:+.0f} W")
|
||||
print(f" site gate {_GATE38:.0f} W (SHIPPED default), hold "
|
||||
f"{_c38.site_sign_wd.cfg.hold_s:.0f} s")
|
||||
for _k, _v in _red38.items():
|
||||
print(f" {_k:>13} = {_v:+7.1f} W "
|
||||
f"{'OVER' if abs(_v) > _GATE38 else 'under'} the gate")
|
||||
check("PRECONDITION: the gate really is the SHIPPED 300 W, so this fixture is "
|
||||
"graded against the magnitude an install runs and not against a number "
|
||||
"chosen to make it pass",
|
||||
_GATE38 == float(SIGN_OPTION_DEFAULTS["site_sign_disagree_w"]) == 300.0)
|
||||
check("⚠️ PRECONDITION, AND IT IS THE WHOLE OF SAFETY-38. EVERY per-unit share "
|
||||
"is BELOW the site gate and their SUM is OVER it. `_sign_site` above "
|
||||
"cannot make this statement - its shares are each individually over the "
|
||||
"gate, so sum, mean and largest-share all agree there and its values "
|
||||
"cannot observe a change that swaps them",
|
||||
len(_share38) >= 2
|
||||
and all(abs(s) < _GATE38 for s in _share38)
|
||||
and abs(sum(_share38)) > _GATE38)
|
||||
check("...and the FOUR candidate reductions give FOUR DISTINCT answers on it, "
|
||||
"of which exactly ONE clears the gate. That is what makes this fixture "
|
||||
"able to tell `sum` from the mean, from the largest share and from the "
|
||||
"first share - and it is the property the existing site fixture lacks",
|
||||
len({round(v, 6) for v in _red38.values()}) == 4
|
||||
and [k for k, v in _red38.items() if abs(v) > _GATE38] == ["sum"])
|
||||
check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
|
||||
"agrees in sign with its own share - and the site is charging into an "
|
||||
"importing house. Without this the trip below could be a per-unit fault "
|
||||
"in disguise rather than the site fault this grades",
|
||||
all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"]))
|
||||
and sum(_mid38["stood"]) * _mid38["grid"] < 0
|
||||
and _mid38["following"] is True)
|
||||
check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. "
|
||||
"Under ALL THREE semantic mutations - `sum -> mean`, "
|
||||
"`sum -> max(key=abs)` AND `sum -> list(...)[0]` - NOTHING trips, ever: "
|
||||
"the reviewer's tree was still commanding -500 W of charge into a "
|
||||
"+2500 W import at t = 595 s with following=True. This is the check all "
|
||||
"three go red on. `[0]` is here BECAUSE IT IS NOT COVERED ELSEWHERE - "
|
||||
"see the section header for the mis-measurement that said it was",
|
||||
_c38.site_sign_wd.tripped
|
||||
and not any(i.sign_wd.tripped for i in _c38.inverters))
|
||||
print(f" {_c38.site_sign_wd.reason!r}")
|
||||
check("⚠️ ...and the sentence an operator reads carries THE SUM IN THE "
|
||||
"COMMANDED SLOT AND THE METER IN THE METER SLOT - the ORDERED PAIR, not "
|
||||
"two numbers loose in a sentence. ROUND 1 ASSERTED `f'{sum(...):.0f} W' "
|
||||
"in reason`, i.e. that '-500 W' appears SOMEWHERE, AND THAT WAS A HOLE "
|
||||
"ONE LINE WIDE. Swapping the detector's first two arguments - "
|
||||
"`evaluate(site_standing, self.grid, ...)` -> "
|
||||
"`evaluate(self.grid, site_standing, ...)`, a ZERO-BYTE edit at "
|
||||
"main.py:1620 - leaves BOTH numbers in the string and moves each into "
|
||||
"the other's slot, so the substring test still passed and the whole "
|
||||
"suite stayed green. The swapped banner reads 'commanded +2500 W "
|
||||
"against a meter reading -500 W': the site is standing at -500 W of "
|
||||
"charge INTO a +2500 W import and the latching, site-stopping banner "
|
||||
"tells the operator the exact opposite, on the one line somebody woken "
|
||||
"by this alarm reads first. watchdog.py:658-665 calls naming the wrong "
|
||||
"instrument there 'a false statement about which instrument produced "
|
||||
"the evidence'. A check whose data cannot reach the failure it names is "
|
||||
"MUTATION.md's own defect; this is the line that closes it",
|
||||
f"commanded {sum(_share38):+.0f} W against a meter reading "
|
||||
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason)
|
||||
check("...and it stops the WHOLE site, through the `sign_site` claim by name "
|
||||
"rather than by some other guard happening to stop it",
|
||||
_c38.target == 0.0 and "sign_site" in _c38.reason
|
||||
and all(i.target == 0.0 for i in _c38.inverters)
|
||||
and _c38.store.data.get("site_sign_tripped") is True)
|
||||
# ⚠️ THE COUNTERFACTUAL, and it is what says the check above is produced by the
|
||||
# SUM crossing the gate rather than by two inverters being present. Same plant,
|
||||
# same roster shape, same everything - except the units are rated so that the
|
||||
# sum lands UNDER the gate. Nothing may trip.
|
||||
_S38_SMALL = (120.0, 130.0) # sum 250 W: under the 300 W gate
|
||||
|
||||
|
||||
async def _s38_under():
|
||||
hass = _S38Plant()
|
||||
c = _s38_ctrl(hass, _S38_SMALL)
|
||||
for _ in range(20):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await asyncio.sleep(0.2)
|
||||
return c
|
||||
|
||||
|
||||
_c38u = asyncio.run(_s38_under())
|
||||
print(f" counterfactual: stood {[round(x) for x in _s38_stood(_c38u)]} "
|
||||
f"sum {sum(_s38_stood(_c38u)):+.0f} W meter {_c38u.grid:+.0f} W")
|
||||
check("...and the counterfactual: the SAME inverted-gain site whose shares sum "
|
||||
"to 250 W - under the gate - does NOT trip, over the same 20 cycles. So "
|
||||
"the trip above is the sum crossing 300 W and not the mere presence of a "
|
||||
"second inverter",
|
||||
abs(sum(_s38_stood(_c38u))) < _GATE38
|
||||
and not _c38u.site_sign_wd.tripped
|
||||
and not any(i.sign_wd.tripped for i in _c38u.inverters))
|
||||
# ⚠️ AND A SECOND COUNTERFACTUAL, WHICH EXISTS BECAUSE OF A MUTATION THE CHECKS
|
||||
# ABOVE COULD NOT SEE AND THIS SEARCH WENT LOOKING FOR.
|
||||
# `site_standing = float(self.target)` - the arbiter's SITE TARGET instead of
|
||||
# the sum of what actually stands on the wire - is -11 bytes, and it survived
|
||||
# every check above AND the whole 20-file suite, because in every fixture so
|
||||
# far the two are the same number. THEY ARE NOT THE SAME NUMBER when a setpoint
|
||||
# device's own reported range is narrower than its `max_w`: the `device`
|
||||
# P_SAFETY claim is filed PER ACTUATOR (main.py, `actuator_of("device", inv)`),
|
||||
# so it bounds each unit's SHARE and never the site target, and
|
||||
# `clamp_to_rating()` clips again on the way out. Here the site asks for -500 W
|
||||
# and -250 W stands.
|
||||
#
|
||||
# WHICH ONE THE GUARDS MEAN IS ALREADY WRITTEN DOWN, IN BOTH OF THEM: SAFETY-02
|
||||
# grades "the command standing on the wire", and `looks_frozen()`'s docstring
|
||||
# says `commanded_w` is "the EMITTED setpoint - what the arbiter resolved and
|
||||
# `write_setpoint()` put on the wire - not the loop's raw wish". SAFETY-20 is
|
||||
# the whole card about the difference. This check is those sentences made to
|
||||
# bite: on the site target the detector fires on a site that is standing at
|
||||
# -250 W against a +2250 W meter - under its own 300 W gate - which is a
|
||||
# LATCHING, site-stopping false trip on an install whose inverters simply
|
||||
# report a narrower range than the operator typed.
|
||||
_S38_DEV = (120.0, 130.0) # narrower than the 220/280 W ratings
|
||||
|
||||
|
||||
async def _s38_devclamped():
|
||||
hass = _S38Plant()
|
||||
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
|
||||
for inv, d in zip(c.inverters, _S38_DEV):
|
||||
inv.dev_min, inv.dev_max = -d, d
|
||||
for _ in range(20):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await asyncio.sleep(0.2)
|
||||
return c
|
||||
|
||||
|
||||
_c38d = asyncio.run(_s38_devclamped())
|
||||
_stood38d = _s38_stood(_c38d)
|
||||
print(f" device-clamped: site target {_c38d.target:+.0f} W but only "
|
||||
f"{[round(x) for x in _stood38d]} = {sum(_stood38d):+.0f} W stands, "
|
||||
f"meter {_c38d.grid:+.0f} W")
|
||||
check("PRECONDITION: the site TARGET and the EMITTED total are DIFFERENT "
|
||||
"numbers here, on opposite sides of the gate - -500 W asked, -250 W "
|
||||
"standing. Without that separation this check and the one above compare "
|
||||
"two values that happen to be equal, which is the defect this card is "
|
||||
"the sixth instance of",
|
||||
abs(_c38d.target) > _GATE38 and abs(sum(_stood38d)) < _GATE38)
|
||||
check("⚠️ ...and NOTHING TRIPS, because the quantity graded is what STANDS ON "
|
||||
"THE WIRE and not what the arbiter asked for. `float(self.target)` is "
|
||||
"-11 bytes, survives the other 20 files, and turns this ordinary "
|
||||
"narrow-rating install into a latching site-wide false trip",
|
||||
not _c38d.site_sign_wd.tripped
|
||||
and not any(i.sign_wd.tripped for i in _c38d.inverters)
|
||||
and _c38d.target != 0.0)
|
||||
|
||||
|
||||
print()
|
||||
print("9b. THE SECOND CONSUMER: the same value is SAFETY-18's `commanded_w`")
|
||||
print(" inside `looks_frozen()`, and both mutations blind it too")
|
||||
# ⚠️ THIS IS THE AC THE CARD REFUSES TO LET BE SCOPED OUT SILENTLY.
|
||||
# `site_standing` is handed to `looks_frozen()` at main.py's `peakguard.decide(
|
||||
# ..., frozen=...)` call, where it is the EMITTED command graded against the
|
||||
# site rail. Both surviving mutations shrink it there too, and nothing went red.
|
||||
#
|
||||
# THE RAIL ARITHMETIC, and it is the same discrimination as 9a at a different
|
||||
# threshold: the site rail is `RAIL_FRAC * site_max_w` = 0.95 * 500 = 475 W.
|
||||
#
|
||||
# sum 500 W >= 475 RAILED -> BLIND_FROZEN, floor 0 W
|
||||
# mean 250 W < 475 silent
|
||||
# max(..., key=abs) 280 W < 475 silent
|
||||
# list(...)[0] 220 W < 475 silent
|
||||
#
|
||||
# ⚠️ THE SIGN IS FLIPPED FROM 9a ON PURPOSE. Here the gain is the SHIPPED
|
||||
# POSITIVE one and the frozen meter reads IMPORT, so the site discharges +500 W
|
||||
# against a +2500 W import - the two agree in sign, SAFETY-30 has nothing to
|
||||
# say, and this section grades SAFETY-18 alone. Reusing 9a's inverted gain
|
||||
# would have the site sign latch park everything at 0 W and the freeze
|
||||
# conjunction could then never be true: a check measuring the other guard's
|
||||
# remedy.
|
||||
#
|
||||
# WHAT THE MUTATIONS COST HERE, measured rather than asserted: on the shipped
|
||||
# tree the guard goes blind and files `floor_w = 0.0` - "the battery may not
|
||||
# import while the billed quarter cannot be measured". Under both mutations it
|
||||
# is NOT BLIND AT ALL and files `floor_w = None`, so the site may go on
|
||||
# charging through a quarter whose billed average is unobservable. That is the
|
||||
# capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
|
||||
|
||||
|
||||
class _S38Src:
|
||||
"""The transport's own `unchanged_s`, which only homewizard_local has."""
|
||||
|
||||
def __init__(self, unchanged_s):
|
||||
self.unchanged_s = float(unchanged_s)
|
||||
|
||||
|
||||
def _s38_feed(c, w, ts=None, mono=None):
|
||||
now = ts if ts is not None else datetime.now(timezone.utc)
|
||||
c.p1.submit(_P1Sample38(
|
||||
ingest_ts=now,
|
||||
ingest_mono=mono if mono is not None else _time38.monotonic(),
|
||||
telegram_ts=now, source="homewizard_local",
|
||||
import_w=max(w, 0.0), export_w=max(-w, 0.0), net_w=w,
|
||||
per_phase_w=(w,), per_phase_import_w=(max(w, 0.0),)))
|
||||
|
||||
|
||||
def _s38_seed_quarter(c, w):
|
||||
"""Backfill the OPEN quarter with the same stuck value, at 10 s spacing.
|
||||
|
||||
⚠️ THIS IS NOT DECORATION. Without it the open block is mostly uncovered and
|
||||
`blindness()` answers BLIND_COVERAGE - which is still blind, still floor
|
||||
0 W, and would therefore MASK the mutations: the mutant would reach the
|
||||
same action by a different route and a check on the action alone could not
|
||||
see the difference it names. Seeded, the mutant is not blind at ALL, which
|
||||
is the honest measurement of what the mutation costs. A meter that has been
|
||||
stuck at the same reading all quarter is also what SAFETY-18 is about.
|
||||
"""
|
||||
wall = _time38.time()
|
||||
q0 = (int(wall) // 900) * 900
|
||||
mono0 = _time38.monotonic() - (wall - q0)
|
||||
t = float(q0)
|
||||
while t < wall:
|
||||
_s38_feed(c, w, datetime.fromtimestamp(t, timezone.utc), mono0 + (t - q0))
|
||||
t += 10.0
|
||||
|
||||
|
||||
def _s38_frozen_ctrl(hass, ratings, unchanged_s=600.0, gain=0.05):
|
||||
# ⚠️ `gain` 0.05 IS THE SCHEMA FLOOR AND IT IS HERE TO BUY A RAMP. At the
|
||||
# shipped 0.6 the loop is at the rail on cycle 0 and the fixture has no
|
||||
# BELOW-the-rail cycles to use as its counterfactual. At 0.05 the site
|
||||
# climbs 125 W a cycle and crosses 475 W part way through the run, so the
|
||||
# SAME run carries both answers.
|
||||
c = _s38_ctrl(hass, ratings, meter_source="homewizard_local",
|
||||
p1_host="meter.local", gain=gain)
|
||||
c.p1_source = _S38Src(unchanged_s)
|
||||
return c
|
||||
|
||||
|
||||
# ⚠️ `n` IS 20 AND WAS 8 (SAFETY-39). The comment on `_s38_frozen_ctrl` says
|
||||
# gain 0.05 is there "to buy a ramp" so that the SAME run carries both a
|
||||
# below-the-rail and an at-the-rail answer. At the shipped rail-following
|
||||
# `slew_w` the ramp is now the SLEW - 55 W a cycle against this 500 W site,
|
||||
# where gain 0.05 alone gave 125 - so eight cycles no longer reach the rail and
|
||||
# the run carried only the counterfactual. The fixture needs the cycles, not a
|
||||
# different tuning: the point is a run that crosses the rail part way through.
|
||||
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=20):
|
||||
hass = _S38Plant()
|
||||
c = _s38_frozen_ctrl(hass, ratings)
|
||||
_s38_seed_quarter(c, meter_w)
|
||||
seen = []
|
||||
for _ in range(n):
|
||||
_s38_feed(c, meter_w)
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
pg = c.peak_guard
|
||||
seen.append(dict(stood=_s38_stood(c),
|
||||
blind=None if pg is None else pg.blind,
|
||||
floor=None if pg is None else pg.floor_w))
|
||||
await asyncio.sleep(0.05)
|
||||
return c, seen
|
||||
|
||||
|
||||
_c38f, _s38f = asyncio.run(_s38_frozen((_S38_A_W, _S38_B_W)))
|
||||
_RAIL38 = _rail_w38(_c38f.site_max_w,
|
||||
dev_min=_c38f.site_dev_bound("dev_min"),
|
||||
dev_max=_c38f.site_dev_bound("dev_max"))
|
||||
_last38 = _s38f[-1]
|
||||
_below38 = [x for x in _s38f if abs(sum(x["stood"])) < _RAIL38]
|
||||
print(f" site rail {_RAIL38:.0f} W of site_max_w {_c38f.site_max_w:.0f} W; "
|
||||
f"meter stuck at {_S38_FROZEN_METER_W:+.0f} W for "
|
||||
f"{_c38f.p1_source.unchanged_s:.0f} s (freeze hold "
|
||||
f"{_c38f.freeze_wd.cfg.hold_s:.0f} s)")
|
||||
for _x in _s38f:
|
||||
print(" stood %-16s sum %+7.1f W floor=%s blind=%r"
|
||||
% ([round(v) for v in _x["stood"]], sum(_x["stood"]),
|
||||
_x["floor"], _x["blind"]))
|
||||
check("PRECONDITION: at the rail EVERY per-unit share is BELOW the site rail "
|
||||
"and their SUM is at or over it - the same discrimination as 9a, at "
|
||||
"SAFETY-18's threshold instead of SAFETY-30's. `[0]`, the mean and the "
|
||||
"largest share are 220, 250 and 280 W against a 475 W rail",
|
||||
len(_last38["stood"]) >= 2
|
||||
and all(abs(s) < _RAIL38 for s in _last38["stood"])
|
||||
and abs(sum(_last38["stood"])) >= _RAIL38
|
||||
and len({round(v, 6) for v in
|
||||
(sum(_last38["stood"]),
|
||||
sum(_last38["stood"]) / len(_last38["stood"]),
|
||||
max(_last38["stood"], key=abs),
|
||||
_last38["stood"][0])}) == 4)
|
||||
check("⚠️ THE PEAK GUARD GOES BLIND ON SAFETY-18's CONJUNCTION and forbids "
|
||||
"charging - floor 0 W - because the SITE's total command is at the rail "
|
||||
"while the meter has stopped moving. Under `sum -> mean` and "
|
||||
"`sum -> max(key=abs)` it is not blind at all and files no floor, so the "
|
||||
"battery may keep importing through an unmeasurable billed quarter. This "
|
||||
"is the second consumer, and it is covered rather than scoped out",
|
||||
_last38["blind"] == _pg38.BLIND_FROZEN and _last38["floor"] == 0.0)
|
||||
check("...and the counterfactual, from the SAME run: on the earlier cycles the "
|
||||
"site command had not yet reached the rail, and the guard did NOT report "
|
||||
"SAFETY-18 - with the meter equally frozen the whole time. So the "
|
||||
"verdict is produced by the COMMAND MAGNITUDE `site_standing` carries, "
|
||||
"not by `unchanged_s` alone",
|
||||
len(_below38) >= 2
|
||||
and all(x["blind"] != _pg38.BLIND_FROZEN for x in _below38))
|
||||
|
||||
|
||||
print()
|
||||
print("9c. AT ONE INVERTER NOTHING CHANGES, and 'nothing' is asserted")
|
||||
# ⚠️ THE REASON THIS SUBSECTION EXISTS. At a roster of one `standing` has a
|
||||
# single entry, so `sum`, the mean, `max(key=abs)` and `[0]` are THE SAME
|
||||
# NUMBER: all three mutations are genuinely EQUIVALENT there and no fixture at
|
||||
# N=1 can ever separate them. That is a fact about the arithmetic, and leaving
|
||||
# it implicit is how "the shipped install is covered" gets believed. It is
|
||||
# asserted here, on a real one-inverter controller's own standing command, so
|
||||
# the claim rests on the roster rather than on the reader.
|
||||
_S38_ONE = (500.0,)
|
||||
|
||||
|
||||
async def _s38_one():
|
||||
hass = _S38Plant()
|
||||
c = _s38_frozen_ctrl(hass, _S38_ONE)
|
||||
_s38_seed_quarter(c, _S38_FROZEN_METER_W)
|
||||
for _ in range(20): # SAFETY-39: the slew is the ramp now - see _s38_frozen
|
||||
_s38_feed(c, _S38_FROZEN_METER_W)
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await asyncio.sleep(0.05)
|
||||
return c
|
||||
|
||||
|
||||
_c38one = asyncio.run(_s38_one())
|
||||
_one38 = _s38_stood(_c38one)
|
||||
_onered38 = (sum(_one38), sum(_one38) / max(1, len(_one38)),
|
||||
max(_one38, key=abs), _one38[0])
|
||||
print(f" roster of {len(_c38one.inverters)}: stood "
|
||||
f"{[round(v) for v in _one38]} -> sum/mean/max/first = "
|
||||
f"{tuple(round(v, 1) for v in _onered38)}")
|
||||
check("at a roster of ONE the four reductions are ONE NUMBER - so all three "
|
||||
"mutations of `site_standing` are EQUIVALENT on a shipped install and no "
|
||||
"N=1 fixture could ever tell them apart. That is why this card needed a "
|
||||
"roster of two, stated as an assertion rather than left to the reader",
|
||||
len(_c38one.inverters) == 1
|
||||
and len({round(v, 6) for v in _onered38}) == 1)
|
||||
check("...and the one-inverter site still reaches SAFETY-18's verdict on that "
|
||||
"same number, unchanged: the command is at the rail, the meter has "
|
||||
"stopped moving, the guard is blind and forbids charging",
|
||||
_c38one.peak_guard is not None
|
||||
and _c38one.peak_guard.blind == _pg38.BLIND_FROZEN
|
||||
and _c38one.peak_guard.floor_w == 0.0
|
||||
and abs(sum(_one38)) >= _rail_w38(
|
||||
_c38one.site_max_w,
|
||||
dev_min=_c38one.site_dev_bound("dev_min"),
|
||||
dev_max=_c38one.site_dev_bound("dev_max")))
|
||||
check("...and the SITE sign detector is still absent at a roster of one - part "
|
||||
"7 counts the calls; this only records that a fixture built the other "
|
||||
"way round, from an `inverters:` roster, reaches the same state",
|
||||
not _c38one.site_sign_wd.tripped
|
||||
and _c38one.site_sign_wd.disagree_since is None)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-39's evidence, on admin/goodwe's OWN plant model.
|
||||
|
||||
python3 tools/slew_sim.py --repro # ⭐ the 2026-09-05 excursion, replayed
|
||||
python3 tools/slew_sim.py --slew # the derivation: slew against the rail
|
||||
python3 tools/slew_sim.py --gain # what the slew does NOT own
|
||||
python3 tools/slew_sim.py --roster # N=1 -> N=2, and the rail that moved
|
||||
python3 tools/slew_sim.py --all
|
||||
|
||||
WHY THIS IS A SCRIPT AND NOT A `test_*.py`
|
||||
==========================================
|
||||
Byte for byte tools/alloc_sim.py's reason, and it has not changed: this needs
|
||||
admin/goodwe checked out beside this repository for `plantsim.Plant`, and the
|
||||
add-on's CI job has no credentials for that repository - it is private and the
|
||||
job's token is minted for this one. A `test_*.py` here is globbed and RUN by
|
||||
.gitea/workflows/checks.yml, so a file that needs the sibling repo would either
|
||||
fail the gate or - worse - pass SILENTLY having checked nothing.
|
||||
|
||||
So the rig evidence lives here and is quoted in the pull request and in
|
||||
DECISIONS/SAFETY-39-slew-against-the-rail.md, and test_control.py's SAFETY-39
|
||||
section grades the same properties in CI against a plant it models itself and
|
||||
says it models. The two plants are INDEPENDENT - a first-order lag written in
|
||||
test_control.py versus `plantsim.Plant`'s ramp-plus-lag with an efficiency and
|
||||
a SoC integrator - and they agree on every sign and rank below, which is worth
|
||||
more than either alone.
|
||||
|
||||
WHAT IS REAL IN HERE
|
||||
====================
|
||||
* the control law - `compute()` and `Tuning`, imported out of THIS checkout,
|
||||
never transcribed.
|
||||
* the wire - every command goes out through `frame_check.setpoint_atomic()`,
|
||||
the firmware's own encoder, and back in through `plantsim.parse`/`decode`,
|
||||
because `Inverter.command()` is what drives the modelled unit.
|
||||
* the plant - `plantsim.Plant`, the model the rig uses.
|
||||
|
||||
MODELLED, and therefore not evidence about the thing modelled:
|
||||
* the meter. `grid = load - pv - sum(battery)` with no poll delay and no
|
||||
noise, sampled once per tick. The real P1 is a poll with its own age.
|
||||
* the tick. 2.0 s, which is what the live install was MEASURED running on
|
||||
2026-09-05; it is a parameter here (`--tick`) and not a constant, because
|
||||
the whole of this card is that the derivation depends on the regime.
|
||||
|
||||
⚠️ WHAT THIS CANNOT SHOW, SAID BEFORE THE RESULTS RATHER THAN AFTER THEM
|
||||
=======================================================================
|
||||
THE -5154 W BATTERY EXCURSION IS NOT REPRODUCIBLE HERE, AND NOT BECAUSE THE
|
||||
TUNING IS WRONG. `plantsim.Plant.limit()` clamps the plant at `p_max`, so the
|
||||
modelled battery cannot exceed its rating by one watt at any tuning, on any
|
||||
step, ever. Neither can test_control.py's model. An acceptance criterion of
|
||||
"battery power no longer exceeds max_w in simulation" would therefore be
|
||||
UNFALSIFIABLE - it passes on the broken configuration too - so no criterion in
|
||||
this card is written on it, and the ticket's own AC to that effect is the one
|
||||
that had to be rewritten rather than met.
|
||||
|
||||
What IS reproducible, and is what everything below measures:
|
||||
* the setpoint stepping a full `slew_w` per tick, over and over (the live
|
||||
symptom that escalated 2 -> 1 -> 13 -> 43 large steps per 30 min);
|
||||
* the loop OVER-ABSORBING - taking more than the site needed - and driving
|
||||
the meter from EXPORT into IMPORT, i.e. manufacturing the error it chases.
|
||||
|
||||
⚠️ INVERTER B DOES NOT EXIST. `plantsim.INVERTER_B` is inverter A's numbers
|
||||
COPIED, `measured=False`. Every N=2 row below is therefore a result about TWO
|
||||
COPIES OF A on a 10000 W rail, NOT about the ~9600 W site that B's datasheet
|
||||
and serial imply (goodwe/NOTES.md §1.1b; B is a GW5048D-ES rated 4600 W, and
|
||||
that figure may itself be DC). Each such row prints the caveat with it.
|
||||
"""
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ADDON = HERE.parent.parent # the goodwe-addon checkout root
|
||||
|
||||
|
||||
def find_sim():
|
||||
"""admin/goodwe's `sim/` directory, or None - and the caller says which.
|
||||
|
||||
$GOODWE_SIM first, the same override test_freeze.py and alloc_sim.py take,
|
||||
then the sibling checkouts that actually occur. A missing sibling is not a
|
||||
finding about the control law and must never be reported as one.
|
||||
"""
|
||||
cands = []
|
||||
if os.environ.get("GOODWE_SIM"):
|
||||
cands.append(pathlib.Path(os.environ["GOODWE_SIM"]))
|
||||
for up in (ADDON.parent, ADDON.parent.parent):
|
||||
cands += [up / "goodwe" / "sim", up / "goodwe-session" / "goodwe" / "sim"]
|
||||
for c in cands:
|
||||
if (c / "plantsim.py").exists():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def load(sim):
|
||||
sys.path[:0] = [str(sim), str(sim.parent / "master")]
|
||||
os.environ.setdefault("GOODWE_ADDON_REPO", str(ADDON))
|
||||
sys.path[:0] = [str(ADDON / "goodwe_controller")]
|
||||
import plantsim # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
return plantsim, control
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# one experiment
|
||||
|
||||
|
||||
def step_run(plantsim, control, *, gain, slew_w, rail_w, n=1, tick_s=2.0,
|
||||
export_w=3385.0, load_w=350.0, phase=5, cycles=300, dt=0.05,
|
||||
lag_s=None):
|
||||
"""One export step onto a converged loop, through the real wire.
|
||||
|
||||
`rail_w` is the SITE rail - what `main.py` hands `compute()` as `max_w`
|
||||
(`Controller.site_max_w`, the roster sum). Each of the `n` modelled units
|
||||
is rated `rail_w / n` and is commanded its even share, which is the
|
||||
arrangement ARCH-01's split-invariance says any valid split is equivalent
|
||||
to at the meter.
|
||||
"""
|
||||
over = {} if lag_s is None else dict(lag_s=lag_s)
|
||||
spec = dict(plantsim.INVERTER_A, rating_w=rail_w / n, **over)
|
||||
invs = [plantsim.Inverter.from_spec(name, spec) for name in "AB"[:n]]
|
||||
site = plantsim.Site(invs, load=load_w, pv=0.0)
|
||||
tuning = control.Tuning(gain=gain, max_w=rail_w, slew_w=slew_w,
|
||||
target_grid_w=-10.0)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
worst_import, peak, biggest, last = -1e9, 0.0, 0.0, None
|
||||
for c in range(cycles):
|
||||
if c == phase:
|
||||
site.pv = export_w + load_w
|
||||
site.grid = site.load - site.pv - site.battery
|
||||
# ⚠️ ONE UNIT'S POWER, NOT THE SITE'S SUM, at n > 1. The shipped add-on
|
||||
# binds a single `inverter_power` sensor, so a two-inverter install has
|
||||
# a loop that sees unit A only. sitesim.run_site() makes the same choice
|
||||
# and says at length why feeding the sum would be a KINDER rig than the
|
||||
# deployment.
|
||||
actual = invs[0].plant.actual
|
||||
d = control.compute(prev, site.grid, actual, tuning, sat, i_w)
|
||||
if c >= phase and last is not None:
|
||||
biggest = max(biggest, abs(d.target_w - last))
|
||||
last = d.target_w
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
for inv in invs:
|
||||
inv.command(d.target_w / n)
|
||||
site.settle(tick_s, dt=dt)
|
||||
if c >= phase:
|
||||
worst_import = max(worst_import, site.grid)
|
||||
peak = max(peak, abs(site.battery))
|
||||
return dict(imp=worst_import, over=peak - export_w, step=biggest)
|
||||
|
||||
|
||||
def family(plantsim, control, *, exports=None, phases=(5, 6, 7), **kw):
|
||||
"""The worst case over a family of step sizes and phases.
|
||||
|
||||
⚠️ A FAMILY, NOT ONE STEP, AND THAT IS ITSELF A FINDING. A single run of
|
||||
this loop is not evidence: the 15 W deadband, the 10 W quantisation and the
|
||||
phase of the step against the tick move the answer by hundreds of watts,
|
||||
NON-MONOTONICALLY, so two adjacent slews rank either way depending on which
|
||||
step you happen to run. The first version of this derivation was a single
|
||||
step and reported gain 0.3 as WORSE than gain 0.6, which the family shows is
|
||||
backwards. Everything printed below is a worst case over the family, which
|
||||
is how admin/goodwe grades the same kind of quantity (config.yaml's "1890
|
||||
closed-loop runs", "924 runs").
|
||||
"""
|
||||
exports = exports or range(1000, 4300, 200)
|
||||
worst = dict(imp=-1e9, over=-1e9, step=0.0, big=0, runs=0)
|
||||
for export_w in exports:
|
||||
for phase in phases:
|
||||
r = step_run(plantsim, control, export_w=float(export_w),
|
||||
phase=phase, **kw)
|
||||
worst["runs"] += 1
|
||||
worst["imp"] = max(worst["imp"], r["imp"])
|
||||
worst["over"] = max(worst["over"], r["over"])
|
||||
worst["step"] = max(worst["step"], r["step"])
|
||||
worst["big"] += r["step"] >= 900.0
|
||||
return worst
|
||||
|
||||
|
||||
def row(label, w):
|
||||
print(f" {label:<44} step {w['step']:6.0f} W "
|
||||
f"{w['big']:3}/{w['runs']} runs >= 900 W "
|
||||
f"over-absorbed {w['over']:6.0f} W meter to {w['imp']:+7.0f} W")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def repro(ps, ct, tick_s):
|
||||
print("⭐ THE 2026-09-05 EXCURSION, REPLAYED - gain 0.6, max_w 5000, "
|
||||
f"slew_w 1000, {tick_s:.0f} s tick")
|
||||
print(" Live, 11:43:55-11:45:55: grid -3385 W, setpoint 380 -> 5000 in five")
|
||||
print(" ticks, battery -5154 W, grid +2831 W. One 3385 W export step here:\n")
|
||||
print(f" {'t':>5} {'grid in':>9} {'setpoint':>9} {'battery':>9} {'grid out':>9} reason")
|
||||
spec = dict(ps.INVERTER_A)
|
||||
inv = ps.Inverter.from_spec("A", spec)
|
||||
site = ps.Site([inv], load=350.0, pv=0.0)
|
||||
t = ct.Tuning(gain=0.6, max_w=5000.0, slew_w=1000.0, target_grid_w=-10.0)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
for c in range(34):
|
||||
if c == 5:
|
||||
site.pv = 3385.0 + 350.0
|
||||
site.grid = site.load - site.pv - site.battery
|
||||
g = site.grid
|
||||
d = ct.compute(prev, g, inv.plant.actual, t, sat, i_w)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
inv.command(d.target_w)
|
||||
site.settle(tick_s, dt=0.05)
|
||||
print(f" {c * tick_s:5.0f} {g:9.0f} {d.target_w:9.0f} "
|
||||
f"{site.battery:9.0f} {site.grid:9.0f} {d.reason}")
|
||||
print("\n ⚠️ THE BATTERY NEVER PASSES 5000 W HERE AND CANNOT: plantsim.Plant")
|
||||
print(" clamps at its rating. The -5154 W figure is NOT reproducible in")
|
||||
print(" this model at any tuning, so nothing in this card is graded on it.")
|
||||
print(" What IS reproduced is the setpoint stepping 1000 W a tick to the")
|
||||
print(" rail, and the meter crossing from export into import.")
|
||||
|
||||
|
||||
def slew_table(ps, ct, tick_s, rail):
|
||||
frac = ct.SLEW_RAIL_FRAC
|
||||
print(f"THE DERIVATION - gain 0.6, site rail {rail:.0f} W, {tick_s:.0f} s tick, "
|
||||
f"51 export steps 1.0-4.2 kW\n")
|
||||
print(f" the constraint: the loop must not cross its whole output range "
|
||||
f"faster than the")
|
||||
print(f" plant settles. {rail:.0f} W / ({ct.PLANT_SETTLE_S:.0f} s / "
|
||||
f"{tick_s:.0f} s per tick) = {frac * rail:.0f} W per tick.\n")
|
||||
for slew in (1000, 800, 700, 600, int(frac * rail), 500, 400, 300, 250):
|
||||
w = family(ps, ct, gain=0.6, slew_w=float(slew), rail_w=rail,
|
||||
tick_s=tick_s)
|
||||
tag = " <- the derived value" if slew == int(frac * rail) else (
|
||||
" <- the 2026-09-05 live pair" if slew == 1000 else (
|
||||
" <- the live interim" if slew == 400 else ""))
|
||||
row(f"slew_w {slew}{tag}", w)
|
||||
print("\n READ IT THIS WAY. The >= 900 W step - the live symptom, which")
|
||||
print(" escalated 2 -> 1 -> 13 -> 43 per 30 min that morning - is gone from")
|
||||
print(" every run below 900 W of slew, and that is what the slew owns.")
|
||||
print(" The OVER-ABSORPTION does not move, and at the slowest slews it is")
|
||||
print(" slightly WORSE. The rig cannot pick the derived value out of that")
|
||||
print(" column, and it is not asked to: the CONSTRAINT picks it, and this")
|
||||
print(" table is what says what the constraint does and does not buy.")
|
||||
|
||||
|
||||
def gain_table(ps, ct, tick_s, rail):
|
||||
frac = ct.SLEW_RAIL_FRAC
|
||||
print(f"WHAT THE SLEW DOES NOT OWN - slew_w at the derived "
|
||||
f"{frac * rail:.0f} W, gain swept\n")
|
||||
for gain in (0.6, 0.5, 0.4, 0.3, 0.2, 0.1):
|
||||
w = family(ps, ct, gain=gain, slew_w=frac * rail, rail_w=rail,
|
||||
tick_s=tick_s)
|
||||
row(f"gain {gain}" + (" <- shipped" if gain == 0.6 else ""), w)
|
||||
print("\n `slew_w` bounds the OUTPUT. The integrator accumulates")
|
||||
print(" `gain * error` per cycle and is bounded only by `integrator_max_w`,")
|
||||
print(" so at gain 0.6 into a 3.4 kW error it moves ~2000 W per tick and")
|
||||
print(f" reaches the {rail:.0f} W rail in under three ticks WHATEVER the slew is.")
|
||||
print(" The over-absorption is therefore a GAIN term. `gain: 0.6` is the")
|
||||
print(" next constant to re-derive; SAFETY-39 measured it and changed")
|
||||
print(" nothing, because two control-law constants moving in one release on")
|
||||
print(" a running house makes the next measurement uninterpretable.")
|
||||
|
||||
|
||||
def roster_table(ps, ct, tick_s):
|
||||
frac = ct.SLEW_RAIL_FRAC
|
||||
print("N=1 -> N=2: THE RAIL IS WHAT MOVED\n")
|
||||
print(" ⚠️ INVERTER B DOES NOT EXIST (SPIKE-04, ~2026-09-12) AND")
|
||||
print(" plantsim.INVERTER_B IS INVERTER A COPIED, measured=False. The")
|
||||
print(" N=2 rows are therefore about TWO COPIES OF A on a 10000 W rail.")
|
||||
print(" The site B actually implies is ~9600 W (5000 + 4600), and 4600 is")
|
||||
print(" B's DATASHEET and SERIAL, not a measurement, and may be DC -")
|
||||
print(" goodwe/NOTES.md §1.1b. Read the rows as architecture, not as a")
|
||||
print(" prediction about this house.\n")
|
||||
for n, rail in ((1, 5000.0), (2, 10000.0)):
|
||||
for slew in (1000.0, frac * rail):
|
||||
w = family(ps, ct, gain=0.6, slew_w=slew, rail_w=rail, n=n,
|
||||
tick_s=tick_s)
|
||||
lab = (f"n={n} rail {rail:.0f} slew {slew:.0f}"
|
||||
+ (" [B = A COPIED]" if n == 2 else ""))
|
||||
row(lab, w)
|
||||
print(f"\n The derived value at the REAL 9600 W site is "
|
||||
f"{frac * 9600:.0f} W per tick, and")
|
||||
print(" validate.py reads it off `sum(unit_caps(opts))` - the same number")
|
||||
print(" `Controller.site_max_w` builds - so adding inverter B moves the")
|
||||
print(" bound with no operator action and no second derivation.")
|
||||
print("\n ⚠️ AND THE DIRECTION IS NOT THE ONE THE TICKET ASSUMED. A rail that")
|
||||
print(" GROWS loosens this bound: slew_w 1000 crosses a 9600 W rail in")
|
||||
print(" 19.2 s and PASSES, while at 5000 W the same 1000 is the")
|
||||
print(" configuration that was measured failing. That is what the")
|
||||
print(" constraint says and it is correct - the bound is on how long the")
|
||||
print(" crossing TAKES, and a bigger rail at the same step size takes")
|
||||
print(" longer to cross.")
|
||||
print("\n ⚠️ AND THE >= 900 W STEP COUNT STAYS AT 48/51 AT THE DERIVED VALUE")
|
||||
print(" ON THE N=2 ROW, WHICH IS NOT A FAILURE - IT IS THE THRESHOLD GOING")
|
||||
print(" STALE, on the same day and in the same way as the constant. 900 W")
|
||||
print(" was a meaningful step against a 5000 W rail; against 10000 W the")
|
||||
print(" derived step IS 1111 W, and a check written on the absolute 900")
|
||||
print(" would report the correct tuning as broken. Anything grading this")
|
||||
print(" symptom at N>=2 has to grade it as a FRACTION of the rail too.")
|
||||
print("\n WHAT DOES NOT GET WORSE HERE, STATED BECAUSE IT WAS EXPECTED TO.")
|
||||
print(" Over the SAME family of 1.0-4.2 kW export steps, doubling the rail")
|
||||
print(" does not worsen the over-absorption (958 -> 810 W): the extra rail")
|
||||
print(" is headroom these disturbances never ask for. That is a statement")
|
||||
print(" about this family, not about the site - a bigger site sees bigger")
|
||||
print(" steps, and the over-absorption is a `gain` term either way, which")
|
||||
print(" the enforced ratio does not bound and this card did not change.")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--repro", action="store_true")
|
||||
ap.add_argument("--slew", action="store_true")
|
||||
ap.add_argument("--gain", action="store_true")
|
||||
ap.add_argument("--roster", action="store_true")
|
||||
ap.add_argument("--all", action="store_true")
|
||||
ap.add_argument("--tick", type=float, default=None,
|
||||
help="seconds per control cycle (default: control.LOOP_TICK_S)")
|
||||
ap.add_argument("--rail", type=float, default=5000.0)
|
||||
a = ap.parse_args()
|
||||
if not any((a.repro, a.slew, a.gain, a.roster, a.all)):
|
||||
a.all = True
|
||||
sim = find_sim()
|
||||
if sim is None:
|
||||
sys.exit("slew_sim: admin/goodwe's sim/ is not beside this checkout, so "
|
||||
"plantsim.Plant is unavailable. Clone it as a sibling or set "
|
||||
"$GOODWE_SIM. This is a missing repository, NOT a result about "
|
||||
"the control law - do not report it as one.")
|
||||
ps, ct = load(sim)
|
||||
tick = a.tick if a.tick is not None else ct.LOOP_TICK_S
|
||||
print(f"slew_sim: plant from {sim}")
|
||||
print(f" control law from {ADDON / 'goodwe_controller' / 'app'}")
|
||||
print(f" {ps.Inverter.from_spec('A', ps.INVERTER_A).describe()}")
|
||||
print(f" tick {tick:.1f} s, SLEW_RAIL_FRAC {ct.SLEW_RAIL_FRAC:.4f} "
|
||||
f"= {ct.LOOP_TICK_S:.0f} s / {ct.PLANT_SETTLE_S:.0f} s\n")
|
||||
if a.repro or a.all:
|
||||
repro(ps, ct, tick); print()
|
||||
if a.slew or a.all:
|
||||
slew_table(ps, ct, tick, a.rail); print()
|
||||
if a.gain or a.all:
|
||||
gain_table(ps, ct, tick, a.rail); print()
|
||||
if a.roster or a.all:
|
||||
roster_table(ps, ct, tick)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,878 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-38 round 3 - the SYMMETRIC-PREDICATE FAMILY, enumerated by machine.
|
||||
|
||||
WHAT IT LOOKS FOR, in one sentence: a call whose argument ORDER is load-bearing
|
||||
for a MESSAGE and for nothing else - so swapping the arguments cannot change a
|
||||
single decision, is invisible to every behavioural check BY CONSTRUCTION, and
|
||||
still inverts what an operator reads on the line they are woken by.
|
||||
|
||||
⚠️ WHY IT IS AN INSTRUMENT AND NOT A LIST. MUTATION.md habit 12: "a hand-written
|
||||
scope is the same defect as a hand-written fixture". SAFETY-38 met this family
|
||||
twice - `SignWatchdog.evaluate()` called at `main.py:1620` (round 2) and at
|
||||
`main.py:1560` (round 3), ONE CALL SITE OVER - and both times the enumeration
|
||||
was written by hand and both times it was short. So the scope is computed.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE TEST IT APPLIES
|
||||
|
||||
For every function in `app/` and every pair (a, b) of its parameters:
|
||||
|
||||
rename a -> b and b -> a throughout the body, ERASE every string literal
|
||||
and f-string, CANONICALISE the commutative operators, and compare the two
|
||||
ASTs. If they are EQUAL, nothing the function DECIDES can depend on which
|
||||
of the two values arrived first.
|
||||
|
||||
Then, if some f-string in that function mentions BOTH names, the order is
|
||||
load-bearing for the MESSAGE ALONE, and the pair is reported.
|
||||
|
||||
⚠️ THE SYMMETRY IS USUALLY ONE CALL DEEP, WHICH IS WHY THIS IS A FIXPOINT AND
|
||||
NOT A SINGLE PASS. `SignWatchdog.evaluate()` does not compare its two arguments
|
||||
itself; it hands them to `watchdog.disagrees()`, whose body is
|
||||
`commanded_w * against_w < 0 and abs(commanded_w) > min_w and
|
||||
abs(against_w) > min_w`. An intraprocedural scan sees `disagrees(a, b, ...)`
|
||||
become `disagrees(b, a, ...)`, calls that a difference, and reports NOTHING -
|
||||
which is exactly what the first version of this file did, on a tree where the
|
||||
answer was already known. So symmetric pairs discovered in one round are used
|
||||
to canonicalise CALL ARGUMENTS in the next, and the scan iterates until the set
|
||||
stops growing.
|
||||
|
||||
CANONICALISATION, and each entry is here because something in `app/` needs it:
|
||||
* `a * b`, `a + b` - operand order sorted (`disagrees`)
|
||||
* `x and y`, `x or y` - term order sorted (`disagrees`' conjunction)
|
||||
* `a == b`, `a != b` - operand order sorted
|
||||
* `min(a, b)`, `max(a, b)` - argument order sorted (`clamp_to_rating`)
|
||||
* a tuple/list/set LITERAL being iterated - element order sorted
|
||||
(`rail_ceiling_w`'s `for bound in (dev_min, dev_max)`)
|
||||
* arguments at a KNOWN SYMMETRIC POSITION PAIR of a called function - sorted
|
||||
(the fixpoint)
|
||||
|
||||
Equal ASTs after the rename is a SUFFICIENT condition for behavioural
|
||||
invariance, not a necessary one. This scan therefore UNDER-REPORTS and says so
|
||||
- see "WHAT THIS CANNOT SEE". That is the conservative direction: everything it
|
||||
names is genuinely in the family, and the gap is stated with its shape.
|
||||
|
||||
Run: `python3 tools/symmetric_message_scan.py [--quiet]` from goodwe_controller/.
|
||||
Exit status is 0 always; it is a census, not a gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import itertools
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parent.parent / "app"
|
||||
|
||||
# {function name: {(i, j) positional index pairs that are interchangeable}}
|
||||
_SYM: dict[str, set[tuple[int, int]]] = {}
|
||||
# ⚠️ AND THE SAME THING FOR KEYWORD-ONLY PARAMETERS, WHICH THE FIRST FIXPOINT
|
||||
# DROPPED. `freeze.rail_ceiling_w(max_w, *, dev_min=None, dev_max=None)` folds
|
||||
# its two device bounds with `for bound in (dev_min, dev_max): ceiling = min(
|
||||
# ceiling, abs(bound))` - perfectly symmetric - and `rail_unreachable()` prints
|
||||
# `"a range of {dev_min}...{dev_max} W"`. A scan that only followed POSITIONAL
|
||||
# arguments called that pair asymmetric and hid a genuine member of the family
|
||||
# behind an implementation detail of its own traversal.
|
||||
_SYM_KW: dict[str, set[tuple[str, str]]] = {}
|
||||
|
||||
|
||||
class _Canon(ast.NodeTransformer):
|
||||
"""Rename one pair, erase every string, and sort what commutes."""
|
||||
|
||||
COMMUTATIVE_BINOP = (ast.Mult, ast.Add, ast.BitAnd, ast.BitOr, ast.BitXor)
|
||||
COMMUTATIVE_CMP = (ast.Eq, ast.NotEq)
|
||||
COMMUTATIVE_CALL = ("min", "max")
|
||||
|
||||
def __init__(self, a: str = "", b: str = ""):
|
||||
self.a, self.b = a, b
|
||||
|
||||
# -- the rename ---------------------------------------------------------
|
||||
def visit_Name(self, node):
|
||||
self.generic_visit(node)
|
||||
if self.a and node.id == self.a:
|
||||
node.id = self.b
|
||||
elif self.b and node.id == self.b:
|
||||
node.id = self.a
|
||||
return node
|
||||
|
||||
def visit_arg(self, node):
|
||||
if self.a and node.arg == self.a:
|
||||
node.arg = self.b
|
||||
elif self.b and node.arg == self.b:
|
||||
node.arg = self.a
|
||||
return node
|
||||
|
||||
# -- the message is exactly what is ALLOWED to differ --------------------
|
||||
def visit_JoinedStr(self, node):
|
||||
return ast.Constant(value="<msg>")
|
||||
|
||||
def visit_Constant(self, node):
|
||||
if isinstance(node.value, str):
|
||||
return ast.Constant(value="<msg>")
|
||||
return node
|
||||
|
||||
# -- canonicalisation ---------------------------------------------------
|
||||
def visit_BinOp(self, node):
|
||||
self.generic_visit(node)
|
||||
if isinstance(node.op, self.COMMUTATIVE_BINOP):
|
||||
l, r = ast.dump(node.left), ast.dump(node.right)
|
||||
if l > r:
|
||||
node.left, node.right = node.right, node.left
|
||||
return node
|
||||
|
||||
def visit_BoolOp(self, node):
|
||||
self.generic_visit(node)
|
||||
node.values = sorted(node.values, key=ast.dump)
|
||||
return node
|
||||
|
||||
def visit_Compare(self, node):
|
||||
self.generic_visit(node)
|
||||
if (len(node.ops) == 1
|
||||
and isinstance(node.ops[0], self.COMMUTATIVE_CMP)):
|
||||
l, r = ast.dump(node.left), ast.dump(node.comparators[0])
|
||||
if l > r:
|
||||
node.left, node.comparators[0] = node.comparators[0], node.left
|
||||
return node
|
||||
|
||||
def visit_For(self, node):
|
||||
self.generic_visit(node)
|
||||
if isinstance(node.iter, (ast.Tuple, ast.List, ast.Set)):
|
||||
node.iter.elts = sorted(node.iter.elts, key=ast.dump)
|
||||
return node
|
||||
|
||||
def visit_Call(self, node):
|
||||
self.generic_visit(node)
|
||||
name = (node.func.id if isinstance(node.func, ast.Name)
|
||||
else node.func.attr if isinstance(node.func, ast.Attribute)
|
||||
else "")
|
||||
if name in self.COMMUTATIVE_CALL and len(node.args) == 2 \
|
||||
and not node.keywords:
|
||||
if ast.dump(node.args[0]) > ast.dump(node.args[1]):
|
||||
node.args = [node.args[1], node.args[0]]
|
||||
return node
|
||||
# the fixpoint: sort the arguments at a known symmetric position pair
|
||||
for (i, j) in sorted(_SYM.get(name, ())):
|
||||
if j < len(node.args):
|
||||
if ast.dump(node.args[i]) > ast.dump(node.args[j]):
|
||||
node.args[i], node.args[j] = node.args[j], node.args[i]
|
||||
# ...and at a known symmetric KEYWORD pair
|
||||
for (ka, kb) in sorted(_SYM_KW.get(name, ())):
|
||||
ia = next((k for k, w in enumerate(node.keywords)
|
||||
if w.arg == ka), None)
|
||||
ib = next((k for k, w in enumerate(node.keywords)
|
||||
if w.arg == kb), None)
|
||||
if ia is not None and ib is not None:
|
||||
va, vb = node.keywords[ia].value, node.keywords[ib].value
|
||||
if ast.dump(va) > ast.dump(vb):
|
||||
node.keywords[ia].value, node.keywords[ib].value = vb, va
|
||||
return node
|
||||
|
||||
|
||||
def _canon(src: str, a: str = "", b: str = "") -> str:
|
||||
return ast.dump(_Canon(a, b).visit(ast.parse(src)))
|
||||
|
||||
|
||||
# ⚠️ THE SINK IS HALF THE CLAIM, AND THE FIRST VERSION OF THIS SCAN OMITTED IT.
|
||||
# Erasing every f-string makes a function whose ONLY job is to FORMAT one look
|
||||
# perfectly symmetric - `store.unit_key(unit, key)` and
|
||||
# `mqtt.unit_state_key(slug, key)` both came back as "the swap changes nothing"
|
||||
# when in fact the string IS the return value: swapping there renames a STORE
|
||||
# KEY and an MQTT TOPIC, which is a behaviour change every ordinary check can
|
||||
# see, and MAINT-05 is the card about exactly that. The family this file is
|
||||
# looking for is narrower - the string goes to an OPERATOR and to nobody else -
|
||||
# so each sentence is classified by where it GOES.
|
||||
_LOG_SINKS = {"debug", "info", "warning", "warn", "error", "exception",
|
||||
"critical", "log", "log_event", "notify", "persistent_notify"}
|
||||
_MSG_ATTRS = {"reason", "message", "msg", "banner", "detail", "note",
|
||||
"explanation", "why", "text"}
|
||||
|
||||
|
||||
def _sink_of(fn, target, follow=True):
|
||||
"""RETURN | MESSAGE | OTHER - where this f-string node ends up.
|
||||
|
||||
`follow` is the ONE step of data flow below, and it is off on the recursive
|
||||
call so that two locals that interpolate each other cannot loop forever.
|
||||
"""
|
||||
for stmt in ast.walk(fn):
|
||||
for child in ast.iter_child_nodes(stmt):
|
||||
pass
|
||||
# a parent map is cheaper than repeated walks
|
||||
parent = {}
|
||||
for node in ast.walk(fn):
|
||||
for child in ast.iter_child_nodes(node):
|
||||
parent[id(child)] = node
|
||||
cur = target
|
||||
while id(cur) in parent:
|
||||
up = parent[id(cur)]
|
||||
if isinstance(up, ast.Return):
|
||||
return "RETURN"
|
||||
if isinstance(up, ast.Raise):
|
||||
return "MESSAGE"
|
||||
if isinstance(up, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
||||
tgts = (up.targets if isinstance(up, ast.Assign) else [up.target])
|
||||
for t in tgts:
|
||||
if isinstance(t, ast.Attribute) and t.attr in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
if isinstance(t, ast.Name) and t.id in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
# ⚠️ ONE STEP OF DATA FLOW, AND IT IS NOT OPTIONAL. `main.py`'s
|
||||
# status page builds the device-range clause into a LOCAL - `_cap =
|
||||
# "; ".join(f"... accepts only {inv.dev_min}...{inv.dev_max} W" ...)`
|
||||
# - and only then concatenates it into the row's `text`. Stopping at
|
||||
# the assignment scores that OTHER and drops a member this card
|
||||
# MEASURED as a live survivor (0 bytes, 21/21 green). So: if the
|
||||
# name assigned here is interpolated into some other sentence in the
|
||||
# same function that IS a message, this one is a message too.
|
||||
if follow:
|
||||
for t in tgts:
|
||||
if isinstance(t, ast.Name) and _feeds_a_message(fn, t.id,
|
||||
up):
|
||||
return "MESSAGE"
|
||||
return "OTHER"
|
||||
# ⚠️ ROUND 4. A SENTENCE HANDED TO A CONSTRUCTOR AS `reason=` IS A
|
||||
# MESSAGE, and scoring only ASSIGNMENTS missed every one of them. This
|
||||
# codebase builds most of its operator sentences that way -
|
||||
# `ArbitrageDecision(action=..., reason=f"...")`,
|
||||
# `out.append({"text": f"..."})` - so the sentence never touches a name
|
||||
# this walker was watching. arbitrage.py's two DISCHARGE/CHARGE reasons
|
||||
# were scored OTHER for exactly this and are genuine members.
|
||||
if isinstance(up, ast.keyword) and up.arg in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
if isinstance(up, ast.Dict):
|
||||
for k, v in zip(up.keys, up.values):
|
||||
if (v is cur and isinstance(k, ast.Constant)
|
||||
and k.value in _MSG_ATTRS):
|
||||
return "MESSAGE"
|
||||
if isinstance(up, ast.Call):
|
||||
name = (up.func.attr if isinstance(up.func, ast.Attribute)
|
||||
else up.func.id if isinstance(up.func, ast.Name) else "")
|
||||
if name in _LOG_SINKS:
|
||||
return "MESSAGE"
|
||||
if name == "append":
|
||||
return "MESSAGE"
|
||||
cur = up
|
||||
return "OTHER"
|
||||
|
||||
|
||||
def _feeds_a_message(fn, name, origin):
|
||||
"""Is `name` interpolated into some OTHER sentence that IS a message?"""
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr) or node is origin:
|
||||
continue
|
||||
used = any(isinstance(x, ast.Name) and x.id == name
|
||||
for v in node.values if isinstance(v, ast.FormattedValue)
|
||||
for x in ast.walk(v.value))
|
||||
if used and _sink_of(fn, node, follow=False) == "MESSAGE":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _fstring_pairs(fn, params):
|
||||
"""{(a, b): [(lineno, sink), ...]} for every f-string mentioning both."""
|
||||
out: dict[tuple[str, str], list[tuple[int, str]]] = {}
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr):
|
||||
continue
|
||||
used = set()
|
||||
for v in node.values:
|
||||
if isinstance(v, ast.FormattedValue):
|
||||
for x in ast.walk(v.value):
|
||||
if isinstance(x, ast.Name) and x.id in params:
|
||||
used.add(x.id)
|
||||
if len(used) < 2:
|
||||
continue
|
||||
sink = _sink_of(fn, node)
|
||||
for a, b in itertools.combinations(sorted(used), 2):
|
||||
out.setdefault((a, b), []).append((node.lineno, sink))
|
||||
return out
|
||||
|
||||
|
||||
def _functions():
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for fn in ast.walk(tree):
|
||||
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
yield path, fn
|
||||
|
||||
|
||||
def _positional(fn):
|
||||
return [a.arg for a in fn.args.posonlyargs + fn.args.args
|
||||
if a.arg != "self"]
|
||||
|
||||
|
||||
def _all_params(fn):
|
||||
return _positional(fn) + [a.arg for a in fn.args.kwonlyargs]
|
||||
|
||||
|
||||
def _body_src(fn) -> str:
|
||||
"""The function's BODY alone.
|
||||
|
||||
⚠️ NOT `ast.unparse(fn)`. The signature lists the parameters IN ORDER, so
|
||||
renaming a pair inside it reorders `arguments.args` and every function in
|
||||
the repository compares unequal to itself - which is what the first version
|
||||
of this scan did, silently, and it reported an empty family on a tree whose
|
||||
answer was already known. The claim being tested is about what the body
|
||||
DECIDES, and the body is what is compared.
|
||||
"""
|
||||
return ast.unparse(ast.Module(body=list(fn.body), type_ignores=[]))
|
||||
|
||||
|
||||
def _symmetric(fn, a, b) -> bool:
|
||||
src = _body_src(fn)
|
||||
return _canon(src) == _canon(src, a, b)
|
||||
|
||||
|
||||
def build_fixpoint(max_rounds=6):
|
||||
"""Fill `_SYM` until it stops growing. Returns the number of rounds."""
|
||||
for round_no in range(1, max_rounds + 1):
|
||||
grew = False
|
||||
for _path, fn in _functions():
|
||||
pos = _positional(fn)
|
||||
kws = [a.arg for a in fn.args.kwonlyargs]
|
||||
if len(pos) + len(kws) < 2:
|
||||
continue
|
||||
found = set()
|
||||
for i, j in itertools.combinations(range(len(pos)), 2):
|
||||
if _symmetric(fn, pos[i], pos[j]):
|
||||
found.add((i, j))
|
||||
found_kw = set()
|
||||
for ka, kb in itertools.combinations(sorted(kws), 2):
|
||||
if _symmetric(fn, ka, kb):
|
||||
found_kw.add((ka, kb))
|
||||
if found - _SYM.get(fn.name, set()):
|
||||
_SYM.setdefault(fn.name, set()).update(found)
|
||||
grew = True
|
||||
if found_kw - _SYM_KW.get(fn.name, set()):
|
||||
_SYM_KW.setdefault(fn.name, set()).update(found_kw)
|
||||
grew = True
|
||||
if not grew:
|
||||
return round_no
|
||||
return max_rounds
|
||||
|
||||
|
||||
def scan():
|
||||
rows = []
|
||||
for path, fn in _functions():
|
||||
params = _all_params(fn)
|
||||
if len(params) < 2:
|
||||
continue
|
||||
kwonly = {a.arg for a in fn.args.kwonlyargs}
|
||||
for (a, b), hits in sorted(_fstring_pairs(fn, params).items()):
|
||||
rows.append(dict(
|
||||
module=path.name, func=fn.name, line=fn.lineno, a=a, b=b,
|
||||
msg_lines=sorted(set(hits)),
|
||||
sinks={s for _l, s in hits},
|
||||
symmetric=_symmetric(fn, a, b),
|
||||
positional=not (a in kwonly or b in kwonly)))
|
||||
return rows
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CENSUS B - THE SENTENCE THE CALLER BUILDS
|
||||
# =============================================================================
|
||||
# ⚠️ CENSUS A ABOVE ONLY SEES A CALLEE FORMATTING ITS OWN PARAMETERS, AND THAT
|
||||
# WAS GAP 2 OF ITS OWN "WHAT THIS CANNOT SEE" LIST. The other half of the family
|
||||
# never passes through a function boundary at all: an operator sentence built
|
||||
# where it is printed, interpolating two values that are INTERCHANGEABLE BY
|
||||
# SHAPE. `main.py`'s status page says
|
||||
#
|
||||
# f"{inv.setpoint_entity or inv.name} accepts only {inv.dev_min}...{inv.dev_max} W"
|
||||
#
|
||||
# and transposing those two costs ZERO BYTES, changes nothing any check can
|
||||
# observe, and tells the operator a range backwards. No predicate is involved,
|
||||
# so census A's symmetry test has nothing to test.
|
||||
#
|
||||
# THE TEST: inside one MESSAGE-sink f-string, take every pair of interpolated
|
||||
# expressions, blank every attribute NAME and every string CONSTANT, and compare.
|
||||
# If the blanked forms are EQUAL while the originals DIFFER, the two expressions
|
||||
# are the same shape with one leaf changed - `inv.dev_min` / `inv.dev_max`,
|
||||
# `site_dev_bound("dev_min")` / `site_dev_bound("dev_max")` - which is exactly
|
||||
# the pair a transposition swaps. Same direction as census A: it under-reports
|
||||
# (two values of the same KIND spelled differently are missed) rather than
|
||||
# inventing members.
|
||||
|
||||
|
||||
|
||||
class _Blank(ast.NodeTransformer):
|
||||
def visit_Attribute(self, node):
|
||||
self.generic_visit(node)
|
||||
node.attr = "<leaf>"
|
||||
return node
|
||||
|
||||
def visit_Constant(self, node):
|
||||
if isinstance(node.value, str):
|
||||
return ast.Constant(value="<leaf>")
|
||||
return node
|
||||
|
||||
|
||||
def _blanked(expr):
|
||||
return ast.dump(_Blank().visit(ast.parse(ast.unparse(expr))))
|
||||
|
||||
|
||||
def _called_name(func):
|
||||
return (func.attr if isinstance(func, ast.Attribute)
|
||||
else func.id if isinstance(func, ast.Name) else "")
|
||||
|
||||
|
||||
def _innermost(tree):
|
||||
"""{id(node): INNERMOST enclosing function}, or None at module level.
|
||||
|
||||
⚠️ NOT `for fn in ast.walk(tree): for node in ast.walk(fn)`, which is what
|
||||
the first version of census B did. That visits every sentence once per
|
||||
ENCLOSING def, so a sentence inside a nested function was classified twice -
|
||||
once against the inner scope and once against the outer - and the two
|
||||
classifications are not obliged to agree. The rows were de-duplicated
|
||||
afterwards, which hid the double visit rather than removing it.
|
||||
"""
|
||||
m = {}
|
||||
|
||||
def rec(node, fn):
|
||||
for child in ast.iter_child_nodes(node):
|
||||
f = (child if isinstance(child, (ast.FunctionDef,
|
||||
ast.AsyncFunctionDef)) else fn)
|
||||
m[id(child)] = f
|
||||
rec(child, f)
|
||||
|
||||
rec(tree, None)
|
||||
return m
|
||||
|
||||
|
||||
def _spec(fv):
|
||||
"""The FORMAT SPEC of one interpolation, as text; '' when there is none.
|
||||
|
||||
⚠️ IT IS PRINTED BESIDE EVERY PAIR BECAUSE IT IS THE ONE CHEAP TYPE SIGNAL
|
||||
THIS SCAN HAS, and gap 4 of the second list below is exactly about the pairs
|
||||
that do not share
|
||||
one. `{self.requested_w:+.0f}` beside `{self.applied_w:+.0f}` is two floats
|
||||
rendered identically - a transposition there is silent and reads as a fact.
|
||||
`{self.limit_w:.0f}` beside `{self.bound}` is a float beside a string: a
|
||||
transposition there prints `rating max_w W`, which is visibly broken rather
|
||||
than quietly wrong. Same row in this census, very different hazard, and the
|
||||
reader cannot tell them apart without this column.
|
||||
"""
|
||||
if fv.format_spec is None:
|
||||
return ""
|
||||
return "".join(str(v.value) if isinstance(v, ast.Constant) else "{...}"
|
||||
for v in fv.format_spec.values)
|
||||
|
||||
|
||||
def _same_shape_pairs(node):
|
||||
"""Pairs of interpolations in ONE f-string that are exactly one leaf apart."""
|
||||
fvs = [v for v in node.values if isinstance(v, ast.FormattedValue)]
|
||||
for x, y in itertools.combinations(fvs, 2):
|
||||
ua, ub = ast.unparse(x.value), ast.unparse(y.value)
|
||||
if ua == ub:
|
||||
continue
|
||||
if _blanked(x.value) != _blanked(y.value):
|
||||
continue
|
||||
yield ua, ub, _spec(x), _spec(y)
|
||||
|
||||
|
||||
def _sentences():
|
||||
"""(path, innermost fn, JoinedStr, sink) for every f-string in app/."""
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
inner = _innermost(tree)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.JoinedStr):
|
||||
continue
|
||||
fn = inner.get(id(node))
|
||||
if fn is None:
|
||||
continue
|
||||
yield path, fn, node, _sink_of(fn, node)
|
||||
|
||||
|
||||
def scan_caller():
|
||||
rows = []
|
||||
for path, fn, node, sink in _sentences():
|
||||
# ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS CENSUS SAID
|
||||
# "anything but a RETURN". That let in `p1.py`'s
|
||||
# `self.url = f"http://{self.host}:{self.port}…"` - an f-string that is
|
||||
# DATA, used to make a request, where a transposition is an ordinary
|
||||
# behavioural change every check can see. A formatted string is only in
|
||||
# this family when its only consumer is a person. RETURN is not OTHER
|
||||
# though, and treating it as if it were is what census C below fixes.
|
||||
if sink != "MESSAGE":
|
||||
continue
|
||||
for ua, ub, sa, sb in _same_shape_pairs(node):
|
||||
rows.append((path.name, node.lineno, fn.name, ua, ub, sa, sb))
|
||||
return sorted(set(rows), key=lambda r: (r[0], r[1], r[3]))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CENSUS C - THE SENTENCE THE OBJECT RETURNS ABOUT ITSELF
|
||||
# =============================================================================
|
||||
# ⚠️ SAFETY-38 ROUND 4. CENSUS A AND CENSUS B BETWEEN THEM COVERED ONE
|
||||
# SYNTACTIC SINK AND CALLED IT THE FAMILY. Every member either census reported
|
||||
# was a sentence APPENDED, ASSIGNED to a `reason`-shaped attribute, or passed as
|
||||
# a LOG ARGUMENT. Not one was a sentence that is RETURNED. Census A had a RETURN
|
||||
# bucket, but only for a callee formatting its own PARAMETERS - so a method
|
||||
# formatting its own ATTRIBUTES and handing the string back was invisible to
|
||||
# both, and `describe()` / `explain()` / the reason-builders are precisely where
|
||||
# this codebase keeps its operator sentences. Two live members were sitting in
|
||||
# that hole:
|
||||
#
|
||||
# clamp.py ClampResult.describe() requested_w <-> applied_w
|
||||
# peakguard.py PeakDecision.explain() projected_w <-> ceiling_w
|
||||
#
|
||||
# Worse than the gap: it was in NONE of the five blind spots this file prints
|
||||
# about itself. A tool that asserts a completeness it does not have is the
|
||||
# recurring defect in a new hat, and the list at the bottom now names its sinks.
|
||||
#
|
||||
# THE TEST is census B's - one f-string, two interpolations one leaf apart - run
|
||||
# over the RETURN sink, plus one thing census B never needed: A RETURNED STRING
|
||||
# IS NOT YET A MESSAGE. `store.unit_key()` and `mqtt.unit_state_key()` return
|
||||
# f-strings too, and swapping there renames a store key and an MQTT topic, which
|
||||
# every ordinary check can see (MAINT-05 is the card about that). So the sink of
|
||||
# a returned sentence is RESOLVED THROUGH ITS CALL SITES: the string is in this
|
||||
# family only when some caller in `app/` feeds it to an operator.
|
||||
|
||||
|
||||
def _return_consumers(func_name, depth=0, seen=frozenset()):
|
||||
"""[(site, sink, verdict)] - where the STRING RETURNED BY `func_name` goes.
|
||||
|
||||
One hop is not enough: `Resolution.explain()` is returned again by wrappers
|
||||
that are themselves logged, so a call site whose own sink is RETURN is
|
||||
resolved by recursing on the ENCLOSING function's name, bounded by `depth`
|
||||
and by `seen` so mutual recursion cannot loop.
|
||||
|
||||
⚠️ THE MATCH IS ON THE BARE METHOD NAME, exactly as `call_sites()` above,
|
||||
and `explain` is the name of at least four unrelated methods in this tree
|
||||
(`PeakDecision`, `UnitPlan`, `Resolution`, the maintenance migration
|
||||
report). So this OVER-collects sites, which pushes a verdict towards
|
||||
MESSAGE. That is the deliberate direction for a census whose measured
|
||||
failure has twice been under-reporting - but it means the verdict is a
|
||||
PROMPT TO READ THE SITES PRINTED BESIDE IT, not a proof.
|
||||
"""
|
||||
if func_name in seen or depth > 2:
|
||||
return []
|
||||
seen = seen | {func_name}
|
||||
out = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
inner = _innermost(tree)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if _called_name(node.func) != func_name:
|
||||
continue
|
||||
fn = inner.get(id(node))
|
||||
if fn is None:
|
||||
continue
|
||||
sink = _sink_of(fn, node)
|
||||
label = f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`"
|
||||
if sink == "RETURN":
|
||||
sub = _return_consumers(fn.name, depth + 1, seen)
|
||||
if any(v == "MESSAGE" for _l, _s, v in sub):
|
||||
verdict = "MESSAGE"
|
||||
elif not sub:
|
||||
verdict = "UNRESOLVED"
|
||||
else:
|
||||
verdict = "OTHER"
|
||||
sink = f"RETURN by {fn.name}() -> {verdict}"
|
||||
else:
|
||||
verdict = sink
|
||||
out.append((label, sink, verdict))
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def scan_returned():
|
||||
"""Census C rows: a returned sentence, two interpolations one leaf apart."""
|
||||
rows = []
|
||||
for path, fn, node, sink in _sentences():
|
||||
if sink != "RETURN":
|
||||
continue
|
||||
pairs = list(_same_shape_pairs(node))
|
||||
if not pairs:
|
||||
continue
|
||||
sites = _return_consumers(fn.name)
|
||||
if any(v == "MESSAGE" for _l, _s, v in sites):
|
||||
verdict = "MESSAGE"
|
||||
elif not sites:
|
||||
verdict = "UNRESOLVED - no call site inside app/"
|
||||
else:
|
||||
verdict = "OTHER"
|
||||
for ua, ub, sa, sb in pairs:
|
||||
rows.append(dict(module=path.name, line=node.lineno, func=fn.name,
|
||||
a=ua, b=ub, spec_a=sa, spec_b=sb,
|
||||
verdict=verdict, sites=sites))
|
||||
return sorted(rows, key=lambda r: (r["module"], r["line"], r["a"]))
|
||||
|
||||
|
||||
# ⚠️ THE UNIT OF COVERAGE IS THE CALL SITE, NOT THE FUNCTION - which is the
|
||||
# whole lesson of SAFETY-38 round 3. `SignWatchdog.evaluate()` had its operand
|
||||
# order pinned and the defect still shipped, because the pin was on the call two
|
||||
# lines below the one a one-inverter roster actually reaches. So a family member
|
||||
# is reported WITH EVERY PLACE IT IS CALLED.
|
||||
def call_sites(name):
|
||||
out = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if _called_name(node.func) != name:
|
||||
continue
|
||||
# ⚠️ THE RECEIVER IS PRINTED, because matching on the bare
|
||||
# attribute name conflates methods: `evaluate` is the name of
|
||||
# SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and
|
||||
# a list of four lines with no receiver tells a reader nothing
|
||||
# about which detector they are looking at. `inv.sign_wd` and
|
||||
# `self.freeze_wd` are the disambiguation, and they are the
|
||||
# caller's own words.
|
||||
out.append(f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`")
|
||||
return out
|
||||
|
||||
|
||||
def lazy_log_sites():
|
||||
"""Log calls with a %-format template and >=2 substitution ARGUMENTS.
|
||||
|
||||
⚠️ THE SIZE OF THE LARGEST HOLE, MEASURED RATHER THAN ASSERTED. The
|
||||
NOT-SCANNED list below used to end "and app/ uses that form", which is a
|
||||
claim about the tree with no number on it - the exact shape this card has
|
||||
spent four rounds arguing against. `_LOG.error("%s ... %s", a, b)` puts both
|
||||
operands in ARGUMENTS, so no f-string census here can ever reach them, and
|
||||
transposing the two costs the same zero bytes. This counts them.
|
||||
"""
|
||||
out = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if _called_name(node.func) not in _LOG_SINKS or len(node.args) < 3:
|
||||
continue
|
||||
head = node.args[0]
|
||||
tmpl = ""
|
||||
if isinstance(head, ast.Constant) and isinstance(head.value, str):
|
||||
tmpl = head.value
|
||||
elif isinstance(head, ast.BinOp) or isinstance(head, ast.JoinedStr):
|
||||
tmpl = ast.unparse(head)
|
||||
if tmpl.count("%") >= 2:
|
||||
out.append((path.name, node.lineno,
|
||||
f"{path.name}:{node.lineno} "
|
||||
f"`{ast.unparse(node.func)}` with "
|
||||
f"{len(node.args) - 1} args"))
|
||||
return [r[2] for r in sorted(out)]
|
||||
|
||||
|
||||
def _pair_line(sa, sb):
|
||||
same = sa == sb
|
||||
return (f" format spec: '{sa}' / '{sb}' -> "
|
||||
+ ("SAME - two values rendered alike, so a transposition is "
|
||||
"SILENT and reads as a fact"
|
||||
if same else
|
||||
"DIFFERENT - likely mixed types, so a transposition is more "
|
||||
"likely to look broken than to lie (gap 4 below)"))
|
||||
|
||||
|
||||
def main():
|
||||
quiet = "--quiet" in sys.argv
|
||||
rounds = build_fixpoint()
|
||||
rows = scan()
|
||||
fam = [r for r in rows if r["symmetric"] and "MESSAGE" in r["sinks"]
|
||||
and "RETURN" not in r["sinks"]]
|
||||
valued = [r for r in rows if r["symmetric"] and "RETURN" in r["sinks"]]
|
||||
print("SYMMETRIC-PREDICATE FAMILY - argument order load-bearing for a "
|
||||
"MESSAGE only")
|
||||
print("=" * 78)
|
||||
print(f"fixpoint reached after {rounds} round(s); "
|
||||
f"{sum(len(v) for v in _SYM.values())} interchangeable positional "
|
||||
f"and {sum(len(v) for v in _SYM_KW.values())} keyword pair(s) "
|
||||
f"across {len(set(_SYM) | set(_SYM_KW))} function(s) in app/")
|
||||
print(f"functions formatting >=2 of their own parameters into one "
|
||||
f"sentence: {len(rows)} pair(s)")
|
||||
print(f"of those, pairs whose SWAP LEAVES EVERY DECISION IDENTICAL: "
|
||||
f"{len(fam) + len(valued)}")
|
||||
print(f" ...of which the sentence is RETURNED (so the swap is a VALUE "
|
||||
f"change an ordinary check can see): {len(valued)}")
|
||||
print(f" ...and the sentence goes ONLY TO AN OPERATOR - THE FAMILY: "
|
||||
f"{len(fam)}")
|
||||
print()
|
||||
print("THE FAMILY - a swap here is invisible to every behavioural check "
|
||||
"BY CONSTRUCTION:")
|
||||
for r in sorted(fam, key=lambda r: (r["module"], r["line"])):
|
||||
kind = ("POSITIONAL - a transposition at a call site costs ZERO BYTES"
|
||||
if r["positional"] else
|
||||
"KEYWORD-ONLY at the definition - a transposition has to be "
|
||||
"written out by name, which is not free and not silent")
|
||||
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
||||
f"({r['a']}, {r['b']})")
|
||||
print(f" sentence at line(s): "
|
||||
f"{', '.join(f'{l} -> {s}' for l, s in r['msg_lines'])}")
|
||||
print(f" {kind}")
|
||||
sites = call_sites(r["func"])
|
||||
print(f" CALL SITES ({len(sites)}) - each one is a separate pin, "
|
||||
f"and the receiver says which detector it is:")
|
||||
for site in sites or ["(none inside app/)"]:
|
||||
print(f" {site}")
|
||||
print()
|
||||
print("SYMMETRIC, BUT THE SENTENCE IS THE RETURN VALUE - READ THIS BUCKET "
|
||||
"BY HAND.")
|
||||
print("A returned string is sometimes a KEY (a swap renames a store key or "
|
||||
"an MQTT topic,")
|
||||
print("which is behaviour every ordinary check can see - MAINT-05 is the "
|
||||
"card about it)")
|
||||
print("and sometimes a SENTENCE (a swap is message-only after all, and it "
|
||||
"IS this family):")
|
||||
for r in sorted(valued, key=lambda r: (r["module"], r["line"])):
|
||||
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
||||
f"({r['a']}, {r['b']})")
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("CENSUS B - SENTENCES THE CALLER BUILDS, interpolating two values of "
|
||||
"the SAME SHAPE")
|
||||
print("A transposition inside one of these is ZERO BYTES and no predicate "
|
||||
"is involved at")
|
||||
print("all, so nothing behavioural can ever see it. Same rule: assert the "
|
||||
"ORDERED PAIR.")
|
||||
cb = scan_caller()
|
||||
print(f"candidates: {len(cb)}")
|
||||
for mod, line, fn, ua, ub, sa, sb in cb:
|
||||
print(f" {mod}:{line} in {fn}()")
|
||||
print(f" {ua} <-> {ub}")
|
||||
print(_pair_line(sa, sb))
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("CENSUS C - SENTENCES AN OBJECT RETURNS ABOUT ITSELF (SAFETY-38 "
|
||||
"round 4)")
|
||||
print("A method formatting its OWN ATTRIBUTES and RETURNING the string was "
|
||||
"in neither")
|
||||
print("census above: census A only reads a callee's PARAMETERS, census B "
|
||||
"only scored a")
|
||||
print("MESSAGE sink. The verdict below is resolved through the CALL SITES "
|
||||
"of the")
|
||||
print("returning method - a returned string is only in this family once "
|
||||
"some caller")
|
||||
print("hands it to an operator (contrast store.unit_key(), whose return "
|
||||
"value is a KEY).")
|
||||
cc = scan_returned()
|
||||
fam_c = [r for r in cc if r["verdict"] == "MESSAGE"]
|
||||
hot = [r for r in fam_c if r["spec_a"] == r["spec_b"]]
|
||||
print(f"returned sentences with a same-shape pair: {len(cc)} pair(s); "
|
||||
f"{len(fam_c)} reach an operator;")
|
||||
print(f"of those, {len(hot)} ALSO share a format spec - two values "
|
||||
f"rendered alike, which is")
|
||||
print("the hazard gap 4 below names: a transposition there is silent and "
|
||||
"reads as a fact.")
|
||||
print()
|
||||
print("*** = reaches an operator AND shares a format spec - THE ACTIONABLE "
|
||||
"SUBSET")
|
||||
print(" ! = reaches an operator, specs differ (a swap is likelier to look "
|
||||
"broken)")
|
||||
print(" = does not reach an operator, or could not be resolved")
|
||||
# grouped by the sentence, so the CONSUMERS list is printed once per
|
||||
# method rather than once per pair - the ungrouped form was 20 identical
|
||||
# lines under each of six pairs and nobody reads that.
|
||||
for key in sorted({(r["module"], r["line"], r["func"]) for r in cc}):
|
||||
mod, line, fname = key
|
||||
group = [r for r in cc if (r["module"], r["line"], r["func"]) == key]
|
||||
v = group[0]["verdict"]
|
||||
print(f" {mod}:{line} {fname}() verdict: {v}")
|
||||
for r in group:
|
||||
mark = ("***" if r["verdict"] == "MESSAGE"
|
||||
and r["spec_a"] == r["spec_b"]
|
||||
else " ! " if r["verdict"] == "MESSAGE" else " ")
|
||||
print(f" {mark} {r['a']} <-> {r['b']}")
|
||||
print(f" {_pair_line(r['spec_a'], r['spec_b']).strip()}")
|
||||
if not quiet:
|
||||
sites = group[0]["sites"]
|
||||
print(f" CONSUMERS ({len(sites)}) - bare-name match, so read "
|
||||
f"the receiver:")
|
||||
for label, sink, _v in sites or [("(none inside app/)", "", "")]:
|
||||
print(f" {label} -> {sink}")
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("WHAT THIS SCANS, AND WHAT IT DOES NOT - stated because round 4's "
|
||||
"blocker was")
|
||||
print("a completeness this file ASSERTED and did not have. The sink "
|
||||
"classifier is a")
|
||||
print("syntactic walk up the parent chain plus ONE step of local data "
|
||||
"flow, and it")
|
||||
print("recognises exactly these sinks and no others:")
|
||||
print(f" SCANNED as a MESSAGE : a call to {sorted(_LOG_SINKS)}")
|
||||
print(" ...or to `append`; a `raise`; an "
|
||||
"assignment to an")
|
||||
print(f" attribute or local named {sorted(_MSG_ATTRS)}")
|
||||
print(" ...or to a local that is then interpolated "
|
||||
"into one of")
|
||||
print(" those (the one data-flow step).")
|
||||
print(" SCANNED as a RETURN : census C - resolved through the returning "
|
||||
"method's")
|
||||
print(" call sites, one hop plus two of recursion.")
|
||||
print(" NOT SCANNED AT ALL, and each of these can hold a member:")
|
||||
lazy = lazy_log_sites()
|
||||
print(" * `%s`-style lazy logging - `_LOG.error('%s ... %s', a, b)`. The "
|
||||
"arguments")
|
||||
print(" are not an f-string, so no census here looks at them, and a "
|
||||
"transposition")
|
||||
print(" of two `%s` arguments is the same zero-byte edit. THIS IS THE "
|
||||
"LARGEST")
|
||||
print(f" REMAINING HOLE, and it is MEASURED, not asserted: "
|
||||
f"{len(lazy)} such call(s)")
|
||||
print(f" in app/, across "
|
||||
f"{len({s.split(':')[0] for s in lazy})} module(s). None of them is "
|
||||
f"scanned by anything")
|
||||
print(" above. Listed here so the size of the hole is a number:")
|
||||
for site in lazy or ["(none)"]:
|
||||
print(f" {site}")
|
||||
print(" * `.format()`, `%`-formatting, and sentences built by `+` or "
|
||||
"`.join()` out of")
|
||||
print(" non-f-string pieces.")
|
||||
print(" * A sentence that leaves the function inside a DICT, LIST or "
|
||||
"dataclass and is")
|
||||
print(" rendered somewhere else - the one data-flow step is local and "
|
||||
"follows only")
|
||||
print(" a bare Name.")
|
||||
print(" * `yield`ed sentences, and sentences stored on an object by any "
|
||||
"attribute")
|
||||
print(f" name outside {sorted(_MSG_ATTRS)}.")
|
||||
print(" * Anything outside `app/`: `sim/`, `tools/`, `web.py`'s "
|
||||
"templates, the")
|
||||
print(" translations JSON, the firmware, the witness.")
|
||||
print()
|
||||
print("WHAT IT CAN SEE BUT GETS WRONG - the shape of each gap, named "
|
||||
"rather than")
|
||||
print("rounded off:")
|
||||
print(" 1. SEMANTIC symmetry no canonicaliser above spells. The list of "
|
||||
"commutative")
|
||||
print(" forms is finite and hand-written; a function symmetric only "
|
||||
"through a form")
|
||||
print(" not on it is REJECTED and never appears. This under-reports.")
|
||||
print(" 2. In CENSUS B and C, two interpolated values of the same KIND "
|
||||
"that are not the")
|
||||
print(" same SHAPE - `a.lo` beside `hi`, or one of them behind a local "
|
||||
"variable.")
|
||||
print(" Only one-leaf-apart expressions are paired.")
|
||||
print(" 3. WHETHER each call site is pinned. The sites are listed above; "
|
||||
"whether a")
|
||||
print(" check asserts the ORDERED PAIR at each one is a fact about "
|
||||
"`test_*.py` and is")
|
||||
print(" recorded in DECISIONS/SAFETY-38-site-standing-pin.md section "
|
||||
"7. Call sites")
|
||||
print(" outside `app/` are not listed either, and every site list here "
|
||||
"matches on the")
|
||||
print(" bare METHOD NAME - three watchdogs are all called `evaluate` "
|
||||
"and at least")
|
||||
print(" four unrelated classes have an `explain()` - so the receiver is "
|
||||
"printed")
|
||||
print(" beside each one and a reader has to pick.")
|
||||
print(" 4. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is "
|
||||
"ERASED before")
|
||||
print(" census A's comparison, so a swap that would make the FORMATTING "
|
||||
"ITSELF fail")
|
||||
print(" - a `DAY_NAMES[start]` where `start` is 1320 - is scored as "
|
||||
"'changes no")
|
||||
print(" decision'. `_raise_gap`'s (start, weekday) pairs are that. The "
|
||||
"hazard is a")
|
||||
print(" SAME-TYPED pair, which is why every census B and C row now "
|
||||
"prints whether")
|
||||
print(" the two format specs match.")
|
||||
print(" 5. CENSUS C's verdict is a bare-name resolution (gap 3) and "
|
||||
"therefore reads")
|
||||
print(" MESSAGE too readily. Every verdict is printed with the sites "
|
||||
"that produced")
|
||||
print(" it; a MESSAGE verdict is a prompt to read them, not a proof.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -234,6 +234,9 @@ configuration:
|
||||
circulation_hold_s:
|
||||
name: "Safety · Circulating-power hold (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
|
||||
delivery_hold_s:
|
||||
name: "Safety · Not-delivering report after (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus 1890 runs at one inverter as the control, which report nothing at any threshold): the worst legitimate episode those runs produce is 30.0 s, the floor of 30 s is the firmware's own link veto below which a unit is already being dropped from the split, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 6.2 times that, and the margin is sized on a measurement rather than a feeling: the second inverter in those tests is a copy of the first, and a second unit four times slower produces 90.0 s. Do not change it without re-deriving it. A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
|
||||
auto_start:
|
||||
name: "Advanced · Start controlling automatically on boot"
|
||||
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
|
||||
@@ -242,7 +245,7 @@ configuration:
|
||||
description: "How much of the remaining error the loop corrects each cycle. MEASURED ON REAL HARDWARE and already at the limit - do not raise it. Lower is slower and safer."
|
||||
slew_w:
|
||||
name: "Advanced · Largest change per cycle (W)"
|
||||
description: "How far the setpoint may move in one control cycle. It bounds how fast the site can turn around, and several safety windows were measured across the whole range this accepts."
|
||||
description: "How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW THE INVERTER RATING and is the recommended setting: the limit becomes about a ninth of the rating, so the loop takes the plant's own MEASURED settling time to cross its whole output range and the number cannot be left behind when the rating changes. DERIVED (SAFETY-39) from an ~18 s measured step response against a 2 s meter tick. A fixed number is accepted and is never silently reduced, but a number faster than the rating allows is refused at startup - at 1000 W against a 5000 W rating this loop drove the meter from 3.4 kW of export to 2.8 kW of import on 2026-09-05. Lower is always accepted."
|
||||
deadband_w:
|
||||
name: "Advanced · Deadband (W)"
|
||||
description: "Errors smaller than this are ignored. ⚠️ The deadband is a one-way ratchet: any resting point inside it holds forever, and the meter's import register counts every positive watt with no export to cancel it - which is exactly why the resting point below is slightly negative."
|
||||
|
||||
@@ -214,6 +214,9 @@ configuration:
|
||||
circulation_hold_s:
|
||||
name: "Veiligheid · Wachttijd rondpompend vermogen (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
delivery_hold_s:
|
||||
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus 1890 tests met een omvormer als controlegroep, die bij geen enkele drempel iets melden): de langste terechte episode in die tests duurt 30.0 s, de ondergrens van 30 s is het eigen veto van de firmware waaronder een omvormer al uit de verdeling wordt gehaald, en de bovengrens is het factuurkwartier van 900 s. 185 s is 6,2 keer zoveel, en die marge is op een meting gebaseerd en niet op een gevoel: de tweede omvormer in die tests is een kopie van de eerste, en een tweede eenheid die vier keer trager is levert 90,0 s op. Verander dit niet zonder de afleiding opnieuw te maken. Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
|
||||
auto_start:
|
||||
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
|
||||
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."
|
||||
@@ -222,7 +225,7 @@ configuration:
|
||||
description: "Welk deel van de resterende afwijking de regeling per cyclus wegregelt. GEMETEN OP ECHTE HARDWARE en al aan de bovengrens - niet verhogen. Lager is trager en veiliger."
|
||||
slew_w:
|
||||
name: "Geavanceerd · Grootste verandering per cyclus (W)"
|
||||
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. Dit begrenst hoe snel de installatie kan omkeren; verschillende veiligheidsvensters zijn over het volledige toegelaten bereik gemeten."
|
||||
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. ⚠️ 0 BETEKENT VOLG HET OMVORMERVERMOGEN en is de aanbevolen instelling: de grens wordt ongeveer een negende van dat vermogen, zodat de regeling er de GEMETEN insteltijd van de installatie over doet om haar volledige bereik te doorlopen en het getal niet achterblijft wanneer het vermogen verandert. AFGELEID (SAFETY-39) uit een gemeten stapresponsie van ~18 s bij een meterinterval van 2 s. Een vast getal wordt aanvaard en wordt nooit stilzwijgend verlaagd, maar een getal sneller dan het vermogen toelaat wordt bij het starten geweigerd - bij 1000 W tegen 5000 W dreef deze regeling de meter op 2026-09-05 van 3,4 kW injectie naar 2,8 kW afname. Lager wordt altijd aanvaard."
|
||||
deadband_w:
|
||||
name: "Geavanceerd · Dode zone (W)"
|
||||
description: "Afwijkingen kleiner dan deze waarde worden genegeerd. ⚠️ De dode zone werkt maar één kant op: elk rustpunt erbinnen blijft eeuwig staan, en het afnameregister van de meter telt elke positieve watt mee zonder injectie die dat compenseert - en precies daarom staat het rustpunt hieronder licht negatief."
|
||||
|
||||
Reference in New Issue
Block a user