SAFETY-37: a stale reading is None, and arrival cannot be what says so #57

Merged
admin merged 2 commits from SAFETY-37 into release/1.0 2026-09-06 13:06:27 +02:00
Owner

SAFETY-37 builds the single freshness policy the architect pass asked for, wires
it to the two consumers the card names, and says what TEL-06 and TEL-08
still owe. One criterion in the card could not be implemented as written; §1
says which one and what replaced it, because that is the most useful thing in
this PR.

Branch off release/1.0 @ a4290d3. No new configuration key and no schema
change
, so no version bump.


1. ⚠️ The criterion that had to be rewritten

The card says:

"A pack legitimately resting at 40 % and a dead sensor reading 40 % are the
same number; only arrival separates them."

At this layer, arrival does not separate them, and cannot. read_inputs()
polls Home Assistant's state cache about once a second (run_control()'s
await asyncio.sleep(1) bounds the loop body), and while the entity exists Home
Assistant answers 200 with a number whether or not the publisher behind it
is alive
. Both members of the pair arrive, identically, for ever.

That is not an argument, it is graded. test_freshness.py part 1a builds the
arrival-age gate the card asks for and runs it against both members of the pair:

  ok   ⚠️ AN ARRIVAL-AGE GATE CANNOT TELL THE PAIR APART … the age since arrival
       is 0.0 s at every instant of BOTH runs - a threshold on it is a threshold
       that can never fire, which is TEL-06's defect rebuilt in this repository

The other half of the card's rule is honoured absolutely. last_updated
does not move on an unchanged rewrite; neither does last_changed; and this
project already measured on the ENV-01 rig (70 s of a frozen meter) and against
the live house that last_reported does not advance on a repeat on either
serialiser — p1.HomeWizardLocalSource's docstring records both. freshness.py
reads no Home Assistant timestamp at all
, and part 1a asserts that its body
contains no last_updated. Every clock in the verdict is time.monotonic()
inside this process, stamped at the instant an arrival carried a different
number.

So the criterion became: a reading is stale when it has not moved through a
change it was OBLIGED to follow.
The obligation, not the age, separates the
pair.


2. The policy

STALE  ⟺  unchanged for ≥ 35 s
          AND |standing command now| ≥ 250 W
          AND |standing command now − standing command when the reading last
               changed| ≥ 250 W          … latched until a different value arrives

Inverter.batt becomes None — never a held number, never a zero — which
is a MISSING INPUT, which §11.1 rule 1 already answers with 0 W and keeps
commanding.

Both consumers are gated by that one change, and neither consumer needed a
line.
They already do the right thing with a None and neither could ever
receive one: SignWatchdog.evaluate clears its window on a None
measurement, and circulating_w returns None for a roster with a missing
term. The gate is the piece that was missing between the shipped read and the
shipped checks. Both facts are asserted on the shipped functions as
preconditions, not assumed.

Why it is a conjunction and not a threshold — measured, this run

Replaying the committed 30-day capture of this house's own 1 W-resolution power
entity (scenarios/p1-net-30d-2026-08/, 485,841 samples out of Home Assistant's
recorder, where a row exists only when the state string changed, so the gap
between rows is the unchanged duration every consumer saw):

longest unchanged run over 30 days 263.2 s (held −899.0 W, 2026-08-11 17:04)
runs ≥ 35 s 10 of 485,840 (0.0021 %)
runs ≥ 17 s 386 of 485,840 (0.0794 %)

A real power figure at 1 W resolution held one value for four and a half
minutes with nothing wrong
. A false-positive-free unchanged threshold would
have to be over 263 s — and 263 s is 3.5× too slow to protect a consumer
that trips at 75 s. There is no admissible threshold. Without the
conjunction the card has no implementable verdict.

⚠️ That is the METER's register, not 0x0518, and it is a surrogate. No
30-day capture of the inverter's own power exists — it is not in the recorder
export. The only thing it is used for is the sentence above; it does not set the
bound. test_freshness.py part 8 recomputes all three figures from the
committed files on every run, so a quoted number that drifts from its evidence
goes red rather than being re-quoted.

⚠️ Every one of those long runs is under an unmoving load — the worst is
263 s of a steady −899 W export. That is exactly what the command-travel term
keys on: none of them arms the gate.


3. The bounds, per signal, with their derivations

⚠️ One constant for both signals would be useless on SoC or trigger-happy on
power.
freshness.BOUNDS carries them as data with a wired column, and part
7 walks the AST of every shipped module to check that column tells the truth.
(Its first draft grepped for the string and answered wrong — both inverter.py
and main.py name BOUNDS["soc"] in a comment explaining why it is unwired.)

batt — 0x0518, S16, 1 W, published every 10 s — WIRED

Floor 17.0 s, the fastest a healthy register can be obliged to answer:

source, read this run unless dated
6.0 s plant settle goodwe/NOTES.md §5.9, measured 2026-08-22: 26 W at +1.43 s, 469 W (94 %) at +3.34 s, settled 507 W at +6.05 s
10.0 s publisher firmware/goodwe-master.yaml:181, update_interval: 10s
1.0 s this add-on's read period main.run_control()'s await asyncio.sleep(1)

Ceiling 75.0 s, the tighter of the two consumers: circulation_hold_s 75
(config.yaml:512) and unit_sign_disagree_s 85 (config.yaml:451). A gate
slower than its consumer hands over the frozen number and explains afterwards.

Chosen 35.0 s. Band [2 × 17.0, 0.5 × 75.0] = [34.0, 37.5]. ⚠️ The band
is derived; 35 is a CHOICE inside it
— the band holds four integers, and my
first draft of that check claimed it held one and went red on its first run. It
is deliberately not 30 (validate.FIRMWARE_INGEST_VETO_S, a borrowed constant)
and not 60 (round). Part 6 computes both ends out of config.yaml: drop
circulation_hold_s to 60 and this goes red.

MOVE_W = 250 W is the largest admissible value, not a sensitivity
preference: the gate must arm everywhere a stale reading could mislead a
consumer, and the earliest either consumer speaks is unit_sign_disagree_w
250 W (config.yaml:450) — below that watchdog.disagrees() is False whatever
the measurement says. Taking the ceiling minimises spurious arming. Pinned to
config.yaml, not to a literal.

soc — 0x050E, U16, 1 %, published every 10 s — STATED, NOT WIRED

SoC cannot use this conjunction: a rested pack holds one percent for hours
under a 0 W command, which is exactly the card's own example. Its obliged
movement is energy: one percent of pack A is ≈ 153.6 Wh (15.36 kWh
nominal, goodwe/NOTES.md §1.1b — ⚠️ NOMINAL and FROM DATASHEET, not a measured
usable capacity), so one percent takes ≥ 110 s at 5000 W and ≈ 553 s at 1000 W.
The bound is "SoC unchanged while ∫|battery power| dt exceeded K × 153.6 Wh",
and K cannot be chosen from anything written down today. Shipping it with a
guessed K would be a gate that fires on a rested pack — the hazard, not the fix.
Freshness takes its obligation term from the caller precisely so an energy
integral can be handed in where command travel is handed in here.


4. ⚠️ What changes on the install that is running in a house today

before after
healthy register, N=1 value reaches consumers identical, asserted: 60 reads of a dithering register under a reversing command all arrive unchanged, gate never arms, status page says nothing new
healthy register, N≥2 value reaches consumers identical
frozen register, N=1 reads as a healthy measurement for ever site stops — inputs_ok False → stale_input_s hold → 0 W, and keeps commanding
frozen register, N≥2 per-unit sign check false-trips at 85 s and parks a healthy inverter; circulation sensor reports a loop that is not happening both consumers get None; both windows clear

The N=1 row is a real behaviour change on the shipped roster and is named
rather than hidden.
It is also the point of the card: HW-01 was a
one-inverter site, and the pack drained to its floor over 6 h 25 m with every
detector reading healthy. The direction it fails in is the safe one — a false
verdict commands 0 W, behind the existing 15 s stale_input_s hold, and clears
itself on the register's next move (one watt is enough; 0x0518 dithers ±1 W
while holding, NOTES.md §5.11, 2026-08-22).

The counterfactual is run, not argued. Part 3 drives real cycle() calls at
N=2 with A's register frozen at −1200 W and B's live at +900 W, the command
travel produced by the shipped compute() and allocator rather than written
into the fixture:

standing on A: [0, 445, 890, 1335, 1335, …]      ← the shipped control law
gated       : sign trip tick=None   circ=[1800, 1800, 1800, None, None, …]
counterfact : sign trip tick=7      circ=[1800, 1800, 1800, 1800, 1800, …]

⚠️ BOTH UNITS IN EVERY TWO-INVERTER FIXTURE HERE ARE INVERTER A, with
different numbers passed in. Inverter B does not physically exist (≈ 2026-09-12;
goodwe/NOTES.md §1.1b, and sim/plantsim.py:216's INVERTER_B is A copied
with measured=False). The 3000 W / 15 % second unit is illustrative and
deliberately not B's datasheet 4600 W / 10 %
: the real pair differs by 400 W,
narrower than a slew step, and two near-equal numbers cannot show a check that
read the wrong unit's.


5. What TEL-06 and TEL-08 still owe

TEL-06 — layer 1, Blocked. Nothing in this card can reach it. The
ESPHome veto tests the value of sensor.goodwe_p1_sample_age; this policy
lives in the add-on, on the far side of a flash. What TEL-06 owes is the same
substitution inside the firmware: a millis() stamp taken when the entity
is written, and a veto on millis() − last_write_ms rather than on the
published number. The policy question — arrival, not value — is answered here.
The remaining work is a firmware stamp and a supervised flash. No firmware was
touched.

TEL-08 — stale SoC. It owes exactly three things and no mechanism:

  1. a measured usable capacity for pack A (or a defensible bound) — the
    153.6 Wh/percent above is nominal and from a datasheet;
  2. the energy integral since the reading last changed;
  3. the constant K.

It should consume freshness.Freshness with BOUNDS["soc"] filled in, not
restate the policy. The row is already in the table with wired=False and its
reason, and part 7 goes red the day that column stops being true.


6. ⚠️ Honest survivors, with their size

Graded in part 9, so they are residuals and not surprises.

kind what survives size
false positive an inverter physically unable to follow — saturating, thermally derating, a BMS refusing a charge — is called stale while telling the truth one spurious site stop, behind stale_input_s, self-clearing the moment the register moves. It cannot drive the battery anywhere
miss a register frozen under a command that never travels 250 W is never detected, for 400 s or for ever scoped: such a unit is one no per-unit sign check can speak about (unit_sign_disagree_w = 250 W) and one contributing at most 250 W to a loop whose floor is 700 W. Asserted, not argued
miss — the largest a dead publisher whose last values alternate, or a cache replaying two old samples, never starts the unchanged clock this gate detects a value that STOPPED; it cannot detect a value that is WRONG. Nothing in this add-on can. TEL-06's firmware arrival stamp is the only layer that could

Recorded because it looks like a survivor and is not: a publisher failure that
freezes both registers is caught on both units independently, on each unit's
own command travel and each unit's own number.


7. Mutation ledger — 2026-09-06

MUTATION.md's rule honoured throughout: purge first (count printed, 0 remaining
every run), tree mounted :ro, python3 -B, fingerprint before and after
the revert.

Clean tree: app/freshness.py sha256:f40fa3e8819014ce, 20,065 B ·
app/inverter.py 7e760c6e8cdfa5b7, 30,778 B · app/main.py
7d7ff4ba0697ea64, 238,535 B. Each mutant graded by test_freshness.py +
test_inverter.py.

Clean tree: app/freshness.py sha256:f40fa3e8819014ce, 20,065 B ·
app/inverter.py 7e760c6e8cdfa5b7, 30,778 B · app/main.py
7d7ff4ba0697ea64, 238,535 B. Each mutant graded by test_freshness.py +
test_inverter.py.

# mutation file Δ B result
M0 none (control) freshness +0 green — the harness is not red on everything
M1 reading() returns self.last unconditionally freshness −24 red, 3 named checks
M2 drop the command-travel term freshness −59 red
M3 drop the magnitude term freshness −54 SURVIVED, then red — the finding
M4 latch never holds freshness +13 red, 3 named checks
M5 bound 90 s (slower than both consumers) freshness +0 red
M6 bound 10 s (under the physical floor) freshness +0 red
M7 MOVE_W 700 W freshness +0 red, on part 6's config.yaml pin
M8 the wiring in _read_unit removed main −5 red, 3 named checks
M9 reset the unchanged clock on every arrival freshness −43 red
M10 standing_w() drops last_write inverter −73 red, and takes test_inverter.py with it
M11 standing_at_change refreshed on every arrival freshness +121 ⚠️ DISCOUNTED — the mutant does not parse
M11b the same defect, written parseably freshness +121 red, 3 named checks
M12 BOUNDS["soc"].wired = True — the table lies freshness −1 red
M13 the wrong signal submitted (inv.soc for inv.batt) main +4 red, and test_inverter.py too
M14 one Freshness shared by every unit inverter +29 red
M15 latch cleared by a failed read freshness +31 SURVIVED, then red

⚠️ M5, M6 and M7 are +0 B, MUTATION.md's risky delta. All three are
reds, the direction stale bytecode cannot fabricate, every run purged with
the count printed, and the tree mounted :ro so it physically cannot hold a
cache.

⚠️ M11 IS NOT A RESULT AND IS NOT COUNTED. Its edit dedents a statement into
an if body, so the module raises IndentationError at line 312 and both files
go red without a check being evaluated — verified with ast.parse, not assumed.
M11b is the same defect written so it parses.

Six were re-run against the exact committed tree (test_freshness.py
sha256:a62f3e1f1ac856cf, 44,913 B), because the two closures, the key=str
fix and the wall-clock hardening all landed after the sweep: M0 green, and M2,
M3, M8, M11b and M15 all red on named checks. The other ten are red on checks in
parts none of those edits touch.

⚠️ The one survivor, and it is the finding

M3, dropping the magnitude term, survived the first draft. Every check was
green, because in all of them a command that had travelled 250 W had also
ended up above 250 W — the fixture never separated the two terms, so one of
them was decoration as far as the suite could tell.
The case that separates
them is a site brought to rest: writes are serialised, so a register can
reach its new value on the same read at which the old command is still
standing; standing_at_change then captures 1300 W, the site is commanded to
0 W, and the travel term alone reads 1300 W of unfollowed command while the
register sits truthfully at +5 W for a minute. Part 4 now grades exactly that,
and M3 is red.

Recorded in MUTATION.md as habit 17 — twelfth instance of "a check whose
DATA cannot reach the failure it names", and the general form: ask of every
conjunction whether there is a fixture in which each term is the only false
one.
If not, the suite cannot tell an AND from an OR.

⚠️ And one process defect worth more than the ledger

One mutation's container run overran a two-minute command budget and the wrapper
was killed between the edit and the revert, leaving app/freshness.py
mutated on disk. Nothing announced it; the next mutation's harness cheerfully
fingerprinted and graded the mutant plus a second mutation. It was caught by
comparing the tree fingerprint against the recorded clean one, restored, and
verified back to byte and sha256 before anything else ran. That is the same
defect stale bytecode causes, from a different cause, and it is now
MUTATION.md habit 18: fingerprint after the revert, not only before the
run.


8. Suite

22/22 PASS in goodwe-addon-ci (the CI image, deps from apk), tree mounted
:ro, PYTHONDONTWRITEBYTECODE=1, python3 -B, bytecode purged before the run
(count printed) and 0 caches present after it. test_freshness.py is the 22nd
file and takes 19 s, 3.5 s of which is replaying the 30-day capture.

test_inverter.py's config-reader audit caught the new option_default() on its
first run and named it — the table is signature-driven, exactly as its comment
promises — so it is registered there rather than left outside the audit.

22 test file(s)
PASS  test_alloc.py (25s)   PASS  test_maintenance.py (1s)
PASS  test_arbiter.py (0s)  PASS  test_mqtt.py (2s)
PASS  test_arbitrage.py (5s)PASS  test_p1.py (7s)
PASS  test_bytecode.py (1s) PASS  test_peakguard.py (61s)
PASS  test_ci_gate.py (1s)  PASS  test_price.py (1s)
PASS  test_clamp.py (1s)    PASS  test_price_forecast.py (0s)
PASS  test_control.py (0s)  PASS  test_static_tariff.py (4s)
PASS  test_freeze.py (34s)  PASS  test_tariff.py (6s)
PASS  test_freshness.py(16s)PASS  test_translations.py (0s)
PASS  test_inverter.py (7s) PASS  test_validate.py (6s)
PASS  test_loop.py (93s)    PASS  test_watchdog.py (53s)
bytecode entries found and purged: 0 ; remaining: 0 ; cache after run: 0

⚠️ Part 3 touches the wall clock, so it MEASURES its own margin (CI-06)

The freshness verdict runs on an injected monotonic clock and is deterministic.
The sign watchdog inside cycle() cannot be injected. CI-06 is the record of
what that costs — test_peakguard.py answered two checks with the wall clock,
flaked at ~20 %, and cost a wrong merge diagnosis, because a flaky check does
not waste a re-run, it manufactures evidence for whichever hypothesis the reader
already holds.
So the margin is asserted with its number:

gate closed after 0.61 s of WALL clock, against the 2 s watchdog window (3.3x margin)

If a loaded machine ever narrows that, the check fails naming the number —
the difference between "the box was slow" and "the code is broken".


🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

SAFETY-37 builds the single freshness policy the architect pass asked for, wires it to the two consumers the card names, and says what [[TEL-06]] and [[TEL-08]] still owe. **One criterion in the card could not be implemented as written; §1 says which one and what replaced it, because that is the most useful thing in this PR.** Branch off `release/1.0` @ `a4290d3`. **No new configuration key and no schema change**, so no version bump. --- ## 1. ⚠️ The criterion that had to be rewritten The card says: > *"A pack legitimately resting at 40 % and a dead sensor reading 40 % are the > same number; **only arrival separates them**."* **At this layer, arrival does not separate them, and cannot.** `read_inputs()` polls Home Assistant's state cache about once a second (`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), and while the entity exists Home Assistant answers `200` with a number **whether or not the publisher behind it is alive**. Both members of the pair arrive, identically, for ever. That is not an argument, it is graded. `test_freshness.py` part 1a builds the arrival-age gate the card asks for and runs it against both members of the pair: ``` ok ⚠️ AN ARRIVAL-AGE GATE CANNOT TELL THE PAIR APART … the age since arrival is 0.0 s at every instant of BOTH runs - a threshold on it is a threshold that can never fire, which is TEL-06's defect rebuilt in this repository ``` **The other half of the card's rule is honoured absolutely.** `last_updated` does not move on an unchanged rewrite; neither does `last_changed`; and this project already measured on the ENV-01 rig (70 s of a frozen meter) and against the live house that `last_reported` does not advance on a repeat on either serialiser — `p1.HomeWizardLocalSource`'s docstring records both. **`freshness.py` reads no Home Assistant timestamp at all**, and part 1a asserts that its body contains no `last_updated`. Every clock in the verdict is `time.monotonic()` inside this process, stamped at the instant an arrival carried a different number. So the criterion became: **a reading is stale when it has not moved through a change it was OBLIGED to follow.** The obligation, not the age, separates the pair. --- ## 2. The policy ``` STALE ⟺ unchanged for ≥ 35 s AND |standing command now| ≥ 250 W AND |standing command now − standing command when the reading last changed| ≥ 250 W … latched until a different value arrives ``` `Inverter.batt` becomes **`None`** — never a held number, never a zero — which is a MISSING INPUT, which §11.1 rule 1 already answers with 0 W and keeps commanding. **Both consumers are gated by that one change, and neither consumer needed a line.** They already do the right thing with a `None` and neither could ever receive one: `SignWatchdog.evaluate` **clears** its window on a `None` measurement, and `circulating_w` returns `None` for a roster with a missing term. The gate is the piece that was missing *between* the shipped read and the shipped checks. Both facts are asserted on the shipped functions as preconditions, not assumed. ### Why it is a conjunction and not a threshold — measured, this run Replaying the committed 30-day capture of this house's own 1 W-resolution power entity (`scenarios/p1-net-30d-2026-08/`, 485,841 samples out of Home Assistant's recorder, where a row exists only when the state string **changed**, so the gap between rows *is* the unchanged duration every consumer saw): | | | |---|---| | longest unchanged run over 30 days | **263.2 s** (held −899.0 W, 2026-08-11 17:04) | | runs ≥ 35 s | 10 of 485,840 (0.0021 %) | | runs ≥ 17 s | 386 of 485,840 (0.0794 %) | A real power figure at 1 W resolution held **one value for four and a half minutes with nothing wrong**. A false-positive-free unchanged threshold would have to be over 263 s — and 263 s is **3.5× too slow** to protect a consumer that trips at 75 s. **There is no admissible threshold.** Without the conjunction the card has no implementable verdict. ⚠️ **That is the METER's register, not `0x0518`, and it is a surrogate.** No 30-day capture of the inverter's own power exists — it is not in the recorder export. The only thing it is used for is the sentence above; it does not set the bound. `test_freshness.py` part 8 recomputes all three figures from the committed files on every run, so a quoted number that drifts from its evidence goes red rather than being re-quoted. ⚠️ **Every one of those long runs is under an unmoving load** — the worst is 263 s of a steady −899 W export. That is exactly what the command-travel term keys on: none of them arms the gate. --- ## 3. The bounds, per signal, with their derivations ⚠️ **One constant for both signals would be useless on SoC or trigger-happy on power.** `freshness.BOUNDS` carries them as data with a `wired` column, and part 7 walks the AST of every shipped module to check that column tells the truth. (Its first draft grepped for the string and answered wrong — both `inverter.py` and `main.py` *name* `BOUNDS["soc"]` in a comment explaining why it is unwired.) ### `batt` — `0x0518`, S16, 1 W, published every 10 s — **WIRED** **Floor 17.0 s**, the fastest a healthy register can be *obliged* to answer: | | | source, read this run unless dated | |---|---|---| | 6.0 s | plant settle | `goodwe/NOTES.md` §5.9, **measured 2026-08-22**: 26 W at +1.43 s, 469 W (94 %) at +3.34 s, settled 507 W at +6.05 s | | 10.0 s | publisher | `firmware/goodwe-master.yaml:181`, `update_interval: 10s` | | 1.0 s | this add-on's read period | `main.run_control()`'s `await asyncio.sleep(1)` | **Ceiling 75.0 s**, the tighter of the two consumers: `circulation_hold_s` **75** (config.yaml:512) and `unit_sign_disagree_s` **85** (config.yaml:451). A gate slower than its consumer hands over the frozen number and explains afterwards. **Chosen 35.0 s.** Band `[2 × 17.0, 0.5 × 75.0] = [34.0, 37.5]`. ⚠️ **The band is derived; 35 is a CHOICE inside it** — the band holds four integers, and my first draft of that check claimed it held one and went red on its first run. It is deliberately not 30 (`validate.FIRMWARE_INGEST_VETO_S`, a borrowed constant) and not 60 (round). Part 6 computes **both ends out of `config.yaml`**: drop `circulation_hold_s` to 60 and this goes red. **`MOVE_W` = 250 W** is the **largest** admissible value, not a sensitivity preference: the gate must arm everywhere a stale reading could mislead a consumer, and the earliest either consumer speaks is `unit_sign_disagree_w` 250 W (config.yaml:450) — below that `watchdog.disagrees()` is `False` whatever the measurement says. Taking the ceiling minimises spurious arming. Pinned to `config.yaml`, not to a literal. ### `soc` — `0x050E`, U16, 1 %, published every 10 s — **STATED, NOT WIRED** SoC **cannot use this conjunction**: a rested pack holds one percent for hours under a 0 W command, which is exactly the card's own example. Its obliged movement is **energy**: one percent of pack A is ≈ **153.6 Wh** (15.36 kWh nominal, `goodwe/NOTES.md` §1.1b — ⚠️ NOMINAL and FROM DATASHEET, not a measured usable capacity), so one percent takes ≥ 110 s at 5000 W and ≈ 553 s at 1000 W. The bound is *"SoC unchanged while ∫|battery power| dt exceeded K × 153.6 Wh"*, and **K cannot be chosen from anything written down today**. Shipping it with a guessed K would be a gate that fires on a rested pack — the hazard, not the fix. `Freshness` takes its obligation term from the caller precisely so an energy integral can be handed in where command travel is handed in here. --- ## 4. ⚠️ What changes on the install that is running in a house today | | before | after | |---|---|---| | healthy register, N=1 | value reaches consumers | **identical**, asserted: 60 reads of a dithering register under a reversing command all arrive unchanged, gate never arms, status page says nothing new | | healthy register, N≥2 | value reaches consumers | identical | | **frozen register, N=1** | reads as a healthy measurement for ever | **site stops** — `inputs_ok` False → `stale_input_s` hold → 0 W, and keeps commanding | | **frozen register, N≥2** | per-unit sign check false-trips at 85 s and parks a healthy inverter; circulation sensor reports a loop that is not happening | both consumers get `None`; both windows **clear** | **The N=1 row is a real behaviour change on the shipped roster and is named rather than hidden.** It is also the point of the card: HW-01 was a one-inverter site, and the pack drained to its floor over 6 h 25 m with every detector reading healthy. The direction it fails in is the safe one — a false verdict commands 0 W, behind the existing 15 s `stale_input_s` hold, and clears itself on the register's next move (one watt is enough; `0x0518` dithers ±1 W while holding, `NOTES.md` §5.11, 2026-08-22). **The counterfactual is run, not argued.** Part 3 drives real `cycle()` calls at N=2 with A's register frozen at −1200 W and B's live at +900 W, the command travel produced by the shipped `compute()` and allocator rather than written into the fixture: ``` standing on A: [0, 445, 890, 1335, 1335, …] ← the shipped control law gated : sign trip tick=None circ=[1800, 1800, 1800, None, None, …] counterfact : sign trip tick=7 circ=[1800, 1800, 1800, 1800, 1800, …] ``` ⚠️ **BOTH UNITS IN EVERY TWO-INVERTER FIXTURE HERE ARE INVERTER A**, with different numbers passed in. Inverter B does not physically exist (≈ 2026-09-12; `goodwe/NOTES.md` §1.1b, and `sim/plantsim.py:216`'s `INVERTER_B` is A copied with `measured=False`). The 3000 W / 15 % second unit is **illustrative and deliberately not B's datasheet 4600 W / 10 %**: the real pair differs by 400 W, narrower than a slew step, and two near-equal numbers cannot show a check that read the wrong unit's. --- ## 5. What TEL-06 and TEL-08 still owe **[[TEL-06]] — layer 1, Blocked. Nothing in this card can reach it.** The ESPHome veto tests the *value* of `sensor.goodwe_p1_sample_age`; this policy lives in the add-on, on the far side of a flash. What TEL-06 owes is the same substitution **inside the firmware**: a `millis()` stamp taken when the entity is *written*, and a veto on `millis() − last_write_ms` rather than on the published number. The policy question — arrival, not value — is answered here. The remaining work is a firmware stamp and a supervised flash. **No firmware was touched.** **[[TEL-08]] — stale SoC.** It owes exactly three things and **no mechanism**: 1. a **measured** usable capacity for pack A (or a defensible bound) — the 153.6 Wh/percent above is nominal and from a datasheet; 2. the **energy integral** since the reading last changed; 3. the constant **K**. It should consume `freshness.Freshness` with `BOUNDS["soc"]` filled in, not restate the policy. The row is already in the table with `wired=False` and its reason, and part 7 goes red the day that column stops being true. --- ## 6. ⚠️ Honest survivors, with their size Graded in part 9, so they are residuals and not surprises. | kind | what survives | size | |---|---|---| | **false positive** | an inverter physically unable to follow — saturating, thermally derating, a BMS refusing a charge — is called stale while telling the truth | one spurious **site stop**, behind `stale_input_s`, self-clearing the moment the register moves. It cannot drive the battery anywhere | | **miss** | a register frozen under a command that never travels 250 W is never detected, for 400 s or for ever | scoped: such a unit is one no per-unit sign check can speak about (`unit_sign_disagree_w` = 250 W) and one contributing at most 250 W to a loop whose floor is 700 W. Asserted, not argued | | **miss — the largest** | a dead publisher whose last values **alternate**, or a cache replaying two old samples, never starts the unchanged clock | **this gate detects a value that STOPPED; it cannot detect a value that is WRONG.** Nothing in this add-on can. TEL-06's firmware arrival stamp is the only layer that could | Recorded because it looks like a survivor and is not: a publisher failure that freezes **both** registers is caught on both units independently, on each unit's own command travel and each unit's own number. --- ## 7. Mutation ledger — 2026-09-06 MUTATION.md's rule honoured throughout: purge first (count printed, 0 remaining every run), tree mounted `:ro`, `python3 -B`, fingerprint before **and after** the revert. Clean tree: `app/freshness.py` `sha256:f40fa3e8819014ce`, 20,065 B · `app/inverter.py` `7e760c6e8cdfa5b7`, 30,778 B · `app/main.py` `7d7ff4ba0697ea64`, 238,535 B. Each mutant graded by `test_freshness.py` + `test_inverter.py`. Clean tree: `app/freshness.py` `sha256:f40fa3e8819014ce`, 20,065 B · `app/inverter.py` `7e760c6e8cdfa5b7`, 30,778 B · `app/main.py` `7d7ff4ba0697ea64`, 238,535 B. Each mutant graded by `test_freshness.py` + `test_inverter.py`. | # | mutation | file | Δ B | result | |---|---|---|---|---| | M0 | none (control) | freshness | +0 | **green** — the harness is not red on everything | | M1 | `reading()` returns `self.last` unconditionally | freshness | −24 | red, 3 named checks | | M2 | drop the command-travel term | freshness | −59 | red | | M3 | drop the magnitude term | freshness | −54 | **SURVIVED**, then red — the finding | | M4 | latch never holds | freshness | +13 | red, 3 named checks | | M5 | bound 90 s (slower than both consumers) | freshness | **+0** | red | | M6 | bound 10 s (under the physical floor) | freshness | **+0** | red | | M7 | `MOVE_W` 700 W | freshness | **+0** | red, on part 6's `config.yaml` pin | | M8 | the wiring in `_read_unit` removed | main | −5 | red, 3 named checks | | M9 | reset the unchanged clock on every arrival | freshness | −43 | red | | M10 | `standing_w()` drops `last_write` | inverter | −73 | red, and takes `test_inverter.py` with it | | M11 | `standing_at_change` refreshed on every arrival | freshness | +121 | ⚠️ **DISCOUNTED — the mutant does not parse** | | M11b | the same defect, written parseably | freshness | +121 | red, 3 named checks | | M12 | `BOUNDS["soc"].wired = True` — the table lies | freshness | −1 | red | | M13 | the wrong signal submitted (`inv.soc` for `inv.batt`) | main | +4 | red, and `test_inverter.py` too | | M14 | one `Freshness` shared by every unit | inverter | +29 | red | | M15 | latch cleared by a failed read | freshness | +31 | **SURVIVED**, then red | ⚠️ **M5, M6 and M7 are `+0 B`, MUTATION.md's risky delta.** All three are **reds**, the direction stale bytecode cannot fabricate, every run purged with the count printed, and the tree mounted `:ro` so it physically cannot hold a cache. ⚠️ **M11 IS NOT A RESULT AND IS NOT COUNTED.** Its edit dedents a statement into an `if` body, so the module raises `IndentationError` at line 312 and both files go red without a check being evaluated — verified with `ast.parse`, not assumed. **M11b** is the same defect written so it parses. **Six were re-run against the exact committed tree** (`test_freshness.py` `sha256:a62f3e1f1ac856cf`, 44,913 B), because the two closures, the `key=str` fix and the wall-clock hardening all landed after the sweep: M0 green, and M2, M3, M8, M11b and M15 all red on named checks. The other ten are red on checks in parts none of those edits touch. ### ⚠️ The one survivor, and it is the finding **M3, dropping the magnitude term, survived the first draft.** Every check was green, because in all of them a command that had *travelled* 250 W had also *ended up* above 250 W — **the fixture never separated the two terms, so one of them was decoration as far as the suite could tell.** The case that separates them is a site brought to **rest**: writes are serialised, so a register can reach its new value on the same read at which the *old* command is still standing; `standing_at_change` then captures 1300 W, the site is commanded to 0 W, and the travel term alone reads 1300 W of unfollowed command while the register sits truthfully at +5 W for a minute. Part 4 now grades exactly that, and M3 is red. Recorded in `MUTATION.md` as **habit 17** — twelfth instance of "a check whose DATA cannot reach the failure it names", and the general form: *ask of every conjunction whether there is a fixture in which each term is the only false one.* If not, the suite cannot tell an `AND` from an `OR`. ### ⚠️ And one process defect worth more than the ledger One mutation's container run overran a two-minute command budget and the wrapper was killed **between the edit and the revert**, leaving `app/freshness.py` mutated on disk. Nothing announced it; the next mutation's harness cheerfully fingerprinted and graded the mutant plus a second mutation. It was caught by comparing the tree fingerprint against the recorded clean one, restored, and verified back to byte and `sha256` before anything else ran. That is the same defect stale bytecode causes, from a different cause, and it is now `MUTATION.md` **habit 18**: *fingerprint after the revert, not only before the run.* --- ## 8. Suite `22/22 PASS` in `goodwe-addon-ci` (the CI image, deps from apk), tree mounted `:ro`, `PYTHONDONTWRITEBYTECODE=1`, `python3 -B`, bytecode purged before the run (count printed) and `0` caches present after it. `test_freshness.py` is the 22nd file and takes 19 s, 3.5 s of which is replaying the 30-day capture. `test_inverter.py`'s config-reader audit caught the new `option_default()` on its first run and named it — the table is signature-driven, exactly as its comment promises — so it is registered there rather than left outside the audit. ``` 22 test file(s) PASS test_alloc.py (25s) PASS test_maintenance.py (1s) PASS test_arbiter.py (0s) PASS test_mqtt.py (2s) PASS test_arbitrage.py (5s)PASS test_p1.py (7s) PASS test_bytecode.py (1s) PASS test_peakguard.py (61s) PASS test_ci_gate.py (1s) PASS test_price.py (1s) PASS test_clamp.py (1s) PASS test_price_forecast.py (0s) PASS test_control.py (0s) PASS test_static_tariff.py (4s) PASS test_freeze.py (34s) PASS test_tariff.py (6s) PASS test_freshness.py(16s)PASS test_translations.py (0s) PASS test_inverter.py (7s) PASS test_validate.py (6s) PASS test_loop.py (93s) PASS test_watchdog.py (53s) bytecode entries found and purged: 0 ; remaining: 0 ; cache after run: 0 ``` ### ⚠️ Part 3 touches the wall clock, so it MEASURES its own margin (CI-06) The freshness verdict runs on an injected monotonic clock and is deterministic. The sign watchdog inside `cycle()` cannot be injected. CI-06 is the record of what that costs — `test_peakguard.py` answered two checks with the wall clock, flaked at ~20 %, and cost a wrong merge diagnosis, because *a flaky check does not waste a re-run, it manufactures evidence for whichever hypothesis the reader already holds.* So the margin is asserted with its number: ``` gate closed after 0.61 s of WALL clock, against the 2 s watchdog window (3.3x margin) ``` If a loaded machine ever narrows that, the check fails **naming the number** — the difference between "the box was slow" and "the code is broken". --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-06 11:53:42 +02:00
The architect pass made SAFETY-37, TEL-06 and TEL-08 one systemic problem -
"a stale reading holds its last value and reads healthy" - and this builds the
single policy. Both consumers the card names are gated by one change, and
neither consumer needed a line: `SignWatchdog.evaluate` already CLEARS its
window on a None measurement and `circulating_w` already returns None for a
roster with a missing term. Neither could ever receive one. The gate is the
piece that was missing BETWEEN the shipped read and the shipped checks.

⚠️ ONE CRITERION IN THE CARD COULD NOT BE IMPLEMENTED AS WRITTEN, AND SAYING SO
IS THE MAIN CONTENT. The card says "only arrival separates them". At this layer
arrival cannot: `read_inputs()` polls Home Assistant's state cache about once a
second and HA answers 200 with a number whether or not the publisher behind the
entity is alive, so both members of the pair arrive identically for ever.
test_freshness.py part 1a BUILDS the arrival-age gate the card asks for and
grades it failing - the age is 0.0 s at every instant of both runs, a threshold
that can never fire, which is TEL-06's own defect one layer up. The half of the
rule that survives is honoured absolutely: nothing here reads a Home Assistant
timestamp, and the check asserts the module contains no `last_updated`.

So the criterion became: a reading is stale when it has not moved through a
change it was OBLIGED to follow.

    STALE  <=>  unchanged >= 35 s
                AND |standing| >= 250 W
                AND |standing - standing when the reading last changed| >= 250 W

Latched until a different value arrives. `Inverter.batt` becomes None - never a
held number - which is a missing input, which §11.1 rule 1 answers with 0 W.

WHY A CONJUNCTION AND NOT A THRESHOLD, MEASURED 2026-09-06. Replaying the
committed 30-day capture of this house's own 1 W power entity (485,841 samples
out of HA's recorder, where a row exists only on a CHANGE, so the gap between
rows IS the unchanged duration every consumer saw): the longest legitimate
unchanged run is 263.2 s, held at -899.0 W. A false-positive-free threshold
would have to exceed that, and 263 s is 3.5x too slow for a consumer that trips
at 75 s. There is no admissible threshold. Every one of those long runs is
under an UNMOVING load, which is what the travel term keys on.

THE BOUND, derived at both ends and graded against the shipped config.yaml.
Floor 17.0 s = 6.0 plant settle (NOTES §5.9, measured 2026-08-22) + 10.0
ESPHome publish (firmware/goodwe-master.yaml:181) + 1.0 read period
(run_control's asyncio.sleep(1)). Ceiling 75.0 s = min(circulation_hold_s 75,
unit_sign_disagree_s 85). Band [34.0, 37.5]; 35 is a CHOICE inside a DERIVED
band and the check says so - the first draft claimed the band held one integer
and went red on its own first run. MOVE_W is the LARGEST admissible value,
unit_sign_disagree_w, because below it the consumer cannot speak at all.

SoC is STATED, NOT WIRED, and BOUNDS carries it with wired=False and the reason.
Its obligation term is energy through the pack, not command travel - a rested
pack holds a percent for hours under a 0 W command - and K needs a usable
capacity nobody has measured. That is TEL-08's; the record says so.

⚠️ BEHAVIOUR CHANGES AT THE SHIPPED ROSTER OF ONE, and it is named rather than
hidden: a FROZEN register now stops the site where it used to read as a healthy
measurement for ever. That is HW-01, which was a one-inverter site. A healthy
install is unchanged, asserted over sixty reads of a dithering register under a
reversing command. A false verdict fails toward 0 W behind the existing
stale_input_s hold and clears itself on the register's next move.

⚠️ BOTH UNITS IN EVERY TWO-INVERTER FIXTURE ARE INVERTER A with different
numbers passed in; inverter B does not physically exist (~2026-09-12).

MUTATION: 16 mutations against a purged, :ro, python3 -B tree, fingerprinted
before and after every revert. One survivor, and it is the finding - dropping
the magnitude term left every check green because no fixture separated "the
command travelled 250 W" from "the command ended up above 250 W". Closed, and
recorded as MUTATION.md habit 17. A second process defect is habit 18: a
mutation run that timed out between the edit and the revert left the module
mutated on disk and nothing announced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-06 12:35:29 +02:00
SAFETY-37: correct a wall-clock tick and a false size claim
checks / unit (pull_request) Successful in 5m32s
8e241a3243
Two blocking findings from review, both in prose, both corrected at every
site.

1. "trips on tick 7" was not what the fixture prints. It prints 11, five
   runs out of five, and the developer's own report says 11. ⚠️ But the
   deeper problem is that the number should never have been quoted: the
   tick is counted on the WALL CLOCK inside cycle() (hold_s 2 s, ticks
   0.20 s apart), so it is load-dependent - 7 is what a box slow enough
   to stretch each tick to ~0.29 s would print - and nothing pins it.
   The DECISIONS transcript now says 11 because it is a transcript of a
   run; the CHANGELOG stops quoting a tick at all and states the
   checkable thing instead: it trips without the gate and never with it,
   which part 3 grades.

2. ⚠️ SURVIVOR 2's SIZE CLAIM WAS FALSE FOR ONE OF THE TWO CONSUMERS THIS
   CARD EXISTS TO PROTECT. It read "one contributing at most 250 W to a
   loop whose reporting floor is 700 W" at three sites. That bounds the
   COMMAND. Once the register is frozen its REPORTED value is unrelated
   to the command - which is exactly HW-01's shape: commanded ~0 W,
   register stuck on the last large number.

   Measured in review: a unit frozen at -1200 W beside a healthy +900 W
   sibling yields circulating_w = 1800 W against the 700 W floor, 2.6x
   OVER, with no gate armed.

   So the scoping is TRUE for the per-unit sign check and FALSE for
   circulation. Corrected to say exactly that, and the circulation
   consumer's exposure below MOVE_W is now recorded as a KNOWN GAP rather
   than a covered one.

   It survived because part 9's S2 check asserts only `not stale` and
   part 4 pairs the frozen unit with a RESTING sibling
   (circulating_w([A_FROZEN_W, 0.0]) == 0.0) - neither grades the size
   sentence.

No behaviour change. Full suite: 22 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin merged commit c8dcb38a95 into release/1.0 2026-09-06 13:06:27 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#57