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
9 changed files with 1866 additions and 8 deletions
+33
View File
@@ -31,6 +31,39 @@ guessing a boundary; saying so is more useful than a guess.
## Unreleased
⚠️ **A PER-INVERTER POWER READING THAT HAS STOPPED FOLLOWING ITS OWN COMMAND IS
NOW `None`, NOT A HELD NUMBER ([[SAFETY-37]]).** `Inverter.batt` (register
`0x0518`) goes through a freshness gate: unchanged for >= 35 s *while* the
standing command is over 250 W *and* has travelled over 250 W since the reading
last moved. A stale reading is a **missing input**, which §11.1 rule 1 already
answers with 0 W and keeps commanding.
**This is a behaviour change at the shipped roster of one, and it is deliberate.**
Before it, a frozen power register read as a healthy measurement for ever - which
is [[HW-01]], where a value held for 6 h 25 m while the pack drained to its floor
and every detector read healthy. A healthy install is byte-identical: sixty reads
of a dithering register under a reversing command all reach the consumers
unchanged, asserted rather than assumed. A false verdict fails toward 0 W, behind
the existing `stale_input_s` hold, and clears itself the moment the register
reports a different value.
At two or more inverters it also removes two false alarms that had no gate at
all: [[SAFETY-30]]'s per-unit sign check no longer trips on a frozen reading
(measured: it trips without the gate and NEVER with it - the tick index is
deliberately not quoted, because it is counted on the wall clock inside
`cycle()` and is therefore load-dependent; the checkable statement is the
presence or absence of the trip, which part 3 grades), and
[[SAFETY-31]]'s circulating-power sensor reports `None` instead of a loop that is
not happening.
**No new configuration key, and no schema change.** The bound is derived from
`circulation_hold_s` and `unit_sign_disagree_*`, which the checks read out of
`config.yaml` at both ends - so moving either default takes the gate red rather
than leaving it silently too slow. `DECISIONS/SAFETY-37-freshness-policy.md`
carries the derivation, what [[TEL-06]] and [[TEL-08]] still owe, and three
named survivors - including the largest: **this detects a value that STOPPED, not
a value that is WRONG.**
⚠️ **`slew_w` IS A RATIO TO THE RAIL NOW, AND ITS DEFAULT CHANGED FROM 1000 TO 0
([[SAFETY-39]]).** `0` does not mean "off" and does not mean "unset" - it means
**follow `max_w`**, exactly as `integrator_max_w: 0` already does, and resolves
@@ -0,0 +1,376 @@
# SAFETY-37 — the freshness policy, and the one sentence in the card that had to be rewritten
**Status: DECIDED 2026-09-06.** A per-unit reading that has stopped following its
own command is reported to its consumers as **`None`**, never as a held number.
The verdict is a **conjunction**, not an age threshold, and this document records
why an age threshold is not available at this layer — which is the one place the
card's wording could not be implemented as written.
Filed with SAFETY-37. Card `6a9a7eda7932387c3acbd700`, scope widened by the
architect pass of 2026-09-06 to carry the single policy for [[TEL-06]] and
[[TEL-08]] as well.
Every figure below was measured or read out of this repository on **2026-09-06**
and is re-derived by `test_freshness.py` on every run. Where a number is carried
forward from an earlier measurement, the section and the date it was taken are
given at the number.
---
## 0. The three faces, and which one this card built
| where | the defect | this card |
|---|---|---|
| **Layer 1** — `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` | the ingest-age veto tests the **VALUE** of `sensor.goodwe_p1_sample_age`, not its arrival. A dead publisher freezes it at a healthy number and the veto can never fire | **not touched.** It is firmware, behind a flash. §5 says what it owes |
| **The add-on** — `app/hass.py:69-82` | returns a held value as a value. Nothing separates "read 40 %" from "still says 40 % because nothing updated it" | **built here** |
| **The evidence** — [[HW-01]] | SoC held 40 % for 6 h 25 m while the pack drained to its floor, every detector reading healthy | the case the policy is answerable to |
The two consumers the card names — SAFETY-30's per-unit sign check and
SAFETY-31's circulating-power sensor — both read `Inverter.batt` (register
`0x0518`) and **neither had a freshness gate of any kind**. Both already do the
right thing with a `None`: `watchdog.SignWatchdog.evaluate` **clears** its window
on a `None` measurement, and `circulation.circulating_w` returns `None` for a
roster with a missing term, which clears that window too. **Neither could ever
receive one.** The gate is the piece that was missing *between* the shipped read
and the shipped checks, which is why the change to each consumer is zero lines.
---
## 1. ⚠️ The sentence that had to be rewritten, and why saying so is the finding
The card says: *"A pack legitimately resting at 40 % and a dead sensor reading
40 % are the same number; **only arrival separates them**."*
**For a signal this add-on polls out of Home Assistant's state cache, arrival
does not separate them, and cannot.** `read_inputs()` runs about once a second
(`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), and while the
entity exists Home Assistant answers `200` with a number **whether or not the
publisher behind it is alive**. So both members of the pair arrive, identically,
for ever. `test_freshness.py` part 1a builds an arrival-age gate and grades it:
the age since arrival is `0.0 s` at every instant of **both** runs. A threshold
on it is a threshold that can never fire — which is TEL-06's own defect rebuilt
one layer up.
The other half of the card's rule survives intact and is honoured absolutely:
> ⚠️ Home Assistant's `last_updated` does **not** move when a value is rewritten
> unchanged, so it cannot be the source of truth.
Neither does `last_changed`, and this project measured on the ENV-01 rig (70 s of
a frozen meter) and independently against the live house that `last_reported`
does not advance on a repeat on either serialiser either —
`p1.HomeWizardLocalSource`'s docstring records both. **`app/freshness.py` reads
no Home Assistant timestamp at all.** Every clock in the verdict is
`time.monotonic()` inside this process, stamped at the instant an arrival carried
a different number. That is what "arrival-stamped" can mean here, and it is
graded (part 1a asserts the module's body contains no `last_updated`).
**So the criterion was rewritten rather than approximated:** *a reading is stale
when it has not moved through a change it was obliged to follow.* The obligation,
not the age, is what separates the pair.
---
## 2. The verdict
```
STALE ⟺ unchanged for ≥ 35.0 s
AND |standing command now| ≥ 250 W
AND |standing command now − standing command when the reading
last changed| ≥ 250 W
```
Latched: **only an arrival carrying a different number clears it.**
`0x0518` is the inverter reporting **itself** — r = 0.998 with battery power
against r = 0.09 with the real meter (`goodwe/NOTES.md` §3.3) — and it tracks its
own command: command and actual agreeing within 4 W once settled (§5.10,
measured 2026-08-22), the register dithering ±1 W while holding (§5.11, same
day). A unit told to move 250 W and still answering the same integer watt is not
tracking.
### 2.1 Why a bare "unchanged for N seconds" threshold is not available
**Measured, not feared.** Replaying the committed 30-day capture of this house's
own 1 W-resolution power entity — `scenarios/p1-net-30d-2026-08/`, 485,841
samples straight out of Home Assistant's recorder, where a row exists only when
the state string **changed**, so the gap between two rows *is* the unchanged
duration every consumer saw:
| | |
|---|---|
| longest unchanged run over 30 days | **263.2 s** (held −899.0 W, 2026-08-11 17:04) |
| runs ≥ 35 s | 10 of 485,840 (0.0021 %) |
| runs ≥ 17 s | 386 of 485,840 (0.0794 %) |
A real power figure at 1 W resolution held **one value for four and a half
minutes with nothing whatever wrong**. So an unchanged threshold free of false
positives must be over 263 s — and 263 s is **3.5× too slow** to protect a
consumer that trips at 75 s. There is no admissible threshold. The conjunction
is not a refinement; without it the card has no implementable verdict.
⚠️ **That is the METER's register, not `0x0518`, and it is used as a surrogate
and named as one.** No 30-day capture of the inverter's own power exists — it is
not in the recorder export. The single thing this measurement is used for is the
statement above: that the legitimate unchanged duration of a power entity in this
house is *minutes*. It is not used to set the bound.
⚠️ **And every one of those long runs happened under an unmoving load** — the
worst is 263 s of a steady −899 W export, a house doing one thing. That is why
the conjunction keys on command travel: none of them arms it.
### 2.2 Why the magnitude term exists — a surviving mutation put it there
Deleting `abs(standing_w) < move_w` from `_grade()` left **every check in the
first draft green**, because in all of them a command that had *travelled*
250 W had also *ended up* above 250 W. The case that separates them is a site
being brought to **rest**: the write path is serialised, so a register can reach
its new value on the same read at which the *old* command is still standing —
`standing_at_change` then captures 1300 W, the site is commanded to 0 W, and the
travel term alone reads 1300 W of unfollowed command for as long as the site
rests. It would call a perfectly truthful +5 W reading dead. The magnitude term
is what says *a site at rest is not owed movement*, and part 4 now grades it.
**The hole that term leaves cannot mislead either consumer**, which is why it is
the right trade: a unit commanded under 250 W is one `watchdog.disagrees()` is
`False` for whatever the measurement says, and a frozen term beside a live unit
that is itself resting nets to **zero** circulating power rather than to a false
alarm. Both halves are asserted, not argued.
---
## 3. The bounds — one per signal, each with its derivation
⚠️ **One constant for both signals would be useless on SoC or trigger-happy on
power**, which is the third clause of the policy. `freshness.BOUNDS` carries them
as data with a `wired` column, and `test_freshness.py` part 7 walks the AST of
every shipped module to check that column tells the truth.
### 3.1 `batt` — `0x0518`, S16, 1 W, published every 10 s — **WIRED**
**Floor, 17.0 s** — the fastest a healthy register can be *obliged* to have
answered. Three terms, each read out of this repository:
| | | source |
|---|---|---|
| 6.0 s | the plant | `goodwe/NOTES.md` §5.9, measured 2026-08-22 with the poll temporarily at 1 s: 26 W at +1.43 s, 469 W (94 %) at +3.34 s, settled 507 W at +6.05 s |
| 10.0 s | the publisher | `firmware/goodwe-master.yaml:181`, `update_interval: 10s` on the `modbus_controller` carrying both `0x0518` and `0x050E` |
| 1.0 s | this add-on's read period | `main.run_control()`'s `await asyncio.sleep(1)` |
**Ceiling, 75.0 s** — the tighter of the two consumers, both read out of the
shipped `config.yaml`: `circulation_hold_s` **75** (line 512) and
`unit_sign_disagree_s` **85** (line 451). A gate slower than its consumer hands
over the frozen number and explains afterwards.
**Chosen: 35.0 s.** The admissible band `[2 × 17.0, 0.5 × 75.0] = [34.0, 37.5]`
contains four integers, and **the band is what is derived — 35 is a choice inside
it**, deliberately not 30 (that is `validate.FIRMWARE_INGEST_VETO_S`, and would
read as a borrowed constant nobody re-derived) and not 60 (round). Part 6
computes both ends **from `config.yaml`** and goes red if an operator default
moves: drop `circulation_hold_s` to 60 and this fails.
**`MOVE_W` = 250 W** is the **largest** admissible value, not a sensitivity
preference. The gate must arm everywhere a stale reading could mislead a
consumer; the earliest either consumer speaks is `unit_sign_disagree_w`, 250 W
(config.yaml:450), because below that `watchdog.disagrees()` is `False` whatever
the measurement says. Taking the ceiling of the admissible range minimises
spurious arming. It is pinned to `config.yaml` by part 6, not to a literal.
### 3.2 `soc` — `0x050E`, U16, 1 %, published every 10 s — **STATED, NOT WIRED**
SoC **cannot use the conjunction above.** A pack resting at its floor holds one
percent for hours with nothing wrong — the card's own example — and the standing
command says nothing about it, because 0 W is exactly what a rested pack is
given. The obliged movement for SoC is **energy, not command travel**: one
percent of pack A is ≈ **153.6 Wh** (15.36 kWh nominal, `goodwe/NOTES.md` §1.1b's
table — ⚠️ **NOMINAL and FROM DATASHEET**, not a measured usable capacity), so at
pack A's 5000 W ceiling one percent takes at least **110 s**, and at a realistic
1000 W about **553 s**.
So the bound is *"SoC unchanged while ∫|battery power| dt since its last change
exceeded K × 153.6 Wh"*, and **K cannot be chosen from anything written down
today**. Wiring it with a guessed K would be a gate that fires on a rested pack,
which is the hazard, not the fix. `Freshness` takes its obligation term from the
caller precisely so an energy integral can be handed in where command travel is
handed in for `batt` — TEL-08 needs no second mechanism.
---
## 4. What changes on a running install
| | before | after |
|---|---|---|
| healthy register, N=1 | value reaches consumers | **identical** — 60 reads of a dithering register under a reversing command, asserted in part 5 |
| healthy register, N≥2 | value reaches consumers | identical |
| **frozen register, N=1** | reads as a healthy measurement, for ever | **site stops**: `inputs_ok` False → `stale_input_s` hold → 0 W, and keeps commanding |
| **frozen register, N≥2** | per-unit sign check false-trips at 85 s and parks a healthy inverter; circulation sensor reports a loop that is not happening | both consumers get `None`, both windows **clear** |
⚠️ **The N=1 row is a behaviour change on the roster that is running in a house
today, and it is named rather than hidden.** It is also the point of the card:
HW-01 was a one-inverter site. The direction it fails in is the safe one — a
false verdict commands 0 W and keeps commanding (§11.1 rule 1), behind the
existing `stale_input_s` hold, and clears itself on the register's next move.
The counterfactual is run, not argued. `test_freshness.py` part 3 drives real
`cycle()` calls at N=2 with A's register frozen at −1200 W and B's live at
+900 W, the command travel produced by the shipped `compute()` and allocator:
```
standing on A: [0, 445, 890, 1335, 1335, ...] (the shipped control law)
gated : sign trip tick=None circulating_w=[1800, 1800, 1800, None, None, ...]
counterfact : sign trip tick=11 circulating_w=[1800, 1800, 1800, 1800, 1800, ...]
```
---
## 5. What TEL-06 and TEL-08 still owe
**[[TEL-06]] — layer 1, Blocked, and nothing in this card can reach it.** The
ESPHome veto tests the *value* of `sensor.goodwe_p1_sample_age`. This policy
lives in the add-on, on the other side of a flash, and cannot help. What TEL-06
owes is the same mechanism **inside the firmware**: a `millis()` stamp taken when
the setpoint/age entity is *written*, and a veto on `millis() − last_write_ms`
rather than on the published number. It is narrowed to that one substitution —
the policy question ("arrival, not value") is answered here; the remaining work
is a firmware stamp and a flash, which is a supervised human step.
**[[TEL-08]] — stale SoC.** It owes exactly three things, and no mechanism:
1. a **measured** usable capacity for pack A (or a defensible bound on it) — the
153.6 Wh per percent above is nominal and from the datasheet;
2. the **energy integral** since the reading last changed;
3. the constant **K** — how many percent-equivalents of throughput must pass
before an unmoved SoC is a fault.
It should consume `freshness.Freshness` with `BOUNDS["soc"]` filled in, not
restate the policy. The row is already in the table with `wired=False` and the
reason, and part 7 goes red the day that column stops being true.
**⚠️ Neither of them, and not this card, closes HW-01's whole class.** See §6.
---
## 6. Known survivors, with their size
Graded in `test_freshness.py` part 9, so they are residuals rather than
surprises.
| # | kind | what survives | size |
|---|---|---|---|
| 1 | **false positive** | an inverter physically unable to follow — saturating, thermally derating, or a BMS refusing a charge — is declared stale while telling the truth | one spurious **site stop**, behind `stale_input_s`, self-clearing the moment the register moves. It cannot drive the battery anywhere |
| 2 | **miss** | a register frozen under a command that never travels 250 W is never detected, for 400 s or for ever | ⚠️ scoped for ONE consumer, NOT both, and the earlier claim here was wrong. TRUE for the per-unit sign check: no such check can speak about that unit (`unit_sign_disagree_w` = 250 W). **FALSE for circulation.** "Contributing at most 250 W" bounds the COMMAND; once the register is frozen its REPORTED value is unrelated to the command, which is exactly HW-01's shape - commanded ~0 W, register stuck on the last large number. Measured: a unit frozen at −1200 W beside a healthy +900 W sibling yields `circulating_w` = **1800 W against the 700 W floor, 2.6× over**, with no gate armed. Found in review; part 9's S2 check asserts only `not stale` and part 4 pairs the frozen unit with a RESTING sibling, so neither graded the sentence |
| 3 | **miss — the largest** | a dead publisher whose last values **alternate**, or a cache replaying two old samples, never starts the unchanged clock at all | **this gate detects a value that STOPPED; it cannot detect a value that is WRONG.** Nothing in this add-on can. TEL-06's arrival stamp inside the firmware is the only layer that could |
Recorded because it looks like a survivor and is not: a publisher failure that
freezes **both** registers is caught on both units independently, on each unit's
own command travel and each unit's own number.
---
## 7. Mutation record — 2026-09-06
`tools/mutation_run.sh`'s rule, honoured on every run: purge first (count printed,
`0` remaining), tree mounted `:ro`, `python3 -B`, fingerprint the tree, **and
fingerprint again after the revert**. Clean tree `app/freshness.py`
`sha256:f40fa3e8819014ce` / 20,065 B, `app/inverter.py` `7e760c6e8cdfa5b7` /
30,778 B, `app/main.py` `7d7ff4ba0697ea64` / 238,535 B. Each mutant graded by
`test_freshness.py` + `test_inverter.py`.
| # | mutation | file | Δ B | result |
|---|---|---|---|---|
| M0 | none (control) | freshness | +0 | **green** — the harness is not red on everything |
| M1 | `reading()` returns `self.last` unconditionally | freshness | −24 | red, 3 named checks |
| M2 | drop the command-travel term | freshness | −59 | red |
| M3 | drop the magnitude term | freshness | −54 | **SURVIVED**, then red — §2.2. This is the finding |
| M4 | latch never holds | freshness | +13 | red, 3 named checks |
| M5 | bound 90 s (slower than both consumers) | freshness | **+0** | red |
| M6 | bound 10 s (under the physical floor) | freshness | **+0** | red |
| M7 | `MOVE_W` 700 W | freshness | **+0** | red, on part 6's `config.yaml` pin |
| M8 | the wiring in `_read_unit` removed | main | −5 | red, 3 named checks |
| M9 | reset the unchanged clock on every arrival (value-stamped) | freshness | −43 | red |
| M10 | `standing_w()` drops `last_write` | inverter | −73 | red, and takes `test_inverter.py` with it |
| M11 | `standing_at_change` refreshed on every arrival | freshness | +121 | ⚠️ **DISCOUNTED — the mutant does not parse** |
| M11b | the same defect, written parseably | freshness | +105 | red |
| M12 | `BOUNDS["soc"].wired = True` — the table lies | freshness | −1 | red |
| M13 | the wrong signal submitted (`inv.soc` for `inv.batt`) | main | +4 | red, and `test_inverter.py` too |
| M14 | one `Freshness` shared by every unit | inverter | +29 | red |
| M15 | latch cleared by a failed read | freshness | +31 | **SURVIVED**, then red — see below |
⚠️ **M5, M6 and M7 are `+0 B`, which MUTATION.md names as the risky delta.** All
three are **reds**, which is the direction stale bytecode cannot fabricate, and
every run purged with the count printed and graded a `:ro` tree that physically
cannot hold a cache.
⚠️ **M11 IS NOT A RESULT AND IS NOT COUNTED.** Its edit dedents a statement into
the middle of an `if` body, so the module raises `IndentationError` at line 312
and both files go red without a single check being evaluated. Verified by
`ast.parse` rather than assumed. **M11b** is the same defect written so it
parses, and it is a real red.
### The two survivors, and what each one taught
**M3, the magnitude term** — see §2.2. Two terms of a conjunction that move
together in every fixture are one term as far as the checks are concerned.
Recorded as `MUTATION.md` **habit 17**, twelfth instance of "a check whose DATA
cannot reach the failure it names".
**M15, the latch cleared by a failed read** — found only because the sweep went
looking for what the checks would NOT see. No fixture had ever injected a read
failure *while latched*, and it matters: a stale verdict stops the site, the site
is then commanded 0 W, and one unreachable-Home-Assistant read would drop the
latch into a state where the conjunction is no longer armed — so the held
−1200 W would come back to the consumers on the very next successful read. Part 4
now grades exactly that and M15 is red.
### And one process defect worth more than the ledger
One mutation's container run overran a two-minute command budget and the wrapper
was killed **between the edit and the revert**, leaving `app/freshness.py`
mutated on disk. Nothing announced it; the next mutation's harness cheerfully
fingerprinted and graded the mutant *plus* a second mutation. It was caught by
comparing the tree fingerprint against the recorded clean one, restored, and
verified back to byte and `sha256` before anything else ran. Same defect as stale
bytecode, different cause. Recorded as `MUTATION.md` **habit 18**: *fingerprint
after the revert, not only before the run.*
### A third, in this file's own fixture
M2 originally reported "red" with **no named check behind it**. The mutant makes
the FRESH run stale, so `test_freshness.py`'s own diagnostic
`print(f"… {sorted(set(_fresh_read))}")` raised `TypeError` comparing `None` with
a float **before check one ran**. MUTATION.md's reporting rule is *"which suite
went red, and on which named check"*, and a print that can crash first destroys
exactly that. Sorted with `key=str` now; M2 reports its check.
### Re-verified at the committed head
The 16-row sweep above graded `test_freshness.py` as it stood when each mutation
ran; the two closures (M3's part-4 check, M15's failed-read check), the `key=str`
fix and the wall-clock hardening all landed afterwards. So six mutations were
re-run against **the exact tree that is committed** —
`test_freshness.py sha256:a62f3e1f1ac856cf`, 44,913 B:
| # | result at head |
|---|---|
| M0 control | **green** |
| M2 drop the command-travel term | red, on part 1's "FRESH AND UNCHANGED is not stale" |
| M3 drop the magnitude term | red, on part 4's magnitude check |
| M8 wiring removed | red, on three named checks in parts 2 and 3 |
| M11b `standing_at_change` drifts | red, on three named checks in part 1 |
| M15 latch cleared by a failed read | red, on part 4's failed-read check |
The other ten are unaffected by those edits: each is red on a check in parts 1,
2, 4, 6, 7 or 9 that none of them touches, and a mutation red on one check
cannot be made green by adding another.
### The wall clock, which part 3 cannot avoid and therefore measures
The freshness verdict runs on an injected monotonic clock and is deterministic.
The sign watchdog inside `cycle()` cannot be injected, so part 3 runs against the
real one — which is CI-06's failure mode exactly (`test_peakguard.py` answered
two checks with the wall clock, flaked at ~20 %, and cost a wrong merge
diagnosis). So the margin is measured and asserted rather than assumed:
gate closed after 0.61 s of WALL clock, against the 2 s watchdog window (3.3x margin)
If a loaded machine ever narrows that, the check fails **naming the number**,
which is the difference between "the box was slow" and "the code is broken".
+36
View File
@@ -295,6 +295,42 @@ the P1 sample already went stale. Size the pair together: the ESP32's own
watchdog commands 0 W after ~30 s of silence from this add-on regardless, and
that layer is unaffected by either option.
#### How long a FROZEN inverter power register takes to reach 0 W
A third clock, and it is a different failure from the one above: the entity is
still there and still answering — it has simply stopped changing. Before
SAFETY-37 that read as a healthy measurement for ever, which is what happened on
2026-09-02 (HW-01): a value held for 6 h 25 m while the pack drained to its
floor, and every detector read healthy throughout.
`Inverter.batt` — the inverter's own AC power, register `0x0518` — is now
withheld from the control path when **all three** of these are true:
| | |
|---|---|
| the reading has not changed for | **35 s** |
| **and** the command standing on that inverter is over | **250 W** |
| **and** that command has travelled, since the reading last moved, over | **250 W** |
All three, because a converged site legitimately repeats one power figure for
minutes — measured at up to **263 s** over 30 days of this house's own meter —
and a site at rest is not owed movement. It is only when the controller has
*asked the inverter to move* and the register has not answered that the reading
is treated as missing.
**A withheld reading is a MISSING INPUT**, so it joins the `stale_input_s` clock
above: 35 s to withhold, then 15 s of hold, then 0 W. It clears itself the
instant the register reports a different value — one watt is enough, and this
register dithers ±1 W while holding — so a working inverter clears it on its
next 10 s publish. There is **no option to configure**: the 35 s is derived from
the ESPHome publish interval and the two watchdog windows it has to stay inside,
and the add-on's own checks refuse a value that does not.
⚠️ **What this does NOT catch: a register whose value is WRONG rather than
STOPPED**, including a dead publisher whose last two samples happen to
alternate. Detecting that needs an arrival stamp inside the ESP32 firmware
itself, which is TEL-06.
There is **no fallback to an inverter-side power figure**, deliberately. The
inverter's own AC power tracks its battery almost perfectly and the real meter
hardly at all, so a controller that failed over to it would be regulating
+40
View File
@@ -546,6 +546,46 @@ Cheap habits fall out of it, and all of them are now used in this repository:
instance of habit 4 in this file, and the second one in an instrument
rather than in a check.**
17. ⚠️ **TWO TERMS OF A CONJUNCTION THAT MOVE TOGETHER IN EVERY FIXTURE ARE
ONE TERM AS FAR AS THE CHECKS ARE CONCERNED - TWELFTH INSTANCE OF HABIT 4,
and the third found by an author's own battery rather than by review.**
SAFETY-37's freshness gate is `unchanged >= 35 s AND |standing| >= 250 W
AND |travel| >= 250 W`. Deleting the MIDDLE term left every check in the
first draft green, because in all of them a command that had *travelled*
250 W had also *ended up* above 250 W: the fixture never separated the two,
so one of them was decoration as far as the suite could tell. The case that
separates them is a site being brought to REST - the register reaches its
new value on the same read at which the OLD command is still standing, so
the travel term alone reads 1300 W of unfollowed command while the site
sits truthfully at +5 W. Ask of every conjunction: **is there a fixture in
which each term is the ONLY one that is false?** If not, the suite cannot
tell an AND from an OR.
18. ⚠️ **AN UNVERIFIED REVERT IS THE SAME DEFECT AS STALE BYTECODE, AND TWO
HARNESSES SHARING ONE BACKUP PATH IS THE WORST VERSION OF IT.** SAFETY-37's
sweep hit this twice, in ascending order of nastiness.
* A mutation's container run overran a two-minute command budget and the
wrapper was killed **between the edit and the `cp` that puts the file
back**, leaving `app/freshness.py` mutated on disk. Nothing announced it;
the next mutation's harness cheerfully fingerprinted and graded the mutant
*plus* a second mutation.
* Then two copies of the sweep were started - a scheduling mistake, not a
tool defect - and the harness kept its backup at the FIXED path
`/tmp/mut.bak`. One copy saved `app/main.py` there; the other restored it
**over `app/freshness.py`**. The result was a 238 KB `freshness.py` that
still parsed as Python, plus `main.py` left holding a live mutation. No
error, no warning, and the only reason it was noticed is that the tree
fingerprint did not match the recorded clean one.
Two rules, and the first is the cheap one:
* **Fingerprint after the revert, not only before the run.**
`tools/mutation_run.sh` prints the tree it is about to grade for exactly
this reason; the revert deserves the same treatment, and a mismatch is a
stop-everything result rather than a note.
* **A harness's scratch file must be unique per invocation** (`mktemp`), so
that two of it cannot silently overwrite each other's work. A fixed path
in a tool that edits source is a shared mutable global with a filename.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
+385
View File
@@ -0,0 +1,385 @@
"""SAFETY-37 - a reading that holds its last value and reads healthy.
THE DEFECT, in one sentence: `hass.number()` returns a held value as a value,
and nothing anywhere in this add-on distinguishes "the inverter reports 40 %"
from "the entity still says 40 % because nothing has updated it since Tuesday".
The architect pass of 2026-09-06 put three cards on one mechanism. This module
is that mechanism; the other two are its consumers.
LAYER 1 `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` - the
(TEL-06) ingest-age veto tests the VALUE of `sensor.goodwe_p1_sample_age`.
A dead publisher leaves the entity frozen at a healthy number and
the veto can never fire. NOT FIXED HERE - it is firmware, it is on
the other side of a flash, and nothing in this module can reach
it. What it owes is written at the bottom of this docstring.
THIS ADD-ON `app/hass.py:69-82`, consumed by SAFETY-30's per-unit sign check
(SAFETY-37) and SAFETY-31's circulating-power sensor. Both read `Inverter.batt`
(register 0x0518) and NEITHER had a freshness gate of any kind.
THE EVIDENCE HW-01, 2026-09-02: SoC held 40 % for 6 h 25 m while the pack
drained to its floor. Every detector read healthy throughout.
⚠️ AT N>=2 EVERY ONE OF THOSE READINGS DOUBLES, which is why this card is the
one that had to build it: the per-unit sign check and the circulation sensor
only exist at N>=2, and both are fed straight from `Inverter.batt`.
WHAT CANNOT BE THE SOURCE OF TRUTH, AND IT IS NOT A DETAIL
==========================================================
Home Assistant's `last_updated` DOES NOT MOVE when a value is rewritten
unchanged. Neither does `last_changed`, and this project measured that
`last_reported` does not advance on a repeat on either serialiser either - see
`p1.HomeWizardLocalSource`'s docstring, which records the ENV-01 rig measurement
over 70 s of a frozen meter and an independent one against the live house.
So a value-side timestamp out of Home Assistant is a "time since the number
changed" sensor wearing an arrival sensor's name. Thresholding it is how a
detector ends up unable to fire, which is TEL-06's defect exactly. That trap
produced two false diagnoses on 2026-09-05, both by a reader who already knew
about it, which is why it is written here in full rather than referenced.
⚠️ AND THE CONVERSE TRAP IS THE ONE THIS MODULE HAD TO SOLVE. The add-on polls
Home Assistant's state cache roughly once a second (`run_control()`'s
`await asyncio.sleep(1)` bounds the loop body and `read_inputs()` runs every
pass). While the entity exists, HA answers 200 with a number whether or not the
publisher behind it is alive. So AN ARRIVAL AT THE ADD-ON IS NOT AN ARRIVAL FROM
THE INVERTER, and an arrival age alone cannot see HW-01 at all.
What IS arrival-stamped here, and what makes this module honest, is the clock
the verdict is measured on: `changed_mono` is the MONOTONIC INSTANT OF THE
ARRIVAL THAT LAST CARRIED A DIFFERENT NUMBER. Every term below is a fact about
arrivals this process observed, on its own clock. Nothing is read out of a Home
Assistant timestamp, and nothing would change if Home Assistant stopped
publishing timestamps altogether.
THE VERDICT, AND WHY IT IS A CONJUNCTION
========================================
A bare "unchanged for N seconds" threshold is not available, and that is
MEASURED rather than feared. Over the committed 30-day capture of this house's
own 1 W-resolution power entity - `scenarios/p1-net-30d-2026-08/`, 485,841
samples straight out of Home Assistant's recorder, where a row exists only when
the state string CHANGED, so the gap between two rows IS the unchanged duration
every consumer saw:
longest unchanged run, 30 days 263.2 s (held -899.0 W, 2026-08-11 17:04)
runs >= 35 s 10 of 485,840 (0.0021 %)
runs >= 17 s 386 of 485,840 (0.0794 %)
Measured on 2026-09-06 by replaying that capture. A real power figure at 1 W
resolution held ONE value for four and a half minutes with nothing whatever
wrong. ⚠️ That is the METER's register, not 0x0518, and it is named as a
surrogate rather than passed off as the thing: no 30-day capture of 0x0518
exists, because that register is not in the recorder export. What it does
establish - and this is all it is used for - is that the LEGITIMATE unchanged
duration of a power entity in this house is minutes, so an unchanged threshold
alone must be minutes, and minutes is three times too slow to protect consumers
that trip at 75 s and 85 s. The conjunction is not a refinement; without it
there is no admissible threshold at all.
⚠️ AND EVERY ONE OF THOSE LONG RUNS HAPPENED UNDER AN UNMOVING LOAD. That is
the way out, and it is the same shape `freeze.looks_frozen()` already uses for
the meter (unchanged AND the command pinned at the rail). A reading is stale
when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
STALE <=> unchanged for >= UNCHANGED_S
AND |standing command now| >= MOVE_W
AND |standing command now - standing command when the reading
last changed| >= MOVE_W
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
register dithering +/-1 W while holding (§5.11, same day). A unit told to move
250 W and still answering the same integer watt cannot be tracking.
THE BOUNDS - ONE PER SIGNAL, EACH WITH ITS DERIVATION
=====================================================
⚠️ ONE CONSTANT FOR BOTH SIGNALS WOULD BE USELESS ON SoC OR TRIGGER-HAPPY ON
POWER, which is the third clause of the policy. They are separated here, and
only one of them is WIRED - see `BOUNDS` below and the note on SoC.
`batt` / 0x0518, S16, 1 W, published every 10 s
----------------------------------------------
LOWER BOUND, 17.0 s, the fastest a healthy register can be OBLIGED to have
answered. Three terms, each read out of this repository on 2026-09-06:
6.0 s the plant. goodwe/NOTES.md §5.9, measured 2026-08-22 with the poll
temporarily at 1 s and a 0 -> +500 W step: 26 W at +1.43 s, 469 W
(94 %) at +3.34 s, settled 507 W at +6.05 s.
10.0 s the publisher. `firmware/goodwe-master.yaml:181`,
`update_interval: 10s` on the `modbus_controller` that carries both
0x0518 and 0x050E. A command that moves right after a poll is not
visible until the next one.
1.0 s this add-on's own read period - `run_control()`'s
`await asyncio.sleep(1)`, which bounds the loop body that calls
`read_inputs()`.
UPPER BOUND, 75.0 s, the tighter of the two consumers this card must protect,
both read from `config.yaml` on 2026-09-06:
75 s `circulation_hold_s` (config.yaml:512) - SAFETY-31 trips here.
85 s `unit_sign_disagree_s` (config.yaml:451) - SAFETY-30's per-unit sign
check trips here.
A gate slower than either is a gate that hands its consumer the frozen number
and then explains afterwards. ⚠️ AND THAT INEQUALITY IS GRADED, NOT ASSERTED -
`test_freshness.py` part 6 reads BOTH ends out of the shipped `config.yaml`
and goes red if an operator's default moves under this bound.
35.0 s IS THE CHOSEN VALUE, and the admissible band is narrow enough that it
is nearly forced: at least 2x the 17.0 s floor (34.0) and at most half the
75.0 s consumer window (37.5) leaves [34.0, 37.5]. 35 is the integer in it.
⚠️ DELIBERATELY NOT 30 AND NOT 60. 30 is `validate.FIRMWARE_INGEST_VETO_S` and
would read as a borrowed constant nobody re-derived; 60 is round, and a round
number here would be a guess wearing a measurement's clothes.
WHAT IT COSTS, stated rather than absorbed: a frozen 0x0518 on a unit that is
never asked to move 250 W is not detected by this module at all.
⚠️ THAT COST IS BOUNDED FOR ONE CONSUMER AND NOT FOR THE OTHER, and an earlier
version of this note claimed both. TRUE for the per-unit sign check: no such
check can speak about that unit either, since `unit_sign_disagree_w` is 250 W.
FALSE for circulation. "Contributing at most 250 W" bounds the COMMAND - but a
frozen register's REPORTED value is unrelated to its command, and that is
precisely HW-01's shape: commanded ~0 W, register stuck on the last large
number. Measured in review: a unit frozen at -1200 W beside a healthy +900 W
sibling gives `circulating_w` = 1800 W against a 700 W floor, 2.6x over, with
no gate armed. So this gate is scoped to where the SIGN consumer can be misled;
the circulation consumer keeps an unbounded exposure below MOVE_W and that is
a known, stated gap rather than a covered one.
`soc` / 0x050E, U16, 1 %, published every 10 s - STATED, NOT WIRED
-----------------------------------------------------------------
⚠️ THIS ROW IS THE POLICY'S ANSWER FOR TEL-08 AND IT IS NOT CONNECTED TO
ANYTHING. It is here because the third clause of the policy is "state each
bound and its derivation", and because a reader who finds only `batt` wired
would reasonably conclude SoC was forgotten. It was not; it needs a
corroborator this card cannot derive.
SoC CANNOT USE THE CONJUNCTION ABOVE. A pack resting at its floor holds one
percent for hours with nothing wrong - the card's own example - and the
standing command says nothing about it, because a command of 0 W is exactly
what a rested pack is given. The physically obliged movement for SoC is
ENERGY, not command travel: one percent of pack A is about 153.6 Wh (15.36 kWh
nominal, goodwe/NOTES.md §1.1b's table - ⚠️ NOMINAL and FROM DATASHEET, not a
measured usable capacity), so at pack A's 5000 W ceiling one percent takes at
least 110 s and at a realistic 1000 W about 553 s. The bound TEL-08 needs is
therefore "SoC unchanged while the integral of |battery power| since its last
change exceeded K x 153.6 Wh", and K cannot be chosen from anything written
down today: nobody has measured this pack's usable capacity, its BMS reporting
granularity, or how far its reported percent lags true state of charge.
So TEL-08 owes: that capacity measurement (or a defensible bound on it), the
integrator, and K. It does NOT owe a second freshness mechanism - `Freshness`
below takes its obligation term from the caller precisely so that an energy
integral can be handed in where command travel is handed in here.
"""
import time
from dataclasses import dataclass
# --- the three terms of the lower bound, each read out of this repository ----
# See the module docstring for where each one is measured. They are separate
# names rather than a literal 17.0 so that a reader who changes the firmware's
# `update_interval` can find this file by grepping for the number they moved.
PLANT_SETTLE_S = 6.0 # goodwe/NOTES.md §5.9, measured 2026-08-22
PUBLISH_PERIOD_S = 10.0 # firmware/goodwe-master.yaml:181
READ_PERIOD_S = 1.0 # main.run_control()'s `await asyncio.sleep(1)`
BATT_OBLIGED_S = PLANT_SETTLE_S + PUBLISH_PERIOD_S + READ_PERIOD_S # 17.0
# The unchanged bound for register 0x0518. See the derivation above; the band is
# [2 * BATT_OBLIGED_S, 0.5 * circulation_hold_s] = [34.0, 37.5].
BATT_UNCHANGED_S = 35.0
# How far the standing command must have travelled for the register to be
# OBLIGED to have followed it.
#
# ⚠️ IT IS THE LARGEST ADMISSIBLE VALUE, NOT A SENSITIVITY PREFERENCE, and that
# is what makes it a derivation. The gate has to arm everywhere a stale reading
# could mislead a consumer; the earliest either consumer speaks is
# `unit_sign_disagree_w`, 250 W (config.yaml:450), because below that
# `watchdog.disagrees()` is False whatever the measurement says. Taking the
# ceiling of the admissible range is what minimises spurious arming. A smaller
# number would arm the gate in states where nothing downstream can be misled,
# and every such arming is a spurious site stop bought for nothing.
#
# ⚠️ NOT A CONFIG KEY. CFG-03 tracks the places a default has to be kept in
# step and this does not earn another one - it is derived from a default
# `config.yaml` already carries, and `test_freshness.py` part 6 READS that file
# and goes red if the two drift.
MOVE_W = 250.0
@dataclass(frozen=True)
class Bound:
"""One signal's freshness bound, and whether anything consumes it."""
signal: str
unchanged_s: float
move_w: float
wired: bool
derivation: str
# ⚠️ THE POLICY AS DATA, AND THE `wired` COLUMN IS LOAD-BEARING. A reader must be
# able to tell, without reading main.py, which of these bounds is actually
# gating a consumer today. `test_freshness.py` part 7 greps the shipped app/ for
# each row and refuses a row that claims to be wired and is not - and a row that
# claims not to be and is.
BOUNDS = {
"batt": Bound(
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
unchanged_s=BATT_UNCHANGED_S,
move_w=MOVE_W,
wired=True,
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]"),
"soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"),
move_w=float("inf"),
wired=False,
derivation="TEL-08. Command travel is the WRONG obligation term for "
"SoC - a rested pack holds a percent for hours under a 0 W "
"command. The term is energy: >= K x 153.6 Wh through the "
"pack since the reading last changed. K needs a measured "
"usable capacity nobody has taken."),
}
class Freshness:
"""One signal's arrival record, and the stale verdict it supports.
⚠️ ARRIVAL-DRIVEN THROUGHOUT. Every clock reading this object ever makes is
handed to `submit()` by the caller at the moment a value arrived; nothing
here reads a clock of its own and nothing here reads a Home Assistant
timestamp. That is what makes the verdict replayable - `test_freshness.py`
drives real seconds through it without sleeping - and it is the difference
between this and the value-stamped veto TEL-06 is blocked on.
⚠️ THE VERDICT LATCHES, AND WITHOUT THAT IT OSCILLATES AT ABOUT 1 Hz.
Declaring the reading stale makes `Controller.inputs_ok` False, which
commands 0 W (§11.1 rule 1), which drops the standing command below
`MOVE_W`, which un-arms the conjunction, which would return the held number
to the consumers - and round again every read. So the ONLY thing that clears
the latch is an arrival carrying a DIFFERENT number, which is exactly the
evidence the verdict was about. A register that is working clears it on its
next 10 s publish; a register that is not, does not.
"""
def __init__(self, bound: Bound):
self.bound = bound
# The last number that ARRIVED, or None if the most recent read did not
# produce one. This is what `reading()` hands back when the gate is open,
# so a read failure is still an immediate None exactly as it is today.
self.last = None
# The number the reading is currently HOLDING, and the monotonic instant
# of the arrival that last carried a different one. `held` survives a
# failed read; the unchanged clock is about the value, not about our
# ability to fetch it.
self.held = None
self.changed_mono = None
# The standing command at that same instant. The conjunction compares
# against THIS, not against the previous cycle's command: what matters
# is whether the register has followed anything since it last moved.
self.standing_at_change = None
self.stale = False
self.reason = ""
self.arrivals = 0
def submit(self, value, standing_w, now_mono) -> None:
"""One READ of this signal. `value` None means nothing arrived.
`value` the number `hass.number()` produced, or None.
`standing_w` what this controller currently has standing on this unit's
wire, signed, + = discharge. None when nothing is known.
`now_mono` the caller's monotonic clock, in seconds.
⚠️ A FAILED READ IS NOT AN ARRIVAL AND IS NOT AN AGE EITHER. It clears
`last` immediately - which is byte-for-byte what shipping code does
today, a missing reading being a missing reading - and it leaves the
unchanged clock running, because the value has not changed and our
inability to fetch it is not evidence that it has.
"""
if value is None:
self.last = None
return
value = float(value)
self.arrivals += 1
if self.changed_mono is None or value != self.held:
self.held = value
self.changed_mono = float(now_mono)
self.standing_at_change = (None if standing_w is None
else float(standing_w))
# Movement is the ONLY thing that clears the latch. See the class
# docstring on the 1 Hz oscillation this prevents.
self.stale = False
self.reason = ""
self.last = value
self._grade(standing_w, now_mono)
def _grade(self, standing_w, now_mono) -> None:
"""The conjunction. Sets the latch; never clears it."""
if self.stale or self.changed_mono is None:
return
held_s = self.unchanged_s(now_mono)
if held_s < self.bound.unchanged_s:
return
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
# always answers a float, so neither branch is reachable from the wired
# consumer today. They are here for TEL-08, whose obligation term is an
# energy integral that has nothing to say until it has a window - and
# "nothing to say" must withhold the verdict, not manufacture one.
if standing_w is None or self.standing_at_change is None:
return
standing_w = float(standing_w)
if abs(standing_w) < self.bound.move_w:
return
moved_w = abs(standing_w - self.standing_at_change)
if moved_w < self.bound.move_w:
return
self.stale = True
self.reason = (
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
f"while the standing command moved {moved_w:.0f} W to "
f"{standing_w:+.0f} W - the reading is not following, so it is "
f"reported as MISSING rather than as a measurement (limits: "
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
f"{self.bound.move_w:.0f} W and moved by over "
f"{self.bound.move_w:.0f} W)")
def unchanged_s(self, now_mono) -> float | None:
"""Seconds since an arrival carried a different number. Status page."""
if self.changed_mono is None:
return None
return max(0.0, float(now_mono) - self.changed_mono)
def reading(self):
"""What the consumers get: the value, or None when it cannot be trusted.
⚠️ `None`, NEVER A HELD NUMBER, and never a zero. Both consumers already
do the right thing with None and neither could ever receive one:
`watchdog.SignWatchdog.evaluate` CLEARS its window on a None measurement
rather than accumulating it, and `circulation.circulating_w` returns None
for a roster with a missing term, which clears the circulation window
too. This module is the thing that was missing between them.
"""
return None if self.stale else self.last
def note(self, unit_name: str) -> str:
"""One line for the status page. Empty when there is nothing to say."""
return f"{unit_name}: {self.reason}" if self.stale else ""
def monotonic() -> float:
"""The clock this module's callers use, in one place so tests can find it.
⚠️ `time.monotonic()`, not the wall clock, for `p1.P1Sample.age_s`'s reason:
an NTP step on a Raspberry Pi that has just found the network is routinely
minutes, and a freshness verdict computed across one would either fake a
stale reading or, worse, hide a real one.
"""
return time.monotonic()
+48
View File
@@ -122,6 +122,9 @@ import math
from datetime import datetime
from .clamp import ClampResult
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
# freshness.py imports nothing from this package, so the direction is one-way.
from .freshness import BOUNDS, Freshness
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
# shipped default and a literal here would be the fourth defaults site that
@@ -213,7 +216,22 @@ class Inverter:
# --- this unit's own measurements ------------------------------------
self.soc = None
# ⚠️ SAFETY-37: `batt` IS NOW THE GATED READING, NOT THE RAW ONE. It is
# None whenever `batt_fresh` says the register is not following its own
# command - see freshness.py - and a None here is a MISSING input, which
# §11.1 rule 1 answers with 0 W. The raw number that arrived is on
# `batt_fresh.last`; read that only for diagnostics, never for control.
self.batt = None
# SAFETY-37. One arrival record per SIGNAL per UNIT. Per unit for
# DEV-02's reason - a freshness verdict about B is not evidence about A,
# and at N>=2 a shared record would be the flat-scalar defect rebuilt
# inside the gate that exists to protect the per-unit checks.
#
# ⚠️ ONLY `batt` IS WIRED. `BOUNDS["soc"]` exists and is deliberately not
# constructed here: SoC's obligation term is energy through the pack, not
# command travel, and the constant it needs has not been measured. That
# is TEL-08's, and freshness.py's docstring says exactly what it owes.
self.batt_fresh = Freshness(BOUNDS["batt"])
# --- what this unit was last told, and what it last accepted ---------
# `target` is THIS unit's share of the site target from the last
@@ -266,6 +284,24 @@ class Inverter:
self.store.set(self.key(name), value)
# -- the numbers other modules ask this object for -----------------------
def standing_w(self) -> float:
"""What this controller has standing on THIS unit's wire right now.
⚠️ ONE DEFINITION, TWO CALLERS, and it was a transcription before
SAFETY-37. `cycle()` computes this for the sign watchdog and
`_read_unit()` needs the identical number for the freshness conjunction;
two spellings of "what is standing" would be two answers to the question
the gate and the check it protects are both asking, and the copy that
drifts is always the one further from the wire.
`last_write` is the value after the device clamp and confirmed accepted;
`target` covers the case where nothing has been written yet and,
deliberately, the case where the last write was REJECTED - the inverter
is then still latched on `last_write`, which is what `last_write` holds.
"""
return float(self.last_write if self.last_write is not None
else self.target)
def rating_w(self) -> float:
"""The largest magnitude this unit can be commanded, in W.
@@ -319,6 +355,18 @@ class Inverter:
f"(limit {float(stale_s):.0f} s)")
return True, ""
def stale_note(self) -> str:
"""SAFETY-37's one line for the status page. Empty when nothing to say.
Separate from `link_note()` because they are about different halves of
the device and want opposite remedies: liveness is about the WRITE path
and its answer is to drop this unit and let the survivors carry it,
while a stale MEASUREMENT is about the read path and its answer is to
stop the site (`inputs_ok`). Folding one into the other is how a
measurement fault ends up handing a sibling extra power.
"""
return self.batt_fresh.note(self.name)
def link_note(self, now: datetime) -> str:
"""One line for the status page. Empty when there is nothing to say."""
if not self.has_writer():
+65 -8
View File
@@ -45,6 +45,7 @@ from .circulation import (
from .clamp import (
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
)
from . import freshness
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
@@ -1109,8 +1110,18 @@ class Controller:
for inv in self.inverters)
# -- io ------------------------------------------------------------------
async def read_inputs(self) -> None:
async def read_inputs(self, now_mono: float | None = None) -> None:
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
`now_mono` the monotonic clock the per-unit freshness records are
stamped on. Defaulted rather than read inside `_read_unit()`
so that ONE instant covers every unit in a gather - two
units stamped from two clock reads would carry a freshness
skew nobody could reproduce - and so that a check can drive
real seconds through the gate without sleeping through them.
"""
o = self.o
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
if self.p1_enabled:
# ⚠️ P1 is the only authoritative measurement of what the utility
# sees (§5.1). When it is stale this is None, which falls into the
@@ -1130,7 +1141,8 @@ class Controller:
# names for the WRITE path, one function up. Within a unit the order is
# unchanged (SoC, then battery power), so at the shipped roster of one
# this is the same two calls in the same order.
await asyncio.gather(*(self._read_unit(inv) for inv in self.inverters))
await asyncio.gather(*(self._read_unit(inv, now_mono)
for inv in self.inverters))
if o.get("peak_forecast_entity"):
raw = await self.hass.number(o["peak_forecast_entity"])
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
@@ -1176,10 +1188,41 @@ class Controller:
elif self.inputs_bad_since is None:
self.inputs_bad_since = datetime.now(timezone.utc)
async def _read_unit(self, inv) -> None:
"""One inverter's own SoC and battery power, from ITS OWN entities."""
async def _read_unit(self, inv, now_mono: float) -> None:
"""One inverter's own SoC and battery power, from ITS OWN entities.
⚠️ SAFETY-37: THE POWER READING GOES THROUGH THE FRESHNESS GATE AND THE
SoC READING DOES NOT, AND THE ASYMMETRY IS DELIBERATE. `batt` feeds the
two consumers this card is about - SAFETY-30's per-unit sign check and
SAFETY-31's circulating-power sensor - and its obligation term is
command travel, which this method already has. SoC's obligation term is
ENERGY through the pack and the constant it needs has never been
measured on this site; wiring it here with a guessed constant would be a
gate that fires on a rested pack, which is TEL-08's whole hazard.
`freshness.BOUNDS["soc"]` states the bound and what TEL-08 owes.
⚠️ THE STANDING COMMAND IS ASKED OF THE UNIT, NOT RECOMPUTED. `cycle()`
grades the sign watchdog against `inv.standing_w()` and this gate arms
off the same method, because the gate exists to protect that check and a
second spelling of "what is standing" would let the two disagree about
the number they are both reasoning about.
"""
inv.soc = await self.hass.number(inv.soc_entity)
inv.batt = await self.hass.number(inv.batt_entity, inv.batt_invert)
raw = await self.hass.number(inv.batt_entity, inv.batt_invert)
was_stale = inv.batt_fresh.stale
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
# ⚠️ `inv.batt` IS THE GATED VALUE. A stale register is a MISSING input,
# not a measurement - `inputs_ok` sees the None, the site holds for
# `stale_input_s` and then commands 0 W and keeps commanding, which is
# §11.1 rule 1 and is already what a missing reading does here.
inv.batt = inv.batt_fresh.reading()
if inv.batt_fresh.stale and not was_stale:
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
_LOG.error("SAFETY-37: %s battery-power register is not following "
"its own command - %s. Treating it as MISSING, which "
"commands 0 W. It clears itself the moment the register "
"reports a different value.",
inv.name, inv.batt_fresh.reason)
def write_refusal(self, value: float, inv=None) -> str | None:
"""Why `value` may NOT be written to `inv` right now, or None if it may.
@@ -1469,9 +1512,20 @@ class Controller:
return tuple(live)
def liveness_notes(self) -> list:
"""Why each unit is or is not commandable, for the status page."""
"""Why each unit is or is not commandable, for the status page.
⚠️ SAFETY-37's STALE-READING LINE RIDES HERE TOO, AND IT IS A DIFFERENT
FAULT WITH A DIFFERENT REMEDY. A unit that is not commandable is dropped
and its share is carried by the survivors; a unit whose power register
has stopped following its command stops the SITE. They share this list
because they share one place on the status page - the operator asking
"why is nothing happening" needs both - and nothing downstream reads it.
"""
now = datetime.now(timezone.utc)
return [n for n in (inv.link_note(now) for inv in self.inverters) if n]
return [n for n in
[x for inv in self.inverters
for x in (inv.link_note(now), inv.stale_note())]
if n]
async def cycle(self) -> None:
"""Gather claims, resolve precedence, write the winner.
@@ -1560,7 +1614,10 @@ class Controller:
standing = {}
for inv in self.inverters:
was_tripped = inv.sign_wd.tripped
stood = inv.last_write if inv.last_write is not None else inv.target
# ⚠️ `Inverter.standing_w()`, NOT THE EXPRESSION IT REPLACES.
# SAFETY-37's freshness gate arms off the same method, and this used
# to be the only spelling of it. See that method's docstring.
stood = inv.standing_w()
standing[inv.name] = stood
against = self.grid if n_units <= 1 else inv.batt
if inv.sign_wd.evaluate(stood, against, self.following, now) \
+879
View File
@@ -0,0 +1,879 @@
"""Runnable check for SAFETY-37's freshness policy. `python3 test_freshness.py`
THE SUBJECT OF THIS FILE IS THE FIXTURE, NOT THE CODE. A pack legitimately
resting at 40 % and a dead sensor reading 40 % ARE THE SAME NUMBER. A check that
cannot separate "fresh and unchanged" from "stale" cannot see the failure it
names, and this repository has recorded that defect - "a check whose DATA cannot
reach the failure it names" - eleven times. So part 1 builds the separation and
ASSERTS THE VALUE STREAMS ARE BIT-IDENTICAL across the pair; everything after it
is downstream of that pair being real.
⚠️ AND THE CARD'S WORDING HAD TO BE SHARPENED TO BUILD IT, WHICH IS SAID HERE
RATHER THAN QUIETLY WORKED AROUND. The card says "only arrival separates them".
For a signal this add-on polls out of Home Assistant's state cache, ARRIVAL
CANNOT SEPARATE THEM: `read_inputs()` runs about once a second and Home
Assistant answers 200 with a number whether or not the publisher behind the
entity is alive, so both members of the pair arrive, identically, forever.
Part 1a demonstrates exactly that - an arrival-age gate is graded and FAILS to
tell the pair apart - and it is the reason the shipped verdict is a conjunction
with an OBLIGATION term rather than an age threshold. Every clock in that
verdict is still an arrival clock: nothing reads a Home Assistant timestamp,
which is the half of "arrival-stamped" that TEL-06 is blocked on and that this
module does honour.
1. THE PAIR. Fresh-and-unchanged vs stale, on identical value streams, on
the policy object itself. Plus 1a, the arrival-age gate that cannot.
2. THE WIRING. The same pair driven through the REAL `Controller.read_inputs`
at a roster of TWO, with the two units carrying DIFFERENT power values.
3. THE CONSUMERS REFUSE, and the counterfactual is run beside them: the
per-unit sign check and the circulating-power sensor, with the gate and
with the gate removed, through real `cycle()` calls.
4. THE LATCH, and the ~1 Hz oscillation it exists to prevent.
5. N=1. Nothing changes on a healthy install - asserted on the shipped read
path, not assumed - and the one thing that DOES change is named.
6. THE BOUNDS, graded against the shipped `config.yaml` at both ends.
7. THE POLICY TABLE tells the truth about what is wired.
8. THE MEASUREMENT the bound rests on, replayed from the committed capture.
9. KNOWN SURVIVORS, graded so they are residuals rather than surprises.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12; goodwe/NOTES.md §1.1b).
Every two-unit case below is two copies of inverter A's behaviour with
DIFFERENT numbers passed in, and no number here is a measured parameter for a
second device.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
stops bytecode being WRITTEN, not a stale one being READ. Read MUTATION.md
before you report a survivor.
"""
import ast
import asyncio
import glob
import gzip
import io
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from app import freshness as F
from app.circulation import circulating_w
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness
from app.inverter import Inverter
from app.main import Controller
from app.store import DEFAULTS
from app.watchdog import SignWatchdog, SignWatchdogConfig, disagrees
fails = []
HERE = Path(__file__).resolve().parent
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
# --------------------------------------------------------------------------- #
# fixtures
# --------------------------------------------------------------------------- #
class MemStore:
def __init__(self, data=None):
self.data = dict(DEFAULTS, **(data or {}))
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
def set_time(self, *a):
pass
class Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Hass:
"""A Home Assistant that answers per ENTITY and RECORDS what it served.
⚠️ PER ENTITY, AND THE RECORD IS LOAD-BEARING. A stub that answered one
number whatever it was asked could not tell "B was read from B's sensor"
from "B was handed A's reading" - and the served-value log is what lets
part 1 assert that the fresh run and the stale run saw the SAME numbers.
"""
def __init__(self, numbers=None, limits=None):
self.numbers = dict(numbers or {})
self.limits_by = dict(limits or {})
self.served = [] # (entity, value) in call order
self.writes = []
async def number(self, entity, invert=False):
v = self.numbers.get(entity)
self.served.append((entity, v))
return None if v is None else (-v if invert else v)
async def limits(self, entity):
return self.limits_by.get(entity, (None, None, None))
async def set_number(self, entity, value):
self.writes.append((entity, value))
return True
async def call(self, domain, service, data):
return True
A_RATING = 5000.0
# ⚠️ ILLUSTRATIVE, AND NOT B'S DATASHEET FIGURES. SPIKE-04a established B as a
# GW5048D-ES rated 4600 W with a 10 % floor (goodwe/NOTES.md §1.1b), and this
# fixture deliberately does not use them: 5000/4600 is a 400 W gap, narrower
# than a slew step, and two equal-ish numbers cannot show a check that read the
# wrong unit's. Same reasoning as test_inverter.py's `B_RATING_HYPOTHESIS`.
B_RATING_SIM = 3000.0
A_FLOOR, B_FLOOR_SIM = 10, 15
ROSTER = [
{"name": "inverter-a", "setpoint_entity": "number.sp_a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": A_RATING, "bms_soc_floor": A_FLOOR},
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
"max_w": B_RATING_SIM, "bms_soc_floor": B_FLOOR_SIM},
]
# ⚠️ A AND B CARRY DIFFERENT POWER VALUES IN EVERY FIXTURE BELOW, and the card
# names this as the trap it expects: "if both read the same, the test cannot
# distinguish a frozen reading from a live one and will pass against the broken
# code". SAFETY-30's fix round walked into exactly that with two watchdog holds
# both set to 1.
A_FROZEN_W = -1200.0 # inverter A's register, stuck on a charge figure
B_LIVE_W = 900.0 # inverter B's register, moving, and NOT A's number
BASE = dict(meter_source="off", meter_entity="sensor.grid", setpoint_entity="",
soc_entity="", batt_entity="", max_w=A_RATING, gain=0.6, slew_w=0,
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
stale_input_s=15, inverters=ROSTER)
def site(opts=None, numbers=None):
"""A real two-inverter Controller off the shipped `build_roster()`."""
hass = Hass(dict({"sensor.grid": 0.0,
"sensor.soc_a": 50.0, "sensor.batt_a": A_FROZEN_W,
"sensor.soc_b": 55.0, "sensor.batt_b": B_LIVE_W},
**(numbers or {})),
{"number.sp_a": (-A_RATING, A_RATING, 1.0),
"number.sp_b": (-B_RATING_SIM, B_RATING_SIM, 1.0)})
c = Controller(dict(BASE, **(opts or {})), hass, MemStore(), Pub(),
stop=asyncio.Event())
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
c.inverters[1].dev_min, c.inverters[1].dev_max = -B_RATING_SIM, B_RATING_SIM
return c, hass
# ==============================================================================
print()
print("1. THE PAIR: 'fresh and unchanged' vs 'stale', on IDENTICAL value streams")
# The two runs below feed the policy object the SAME forty readings of the same
# number at the same instants. The ONLY difference is what the controller was
# asking the inverter to do while those readings arrived.
#
# FRESH the site is converged. The command sits where it is, the register
# answers the same watt, and nothing is wrong. This is the case the
# 30-day capture in part 8 measures at up to 263.2 s.
# STALE the site reversed. The command travelled 2500 W and the register did
# not move by one watt in 40 s, which is 2.4x the fastest it could
# possibly be obliged to answer in.
STREAM = [(float(t), A_FROZEN_W) for t in range(0, 40)]
def drive(bound, stream, standing_of):
"""Feed a Freshness one arrival per (t, value). Returns (obj, served, verdicts)."""
f = Freshness(bound)
served, verdicts = [], []
for t, v in stream:
f.submit(v, standing_of(t), t)
served.append(v)
verdicts.append(f.reading())
return f, served, verdicts
_fresh, _fresh_served, _fresh_read = drive(
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W)
# The command travels from -1200 W to +1300 W at t=1 and stays there: a site
# turnaround, which is the ordinary stimulus SAFETY-31's own campaign sweeps.
_stale, _stale_served, _stale_read = drive(
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W if t < 1 else 1300.0)
print(f" served (both runs): {_fresh_served[:3]} ... x{len(_fresh_served)}")
# ⚠️ `key=str`, AND IT IS NOT COSMETIC. Without it this line raises TypeError
# comparing None with a float the moment a mutation makes the FRESH run stale -
# which is what M2 (dropping the command-travel term) did: the file died on a
# DIAGNOSTIC PRINT before check one, so the harness reported "red" with no named
# check behind it. MUTATION.md's reporting rule is "which suite went red, and on
# WHICH NAMED CHECK"; a print that can crash first destroys exactly that.
print(f" fresh verdicts: unique={sorted(set(_fresh_read), key=str)}")
print(f" stale verdicts: first None at t="
f"{next((i for i, v in enumerate(_stale_read) if v is None), None)}")
check("⚠️ THE FIXTURE'S OWN PRECONDITION: the two runs saw a BIT-IDENTICAL "
"value stream. If this ever stops being true the pair below is comparing "
"two different experiments and proves nothing about freshness",
_fresh_served == _stale_served
and len({v for v in _fresh_served}) == 1)
check("FRESH AND UNCHANGED is not stale. Forty identical readings over 39 s "
"with the command standing still - a converged site - and the reading "
"reaches its consumers untouched",
not _fresh.stale and _fresh_read == [A_FROZEN_W] * len(STREAM))
check("⚠️ STALE IS. The same forty readings, the same instants, the same "
"numbers - and the command moved 2500 W underneath them. The reading "
"becomes None to its consumers, which is the whole card",
_stale.stale and _stale_read[-1] is None)
check("...and it becomes None at the BOUND, not before: 35.0 s of unchanged "
"arrivals, so the last fresh verdict is at t=34 and the first None at "
"t=35",
_stale_read[34] == A_FROZEN_W and _stale_read[35] is None
and BATT_UNCHANGED_S == 35.0)
check("...and NEVER a held number and never a zero - `reading()` has exactly "
"two shapes, the arrived value or None",
set(type(v) for v in _stale_read) == {float, type(None)}
and 0.0 not in [v for v in _stale_read if v is not None])
check("...and the reason names the three terms an operator has to check, with "
"the numbers that fired it",
"STALE_READING" in _stale.reason and "2500 W" in _stale.reason
and "unchanged for 35 s" in _stale.reason)
# --- 1a. the gate the card asked for first, graded and found unable ----------
# An arrival-age gate is what "arrival-stamped" reads as on first hearing. It is
# built here and run against the SAME pair, because a criterion that cannot be
# satisfied is worth more said out loud than approximated in silence.
_arrival_ages = []
_af = Freshness(BOUNDS["batt"])
for t, v in STREAM:
_af.submit(v, A_FROZEN_W, t)
# Every read produced a number, so on both members of the pair the age
# since the last ARRIVAL is 0.0 at every instant. There is nothing to
# threshold.
_arrival_ages.append(0.0 if _af.last is not None else None)
check("⚠️ AN ARRIVAL-AGE GATE CANNOT TELL THE PAIR APART, and that is why the "
"shipped verdict is not one. Home Assistant answered every read with a "
"number in BOTH runs, so the age since arrival is 0.0 s at every instant "
"of both - a threshold on it is a threshold that can never fire, which "
"is TEL-06's defect rebuilt in this repository",
set(_arrival_ages) == {0.0} and _af.arrivals == len(STREAM))
check("...while the arrival record IS what the shipped verdict is measured on: "
"`unchanged_s` is the gap between arrivals that carried different "
"numbers, on this process's own monotonic clock, and no Home Assistant "
"timestamp is read anywhere in the module",
_stale.unchanged_s(39.0) == 39.0
and "last_updated" not in io.open(HERE / "app" / "freshness.py",
encoding="utf-8").read().split(
'"""', 2)[2])
check("...and a read that produced NOTHING is still an immediate None, exactly "
"as it is today - a failed read is not an arrival and is not an age",
Freshness(BOUNDS["batt"]).reading() is None
and (lambda f: (f.submit(5.0, 0.0, 0.0), f.submit(None, 0.0, 1.0),
f.reading() is None and f.held == 5.0)[-1])(
Freshness(BOUNDS["batt"])))
# ==============================================================================
print()
print("2. THE WIRING: the same pair through the REAL read path, at a roster of 2")
async def _reads(c, hass, plan):
"""Drive real `read_inputs()` calls. `plan` is (t, standing_a, batt_a)."""
out = []
for t, standing_a, batt_a in plan:
hass.numbers["sensor.batt_a"] = batt_a
# `last_write` is what the write path sets and what `standing_w()`
# reads; setting it here is standing in for a write, not for the gate.
c.inverters[0].last_write = standing_a
await c.read_inputs(now_mono=t)
out.append((t, c.inverters[0].batt, c.inverters[1].batt))
return out
_PLAN_FRESH = [(float(t), A_FROZEN_W, A_FROZEN_W) for t in range(0, 40)]
_PLAN_STALE = [(float(t), A_FROZEN_W if t < 1 else 1300.0, A_FROZEN_W)
for t in range(0, 40)]
_cf, _hf = site()
_of = run(_reads(_cf, _hf, _PLAN_FRESH))
_cs, _hs = site()
_os = run(_reads(_cs, _hs, _PLAN_STALE))
print(f" fresh run: A={sorted({r[1] for r in _of})} B={sorted({r[2] for r in _of})}")
print(f" stale run: A={sorted({r[1] for r in _os}, key=str)} "
f"B={sorted({r[2] for r in _os})}")
check("PRECONDITION: both runs were served the same per-entity numbers, so the "
"difference below is the wiring and not the stimulus",
[x for x in _hf.served if x[0] == "sensor.batt_a"]
== [x for x in _hs.served if x[0] == "sensor.batt_a"])
check("⚠️ THE TWO UNITS CARRY DIFFERENT POWER VALUES - A at -1200 W, B at "
"+900 W. A fixture whose units read the same number cannot show a gate "
"that answered for the wrong unit, which is the trap the card names",
A_FROZEN_W != B_LIVE_W and all(r[2] == B_LIVE_W for r in _of))
check("THROUGH THE REAL PATH: a converged site keeps its reading",
all(r[1] == A_FROZEN_W for r in _of))
check("⚠️ THROUGH THE REAL PATH: a frozen register becomes `Inverter.batt is "
"None`. Not a held number, not a zero - the same None a missing entity "
"produces, which is the state §11.1 rule 1 already answers with 0 W",
_os[34][1] == A_FROZEN_W and _os[35][1] is None
and all(r[1] is None for r in _os[35:]))
check("...and ONLY the frozen unit's. B's own reading is untouched, which is "
"the per-unit half: a freshness verdict about A is not evidence about B",
all(r[2] == B_LIVE_W for r in _os) and _cs.inverters[1].batt == B_LIVE_W
and not _cs.inverters[1].batt_fresh.stale)
check("...and `inputs_ok` goes False, so the site stops rather than regulating "
"against a number nobody measured",
_cs.inputs_ok is False and _cf.inputs_ok is True)
check("...and the operator gets a line saying which unit and why, on the same "
"status list the liveness notes use",
any("STALE_READING" in n and "inverter-a" in n
for n in _cs.liveness_notes())
and _cf.liveness_notes() == [])
check("...and the raw number is still recorded for diagnostics, so the "
"operator can see WHAT it was stuck on",
_cs.inverters[0].batt_fresh.last == A_FROZEN_W)
# ==============================================================================
print()
print("3. THE CONSUMERS REFUSE - and the counterfactual runs beside them")
# Both consumers already do the right thing with a None. Neither could ever
# receive one before this card, which is the entire exposure: the gate is the
# thing that was missing BETWEEN the shipped read and the shipped check.
_cfg3 = SignWatchdogConfig(min_w=250.0, hold_s=85.0)
check("PRECONDITION on the shipped predicate: a None measurement does not "
"disagree with anything, so it CLEARS the sign window",
disagrees(1300.0, None, True, _cfg3) is False
and disagrees(1300.0, A_FROZEN_W, True, _cfg3) is True)
check("PRECONDITION on the shipped sensor: a roster with a missing term is "
"None, and None clears the circulation window rather than accumulating",
circulating_w([1300.0, None]) is None
and circulating_w([A_FROZEN_W, B_LIVE_W]) == 2 * min(
abs(A_FROZEN_W), abs(B_LIVE_W)))
async def _cycles(gated, ticks=25, step_mono=12.0, wall=0.20):
"""Real cycles at N=2 with A's register frozen and the LOOP driving.
⚠️ THE COMMAND TRAVEL IS PRODUCED BY THE SHIPPED CONTROL LAW, NOT BY THE
FIXTURE. The meter is pinned at +3000 W of import, so `compute()` ramps the
site into discharge at its own slew rate through the real allocator and the
real write path; nothing here assigns `last_write`. A fixture that set the
standing command by hand would be feeding the gate its own arming condition,
which is the shape of check this repository keeps finding.
`gated=False` is THE COUNTERFACTUAL - the shipped behaviour before this
card, rebuilt by handing the consumers the raw held number the gate
withheld. Same cycles, same stimulus, so the difference between the two
columns is the gate and nothing else.
⚠️ TWO CLOCKS, DELIBERATELY, AND THE WALL-CLOCK ONE IS THE FLAKE RISK.
The freshness verdict runs on the INJECTED monotonic clock (12 s a tick, so
the 35 s bound is crossed on tick 3, deterministically, however loaded the
machine is). The sign watchdog runs on the WALL clock inside `cycle()`,
which cannot be injected - so `unit_sign_disagree_s` is set to 2 s and the
ticks are 0.20 s apart, putting the gate's close at ~0.6 s against a 2 s
window. That ORDER is the field's - 35 s < 75 s < 85 s by construction, part
6 - and the gate is given no head start beyond it.
⚠️ AND THE MARGIN IS MEASURED AND ASSERTED RATHER THAN ASSUMED (CI-06).
`test_peakguard.py` answered two of its own checks with the wall clock, flaked
at ~20 %, and cost a wrong merge diagnosis - a flaky check does not waste a
re-run, it manufactures evidence for whichever hypothesis the reader already
holds. So this returns the wall clock at which the gate closed, and the check
below fails with THAT NUMBER if the box was too slow, rather than reporting a
trip that looks like a code defect.
"""
c, hass = site({"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 2,
"sign_disagree_w": 250, "sign_disagree_s": 2,
"site_sign_disagree_w": 250, "site_sign_disagree_s": 2,
"auto_start": True},
{"sensor.grid": 3000.0})
c.auto = True
circ, tripped_at, standing = [], None, []
t_start, t_gate = time.monotonic(), None
for k in range(ticks):
# A's register is FROZEN on the charge figure it stopped at; B's is
# live and is a DIFFERENT number. Neither is touched by the loop.
hass.numbers["sensor.batt_a"] = A_FROZEN_W
hass.numbers["sensor.batt_b"] = B_LIVE_W
await c.read_inputs(now_mono=k * step_mono)
if not gated:
for inv in c.inverters:
inv.batt = inv.batt_fresh.last
if t_gate is None and c.inverters[0].batt_fresh.stale:
t_gate = time.monotonic() - t_start
circ.append(c.site_circulating_w)
standing.append(c.inverters[0].standing_w())
await c.cycle()
if c.inverters[0].sign_wd.tripped and tripped_at is None:
tripped_at = k
await asyncio.sleep(wall)
return c, circ, tripped_at, standing, t_gate
_cg, _circg, _tripg, _standg, _tgate = run(_cycles(True))
_cu, _circu, _tripu, _standu, _ = run(_cycles(False))
_WD_S = 2.0 # `unit_sign_disagree_s` as this fixture sets it, above
print(f" standing on A (gated): {[round(v) for v in _standg]}")
print(f" gated : sign trip tick={_tripg} circ={_circg[:6]}...")
print(f" gate closed after {_tgate:.2f} s of WALL clock, against the "
f"{_WD_S:.0f} s watchdog window ({_WD_S / _tgate:.1f}x margin)")
print(f" counterfact: sign trip tick={_tripu} circ={_circu[:6]}...")
check("⚠️ PRECONDITION ON THE WALL CLOCK, AND IT IS A CHECK RATHER THAN AN "
"ASSUMPTION (CI-06). The gate closes on the INJECTED clock, the watchdog "
"on the real one, so this fixture only means something while the gate "
f"closes well inside the window: it closed at {_tgate:.2f} s against "
f"{_WD_S:.0f} s. If this fails the machine was slow, not the code - and "
"saying so here is what stops that being read as a defect",
_tgate is not None and _tgate < 0.5 * _WD_S)
check("PRECONDITION: the SHIPPED control law really did travel the command - "
"the gate's arming term is produced by `compute()` and the allocator, "
"not written into the fixture",
max(abs(v) for v in _standu) > MOVE_W
and abs(_standu[3] - _standu[0]) > MOVE_W)
check("⚠️ COUNTERFACTUAL FIRST - the stimulus is REAL. Without the gate, the "
"frozen register false-trips SAFETY-30's per-unit sign check and parks a "
"healthy inverter. That is the fault this card was filed for",
_tripu is not None and _cu.inverters[0].sign_wd.tripped
and "SIGN_DISAGREEMENT" in _cu.inverters[0].sign_wd.reason)
check("⚠️ AND WITH THE GATE IT DOES NOT TRIP. The reading is withheld before "
"the watchdog's window can accumulate, so the check refuses to conclude "
"rather than concluding wrongly - which is what a stale reading deserves",
_tripg is None and not _cg.inverters[0].sign_wd.tripped)
check("...and inverter B was not parked either way - the verdict is per unit",
not _cg.inverters[1].sign_wd.tripped
and not _cu.inverters[1].sign_wd.tripped)
check("⚠️ COUNTERFACTUAL: without the gate the circulating-power sensor "
"reports a loop that is not happening, computed from one frozen term and "
"one live one",
all(v == 2 * min(abs(A_FROZEN_W), abs(B_LIVE_W)) for v in _circu)
and _circu[0] == 1800.0)
check("⚠️ AND WITH THE GATE IT REPORTS `None` - an unknown, which CLEARS the "
"circulation window instead of accumulating it. Both consumers gated, "
"from one place, by one mechanism",
_circg[0] == 1800.0 and _circg[-1] is None
and all(v is None for v in _circg[_circg.index(None):]))
check("...and the circulation watchdog did not latch on the stale evidence",
not _cg.circ_wd.tripped and _cg.circ_wd.over_since is None)
# ==============================================================================
print()
print("4. THE LATCH, and the ~1 Hz oscillation it exists to prevent")
# Declaring the reading stale makes inputs_ok False, which commands 0 W, which
# drops the standing command below MOVE_W, which un-arms the conjunction. Without
# a latch the held number would come straight back to the consumers, and the
# whole thing would flip once per read for as long as the register stayed dead.
_lat = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_lat.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
_was = _lat.stale
_after_zero = []
for t in range(40, 50):
_lat.submit(A_FROZEN_W, 0.0, float(t)) # the site has been stopped
_after_zero.append(_lat.reading())
check("PRECONDITION: it is stale before the site is stopped",
_was and _lat.reading() is None)
check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W. The conjunction is no "
"longer armed - |standing| is 0 W, below the 250 W term - so an unlatched "
"verdict would hand the held -1200 W straight back and the site would "
"restart into it, then stop, then restart, once per read",
all(v is None for v in _after_zero))
# ⚠️ AND A FAILED READ MUST NOT CLEAR IT EITHER. This check exists because the
# mutation that added `self.stale = False` to `submit()`'s None branch SURVIVED
# the whole sweep - no fixture had ever injected a read failure while latched.
# It matters: a stale verdict stops the site, the site is then commanded 0 W, and
# a single unreachable-Home-Assistant read would drop the latch into a state
# where the conjunction is no longer armed - so the held -1200 W would come back
# to the consumers on the very next successful read.
_lat_fail = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_lat_fail.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
_lat_fail.submit(None, 0.0, 40.0) # Home Assistant did not answer
_after_fail = _lat_fail.reading()
_lat_fail.submit(A_FROZEN_W, 0.0, 41.0) # it answers again, same number,
# site now resting at 0 W
check("⚠️ A FAILED READ DOES NOT CLEAR THE LATCH - found by a SURVIVING "
"mutation, not by design. Our inability to fetch a value is not evidence "
"that the value moved, and a stale verdict that a single unreachable read "
"can drop is one a stopped site would drop every time",
_after_fail is None and _lat_fail.stale
and _lat_fail.reading() is None)
check("⚠️ AND ONLY MOVEMENT CLEARS IT - a different number arriving, which is "
"exactly the evidence the verdict was about. Not a timer, not a restart, "
"not the operator's start button",
(lambda f: (f.submit(A_FROZEN_W + 1.0, 0.0, 60.0),
(not f.stale) and f.reading() == A_FROZEN_W + 1.0)[-1])(_lat))
# ⚠️ THE MAGNITUDE TERM OF THE CONJUNCTION, AND THIS CHECK EXISTS BECAUSE A
# MUTATION SURVIVED WITHOUT IT. Deleting `abs(standing_w) < move_w` from
# `_grade()` left every other check in this file green, because in all of them a
# command that has TRAVELLED 250 W has also ENDED UP above 250 W. The case that
# separates them is a site being brought to REST.
_rest = Freshness(BOUNDS["batt"])
# The register reaches +5 W on the same read at which the old +1300 W command is
# still standing - the write path is serialised, so the register can catch up
# BEFORE the command settles - and then the site is commanded to 0 W and the
# register sits at +5 W, correctly, for a minute.
_rest.submit(1290.0, 1300.0, 0.0)
_rest.submit(5.0, 1300.0, 1.0) # standing_at_change captures 1300 W
for t in range(2, 60):
_rest.submit(5.0, 0.0, float(t)) # site at rest; register truthfully +5 W
check("⚠️ THE MAGNITUDE TERM IS LOAD-BEARING, and it took a surviving mutation "
"to show it. A register that reached +5 W while the old +1300 W command "
"was still standing has a `standing_at_change` of 1300 W, so the TRAVEL "
"term alone reads 1300 W of unfollowed command for the whole minute the "
"site rests - and calls a perfectly truthful reading dead. Requiring "
"|standing| >= 250 W as well is what says 'a site at rest is not owed "
"movement'",
not _rest.stale and _rest.reading() == 5.0)
check("...and the hole that term leaves CANNOT MISLEAD EITHER CONSUMER, which "
"is why it is the right trade: a unit commanded under 250 W is one "
"`watchdog.disagrees()` is False for whatever the measurement says, and a "
"frozen term beside a live unit that is itself resting nets to zero "
"circulating power rather than to a false alarm",
disagrees(0.0, A_FROZEN_W, True, _cfg3) is False
and circulating_w([A_FROZEN_W, 0.0]) == 0.0)
check("...and one watt is enough, because 0x0518 is a 1 W register that "
"dithers +/-1 W while holding (goodwe/NOTES.md §5.11, measured "
"2026-08-22) - a working register clears this on its next 10 s publish",
_lat.held == A_FROZEN_W + 1.0 and not _lat.stale)
# ==============================================================================
print()
print("5. N=1: nothing changes on a healthy install, and what DOES is named")
_ONE = dict(BASE, inverters=[], setpoint_entity="number.sp_a",
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
bms_soc_floor=A_FLOOR)
def one_site():
hass = Hass({"sensor.grid": 0.0, "sensor.soc_a": 50.0,
"sensor.batt_a": A_FROZEN_W},
{"number.sp_a": (-A_RATING, A_RATING, 1.0)})
c = Controller(dict(_ONE), hass, MemStore(), Pub(), stop=asyncio.Event())
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
return c, hass
async def _one_healthy():
c, hass = one_site()
seen = []
for t in range(0, 60):
# A healthy register under a moving command: it dithers, so it is never
# the same integer watt twice running.
hass.numbers["sensor.batt_a"] = A_FROZEN_W + (t % 3)
c.inverters[0].last_write = 1300.0 if t % 2 else -1200.0
await c.read_inputs(now_mono=float(t))
seen.append(c.inverters[0].batt)
return c, seen
_c1, _seen1 = run(_one_healthy())
check("⚠️ AC: AT N=1 NOTHING CHANGES ON A HEALTHY INSTALL, and it is asserted "
"rather than assumed. Sixty reads of a dithering register under a command "
"reversing every second: every value reaches the consumers unmodified, "
"the gate never arms, and the status page says nothing new",
_seen1 == [A_FROZEN_W + (t % 3) for t in range(60)]
and not _c1.inverters[0].batt_fresh.stale
and _c1.liveness_notes() == [] and _c1.inputs_ok is True)
check("...and the shipped roster really is one unit, so this is the install "
"that is running in a house today and not a fixture shaped like it",
len(_c1.inverters) == 1 and _c1.inverters[0].name == "inverter-a")
async def _one_frozen():
c, hass = one_site()
for t in range(0, 40):
hass.numbers["sensor.batt_a"] = A_FROZEN_W
c.inverters[0].last_write = A_FROZEN_W if t < 1 else 1300.0
await c.read_inputs(now_mono=float(t))
return c
_c1f = run(_one_frozen())
check("⚠️ AND WHAT DOES CHANGE AT N=1 IS NAMED RATHER THAN HIDDEN: a FROZEN "
"register now stops the site, where before it read as a healthy "
"measurement. That is a behaviour change on the shipped roster and it is "
"the point of the card - HW-01 was a one-inverter site, and the pack "
"drained to its floor with every detector reading healthy for 6 h 25 m",
_c1f.inverters[0].batt is None and _c1f.inputs_ok is False)
check("...and the direction it fails in is the safe one: a false verdict "
"commands 0 W and keeps commanding (§11.1 rule 1), behind the existing "
"`stale_input_s` hold, and clears itself on the register's next move",
float(_c1f.o.get("stale_input_s")) > 0
and _c1f.inverters[0].batt_fresh.stale)
# ==============================================================================
print()
print("6. THE BOUNDS, graded against the SHIPPED config.yaml at BOTH ends")
def option_default(key):
"""`key`'s default out of config.yaml's `options:` block, at two spaces."""
body = io.open(HERE / "config.yaml", encoding="utf-8").read()
body = body[body.index("\noptions:"):body.index("\nschema:")]
for line in body.splitlines():
if line.startswith(f" {key}:") and not line[2:3].isspace():
return float(line.split(":", 1)[1].strip())
return None
_circ_hold = option_default("circulation_hold_s")
_unit_hold = option_default("unit_sign_disagree_s")
_unit_min_w = option_default("unit_sign_disagree_w")
_ceiling = min(_circ_hold, _unit_hold)
print(f" floor {BATT_OBLIGED_S:.1f} s = settle 6.0 + publish 10.0 + read 1.0")
print(f" bound {BATT_UNCHANGED_S:.1f} s")
print(f" ceil {_ceiling:.1f} s = min(circulation_hold_s {_circ_hold:.0f}, "
f"unit_sign_disagree_s {_unit_hold:.0f})")
check("PRECONDITION: both consumer windows were actually found in config.yaml, "
"so a rename cannot turn this part into a check of two Nones",
_circ_hold == 75.0 and _unit_hold == 85.0 and _unit_min_w == 250.0)
check("⚠️ THE BOUND IS AT LEAST 2x THE PHYSICAL FLOOR. Below 17.0 s a healthy "
"register has not yet been OBLIGED to answer - 6.0 s of plant "
"(NOTES §5.9), one 10 s ESPHome publish (firmware/goodwe-master.yaml:181) "
"and one 1 s add-on read - so a shorter bound trips on a working inverter",
BATT_UNCHANGED_S >= 2 * BATT_OBLIGED_S and BATT_OBLIGED_S == 17.0)
check("⚠️ AND AT MOST HALF THE TIGHTER CONSUMER WINDOW. A gate slower than its "
"consumer hands over the frozen number and explains afterwards. THIS IS "
"THE CHECK THAT BITES IF AN OPERATOR'S DEFAULT MOVES: drop "
"`circulation_hold_s` to 60 in config.yaml and this goes red",
BATT_UNCHANGED_S <= 0.5 * _ceiling)
# ⚠️ THE FIRST DRAFT OF THIS CHECK CLAIMED THE BAND CONTAINED EXACTLY ONE
# INTEGER AND IT DOES NOT - it contains 34, 35, 36 and 37, and the check went
# red on its first run and is written the honest way round here. The band is
# what is derived; the value inside it is a choice, and the only defensible
# claims about the choice are that it IS inside and that it is not a number
# borrowed from somewhere else.
check("...and the admissible band computed from config.yaml is [34.0, 37.5], "
"which contains four integers. 35 is a CHOICE inside a DERIVED band, not "
"a derived number, and it is deliberately not 30 - which is "
"FIRMWARE_INGEST_VETO_S and would read as a borrowed constant - or 60, "
"which is round",
[n for n in range(0, 200)
if 2 * BATT_OBLIGED_S <= n <= 0.5 * _ceiling] == [34, 35, 36, 37]
and BATT_UNCHANGED_S not in (30.0, 60.0))
check("⚠️ AND `MOVE_W` IS PINNED TO config.yaml TOO. It is the largest value "
"that still arms the gate everywhere the per-unit sign check can speak; "
"raise `unit_sign_disagree_w` and the gate would stop arming in states "
"that check still fires in, which this catches",
MOVE_W == _unit_min_w)
check("...and the module's three floor terms are the numbers this repository "
"actually carries, not a transcription of them: the ESPHome publish "
"interval is read back out of the shipped firmware",
"update_interval: 10s" in io.open(
HERE.parent / "firmware" / "goodwe-master.yaml",
encoding="utf-8").read()
and F.PUBLISH_PERIOD_S == 10.0 and F.READ_PERIOD_S == 1.0
and F.PLANT_SETTLE_S == 6.0)
# ==============================================================================
print()
print("7. THE POLICY TABLE tells the truth about what is WIRED")
def bounds_users(key):
"""Modules that SUBSCRIPT `BOUNDS[key]` in CODE - parsed, not grepped.
⚠️ THE FIRST DRAFT GREPPED FOR THE STRING AND ANSWERED WRONG, because both
`inverter.py` and `main.py` NAME `BOUNDS["soc"]` in a comment explaining why
it is not wired. A text search cannot tell a mention from a use, and this
part's whole job is to tell them apart - so it walks the AST instead.
"""
out = []
for path in sorted((HERE / "app").glob("*.py")):
if path.name == "freshness.py":
continue
tree = ast.parse(io.open(path, encoding="utf-8").read())
for node in ast.walk(tree):
if (isinstance(node, ast.Subscript)
and isinstance(node.slice, ast.Constant)
and node.slice.value == key
and ((isinstance(node.value, ast.Name)
and node.value.id == "BOUNDS")
or (isinstance(node.value, ast.Attribute)
and node.value.attr == "BOUNDS"))):
out.append(path.name)
break
return out
_uses_batt = bounds_users("batt")
_uses_soc = bounds_users("soc")
for _k, _b in BOUNDS.items():
print(f" {_k:5} wired={_b.wired!s:5} unchanged={_b.unchanged_s:>6} "
f"move={_b.move_w:>6} {_b.signal}")
print(f" BOUNDS['batt'] subscripted in CODE by: {_uses_batt}")
print(f" BOUNDS['soc'] subscripted in CODE by: {_uses_soc or 'nothing'}")
check("⚠️ `wired=True` MEANS SOMETHING CONSUMES IT. A policy table nobody "
"checks is a comment; this is the row `batt` claims and the module that "
"claims it",
BOUNDS["batt"].wired is True and _uses_batt == ["inverter.py"])
check("⚠️ AND `wired=False` MEANS NOTHING DOES. SoC is TEL-08's and it is "
"STATED here rather than wired, because its obligation term is energy "
"through the pack and the constant that needs has never been measured on "
"this site. A guessed constant would fire on a pack resting at its floor",
BOUNDS["soc"].wired is False and _uses_soc == [])
check("...and the SoC row says what TEL-08 owes, in the row itself, so the "
"next reader does not have to reconstruct why it is unwired",
"energy" in BOUNDS["soc"].derivation
and "153.6 Wh" in BOUNDS["soc"].derivation
and "TEL-08" in BOUNDS["soc"].derivation)
check("...and every wired bound is a real number - an infinite bound is how a "
"row silently becomes decorative",
all(b.unchanged_s < float("inf") and b.move_w < float("inf")
for b in BOUNDS.values() if b.wired))
# ==============================================================================
print()
print("8. THE MEASUREMENT the bound rests on, replayed from the committed data")
# ⚠️ THIS IS THE PART THAT STOPS THE DERIVATION BECOMING A STORY. The docstring
# in freshness.py quotes three numbers off this capture; they are recomputed
# here from the committed files, so a figure that drifts from its evidence goes
# red instead of being re-quoted forever.
#
# ⚠️ AND IT IS THE METER'S REGISTER, NOT 0x0518. No 30-day capture of the
# inverter's own power exists. What this establishes is the only thing it is
# used for: that the legitimate unchanged duration of a 1 W power entity in this
# house is MINUTES, so an unchanged threshold alone has no admissible value.
_cap = sorted(glob.glob(str(HERE / "scenarios" / "p1-net-30d-2026-08"
/ "p1-net-*.jsonl.gz")))
_runs, _prev = [], None
_t0 = time.monotonic()
for _f in _cap:
with gzip.open(_f, "rt") as _fh:
for _line in _fh:
_r = json.loads(_line)
_t = datetime.fromisoformat(_r["t"]).timestamp()
if _prev is not None:
_runs.append((_t - _prev, _pw))
_prev, _pw = _t, float(_r["w"])
_worst = max(_runs)[0] if _runs else 0.0
_over_bound = sum(1 for d, _ in _runs if d >= BATT_UNCHANGED_S)
print(f" {len(_cap)} days, {len(_runs) + 1} samples, "
f"{time.monotonic() - _t0:.1f}s to replay")
print(f" longest unchanged run {_worst:.1f} s; "
f"{_over_bound} runs >= {BATT_UNCHANGED_S:.0f} s")
check("PRECONDITION: the committed capture is all thirty days, so this is not "
"a claim about whatever files happened to be present",
len(_cap) == 30 and len(_runs) + 1 == 485841)
check("⚠️ A REAL 1 W POWER ENTITY HELD ONE VALUE FOR 263.2 s WITH NOTHING "
"WRONG. That single measurement is why the bound is a conjunction and "
"not a threshold: no unchanged threshold under 263 s is free of false "
"positives, and 263 s is 3.5x too slow to protect a 75 s consumer",
abs(_worst - 263.2) < 0.1 and _worst > _ceiling)
check("...and the two figures freshness.py's docstring quotes off this capture "
"are the ones it produces today",
_over_bound == 10
and sum(1 for d, _ in _runs if d >= BATT_OBLIGED_S) == 386)
check("⚠️ AND THE LONG RUNS ARE ALL UNDER AN UNMOVING LOAD, which is what the "
"conjunction keys on. The worst one is 263 s of a steady -899 W export - "
"a house doing one thing - so a gate that also requires the COMMAND to "
"have travelled 250 W is not armed by any of them",
max(_runs)[1] == -899.0)
# ==============================================================================
print()
print("9. KNOWN SURVIVORS - graded, so they are residuals and not surprises")
# ⚠️ THE REPO'S RECURRING DEFECT IS A CHECK WHOSE DATA CANNOT REACH THE FAILURE
# IT NAMES. The honest end of that habit is to build the mutations this file
# would NOT see and record them. Each one below is a real hole in the shipped
# gate, demonstrated rather than described.
# S1. A register pinned by the INVERTER's own limit, not by a fault. If the
# device is derating and cannot follow a command that ASKS FOR MORE, the
# register can legitimately hold while the command travels.
_s1 = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_s1.submit(2503.0, 2650.0 if t < 1 else 4600.0, float(t))
check("⚠️ SURVIVOR 1, FALSE POSITIVE: a saturated or derating inverter pinned "
"at 2503 W while the command climbs to 4600 W is declared stale. The "
"register is telling the truth and the gate calls it dead",
_s1.stale)
check("...and its SIZE is bounded and its direction is the safe one: the "
"verdict is a site stop, behind the `stale_input_s` hold, and the moment "
"the command comes back down the register moves and the latch clears. "
"Cost = one spurious stop; it cannot drive the battery anywhere",
(lambda f: (f.submit(2400.0, 0.0, 60.0), not f.stale)[-1])(_s1))
# S2. A unit whose command never travels 250 W. HW-01's own shape, at a
# smaller scale, and the gate is deliberately not armed for it.
_s2 = Freshness(BOUNDS["batt"])
for t in range(0, 400):
_s2.submit(A_FROZEN_W, -1200.0 if t < 1 else -1400.0, float(t))
check("⚠️ SURVIVOR 2, MISS: a register frozen under a command that only ever "
"moves 200 W is NEVER detected, for 400 s or forever. This is scoped, "
"not overlooked - such a unit is one no per-unit sign check can speak "
"about (`unit_sign_disagree_w` is 250 W) and one contributing at most "
"250 W to a loop whose reporting floor is 700 W",
not _s2.stale and _s2.reading() == A_FROZEN_W)
# S3. A register that dithers on dead data. A publisher that keeps republishing
# a stale WINDOW of values rather than one value defeats this entirely.
_s3 = Freshness(BOUNDS["batt"])
for t in range(0, 400):
_s3.submit(A_FROZEN_W + (t % 2), 1300.0, float(t))
check("⚠️ SURVIVOR 3, MISS, AND IT IS THE LARGEST ONE: a dead publisher whose "
"last value happens to ALTERNATE - or a cache replaying two old samples - "
"never triggers the unchanged clock at all. This gate detects a value "
"that STOPPED; it cannot detect a value that is WRONG. Nothing in this "
"add-on can, and TEL-06's arrival stamp inside the firmware is the only "
"layer that could",
not _s3.stale and _s3.reading() is not None)
# S4. The gate is per unit, so a whole-site publisher failure that freezes both
# registers is caught twice rather than missed - recorded because a reader
# might expect the opposite.
_s4a, _s4b = Freshness(BOUNDS["batt"]), Freshness(BOUNDS["batt"])
for t in range(0, 40):
_s4a.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
_s4b.submit(B_LIVE_W, B_LIVE_W if t < 1 else -1300.0, float(t))
check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
"that freezes BOTH registers is caught on both units independently, on "
"each unit's own command travel and each unit's own number",
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
+4
View File
@@ -1911,6 +1911,10 @@ _AUDITED = {
("test_maintenance.py", "shipped_default"),
("test_maintenance.py", "schema_range"),
("test_freeze.py", "_cfg_default"),
# SAFETY-37. Found by this very check on its first run - `option_default()`
# reads the two consumer windows the freshness bound is derived from, so it
# is a key-taking reader and belongs here rather than outside the audit.
("test_freshness.py", "option_default"),
("test_price.py", "shipped"),
("test_tariff.py", "shipped_option"),
}