Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22ca341459 | ||
|
|
9c1a9e0f17 | ||
|
|
638c213944 | ||
|
|
1841416aa4 | ||
|
|
cb8f800180 | ||
|
|
018da00b74 | ||
|
|
e06906d65f | ||
|
|
37dd0f00cc | ||
|
|
1448bd909b | ||
|
|
a0d981731c | ||
|
|
b21e229adf | ||
|
|
2271e147c6 | ||
|
|
6719bd882f | ||
|
|
9099ceb1af | ||
|
|
295a2aedb6 |
@@ -46,8 +46,10 @@ threshold. Measured: a wedged inverter goes from named on 199 of 200 cycles to
|
|||||||
**0 of 200** at a load its sibling can carry alone. The alarm returns once the
|
**0 of 200** at a load its sibling can carry alone. The alarm returns once the
|
||||||
healthy pack saturates - swept, that is a 5000 W load on the fixture, **below**
|
healthy pack saturates - swept, that is a 5000 W load on the fixture, **below**
|
||||||
the proportional-regime formula's 6500 W and not above it. ⚠️ **N=1 IS
|
the proportional-regime formula's 6500 W and not above it. ⚠️ **N=1 IS
|
||||||
UNAFFECTED**: `allocator.plan()` returns before weights are read at all. The
|
UNAFFECTED**: `allocator.plan()` returns before weights are read at all.
|
||||||
closure is card SAFETY-43.
|
⚠️ **CLOSED BY SAFETY-43 ON THIS BRANCH** - see its section below; the regression
|
||||||
|
above is history, and the record of it is kept because the measurement that
|
||||||
|
found it is what the fix is graded against.
|
||||||
|
|
||||||
**PKG-09** adds `tools/predeploy_check.py`. ⚠️ **RUN IT BEFORE DEPLOYING THIS
|
**PKG-09** adds `tools/predeploy_check.py`. ⚠️ **RUN IT BEFORE DEPLOYING THIS
|
||||||
RELEASE** - it is what caught the fact that 0.8.0 had five runtime commits
|
RELEASE** - it is what caught the fact that 0.8.0 had five runtime commits
|
||||||
@@ -97,6 +99,65 @@ what happens when nobody does.
|
|||||||
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
|
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
|
||||||
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
|
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
|
||||||
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
|
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
|
||||||
|
| _this branch_ | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression |
|
||||||
|
|
||||||
|
⚠️ **SAFETY-43 CLOSES THE DETECTION REGRESSION OPT-09 DISCLOSED, AND IT IS A
|
||||||
|
TWO-INVERTER FIX. N=1 IS BYTE-IDENTICAL.** `delivery.delinquent()` named a unit
|
||||||
|
whose shortfall exceeded an **absolute** `min_w = saturation_w = 500 W`. OPT-09's
|
||||||
|
weights shrink a low-headroom unit's share, and shrinking the share shrinks the
|
||||||
|
gap - so a unit standing at +125 W and delivering **0 W**, 100 % delinquent, was
|
||||||
|
never named. It now names a unit whose gap exceeds `min_w` **OR** exceeds
|
||||||
|
`DELIVERY_SHARE_FRACTION` (0.85) of that unit's **own share**. Measured on the
|
||||||
|
same closed-loop fixture, same weights, same detector:
|
||||||
|
|
||||||
|
load 1500 W OPT-09 weights the old rule named on 0/200 cycles
|
||||||
|
load 1500 W OPT-09 weights the shipped one named on 200/200 cycles
|
||||||
|
|
||||||
|
⚠️ **`saturation_w` WAS NOT LOWERED, AND THAT IS THE POINT.** Lowering it would
|
||||||
|
report normal saturation as delinquency on every unit at once - SAFETY-41's
|
||||||
|
four-attempt false-trip regime, *"i'm getting really annoyed by the notifications
|
||||||
|
in my HA"*. This ADDS a second way to be named: all seven of `test_delivery.py`
|
||||||
|
part 1's existing fixtures answer identically under the old rule and the new one,
|
||||||
|
and the absolute arm's suppression window is still exactly 1000 W / 6500 W.
|
||||||
|
|
||||||
|
**0.85 is derived, not chosen.** `admin/goodwe:sim/delivery_window.py --campaign
|
||||||
|
--fractions` grades it over the SAME 2640 no-fault runs that produced
|
||||||
|
`delivery_hold_s`'s floor. The worst continuous legitimate episode is **35.0 s**
|
||||||
|
under the absolute arm alone (SAFETY-42 §3.3, reproduced in the same run) and
|
||||||
|
**40.0 s** at 0.85. ⚠️ **THE CURVE HAS NO CLIFF AND THAT IS THE FINDING**: swept
|
||||||
|
at twenty fractions from 0.05 to 0.99, everything from **0.45 to 0.99** is 40.0
|
||||||
|
or 45.0 s - within one control cycle of every other - and below 0.45 it rises
|
||||||
|
gradually to 90.0 s at 0.05. ⚠️ **"Zero false namings" is not achievable at any
|
||||||
|
fraction and the record says so**; what is zero is false ALARMS - the longest
|
||||||
|
legitimate episode in the whole sweep is 90.0 s against a 185 s hold, so nothing
|
||||||
|
comes within 95 s of notifying anybody at any fraction measured, and 145 s at the
|
||||||
|
shipped one. The N=1 control arm is 0.0 s at every fraction in all 2640
|
||||||
|
one-inverter runs, and the flapping family is 0.0 s in all 840.
|
||||||
|
|
||||||
|
⚠️ **[[CFG-07]] GETS ONE CONTROL CYCLE WORSE AND IS NOT FIXED HERE.**
|
||||||
|
`delivery_hold_s` is declared `int(30,900)` while the measured legitimate worst
|
||||||
|
was 35.0 s; it is now 40.0 s, so the band of schema-legal false-tripping values
|
||||||
|
widens from `[30, 35]` to `[30, 40]`. CFG-07 should be re-derived against 40.0 s.
|
||||||
|
⚠️ **IT MUST CLOSE BEFORE INVERTER B IS COMMISSIONED (~2026-09-12)**, not before
|
||||||
|
the next deploy: the band is UNREACHABLE at N=1 - `delinquent()` answers `{}` for
|
||||||
|
every input at a roster of one, so no `delivery_hold_s` can false-trip on the
|
||||||
|
install running today - and commissioning B is the moment it becomes reachable.
|
||||||
|
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE** - it exists as a card
|
||||||
|
reference here and in SAFETY-43's record and nowhere else, so whoever takes it
|
||||||
|
writes `DECISIONS/CFG-07-*.md` first.
|
||||||
|
**SAFETY-43 CHANGES NO OPTION VALUE, NO SCHEMA BOUND AND NO DEFAULT** - the
|
||||||
|
fraction is a derived constant with a committed harness behind it, deliberately
|
||||||
|
not a knob, for the reason there is no `delivery_w`. The one edit to
|
||||||
|
`config.yaml` is a COMMENT: the `delivery_hold_s` block described the check as
|
||||||
|
"short by more than `saturation_w`", which is now only half of it.
|
||||||
|
|
||||||
|
**No behaviour change at one inverter, asserted rather than argued**: `plan()` is
|
||||||
|
handed a `weights` mapping that raises if anything reads it and still answers
|
||||||
|
identically at a roster of one (and the same mapping raises at a roster of two,
|
||||||
|
so the tripwire is real), and `delinquent()` answers `{}` on all 84 single-unit
|
||||||
|
(share, measured, rule) combinations including 0 of 125 W.
|
||||||
|
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` carries the curve, the
|
||||||
|
positive controls and the CFG-07 assessment.
|
||||||
|
|
||||||
⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD
|
⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD
|
||||||
CLAIMED.** `delivery_hold_s: 185` shipped resting on *"30.0 s, the worst
|
CLAIMED.** `delivery_hold_s: 185` shipped resting on *"30.0 s, the worst
|
||||||
|
|||||||
@@ -161,17 +161,18 @@ its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after
|
|||||||
this card that ask does not arrive in the first place, which removes the benign
|
this card that ask does not arrive in the first place, which removes the benign
|
||||||
arm from the detector's input rather than teaching it a new discriminator.
|
arm from the detector's input rather than teaching it a new discriminator.
|
||||||
|
|
||||||
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWS SAFETY-42's DETECTION WINDOW. THIS
|
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWED SAFETY-42's DETECTION WINDOW.
|
||||||
IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE.** An earlier draft of
|
THAT WAS A DETECTION REGRESSION AT N=2, IT WAS NOT FIXED HERE, AND SAFETY-43
|
||||||
|
CLOSED IT - see the block at the end of this section.** An earlier draft of
|
||||||
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
|
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
|
||||||
at all". The second clause was **false**, and it was load-bearing for the claim
|
at all". The second clause was **false**, and it was load-bearing for the claim
|
||||||
that the two cards do not overlap. A weight is a function of the pack's ENERGY
|
that the two cards do not overlap. A weight is a function of the pack's ENERGY
|
||||||
HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever
|
HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever
|
||||||
its headroom differs from its sibling's - which is the ordinary case this card
|
its headroom differs from its sibling's - which is the ordinary case this card
|
||||||
exists to exploit. And `delivery.delinquent()` (`app/delivery.py:484`)
|
exists to exploit. And `delivery.delinquent()`
|
||||||
names a unit only while its `short_by()` gap exceeds `min_w =
|
named a unit only while its `short_by()` gap exceeded `min_w =
|
||||||
Tuning.saturation_w` - the comparison is `app/delivery.py:531`, and it is
|
Tuning.saturation_w`, and that was **500 W in absolute watts** with nothing
|
||||||
**500 W in absolute watts**. Shrinking a unit's share shrinks its gap. Shrink the
|
else beside it. Shrinking a unit's share shrinks its gap. Shrink the
|
||||||
share far enough and the gap drops below 500 W while the unit is still delivering
|
share far enough and the gap drops below 500 W while the unit is still delivering
|
||||||
nothing, and the alarm stops firing.
|
nothing, and the alarm stops firing.
|
||||||
|
|
||||||
@@ -200,20 +201,47 @@ a 9600 W site; above it - once the healthy pack is at its own rating and
|
|||||||
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
|
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
|
||||||
returns. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
returns. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||||
|
|
||||||
`test_packshare.py` part 8b grades all of it and **pins the current,
|
`test_packshare.py` part 8b graded all of it and **pinned the unflattering
|
||||||
unflattering behaviour**, so that the day it changes a named check moves. The
|
behaviour**, so that the day it changed a named check would move. It has moved.
|
||||||
closure - `delinquent()` measured against the ask rather than a flat 500 W - is
|
|
||||||
card **SAFETY-43**.
|
|
||||||
|
|
||||||
So the seam, stated accurately: **OPT-09 stops the ask AND narrows SAFETY-42's
|
### ⚠️ CLOSED BY SAFETY-43 - THIS IS NO LONGER A DISCLOSED RESIDUAL
|
||||||
detection window for a low-headroom unit.** The two cards do not merely meet from
|
|
||||||
opposite sides; this one takes something away from the other one. Closing the gap
|
This section used to end *"until then this is a disclosed residual [...] at two
|
||||||
means making `delinquent()` relative to the ask rather than absolute, which is
|
inverters, a wedged unit with materially less energy headroom than its sibling
|
||||||
SAFETY-42's code and a design change - it is filed as its own card, deliberately
|
can go unreported at ordinary house loads"*. That sentence is **retired, not
|
||||||
not done here, and this paragraph is its input. Until then this is a **disclosed
|
softened**, and what replaces it is a measurement rather than an intention.
|
||||||
residual**, in the same voice as SAFETY-13's "2 of 272" and SAFETY-42's own "the
|
|
||||||
discriminator does not exist": at two inverters, a wedged unit with materially
|
`delivery.delinquent()` is now relative to the ask **as well as** absolute:
|
||||||
less energy headroom than its sibling can go unreported at ordinary house loads.
|
|
||||||
|
named if short_by() > min_w OR short_by() > frac * share
|
||||||
|
|
||||||
|
with `frac = delivery.DELIVERY_SHARE_FRACTION`. A unit delivering 0 of 125 W is
|
||||||
|
100 % delinquent and is named; a unit delivering 0 of 1500 W was already named
|
||||||
|
and still is. On the **same fixture, under the same weights**, part 8b now
|
||||||
|
grades both rules in one run - `share_frac=None` is SAFETY-42's absolute arm,
|
||||||
|
exactly:
|
||||||
|
|
||||||
|
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
|
||||||
|
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
|
||||||
|
|
||||||
|
⚠️ **`min_w` WAS NOT LOWERED, AND THAT IS THE HALF THAT MATTERS HERE.** The
|
||||||
|
absolute arm's suppression window is still exactly the arithmetic above - 1000 W
|
||||||
|
at equal weights, 6500 W at these - and part 8b still asserts both numbers.
|
||||||
|
Lowering `saturation_w` would have traded a missed alarm for a false one on
|
||||||
|
every unit at once, which is SAFETY-41's four-attempt regime. SAFETY-43 added a
|
||||||
|
**second** way to be named and left the first where it was.
|
||||||
|
|
||||||
|
⚠️ **AND WHAT OPT-09 STILL DOES NOT DO IS UNCHANGED.** This card stops the site
|
||||||
|
ASKING a pack whose own SoC says it cannot deliver; it does not re-allocate a
|
||||||
|
wedged unit's watts, and SAFETY-43 did not make it. The alarm got louder; the
|
||||||
|
loop did not move. The derivation of `frac` - swept over the same 2640 no-fault
|
||||||
|
runs that produced `delivery_hold_s`'s floor - is in
|
||||||
|
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md`.
|
||||||
|
|
||||||
|
So the seam, stated accurately **as of SAFETY-43**: OPT-09 stops the ask, and it
|
||||||
|
still narrows the ABSOLUTE arm's window for a low-headroom unit exactly as
|
||||||
|
described above - but it no longer narrows the DETECTOR, because the detector no
|
||||||
|
longer reads only that arm.
|
||||||
|
|
||||||
⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without
|
⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without
|
||||||
connecting it.** That module's rejection of re-allocation rests on *"the only
|
connecting it.** That module's rejection of re-allocation rests on *"the only
|
||||||
|
|||||||
@@ -149,8 +149,70 @@ each swept across the **whole range `config.yaml` accepts** for the two settings
|
|||||||
that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and
|
that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and
|
||||||
`slew_w` `int(50,5000)`.
|
`slew_w` `int(50,5000)`.
|
||||||
|
|
||||||
|
### ⚠️ THE HARNESS BEHIND EVERY FIGURE ABOVE IS NOT COMMITTED, AND UNTIL 2026-09-07 THAT MADE THEM UNCHECKABLE
|
||||||
|
|
||||||
|
**Read this before quoting any number in this section.** The paragraph below
|
||||||
|
said the campaign harness "is not committed here" and treated that as a
|
||||||
|
packaging detail. It is not: a figure produced by a script that exists in
|
||||||
|
nobody's tree cannot be re-run, cannot be reviewed, and cannot be defended when
|
||||||
|
it is contradicted. It has now been contradicted twice.
|
||||||
|
|
||||||
|
| sweep | worst peak | worst continuous | trips |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| the 924-run campaign below — **harness uncommitted** | 1700 W | 505.0 s at 100 W; 5.0 s above 700 W | 0 / 924 |
|
||||||
|
| independent reviewer sweep, 2026-09-07, 48 honest reversals | **2750 W** | **15.0 s above 700 W** | 0 / 48 |
|
||||||
|
| `admin/goodwe` `two_master_circulation`, 840 runs — **committed** | 1048 W | 10.0 s at 100 W; 5.0 s above 700 W | **0 / 840** |
|
||||||
|
|
||||||
|
**This is not proof that anything was fabricated, and it should not be reported
|
||||||
|
as one.** The three swept different grids. What the third one adds is a
|
||||||
|
measurement of *why* the spread is that wide — on one grid, changing nothing but
|
||||||
|
the model:
|
||||||
|
|
||||||
|
| plant / stagger | worst legitimate peak |
|
||||||
|
|---|---:|
|
||||||
|
| modelled plant (lag 10 s, ramp 800 W/s), staggers up to a whole cycle | 1048 W |
|
||||||
|
| `INSTANT` (lag 0, unbounded ramp), staggers up to a whole cycle | 2250 W |
|
||||||
|
| `INSTANT`, staggers strictly under a whole cycle | **0 W** |
|
||||||
|
|
||||||
|
A factor of 2.1×, and a factor of infinity, between three defensible
|
||||||
|
descriptions of the same rig. **The plant model and the write-stagger set are
|
||||||
|
terms of the number**, and a worst case quoted without them is not a
|
||||||
|
measurement. That is enough to explain the whole spread above without anybody
|
||||||
|
having invented anything — and it is exactly what cannot be established when the
|
||||||
|
instrument is missing.
|
||||||
|
|
||||||
|
**What survives, and what does not.**
|
||||||
|
|
||||||
|
* **The window argument survives, and is now committed.** The claim that
|
||||||
|
decides `circulation_hold_s: 75` is *"the worst continuous legitimate episode
|
||||||
|
above 700 W is about one control cycle"*. All three sweeps agree on the shape
|
||||||
|
of that (5.0 s, 15.0 s, 5.0 s — one to three cycles against a fifteen-cycle
|
||||||
|
window), and **all three trip zero times**. `two_master_circulation` re-runs
|
||||||
|
it on every pull request in `admin/goodwe`.
|
||||||
|
* **The threshold curve does NOT survive as stated.** `505.0 s at 100 W` is the
|
||||||
|
number the "knee at 400 W" argument for `circulation_w: 700` rests on, and the
|
||||||
|
committed sweep measures **10.0 s** at 100 W on its grid — 50× smaller. The
|
||||||
|
curve still *falls* with the threshold (126 of 840 runs cross 100 W against 1
|
||||||
|
that crosses 700 W), so the knee is real in direction; its depth is not
|
||||||
|
established by anything anybody can run. **Do not quote 505.0 s.**
|
||||||
|
* **Nothing about the guard is compromised.** 0 trips in 924 + 48 + 840
|
||||||
|
non-faulty runs, from three independent grids.
|
||||||
|
|
||||||
|
**What would settle it:** the original harness, committed. Until then the
|
||||||
|
committed sweep is the only figure in this section anybody can check, and it is
|
||||||
|
labelled as its own grid's rather than as a reproduction.
|
||||||
|
|
||||||
### Reproducing it
|
### Reproducing it
|
||||||
|
|
||||||
|
⚠️ **THE COMMITTED VERSION IS `admin/goodwe`'s `sim/inject.py
|
||||||
|
two_master_circulation`** (`sim/sitesim.py:circulation_findings`, runnable on its
|
||||||
|
own as `python3 sim/sitesim.py --circulation`, `norig`, ~36 s, graded by
|
||||||
|
`checks.yml` on every pull request there). It produces the fault with
|
||||||
|
`plantsim.Inverter.wedge()` — a unit answering its bus and no longer steering its
|
||||||
|
power stage, which is HW-01 — and sweeps the legitimate family with the
|
||||||
|
serialised write path staggered up to a whole control cycle. The paragraph below
|
||||||
|
describes the ORIGINAL campaign, which is the one that is not committed.
|
||||||
|
|
||||||
The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness
|
The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness
|
||||||
is not committed here because it imports that repo's `plantsim`/`frame_check`,
|
is not committed here because it imports that repo's `plantsim`/`frame_check`,
|
||||||
which are not present in this one or in CI. What it does is small enough to
|
which are not present in this one or in CI. What it does is small enough to
|
||||||
@@ -206,7 +268,11 @@ margin above the knee is what absorbs that without re-opening this card.
|
|||||||
**Revisit when SPIKE-04 lands, not before.**
|
**Revisit when SPIKE-04 lands, not before.**
|
||||||
|
|
||||||
The **schema floor is the knee itself**, 400 W, so a legally configured install
|
The **schema floor is the knee itself**, 400 W, so a legally configured install
|
||||||
cannot land in the 445–505 s regime. What the threshold costs: a circulating
|
cannot land in the 445–505 s regime — ⚠️ and those two figures are from the
|
||||||
|
uncommitted campaign; the committed sweep measures 10.0 s at 100 W on its own
|
||||||
|
grid. See the disclosure at the head of this section. The floor is still the
|
||||||
|
right shape (fewer runs cross a higher threshold, measured 126/840 at 100 W
|
||||||
|
against 1/840 at 700 W); its DEPTH is not established by anything re-runnable. What the threshold costs: a circulating
|
||||||
loop smaller than 350 W per unit is not reported, which is deliberate — at that
|
loop smaller than 350 W per unit is not reported, which is deliberate — at that
|
||||||
scale the round-trip loss is a few watts.
|
scale the round-trip loss is a few watts.
|
||||||
|
|
||||||
@@ -289,6 +355,32 @@ SAFETY-25 — so no stale `.pyc` could exist to be read. Byte deltas recorded;
|
|||||||
| M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check |
|
| M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check |
|
||||||
| M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks |
|
| M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks |
|
||||||
| M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED |
|
| M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED |
|
||||||
|
| **M20** | `main.py` | **`pos, neg = opposing(...)` → `([], [])`** — the attribution clause deleted from the alarm | −102 B | **SURVIVED ALL 26 FILES on the shipped battery — GAP CLOSED**, then RED on §6a |
|
||||||
|
| M21 | `main.py` | `pos` and `neg` exchanged — the alarm names both packs, both the wrong way round | **+0 B** | **RED** — §6a ×3 (the mirrored run, and "only its own sentence") |
|
||||||
|
| M22 | `main.py` | `opposing(..., min_w=cfg.min_w / 2.0)` → `min_w=0.0` | −25 B | **RED** — §6a's resting-pack check |
|
||||||
|
|
||||||
|
**And four in `admin/goodwe`**, graded through `python3 sim/sitesim.py
|
||||||
|
--circulation` rather than through the injector — deliberately, because
|
||||||
|
`inject.py` is the file that takes the rig lock, and "run the injector to grade a
|
||||||
|
mutation" is not a sentence anybody should have to type. Byte deltas over
|
||||||
|
`plantsim.py` + `sitesim.py` together.
|
||||||
|
|
||||||
|
| # | file | mutation | Δ | result |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| S1 | `plantsim.py` | `wedge()` no longer stops the latch reaching the power stage | −20 B | **RED** — the opposing-signs precondition **and** "the watchdog did not trip" |
|
||||||
|
| S2 | `sitesim.py` | the sensor computed over the **allocation record** instead of register 0x0518 | **+0 B** | **RED** ×3 — this is M10 restated on the rig |
|
||||||
|
| S3 | `sitesim.py` | the campaign run on `plant=INSTANT` | −51 B | **RED** — the plant is a *term* of the figures; it was a SURVIVOR until that check was added, and finding out why produced §3's 1048/2250/0 W table |
|
||||||
|
| S4 | `sitesim.py` | the write stagger removed from the legitimate campaign | −19 B | **RED** — "the legitimate campaign produced no opposition at all" |
|
||||||
|
|
||||||
|
**S3 is recorded as a survivor because it was one, and it is the most useful
|
||||||
|
result in this table.** The scenario first shipped with a vacuity check whose
|
||||||
|
text claimed `plant=INSTANT` would make the sweep measure 0 W. S3 was run and
|
||||||
|
**passed**: `INSTANT` with a stagger of exactly one control cycle samples the
|
||||||
|
plant before the second unit has stepped, so it produces opposition anyway — and
|
||||||
|
a *larger* worst peak (2250 W) than the modelled plant does (1048 W). The claim
|
||||||
|
in the check's own text was wrong. It was corrected against the measurement, the
|
||||||
|
plant was pinned as an explicit term, and S3 is red now for a stated reason
|
||||||
|
rather than by accident. §3's spread table is that measurement.
|
||||||
|
|
||||||
**M19 is recorded as a survivor because it was one.** The first battery found no
|
**M19 is recorded as a survivor because it was one.** The first battery found no
|
||||||
check grading what `publish()` does when a per-unit reading is missing, so
|
check grading what `publish()` does when a per-unit reading is missing, so
|
||||||
@@ -297,6 +389,30 @@ watches went unnoticed — the sensor's own `None` rule, defeated one layer out.
|
|||||||
A check was added and the mutation re-run; it is red now. The run that found it
|
A check was added and the mutation re-run; it is red now. The run that found it
|
||||||
was purged, so it is a result and not an artefact.
|
was purged, so it is a result and not an artefact.
|
||||||
|
|
||||||
|
**M20 IS RECORDED AS A SURVIVOR BECAUSE IT WAS ONE, AND IT IS THE MORE
|
||||||
|
UNCOMFORTABLE OF THE TWO.** The commit message for this card claimed *"18
|
||||||
|
mutations, all RED"*, and M1–M19 above have no entry for the notification's
|
||||||
|
attribution clause. Verification on 2026-09-07 ran it and it **survived the
|
||||||
|
whole 26-file suite**: the alarm still fires, still stops the site, still says
|
||||||
|
the meter cannot see it — and no longer says *which two packs* or *which way
|
||||||
|
round*. `circulation.py`'s own docstring calls that sentence the first thing
|
||||||
|
somebody woken by this alarm needs and *"impossible to reconstruct from the
|
||||||
|
number alone"*.
|
||||||
|
|
||||||
|
It survived for one reason, and it is not reachability: **M18 (the whole call
|
||||||
|
removed) is red**, so `check_circulating()` is exercised. The three checks on
|
||||||
|
that notification asserted `METER CANNOT SEE`, `CONTROL HAS BEEN STOPPED` and
|
||||||
|
`isolator` — and **never an inverter name**. Nothing graded the attribution.
|
||||||
|
|
||||||
|
`test_watchdog.py` §6a closes it, and closes it on a fixture that can tell the
|
||||||
|
three failures apart. It runs a roster of **`["garage", "shed", "cellar"]`** —
|
||||||
|
operator names, none of them `UNIT_A`, for test_maintenance.py §11's reason:
|
||||||
|
on a fixture whose names are the module's own constants a check cannot tell the
|
||||||
|
roster from the constant. It runs the site **twice, mirrored**, so the sentence
|
||||||
|
is not symmetric and a swap (M21, `+0 B`) is a different red from a deletion.
|
||||||
|
And it carries a **third pack resting at 5 W**, so the floor that stops a pack
|
||||||
|
in standby being named as a participant in a 6 kW loop (M22) is graded too.
|
||||||
|
|
||||||
## 7. What this card does NOT close
|
## 7. What this card does NOT close
|
||||||
|
|
||||||
* **Inverter B's ramp and lag are placeholders.** Every transient number in §3
|
* **Inverter B's ramp and lag are placeholders.** Every transient number in §3
|
||||||
@@ -305,7 +421,9 @@ was purged, so it is a result and not an artefact.
|
|||||||
margin. **Do not present any figure here as a measurement of inverter B.**
|
margin. **Do not present any figure here as a measurement of inverter B.**
|
||||||
* **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the
|
* **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the
|
||||||
notification says so; there is no software fix and this card does not pretend
|
notification says so; there is no software fix and this card does not pretend
|
||||||
to one.
|
to one. It *can* now be produced on demand: `plantsim.Inverter.wedge()`.
|
||||||
|
* **The depth of the threshold curve is not established by anything committed.**
|
||||||
|
§3's disclosure. The window is; the knee's magnitude is not.
|
||||||
* **A loop below ~350 W per unit is not reported.** Deliberate, §3.
|
* **A loop below ~350 W per unit is not reported.** Deliberate, §3.
|
||||||
* **The saturation freeze fires under a wedged unit and leaves a standing grid
|
* **The saturation freeze fires under a wedged unit and leaves a standing grid
|
||||||
error.** Observed on the rig while producing §2's numbers: with B wedged, the
|
error.** Observed on the rig while producing §2's numbers: with B wedged, the
|
||||||
|
|||||||
@@ -142,6 +142,16 @@ take the **longest run of consecutive cycles** on which it names anybody — not
|
|||||||
count of naming samples, because the window is cleared by any cycle that does
|
count of naming samples, because the window is cleared by any cycle that does
|
||||||
not.
|
not.
|
||||||
|
|
||||||
|
⚠️ **SINCE [[SAFETY-43]] THE ROWS OF §3.3's TABLE ARE GRADED WITH
|
||||||
|
`share_frac=None`** — `delinquent()`'s ABSOLUTE arm alone, which is this card's
|
||||||
|
rule exactly. Said here because it is the difference between §3.3 still meaning
|
||||||
|
what it is labelled as and quietly becoming a table about a different predicate:
|
||||||
|
SAFETY-43 added a SECOND way to be named, the harness grades it in its own rows
|
||||||
|
(`--fractions`), and the numbers below are unchanged and were re-measured to
|
||||||
|
confirm it. `DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` §3 carries
|
||||||
|
those rows and what they cost — the worst legitimate episode goes from 35.0 s to
|
||||||
|
40.0 s at the shipped fraction, which does not move `delivery_hold_s`.
|
||||||
|
|
||||||
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
|
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
|
||||||
command standing before the write" is the **controller's** standing command —
|
command standing before the write" is the **controller's** standing command —
|
||||||
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
|
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
# SAFETY-43 — delinquency measured against the ask, not against a flat 500 W
|
||||||
|
|
||||||
|
**Status: closed.** `delivery.delinquent()` names a unit whose `short_by()` gap
|
||||||
|
exceeds `min_w` **or** exceeds a fraction of that unit's own share. The fraction
|
||||||
|
is `delivery.DELIVERY_SHARE_FRACTION = 0.85`, derived on the campaign in §3.
|
||||||
|
|
||||||
|
⚠️ **INVERTER B DOES NOT PHYSICALLY EXIST** (expected ~2026-09-12; NOTES.md
|
||||||
|
§1.1b). `plantsim.INVERTER_B` is inverter A **copied**, `measured=False`, and
|
||||||
|
`test_packshare.py`'s plant is modelled in that file. **Every two-inverter
|
||||||
|
figure in this document is two copies of A.** Nothing here is a measurement of
|
||||||
|
inverter B.
|
||||||
|
|
||||||
|
## 0. The defect, and why it is the quiet regime that matters
|
||||||
|
|
||||||
|
[[OPT-09]] passes SoC- and capacity-aware weights to the allocator. A weight is
|
||||||
|
a function of a pack's **energy headroom**, not of what the pack delivers, so a
|
||||||
|
**wedged** unit — one that accepts its share and produces nothing — is weighted
|
||||||
|
down exactly like a legitimately low-headroom one. `delinquent()` read an
|
||||||
|
**absolute** gap, `min_w = Tuning.saturation_w = 500 W`. Shrinking the share
|
||||||
|
shrinks the gap. Measured end to end on the real loop (`test_packshare.py`
|
||||||
|
part 8b: real `compute()`, real `plan()`, real `packshare.weights()`, real
|
||||||
|
`delivery.account()`, real `delinquent()`; B wedged at a healthy 15 % against its
|
||||||
|
own 10 % cut-out, so no envelope closes and `account()` explains nothing — a
|
||||||
|
SAFETY-42 **true positive** by that card's own definition):
|
||||||
|
|
||||||
|
load 1500 W equal weights shares {a:1500, b:1500} named on 199/200
|
||||||
|
load 1500 W OPT-09 weights shares {a:1505, b: 125} named on 0/200
|
||||||
|
|
||||||
|
A unit delivering **0 of 125 W** is 100 % delinquent and 125 W short. Only the
|
||||||
|
absolute threshold disagreed.
|
||||||
|
|
||||||
|
⚠️ **THE SUPPRESSED REGIME IS THE DANGEROUS ONE.** At a load the healthy sibling
|
||||||
|
covers, the meter is satisfied (grid within 5 W of zero on that fixture),
|
||||||
|
`unmet_w` is 0 because both units *accepted* their shares, no envelope closes and
|
||||||
|
no action-log line fires. The delinquency alarm is the **only** instrument that
|
||||||
|
would have told the operator the site was quietly running one inverter.
|
||||||
|
|
||||||
|
## 1. What was NOT done, and it is the larger half of this card
|
||||||
|
|
||||||
|
⚠️ **`min_w` WAS NOT LOWERED.** It is `Tuning.saturation_w` — the gap at which
|
||||||
|
the control law itself concludes the inverter is not following — and it exists to
|
||||||
|
stop **normal saturation** being reported as delinquency. Lowering it trades a
|
||||||
|
missed alarm for a false one on every unit at once. [[SAFETY-41]] spent **four
|
||||||
|
attempts** on notification cadence in this system and the owner's words were
|
||||||
|
*"i'm getting really annoyed by the notifications in my HA"*.
|
||||||
|
|
||||||
|
So the change is an **`or`**, never a looser first arm:
|
||||||
|
|
||||||
|
named if short_by() > min_w OR short_by() > frac * share
|
||||||
|
|
||||||
|
Everything `min_w` named before is still named. Nothing `min_w` refused becomes
|
||||||
|
noisy **unless the unit is delivering almost none of its ask**, which is not
|
||||||
|
saturation-shaped at all. `test_delivery.py` part 1 runs all seven of its
|
||||||
|
existing `delinquent()` fixtures under **both** rules — `share_frac=None` is
|
||||||
|
SAFETY-42's rule exactly — and requires identical answers on every one.
|
||||||
|
|
||||||
|
The absolute arm's suppression window is therefore **unchanged**: still
|
||||||
|
`site target < 2·min_w` = 1000 W at equal weights, still
|
||||||
|
`site target < min_w·(w_A+w_B)/w_B` = 6500 W under OPT-09's weights on part 8b's
|
||||||
|
fixture. Part 8b still asserts both numbers, now as evidence that the first arm
|
||||||
|
did not move.
|
||||||
|
|
||||||
|
## 2. The evidence is a diff between two rules on ONE run
|
||||||
|
|
||||||
|
`delinquent()` takes `share_frac`, and **`None` means the absolute arm alone**.
|
||||||
|
That parameter exists for one reason: so the red and the green come out of one
|
||||||
|
run over one tree instead of out of two commits that a reader has to trust.
|
||||||
|
`test_packshare.py` part 8b grades every cycle twice:
|
||||||
|
|
||||||
|
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
|
||||||
|
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
|
||||||
|
|
||||||
|
load 1500 W equal weights absolute arm alone named on 199/200 cycles
|
||||||
|
load 1500 W equal weights the shipped rule named on 200/200 cycles
|
||||||
|
|
||||||
|
The equal-weights arm gains **exactly one cycle** — the first, where the slew
|
||||||
|
limit holds the split at 500/500, a 500 W gap is not *over* a 500 W threshold
|
||||||
|
(the absolute bound is strict) and the relative arm names a box delivering none
|
||||||
|
of its 500 W. That single cycle is the whole of what this card costs an arm that
|
||||||
|
was already firing.
|
||||||
|
|
||||||
|
⚠️ **THE PIN THAT INVERTED.** Part 8b was written by OPT-09 to pin the
|
||||||
|
**suppressed** behaviour, and its comment said: *"if it ever goes red because the
|
||||||
|
alarm fired, read the card that made it fire before 'fixing' this line."* This is
|
||||||
|
that card. The pin now asserts the opposite answer and keeps the old one beside
|
||||||
|
it, measured rather than remembered.
|
||||||
|
|
||||||
|
## 3. ⭐ Deriving the fraction
|
||||||
|
|
||||||
|
⚠️ **DERIVED ON THE SAME CAMPAIGN AS `delivery_hold_s`'s FLOOR**, not chosen.
|
||||||
|
`admin/goodwe:sim/delivery_window.py` — the harness [[TEST-21]] committed —
|
||||||
|
gained a second grading axis: the same **2640 no-fault runs at N=2** (three
|
||||||
|
families × 6 gains × 5 slews × write staggers; every unit executes the share the
|
||||||
|
allocator gave it) and the same **2640 N=1 control runs**, graded at the shipped
|
||||||
|
`min_w = 500 W` for each candidate fraction, on every cycle of every run — so a
|
||||||
|
pass grades every fraction it is given, and the absolute arm beside them, over
|
||||||
|
one tree and one set of runs. Every row in a pass is comparable to every other
|
||||||
|
row in it, run for run.
|
||||||
|
|
||||||
|
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||||
|
python3 -B delivery_window.py --selftest --campaign --fractions
|
||||||
|
|
||||||
|
⚠️ **THE TABLE BELOW WAS PRODUCED BY TWO PASSES; `FRACTIONS` NOW HOLDS ALL
|
||||||
|
TWENTY SO IT TAKES ONE.** The derivation ran the shipped ten
|
||||||
|
`(0.25 … 0.99)` and then the ten that resolve the low end
|
||||||
|
`(0.05 … 0.65)` with only that tuple edited between them. **The absolute-arm
|
||||||
|
control row came out identical in both** (30.0 / 0.0 / 35.0 / 0.0, same argmax),
|
||||||
|
which is what says the two passes are one campaign. A table a reviewer cannot
|
||||||
|
reproduce without knowing to edit a line nobody wrote down is PROC-01's shape in
|
||||||
|
miniature, so the committed `FRACTIONS` was widened to all twenty in review: the
|
||||||
|
command above now reproduces every row below.
|
||||||
|
|
||||||
|
### 3.1 The curve
|
||||||
|
|
||||||
|
Worst **continuous** legitimate episode (the measure `DeliveryWatchdog` actually
|
||||||
|
sees — its window is cleared by any cycle that does not name), runs naming in
|
||||||
|
brackets:
|
||||||
|
|
||||||
|
| `share_frac` | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640)** |
|
||||||
|
|---:|---:|---:|---:|---:|
|
||||||
|
| absolute only | 30.0 s (460) | 0.0 s (0) | 35.0 s (403) | **0.0 s (0)** |
|
||||||
|
| 0.05 | 85.0 s (826) | 0.0 s (0) | 90.0 s (616) | **0.0 s (0)** |
|
||||||
|
| 0.10 | 75.0 s (824) | 0.0 s (0) | 90.0 s (607) | **0.0 s (0)** |
|
||||||
|
| 0.15 | 70.0 s (836) | 0.0 s (0) | 70.0 s (609) | **0.0 s (0)** |
|
||||||
|
| 0.20 | 70.0 s (839) | 0.0 s (0) | 60.0 s (629) | **0.0 s (0)** |
|
||||||
|
| 0.25 | 55.0 s (832) | 0.0 s (0) | 60.0 s (611) | **0.0 s (0)** |
|
||||||
|
| 0.30 | 55.0 s (840) | 0.0 s (0) | 50.0 s (614) | **0.0 s (0)** |
|
||||||
|
| 0.35 | 55.0 s (840) | 0.0 s (0) | 55.0 s (618) | **0.0 s (0)** |
|
||||||
|
| 0.40 | 50.0 s (840) | 0.0 s (0) | 60.0 s (658) | **0.0 s (0)** |
|
||||||
|
| 0.45 | 45.0 s (840) | 0.0 s (0) | 45.0 s (627) | **0.0 s (0)** |
|
||||||
|
| 0.50 | 45.0 s (840) | 0.0 s (0) | 45.0 s (636) | **0.0 s (0)** |
|
||||||
|
| 0.55 | 40.0 s (840) | 0.0 s (0) | 40.0 s (636) | **0.0 s (0)** |
|
||||||
|
| 0.60 | 40.0 s (840) | 0.0 s (0) | 40.0 s (645) | **0.0 s (0)** |
|
||||||
|
| 0.65 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||||
|
| 0.70 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||||
|
| 0.75 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||||
|
| 0.80 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||||
|
| **0.85** | **35.0 s (840)** | **0.0 s (0)** | **40.0 s (635)** | **0.0 s (0)** |
|
||||||
|
| 0.90 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||||
|
| 0.95 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||||
|
| 0.99 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||||
|
|
||||||
|
Worst of the three families, which is what `delivery_hold_s` has to cover:
|
||||||
|
|
||||||
|
frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
|
||||||
|
worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
|
||||||
|
|
||||||
|
frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
|
||||||
|
worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
|
||||||
|
|
||||||
|
⚠️ **THE FIRST ROW IS THE CONTROL AND IT REPRODUCED SAFETY-42 §3.3 EXACTLY** —
|
||||||
|
30.0 s / 0.0 s / 35.0 s, and the same argmax (`gain=0.2 slew_w=1000 stagger=4
|
||||||
|
split=0.10/0.90`, ±2200 W reversal). It is measured **in the same run** as every
|
||||||
|
row below it, which is what makes those rows a comparison rather than a claim.
|
||||||
|
|
||||||
|
### 3.2 What the curve says
|
||||||
|
|
||||||
|
* ⚠️ **THERE IS NO CLIFF ANYWHERE IN THE CURVE, AND THAT IS THE FINDING.** From
|
||||||
|
**0.45 to 0.99** every figure is 40.0 or 45.0 s — within **one control cycle
|
||||||
|
(5.0 s)** of every other — and below 0.45 it rises **gradually**: 60.0 s at
|
||||||
|
0.25, 70.0 s at 0.15, 90.0 s at 0.05. A reviewer asking "why 0.85 and not
|
||||||
|
0.90?" is asking about a difference this campaign cannot resolve, and the
|
||||||
|
honest answer is that it cannot.
|
||||||
|
* ⚠️ **IT IS NOT MONOTONE** — 0.40 reports 60.0 s where 0.35 reports 55.0, and
|
||||||
|
0.65–0.80 report 45.0 s where 0.55/0.60 and 0.85–0.99 report 40.0 — and that is
|
||||||
|
SAFETY-42 §3.3's own mechanism (c) rather than noise in the plant: **a more
|
||||||
|
sensitive rule puts MORE units into the short set, which can turn a proper
|
||||||
|
subset into the WHOLE roster and END the episode.** The reduction is not
|
||||||
|
monotone in its own threshold. Do not read this column as a sensitivity curve
|
||||||
|
in the ordinary sense.
|
||||||
|
* **The shoulder is at about 0.45**, and it is a shoulder rather than an edge:
|
||||||
|
the reversal family's naming count saturates at 840 of 1050 runs from 0.30
|
||||||
|
upward, so below the shoulder what grows is the LENGTH of an episode, not the
|
||||||
|
number of runs that have one.
|
||||||
|
* **The N=1 column is 0.0 s at every fraction, in all 2640 one-inverter runs.**
|
||||||
|
The compatibility promise is measured for the new arm, not argued.
|
||||||
|
* **The flapping family is 0.0 s at every fraction, in all 840 runs.** A common
|
||||||
|
stimulus makes both units short at once and the subset stops being proper —
|
||||||
|
the property that defeated SAFETY-30's site window still holds.
|
||||||
|
|
||||||
|
### 3.3 ⚠️ "Zero false namings" is not available at ANY fraction
|
||||||
|
|
||||||
|
The card asked for *the largest fraction that yields zero false namings across
|
||||||
|
the whole no-fault campaign*. **There is none.** Every fraction adds naming
|
||||||
|
cycles: the reversal family goes from 460 runs naming to 840, the weighted family
|
||||||
|
from 403 to 635. A relative arm that never fired on a legitimate transient would
|
||||||
|
be a relative arm that never fired at all — the write stagger lands B's number up
|
||||||
|
to four cycles late, and a unit whose new command has not arrived is, for those
|
||||||
|
cycles, delivering a small fraction of what is standing on its wire.
|
||||||
|
|
||||||
|
What **is** zero at every fraction is **false alarms**:
|
||||||
|
|
||||||
|
> the longest continuous legitimate episode anywhere in the 20-point sweep is
|
||||||
|
> **90.0 s** — at 0.05, the most sensitive fraction measured — against a
|
||||||
|
> `delivery_hold_s` of **185 s**. Nothing in 2640 no-fault runs comes within
|
||||||
|
> **95 s** of notifying an operator, **at any fraction measured**. At the
|
||||||
|
> shipped 0.85 it is 40.0 s, i.e. 145 s of slack.
|
||||||
|
|
||||||
|
That is the quantity SAFETY-41 cares about and it is the one this card had to
|
||||||
|
protect. Stated as margin, at the shipped fraction: **185 s is 4.62×** the worst
|
||||||
|
legitimate episode, where the absolute arm alone gave 5.29×.
|
||||||
|
|
||||||
|
### 3.4 The bounds 0.85 answers to
|
||||||
|
|
||||||
|
* **LOWER, from the campaign** — the shoulder at about **0.45**, below which the
|
||||||
|
added episode starts to grow (60.0 s at 0.25, 90.0 s at 0.05). 0.85 is 0.40
|
||||||
|
above it.
|
||||||
|
* **UPPER, from the card's own case** — strictly **below 1.0**. At 1.0 the arm
|
||||||
|
can only fire on a unit that is *importing* while asked to discharge, which is
|
||||||
|
SAFETY-30's fault and not this one, so a unit delivering **none** of its share
|
||||||
|
(gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
|
||||||
|
wedged box reporting a small standby figure instead of a clean zero.
|
||||||
|
* **UPPER, from the hold** — every fraction in the flat range leaves
|
||||||
|
`delivery_hold_s` between **4.11×** (45.0 s) and **4.62×** (40.0 s) the worst
|
||||||
|
legitimate episode, well inside the four bounds SAFETY-42 §3.3 requires of it.
|
||||||
|
**`delivery_hold_s` IS NOT CHANGED BY THIS CARD.**
|
||||||
|
* ⚠️ **NOT DERIVED, AND SAID SO: the exact value inside [0.45, 1.0).** The
|
||||||
|
campaign cannot separate them. 0.85 is the midpoint of the argument above —
|
||||||
|
0.40 of margin above the shoulder, 0.15 below the ceiling — and a
|
||||||
|
re-derivation that moved it to 0.90 would be moving a number this campaign
|
||||||
|
never resolved. It is not moved for that reason.
|
||||||
|
|
||||||
|
## 4. The positive control — what the relative arm must still NOT say
|
||||||
|
|
||||||
|
A card that adds a way to raise an alarm has to show what it did **not** add.
|
||||||
|
`test_packshare.py` part 8b runs the same fixture, the same weights and the same
|
||||||
|
125 W share with the one fact that makes the shortfall legitimate changed:
|
||||||
|
|
||||||
|
| fixture | B's share | named | why it is silent |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B wedged at a healthy 15 % (the fault) | 77–154 W | **200 / 200** | — it is the fault |
|
||||||
|
| B at its own 10 % cut-out | **0 W, all 200 cycles** | **0 / 200** | it is never **asked** |
|
||||||
|
| B delivering **half** its share | 120 W | **0 / 200** | short by 50 %, inside both arms |
|
||||||
|
|
||||||
|
### ⚠️ The cut-out row does NOT grade `account()`, and this record said it did
|
||||||
|
|
||||||
|
**Corrected in review.** The first draft of this section claimed the wedged and
|
||||||
|
cut-out arms had *"identical power traces — 0 W against a positive share, 100 %
|
||||||
|
of the ask missing"* and that *"what separates them is `account()`"*. **Every
|
||||||
|
part of that was false**, and the check that carried it was **vacuous**:
|
||||||
|
|
||||||
|
* at its own cut-out the pack's usable energy **above that cut-out is zero**, so
|
||||||
|
`packshare.weights()` answers **0.0** for it, `_fill()`'s preferred pass gives
|
||||||
|
it nothing, the healthy sibling covers the load alone so the last-resort pass
|
||||||
|
never runs, and its share is **0 W on all 200 cycles**;
|
||||||
|
* a unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns `{}`
|
||||||
|
**before either arm is evaluated** and `account()` is **never consulted**;
|
||||||
|
* the traces are not identical — the cut-out arm's share is **0 W on every
|
||||||
|
cycle** (asserted in part 8b and printed on its line) while the wedged arm's
|
||||||
|
is **125 W** at the last cycle (asserted in the GREEN check) and takes four
|
||||||
|
distinct values 77/125/133/154 W across the 200, measured by re-running the
|
||||||
|
same loop with per-cycle recording during review — and "100 % of the ask" is
|
||||||
|
0/0.
|
||||||
|
|
||||||
|
Measured, by deleting the gate the claim rested on:
|
||||||
|
|
||||||
|
cut-out arm, account() live named 0/200, distinct B shares: [0]
|
||||||
|
cut-out arm, account() DELETED named 0/200, distinct B shares: [0]
|
||||||
|
wedged arm, account() live named 200/200, shares [77,125,133,154]
|
||||||
|
|
||||||
|
⚠️ **The fact that replaces it is better, not weaker.** At its own cut-out
|
||||||
|
**OPT-09 stops asking the pack at all**, so SAFETY-43's relative arm is never
|
||||||
|
reached — a **second, independent protection sitting upstream of `account()`**:
|
||||||
|
the allocator declines to ask before the detector has to decline to alarm. Part
|
||||||
|
8b now asserts the 0 W share and prints it, and runs the arm a second time with
|
||||||
|
`account()` deleted to show which mechanism is doing the work.
|
||||||
|
|
||||||
|
⚠️ **It was invisible for one reason worth keeping**: that line was the only one
|
||||||
|
in part 8b that did not print its shares. `inverter-b+0` would have been on
|
||||||
|
screen from the first run. Every line in that part prints them now, and the
|
||||||
|
derated control's vacuity guard now asserts **its own** unit is asked, rather
|
||||||
|
than re-asserting a different run's 200/200 — which is exactly why the old guard
|
||||||
|
could not see this.
|
||||||
|
|
||||||
|
**The account gate is graded where it is real**, on a positive 125 W share, in
|
||||||
|
`test_delivery.py` part 1 (*"THE ACCOUNT GATE IS UNTOUCHED BY IT"* — the same
|
||||||
|
0 of 125 W with the shortfall explained names nobody). That part also carries
|
||||||
|
that the relative bound is **strict** (a gap of exactly `frac · share` is not
|
||||||
|
over it) and that the proper-subset rule still refuses a site-wide cause (both
|
||||||
|
units delivering none of a 125 W share names nobody).
|
||||||
|
|
||||||
|
## 5. N=1 is byte-identical, and it is asserted rather than argued
|
||||||
|
|
||||||
|
The live install is **N=1**. Two independent statements, each graded against the
|
||||||
|
thing that would break it (`test_packshare.py` part 8b):
|
||||||
|
|
||||||
|
1. **`allocator.plan()` returns before `weights` is read at all.** Graded by
|
||||||
|
handing it a `weights` mapping that **raises on any read**: at a roster of one
|
||||||
|
it answers identically to the same call with no weights; at a roster of two
|
||||||
|
the same mapping is read and raises, so the tripwire is a real one.
|
||||||
|
2. **`delinquent()` answers `{}` at a roster of one for every input** — 84
|
||||||
|
combinations of (share, measured, rule), including **0 of 125 W**, the exact
|
||||||
|
input this card added an arm for. `len(asked) < 2` returns before either
|
||||||
|
threshold is compared, so the fraction is never even multiplied.
|
||||||
|
|
||||||
|
Nothing in this card is reachable at one inverter. It must land before inverter B
|
||||||
|
is commissioned (~2026-09-12), not before the next deploy.
|
||||||
|
|
||||||
|
## 5b. Mutation — the fraction is pinned from BOTH directions
|
||||||
|
|
||||||
|
⚠️ **RUN, NOT REASONED ABOUT.** Each mutation was applied to a tree **copied
|
||||||
|
fresh** from this branch and mounted `:ro` inside the checks image, so no `.pyc`
|
||||||
|
could go stale — MUTATION.md's defect, and all three of these are **reds**, the
|
||||||
|
direction that defect cannot fabricate. Recorded in `test_packshare.py` part 11
|
||||||
|
as M12–M14.
|
||||||
|
|
||||||
|
| mutation | named checks | where |
|
||||||
|
|---|---:|---|
|
||||||
|
| **M12** drop the relative arm, keep `gap > min_w` — *the card itself reverted* | **7** | `test_delivery.py` part 1 (3: the 0-of-125 W naming, the proportion in the sentence, the strict relative bound) + `test_packshare.py` 8b (4: GREEN back to 0/200, the equal arm's 200 back to 199, the 6000 W arm back to 195/199, the derated control's guard). `test_alloc.py` / `test_control.py` stay green — nothing outside this check moves. |
|
||||||
|
| **M13** `DELIVERY_SHARE_FRACTION` → **1.0** — *too loose* | **7** | part 1 (2) + 8b (5), including the PRECONDITION that the fraction is strictly below 1.0. At 1.0 the card is a no-op: gap/share of exactly 1.0 is not *over* 1.0. |
|
||||||
|
| **M14** `DELIVERY_SHARE_FRACTION` → **0.05** — *too sensitive* | **6** | part 1 (2: the strict bound, and *"IT TOOK NOTHING AWAY"* stops being true) + part **4b** (3: the `saturation_w` pin goes **vacuous**, the relative arm naming B at every threshold) + 8b's derated control, where a unit delivering half its share starts being named. |
|
||||||
|
|
||||||
|
M13 and M14 are the pair that matters: the constant cannot be moved in **either**
|
||||||
|
direction without a named check moving with it.
|
||||||
|
|
||||||
|
### ⚠️ Two of these counts were wrong, and the reason is worth keeping
|
||||||
|
|
||||||
|
**Corrected in review.**
|
||||||
|
|
||||||
|
* **M12 and M13 read 1 and 1 in `test_delivery.py`, not 3 and 2.** Part 1 printed
|
||||||
|
the alarm sentence with a bare subscript, `delinquent(_tiny, 500)["b"]`. On any
|
||||||
|
tree where the relative arm is broken or absent that raises `KeyError` and
|
||||||
|
**aborts the file at part 1**, leaving parts 2–8 ungraded — so the recorded
|
||||||
|
coverage of a mutation run read *smaller* than the truth. The direction is safe
|
||||||
|
(an abort is a red, and MUTATION.md's defect can only fabricate a green), but a
|
||||||
|
figure that does not reproduce is a figure that does not reproduce. It is a
|
||||||
|
`.get` now, with the reason written beside it, and the counts above are from
|
||||||
|
re-runs with the guard in place.
|
||||||
|
* **M14 was recorded as 4 named checks in `test_delivery.py` and it is 5** — part
|
||||||
|
1's pre-existing *"a shortfall AT the threshold is not one"* also goes red. That
|
||||||
|
one under-claimed, so it never inflated the evidence; it is still a recorded
|
||||||
|
number that did not reproduce.
|
||||||
|
|
||||||
|
## 6. ⚠️ [[CFG-07]] is adjacent and is NOT absorbed here
|
||||||
|
|
||||||
|
CFG-07 is that `delivery_hold_s` is declared `int(30,900)` in `config.yaml`'s
|
||||||
|
schema while the measured worst legitimate episode at the shipped threshold is
|
||||||
|
**35.0 s** — so the schema offers an operator a value (30) that is **below a
|
||||||
|
measured legitimate episode** and would report on ordinary saturation.
|
||||||
|
|
||||||
|
**This card makes it worse, and by exactly one control cycle.** The relative arm
|
||||||
|
takes the worst legitimate episode from **35.0 s to 40.0 s** at the shipped
|
||||||
|
fraction, so the band of schema-legal but false-tripping values widens from
|
||||||
|
`[30, 35]` to `[30, 40]`. The floor the schema should have is now **40 s**, not
|
||||||
|
35 s. It is not fixed here: raising a schema floor is an option-surface change
|
||||||
|
with its own compatibility argument (an existing config carrying `35` would start
|
||||||
|
being refused), and folding it into a safety fix would hide it. **CFG-07 should
|
||||||
|
be re-derived against 40.0 s, not 35.0 s.**
|
||||||
|
|
||||||
|
⚠️ **WHEN IT HAS TO CLOSE: BEFORE INVERTER B IS COMMISSIONED (~2026-09-12).**
|
||||||
|
Not before the next deploy. The band is **unreachable at N=1** — `delinquent()`
|
||||||
|
returns `{}` for every input at a roster of one, so no value of
|
||||||
|
`delivery_hold_s` can false-trip on the install that is running today — and the
|
||||||
|
live install is N=1 until B exists. Commissioning B is the moment the band
|
||||||
|
becomes reachable, and that is the deadline this card hands it.
|
||||||
|
|
||||||
|
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE.** It exists as a
|
||||||
|
`[[CFG-07]]` mention in `CHANGELOG.md` and in this section, and nowhere else —
|
||||||
|
so there is no file for the 40.0 s to be recorded in, and a reader who goes
|
||||||
|
looking will find a card reference with no record behind it. Whoever takes
|
||||||
|
CFG-07 writes `DECISIONS/CFG-07-*.md` first; the number it has to answer to is
|
||||||
|
**40.0 s**, measured here, and the harness that produces it is
|
||||||
|
`admin/goodwe:sim/delivery_window.py --campaign --fractions`.
|
||||||
|
|
||||||
|
## 7. Provenance of every number in this document
|
||||||
|
|
||||||
|
| number | where it came from |
|
||||||
|
|---|---|
|
||||||
|
| 199/200, 0/200, 200/200, shares 1505/125 | `test_packshare.py` part 8b, run |
|
||||||
|
| 35.0 s absolute-arm floor, and its argmax | `delivery_window.py --campaign`, re-run here, reproduces SAFETY-42 §3.3 |
|
||||||
|
| every row of §3.1 | `delivery_window.py --campaign --fractions`, two passes over the same campaign with `FRACTIONS` changed between them |
|
||||||
|
| 1000 W / 6500 W suppression windows | part 8b's arithmetic, asserted there |
|
||||||
|
| 185 s, 5.29× | SAFETY-42 §3.3 / TEST-21, unchanged by this card |
|
||||||
|
| 4.62× | 185 / 40.0, printed by the campaign |
|
||||||
|
| 0/200 on both positive controls, and B's 0 W share at the cut-out | part 8b, run and asserted |
|
||||||
|
| 0 W cut-out share / 125 W wedged share | part 8b, asserted and printed |
|
||||||
|
| 77/125/133/154 W (the wedged arm's four distinct shares) | the same loop re-run in review with per-cycle recording — NOT part 8b's printed line, which shows the last cycle only |
|
||||||
|
| M12/M13/M14 named-check counts (7 / 7 / 6) | re-run in review on fresh `:ro` trees, after the `KeyError` abort in part 1 was guarded |
|
||||||
|
| 84 single-unit rosters | part 8b, run |
|
||||||
|
|
||||||
|
⚠️ Every two-inverter figure above is **two copies of inverter A**.
|
||||||
@@ -1574,6 +1574,185 @@ battery power, circulating power, state of charge, maintenance phase and
|
|||||||
controller status by MQTT discovery. This is observability only — the controller works fine without a
|
controller status by MQTT discovery. This is observability only — the controller works fine without a
|
||||||
broker, and MQTT problems can never affect control.
|
broker, and MQTT problems can never affect control.
|
||||||
|
|
||||||
|
## Before you commission inverter B: `tools/rehearse_roster.py` (ARCH-02)
|
||||||
|
|
||||||
|
The day the `inverters:` roster is declared, two things change that are
|
||||||
|
**one-way on a live box and invisible until after the restart that applies
|
||||||
|
them**: the store migration renames the keys that hold the SAFETY-02 latch and
|
||||||
|
the maintenance history, and the entity ids Home Assistant composes change
|
||||||
|
shape. This tool runs the **real `Controller`** through that transition against
|
||||||
|
a **copy** of the state file and reports the difference.
|
||||||
|
|
||||||
|
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v <repo>:/w:ro \
|
||||||
|
goodwe-addon-checks:1 sh -c 'cd /w/goodwe_controller && \
|
||||||
|
python3 tools/rehearse_roster.py \
|
||||||
|
--state /w/state-copy.json \
|
||||||
|
--before /w/live-options.json \
|
||||||
|
--after /w/roster-options.json \
|
||||||
|
--live-entities /w/live-entities.txt \
|
||||||
|
--reverse --round-trip'
|
||||||
|
|
||||||
|
Exit `0` clean · `1` at least one finding · `2` could not run. ⚠️ **`2` is not a
|
||||||
|
pass.**
|
||||||
|
|
||||||
|
### The owner steps, because this session could not take them
|
||||||
|
|
||||||
|
⚠️ **The rehearsal is only as good as the state it is pointed at, and nothing in
|
||||||
|
this repository can fetch that.** SSH to the live box is blocked by policy and
|
||||||
|
that is correct — `/data/state.json` is the file the running add-on is writing.
|
||||||
|
So three things have to be exported by hand, onto the machine that runs the
|
||||||
|
check, **before** the roster is declared:
|
||||||
|
|
||||||
|
1. **The state file.** From the Supervisor host:
|
||||||
|
|
||||||
|
ha addons info local_goodwe_controller # confirm the slug first
|
||||||
|
docker cp addon_local_goodwe_controller:/data/state.json ./state-copy.json
|
||||||
|
|
||||||
|
or take it out of a full add-on backup, which is the same bytes. ⚠️ **Copy
|
||||||
|
it, never move it, and never write to the original.** The tool opens the path
|
||||||
|
it is given exactly once and does all of its writing on a scratch copy — but
|
||||||
|
the safe habit is to hand it a file the add-on has never heard of.
|
||||||
|
|
||||||
|
2. **The options blob**, as `--before`. It is not reachable through the Home
|
||||||
|
Assistant REST API and no entity carries it; export it from the Supervisor,
|
||||||
|
the same way `tools/predeploy_check.py` needs it. Write the roster you intend
|
||||||
|
to deploy into a second file and pass it as `--after`.
|
||||||
|
|
||||||
|
3. **The entity list**, as `--live-entities` — one entity id per line:
|
||||||
|
|
||||||
|
Developer tools -> Template:
|
||||||
|
{{ states | map(attribute='entity_id') | select('search','goodwe') | join('\n') }}
|
||||||
|
|
||||||
|
⚠️ **Omitting it is a REFUSAL (exit 2), not a quieter run.** The orphan check
|
||||||
|
is graded against the ids the install actually has, and without that file it
|
||||||
|
is not graded against reality at all — so the tool will not run rather than
|
||||||
|
hand back a clean bill of health for a half of the transition it did not
|
||||||
|
check. If you genuinely have to run without it, pass `--no-live-entities`:
|
||||||
|
the run then carries a standing banner in the report header **and** on the
|
||||||
|
exit line saying the orphan check was not graded. The asymmetry used to be
|
||||||
|
the other way round — an unreadable file refused, a missing flag passed with
|
||||||
|
exit 0 and no disclosure at all — and the owner runs this once, at
|
||||||
|
commissioning, under time pressure.
|
||||||
|
|
||||||
|
The install carries roughly 49 `goodwe*` entities and this add-on announces
|
||||||
|
eight of them. The tool separates the ids it owns from the ids it does not,
|
||||||
|
rather than claiming orphans it cannot cause.
|
||||||
|
|
||||||
|
### The reference, which is loaded on every run
|
||||||
|
|
||||||
|
`--reference` defaults to `tools/rehearsal/announced-reference.txt` and is read
|
||||||
|
**whether or not** you pass an entity list. It records what the build is
|
||||||
|
supposed to announce, three columns per row:
|
||||||
|
|
||||||
|
<entity_id> <unique_id> <device identifier>
|
||||||
|
|
||||||
|
⚠️ **All three, and the second and third are the ones an entity_id-only list
|
||||||
|
cannot see.** The tool's own before/after diff is composed twice by the same
|
||||||
|
build, so a change to the composition moves both sides equally and the diff
|
||||||
|
reports nothing. Worse, two changes orphan an install *without moving a single
|
||||||
|
composed entity_id*: renaming `mqtt.BASE` (every `unique_id` changes) and
|
||||||
|
changing `DEVICE["identifiers"]` (HA sees a new device). Home Assistant keys its
|
||||||
|
entity registry on `unique_id` and its device registry on the identifier, so
|
||||||
|
either one creates a **new** entity beside the old — and because discovery is
|
||||||
|
published `retain=True` with no removal payload, the old config stays on the
|
||||||
|
broker and the old entity stays in the registry, still named by every automation
|
||||||
|
that referenced it, never updating again. Both were measured coming back
|
||||||
|
**exit 0** before the reference carried three columns.
|
||||||
|
|
||||||
|
`object_id` is deliberately **not** a column: `app/mqtt.py` records that HA
|
||||||
|
2026.8 does not honour it in a discovery payload, so renaming one is a true
|
||||||
|
no-op, and `test_rehearse.py` requires that mutation to **survive** — otherwise
|
||||||
|
"the reference notices any edit to `mqtt.py`" would be indistinguishable from
|
||||||
|
"the reference notices what HA keys on".
|
||||||
|
|
||||||
|
⚠️ **No result in this repository was produced against real state.** Everything
|
||||||
|
recorded here and in `test_rehearse.py` was measured on the synthetic fixtures
|
||||||
|
in `tools/rehearsal/`.
|
||||||
|
|
||||||
|
### What it confirms on the synthetic fixtures
|
||||||
|
|
||||||
|
⚠️ **This is a SECOND INSTRUMENT reaching a conclusion this repository already
|
||||||
|
holds, not a discovery.** `test_maintenance.py` section 13 — *"RECORDED, NOT
|
||||||
|
FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a
|
||||||
|
LATCH"* — already states it, already names `namespaced_latched`, and already
|
||||||
|
carries the sentence *"the guard is defeated by the very key the defect wrote"*.
|
||||||
|
That file is on `release/1.0` and this card does not touch it. What is new here
|
||||||
|
is that the finding is now reachable **from a state file the owner supplies**
|
||||||
|
rather than only from a fixture built in a test, and that it is reached by a
|
||||||
|
different route: a full `Controller` start diffed against its predecessor,
|
||||||
|
rather than assertions over a `MemStore`. Two instruments, one answer.
|
||||||
|
|
||||||
|
**The finding.** A pre-DEV-02 flat store that declares a multi-inverter roster
|
||||||
|
comes up with its SAFETY-02 latch **DISARMED**, unless the first roster entry
|
||||||
|
happens to be named `inverter-a`. `Controller.__init__` calls
|
||||||
|
`migrate_unit_keys(store, UNIT_A)` with a hardcoded constant, so the latch lands
|
||||||
|
in `unit/inverter-a/sign_tripped`; a roster of `["garage", "shed"]` reads
|
||||||
|
`unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()` —
|
||||||
|
the guard written for exactly this — sees the migration's own misdirected key
|
||||||
|
satisfy its `namespaced_latched` test and returns without re-arming. The file
|
||||||
|
says `sign_tripped: true`, the banner is green, and nothing is held at 0 W.
|
||||||
|
|
||||||
|
**Both routes reach it, and they are NOT the same defect.** The rehearsal
|
||||||
|
reports them as separate legs because they need separate fixes:
|
||||||
|
|
||||||
|
| leg | how it gets there |
|
||||||
|
|---|---|
|
||||||
|
| `SAME-STEP` | the roster is declared in the same step as the upgrade. The migration runs, and is handed `UNIT_A`. |
|
||||||
|
| `STAGED` | the flat config runs once under 0.7.0+ first. That start stamps `schema_version = 1`, so the roster start's migration takes its `from_version >= SCHEMA_VERSION` early return and **the latch never moves at all**. |
|
||||||
|
|
||||||
|
⚠️ **So "hand `migrate_unit_keys()` the roster's own first name" is NOT the
|
||||||
|
whole fix, and the rehearsal measures that rather than asserting it.**
|
||||||
|
`test_rehearse.py` part 5b builds exactly that change and reports:
|
||||||
|
|
||||||
|
LEG STAGED: DISARMED
|
||||||
|
LEG SAME-STEP: clean
|
||||||
|
|
||||||
|
The staged path never reaches the line that was changed. A fix that stops there
|
||||||
|
closes the half an operator is least likely to take and leaves the half that
|
||||||
|
"upgrade first, declare the roster later" produces. The guard side —
|
||||||
|
`namespaced_latched` counting a namespace that is not on the roster — is what
|
||||||
|
covers both, and a change there turns every SAFETY-44 check in part 5 red, which
|
||||||
|
is what a fix should do to a pin that specifies a defect.
|
||||||
|
|
||||||
|
The fix belongs to its own card, shown red first. Part 5 pins the current
|
||||||
|
behaviour as a requirement so it cannot drift green by accident.
|
||||||
|
|
||||||
|
### The reverse direction: the retreat is supported, the round trip is not
|
||||||
|
|
||||||
|
If commissioning B goes wrong, **backing out to the flat single-inverter config
|
||||||
|
is supported and does not corrupt state.** Measured on the fixtures:
|
||||||
|
|
||||||
|
* the SAFETY-02 latch and the first pack's maintenance history both survive,
|
||||||
|
because `store.py` rule 2 keeps the flat spellings **live** rather than merely
|
||||||
|
present — `maintenance.UnitHistory.mirror_flat` writes them on every change,
|
||||||
|
so a single-FSM add-on finds a current history rather than one dated the day
|
||||||
|
the roster was declared;
|
||||||
|
* **it orphans eight Home Assistant entities** — the four per-unit rows for each
|
||||||
|
of the two units. **Measured:** the add-on stops announcing them and stops
|
||||||
|
publishing their state, so their history gaps and any automation whose
|
||||||
|
trigger names them stops firing, silently and without an error.
|
||||||
|
|
||||||
|
⚠️ **What is NOT measured, and was previously overstated here:** whether
|
||||||
|
advancing again restores them. An earlier draft of this section said the
|
||||||
|
orphaning was "not recoverable by advancing again: HA keeps the id it composed
|
||||||
|
at first discovery" — that reasoning cuts the other way, since keeping the
|
||||||
|
composed id is precisely what would let a re-advertised entity land back on
|
||||||
|
it, and discovery is published `retain=True` with no removal payload. Nothing
|
||||||
|
in this repository has tested a re-advance against a real Home Assistant, so
|
||||||
|
the claim is withdrawn rather than restated in the other direction. Plan the
|
||||||
|
retreat around the measured part: **the eight rows stop updating for as long
|
||||||
|
as you are retreated.**
|
||||||
|
|
||||||
|
⚠️ **What is NOT supported is the round trip.** A maintenance cycle that
|
||||||
|
completes *while the install is rolled back* is **discarded** when it
|
||||||
|
re-advances: `maint_schema_version` is already stamped, so `migrate_maint_keys()`
|
||||||
|
takes its early return and the pack reads the namespaced history from before the
|
||||||
|
retreat. The pack is then recalibrated on a schedule that does not know it
|
||||||
|
already was. **So: retreat freely, but treat a retreat as ending the current
|
||||||
|
maintenance bookkeeping — and if a cycle ran during it, correct
|
||||||
|
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
|
||||||
|
decision, not a discovery made at the inverter.
|
||||||
|
|
||||||
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
|
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
|
||||||
|
|
||||||
Two ways a deploy has gone wrong on this system, both of which the Supervisor
|
Two ways a deploy has gone wrong on this system, both of which the Supervisor
|
||||||
|
|||||||
@@ -225,6 +225,78 @@ same place twice, the check is decoration.
|
|||||||
forever, *including after the call site is fixed*. The check was true and
|
forever, *including after the call site is fixed*. The check was true and
|
||||||
passed for the wrong reason. Habit **9** below is what falls out of it.
|
passed for the wrong reason. Habit **9** below is what falls out of it.
|
||||||
|
|
||||||
|
* ⚠️ **ARCH-02's OWN REHEARSAL HARNESS, CAUGHT BY ITS OWN MUTATION BATTERY —
|
||||||
|
the THIRTEENTH instance, and the third in an INSTRUMENT rather than in a
|
||||||
|
check.** `tools/rehearse_roster.py` reports orphaned Home Assistant entities
|
||||||
|
by composing the ids the add-on announces **before** the roster switch and
|
||||||
|
**after** it, and diffing. Both sides are composed by the same build. So the
|
||||||
|
two mutations that change the id composition itself — renaming the MQTT device
|
||||||
|
(`"GoodWe"` -> `"GoodWe Controller"`), renaming one site sensor (`"Grid power"`
|
||||||
|
-> `"Grid power W"`) — moved **every id on both sides equally**, and the
|
||||||
|
harness reported *"no orphans"* about a change that orphans the entire live
|
||||||
|
entity list. It read exactly like a clean transition. The first draft's
|
||||||
|
M5/M6 came back `exit 0` and `exit 2`, and neither was the failure they name.
|
||||||
|
|
||||||
|
The fix is habit 2 with a filename: **the reference has to be data that does
|
||||||
|
not move with the code.** `--live-entities` takes a list of the ids the
|
||||||
|
install actually has, `tools/rehearsal/live-entities-recorded.txt` is the
|
||||||
|
recorded stand-in that keeps it live in CI, and a build that announces an id
|
||||||
|
the list does not carry is now a `COMPOSITION DRIFT` finding rather than a
|
||||||
|
silence. ⚠️ **The anchor moved for the same reason**: it had been
|
||||||
|
`compose(mqtt.DEVICE["name"], p1["name"]) == "sensor.goodwe_p1_sample_age"`,
|
||||||
|
which any renaming of the device satisfies by moving both operands, and it is
|
||||||
|
now composed from two recorded **literals**.
|
||||||
|
|
||||||
|
Ask of an instrument what habit 4 asks of a check: *where did each side of
|
||||||
|
this comparison come from* — and if a mutation of the subject moves both
|
||||||
|
sides, the instrument is decoration.
|
||||||
|
|
||||||
|
⚠️ **AND REVIEW FOUND TWO MORE IN THE SAME CARD, ONE OF THEM INSIDE THE FIX
|
||||||
|
FOR THE OTHER — the FOURTEENTH and FIFTEENTH.** Both are the pin, not the
|
||||||
|
tool, and both are the same sentence in a new costume: *a check whose data
|
||||||
|
cannot reach the failure it names.*
|
||||||
|
|
||||||
|
* **Fourteenth — a COUNT cannot tell one red leg from two.** The rehearsal
|
||||||
|
reports two independent routes to the disarmed latch (`SAME-STEP` and
|
||||||
|
`STAGED`) and prints every finding **twice per red leg**: once in that
|
||||||
|
leg's own block and once in the closing summary. The check named *"NOT THE
|
||||||
|
SAME-STEP PATH ALONE"* was written `out.count("LATCH CAME UP DISARMED")
|
||||||
|
>= 2`, so **one** red leg always satisfied it. Measured on three builds:
|
||||||
|
shipped code (both legs red) → 4; `migrate_unit_keys()` handed the roster
|
||||||
|
name (SAME-STEP fixed) → 2, **passes**; a guard-side fix (both fixed) → 0.
|
||||||
|
The check reported `ok` on the build whose only remaining failure *is* the
|
||||||
|
path it says is not the only one. The fix is to parse the legs and require
|
||||||
|
the finding in **each**, which is habit 2 again: grade the thing, not a
|
||||||
|
total over the thing.
|
||||||
|
|
||||||
|
* **Fifteenth — and it is INSIDE THAT FIX, which is the third time this file
|
||||||
|
has had to write that sentence.** The first leg-splitter opened on a
|
||||||
|
`LEG <id>` marker and never closed, so every line after the *last* marker
|
||||||
|
was attributed to that leg — including the closing summary, which repeats
|
||||||
|
every finding from every leg. The last leg therefore read red whenever
|
||||||
|
**any** leg was red, and the new per-leg check was the old count with extra
|
||||||
|
steps. It was caught by building the one fixture that separates them: a
|
||||||
|
tree in which `SAME-STEP` is genuinely clean and `STAGED` is not. The tool
|
||||||
|
now prints `END LEG <id>` and the splitter closes on it.
|
||||||
|
|
||||||
|
The rule that falls out, and it is the cheap one: **a per-thing check that
|
||||||
|
has never seen the things DISAGREE is a total.** Part 5b exists to make
|
||||||
|
them disagree on every run.
|
||||||
|
|
||||||
|
⚠️ **AND ONE THAT HAS ONLY SEEN THEM DISAGREE IN ONE ARRANGEMENT IS A TOTAL
|
||||||
|
OVER THAT ARRANGEMENT.** Review found this by attacking the fix itself. Part
|
||||||
|
5b does make the legs disagree, on every run - and was still blind to a build
|
||||||
|
that merely REORDERS them, because it had only ever seen the clean leg
|
||||||
|
printed LAST. Three edits, two of them ordinary refactors (reorder the report
|
||||||
|
legs, reword a summary line), produced `all checks passed` from a splitter
|
||||||
|
that closed ZERO legs.
|
||||||
|
|
||||||
|
A fixture cannot enumerate the arrangements, so the STRUCTURE is asserted
|
||||||
|
instead: a leg may not open while one is open, and every leg that opens must
|
||||||
|
be explicitly closed. That fires on the FIRST edit of all three attacks,
|
||||||
|
whatever the order or the wording. **When a fixture cannot enumerate the
|
||||||
|
arrangements, assert the invariant they share.**
|
||||||
|
|
||||||
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
|
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
|
||||||
reaching outside the suite. The probe is the committed evidence that
|
reaching outside the suite. The probe is the committed evidence that
|
||||||
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
|
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
|
||||||
|
|||||||
@@ -414,6 +414,94 @@ DELIVERY_OPTION_DEFAULTS = {
|
|||||||
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
|
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SAFETY-43. THE SECOND WAY TO BE NAMED, AND WHY IT IS A FRACTION OF THE ASK.
|
||||||
|
#
|
||||||
|
# `min_w` above is an ABSOLUTE gap, and OPT-09 made that a hole. `weights()`
|
||||||
|
# shrinks a low-headroom unit's share, and shrinking the share shrinks the gap:
|
||||||
|
# a unit standing at +125 W and delivering 0 W is 100 % delinquent and 125 W
|
||||||
|
# short, so an absolute 500 W never names it. Measured end to end
|
||||||
|
# (`test_packshare.py` part 8b, real `compute()`, real `plan()`, real
|
||||||
|
# `delinquent()`, B wedged at a healthy 15 % against its own 10 % cut-out):
|
||||||
|
#
|
||||||
|
# load 1500 W, equal weights the wedged unit named on 199/200 cycles
|
||||||
|
# load 1500 W, OPT-09 weights named on 0/200 cycles
|
||||||
|
#
|
||||||
|
# ⚠️ AND THE SUPPRESSED REGIME IS THE QUIET ONE. At a load the healthy sibling
|
||||||
|
# covers, the meter is satisfied, `unmet_w` is 0 and no action-log line fires,
|
||||||
|
# so this alarm is the ONLY instrument that would have told the operator.
|
||||||
|
#
|
||||||
|
# ⚠️ THE FIX IS NOT A SMALLER `min_w`, AND THAT IS THE WHOLE SHAPE OF THIS
|
||||||
|
# CARD. `min_w` is `Tuning.saturation_w` and it exists to stop NORMAL
|
||||||
|
# SATURATION being reported as delinquency - the crying-wolf failure the whole
|
||||||
|
# module is written against, and the one SAFETY-41 spent four attempts on
|
||||||
|
# ("i'm getting really annoyed by the notifications in my HA"). Lowering it
|
||||||
|
# trades a missed alarm for a false one on every unit at once. A fraction of
|
||||||
|
# the unit's OWN SHARE is a SECOND way to be named that leaves the first
|
||||||
|
# exactly where it was: `gap > min_w OR gap > frac * share`. Nothing that was
|
||||||
|
# silent before this card becomes noisy unless the unit is delivering almost
|
||||||
|
# none of what it was asked for, which is not saturation-shaped at all.
|
||||||
|
#
|
||||||
|
# WHERE 0.85 COMES FROM - MEASURED ON THE SAME CAMPAIGN AS `delivery_hold_s`.
|
||||||
|
# `admin/goodwe:sim/delivery_window.py --campaign --fractions` grades this
|
||||||
|
# constant over the SAME 2640 no-fault runs (three families x 6 gains x 5 slews
|
||||||
|
# x the write staggers, every unit executing the share it was given) that
|
||||||
|
# produced the 35.0 s worst legitimate episode, at the shipped `min_w`, and
|
||||||
|
# reports what the relative arm ADDS. Worst continuous legitimate episode,
|
||||||
|
# worst of the three families:
|
||||||
|
#
|
||||||
|
# frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
|
||||||
|
# worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
|
||||||
|
#
|
||||||
|
# frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
|
||||||
|
# worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
|
||||||
|
#
|
||||||
|
# absolute arm alone: 35.0 s - SAFETY-42 §3.3's figure, re-measured in the
|
||||||
|
# SAME runs, which is what makes every row above a comparison and not a claim.
|
||||||
|
# N=1 control: 0.0 s at EVERY fraction, in all 2640 one-inverter runs.
|
||||||
|
# Flapping family: 0.0 s at EVERY fraction, in all 840 runs.
|
||||||
|
#
|
||||||
|
# ⚠️ READ THE CURVE, NOT THE ROW. THERE IS NO CLIFF ANYWHERE IN IT. From 0.45
|
||||||
|
# to 0.99 every figure is 40.0 or 45.0 s - within ONE CONTROL CYCLE (5.0 s) of
|
||||||
|
# every other - and below 0.45 it rises gradually to 90.0 s at 0.05. It is not
|
||||||
|
# monotone either (0.40 reports 60.0 s where 0.35 reports 55.0), which is
|
||||||
|
# §3.3's own mechanism (c) rather than noise in the plant: a more sensitive rule
|
||||||
|
# puts MORE units into the short set, which can turn a proper subset into the
|
||||||
|
# WHOLE roster and END the episode. So this constant is INSENSITIVE over the
|
||||||
|
# whole usable range, and that is the finding rather than a caveat on it. 0.85
|
||||||
|
# sits in the flat part, 0.40 above the shoulder at ~0.45 and 0.15 below 1.0.
|
||||||
|
#
|
||||||
|
# ⚠️ AND "ZERO FALSE NAMINGS" IS NOT AVAILABLE AT ANY FRACTION - said here
|
||||||
|
# because the card asked for exactly that. Every fraction adds naming CYCLES to
|
||||||
|
# the no-fault campaign; a relative arm that never fired on a legitimate
|
||||||
|
# transient would be one that never fired at all, because the modelled write
|
||||||
|
# stagger lands B's number up to four cycles late and a unit whose new command
|
||||||
|
# has not arrived IS delivering a small fraction of what is standing on its
|
||||||
|
# wire. What is zero at every fraction is false ALARMS: the longest legitimate
|
||||||
|
# episode anywhere in the 20-point sweep is 90.0 s - at 0.05, the most sensitive
|
||||||
|
# fraction measured and far below anything this card would ship - against a
|
||||||
|
# `delivery_hold_s` of 185 s, so NOTHING in 2640 no-fault runs comes within 95 s
|
||||||
|
# of notifying an operator at any fraction measured. At 0.85 the hold is 4.62x
|
||||||
|
# the worst legitimate episode where the absolute arm alone gave 5.29x. That is
|
||||||
|
# the price, in full.
|
||||||
|
#
|
||||||
|
# ⚠️ THE DIRECTION IS THE OPPOSITE OF `min_w`'s AND IT IS EASY TO GET BACKWARDS.
|
||||||
|
# A SMALLER fraction names MORE units - it is the more sensitive setting and the
|
||||||
|
# more dangerous one - so the campaign puts a LOWER bound on this number, not an
|
||||||
|
# upper one. It is bounded ABOVE by the card's own case: at 1.0 the arm can only
|
||||||
|
# fire on a unit that is IMPORTING while asked to discharge, which is
|
||||||
|
# SAFETY-30's fault and not this one, so a unit delivering NONE of its share
|
||||||
|
# (gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
|
||||||
|
# wedged box reporting a small standby figure rather than a clean zero.
|
||||||
|
#
|
||||||
|
# ⚠️ AND IT IS NOT AN OPTION, FOR `delivery_w`'s REASON ONE PARAGRAPH UP. An
|
||||||
|
# operator has no instrument to derive it with, a knob here is a knob on how
|
||||||
|
# loud this add-on is, and SAFETY-41 is the record of what that costs. It is a
|
||||||
|
# derived constant with a committed harness behind it, and the way to move it
|
||||||
|
# is to re-run the campaign.
|
||||||
|
DELIVERY_SHARE_FRACTION = 0.85
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DeliveryConfig:
|
class DeliveryConfig:
|
||||||
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
||||||
@@ -532,12 +620,30 @@ def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def delinquent(units, min_w: float) -> dict:
|
def delinquent(units, min_w: float,
|
||||||
|
share_frac: float = DELIVERY_SHARE_FRACTION) -> dict:
|
||||||
"""Which units are short with NO ACCOUNT, when the roster disagrees with them.
|
"""Which units are short with NO ACCOUNT, when the roster disagrees with them.
|
||||||
|
|
||||||
`units` one `UnitDelivery` per inverter, in roster order.
|
`units` one `UnitDelivery` per inverter, in roster order.
|
||||||
`min_w` the shortfall that counts - `control.Tuning.saturation_w`. See
|
`min_w` the ABSOLUTE shortfall that counts - `control.Tuning.
|
||||||
`MIN_W_IS_SATURATION_W`.
|
saturation_w`. See `MIN_W_IS_SATURATION_W`.
|
||||||
|
`share_frac` SAFETY-43. The RELATIVE shortfall that counts, as a fraction
|
||||||
|
of that unit's own share. See `DELIVERY_SHARE_FRACTION`; it
|
||||||
|
is a parameter rather than a read of the constant so the
|
||||||
|
campaign that derived it can sweep it over the shipped code.
|
||||||
|
⚠️ `None` MEANS THE ABSOLUTE ARM ALONE - SAFETY-42's rule,
|
||||||
|
exactly - and it exists for ONE caller: the campaign in
|
||||||
|
`admin/goodwe:sim/delivery_window.py`, which has to be able
|
||||||
|
to measure what this card ADDS against what it replaced, run
|
||||||
|
for run, over ONE tree. Nothing in the add-on passes it, and
|
||||||
|
`main.py` passes nothing at all.
|
||||||
|
|
||||||
|
⚠️ THE TWO ARMS ARE AN `or` AND THE ORDER OF THAT SENTENCE MATTERS. The
|
||||||
|
absolute arm is untouched by SAFETY-43: everything `min_w` named before it
|
||||||
|
is still named, and nothing `min_w` refused becomes noisy unless the unit
|
||||||
|
is delivering almost NONE of its ask. Lowering `min_w` would have been the
|
||||||
|
other change - the one that reports normal saturation on every unit at once
|
||||||
|
- and `DELIVERY_SHARE_FRACTION` says at length why it was refused.
|
||||||
|
|
||||||
Returns `{name: sentence}`, empty when there is nothing to say. The sentence
|
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
|
is the operator's first line and it carries the peer clause, because "B is
|
||||||
@@ -579,7 +685,13 @@ def delinquent(units, min_w: float) -> dict:
|
|||||||
gaps = {}
|
gaps = {}
|
||||||
for u in asked:
|
for u in asked:
|
||||||
gap = short_by(u.share_w, u.measured_w)
|
gap = short_by(u.share_w, u.measured_w)
|
||||||
if gap is not None and gap > float(min_w):
|
if gap is None:
|
||||||
|
continue
|
||||||
|
# SAFETY-43: absolute OR relative. `share_w` is > 0 for every member
|
||||||
|
# of `asked`, so the second comparison is always well formed.
|
||||||
|
if gap > float(min_w) or (share_frac is not None
|
||||||
|
and gap > float(share_frac)
|
||||||
|
* float(u.share_w)):
|
||||||
gaps[u.name] = gap
|
gaps[u.name] = gap
|
||||||
|
|
||||||
unaccounted = {u.name: gaps[u.name] for u in asked
|
unaccounted = {u.name: gaps[u.name] for u in asked
|
||||||
@@ -595,7 +707,13 @@ def delinquent(units, min_w: float) -> dict:
|
|||||||
out[u.name] = (
|
out[u.name] = (
|
||||||
f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W "
|
f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W "
|
||||||
f"and delivering {float(u.measured_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"{unaccounted[u.name]:.0f} W of its share "
|
||||||
|
# SAFETY-43. The PROPORTION is in the sentence because the watts
|
||||||
|
# alone stopped being the whole story the moment a unit could be
|
||||||
|
# named on the relative arm: "125 W is not arriving" reads like a
|
||||||
|
# rounding error, and "125 W (100 % of its share)" is the fault.
|
||||||
|
f"({100.0 * unaccounted[u.name] / float(u.share_w):.0f} % of it) "
|
||||||
|
f"is not arriving - while "
|
||||||
f"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
|
f"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
|
||||||
f"that state. Nothing this add-on reads explains it")
|
f"that state. Nothing this add-on reads explains it")
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
|
|||||||
never rises. That module DETECTS it; this one cannot fix it, and saying so
|
never rises. That module DETECTS it; this one cannot fix it, and saying so
|
||||||
is worth more than a weighting scheme that looks like it might.
|
is worth more than a weighting scheme that looks like it might.
|
||||||
|
|
||||||
⚠️ AND IT IS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROW SAFETY-42's
|
⚠️ AND IT WAS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROWED SAFETY-42's
|
||||||
DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2
|
DETECTION WINDOW - A DETECTION REGRESSION AT N=2, NOW CLOSED BY SAFETY-43
|
||||||
=======================================================================
|
=======================================================================
|
||||||
This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said
|
This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said
|
||||||
with it - that a wedged unit's weight does not move, so OPT-09 cannot see the
|
with it - that a wedged unit's weight does not move, so OPT-09 cannot see the
|
||||||
@@ -68,27 +68,40 @@ pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight
|
|||||||
moves whenever its headroom differs from its sibling's - the ordinary case this
|
moves whenever its headroom differs from its sibling's - the ordinary case this
|
||||||
module exists to exploit.
|
module exists to exploit.
|
||||||
|
|
||||||
`delivery.delinquent()` names a unit only while its `short_by()` gap exceeds
|
`delivery.delinquent()` named a unit only while its `short_by()` gap exceeded
|
||||||
`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's
|
`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's
|
||||||
share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real
|
share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real
|
||||||
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
|
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
|
||||||
own 10 % floor, so `envelope()` does not close and `account()` explains
|
own 10 % cut-out, so `envelope()` does not close and `account()` explains
|
||||||
nothing): at a 1500 W load the healthy pack can carry alone, equal weights name
|
nothing): at a 1500 W load the healthy pack can carry alone, equal weights named
|
||||||
the wedged unit on 199 of 200 cycles and these weights name it on NONE. The
|
the wedged unit on 199 of 200 cycles and these weights named it on NONE. The
|
||||||
suppression window widens from `site target < 2 * min_w` = 1000 W to
|
suppression window widened from `site target < 2 * min_w` = 1000 W to
|
||||||
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it has
|
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it had
|
||||||
an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass
|
an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass
|
||||||
hands the wedged unit a big share again and the alarm returns.
|
hands the wedged unit a big share again and the absolute arm names it.
|
||||||
|
|
||||||
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE - KEPT HERE BECAUSE IT IS A MEASUREMENT, EVEN THOUGH SAFETY-43 HAS SINCE MADE THE WINDOW IRRELEVANT TO THE DETECTOR. Swept on this fixture with the ABSOLUTE arm alone, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returned BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm named the wedged unit 199/200 at every load swept, 1000 W included.
|
||||||
|
|
||||||
⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK
|
⚠️ THAT IS NO LONGER A RESIDUAL. SAFETY-43 made `delinquent()` relative to
|
||||||
**AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That
|
the ask AS WELL AS absolute - `gap > min_w OR gap > frac * share`, and a
|
||||||
is a disclosed residual, not a fixed thing. Closing it means making
|
unit delivering 0 of 125 W is as delinquent as one delivering 0 of 1500 W.
|
||||||
`delinquent()` relative to the ask rather than absolute - SAFETY-42's code
|
On the SAME fixture, under the SAME weights, the wedged unit is now named
|
||||||
and a design change, filed as its own card and deliberately NOT done here.
|
on 200 of 200 cycles. Part 8b grades both rules on one run:
|
||||||
Do not "fix" part 8b by making it assert the alarm still fires; it does not,
|
`share_frac=None` is SAFETY-42's absolute arm alone and still says nothing,
|
||||||
and a green check that says otherwise would be worse than the gap.
|
which is what keeps the paragraph above honest rather than nostalgic.
|
||||||
|
|
||||||
|
⚠️ AND `min_w` WAS NOT LOWERED. The absolute arm's suppression window is
|
||||||
|
still exactly the arithmetic above - 1000 W at equal weights, 6500 W at
|
||||||
|
these - because a lower `saturation_w` would report NORMAL SATURATION as
|
||||||
|
delinquency on every unit at once, which is the false-trip regime SAFETY-41
|
||||||
|
spent four attempts in. What changed is that there is a SECOND way to be
|
||||||
|
named; nothing that was quiet before became noisy.
|
||||||
|
|
||||||
|
⚠️ WHAT THIS MODULE STILL CANNOT DO IS UNCHANGED. OPT-09 stops the site
|
||||||
|
ASKING a pack whose own SoC says it cannot deliver; it does not, and will
|
||||||
|
not, re-allocate a wedged unit's watts to its sibling - `delivery.py`'s
|
||||||
|
"IT REPORTS. IT DOES NOT ACT" block is the measurement behind that, and
|
||||||
|
SAFETY-43 did not touch it. The alarm is louder; the loop is identical.
|
||||||
|
|
||||||
WHY ENERGY AND NOT POWER
|
WHY ENERGY AND NOT POWER
|
||||||
========================
|
========================
|
||||||
|
|||||||
@@ -548,8 +548,10 @@ options:
|
|||||||
# only ever ran at 500 could not tell this wiring from a hard-coded number.
|
# 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 -
|
# 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
|
# short by more than `saturation_w`, OR short by more than a fixed FRACTION
|
||||||
# that state, before the add-on names it. It REPORTS: nothing is commanded
|
# of its own share (SAFETY-43; the fraction is derived and is not an option,
|
||||||
|
# see app/delivery.py) - 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
|
# 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
|
# that unit's share until somebody looks. app/delivery.py carries why, with
|
||||||
# the rig measurements on both sides of the decision.
|
# the rig measurements on both sides of the decision.
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from app.control import Tuning
|
from app.control import Tuning
|
||||||
from app.delivery import (
|
from app.delivery import (
|
||||||
DELIVERY_OPTION_DEFAULTS, REASON_CODE, DeliveryConfig, DeliveryWatchdog,
|
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
|
||||||
UnitDelivery, account, delinquent, short_by,
|
DeliveryConfig, DeliveryWatchdog, UnitDelivery, account, delinquent,
|
||||||
|
short_by,
|
||||||
)
|
)
|
||||||
from app.inverter import LINK_STALE_S
|
from app.inverter import LINK_STALE_S
|
||||||
from app.main import Controller
|
from app.main import Controller
|
||||||
@@ -151,6 +152,77 @@ check("a unit given no share is not asked and cannot be named, and it is not "
|
|||||||
"a peer either - with only one unit asked there is nothing to compare",
|
"a peer either - with only one unit asked there is nothing to compare",
|
||||||
delinquent([U("a", 0, 0), U("b", 2000, 0)], 500) == {})
|
delinquent([U("a", 0, 0), U("b", 2000, 0)], 500) == {})
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" SAFETY-43: THE RELATIVE ARM - short OF ITS OWN ASK, not just short")
|
||||||
|
|
||||||
|
# ⚠️ `share_frac=None` IS SAFETY-42's RULE EXACTLY, and every check in this part
|
||||||
|
# above this line is run under BOTH rules below to show the card took nothing
|
||||||
|
# away. What it ADDS is one thing: a unit can now be named for delivering almost
|
||||||
|
# none of a SMALL share. OPT-09's weights make small shares ordinary - a
|
||||||
|
# low-headroom unit is weighted down to 125 W of a 1630 W site target - so
|
||||||
|
# before this card a wedged unit could be 100 % delinquent and 125 W short, and
|
||||||
|
# 125 W is under a 500 W threshold. test_packshare.py part 8b measures that end
|
||||||
|
# to end on the real loop; what is graded here is the predicate.
|
||||||
|
_ABS = None # SAFETY-42's rule
|
||||||
|
|
||||||
|
_tiny = [U("a", 1505, 1505), U("b", 125, 0)]
|
||||||
|
check("A UNIT DELIVERING 0 OF 125 W IS NAMED, AND UNDER THE OLD RULE IT WAS "
|
||||||
|
"NOT. Same three units, same `min_w`, one input changed: the absolute arm "
|
||||||
|
"alone answers {} because 125 W < 500 W, and the shipped rule names it "
|
||||||
|
"because 100 % of its ask is not arriving",
|
||||||
|
delinquent(_tiny, 500, _ABS) == {}
|
||||||
|
and list(delinquent(_tiny, 500)) == ["b"])
|
||||||
|
# ⚠️ `.get`, NOT `["b"]`, AND THAT IS NOT STYLE. A bare subscript here raises
|
||||||
|
# KeyError on any tree where the relative arm is broken or absent, which ABORTS
|
||||||
|
# THE FILE at part 1 and leaves parts 2-8 UNGRADED. The direction is safe - an
|
||||||
|
# abort is a red, and MUTATION.md's defect can only fabricate a green - but the
|
||||||
|
# recorded coverage of a mutation run then reads larger than it was, which is
|
||||||
|
# the shape M11's entry in test_packshare.py part 11 already discloses. Found
|
||||||
|
# in review of this card, on M12 and M13.
|
||||||
|
_tiny_said = delinquent(_tiny, 500).get("b", "(not named - see the check above)")
|
||||||
|
print(f" {_tiny_said}")
|
||||||
|
check("...and the sentence carries the PROPORTION as well as the watts, because "
|
||||||
|
"'125 W is not arriving' reads like a rounding error and '125 W (100 % of "
|
||||||
|
"it)' is the fault",
|
||||||
|
"125 W of its share (100 % of it) is not arriving" in _tiny_said)
|
||||||
|
|
||||||
|
check("THE ACCOUNT GATE IS UNTOUCHED BY IT - the same 0 of 125 W with the "
|
||||||
|
"shortfall EXPLAINED names nobody. A relative criterion that could talk "
|
||||||
|
"its way past `account()` would report every pack sitting at its own "
|
||||||
|
"cut-out on a small share, which is the crying-wolf failure this module "
|
||||||
|
"exists to avoid",
|
||||||
|
delinquent([U("a", 1505, 1505), U("b", 125, 0, "its pack is empty")],
|
||||||
|
500) == {})
|
||||||
|
check("...and so is the PROPER-SUBSET rule: both units delivering none of a "
|
||||||
|
"small share is a SITE condition and is still nobody's fault to name",
|
||||||
|
delinquent([U("a", 125, 0), U("b", 125, 0)], 500) == {})
|
||||||
|
check("...and the relative bound is STRICT, like the absolute one. A gap of "
|
||||||
|
"EXACTLY the fraction of the share is not over it; one watt more is",
|
||||||
|
delinquent([U("a", 2000, 2000),
|
||||||
|
U("b", 1000, 1000 * (1.0 - DELIVERY_SHARE_FRACTION))],
|
||||||
|
5000) == {}
|
||||||
|
and list(delinquent([U("a", 2000, 2000),
|
||||||
|
U("b", 1000,
|
||||||
|
1000 * (1.0 - DELIVERY_SHARE_FRACTION) - 1)],
|
||||||
|
5000)) == ["b"])
|
||||||
|
|
||||||
|
_both_rules = [(delinquent(u, w, _ABS), delinquent(u, w)) for u, w in (
|
||||||
|
([U("a", 2000, 2000), U("b", 2000, 0)], 500),
|
||||||
|
([U("a", 2000, 2000), U("b", 2000, 0, "its pack is empty")], 500),
|
||||||
|
([U("a", 2000, 0), U("b", 2000, 0)], 500),
|
||||||
|
([U("a", 2000, 2000), U("b", 2000, 0), U("c", 2000, 0)], 500),
|
||||||
|
([U("a", 2000, 0, "at its floor"), U("b", 2000, 0)], 500),
|
||||||
|
([U("a", 2000, 2000), U("b", 2000, 1500)], 500),
|
||||||
|
([U("a", 0, 0), U("b", 2000, 0)], 500),
|
||||||
|
)]
|
||||||
|
check(f"⚠️ AND IT TOOK NOTHING AWAY: all {len(_both_rules)} fixtures above this "
|
||||||
|
f"block answer IDENTICALLY under SAFETY-42's rule and the shipped one. "
|
||||||
|
f"The relative arm is an `or`, so it can only ever ADD a naming - and on "
|
||||||
|
f"every case this part already graded it adds none. That is the "
|
||||||
|
f"difference between adding a second way to be named and lowering "
|
||||||
|
f"`min_w`, which would have moved all seven",
|
||||||
|
all(a == b for a, b in _both_rules))
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(" AC 5: AT ONE INVERTER IT IS INERT, AND BY ALGEBRA RATHER THAN BY A")
|
print(" AC 5: AT ONE INVERTER IT IS INERT, AND BY ALGEBRA RATHER THAN BY A")
|
||||||
print(" ROSTER GUARD - `len(asked) < 2` is unreachable-by-construction there")
|
print(" ROSTER GUARD - `len(asked) < 2` is unreachable-by-construction there")
|
||||||
@@ -323,9 +395,14 @@ class _DeliveryHass:
|
|||||||
same in both.
|
same in both.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, load_w=_LOAD_W, b_soc=50.0):
|
def __init__(self, load_w=_LOAD_W, b_soc=50.0, b_frac=0.0):
|
||||||
self.load_w = float(load_w)
|
self.load_w = float(load_w)
|
||||||
self.b_soc = float(b_soc)
|
self.b_soc = float(b_soc)
|
||||||
|
# SAFETY-43. What fraction of its command B actually delivers. 0.0 is a
|
||||||
|
# WEDGED unit and is every arm in this file but part 4b(a) - see there
|
||||||
|
# for why that one arm needs a unit that is short WITHOUT being 100 %
|
||||||
|
# short.
|
||||||
|
self.b_frac = float(b_frac)
|
||||||
self.writes = []
|
self.writes = []
|
||||||
self.calls = []
|
self.calls = []
|
||||||
self.ctrl = None
|
self.ctrl = None
|
||||||
@@ -343,8 +420,11 @@ class _DeliveryHass:
|
|||||||
# A obeys. B produces NOTHING, whatever it is told - and whether that is
|
# A obeys. B produces NOTHING, whatever it is told - and whether that is
|
||||||
# a wedged power stage or an empty pack is not a question its power
|
# a wedged power stage or an empty pack is not a question its power
|
||||||
# register can answer.
|
# register can answer.
|
||||||
|
b = self.ctrl.inverters[1]
|
||||||
|
b_cmd = float((b.last_write if b.last_write is not None else b.target)
|
||||||
|
or 0.0)
|
||||||
return [float((a.last_write if a.last_write is not None else a.target)
|
return [float((a.last_write if a.last_write is not None else a.target)
|
||||||
or 0.0), 0.0]
|
or 0.0), self.b_frac * b_cmd]
|
||||||
|
|
||||||
async def number(self, entity, invert=False):
|
async def number(self, entity, invert=False):
|
||||||
m = self.measured()
|
m = self.measured()
|
||||||
@@ -630,15 +710,27 @@ print(" schema. Same load, same wedged unit, two configured thresholds.")
|
|||||||
# load is what puts B's shortfall BETWEEN the two: above the shipped threshold,
|
# load is what puts B's shortfall BETWEEN the two: above the shipped threshold,
|
||||||
# below the raised one. The gap itself is printed by the run that produces it
|
# below the raised one. The gap itself is printed by the run that produces it
|
||||||
# and is never written down here; what is asserted is the ORDERING.
|
# and is never written down here; what is asserted is the ORDERING.
|
||||||
|
#
|
||||||
|
# ⚠️ AND B IS DERATED HERE RATHER THAN WEDGED, WHICH SAFETY-43 MADE NECESSARY.
|
||||||
|
# `delinquent()` now names a unit whose gap exceeds `min_w` OR a fraction of its
|
||||||
|
# own share, so a unit delivering NONE of its ask is named at every `min_w`
|
||||||
|
# there is - which is the whole point of that card, and which would make the
|
||||||
|
# raised arm below fire for a reason that has nothing to do with the watts. A
|
||||||
|
# unit delivering HALF of what it is asked for is short in absolute watts and
|
||||||
|
# comfortably inside the fraction, so it is the fixture that still isolates
|
||||||
|
# `saturation_w`. `_SAT_B_FRAC` is asserted below to be inside the relative
|
||||||
|
# arm's silence, so a future change to the fraction turns this red rather than
|
||||||
|
# quietly turning the pin vacuous.
|
||||||
_SAT_RAISED = 1800
|
_SAT_RAISED = 1800
|
||||||
_SAT_LOAD_W = 1600.0
|
_SAT_LOAD_W = 1600.0
|
||||||
|
_SAT_B_FRAC = 0.5
|
||||||
_SAT_SHIPPED = int(read_option("saturation_w"))
|
_SAT_SHIPPED = int(read_option("saturation_w"))
|
||||||
_sat_lo, _sat_hi = (int(x) for x in re.match(
|
_sat_lo, _sat_hi = (int(x) for x in re.match(
|
||||||
r"int\((\d+),(\d+)\)", read_option("saturation_w", "schema")).groups())
|
r"int\((\d+),(\d+)\)", read_option("saturation_w", "schema")).groups())
|
||||||
|
|
||||||
|
|
||||||
async def _at_saturation_w(sat):
|
async def _at_saturation_w(sat, b_frac=_SAT_B_FRAC):
|
||||||
hass = _DeliveryHass(load_w=_SAT_LOAD_W)
|
hass = _DeliveryHass(load_w=_SAT_LOAD_W, b_frac=b_frac)
|
||||||
c = _ctrl(hass, saturation_w=sat)
|
c = _ctrl(hass, saturation_w=sat)
|
||||||
return c, await _drive(c, watch=_sample)
|
return c, await _drive(c, watch=_sample)
|
||||||
|
|
||||||
@@ -674,6 +766,17 @@ check("the raised arm REACHED the comparison: B was asked for a discharge "
|
|||||||
and not _b_up.sign_wd.tripped
|
and not _b_up.sign_wd.tripped
|
||||||
and account(live=True, parked=False, soc=_b_up.soc,
|
and account(live=True, parked=False, soc=_b_up.soc,
|
||||||
bms_floor=_b_up.bms_soc_floor) is None)
|
bms_floor=_b_up.bms_soc_floor) is None)
|
||||||
|
check("...and SAFETY-43's RELATIVE arm is silent on both arms of this fixture, "
|
||||||
|
"which is what leaves the watts as the only moving part. B delivers half "
|
||||||
|
"of what it is asked for, so its gap is half its share and that is "
|
||||||
|
"inside `DELIVERY_SHARE_FRACTION`. ⚠️ A FIXTURE WHERE THE RELATIVE ARM "
|
||||||
|
"FIRED WOULD NAME B AT EVERY `saturation_w` AND THIS PIN WOULD BE "
|
||||||
|
"VACUOUS, so it is asserted rather than assumed",
|
||||||
|
_gap_up / float(_b_up.standing_w()) <= DELIVERY_SHARE_FRACTION
|
||||||
|
and _gap_ship / float(_b_ship.standing_w()) <= DELIVERY_SHARE_FRACTION
|
||||||
|
and delinquent([U("a", 1000, 1000),
|
||||||
|
U("b", float(_b_up.standing_w()), _b_up.batt)],
|
||||||
|
_SAT_RAISED) == {})
|
||||||
check("...and its shortfall lands STRICTLY BETWEEN the two configured "
|
check("...and its shortfall lands STRICTLY BETWEEN the two configured "
|
||||||
"thresholds, which is the one place the two answers can differ. Part 4's "
|
"thresholds, which is the one place the two answers can differ. Part 4's "
|
||||||
"fixtures are all above both, which is exactly why they cannot see this",
|
"fixtures are all above both, which is exactly why they cannot see this",
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ from app.arbiter import (
|
|||||||
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve,
|
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve,
|
||||||
)
|
)
|
||||||
from app.control import Tuning, compute
|
from app.control import Tuning, compute
|
||||||
from app.delivery import UnitDelivery, account, delinquent
|
from app.delivery import (
|
||||||
|
DELIVERY_SHARE_FRACTION, UnitDelivery, account, delinquent,
|
||||||
|
)
|
||||||
from app.peakguard import discharge_permitted
|
from app.peakguard import discharge_permitted
|
||||||
|
|
||||||
fails = []
|
fails = []
|
||||||
@@ -605,67 +607,87 @@ check("...and SAFETY-42 is where the case this card CANNOT reach is handled. A "
|
|||||||
"site ASKING a pack whose own SoC says it cannot deliver; SAFETY-42 "
|
"site ASKING a pack whose own SoC says it cannot deliver; SAFETY-42 "
|
||||||
"alarms when a pack that said it could does not. ⚠️ THE WEIGHT, HOWEVER, "
|
"alarms when a pack that said it could does not. ⚠️ THE WEIGHT, HOWEVER, "
|
||||||
"DOES MOVE - this check used to say it did not - and part 8b grades what "
|
"DOES MOVE - this check used to say it did not - and part 8b grades what "
|
||||||
"that costs the alarm",
|
"that cost the alarm, and what SAFETY-43 did about it",
|
||||||
not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding)
|
not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding)
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
print()
|
print()
|
||||||
print("8b. ⚠️ AND WHAT THE WEIGHTS DO TO SAFETY-42's DETECTION WINDOW, WHICH IS "
|
print("8b. ⚠️ WHAT THE WEIGHTS DID TO SAFETY-42's DETECTION WINDOW, AND WHAT "
|
||||||
"A REGRESSION AND IS PINNED HERE AS ONE")
|
"SAFETY-43 DID ABOUT IT - GRADED UNDER BOTH RULES, ON ONE FIXTURE")
|
||||||
|
|
||||||
# ⚠️ READ THIS BEFORE READING THE CHECKS: THEY ASSERT THE UNFLATTERING ANSWER ON
|
# ⚠️ THIS PART USED TO PIN THE UNFLATTERING ANSWER ON PURPOSE, AND SAFETY-43 IS
|
||||||
# PURPOSE. An earlier draft of §5 of the decision said a wedged unit's weight
|
# THE CARD THAT INVERTED IT. What it said - and it was true when it was written
|
||||||
# does not move, so OPT-09 "cannot see it at all". The first clause is about the
|
# - was: a wedged unit's weight MOVES whenever its energy headroom differs from
|
||||||
# ENVELOPE and is true; the second is FALSE. A wedged unit's weight moves
|
# its sibling's (it is a pack fact, not a delivery fact), `delivery.delinquent()`
|
||||||
# whenever its energy headroom differs from its sibling's - it is a pack fact,
|
# named a unit only while its `short_by()` gap exceeded `min_w`
|
||||||
# not a delivery fact - and `delivery.delinquent()` names a unit only while its
|
# (`Tuning.saturation_w`, 500 W) IN ABSOLUTE WATTS, and shrinking a unit's share
|
||||||
# `short_by()` gap exceeds `min_w` (`Tuning.saturation_w`, 500 W) IN ABSOLUTE
|
# shrinks its gap. Shrink it below 500 W and the alarm stopped firing on a unit
|
||||||
# WATTS. Shrinking a unit's share shrinks its gap. Shrink it below 500 W and the
|
# that was still delivering nothing. The old text ended "if it ever goes red
|
||||||
# alarm stops firing on a unit that is still delivering nothing.
|
# because the alarm fired, read the card that made it fire before 'fixing' this
|
||||||
|
# line". SAFETY-43 is that card, this is that red, and the checks below are the
|
||||||
|
# same three measurements with their answers the other way round.
|
||||||
#
|
#
|
||||||
# So the seam is not "OPT-09 cannot see it". It is: OPT-09 stops the ask AND
|
# ⚠️ AND THE EVIDENCE IS THE DIFF BETWEEN TWO RULES ON ONE RUN, NOT A NUMBER
|
||||||
# narrows SAFETY-42's detection window on the same unit. At equal weights the
|
# THAT CHANGED IN A COMMIT. `delinquent()` takes `share_frac`, and
|
||||||
# window is `site target < 2 * min_w` = 1000 W; at OPT-09's weights it is
|
# `share_frac=None` is SAFETY-42's rule EXACTLY - the absolute arm alone. So
|
||||||
# `site target < min_w * (w_A + w_B) / w_B`, which on this fixture is 6500 W.
|
# `wedged_loop` grades every cycle twice, once under each rule, and the red and
|
||||||
|
# the green sit side by side in one output:
|
||||||
#
|
#
|
||||||
# ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
# load 1500 W, OPT-09 weights, absolute arm only named on 0/200 cycles
|
||||||
|
# load 1500 W, OPT-09 weights, the shipped rule named on 200/200 cycles
|
||||||
#
|
#
|
||||||
# ⚠️ THIS IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE. Making
|
# A future reader who wants to know what this card did runs this file.
|
||||||
# `delinquent()` relative to the ask rather than absolute is SAFETY-42's code and
|
#
|
||||||
# a design change; it is filed as card SAFETY-43. What this part does is GRADE THE
|
# ⚠️ WHAT SAFETY-43 DID NOT DO IS LOWER `min_w`, and the check on the window
|
||||||
# CURRENT BEHAVIOUR so that the day it changes, a named check moves.
|
# arithmetic below is what holds that open: the ABSOLUTE arm's suppression
|
||||||
|
# window is still 500 W wide and still widens to 6500 W under these weights.
|
||||||
|
# Nothing that was quiet became noisy; a SECOND way to be named was added, and
|
||||||
|
# it is a fraction of the unit's OWN ask, so it fires on a unit delivering none
|
||||||
|
# of 125 W exactly as it fires on one delivering none of 1500 W.
|
||||||
#
|
#
|
||||||
# ⚠️ AND THE FIXTURE IS A SAFETY-42 TRUE POSITIVE BY THAT CARD'S OWN DEFINITION,
|
# ⚠️ AND THE FIXTURE IS A SAFETY-42 TRUE POSITIVE BY THAT CARD'S OWN DEFINITION,
|
||||||
# checked below before anything is concluded from it: B is WEDGED - it accepts
|
# checked below before anything is concluded from it: B is WEDGED - it accepts
|
||||||
# its share and produces nothing - at a HEALTHY 15 % against its own 10 % floor,
|
# its share and produces nothing - at a HEALTHY 15 % against its own 10 % floor,
|
||||||
# so `envelope()` does not close and `account()` explains nothing. This is
|
# so `envelope()` does not close and `account()` explains nothing. This is
|
||||||
# exactly the case §5 says the envelope cannot reach, which is why it is the
|
# exactly the case §5 says the envelope cannot reach, which is why it is the
|
||||||
# case where the weights' side effect matters.
|
# case where the weights' side effect mattered.
|
||||||
#
|
#
|
||||||
# ⚠️ TWO COPIES OF A. Inverter B does not exist; the plant below is modelled in
|
# ⚠️ TWO COPIES OF A. Inverter B does not exist; the plant below is modelled in
|
||||||
# this file the way part 8's is, and B's ratings are datasheet.
|
# this file the way part 8's is, and B's ratings are datasheet.
|
||||||
|
|
||||||
WEDGED_A_SOC, WEDGED_B_SOC = 90.0, 15.0 # both healthy, both above their floor
|
WEDGED_A_SOC, WEDGED_B_SOC = 90.0, 15.0 # both healthy, both above their floor
|
||||||
MIN_W = Tuning().saturation_w # what `delinquent()` is handed
|
MIN_W = Tuning().saturation_w # what `delinquent()` is handed
|
||||||
|
ABSOLUTE_ONLY = None # `share_frac=None` - SAFETY-42's rule
|
||||||
|
|
||||||
|
|
||||||
def wedged_loop(load_w, weighted, cycles=200):
|
def wedged_loop(load_w, weighted, cycles=200, share_frac=DELIVERY_SHARE_FRACTION,
|
||||||
|
b_delivers=0.0, b_soc=WEDGED_B_SOC, no_account=False):
|
||||||
"""The closed loop with unit B WEDGED at a healthy SoC. Real everything.
|
"""The closed loop with unit B WEDGED at a healthy SoC. Real everything.
|
||||||
|
|
||||||
Real `compute()`, real `plan()`, real `packshare.weights()`, real
|
Real `compute()`, real `plan()`, real `packshare.weights()`, real
|
||||||
`delivery.account()` and real `delivery.delinquent()`. The only model is the
|
`delivery.account()` and real `delivery.delinquent()`. The only model is the
|
||||||
plant: A obeys within one cycle inside its rating, B accepts whatever it is
|
plant: A obeys within one cycle inside its rating, B delivers `b_delivers`
|
||||||
told and delivers 0 W - which is the wedged box, and is what makes this a
|
of whatever it is told - 0.0 is the wedged box, and is what makes this a
|
||||||
SAFETY-42 case rather than an OPT-09 one.
|
SAFETY-42 case rather than an OPT-09 one.
|
||||||
|
|
||||||
`weighted=False` is equal weights - what shipped. Envelopes are left OPEN in
|
`weighted=False` is equal weights - what shipped. Envelopes are left OPEN in
|
||||||
both arms deliberately: neither pack's SoC closes one here, so filing them
|
both arms deliberately: neither pack's SoC closes one here, so filing them
|
||||||
would change nothing and would blur which half of the card is being graded.
|
would change nothing and would blur which half of the card is being graded.
|
||||||
|
|
||||||
|
`share_frac` is SAFETY-43's relative arm. `ABSOLUTE_ONLY` grades the same
|
||||||
|
cycles under SAFETY-42's rule, which is how the red and the green below come
|
||||||
|
out of ONE run over ONE tree instead of out of two commits.
|
||||||
|
|
||||||
|
`no_account=True` DELETES the account gate - every unit is handed
|
||||||
|
`accounted=None` - and it exists to answer "would this arm still be silent
|
||||||
|
if `account()` did nothing at all?". A control that answers the same way
|
||||||
|
with the gate removed is not grading the gate, and the cut-out arm below is
|
||||||
|
exactly that case. See it there; it is the point of that block.
|
||||||
|
|
||||||
Returns `(cycles on which delinquent() named somebody, last cycle)`.
|
Returns `(cycles on which delinquent() named somebody, last cycle)`.
|
||||||
"""
|
"""
|
||||||
packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, WEDGED_B_SOC, B_KWH)]
|
packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, b_soc, B_KWH)]
|
||||||
tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=A_W + B_W)
|
tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=A_W + B_W)
|
||||||
prev, i_w, sat = 0.0, None, 0
|
prev, i_w, sat = 0.0, None, 0
|
||||||
power = [0.0, 0.0]
|
power = [0.0, 0.0]
|
||||||
@@ -678,13 +700,15 @@ def wedged_loop(load_w, weighted, cycles=200):
|
|||||||
r = plan(d.target_w, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=w)
|
r = plan(d.target_w, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=w)
|
||||||
assert r.ok, r.refused
|
assert r.ok, r.refused
|
||||||
# THE MODELLED PLANT: A within one cycle inside its rating; B WEDGED.
|
# THE MODELLED PLANT: A within one cycle inside its rating; B WEDGED.
|
||||||
power = [max(-A_W, min(A_W, float(r.shares[A]))), 0.0]
|
power = [max(-A_W, min(A_W, float(r.shares[A]))),
|
||||||
|
b_delivers * max(-B_W, min(B_W, float(r.shares[Bee])))]
|
||||||
grid = load_w - sum(power)
|
grid = load_w - sum(power)
|
||||||
who = delinquent(
|
who = delinquent(
|
||||||
[UnitDelivery(p.name, r.shares[p.name], m,
|
[UnitDelivery(p.name, r.shares[p.name], m,
|
||||||
|
None if no_account else
|
||||||
account(live=True, parked=False, soc=p.soc,
|
account(live=True, parked=False, soc=p.soc,
|
||||||
bms_floor=p.bms_floor))
|
bms_floor=p.bms_floor))
|
||||||
for p, m in zip(packs, power)], MIN_W)
|
for p, m in zip(packs, power)], MIN_W, share_frac)
|
||||||
named += bool(who)
|
named += bool(who)
|
||||||
last = (d.target_w, dict(r.shares), grid, who)
|
last = (d.target_w, dict(r.shares), grid, who)
|
||||||
return named, last
|
return named, last
|
||||||
@@ -698,75 +722,271 @@ check("PRECONDITION: the wedged unit is a SAFETY-42 TRUE POSITIVE and NOT an "
|
|||||||
not packshare.envelope(_WEDGED_PACK).binding
|
not packshare.envelope(_WEDGED_PACK).binding
|
||||||
and account(live=True, parked=False, soc=WEDGED_B_SOC,
|
and account(live=True, parked=False, soc=WEDGED_B_SOC,
|
||||||
bms_floor=FLOOR) is None)
|
bms_floor=FLOOR) is None)
|
||||||
|
check("PRECONDITION: the shipped fraction is a fraction, and it is STRICTLY "
|
||||||
|
"below 1.0 - at 1.0 the relative arm could only fire on a unit IMPORTING "
|
||||||
|
"while asked to discharge, which is SAFETY-30's fault and not this one, "
|
||||||
|
"and this card's own case (a unit delivering NONE of its share, gap over "
|
||||||
|
"share exactly 1.0) would be missed",
|
||||||
|
0.0 < DELIVERY_SHARE_FRACTION < 1.0)
|
||||||
|
|
||||||
_wedge = {}
|
_wedge = {}
|
||||||
for _load in (1500.0, 6000.0):
|
for _load in (1500.0, 6000.0):
|
||||||
for _weighted in (False, True):
|
for _weighted in (False, True):
|
||||||
_n, _t = wedged_loop(_load, _weighted)
|
for _frac in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION):
|
||||||
_wedge[(_load, _weighted)] = (_n, _t)
|
_n, _t = wedged_loop(_load, _weighted, share_frac=_frac)
|
||||||
print(f" load {_load:.0f} W, B wedged at 15 %, "
|
_wedge[(_load, _weighted, _frac)] = (_n, _t)
|
||||||
f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} "
|
print(f" load {_load:.0f} W, B wedged at 15 %, "
|
||||||
f"named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W, shares "
|
f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} "
|
||||||
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items()))
|
+ ("absolute arm only" if _frac is ABSOLUTE_ONLY
|
||||||
+ f", grid {_t[2]:+.0f} W, named="
|
else f"frac {_frac:.2f} ")
|
||||||
+ (str(sorted(_t[3])) if _t[3] else "(nobody)"))
|
+ f", named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W,"
|
||||||
|
+ " shares "
|
||||||
|
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items()))
|
||||||
|
+ f", grid {_t[2]:+.0f} W, named="
|
||||||
|
+ (str(sorted(_t[3])) if _t[3] else "(nobody)"))
|
||||||
|
|
||||||
check("AT EQUAL WEIGHTS - WHAT SHIPPED - the wedged unit is named on every "
|
check("⚠️ THE CARD, RED: UNDER SAFETY-42's RULE AND OPT-09's WEIGHTS THE WEDGED "
|
||||||
"cycle but the first, at a load its sibling can carry on its own. The "
|
"UNIT IS NAMED ON NONE OF THE 200. Same wedged box, same healthy SoC, "
|
||||||
"loop winds the site target to twice the load, the 50/50 split stands "
|
"same load, same detector: the split moves to 1505/125, the gap on the "
|
||||||
"+1500 W on a box producing nothing, and 1500 W > 500 W is what "
|
"wedged unit falls to 125 W, and 125 W < 500 W so the absolute arm "
|
||||||
"`delinquent()` reads",
|
"returns {}. This is the defect SAFETY-43 was filed for, and it is "
|
||||||
_wedge[(1500.0, False)][0] == 199
|
"measured here rather than remembered",
|
||||||
and sorted(_wedge[(1500.0, False)][1][3]) == [Bee]
|
_wedge[(1500.0, True, ABSOLUTE_ONLY)][0] == 0
|
||||||
and _wedge[(1500.0, False)][1][1] == {A: 1500, Bee: 1500})
|
and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][3] == {}
|
||||||
|
and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][1] == {A: 1505, Bee: 125})
|
||||||
|
|
||||||
check("⚠️ AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200. Same wedged "
|
check("⚠️ THE CARD, GREEN: THE SHIPPED RULE NAMES IT ON ALL 200, ON THE SAME "
|
||||||
"box, same healthy SoC, same load, same detector: the split moves to "
|
"RUN. The unit is standing at +125 W and delivering 0 W - 100 % of its "
|
||||||
"1505/125, the gap on the wedged unit falls to 125 W, and 125 W < 500 W "
|
"ask is not arriving - and `gap > frac * share` does not care that 125 W "
|
||||||
"so `delinquent()` returns {}. THIS CHECK PINS A REGRESSION - it asserts "
|
"is a small number. The ONLY input that moved between this check and the "
|
||||||
"what the code DOES, not what anyone wants, and if it ever goes red "
|
"one above is `share_frac`",
|
||||||
"because the alarm fired, read the card that made it fire before "
|
_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||||
"'fixing' this line",
|
and sorted(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee]
|
||||||
_wedge[(1500.0, True)][0] == 0
|
and _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][1] \
|
||||||
and _wedge[(1500.0, True)][1][3] == {}
|
== {A: 1505, Bee: 125})
|
||||||
and _wedge[(1500.0, True)][1][1] == {A: 1505, Bee: 125})
|
|
||||||
|
check("AT EQUAL WEIGHTS - WHAT SHIPPED BEFORE OPT-09 - the wedged unit was "
|
||||||
|
"already named under the absolute rule on every cycle but the first, at "
|
||||||
|
"a load its sibling can carry on its own. The loop winds the site target "
|
||||||
|
"to twice the load, the 50/50 split stands +1500 W on a box producing "
|
||||||
|
"nothing, and 1500 W > 500 W is what the absolute arm reads",
|
||||||
|
_wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 199
|
||||||
|
and sorted(_wedge[(1500.0, False, ABSOLUTE_ONLY)][1][3]) == [Bee]
|
||||||
|
and _wedge[(1500.0, False, ABSOLUTE_ONLY)][1][1] == {A: 1500, Bee: 1500})
|
||||||
|
|
||||||
|
check("...and the shipped rule adds exactly the FIRST cycle to that arm and "
|
||||||
|
"nothing else - 199 becomes 200. On cycle one the slew limit holds the "
|
||||||
|
"split at 500/500, a 500 W gap is NOT over a 500 W threshold (the "
|
||||||
|
"absolute bound is strict), and the relative arm names a box delivering "
|
||||||
|
"none of its 500 W. That one cycle is the whole of what this card costs "
|
||||||
|
"an arm that was already firing",
|
||||||
|
_wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||||
|
and _wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0]
|
||||||
|
- _wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 1)
|
||||||
|
|
||||||
check("...and this is the regime where NOTHING ELSE TELLS THE OPERATOR "
|
check("...and this is the regime where NOTHING ELSE TELLS THE OPERATOR "
|
||||||
"ANYTHING: the meter is satisfied (grid within 5 W of zero), `unmet_w` is "
|
"ANYTHING: the meter is satisfied (grid within 5 W of zero), `unmet_w` is "
|
||||||
"0 because both units accepted their shares, no envelope closed and no "
|
"0 because both units accepted their shares, no envelope closed and no "
|
||||||
"action-log line fires. The site is quietly running one inverter and the "
|
"action-log line fires. The site is quietly running one inverter, and "
|
||||||
"one instrument built to say so has gone quiet with it",
|
"before this card the one instrument built to say so had gone quiet with "
|
||||||
abs(_wedge[(1500.0, True)][1][2]) <= 5.0
|
"it",
|
||||||
and abs(_wedge[(1500.0, False)][1][2]) <= 5.0)
|
abs(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0
|
||||||
|
and abs(_wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0)
|
||||||
|
|
||||||
_w = packshare.weights([PE(A, WEDGED_A_SOC, A_KWH), _WEDGED_PACK], 1000.0)
|
_w = packshare.weights([PE(A, WEDGED_A_SOC, A_KWH), _WEDGED_PACK], 1000.0)
|
||||||
_window_equal = MIN_W * 2.0
|
_window_equal = MIN_W * 2.0
|
||||||
_window_opt09 = MIN_W * sum(_w.values()) / _w[Bee]
|
_window_opt09 = MIN_W * sum(_w.values()) / _w[Bee]
|
||||||
print(f" suppression window on this fixture: equal weights, site target "
|
print(f" the ABSOLUTE arm's suppression window is UNCHANGED by SAFETY-43: "
|
||||||
f"< {_window_equal:.0f} W; OPT-09 weights, site target "
|
f"equal weights, site target < {_window_equal:.0f} W; OPT-09 weights, "
|
||||||
f"< {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site")
|
f"site target < {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site")
|
||||||
check("...and the arithmetic behind it, stated as a window rather than as one "
|
check("⚠️ AND `min_w` WAS NOT LOWERED, WHICH IS THE HALF OF THIS CARD THAT IS "
|
||||||
"fixture: `delinquent()` reads an ABSOLUTE gap, so a unit weighted to a "
|
"EASIEST TO GET WRONG. The absolute arm still suppresses below 1000 W at "
|
||||||
"fraction f of the site target is invisible while `f * target < min_w`. "
|
"equal weights and below 6500 W at these ones - the same arithmetic this "
|
||||||
"Equal weights suppress below 1000 W; these weights suppress below "
|
"part has always printed - because SAFETY-43 ADDED a second way to be "
|
||||||
"6500 W of a 9600 W site. ⚠️ BOTH ARE PROPORTIONAL-REGIME BOUNDS AND "
|
"named rather than loosening the first. Lowering `saturation_w` would "
|
||||||
"NEITHER IS REACHABLE: the healthy pack's own rating binds first, so the "
|
"have reported normal saturation on every unit at once, which is the "
|
||||||
"alarm returns BELOW 6500 W (swept: 0/200 to a 3000 W load, 41/200 at "
|
"false-trip regime SAFETY-41 spent four attempts in",
|
||||||
"4900 W, 195/200 at 5000 W) and the equal arm never suppresses at all. "
|
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0
|
||||||
"That is the regression, and it is a property "
|
and MIN_W == Tuning().saturation_w == 500.0)
|
||||||
"of the threshold being absolute rather than of any number chosen here",
|
|
||||||
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0)
|
check("⚠️ AND THE ABSOLUTE ARM WAS NEVER SILENT EVERYWHERE - which is what a "
|
||||||
|
"check that only ever ran the quiet load would be pinning. Push the load "
|
||||||
|
"past the healthy pack's OWN RATING and `_fill()`'s last-resort pass "
|
||||||
|
"hands the wedged unit 3000 W anyway, the gap goes back over 500 W, and "
|
||||||
|
"both arms named it under SAFETY-42's rule too. The window was real and "
|
||||||
|
"it had an upper edge; the hole was the quiet regime below that edge, "
|
||||||
|
"and the shipped rule now names the unit on every cycle of all four runs",
|
||||||
|
_wedge[(6000.0, True, ABSOLUTE_ONLY)][0] == 195
|
||||||
|
and _wedge[(6000.0, False, ABSOLUTE_ONLY)][0] == 199
|
||||||
|
and _wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||||
|
and _wedge[(6000.0, False, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||||
|
and sorted(_wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee])
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" THE POSITIVE CONTROL: what the relative arm must STILL NOT say")
|
||||||
|
|
||||||
|
# ⚠️ A CARD THAT ADDS A WAY TO RAISE AN ALARM HAS TO SHOW WHAT IT DID NOT ADD,
|
||||||
|
# or it has traded a missed alarm for a false one - which in this system is not
|
||||||
|
# a rhetorical risk. SAFETY-41 took four attempts on notification cadence and
|
||||||
|
# the owner's words were "i'm getting really annoyed by the notifications in my
|
||||||
|
# HA". So: the same fixture, the same weights, the same tiny 125 W share, with
|
||||||
|
# the ONE fact that makes the shortfall legitimate changed.
|
||||||
|
|
||||||
|
# ⚠️ AND THE FIRST DRAFT OF THIS BLOCK WAS VACUOUS, WHICH IS WHY IT NOW PRINTS
|
||||||
|
# ITS SHARES. It ran the cut-out arm, saw 0/200, and concluded "`account()`
|
||||||
|
# explains it" and "identical trace to the wedged arm". BOTH WERE FALSE. At its
|
||||||
|
# own cut-out the pack's usable energy ABOVE that cut-out is zero, so
|
||||||
|
# `weights()` gives it 0.0, `_fill()`'s preferred pass gives it nothing, the
|
||||||
|
# healthy sibling covers the load alone so the LAST-RESORT pass never runs, and
|
||||||
|
# its share is 0 W on ALL 200 CYCLES. A unit with a 0 W share is not in `asked`,
|
||||||
|
# so `len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()`
|
||||||
|
# is never consulted - measured below by deleting the gate and getting the same
|
||||||
|
# answer. The wedged arm's share is the 125 W printed on its own line above;
|
||||||
|
# this one's is 0, printed and asserted below. Nothing was identical and nothing
|
||||||
|
# reached the comparison.
|
||||||
|
#
|
||||||
|
# ⚠️ THE FACT THAT REPLACES IT IS BETTER, NOT WEAKER: at its own cut-out OPT-09
|
||||||
|
# STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached.
|
||||||
|
# That is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the
|
||||||
|
# allocator declines to ask before the detector has to decline to alarm. The
|
||||||
|
# account gate itself is graded where it is REAL, on a positive 125 W share, in
|
||||||
|
# `test_delivery.py` part 1 ("THE ACCOUNT GATE IS UNTOUCHED BY IT").
|
||||||
|
#
|
||||||
|
# ⚠️ AND IT WAS INVISIBLE FOR ONE REASON: this was the only line in part 8b that
|
||||||
|
# did not print its shares. `inverter-b+0` would have been on screen from the
|
||||||
|
# first run. Every line in this part prints them now.
|
||||||
|
_sat_named, _sat_last = wedged_loop(1500.0, True, b_soc=FLOOR)
|
||||||
|
_sat_nogate, _sat_nogate_last = wedged_loop(1500.0, True, b_soc=FLOOR,
|
||||||
|
no_account=True)
|
||||||
|
print(f" load 1500 W, B AT ITS OWN {FLOOR:.0f} % CUT-OUT, OPT-09 weights, "
|
||||||
|
f"frac {DELIVERY_SHARE_FRACTION:.2f}: named on {_sat_named:3d}/200 "
|
||||||
|
f"cycles, shares "
|
||||||
|
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_sat_last[1].items()))
|
||||||
|
+ ", named="
|
||||||
|
+ (str(sorted(_sat_last[3])) if _sat_last[3] else "(nobody)")
|
||||||
|
+ f"; with account() DELETED: {_sat_nogate:3d}/200")
|
||||||
|
# ⚠️ AND THE `no_account=True` SWITCH ITSELF WAS GRADED BY NOTHING. It is used
|
||||||
|
# in exactly one place - the fixture above - where it PROVABLY cannot change the
|
||||||
|
# answer, because a cut-out pack is never asked and the gate is never reached.
|
||||||
|
# Review neutered the switch so it removed nothing: the file stayed GREEN and
|
||||||
|
# printed "with account() DELETED: 0/200" while `account()` had not been
|
||||||
|
# deleted. A line asserting a mechanism that did not happen - B1's shape, one
|
||||||
|
# level down, in the fix for B1.
|
||||||
|
#
|
||||||
|
# The switch DOES bite, on a fixture already reachable from this helper: at the
|
||||||
|
# cut-out with EQUAL weights, `plan()` hands B a 50/50 share, so the gate is the
|
||||||
|
# only thing keeping it silent. Measured: gate on 0/200, gate deleted 200/200.
|
||||||
|
# That proves the switch removes the gate, AND gives this part the end-to-end
|
||||||
|
# account-gate control on a POSITIVE share that it otherwise has to outsource to
|
||||||
|
# test_delivery.py part 1.
|
||||||
|
_eq_gate, _eq_last = wedged_loop(1500.0, False, b_soc=FLOOR)
|
||||||
|
_eq_nogate, _ = wedged_loop(1500.0, False, b_soc=FLOOR, no_account=True)
|
||||||
|
print(f" ...and the SWITCH is real: same cut-out, EQUAL weights so B IS "
|
||||||
|
f"asked ({_eq_last[1][Bee]:+d} W last cycle) - gate on {_eq_gate:3d}/200, "
|
||||||
|
f"gate DELETED {_eq_nogate:3d}/200")
|
||||||
|
check("⭐ THE `no_account` SWITCH ACTUALLY REMOVES THE GATE, on a fixture where "
|
||||||
|
"the gate is the only thing keeping the pack silent. Without this the "
|
||||||
|
"line above prints 'with account() DELETED' about a run in which nothing "
|
||||||
|
"was deleted - which is exactly the defect this block was rewritten for",
|
||||||
|
_eq_gate == 0 and _eq_nogate == 200 and _eq_last[1][Bee] > 0)
|
||||||
|
|
||||||
|
check("A PACK AT ITS OWN CUT-OUT IS NEVER ASKED, SO THE RELATIVE ARM IS NEVER "
|
||||||
|
"REACHED. Its usable energy above its own cut-out is zero, `weights()` "
|
||||||
|
"answers 0.0 for it, the healthy sibling covers the load so `_fill()`'s "
|
||||||
|
"last-resort pass never runs, and its share is 0 W on every one of the "
|
||||||
|
"200 cycles - so it is not in `asked` and `len(asked) < 2` returns {} "
|
||||||
|
"before either arm is compared. ⚠️ THE SHARE IS ASSERTED, not inferred "
|
||||||
|
"from the silence: a control that cannot say WHY it is quiet is a control "
|
||||||
|
"that cannot fail",
|
||||||
|
_sat_named == 0 and _sat_last[3] == {} and _sat_last[1][Bee] == 0)
|
||||||
|
check("⚠️ AND THAT IS A SECOND, INDEPENDENT PROTECTION - NOT THE ACCOUNT GATE, "
|
||||||
|
"WHICH THIS ARM DOES NOT TOUCH. Delete `account()` entirely, hand every "
|
||||||
|
"unit `accounted=None`, and this arm is STILL silent on all 200 cycles, "
|
||||||
|
"because the protection is UPSTREAM of it: the allocator declines to ask "
|
||||||
|
"before the detector has to decline to alarm. ⚠️ SO DO NOT READ THIS "
|
||||||
|
"BLOCK AS EVIDENCE ABOUT `account()` - it is evidence about the ask. The "
|
||||||
|
"account gate on a POSITIVE share is graded in test_delivery.py part 1",
|
||||||
|
_sat_nogate == 0 and _sat_nogate_last[3] == {})
|
||||||
|
|
||||||
|
_derate_named, _derate_last = wedged_loop(1500.0, True, b_delivers=0.5)
|
||||||
|
print(f" load 1500 W, B delivering HALF its share, OPT-09 weights, frac "
|
||||||
|
f"{DELIVERY_SHARE_FRACTION:.2f}: named on {_derate_named:3d}/200 cycles, "
|
||||||
|
f"shares "
|
||||||
|
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_derate_last[1].items()))
|
||||||
|
+ ", named="
|
||||||
|
+ (str(sorted(_derate_last[3])) if _derate_last[3] else "(nobody)"))
|
||||||
|
check("...and ORDINARY TRACKING ON A SMALL SHARE IS NOT DELINQUENCY EITHER. "
|
||||||
|
"The same unit at the same weights delivering HALF of what it is asked "
|
||||||
|
"for is short by 50 % - well inside the shipped fraction - and its gap "
|
||||||
|
"in watts is far under `min_w`, so NEITHER arm names it. A relative "
|
||||||
|
"criterion that fired here would be the saturation-shaped alarm this "
|
||||||
|
"whole module is written against",
|
||||||
|
_derate_named == 0 and _derate_last[3] == {})
|
||||||
|
|
||||||
|
check("...and the DERATED control is not vacuous, which is asserted on the ONE "
|
||||||
|
"thing that could make it so: unlike the cut-out arm above, its unit IS "
|
||||||
|
"asked - a positive share on every cycle - so it really does reach both "
|
||||||
|
"arms and is turned away by the size of its shortfall. ⚠️ THIS GUARD USED "
|
||||||
|
"TO RE-ASSERT THE WEDGED ARM's 200/200 INSTEAD, WHICH IS WHY IT DID NOT "
|
||||||
|
"CATCH THE CUT-OUT ARM BEING VACUOUS: a check on a DIFFERENT run cannot "
|
||||||
|
"say anything about whether THIS one reached the comparison",
|
||||||
|
_derate_last[1][Bee] > 0
|
||||||
|
and _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" N=1 IS BYTE-IDENTICAL, AND THAT IS ASSERTED RATHER THAN ARGUED")
|
||||||
|
|
||||||
|
# ⚠️ THE LIVE INSTALL IS N=1 AND THIS CARD MUST NOT REACH IT. Two independent
|
||||||
|
# statements, and each is checked against the thing that would break it:
|
||||||
|
# 1. `plan()` RETURNS BEFORE `weights` IS READ AT ALL, so no weighting - and
|
||||||
|
# no change to how one is derived - can move a one-inverter share. Graded
|
||||||
|
# with a `weights` object that RAISES if anything reads it.
|
||||||
|
# 2. `delinquent()` returns {} at a roster of one for every input, including
|
||||||
|
# the input the RELATIVE arm was built for. The proper-subset algebra runs
|
||||||
|
# before either arm, so the fraction is never even multiplied.
|
||||||
|
|
||||||
|
|
||||||
|
class _Poison(dict):
|
||||||
|
"""A `weights` that cannot be read without saying so. Not a mock: a tripwire."""
|
||||||
|
|
||||||
|
def get(self, *a, **k):
|
||||||
|
raise AssertionError("plan() read weights at a roster of one")
|
||||||
|
|
||||||
|
def __getitem__(self, k):
|
||||||
|
raise AssertionError("plan() read weights at a roster of one")
|
||||||
|
|
||||||
|
|
||||||
|
_n1_plain = plan(1234.5, {A: _B(-A_W, A_W)})
|
||||||
|
_n1_poison = plan(1234.5, {A: _B(-A_W, A_W)}, weights=_Poison())
|
||||||
|
check("`plan()` at a roster of ONE returns the site target unread, and it "
|
||||||
|
"returns it WITHOUT TOUCHING `weights` - handed a mapping that raises on "
|
||||||
|
"any read, it answers identically to the same call with no weights at "
|
||||||
|
"all. The N=1 pass-through is above the weighting, so OPT-09 could not "
|
||||||
|
"move it and SAFETY-43 does not either",
|
||||||
|
_n1_poison.shares == _n1_plain.shares == {A: 1234.5}
|
||||||
|
and _n1_poison.unmet_w == _n1_plain.unmet_w)
|
||||||
|
try:
|
||||||
|
plan(1234.5, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=_Poison())
|
||||||
|
except AssertionError:
|
||||||
|
_poison_bites = True
|
||||||
|
else:
|
||||||
|
_poison_bites = False
|
||||||
|
check("...and the tripwire is a real one: the SAME poisoned mapping at a roster "
|
||||||
|
"of TWO is read and raises. A check that could not fail would be "
|
||||||
|
"measuring nothing",
|
||||||
|
_poison_bites)
|
||||||
|
|
||||||
|
_n1_rel = [delinquent([UnitDelivery(A, s, m)], MIN_W, f)
|
||||||
|
for s in (0, 125, 500, 2000, 5000, -2000, None)
|
||||||
|
for m in (0, 60, 125, 2000, -2000, None)
|
||||||
|
for f in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION)]
|
||||||
|
check(f"...and `delinquent()` answers {{}} at a roster of one on all "
|
||||||
|
f"{len(_n1_rel)} of (share, measured, rule) - INCLUDING 0 of 125 W, the "
|
||||||
|
f"exact input this card added an arm for. `len(asked) < 2` returns "
|
||||||
|
f"before either threshold is compared, so the one-inverter install this "
|
||||||
|
f"add-on is running today cannot tell that SAFETY-43 happened",
|
||||||
|
all(r == {} for r in _n1_rel))
|
||||||
|
|
||||||
check("⚠️ AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY - which is "
|
|
||||||
"what a check that only ever ran the quiet load would be pinning. Push "
|
|
||||||
"the load past the healthy pack's OWN RATING and `_fill()`'s last-resort "
|
|
||||||
"pass hands the wedged unit 3000 W anyway, the gap goes back over 500 W, "
|
|
||||||
"and BOTH arms name it. The window is real and it has an upper edge; the "
|
|
||||||
"hole is the quiet regime below that edge",
|
|
||||||
_wedge[(6000.0, True)][0] == 195
|
|
||||||
and sorted(_wedge[(6000.0, True)][1][3]) == [Bee]
|
|
||||||
and _wedge[(6000.0, False)][0] == 199
|
|
||||||
and sorted(_wedge[(6000.0, False)][1][3]) == [Bee])
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
print()
|
print()
|
||||||
@@ -978,9 +1198,12 @@ print(" a preference and are honest about being one.")
|
|||||||
print(" * A WEDGED UNIT IS NOT REACHABLE BY THE ENVELOPE. Its SoC reads")
|
print(" * A WEDGED UNIT IS NOT REACHABLE BY THE ENVELOPE. Its SoC reads")
|
||||||
print(" healthy, so nothing closes, and SAFETY-42 owns the detection.")
|
print(" healthy, so nothing closes, and SAFETY-42 owns the detection.")
|
||||||
print(" ⚠️ BUT IT IS REACHABLE BY THE WEIGHTS, WHICH MOVE ON ENERGY")
|
print(" ⚠️ BUT IT IS REACHABLE BY THE WEIGHTS, WHICH MOVE ON ENERGY")
|
||||||
print(" HEADROOM AND NOT ON DELIVERY - part 8b grades what that does")
|
print(" HEADROOM AND NOT ON DELIVERY. That narrowed SAFETY-42's")
|
||||||
print(" to SAFETY-42's window, and the answer is a REGRESSION that is")
|
print(" detection window - a regression this file PINNED rather than")
|
||||||
print(" pinned rather than fixed.")
|
print(" fixed - and SAFETY-43 closed it by making delinquency relative")
|
||||||
|
print(" to the ask as well as absolute. Part 8b now grades both rules")
|
||||||
|
print(" on one fixture: the absolute arm still says nothing, and the")
|
||||||
|
print(" shipped one names the unit on all 200 cycles.")
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
print()
|
print()
|
||||||
@@ -1023,13 +1246,41 @@ MUT = [
|
|||||||
"mutation took it down with a TypeError three lines into part 1"),
|
"mutation took it down with a TypeError three lines into part 1"),
|
||||||
("M11 weights(): keep the shape, force every weight to 1.0 <- THE PROOF "
|
("M11 weights(): keep the shape, force every weight to 1.0 <- THE PROOF "
|
||||||
"THAT PART 8b CAN GO RED",
|
"THAT PART 8b CAN GO RED",
|
||||||
"test_packshare 8b, three named checks: the wedged unit goes back to being "
|
"test_packshare 8b, FIVE named checks - re-run for SAFETY-43 rather than "
|
||||||
"named on 199/200 at 1500 W, the split reverts to 1500/1500 and the "
|
"carried across, because the part it grades was rewritten: the split "
|
||||||
"window collapses from 6500 W to 1000 W. ⚠️ RUN, NOT REASONED ABOUT - a "
|
"reverts to 1500/1500 so the absolute arm names the unit on 199/200 "
|
||||||
"check that pins an UNFLATTERING behaviour has to be shown capable of "
|
"(taking the card's RED check down), the shipped rule's 200/200 comes "
|
||||||
"failing, or it is indistinguishable from a check that pins nothing. "
|
"from the wrong shares (taking the GREEN one down), the OPT-09 window "
|
||||||
|
"collapses from 6500 W to 1000 W, the 6000 W arm's 195 becomes 199, and "
|
||||||
|
"the derated positive control moves from 0/200 to 3/200. ⚠️ RUN, NOT "
|
||||||
|
"REASONED ABOUT - a check that pins a behaviour has to be shown capable "
|
||||||
|
"of failing, or it is indistinguishable from a check that pins nothing. "
|
||||||
"(It also takes parts 1, 3, 7 and 8 down, which is M3's territory; 8b is "
|
"(It also takes parts 1, 3, 7 and 8 down, which is M3's territory; 8b is "
|
||||||
"the part that names the SAFETY-42 interaction)"),
|
"the part that names the SAFETY-42 interaction)"),
|
||||||
|
# --- SAFETY-43's three, and they pin the new arm from BOTH sides --------
|
||||||
|
("M12 delinquent(): drop the relative arm, keep `gap > min_w` <- THE "
|
||||||
|
"CARD ITSELF REVERTED",
|
||||||
|
"SEVEN named checks: test_delivery 1 (THREE - the 0-of-125 W naming, the "
|
||||||
|
"proportion in the sentence, and the strict relative bound) and "
|
||||||
|
"test_packshare 8b (FOUR - the GREEN check goes back to 0/200 under "
|
||||||
|
"OPT-09's weights, the equal arm's 200 back to 199, the 6000 W arm back "
|
||||||
|
"to 195/199, and the derated control's vacuity guard falls with them). "
|
||||||
|
"test_alloc and test_control stay green, which is the point - nothing "
|
||||||
|
"outside this check moves"),
|
||||||
|
("M13 DELIVERY_SHARE_FRACTION 0.85 -> 1.0 <- THE TOO-LOOSE DIRECTION",
|
||||||
|
"SEVEN named checks: test_delivery 1 (TWO) and test_packshare 8b (FIVE), "
|
||||||
|
"including the PRECONDITION that the fraction is strictly below 1.0. At "
|
||||||
|
"1.0 the arm can only fire on a unit IMPORTING while asked to discharge, "
|
||||||
|
"so the card's own case - 0 of 125 W, gap over share exactly 1.0 - is "
|
||||||
|
"missed and the whole card is a no-op"),
|
||||||
|
("M14 DELIVERY_SHARE_FRACTION 0.85 -> 0.05 <- THE TOO-SENSITIVE ONE",
|
||||||
|
"SIX named checks: test_delivery 1 (TWO - the strict bound, and 'IT TOOK "
|
||||||
|
"NOTHING AWAY' stops being true) and 4b (THREE - the `saturation_w` pin "
|
||||||
|
"goes VACUOUS, because the relative arm names B at every threshold), plus "
|
||||||
|
"test_packshare 8b's DERATED positive control, where a unit delivering "
|
||||||
|
"half its share starts being named. ⚠️ THIS IS THE ROW THAT MATTERS - the "
|
||||||
|
"fraction is pinned from BOTH directions, so it is a derived number with "
|
||||||
|
"checks around it rather than a literal nobody can move"),
|
||||||
]
|
]
|
||||||
for _n, _w in MUT:
|
for _n, _w in MUT:
|
||||||
print(f" caught {_n}")
|
print(f" caught {_n}")
|
||||||
@@ -1039,8 +1290,17 @@ check("every mutation in the table above was RUN and every one of them is "
|
|||||||
"M6: the whole defect this card fixes - a parameter nothing passes - is "
|
"M6: the whole defect this card fixes - a parameter nothing passes - is "
|
||||||
"invisible to every behavioural fixture in this repository, because they "
|
"invisible to every behavioural fixture in this repository, because they "
|
||||||
"all build their own claim list and call `resolve()` themselves. That is "
|
"all build their own claim list and call `resolve()` themselves. That is "
|
||||||
"why the call site is pinned statically as well",
|
"why the call site is pinned statically as well. ⚠️ M12-M14 ARE "
|
||||||
len(MUT) == 11)
|
"SAFETY-43's and were run the same way, on trees copied fresh and "
|
||||||
|
"mounted :ro so no `.pyc` could go stale - MUTATION.md's defect. All "
|
||||||
|
"three are REDS, which is the direction that defect cannot fabricate. "
|
||||||
|
"⚠️ AND THEIR COUNTS WERE RE-RUN IN REVIEW AND TWO OF THEM MOVED: "
|
||||||
|
"test_delivery.py part 1 used to subscript `delinquent(...)['b']` for a "
|
||||||
|
"print, which raised KeyError under M12 and M13 and ABORTED THE FILE at "
|
||||||
|
"part 1, leaving parts 2-8 ungraded and the recorded coverage smaller "
|
||||||
|
"than the truth. It is a `.get` now. M14's count was simply wrong: five "
|
||||||
|
"named checks in test_delivery, not four",
|
||||||
|
len(MUT) == 14)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
|
|||||||
@@ -0,0 +1,743 @@
|
|||||||
|
"""Runnable check for the roster rehearsal. `python3 test_rehearse.py`
|
||||||
|
|
||||||
|
ARCH-02. `tools/rehearse_roster.py` is the instrument that decides whether the
|
||||||
|
flat -> roster switch on 2026-09-12 loses a safety latch, a month of maintenance
|
||||||
|
history, or a Home Assistant entity that an automation is watching. This file
|
||||||
|
grades the instrument.
|
||||||
|
|
||||||
|
⚠️ AN INSTRUMENT THAT HAS NEVER BEEN SEEN TO GO RED IS NOT EVIDENCE. This
|
||||||
|
repository's most-repeated defect, `MUTATION.md` habit 4 and now FIFTEEN
|
||||||
|
recorded instances, is "a check whose DATA cannot reach the failure it names" -
|
||||||
|
and a rehearsal that reports "migration OK" because it compared a structure to
|
||||||
|
itself is the purest example of it there is. So this file does two things in
|
||||||
|
order, and the first one is the one that makes the second mean anything:
|
||||||
|
|
||||||
|
Part 2 TWO CLEAN CONTROLS. Two (state file, roster) pairs on which the
|
||||||
|
shipped code is CORRECT and the harness must report NOTHING. A
|
||||||
|
harness that is red on every input has measured nothing, and every
|
||||||
|
red below it would be free.
|
||||||
|
|
||||||
|
Part 3 ⭐ SIX DELIBERATE MUTATIONS OF THE SHIPPED MIGRATION AND OF THE
|
||||||
|
ENTITY-ID COMPOSITION, each applied to a private copy of the tree,
|
||||||
|
each required to flip its control from green to red, AND each
|
||||||
|
required to produce the SPECIFIC finding it should - not merely a
|
||||||
|
non-zero exit. A mutation that goes red for the wrong reason is a
|
||||||
|
harness that would go red on the real defect for the wrong reason
|
||||||
|
too.
|
||||||
|
|
||||||
|
Part 4 TWO MUTATIONS THAT DELIBERATELY SURVIVE, with the reason written
|
||||||
|
down. A battery in which everything dies is a battery whose author
|
||||||
|
stopped as soon as the number looked good - and the `object_id` one
|
||||||
|
is what BOUNDS the four `app/mqtt.py` mutations above it: without a
|
||||||
|
change to that file that must NOT be caught, "the reference notices
|
||||||
|
any edit to mqtt.py" and "the reference notices what HA keys on" are
|
||||||
|
the same green.
|
||||||
|
|
||||||
|
Part 5b THE PER-LEG CHECK'S OWN POSITIVE CONTROL: a build in which exactly
|
||||||
|
ONE of the two legs is clean. ⚠️ A per-thing check that has never
|
||||||
|
seen the things DISAGREE is a total with extra steps, and this file
|
||||||
|
shipped that defect twice - see `legs()`.
|
||||||
|
|
||||||
|
Part 5 SAFETY-44's exact path, pinned as a finding, by name, in both
|
||||||
|
directions.
|
||||||
|
|
||||||
|
⚠️ THE MUTATIONS ARE SOURCE EDITS IN A COPIED TREE, NOT MONKEYPATCHES. A
|
||||||
|
monkeypatch grades the name a module was asked for; an edit grades the code that
|
||||||
|
runs. Each copy is made with `mkdtemp()`, so two runs of this file cannot
|
||||||
|
overwrite each other's work - habit 18 - and `__pycache__` is never copied, so
|
||||||
|
no mutant is ever graded through a stale `.pyc` - habit 1 / TEST-15.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
FIX = HERE / "tools" / "rehearsal"
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("1. the harness's own preconditions")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
sys.path.insert(0, str(HERE / "tools"))
|
||||||
|
import rehearse_roster as RR # noqa: E402
|
||||||
|
|
||||||
|
check("the tool refuses rather than passing when it cannot run: its three exit "
|
||||||
|
"codes are distinct and 2 is not 0",
|
||||||
|
(RR.EXIT_OK, RR.EXIT_FINDING, RR.EXIT_COULD_NOT_RUN) == (0, 1, 2))
|
||||||
|
|
||||||
|
# ⚠️ THE ANCHOR. `app/mqtt.py` records `sensor.goodwe_p1_sample_age` as VERIFIED
|
||||||
|
# against the live system on 2026-08-26, and the ESP32's ingest guard subscribes
|
||||||
|
# to it. The tool's entity-id model is written independently of `mqtt._slug` on
|
||||||
|
# purpose (habit 4), so this is the one external fact that says the independent
|
||||||
|
# model is right. If it fails, every orphan claim the tool makes is worthless
|
||||||
|
# and the tool exits 2 rather than reporting.
|
||||||
|
try:
|
||||||
|
RR._check_anchor()
|
||||||
|
anchored = True
|
||||||
|
except RR.Refused:
|
||||||
|
anchored = False
|
||||||
|
check("the entity-id model reproduces the ONE id verified against the live "
|
||||||
|
f"system ({RR.ANCHOR_ID})", anchored)
|
||||||
|
|
||||||
|
check("...and it is NOT built out of `mqtt._slug`, so a mutation in the "
|
||||||
|
"shipped slugifier moves one side of the comparison and not both",
|
||||||
|
"_slug" not in RR.compose.__code__.co_names)
|
||||||
|
|
||||||
|
_opts = RR.load_options(str(FIX / "roster.yaml"))
|
||||||
|
check("the built-in YAML reader parses the roster fixture the check image "
|
||||||
|
"cannot parse with PyYAML (there is no `yaml` module in it)",
|
||||||
|
[e["name"] for e in _opts["inverters"]] == ["garage", "shed"]
|
||||||
|
and _opts["max_w"] == 5000 and _opts["batt_invert"] is False
|
||||||
|
and _opts["meter_source"] == "off")
|
||||||
|
try:
|
||||||
|
RR._mini_yaml("inverters: [a, b]\n")
|
||||||
|
_refused = False
|
||||||
|
except RR.Refused:
|
||||||
|
_refused = True
|
||||||
|
check("...and it REFUSES a construct it does not understand rather than "
|
||||||
|
"guessing, because a config silently mis-parsed here rehearses a "
|
||||||
|
"configuration nobody is going to deploy", _refused)
|
||||||
|
|
||||||
|
_flat = RR.load_options(str(FIX / "flat.yaml"))
|
||||||
|
check("⚠️ the two configs differ in the ONE way the transition is about: the "
|
||||||
|
"BEFORE roster is empty and the AFTER roster is declared - if they did "
|
||||||
|
"not, every diff below would be a structure compared to itself",
|
||||||
|
_flat.get("inverters") == [] and len(_opts["inverters"]) == 2)
|
||||||
|
check("...and the AFTER roster's first entry is NOT `inverter-a`, which is "
|
||||||
|
"habit 4's seventh instance in one line: with it, `UNIT_A` and "
|
||||||
|
"`inverters[0].name` are the same string and a misdirected namespace "
|
||||||
|
"cannot be seen",
|
||||||
|
_opts["inverters"][0]["name"] != RR.UNIT_A)
|
||||||
|
|
||||||
|
_legacy = json.loads((FIX / "state-legacy.json").read_text())
|
||||||
|
check("⚠️ the synthetic state carries MAINTENANCE HISTORY - state built by the "
|
||||||
|
"happy path cannot detect a migration that drops it",
|
||||||
|
_legacy.get("last_completed") and _legacy.get("phase"))
|
||||||
|
check("⚠️ ...and a LEGACY-SHAPED KEY WHOSE LOSS WOULD BE SILENT: a LATCHED "
|
||||||
|
"`sign_tripped` with no `schema_version` beside it, i.e. written by a "
|
||||||
|
"controller that had never heard of unit namespaces",
|
||||||
|
_legacy.get("sign_tripped") is True
|
||||||
|
and "schema_version" not in _legacy
|
||||||
|
and "maint_schema_version" not in _legacy)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print()
|
||||||
|
print("2. THE CLEAN CONTROLS: three transitions on which the shipped code is "
|
||||||
|
"right and the harness must say nothing")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# ⚠️ THIS PART IS WHAT MAKES PART 3 EVIDENCE. Six reds prove nothing about an
|
||||||
|
# instrument that is red on its own fixtures. Each control below is a genuine
|
||||||
|
# flat -> roster switch that the shipped migration handles correctly, and the
|
||||||
|
# required answer is EXIT 0. CONTROL-C earned its place by failing: it found the
|
||||||
|
# harness reporting a defect on every fresh install.
|
||||||
|
#
|
||||||
|
# CONTROL-A a PRE-DEV-02 flat store, latch tripped, onto a roster whose
|
||||||
|
# first entry IS `inverter-a`. This is the path SAFETY-44's
|
||||||
|
# documented reasoning describes, and on it that reasoning holds.
|
||||||
|
# CONTROL-B a store that has already run flat under 0.7.0 (schema stamped,
|
||||||
|
# no latch), onto a roster named `garage`/`shed`. This is the
|
||||||
|
# MAINT-05 half, and the names are deliberately not `inverter-a`.
|
||||||
|
# CONTROL-C a FRESH INSTALL - an empty state file - onto `garage`/`shed`.
|
||||||
|
# ⚠️ THIS ONE FOUND A DEFECT IN THE HARNESS. A fresh store's
|
||||||
|
# migration writes `sign_tripped = False` and `sign_reason = ""`
|
||||||
|
# under `inverter-a` before any roster exists, and the first draft
|
||||||
|
# reported both as STRANDED - red on every fresh install. Losing a
|
||||||
|
# DEFAULT loses nothing; that is `MigrationReport.lost`'s own
|
||||||
|
# distinction, and an alarm that fires on the harmless case is one
|
||||||
|
# an operator learns to dismiss before the real one arrives.
|
||||||
|
CONTROLS = {
|
||||||
|
"CONTROL-A": ("state-legacy.json", "roster-inverter-a.yaml"),
|
||||||
|
"CONTROL-B": ("state-flat-070.json", "roster.yaml"),
|
||||||
|
"CONTROL-C": ("state-fresh.json", "roster.yaml"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run(tree: Path, state: str, after: str, *extra) -> "tuple[int, str]":
|
||||||
|
"""The harness, as the owner runs it, over `tree`.
|
||||||
|
|
||||||
|
⚠️ ALWAYS WITH `--live-entities`. That file is the only part of the
|
||||||
|
entity-id comparison that does not move with the code, so a run without it
|
||||||
|
is a run that cannot see a renamed device.
|
||||||
|
"""
|
||||||
|
fixtures = tree / "tools" / "rehearsal"
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(tree / "tools" / "rehearse_roster.py"),
|
||||||
|
"--state", str(fixtures / state),
|
||||||
|
"--before", str(fixtures / "flat.yaml"),
|
||||||
|
"--after", str(fixtures / after),
|
||||||
|
"--live-entities", str(fixtures / "live-entities-recorded.txt"),
|
||||||
|
"--reference", str(fixtures / "announced-reference.txt"),
|
||||||
|
*extra],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
|
||||||
|
return proc.returncode, proc.stdout + proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def mutant_tree(edits) -> str:
|
||||||
|
"""A private copy of the add-on with `edits` applied.
|
||||||
|
|
||||||
|
⚠️ EVERY EDIT IS ASSERTED TO HAVE MATCHED. Four defects in this session came
|
||||||
|
from `str.replace` calls whose pattern did not match: the call silently
|
||||||
|
returns the original string, the mutation never happens, and the harness
|
||||||
|
reports a SURVIVOR on code it never changed. That is a fabricated result.
|
||||||
|
"""
|
||||||
|
tmp = tempfile.mkdtemp(prefix="rehearse-mut-")
|
||||||
|
dest = Path(tmp) / "tree"
|
||||||
|
ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
|
||||||
|
(dest / "tools").mkdir(parents=True)
|
||||||
|
shutil.copytree(HERE / "app", dest / "app", ignore=ignore)
|
||||||
|
shutil.copytree(FIX, dest / "tools" / "rehearsal", ignore=ignore)
|
||||||
|
shutil.copyfile(HERE / "tools" / "rehearse_roster.py",
|
||||||
|
dest / "tools" / "rehearse_roster.py")
|
||||||
|
for rel, old, new in edits:
|
||||||
|
path = dest / rel
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
hits = text.count(old)
|
||||||
|
if hits != 1:
|
||||||
|
raise AssertionError(
|
||||||
|
f"mutation target appears {hits} times in {rel}, expected 1: "
|
||||||
|
f"{old!r} - the mutation did NOT happen and any result from it "
|
||||||
|
f"would be fabricated")
|
||||||
|
path.write_text(text.replace(old, new), encoding="utf-8")
|
||||||
|
return str(dest)
|
||||||
|
|
||||||
|
|
||||||
|
def legs(out: str) -> dict:
|
||||||
|
"""The report, split into its legs on the tool's `LEG <id>` markers.
|
||||||
|
|
||||||
|
⚠️ THIS FUNCTION IS THE FIX FOR A DEFECT IN THIS FILE, AND THE DEFECT IS
|
||||||
|
HABIT 4 ONE LEVEL UP. Part 5's check that the disarmed latch is "NOT THE
|
||||||
|
SAME-STEP PATH ALONE" was written as
|
||||||
|
`out.count("LATCH CAME UP DISARMED") >= 2`. The tool prints every finding
|
||||||
|
TWICE per red leg - once in that leg's own FINDINGS block and once in the
|
||||||
|
closing summary - so ONE red leg yields exactly 2 and the check passes.
|
||||||
|
Measured on three builds:
|
||||||
|
|
||||||
|
shipped code, both legs red count 4
|
||||||
|
fix at main.py:250 only -> STAGED alone red count 2 ok
|
||||||
|
partial guard fix on rep.ran -> SAME-STEP alone red count 2 ok
|
||||||
|
|
||||||
|
The check named "NOT THE SAME-STEP PATH ALONE" reported `ok` on the build
|
||||||
|
whose remaining failure IS the same-step path alone - and, worse, applying
|
||||||
|
the fix DOCS.md names as the cause left all five part-5 checks green. A
|
||||||
|
count over a whole report cannot distinguish one leg from two; only reading
|
||||||
|
the legs separately can.
|
||||||
|
|
||||||
|
⚠️ AND IT CLOSES ON `END LEG`, WHICH IS THE SAME DEFECT ONE LEVEL DOWN.
|
||||||
|
The first version of this splitter opened on `LEG <id>` and never closed,
|
||||||
|
so every line after the LAST marker went to that leg - including the
|
||||||
|
closing summary, which repeats every finding from every leg. The last leg
|
||||||
|
then read red whenever ANY leg was red. Measured on a build whose SAME-STEP
|
||||||
|
leg is genuinely clean: it still reported SAME-STEP disarmed. Part 5b is
|
||||||
|
the running control for exactly that.
|
||||||
|
"""
|
||||||
|
out_legs, current, closed = {}, None, set()
|
||||||
|
for line in out.splitlines():
|
||||||
|
if line.startswith(" LEG "):
|
||||||
|
# ⚠️ AND THIS ASSERTION IS THE ONE THAT KILLS THE CLASS. Review
|
||||||
|
# attacked part 5b directly and got through in three edits: delete
|
||||||
|
# `END LEG` (5b catches it), ALSO reorder the legs (5b goes blind -
|
||||||
|
# it only ever saw them disagree with the clean leg LAST), and ALSO
|
||||||
|
# reword the closing summary (the backstop is a transcription of the
|
||||||
|
# tool's own sentence, so rewording blinds that too). Result: "all
|
||||||
|
# checks passed" with a splitter that closed ZERO legs.
|
||||||
|
#
|
||||||
|
# Two of those three are ordinary refactors - reordering report legs
|
||||||
|
# and rewording a summary line - neither of which reads as touching
|
||||||
|
# a safety check. A fixture cannot cover every arrangement, so the
|
||||||
|
# STRUCTURE is asserted instead: a leg may not open while one is
|
||||||
|
# open, and every leg that opens must be explicitly closed. Under
|
||||||
|
# all three attacks nothing is ever closed, so this fires on the
|
||||||
|
# FIRST edit regardless of order or wording.
|
||||||
|
assert current is None, (
|
||||||
|
f"leg {line.strip()[4:].strip()!r} opened while {current!r} was "
|
||||||
|
"still open - the tool stopped closing its legs, and a splitter "
|
||||||
|
"that never closes swallows the closing summary into the last "
|
||||||
|
"leg. That is the defect this function exists to fix.")
|
||||||
|
current = line.strip()[4:].strip()
|
||||||
|
out_legs[current] = []
|
||||||
|
elif line.startswith(" END LEG "):
|
||||||
|
assert current is not None, "END LEG without an open leg"
|
||||||
|
closed.add(current)
|
||||||
|
current = None
|
||||||
|
elif current is not None:
|
||||||
|
out_legs[current].append(line)
|
||||||
|
assert current is None, f"leg {current!r} was never closed"
|
||||||
|
assert closed == set(out_legs), (
|
||||||
|
f"legs opened {sorted(out_legs)} but closed {sorted(closed)} - a leg "
|
||||||
|
"that is never closed absorbs everything after it")
|
||||||
|
return {k: "\n".join(v) for k, v in out_legs.items()}
|
||||||
|
|
||||||
|
|
||||||
|
for label, (state, after) in CONTROLS.items():
|
||||||
|
code, out = run(HERE, state, after)
|
||||||
|
check(f"{label}: the shipped code, {state} -> {after}, exits 0 with no "
|
||||||
|
f"findings", code == RR.EXIT_OK and "NO FINDINGS" in out)
|
||||||
|
|
||||||
|
# ⚠️ AND THE SOFTENING IS NOT A HOLE. The SAME key stranded with a NON-default
|
||||||
|
# value is still a finding - that is the whole of part 5 - so the two cases are
|
||||||
|
# separated by the VALUE and not by the key name.
|
||||||
|
_fresh_code, _fresh_out = run(HERE, "state-fresh.json", "roster.yaml")
|
||||||
|
# ⚠️ THE NAMESPACE IS READ OUT OF THE TOOL'S OWN REPORT, NOT SPELLED HERE. A
|
||||||
|
# hardcoded `unit/inverter-a/...` makes this check fail for the RIGHT reason on
|
||||||
|
# the shipped code and for the WRONG reason on a build that fixes the migration
|
||||||
|
# to use the roster's own name - it would look like the default-softening broke
|
||||||
|
# when what changed is the namespace. What this check is about is the VALUE.
|
||||||
|
_stranded_default = set(re.findall(
|
||||||
|
r"^\s+stranded\s+(unit/\S+/sign_tripped) = False$", _fresh_out, re.M))
|
||||||
|
check("a fresh install DOES strand a `.../sign_tripped` key - it is written "
|
||||||
|
"and nothing on the roster reads it - and the report SAYS SO under its "
|
||||||
|
f"own heading (found: {sorted(_stranded_default)})",
|
||||||
|
_fresh_code == RR.EXIT_OK and len(_stranded_default) == 1)
|
||||||
|
check("...and does NOT call it a finding, because the value is the DEFAULT; "
|
||||||
|
"the same key at True is the finding part 5 turns on, so the two cases "
|
||||||
|
"are separated by the VALUE and not by the key name",
|
||||||
|
not any(f"STRANDED STORE KEY: {k}" in _fresh_out
|
||||||
|
for k in _stranded_default)
|
||||||
|
and "STRANDED STORE KEY: unit/inverter-a/sign_tripped = True"
|
||||||
|
in run(HERE, "state-legacy.json", "roster.yaml")[1])
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print()
|
||||||
|
print("2b. ⚠️ OMITTING THE INSTALL'S ENTITY LIST IS A REFUSAL, NOT A QUIETER "
|
||||||
|
"RUN")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# ⚠️ THIS PART EXISTS BECAUSE THE ASYMMETRY WAS BACKWARDS AND IT WAS MEASURED.
|
||||||
|
# A `--live-entities` file that could not be READ refused with exit 2; the flag
|
||||||
|
# simply not being PASSED produced "NO FINDINGS" and exit 0 - and
|
||||||
|
# `grep -ci "live-entities|recorded list|composition"` over that report returned
|
||||||
|
# ZERO, so nothing in the artifact an owner reads said half the check had not
|
||||||
|
# run. Measured on the two entity mutations at the time:
|
||||||
|
#
|
||||||
|
# M5 device rename with the flag: exit 1 without: exit 0
|
||||||
|
# M6 sensor rename with the flag: exit 1 without: exit 0
|
||||||
|
#
|
||||||
|
# The owner runs this ONCE, at commissioning, under time pressure. A forgotten
|
||||||
|
# flag must not hand back a clean bill of health for the half of the transition
|
||||||
|
# that orphans entities.
|
||||||
|
def raw(*args):
|
||||||
|
"""`(exit code, THE REPORT, the log)` - and the split matters.
|
||||||
|
|
||||||
|
⚠️ THE REPORT IS STDOUT AND THE CONTROLLER'S LOGGING IS STDERR. Grading
|
||||||
|
"is the degraded banner near the end of the report" over the two
|
||||||
|
CONCATENATED reads the last lines of the LOG - `SAFETY-02 was TRIPPED...`,
|
||||||
|
`unparseable timestamp...` - and reports a missing banner that is in fact
|
||||||
|
printed. That is not a hypothetical; it is how this check first failed.
|
||||||
|
"""
|
||||||
|
fx = HERE / "tools" / "rehearsal"
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(HERE / "tools" / "rehearse_roster.py"),
|
||||||
|
"--state", str(fx / "state-legacy.json"),
|
||||||
|
"--before", str(fx / "flat.yaml"),
|
||||||
|
"--after", str(fx / "roster-inverter-a.yaml"), *args],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
|
||||||
|
return proc.returncode, proc.stdout, proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
_no_code, _no_out, _no_err = raw()
|
||||||
|
check("omitting --live-entities REFUSES with exit 2 rather than reporting a "
|
||||||
|
"clean transition it did not check",
|
||||||
|
_no_code == RR.EXIT_COULD_NOT_RUN and "REFUSED" in _no_out + _no_err)
|
||||||
|
|
||||||
|
_deg_code, _deg_out, _ = raw("--no-live-entities")
|
||||||
|
check("...and the explicit opt-out RUNS, so the escape hatch exists",
|
||||||
|
_deg_code in (RR.EXIT_OK, RR.EXIT_FINDING))
|
||||||
|
check("⚠️ ...carrying a standing banner in the report HEADER and again in "
|
||||||
|
"the closing block, because a report is skimmed from the bottom",
|
||||||
|
_deg_out.count("DEGRADED RUN") >= 2
|
||||||
|
and "DEGRADED RUN" in "\n".join(
|
||||||
|
_deg_out.strip().splitlines()[-4:]))
|
||||||
|
check("...and the degraded report is not silent about what it did not do - "
|
||||||
|
"the words are in it, which is the grep that returned 0 before",
|
||||||
|
sum(w in _deg_out.lower() for w in
|
||||||
|
("live-entities", "orphan check", "not graded")) == 3)
|
||||||
|
check("contradictory flags are refused rather than one silently winning",
|
||||||
|
raw("--no-live-entities", "--live-entities",
|
||||||
|
str(FIX / "live-entities-recorded.txt"))[0]
|
||||||
|
== RR.EXIT_COULD_NOT_RUN)
|
||||||
|
check("...and the banner really is at the END of the report, which is the "
|
||||||
|
"half of that claim a concatenated stdout+stderr could not have graded",
|
||||||
|
"DEGRADED RUN" in "\n".join(_deg_out.strip().splitlines()[-4:]))
|
||||||
|
|
||||||
|
# ⚠️ AND THE COMPOSITION HALF IS NOT BEHIND THE FLAG AT ALL ANY MORE. The
|
||||||
|
# reference is loaded on EVERY run, so a degraded run still catches a renamed
|
||||||
|
# device - which is what makes `--no-live-entities` a genuinely bounded
|
||||||
|
# concession rather than "half the tool off".
|
||||||
|
_ref_missing = raw("--no-live-entities", "--reference", "/nonexistent.txt")
|
||||||
|
check("a missing or malformed reference is exit 2 on every run, because it is "
|
||||||
|
"the only reference in the tool that does not move with the code",
|
||||||
|
_ref_missing[0] == RR.EXIT_COULD_NOT_RUN)
|
||||||
|
|
||||||
|
# ⚠️ AND THE VANISHED CHECK CARRIES THE SAME DEFAULT/NON-DEFAULT RULE, BOUNDED
|
||||||
|
# THE SAME WAY. `M9` below drops a NON-default key and must still be a finding;
|
||||||
|
# without it, "vanished at the default is not a finding" could widen into
|
||||||
|
# "vanished is not a finding" and nobody would see it.
|
||||||
|
_m9 = mutant_tree([("app/store.py",
|
||||||
|
" value = data.get(legacy, DEFAULTS.get(legacy))\n"
|
||||||
|
" store.set(target, value)",
|
||||||
|
" value = data.get(legacy, DEFAULTS.get(legacy))\n"
|
||||||
|
" store.data.pop(legacy, None)\n"
|
||||||
|
" store.set(target, value)")])
|
||||||
|
_m9_code, _m9_out = run(Path(_m9), "state-legacy.json", "roster-inverter-a.yaml")
|
||||||
|
_m9_vanished = set(re.findall(r"VANISHED STORE KEY: (\S+) =", _m9_out))
|
||||||
|
print(f" deleting the flat key as it migrates -> exit {_m9_code}, "
|
||||||
|
f"vanished {sorted(_m9_vanished)}")
|
||||||
|
check("⭐ a NON-default key that vanishes is STILL a finding - a migration "
|
||||||
|
"made to DELETE the flat rollback view store.py rule 2 requires it to "
|
||||||
|
"leave in place is caught, so the default-softening above is bounded by "
|
||||||
|
"its own positive",
|
||||||
|
_m9_code == RR.EXIT_FINDING
|
||||||
|
and {"last_completed", "phase_started", "last_start_attempt"}
|
||||||
|
<= _m9_vanished)
|
||||||
|
check("...and `phase` is NOT among them, because its value 'idle' IS the "
|
||||||
|
"default - the same mutation, the same file, and the rule splitting the "
|
||||||
|
"two on the VALUE rather than on the key",
|
||||||
|
"phase" not in _m9_vanished)
|
||||||
|
shutil.rmtree(Path(_m9).parent, ignore_errors=True)
|
||||||
|
|
||||||
|
# ⚠️ AND THE INPUT FILE IS NEVER WRITTEN. The owner runs this against a copy of
|
||||||
|
# a live state file; a tool that edited it would be a tool that had to be
|
||||||
|
# trusted rather than one that cannot do harm.
|
||||||
|
_before_bytes = (FIX / "state-legacy.json").read_bytes()
|
||||||
|
run(HERE, "state-legacy.json", "roster.yaml", "--reverse", "--round-trip")
|
||||||
|
check("the state file the harness was pointed at is BYTE-IDENTICAL afterwards, "
|
||||||
|
"including after the reverse and round-trip legs, which write",
|
||||||
|
(FIX / "state-legacy.json").read_bytes() == _before_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print()
|
||||||
|
print("3. ⭐ THE MUTATIONS: each one breaks the shipped migration or the "
|
||||||
|
"entity-id composition, and each must turn its control RED")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
MUTATIONS = [
|
||||||
|
# (id, control, what it breaks, edits, the finding it must produce)
|
||||||
|
("M1", "CONTROL-B",
|
||||||
|
"the maintenance migration is handed the hardcoded `UNIT_A` instead of "
|
||||||
|
"the roster's own first name - MAINT-05's exact shipped regression, which "
|
||||||
|
"left all 19 suites green",
|
||||||
|
[("app/main.py",
|
||||||
|
"store, self.inverters[0].name, [inv.name for inv in self.inverters])",
|
||||||
|
"store, UNIT_A, [inv.name for inv in self.inverters])")],
|
||||||
|
"MAINTENANCE HISTORY LOST"),
|
||||||
|
|
||||||
|
("M2", "CONTROL-B",
|
||||||
|
"the maintenance namespace is DROPPED: the migration moves no keys at all, "
|
||||||
|
"so a month of history stays under a flat spelling the new packs do not "
|
||||||
|
"read",
|
||||||
|
[("app/store.py",
|
||||||
|
"_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)",
|
||||||
|
"_move_flat_keys(store, unit, (), rep)")],
|
||||||
|
"MAINTENANCE HISTORY LOST"),
|
||||||
|
|
||||||
|
("M3", "CONTROL-A",
|
||||||
|
"the SAFETY-02 pair loses HALF of itself: `sign_reason` drops out of "
|
||||||
|
"`LEGACY_UNIT_KEYS`, so the latch migrates without the sentence that says "
|
||||||
|
"why it tripped",
|
||||||
|
[("app/store.py",
|
||||||
|
'LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")',
|
||||||
|
'LEGACY_UNIT_KEYS = ("sign_tripped",)')],
|
||||||
|
"LATCH KEY NOT UNDER A ROSTER NAME"),
|
||||||
|
|
||||||
|
("M4", "CONTROL-A",
|
||||||
|
"the READER's key composition changes while the migration's does not - "
|
||||||
|
"`Inverter.key()` upper-cases the unit - so the latch is written "
|
||||||
|
"perfectly, read back perfectly by the migration, and looked for under a "
|
||||||
|
"name that is not there",
|
||||||
|
[("app/inverter.py",
|
||||||
|
"return unit_key(self.name, name)",
|
||||||
|
"return unit_key(self.name.upper(), name)")],
|
||||||
|
"LATCH CAME UP DISARMED"),
|
||||||
|
|
||||||
|
("M5", "CONTROL-A",
|
||||||
|
"the entity-id composition changes on the DEVICE half: the MQTT device is "
|
||||||
|
"renamed, which is the 'tidy it up' change `app/mqtt.py` spends thirty "
|
||||||
|
"lines in capitals refusing. ⚠️ THE BEFORE/AFTER DIFF IS BLIND TO THIS - "
|
||||||
|
"both sides are composed by the mutant, so every id moves equally and "
|
||||||
|
"nothing looks orphaned. Only the recorded list catches it",
|
||||||
|
[("app/mqtt.py", '"name": "GoodWe",', '"name": "GoodWe Controller",')],
|
||||||
|
"COMPOSITION DRIFT"),
|
||||||
|
|
||||||
|
("M6", "CONTROL-A",
|
||||||
|
"the entity-id composition changes on the ENTITY half: one site sensor is "
|
||||||
|
"renamed, which orphans exactly one id and leaves the other seven alone",
|
||||||
|
[("app/mqtt.py",
|
||||||
|
'("grid", "goodwe_grid_power", "Grid power", "W", "power", "measurement", None),',
|
||||||
|
'("grid", "goodwe_grid_power", "Grid power W", "W", "power", "measurement", None),')],
|
||||||
|
"COMPOSITION DRIFT: this build announces sensor.goodwe_grid_power_w"),
|
||||||
|
|
||||||
|
# ⚠️ M7 AND M8 ARE THE TWO CHANGES THAT ORPHAN AN INSTALL WITHOUT MOVING A
|
||||||
|
# SINGLE COMPOSED entity_id, so an entity_id-only reference cannot see them.
|
||||||
|
# Both came back exit 0 before the reference carried three columns.
|
||||||
|
("M7", "CONTROL-A",
|
||||||
|
"the `unique_id` composition changes and NOT the entity_id: `mqtt.BASE` "
|
||||||
|
"is renamed, so every announced unique_id moves while every composed "
|
||||||
|
"entity_id stays byte-identical. HA's MQTT entity registry keys on "
|
||||||
|
"unique_id, so this is a NEW entity beside a dead one - and discovery is "
|
||||||
|
"retained with no removal payload, so the dead one stays",
|
||||||
|
[("app/mqtt.py", 'BASE = "goodwe_ctl"', 'BASE = "goodwe_controller"')],
|
||||||
|
"UNIQUE_ID DRIFT"),
|
||||||
|
|
||||||
|
("M8", "CONTROL-A",
|
||||||
|
"the DEVICE IDENTIFIER changes: HA's device registry keys on it, so the "
|
||||||
|
"whole install moves onto a new device and the old one keeps its dead "
|
||||||
|
"entities. Again no composed entity_id moves",
|
||||||
|
[("app/mqtt.py", '"identifiers": ["goodwe_rs485_controller"],',
|
||||||
|
'"identifiers": ["goodwe_rs485_ctl_v2"],')],
|
||||||
|
"DEVICE IDENTIFIER DRIFT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for mid, control, what, edits, must_say in MUTATIONS:
|
||||||
|
state, after = CONTROLS[control]
|
||||||
|
tree = mutant_tree(edits)
|
||||||
|
code, out = run(Path(tree), state, after)
|
||||||
|
red = code == RR.EXIT_FINDING
|
||||||
|
named = must_say in out
|
||||||
|
print()
|
||||||
|
print(f" --- {mid} ({control}) {what}")
|
||||||
|
print(f" exit {code} (0=clean 1=finding 2=could-not-run)")
|
||||||
|
# ⚠️ THE FINDINGS, DE-DUPLICATED AND IN ORDER. The tool prints each finding
|
||||||
|
# once per transition leg and once more in its summary, and its own header
|
||||||
|
# is full of ⚠️ paragraphs - printing every line that starts with one would
|
||||||
|
# bury the evidence this part exists to show under the tool's docstring.
|
||||||
|
seen = []
|
||||||
|
for line in out.splitlines():
|
||||||
|
text = line.strip()
|
||||||
|
# ⚠️ `_` IS IN THE CLASS. Without it `UNIQUE_ID DRIFT:` does not match
|
||||||
|
# its own finding kind, and M7 - the mutation that renames `mqtt.BASE`
|
||||||
|
# - printed its verdict with NO evidence under it. A filter that
|
||||||
|
# silently drops the very finding the mutation exists to produce is a
|
||||||
|
# small version of the same defect this file is about.
|
||||||
|
if text.startswith("⚠️ ") and re.match(r"⚠️ [A-Z][A-Z _-]+[A-Z()]:",
|
||||||
|
text):
|
||||||
|
if text not in seen:
|
||||||
|
seen.append(text)
|
||||||
|
for text in seen:
|
||||||
|
print(f" {text[:160]}")
|
||||||
|
check(f"{mid} turns {control} RED", red)
|
||||||
|
check(f"{mid} says WHY, and it is the right why: {must_say!r}", named)
|
||||||
|
shutil.rmtree(Path(tree).parent, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print()
|
||||||
|
print("4. THE MUTATION THAT SURVIVES, ON PURPOSE, WITH THE REASON WRITTEN DOWN")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# ⚠️ A BATTERY IN WHICH EVERYTHING DIES IS A BATTERY SOMEBODY STOPPED WRITING
|
||||||
|
# AS SOON AS THE NUMBER LOOKED GOOD. `store.unit_key()` is the ONE spelling of
|
||||||
|
# the namespaced key, read by `Inverter.key()`, by `UnitHistory.key()` and by
|
||||||
|
# both migrations - that is store.py's stated design, "one spelling, in one
|
||||||
|
# place". Changing it therefore moves the writer AND every reader together, and
|
||||||
|
# the store comes out internally consistent under a different name.
|
||||||
|
#
|
||||||
|
# The harness is BLIND to it and cannot be otherwise: nothing inside one version
|
||||||
|
# of this add-on can see that the key shape stopped matching what an OLDER
|
||||||
|
# version reads. That is a ROLLBACK contract, and grading it needs two versions
|
||||||
|
# of the code, which is a different instrument (`tools/predeploy_check.py`'s
|
||||||
|
# territory). Stated here rather than left as an unexplained green.
|
||||||
|
_tree = mutant_tree([("app/store.py",
|
||||||
|
'return f"unit/{unit}/{key}"',
|
||||||
|
'return f"ns.{unit}.{key}"')])
|
||||||
|
_code, _out = run(Path(_tree), *CONTROLS["CONTROL-A"])
|
||||||
|
print(f" renaming the ONE key spelling -> exit {_code}")
|
||||||
|
check("renaming `unit_key()` survives, because writer and readers move "
|
||||||
|
"together - a fact about the design, recorded, not a gap being hidden",
|
||||||
|
_code == RR.EXIT_OK)
|
||||||
|
check("...and the store really did change shape underneath it, so this is a "
|
||||||
|
"survivor of a mutation that HAPPENED rather than of one that did not",
|
||||||
|
"ns.inverter-a.sign_tripped" in _out and "unit/inverter-a/" not in _out)
|
||||||
|
shutil.rmtree(Path(_tree).parent, ignore_errors=True)
|
||||||
|
|
||||||
|
# ⚠️ THE SECOND DECLARED SURVIVOR, AND IT IS THE ONE THAT BOUNDS M5-M8. Four
|
||||||
|
# mutations of `app/mqtt.py` are now required to go red; without a mutation of
|
||||||
|
# the same file that must NOT, "the reference notices any edit to mqtt.py"
|
||||||
|
# would be indistinguishable from "the reference notices what HA keys on".
|
||||||
|
# `object_id` is the case: `app/mqtt.py` lines 36-42 and 61-76 record that HA
|
||||||
|
# 2026.8 does NOT honour it in a discovery payload - the entity_id is composed
|
||||||
|
# from device name plus entity name - so renaming one changes nothing an
|
||||||
|
# install can see, and a tool that refused it would refuse a free change.
|
||||||
|
_tree2 = mutant_tree([("app/mqtt.py",
|
||||||
|
'("grid", "goodwe_grid_power", "Grid power",',
|
||||||
|
'("grid", "goodwe_grid_pwr", "Grid power",')])
|
||||||
|
_code2, _out2 = run(Path(_tree2), *CONTROLS["CONTROL-A"])
|
||||||
|
print(f" renaming an object_id (HA does not honour it) -> exit {_code2}")
|
||||||
|
check("renaming an `object_id` survives, because HA composes the entity_id "
|
||||||
|
"from the device and entity NAMES and ignores it - so the four mqtt.py "
|
||||||
|
"mutations above are red for what HA keys on, not for being edits to "
|
||||||
|
"mqtt.py",
|
||||||
|
_code2 == RR.EXIT_OK)
|
||||||
|
shutil.rmtree(Path(_tree2).parent, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print()
|
||||||
|
print("5. SAFETY-44: a pre-DEV-02 flat store meeting a DECLARED ROSTER, which "
|
||||||
|
"is the path nobody had established either way")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# The question on the card: `migrate_unit_keys(store, UNIT_A)` takes a hardcoded
|
||||||
|
# constant and has no `misdirected` guard, while `migrate_maint_keys(store,
|
||||||
|
# unit, roster)` takes the roster and does. Is that asymmetry safe?
|
||||||
|
#
|
||||||
|
# ⚠️ MEASURED, NOT ARGUED. The two checks below are the answer, and they are
|
||||||
|
# written as REQUIREMENTS so that a fix to `migrate_unit_keys` turns them red
|
||||||
|
# and this file has to be updated deliberately rather than drifting.
|
||||||
|
code44, out44 = run(HERE, "state-legacy.json", "roster.yaml")
|
||||||
|
print(f" flat store + roster ['garage','shed'] -> exit {code44}")
|
||||||
|
check("⚠️ SAFETY-44 IS A DEFECT AGAIN: a store that ran pre-DEV-02 flat and "
|
||||||
|
"declares a multi-inverter roster comes up with its SAFETY-02 latch "
|
||||||
|
"DISARMED - the file says `sign_tripped: true` and not one inverter is "
|
||||||
|
"latched",
|
||||||
|
code44 == RR.EXIT_FINDING and "LATCH CAME UP DISARMED" in out44)
|
||||||
|
check("...and the harness names the two latch keys SAFETY-44 asks about, by "
|
||||||
|
"the namespace they were stranded in",
|
||||||
|
"unit/inverter-a/sign_tripped" in out44
|
||||||
|
and "unit/inverter-a/sign_reason" in out44)
|
||||||
|
check("...and the mechanism is visible in the report: the keys are stranded "
|
||||||
|
"under `inverter-a`, which is not on the roster, so no machine reads them",
|
||||||
|
"STRANDED STORE KEY" in out44 and "['garage', 'shed']" in out44)
|
||||||
|
# ⚠️ PER LEG, NOT A COUNT OVER THE WHOLE REPORT. See `legs()` for the three
|
||||||
|
# builds this distinction was measured on and for what the count missed.
|
||||||
|
_L = legs(out44)
|
||||||
|
print(f" legs in the report: {sorted(_L)}")
|
||||||
|
for _leg in ("STAGED", "SAME-STEP"):
|
||||||
|
print(f" LEG {_leg}: "
|
||||||
|
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _L.get(_leg, '') else 'clean'}")
|
||||||
|
check("the report is split into legs this check can grade SEPARATELY - the "
|
||||||
|
"tool prints a `LEG <id>` marker, because counting a finding's text over "
|
||||||
|
"the whole report cannot tell one red leg from two",
|
||||||
|
set(_L) == {"STAGED", "SAME-STEP"})
|
||||||
|
check("⚠️ SAME-STEP is disarmed: the roster is declared in the SAME step as "
|
||||||
|
"the upgrade, the migration runs and misdirects the latch to "
|
||||||
|
"`inverter-a`",
|
||||||
|
"LATCH CAME UP DISARMED" in _L.get("SAME-STEP", ""))
|
||||||
|
check("⚠️ AND IT IS NOT THE SAME-STEP PATH ALONE - the STAGED leg reaches the "
|
||||||
|
"same disarmed latch by a DIFFERENT route: the flat start stamps "
|
||||||
|
"`schema_version`, so the roster start's migration takes its early "
|
||||||
|
"return and the latch never moves at all",
|
||||||
|
"LATCH CAME UP DISARMED" in _L.get("STAGED", ""))
|
||||||
|
|
||||||
|
# ⚠️ AND THE CONTRAST, WHICH IS WHAT STOPS THE CHECK ABOVE BEING VACUOUS. The
|
||||||
|
# SAME state file onto a roster whose first entry IS `inverter-a` comes up
|
||||||
|
# LATCHED. The two runs differ in one thing - the first roster name - so the
|
||||||
|
# finding is attributable to it and to nothing else.
|
||||||
|
code_ok, out_ok = run(HERE, "state-legacy.json", "roster-inverter-a.yaml")
|
||||||
|
check("...and no leg's text has swallowed the CLOSING SUMMARY, which repeats "
|
||||||
|
"every finding from every leg - the failure that made the last leg read "
|
||||||
|
"red whenever any leg was",
|
||||||
|
not any("FINDING(S) - this transition is not clean" in v
|
||||||
|
for v in _L.values()))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print()
|
||||||
|
print("5b. THE SPLITTER'S POSITIVE CONTROL: a build in which exactly ONE leg "
|
||||||
|
"is clean")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# ⚠️ A PER-LEG CHECK THAT HAS NEVER SEEN THE LEGS DISAGREE IS A COUNT WITH
|
||||||
|
# EXTRA STEPS. Every fixture above has both legs red, so nothing so far
|
||||||
|
# separates "reads each leg" from "reads the whole report twice". This part
|
||||||
|
# builds the one case that does: `migrate_unit_keys()` handed the roster's own
|
||||||
|
# first name instead of the hardcoded `UNIT_A`.
|
||||||
|
#
|
||||||
|
# ⚠️ AND WHAT IT MEASURES IS ALSO THE ANSWER TO "IS THAT THE FIX". It is NOT.
|
||||||
|
# It repairs the SAME-STEP path only. The STAGED path is untouched, because
|
||||||
|
# there the flat start stamps `schema_version = 1` and the roster start's
|
||||||
|
# migration takes its `from_version >= SCHEMA_VERSION` early return before the
|
||||||
|
# name it would be handed matters at all. A fix that stops at this line would
|
||||||
|
# close the half an operator is least likely to take and leave the half that
|
||||||
|
# "upgrade first, declare the roster later" produces.
|
||||||
|
_one_leg = mutant_tree([(
|
||||||
|
"app/main.py",
|
||||||
|
" self.store_migration = migrate_unit_keys(store, UNIT_A)",
|
||||||
|
" self.store_migration = migrate_unit_keys(store, "
|
||||||
|
"roster_names(opts)[0])")])
|
||||||
|
_ol_code, _ol_out = run(Path(_one_leg), "state-legacy.json", "roster.yaml")
|
||||||
|
_OL = legs(_ol_out)
|
||||||
|
for _leg in ("STAGED", "SAME-STEP"):
|
||||||
|
print(f" LEG {_leg}: "
|
||||||
|
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _OL.get(_leg, '') else 'clean'}")
|
||||||
|
check("⭐ with `migrate_unit_keys()` handed the roster's own first name, the "
|
||||||
|
"SAME-STEP leg comes up CLEAN - so the splitter really does read the "
|
||||||
|
"legs separately, and a check that only counted would have missed this",
|
||||||
|
"LATCH CAME UP DISARMED" not in _OL["SAME-STEP"])
|
||||||
|
check("⚠️ ...while the STAGED leg is STILL DISARMED on the same build: that "
|
||||||
|
"line is not the whole fix, because the staged path never reaches it - "
|
||||||
|
"the first start stamps `schema_version` and the second start's "
|
||||||
|
"migration returns early",
|
||||||
|
"LATCH CAME UP DISARMED" in _OL["STAGED"])
|
||||||
|
check("...and the run as a whole is still a finding, so nobody reads this "
|
||||||
|
"partial fix as done",
|
||||||
|
_ol_code == RR.EXIT_FINDING)
|
||||||
|
shutil.rmtree(Path(_one_leg).parent, ignore_errors=True)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("5c. back to the shipped code")
|
||||||
|
check("the SAME state file onto a roster whose first entry IS `inverter-a` "
|
||||||
|
"comes up latched and clean - so the finding above is caused by the "
|
||||||
|
"roster NAME and not by the fixture being tripped",
|
||||||
|
code_ok == RR.EXIT_OK and "LATCH CAME UP DISARMED" not in out_ok)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print()
|
||||||
|
print("6. THE REVERSE DIRECTION: the retreat, and the return from it")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
code_r, out_r = run(HERE, "state-flat-070.json", "roster.yaml",
|
||||||
|
"--reverse", "--round-trip")
|
||||||
|
_RL = legs(out_r)
|
||||||
|
print(f" legs in the reverse run: {sorted(_RL)}")
|
||||||
|
_orphaned = set(re.findall(r"ORPHANED ENTITY: (sensor\.\S+)",
|
||||||
|
_RL.get("RETREAT", "")))
|
||||||
|
print(f" the RETREAT leg orphans: {sorted(_orphaned)}")
|
||||||
|
check("the RETREAT is rehearsed as its own leg, and THAT LEG reports the "
|
||||||
|
"entities it orphans - the eight per-unit rows, which is the cost of "
|
||||||
|
"backing out",
|
||||||
|
{"RETREAT", "ROUND-TRIP"} <= set(_RL) and _orphaned == {
|
||||||
|
f"sensor.goodwe_{u}_{k}"
|
||||||
|
for u in ("garage", "shed")
|
||||||
|
for k in ("setpoint", "battery_power", "battery_soc", "sign_latch")})
|
||||||
|
check("...and NOT one site entity: the eight ids the install has today survive "
|
||||||
|
"the retreat, which is what makes the eight above attributable to the "
|
||||||
|
"per-unit rows and not to a blanket rename",
|
||||||
|
not any(o in _orphaned for o in
|
||||||
|
("sensor.goodwe_grid_power", "sensor.goodwe_setpoint",
|
||||||
|
"sensor.goodwe_battery_power", "sensor.goodwe_p1_sample_age")))
|
||||||
|
check("...and the first pack's maintenance history SURVIVES the retreat, "
|
||||||
|
"because `UnitHistory.mirror_flat` keeps the flat keys live - so the "
|
||||||
|
"retreat itself does not lose state. ⚠️ Read from the RETREAT LEG "
|
||||||
|
"alone, so the round trip's own loss cannot be mistaken for it",
|
||||||
|
"MAINTENANCE HISTORY LOST" not in _RL["RETREAT"])
|
||||||
|
check("⚠️ ...but the ROUND TRIP does not: a maintenance cycle completed while "
|
||||||
|
"the install is rolled back is DISCARDED when it re-advances, because "
|
||||||
|
"`maint_schema_version` is already stamped and the migration skips",
|
||||||
|
"RETREAT-WINDOW WORK DISCARDED" in _RL["ROUND-TRIP"])
|
||||||
|
check("...and that is a leg whose data can reach the failure it names: the "
|
||||||
|
"retreat window actually contains a completion, written through the "
|
||||||
|
"shipped `UnitHistory`, at a timestamp the starting fixture does not have",
|
||||||
|
RR.RETREAT_WINDOW_COMPLETION in out_r
|
||||||
|
and RR.RETREAT_WINDOW_COMPLETION
|
||||||
|
not in (FIX / "state-flat-070.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"FAILED {len(fails)}:")
|
||||||
|
for f in fails:
|
||||||
|
print(f" - {f}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("all checks passed")
|
||||||
@@ -722,18 +722,41 @@ print(f" e.g. {_sustained.reason!r}")
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print(" ⚠️ AND NOT ON THE TRANSIENT, WHICH IS THE FALSE-ALARM CASE")
|
print(" ⚠️ AND NOT ON THE TRANSIENT, WHICH IS THE FALSE-ALARM CASE")
|
||||||
# ⚠️ THE SHAPES BELOW ARE THE RIG'S, NOT INVENTED. Measured on TEST-19 over 924
|
# THE SHAPE BELOW IS THE RIG'S: the serialised write path means that on a
|
||||||
# closed-loop runs in which every unit executed the share it was given: the
|
# reversing cycle B's new number lands up to a whole control cycle after A's,
|
||||||
# worst continuous opposition above 700 W is 5.0 s - ONE control cycle - at a
|
# both slew through zero behind a real ramp and a real lag, and between the two
|
||||||
# peak of 1700 W. That is the serialised write path: on a reversing cycle B's
|
# crossings they genuinely oppose. A guard that trips on that is a false-alarm
|
||||||
# new number lands up to a whole cycle after A's, both slew through zero at
|
# generator, and this project's doctrine on crying wolf is explicit (SAFETY-15,
|
||||||
# 800 W/s behind a 10 s lag, and between the two crossings they genuinely
|
# SAFETY-19).
|
||||||
# oppose. A guard that trips on that is a false-alarm generator, and this
|
#
|
||||||
# project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19).
|
# ⚠️ THE MAGNITUDE IS A FIXTURE INPUT, NOT A MEASUREMENT, AND THIS COMMENT USED
|
||||||
|
# TO SAY OTHERWISE. It read "the worst single transient the rig can produce -
|
||||||
|
# 1700 W", sourced to a 924-run campaign whose harness IS NOT COMMITTED IN
|
||||||
|
# EITHER REPOSITORY (DECISIONS/SAFETY-31-circulating-power.md §3 says so in its
|
||||||
|
# own words). Three sweeps have now been run against that claim and no two agree:
|
||||||
|
#
|
||||||
|
# 924-run campaign, harness uncommitted peak 1700 W, 505.0 s at 100 W
|
||||||
|
# independent reviewer sweep, 2026-09-07 peak 2750 W, 15.0 s above 700 W
|
||||||
|
# goodwe `two_master_circulation`, 840 runs peak 1048 W, 5.0 s above 700 W
|
||||||
|
#
|
||||||
|
# ⚠️ THAT IS NOT EVIDENCE THAT ANY OF THEM IS WRONG. The three swept different
|
||||||
|
# grids, and the last one measured WHY the spread is so wide: on its own grid the
|
||||||
|
# worst legitimate peak is 1048 W on the modelled plant, 2250 W on `INSTANT`, and
|
||||||
|
# 0 W on `INSTANT` with no write stagger reaching a whole cycle. The plant model
|
||||||
|
# and the stagger set are TERMS of the number, and a worst case quoted without
|
||||||
|
# them is not a measurement. It is evidence that the claim cannot be adjudicated
|
||||||
|
# while the instrument is missing.
|
||||||
|
#
|
||||||
|
# So what this check now says is what it can support: 1700 W is LARGER than
|
||||||
|
# anything any of the three sweeps produced legitimately, and one control cycle
|
||||||
|
# of it does not trip. The committed, re-runnable version of the claim is
|
||||||
|
# `admin/goodwe`'s `sim/inject.py two_master_circulation`, which runs on every
|
||||||
|
# pull request there and requires ZERO of ~840 non-faulty runs to trip.
|
||||||
_one_cycle = [0.0] * 5 + [1700.0] + [0.0] * 30
|
_one_cycle = [0.0] * 5 + [1700.0] + [0.0] * 30
|
||||||
_t1, _ = circ_drive(_one_cycle)
|
_t1, _ = circ_drive(_one_cycle)
|
||||||
check("the worst single transient the rig can produce - 1700 W for ONE control "
|
check("a single transient of 1700 W - larger than the worst peak any committed "
|
||||||
"cycle - does not trip", not _t1.tripped)
|
"sweep of the legitimate family has produced - lasting ONE control cycle "
|
||||||
|
"does not trip", not _t1.tripped)
|
||||||
_repeated = ([0.0] * 3 + [1700.0]) * 20
|
_repeated = ([0.0] * 3 + [1700.0]) * 20
|
||||||
_t2, _ = circ_drive(_repeated)
|
_t2, _ = circ_drive(_repeated)
|
||||||
check("...and neither does it REPEATED every fourth cycle for 400 s, which is "
|
check("...and neither does it REPEATED every fourth cycle for 400 s, which is "
|
||||||
@@ -1091,6 +1114,158 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
|
|||||||
"this' a different question from 'is this site tripped'",
|
"this' a different question from 'is this site tripped'",
|
||||||
len(_notes2) == 1)
|
len(_notes2) == 1)
|
||||||
|
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
|
||||||
|
# ⚠️ THIS BLOCK EXISTS BECAUSE A MUTATION SURVIVED ALL 26 FILES. Replacing
|
||||||
|
#
|
||||||
|
# pos, neg = opposing([inv.batt for inv in self.inverters],
|
||||||
|
# min_w=self.circ_wd.cfg.min_w / 2.0)
|
||||||
|
#
|
||||||
|
# in `check_circulating()` with `pos, neg = ([], [])` (-102 B) silently deletes
|
||||||
|
# the "Right now X is discharging while Y charges" clause from the notification,
|
||||||
|
# and the whole suite stayed GREEN. The block above asserts three tokens -
|
||||||
|
# METER CANNOT SEE, CONTROL HAS BEEN STOPPED, isolator - and NEVER an inverter
|
||||||
|
# name, so the one sentence `circulation.py` calls "the first thing somebody
|
||||||
|
# woken by this alarm needs" and "impossible to reconstruct from the number
|
||||||
|
# alone" was graded by nothing at all.
|
||||||
|
#
|
||||||
|
# ⚠️ IT IS NOT DEAD CODE, WHICH IS THE PART THAT MAKES IT A CHECK GAP RATHER
|
||||||
|
# THAN A REACHABILITY ONE. Ledger M18 - the `await self.check_circulating()`
|
||||||
|
# call removed - goes red on four checks above, so this function runs. What
|
||||||
|
# nothing asserted was the ATTRIBUTION.
|
||||||
|
#
|
||||||
|
# ⚠️ AND THE ROSTER IS ["garage", "shed", "cellar"], WHICH IS THE OTHER HALF OF
|
||||||
|
# THE FIX. Every fixture above runs on `inverter-a`/`inverter-b`;
|
||||||
|
# test_maintenance.py section 11 records what that costs - on a fixture whose
|
||||||
|
# names are the module's own constants, a check cannot tell the roster from the
|
||||||
|
# constant, and `UNIT_A` is not even what a nameless entry falls back to. Here
|
||||||
|
# the names are an operator's, the two participants pull OPPOSITE ways so the
|
||||||
|
# sentence is not symmetric, and a third pack rests at 5 W. That makes three
|
||||||
|
# DIFFERENT mutations - the clause dropped, the two directions swapped, and the
|
||||||
|
# `min_w` floor dropped - into three DIFFERENT red checks. A fixture naming both
|
||||||
|
# units the same, or one in which the direction did not matter, would see the
|
||||||
|
# first and neither of the others.
|
||||||
|
|
||||||
|
_CIRC_ROSTER = [
|
||||||
|
dict(name="garage", setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||||
|
batt_entity="sensor.batt_a", max_w=5000),
|
||||||
|
# ⚠️ B_RATING_HYPOTHESIS, and part 4's note above says why it is not 4600:
|
||||||
|
# inverter B does not physically exist and a datasheet is not a measurement.
|
||||||
|
dict(name="shed", setpoint_entity="number.sp_b", soc_entity="sensor.soc_b",
|
||||||
|
batt_entity="sensor.batt_b", max_w=B_RATING_HYPOTHESIS),
|
||||||
|
# THE BYSTANDER. A third pack resting at standby draw, on the roster, read
|
||||||
|
# every cycle, and NOT a participant in anybody's loop.
|
||||||
|
dict(name="cellar", setpoint_entity="number.sp_c", soc_entity="sensor.soc_c",
|
||||||
|
batt_entity="sensor.batt_c", max_w=B_RATING_HYPOTHESIS),
|
||||||
|
]
|
||||||
|
_CIRC_NAMES = [e["name"] for e in _CIRC_ROSTER]
|
||||||
|
_REST_W = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
class _NamedCircHass(_Hass):
|
||||||
|
"""Three named packs; the meter still reads exactly zero.
|
||||||
|
|
||||||
|
Same house as `_CircHass` - grid = load - sum(B) with two units cancelling -
|
||||||
|
with one extra pack idling at standby draw so that "who is named" and "who
|
||||||
|
is merely on the roster" are different questions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, a_w, b_w, c_w=_REST_W):
|
||||||
|
super().__init__()
|
||||||
|
self.vals = {"sensor.batt_a": a_w, "sensor.batt_b": b_w,
|
||||||
|
"sensor.batt_c": c_w}
|
||||||
|
|
||||||
|
async def number(self, entity, invert=False):
|
||||||
|
return {"sensor.grid": 0.0, "sensor.soc_a": 50.0,
|
||||||
|
"sensor.soc_b": 50.0, "sensor.soc_c": 50.0,
|
||||||
|
**self.vals}.get(entity)
|
||||||
|
|
||||||
|
|
||||||
|
def _named_trio(hass, store, *, hold_s=1, min_w=700):
|
||||||
|
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
|
||||||
|
"max_w": 5000, "gain": 0.6, "auto_start": True,
|
||||||
|
"circulation_w": min_w, "circulation_hold_s": hold_s,
|
||||||
|
"inverters": _CIRC_ROSTER}
|
||||||
|
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
|
||||||
|
c.auto = True
|
||||||
|
for inv in c.inverters:
|
||||||
|
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
async def _circ_named(a_w, b_w):
|
||||||
|
hass = _NamedCircHass(a_w, b_w)
|
||||||
|
c = _named_trio(hass, _MemStore())
|
||||||
|
for _ in range(14):
|
||||||
|
await c.read_inputs()
|
||||||
|
await c.cycle()
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
notes = [x for x in hass.calls
|
||||||
|
if x[0] == "persistent_notification"
|
||||||
|
and x[2].get("notification_id") == "goodwe_circulating_power"]
|
||||||
|
return c, notes
|
||||||
|
|
||||||
|
|
||||||
|
_cg, _notes_g = asyncio.run(_circ_named(+3000.0, -3000.0))
|
||||||
|
_cs, _notes_s = asyncio.run(_circ_named(-3000.0, +3000.0))
|
||||||
|
_msg_g = _notes_g[0][2]["message"] if _notes_g else ""
|
||||||
|
_msg_s = _notes_s[0][2]["message"] if _notes_s else ""
|
||||||
|
print(f" roster : {_cg.units}")
|
||||||
|
print(f" garage discharging, measured: "
|
||||||
|
f"{dict(zip(_cg.units, [i.batt for i in _cg.inverters]))}")
|
||||||
|
print(f" -> {_msg_g.split(chr(10))[2] if _msg_g.count(chr(10)) > 2 else _msg_g!r}")
|
||||||
|
print(f" shed discharging, measured: "
|
||||||
|
f"{dict(zip(_cs.units, [i.batt for i in _cs.inverters]))}")
|
||||||
|
print(f" -> {_msg_s.split(chr(10))[2] if _msg_s.count(chr(10)) > 2 else _msg_s!r}")
|
||||||
|
|
||||||
|
# --- THE PRECONDITIONS, AS THEIR OWN CHECKS (MUTATION.md rule 1) ------------
|
||||||
|
check("PRECONDITION: the roster carries three DISTINCT operator-chosen names, "
|
||||||
|
f"none of them `UNIT_A` ({UNIT_A!r}) - so every claim below is a claim "
|
||||||
|
"about the roster and not about a constant that happens to equal it",
|
||||||
|
_cg.units == tuple(_CIRC_NAMES) and len(set(_CIRC_NAMES)) == 3
|
||||||
|
and UNIT_A not in _CIRC_NAMES)
|
||||||
|
check("PRECONDITION: the two runs are MIRROR IMAGES - same house, same meter at "
|
||||||
|
"0 W, same magnitudes, and the two participants swapped. Without this the "
|
||||||
|
"direction claim below could be satisfied by a sentence that ignores the "
|
||||||
|
"measurement entirely",
|
||||||
|
[i.batt for i in _cg.inverters] == [+3000.0, -3000.0, _REST_W]
|
||||||
|
and [i.batt for i in _cs.inverters] == [-3000.0, +3000.0, _REST_W]
|
||||||
|
and _cg.grid == 0.0 and _cs.grid == 0.0)
|
||||||
|
check("PRECONDITION: both runs actually tripped and actually raised the card, "
|
||||||
|
"so an absent sentence below is an absent sentence and not an absent alarm",
|
||||||
|
_cg.circ_wd.tripped and _cs.circ_wd.tripped
|
||||||
|
and len(_notes_g) == 1 and len(_notes_s) == 1)
|
||||||
|
|
||||||
|
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
|
||||||
|
_WHO_G = "Right now garage is discharging while shed charges."
|
||||||
|
_WHO_S = "Right now shed is discharging while garage charges."
|
||||||
|
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
|
||||||
|
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
|
||||||
|
"needs and impossible to reconstruct from the watts alone, and until this "
|
||||||
|
"line the sentence could be deleted with all 26 files green",
|
||||||
|
_WHO_G in _msg_g)
|
||||||
|
check("⚠️ AND WITH THE TWO SWAPPED, THE SENTENCE SWAPS. This is the half a "
|
||||||
|
"single fixture cannot have: `pos` and `neg` exchanged, or a hard-coded "
|
||||||
|
"sentence, satisfies the check above and fails this one. The operator is "
|
||||||
|
"being told which pack to walk to",
|
||||||
|
_WHO_S in _msg_s)
|
||||||
|
check("...and each run carries ONLY its own sentence - the mirrored alarm does "
|
||||||
|
"not also contain the other direction, so the clause is derived from this "
|
||||||
|
"cycle's measurement rather than assembled from the roster",
|
||||||
|
_WHO_S not in _msg_g and _WHO_G not in _msg_s)
|
||||||
|
check("...and the RESTING pack is not named. `cellar` is on the roster, is read "
|
||||||
|
"every cycle and is measured at 5 W of standby draw - `opposing()` is "
|
||||||
|
"called with a floor of half the trip threshold precisely so a pack idling "
|
||||||
|
"beside a 6 kW loop is not reported as one of its two participants",
|
||||||
|
"cellar" not in _msg_g and "cellar" not in _msg_s
|
||||||
|
and _cg.inverters[2].batt == _REST_W)
|
||||||
|
check("...and the sentence sits with the figure, not instead of it: the same "
|
||||||
|
"card still carries the watts, the window and the three things only this "
|
||||||
|
"alarm can say",
|
||||||
|
all("CIRCULATING_POWER" in m and "METER CANNOT SEE" in m
|
||||||
|
and "CONTROL HAS BEEN STOPPED" in m and "isolator" in m
|
||||||
|
for m in (_msg_g, _msg_s)))
|
||||||
print()
|
print()
|
||||||
print(" the operator's press is the only way out, and it clears BOTH halves")
|
print(" the operator's press is the only way out, and it clears BOTH halves")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# WHAT THIS BUILD IS SUPPOSED TO ANNOUNCE, recorded so that a change to the
|
||||||
|
# composition has something to disagree with. THREE COLUMNS:
|
||||||
|
#
|
||||||
|
# <entity_id> <unique_id> <device identifier>
|
||||||
|
#
|
||||||
|
# ⚠️ ALL THREE, AND THE SECOND AND THIRD ARE THE HALF THE FIRST DRAFT MISSED.
|
||||||
|
# A reference that carries only entity_ids cannot see the two changes that
|
||||||
|
# orphan an install without changing a single composed id:
|
||||||
|
#
|
||||||
|
# `BASE` renamed (`goodwe_ctl` -> anything) every unique_id changes
|
||||||
|
# `DEVICE["identifiers"]` changed HA sees a NEW device
|
||||||
|
#
|
||||||
|
# Home Assistant's MQTT integration keys its entity registry on `unique_id` and
|
||||||
|
# its device registry on the device identifier. Change either and HA creates a
|
||||||
|
# NEW entity beside the old one - and because discovery is published with
|
||||||
|
# `retain=True` and nothing publishes a removal payload, the OLD config stays on
|
||||||
|
# the broker and the dead entity stays in the registry, still named by every
|
||||||
|
# automation that referenced it, never updating again. The composed entity_id is
|
||||||
|
# identical on both, so an entity_id-only comparison reports a clean transition.
|
||||||
|
# That is the same shape as the habit-4 this file's own card already caught
|
||||||
|
# once: a reference that does not carry the thing that moved.
|
||||||
|
#
|
||||||
|
# ⚠️ AND `object_id` IS DELIBERATELY NOT A COLUMN. `app/mqtt.py` lines 36-42 and
|
||||||
|
# 61-76 record that HA 2026.8 does NOT honour `object_id` in a discovery
|
||||||
|
# payload - the id is composed from device name plus entity name - so renaming
|
||||||
|
# one is a true no-op and a column for it would make this file refuse a change
|
||||||
|
# that costs nothing. The check is graded on what HA actually keys on.
|
||||||
|
#
|
||||||
|
# PROVENANCE, stated exactly:
|
||||||
|
# - recorded 2026-09-08 from `release/1.0` at `meter_source: firmware`,
|
||||||
|
# roster of one, by reading `mqtt.discovery_payloads(units=[])`. It is what
|
||||||
|
# the code announces.
|
||||||
|
# - `sensor.goodwe_p1_sample_age` is additionally recorded in `app/mqtt.py` as
|
||||||
|
# VERIFIED AGAINST THE LIVE SYSTEM on 2026-08-26, and the ESP32's ingest
|
||||||
|
# guard subscribes to that exact string.
|
||||||
|
# - ⚠️ IT IS NOT A DUMP OF THE LIVE INSTALL'S ENTITY REGISTRY. That is what
|
||||||
|
# `--live-entities` is for. This file is about THE BUILD.
|
||||||
|
#
|
||||||
|
# Editing this file is how you say "the announcement changed on purpose". Do not
|
||||||
|
# edit it to make a check pass.
|
||||||
|
sensor.goodwe_setpoint goodwe_ctl_setpoint goodwe_rs485_controller
|
||||||
|
sensor.goodwe_grid_power goodwe_ctl_grid goodwe_rs485_controller
|
||||||
|
sensor.goodwe_battery_power goodwe_ctl_battery goodwe_rs485_controller
|
||||||
|
sensor.goodwe_battery_soc goodwe_ctl_soc goodwe_rs485_controller
|
||||||
|
sensor.goodwe_circulating_power goodwe_ctl_circulation goodwe_rs485_controller
|
||||||
|
sensor.goodwe_maintenance_phase goodwe_ctl_phase goodwe_rs485_controller
|
||||||
|
sensor.goodwe_controller_status goodwe_ctl_status goodwe_rs485_controller
|
||||||
|
sensor.goodwe_p1_sample_age goodwe_ctl_p1_age goodwe_rs485_controller
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ARCH-02 fixture: THE SHAPE THE LIVE BOX IS IN TODAY (a flat, single-inverter
|
||||||
|
# config). ⚠️ ILLUSTRATIVE ENTITY NAMES, NOT THE LIVE ONES - the owner runs the
|
||||||
|
# rehearsal against a copy of the real options blob, and this file exists so
|
||||||
|
# that the harness has a synthetic transition it can be graded on.
|
||||||
|
setpoint_entity: number.goodwe_setpoint
|
||||||
|
soc_entity: sensor.goodwe_battery_soc_pct
|
||||||
|
batt_entity: sensor.goodwe_battery_power_w
|
||||||
|
batt_invert: false
|
||||||
|
max_w: 5000
|
||||||
|
bms_soc_floor: 10
|
||||||
|
meter_source: 'off'
|
||||||
|
meter_entity: sensor.p1_active_power
|
||||||
|
inverters: []
|
||||||
|
gain: 0.6
|
||||||
|
slew_w: 0
|
||||||
|
deadband_w: 15
|
||||||
|
target_grid_w: 0
|
||||||
|
step_w: 10
|
||||||
|
heartbeat_s: 10
|
||||||
|
stale_input_s: 15
|
||||||
|
maintenance_discharge_w: 2000
|
||||||
|
maintenance_charge_w: 2000
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# THE ENTITY IDS THE INSTALL HAS — a stand-in for the owner's real export.
|
||||||
|
#
|
||||||
|
# ⚠️ THIS FILE AND `announced-reference.txt` ANSWER DIFFERENT QUESTIONS AND ARE
|
||||||
|
# NOT INTERCHANGEABLE:
|
||||||
|
#
|
||||||
|
# announced-reference.txt what the BUILD announces. Loaded on EVERY run.
|
||||||
|
# Three columns, because HA keys on `unique_id` and
|
||||||
|
# on the device identifier as well as on the
|
||||||
|
# composed entity_id.
|
||||||
|
# this file (--live-entities)
|
||||||
|
# what the INSTALL has. One id per line. Orphans
|
||||||
|
# are graded against it: an id the install has and
|
||||||
|
# the AFTER shape does not announce is an orphan
|
||||||
|
# whatever the build's own history says.
|
||||||
|
#
|
||||||
|
# ⚠️ OMITTING `--live-entities` IS A REFUSAL (exit 2), NOT A QUIETER RUN. It
|
||||||
|
# used to be the other way round - an unreadable file refused, a missing flag
|
||||||
|
# passed with exit 0 and said nothing about the half it had not checked - and
|
||||||
|
# the owner runs this ONCE, at commissioning, under time pressure.
|
||||||
|
# `--no-live-entities` is the deliberate opt-out and it prints a standing
|
||||||
|
# banner in the report header and again on the exit line.
|
||||||
|
#
|
||||||
|
# PROVENANCE: these eight are what `release/1.0` announces at
|
||||||
|
# `meter_source: firmware`, roster of one, recorded 2026-09-08. They stand in
|
||||||
|
# for the export so the checks run in CI. ⚠️ THE REAL INSTALL CARRIES ROUGHLY 49
|
||||||
|
# `goodwe*` ENTITIES (card ARCH-02, read 2026-09-06); the other ~41 belong to
|
||||||
|
# the ESPHome firmware, to template sensors and to helpers, and this add-on
|
||||||
|
# announces none of them. The tool separates the ids it owns from the ids it
|
||||||
|
# does not rather than claiming orphans it cannot cause - which is the half of
|
||||||
|
# this file the eight-line stand-in cannot exercise, and the reason DOCS.md's
|
||||||
|
# owner step asks for the real export.
|
||||||
|
sensor.goodwe_battery_power
|
||||||
|
sensor.goodwe_battery_soc
|
||||||
|
sensor.goodwe_circulating_power
|
||||||
|
sensor.goodwe_controller_status
|
||||||
|
sensor.goodwe_grid_power
|
||||||
|
sensor.goodwe_maintenance_phase
|
||||||
|
sensor.goodwe_p1_sample_age
|
||||||
|
sensor.goodwe_setpoint
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
|
||||||
|
#
|
||||||
|
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
|
||||||
|
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
|
||||||
|
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
|
||||||
|
# every two-pack fixture in the suite named its first entry `inverter-a`, so
|
||||||
|
# `UNIT_A` and `inverters[0].name` were the same string in every check that
|
||||||
|
# existed and the defect was invisible. Names that are not `inverter-a` are what
|
||||||
|
# make a misdirected namespace something a rehearsal can SEE.
|
||||||
|
#
|
||||||
|
# `roster-inverter-a.yaml` beside this file is the same roster with the first
|
||||||
|
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
|
||||||
|
setpoint_entity: number.goodwe_setpoint
|
||||||
|
soc_entity: sensor.goodwe_battery_soc_pct
|
||||||
|
batt_entity: sensor.goodwe_battery_power_w
|
||||||
|
batt_invert: false
|
||||||
|
max_w: 5000
|
||||||
|
bms_soc_floor: 10
|
||||||
|
meter_source: 'off'
|
||||||
|
meter_entity: sensor.p1_active_power
|
||||||
|
gain: 0.6
|
||||||
|
slew_w: 0
|
||||||
|
deadband_w: 15
|
||||||
|
target_grid_w: 0
|
||||||
|
step_w: 10
|
||||||
|
heartbeat_s: 10
|
||||||
|
stale_input_s: 15
|
||||||
|
maintenance_discharge_w: 2000
|
||||||
|
maintenance_charge_w: 2000
|
||||||
|
inverters:
|
||||||
|
- name: inverter-a
|
||||||
|
setpoint_entity: number.goodwe_setpoint
|
||||||
|
soc_entity: sensor.goodwe_battery_soc_pct
|
||||||
|
batt_entity: sensor.goodwe_battery_power_w
|
||||||
|
max_w: 5000
|
||||||
|
bms_soc_floor: 10
|
||||||
|
- name: inverter-b
|
||||||
|
setpoint_entity: number.goodwe_b_setpoint
|
||||||
|
soc_entity: sensor.goodwe_b_battery_soc_pct
|
||||||
|
batt_entity: sensor.goodwe_b_battery_power_w
|
||||||
|
max_w: 4600
|
||||||
|
bms_soc_floor: 10
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
|
||||||
|
#
|
||||||
|
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
|
||||||
|
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
|
||||||
|
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
|
||||||
|
# every two-pack fixture in the suite named its first entry `inverter-a`, so
|
||||||
|
# `UNIT_A` and `inverters[0].name` were the same string in every check that
|
||||||
|
# existed and the defect was invisible. Names that are not `inverter-a` are what
|
||||||
|
# make a misdirected namespace something a rehearsal can SEE.
|
||||||
|
#
|
||||||
|
# `roster-inverter-a.yaml` beside this file is the same roster with the first
|
||||||
|
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
|
||||||
|
setpoint_entity: number.goodwe_setpoint
|
||||||
|
soc_entity: sensor.goodwe_battery_soc_pct
|
||||||
|
batt_entity: sensor.goodwe_battery_power_w
|
||||||
|
batt_invert: false
|
||||||
|
max_w: 5000
|
||||||
|
bms_soc_floor: 10
|
||||||
|
meter_source: 'off'
|
||||||
|
meter_entity: sensor.p1_active_power
|
||||||
|
gain: 0.6
|
||||||
|
slew_w: 0
|
||||||
|
deadband_w: 15
|
||||||
|
target_grid_w: 0
|
||||||
|
step_w: 10
|
||||||
|
heartbeat_s: 10
|
||||||
|
stale_input_s: 15
|
||||||
|
maintenance_discharge_w: 2000
|
||||||
|
maintenance_charge_w: 2000
|
||||||
|
inverters:
|
||||||
|
- name: garage
|
||||||
|
setpoint_entity: number.goodwe_setpoint
|
||||||
|
soc_entity: sensor.goodwe_battery_soc_pct
|
||||||
|
batt_entity: sensor.goodwe_battery_power_w
|
||||||
|
max_w: 5000
|
||||||
|
bms_soc_floor: 10
|
||||||
|
- name: shed
|
||||||
|
setpoint_entity: number.goodwe_b_setpoint
|
||||||
|
soc_entity: sensor.goodwe_b_battery_soc_pct
|
||||||
|
batt_entity: sensor.goodwe_b_battery_power_w
|
||||||
|
max_w: 4600
|
||||||
|
bms_soc_floor: 10
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a store that has ALREADY run the flat config under 0.7.0+, so DEV-02's `schema_version` is stamped and the SAFETY-02 latch is not tripped. NOT a copy of the live box's /data/state.json. This is the CLEAN CONTROL for the MAINT-05 half of the rehearsal: the flat->roster transition over it must come out with NO findings, which is what makes each deliberate mutation's RED mean something. A harness that is red on every input has not measured anything.",
|
||||||
|
"schema_version": 1,
|
||||||
|
"phase": "idle",
|
||||||
|
"phase_started": "2026-08-07T10:00:00+00:00",
|
||||||
|
"last_completed": "2026-08-07T18:42:00+00:00",
|
||||||
|
"last_start_attempt": "2026-08-07T10:00:00+00:00",
|
||||||
|
"sign_tripped": false,
|
||||||
|
"sign_reason": "",
|
||||||
|
"auto": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a FRESH INSTALL. No maintenance history, no latch, no schema markers: what /data/state.json looks like before the add-on has ever completed a cycle. It is a clean control, and it earned its place: the first draft of the harness reported this transition RED, because the DEV-02 migration writes both SAFETY-02 defaults under `inverter-a` before a roster exists and the tool called them stranded. Losing a default loses nothing."
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a PRE-DEV-02 flat state file. NOT a copy of the live box's /data/state.json; this session cannot read that one and did not try. It is built to carry, deliberately, the two shapes that state built by the happy path does not have: a LATCHED SAFETY-02 failsafe under the flat spelling, and a real maintenance history under the flat spelling. Both are LEGACY-SHAPED: there is no schema_version and no maint_schema_version, so this file was last written by a controller that had never heard of unit namespaces. Losing either one is silent.",
|
||||||
|
"phase": "idle",
|
||||||
|
"phase_started": "2026-08-07T10:00:00+00:00",
|
||||||
|
"last_completed": "2026-08-07T18:42:00+00:00",
|
||||||
|
"last_start_attempt": "2026-08-07T10:00:00+00:00",
|
||||||
|
"sign_tripped": true,
|
||||||
|
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
|
||||||
|
"auto": true
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user