Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22ca341459 | ||
|
|
9c1a9e0f17 | ||
|
|
638c213944 | ||
|
|
1841416aa4 | ||
|
|
cb8f800180 | ||
|
|
018da00b74 | ||
|
|
e06906d65f | ||
|
|
37dd0f00cc | ||
|
|
a0d981731c | ||
|
|
9099ceb1af | ||
|
|
295a2aedb6 |
@@ -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
|
||||
`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
|
||||
|
||||
⚠️ **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
|
||||
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
|
||||
@@ -206,7 +268,11 @@ margin above the knee is what absorbs that without re-opening this card.
|
||||
**Revisit when SPIKE-04 lands, not before.**
|
||||
|
||||
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
|
||||
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 |
|
||||
| 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 |
|
||||
| **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
|
||||
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
|
||||
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
|
||||
|
||||
* **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.**
|
||||
* **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
|
||||
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.
|
||||
* **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
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
reaching outside the suite. The probe is the committed evidence that
|
||||
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
|
||||
|
||||
@@ -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(" ⚠️ 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
|
||||
# closed-loop runs in which every unit executed the share it was given: the
|
||||
# worst continuous opposition above 700 W is 5.0 s - ONE control cycle - at a
|
||||
# peak of 1700 W. That is the serialised write path: on a reversing cycle B's
|
||||
# new number lands up to a whole cycle after A's, both slew through zero at
|
||||
# 800 W/s behind a 10 s lag, and between the two crossings they genuinely
|
||||
# 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 SHAPE BELOW IS THE RIG'S: the serialised write path means that on a
|
||||
# reversing cycle B's new number lands up to a whole control cycle after A's,
|
||||
# both slew through zero behind a real ramp and a real lag, and between the two
|
||||
# crossings they genuinely 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
|
||||
_t1, _ = circ_drive(_one_cycle)
|
||||
check("the worst single transient the rig can produce - 1700 W for ONE control "
|
||||
"cycle - does not trip", not _t1.tripped)
|
||||
check("a single transient of 1700 W - larger than the worst peak any committed "
|
||||
"sweep of the legitimate family has produced - lasting ONE control cycle "
|
||||
"does not trip", not _t1.tripped)
|
||||
_repeated = ([0.0] * 3 + [1700.0]) * 20
|
||||
_t2, _ = circ_drive(_repeated)
|
||||
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'",
|
||||
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(" 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