SAFETY-37: a stale reading is None, and arrival cannot be what says so
#57
@@ -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".
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""SAFETY-37 - a reading that holds its last value and reads healthy.
|
||||
|
||||
THE DEFECT, in one sentence: `hass.number()` returns a held value as a value,
|
||||
and nothing anywhere in this add-on distinguishes "the inverter reports 40 %"
|
||||
from "the entity still says 40 % because nothing has updated it since Tuesday".
|
||||
|
||||
The architect pass of 2026-09-06 put three cards on one mechanism. This module
|
||||
is that mechanism; the other two are its consumers.
|
||||
|
||||
LAYER 1 `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` - the
|
||||
(TEL-06) ingest-age veto tests the VALUE of `sensor.goodwe_p1_sample_age`.
|
||||
A dead publisher leaves the entity frozen at a healthy number and
|
||||
the veto can never fire. NOT FIXED HERE - it is firmware, it is on
|
||||
the other side of a flash, and nothing in this module can reach
|
||||
it. What it owes is written at the bottom of this docstring.
|
||||
THIS ADD-ON `app/hass.py:69-82`, consumed by SAFETY-30's per-unit sign check
|
||||
(SAFETY-37) and SAFETY-31's circulating-power sensor. Both read `Inverter.batt`
|
||||
(register 0x0518) and NEITHER had a freshness gate of any kind.
|
||||
THE EVIDENCE HW-01, 2026-09-02: SoC held 40 % for 6 h 25 m while the pack
|
||||
drained to its floor. Every detector read healthy throughout.
|
||||
|
||||
⚠️ AT N>=2 EVERY ONE OF THOSE READINGS DOUBLES, which is why this card is the
|
||||
one that had to build it: the per-unit sign check and the circulation sensor
|
||||
only exist at N>=2, and both are fed straight from `Inverter.batt`.
|
||||
|
||||
|
||||
WHAT CANNOT BE THE SOURCE OF TRUTH, AND IT IS NOT A DETAIL
|
||||
==========================================================
|
||||
Home Assistant's `last_updated` DOES NOT MOVE when a value is rewritten
|
||||
unchanged. Neither does `last_changed`, and this project measured that
|
||||
`last_reported` does not advance on a repeat on either serialiser either - see
|
||||
`p1.HomeWizardLocalSource`'s docstring, which records the ENV-01 rig measurement
|
||||
over 70 s of a frozen meter and an independent one against the live house.
|
||||
|
||||
So a value-side timestamp out of Home Assistant is a "time since the number
|
||||
changed" sensor wearing an arrival sensor's name. Thresholding it is how a
|
||||
detector ends up unable to fire, which is TEL-06's defect exactly. That trap
|
||||
produced two false diagnoses on 2026-09-05, both by a reader who already knew
|
||||
about it, which is why it is written here in full rather than referenced.
|
||||
|
||||
⚠️ AND THE CONVERSE TRAP IS THE ONE THIS MODULE HAD TO SOLVE. The add-on polls
|
||||
Home Assistant's state cache roughly once a second (`run_control()`'s
|
||||
`await asyncio.sleep(1)` bounds the loop body and `read_inputs()` runs every
|
||||
pass). While the entity exists, HA answers 200 with a number whether or not the
|
||||
publisher behind it is alive. So AN ARRIVAL AT THE ADD-ON IS NOT AN ARRIVAL FROM
|
||||
THE INVERTER, and an arrival age alone cannot see HW-01 at all.
|
||||
|
||||
What IS arrival-stamped here, and what makes this module honest, is the clock
|
||||
the verdict is measured on: `changed_mono` is the MONOTONIC INSTANT OF THE
|
||||
ARRIVAL THAT LAST CARRIED A DIFFERENT NUMBER. Every term below is a fact about
|
||||
arrivals this process observed, on its own clock. Nothing is read out of a Home
|
||||
Assistant timestamp, and nothing would change if Home Assistant stopped
|
||||
publishing timestamps altogether.
|
||||
|
||||
|
||||
THE VERDICT, AND WHY IT IS A CONJUNCTION
|
||||
========================================
|
||||
A bare "unchanged for N seconds" threshold is not available, and that is
|
||||
MEASURED rather than feared. Over the committed 30-day capture of this house's
|
||||
own 1 W-resolution power entity - `scenarios/p1-net-30d-2026-08/`, 485,841
|
||||
samples straight out of Home Assistant's recorder, where a row exists only when
|
||||
the state string CHANGED, so the gap between two rows IS the unchanged duration
|
||||
every consumer saw:
|
||||
|
||||
longest unchanged run, 30 days 263.2 s (held -899.0 W, 2026-08-11 17:04)
|
||||
runs >= 35 s 10 of 485,840 (0.0021 %)
|
||||
runs >= 17 s 386 of 485,840 (0.0794 %)
|
||||
|
||||
Measured on 2026-09-06 by replaying that capture. A real power figure at 1 W
|
||||
resolution held ONE value for four and a half minutes with nothing whatever
|
||||
wrong. ⚠️ That is the METER's register, not 0x0518, and it is named as a
|
||||
surrogate rather than passed off as the thing: no 30-day capture of 0x0518
|
||||
exists, because that register is not in the recorder export. What it does
|
||||
establish - and this is all it is used for - is that the LEGITIMATE unchanged
|
||||
duration of a power entity in this house is minutes, so an unchanged threshold
|
||||
alone must be minutes, and minutes is three times too slow to protect consumers
|
||||
that trip at 75 s and 85 s. The conjunction is not a refinement; without it
|
||||
there is no admissible threshold at all.
|
||||
|
||||
⚠️ AND EVERY ONE OF THOSE LONG RUNS HAPPENED UNDER AN UNMOVING LOAD. That is
|
||||
the way out, and it is the same shape `freeze.looks_frozen()` already uses for
|
||||
the meter (unchanged AND the command pinned at the rail). A reading is stale
|
||||
when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
|
||||
|
||||
STALE <=> unchanged for >= UNCHANGED_S
|
||||
AND |standing command now| >= MOVE_W
|
||||
AND |standing command now - standing command when the reading
|
||||
last changed| >= MOVE_W
|
||||
|
||||
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
|
||||
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
|
||||
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
|
||||
register dithering +/-1 W while holding (§5.11, same day). A unit told to move
|
||||
250 W and still answering the same integer watt cannot be tracking.
|
||||
|
||||
|
||||
THE BOUNDS - ONE PER SIGNAL, EACH WITH ITS DERIVATION
|
||||
=====================================================
|
||||
⚠️ ONE CONSTANT FOR BOTH SIGNALS WOULD BE USELESS ON SoC OR TRIGGER-HAPPY ON
|
||||
POWER, which is the third clause of the policy. They are separated here, and
|
||||
only one of them is WIRED - see `BOUNDS` below and the note on SoC.
|
||||
|
||||
`batt` / 0x0518, S16, 1 W, published every 10 s
|
||||
----------------------------------------------
|
||||
LOWER BOUND, 17.0 s, the fastest a healthy register can be OBLIGED to have
|
||||
answered. Three terms, each read out of this repository on 2026-09-06:
|
||||
|
||||
6.0 s the plant. goodwe/NOTES.md §5.9, measured 2026-08-22 with the poll
|
||||
temporarily at 1 s and a 0 -> +500 W step: 26 W at +1.43 s, 469 W
|
||||
(94 %) at +3.34 s, settled 507 W at +6.05 s.
|
||||
10.0 s the publisher. `firmware/goodwe-master.yaml:181`,
|
||||
`update_interval: 10s` on the `modbus_controller` that carries both
|
||||
0x0518 and 0x050E. A command that moves right after a poll is not
|
||||
visible until the next one.
|
||||
1.0 s this add-on's own read period - `run_control()`'s
|
||||
`await asyncio.sleep(1)`, which bounds the loop body that calls
|
||||
`read_inputs()`.
|
||||
|
||||
UPPER BOUND, 75.0 s, the tighter of the two consumers this card must protect,
|
||||
both read from `config.yaml` on 2026-09-06:
|
||||
|
||||
75 s `circulation_hold_s` (config.yaml:512) - SAFETY-31 trips here.
|
||||
85 s `unit_sign_disagree_s` (config.yaml:451) - SAFETY-30's per-unit sign
|
||||
check trips here.
|
||||
|
||||
A gate slower than either is a gate that hands its consumer the frozen number
|
||||
and then explains afterwards. ⚠️ AND THAT INEQUALITY IS GRADED, NOT ASSERTED -
|
||||
`test_freshness.py` part 6 reads BOTH ends out of the shipped `config.yaml`
|
||||
and goes red if an operator's default moves under this bound.
|
||||
|
||||
35.0 s IS THE CHOSEN VALUE, and the admissible band is narrow enough that it
|
||||
is nearly forced: at least 2x the 17.0 s floor (34.0) and at most half the
|
||||
75.0 s consumer window (37.5) leaves [34.0, 37.5]. 35 is the integer in it.
|
||||
⚠️ DELIBERATELY NOT 30 AND NOT 60. 30 is `validate.FIRMWARE_INGEST_VETO_S` and
|
||||
would read as a borrowed constant nobody re-derived; 60 is round, and a round
|
||||
number here would be a guess wearing a measurement's clothes.
|
||||
|
||||
WHAT IT COSTS, stated rather than absorbed: a frozen 0x0518 on a unit that is
|
||||
never asked to move 250 W is not detected by this module at all.
|
||||
|
||||
⚠️ THAT COST IS BOUNDED FOR ONE CONSUMER AND NOT FOR THE OTHER, and an earlier
|
||||
version of this note claimed both. TRUE for the per-unit sign check: no such
|
||||
check can speak about that unit either, since `unit_sign_disagree_w` is 250 W.
|
||||
FALSE for circulation. "Contributing at most 250 W" bounds the COMMAND - but a
|
||||
frozen register's REPORTED value is unrelated to its command, and that is
|
||||
precisely HW-01's shape: commanded ~0 W, register stuck on the last large
|
||||
number. Measured in review: a unit frozen at -1200 W beside a healthy +900 W
|
||||
sibling gives `circulating_w` = 1800 W against a 700 W floor, 2.6x over, with
|
||||
no gate armed. So this gate is scoped to where the SIGN consumer can be misled;
|
||||
the circulation consumer keeps an unbounded exposure below MOVE_W and that is
|
||||
a known, stated gap rather than a covered one.
|
||||
|
||||
`soc` / 0x050E, U16, 1 %, published every 10 s - STATED, NOT WIRED
|
||||
-----------------------------------------------------------------
|
||||
⚠️ THIS ROW IS THE POLICY'S ANSWER FOR TEL-08 AND IT IS NOT CONNECTED TO
|
||||
ANYTHING. It is here because the third clause of the policy is "state each
|
||||
bound and its derivation", and because a reader who finds only `batt` wired
|
||||
would reasonably conclude SoC was forgotten. It was not; it needs a
|
||||
corroborator this card cannot derive.
|
||||
|
||||
SoC CANNOT USE THE CONJUNCTION ABOVE. A pack resting at its floor holds one
|
||||
percent for hours with nothing wrong - the card's own example - and the
|
||||
standing command says nothing about it, because a command of 0 W is exactly
|
||||
what a rested pack is given. The physically obliged movement for SoC is
|
||||
ENERGY, not command travel: one percent of pack A is about 153.6 Wh (15.36 kWh
|
||||
nominal, goodwe/NOTES.md §1.1b's table - ⚠️ NOMINAL and FROM DATASHEET, not a
|
||||
measured usable capacity), so at pack A's 5000 W ceiling one percent takes at
|
||||
least 110 s and at a realistic 1000 W about 553 s. The bound TEL-08 needs is
|
||||
therefore "SoC unchanged while the integral of |battery power| since its last
|
||||
change exceeded K x 153.6 Wh", and K cannot be chosen from anything written
|
||||
down today: nobody has measured this pack's usable capacity, its BMS reporting
|
||||
granularity, or how far its reported percent lags true state of charge.
|
||||
|
||||
So TEL-08 owes: that capacity measurement (or a defensible bound on it), the
|
||||
integrator, and K. It does NOT owe a second freshness mechanism - `Freshness`
|
||||
below takes its obligation term from the caller precisely so that an energy
|
||||
integral can be handed in where command travel is handed in here.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
# --- the three terms of the lower bound, each read out of this repository ----
|
||||
# See the module docstring for where each one is measured. They are separate
|
||||
# names rather than a literal 17.0 so that a reader who changes the firmware's
|
||||
# `update_interval` can find this file by grepping for the number they moved.
|
||||
PLANT_SETTLE_S = 6.0 # goodwe/NOTES.md §5.9, measured 2026-08-22
|
||||
PUBLISH_PERIOD_S = 10.0 # firmware/goodwe-master.yaml:181
|
||||
READ_PERIOD_S = 1.0 # main.run_control()'s `await asyncio.sleep(1)`
|
||||
BATT_OBLIGED_S = PLANT_SETTLE_S + PUBLISH_PERIOD_S + READ_PERIOD_S # 17.0
|
||||
|
||||
# The unchanged bound for register 0x0518. See the derivation above; the band is
|
||||
# [2 * BATT_OBLIGED_S, 0.5 * circulation_hold_s] = [34.0, 37.5].
|
||||
BATT_UNCHANGED_S = 35.0
|
||||
|
||||
# How far the standing command must have travelled for the register to be
|
||||
# OBLIGED to have followed it.
|
||||
#
|
||||
# ⚠️ IT IS THE LARGEST ADMISSIBLE VALUE, NOT A SENSITIVITY PREFERENCE, and that
|
||||
# is what makes it a derivation. The gate has to arm everywhere a stale reading
|
||||
# could mislead a consumer; the earliest either consumer speaks is
|
||||
# `unit_sign_disagree_w`, 250 W (config.yaml:450), because below that
|
||||
# `watchdog.disagrees()` is False whatever the measurement says. Taking the
|
||||
# ceiling of the admissible range is what minimises spurious arming. A smaller
|
||||
# number would arm the gate in states where nothing downstream can be misled,
|
||||
# and every such arming is a spurious site stop bought for nothing.
|
||||
#
|
||||
# ⚠️ NOT A CONFIG KEY. CFG-03 tracks the places a default has to be kept in
|
||||
# step and this does not earn another one - it is derived from a default
|
||||
# `config.yaml` already carries, and `test_freshness.py` part 6 READS that file
|
||||
# and goes red if the two drift.
|
||||
MOVE_W = 250.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bound:
|
||||
"""One signal's freshness bound, and whether anything consumes it."""
|
||||
|
||||
signal: str
|
||||
unchanged_s: float
|
||||
move_w: float
|
||||
wired: bool
|
||||
derivation: str
|
||||
|
||||
|
||||
# ⚠️ THE POLICY AS DATA, AND THE `wired` COLUMN IS LOAD-BEARING. A reader must be
|
||||
# able to tell, without reading main.py, which of these bounds is actually
|
||||
# gating a consumer today. `test_freshness.py` part 7 greps the shipped app/ for
|
||||
# each row and refuses a row that claims to be wired and is not - and a row that
|
||||
# claims not to be and is.
|
||||
BOUNDS = {
|
||||
"batt": Bound(
|
||||
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
|
||||
unchanged_s=BATT_UNCHANGED_S,
|
||||
move_w=MOVE_W,
|
||||
wired=True,
|
||||
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
|
||||
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]"),
|
||||
"soc": Bound(
|
||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||
unchanged_s=float("inf"),
|
||||
move_w=float("inf"),
|
||||
wired=False,
|
||||
derivation="TEL-08. Command travel is the WRONG obligation term for "
|
||||
"SoC - a rested pack holds a percent for hours under a 0 W "
|
||||
"command. The term is energy: >= K x 153.6 Wh through the "
|
||||
"pack since the reading last changed. K needs a measured "
|
||||
"usable capacity nobody has taken."),
|
||||
}
|
||||
|
||||
|
||||
class Freshness:
|
||||
"""One signal's arrival record, and the stale verdict it supports.
|
||||
|
||||
⚠️ ARRIVAL-DRIVEN THROUGHOUT. Every clock reading this object ever makes is
|
||||
handed to `submit()` by the caller at the moment a value arrived; nothing
|
||||
here reads a clock of its own and nothing here reads a Home Assistant
|
||||
timestamp. That is what makes the verdict replayable - `test_freshness.py`
|
||||
drives real seconds through it without sleeping - and it is the difference
|
||||
between this and the value-stamped veto TEL-06 is blocked on.
|
||||
|
||||
⚠️ THE VERDICT LATCHES, AND WITHOUT THAT IT OSCILLATES AT ABOUT 1 Hz.
|
||||
Declaring the reading stale makes `Controller.inputs_ok` False, which
|
||||
commands 0 W (§11.1 rule 1), which drops the standing command below
|
||||
`MOVE_W`, which un-arms the conjunction, which would return the held number
|
||||
to the consumers - and round again every read. So the ONLY thing that clears
|
||||
the latch is an arrival carrying a DIFFERENT number, which is exactly the
|
||||
evidence the verdict was about. A register that is working clears it on its
|
||||
next 10 s publish; a register that is not, does not.
|
||||
"""
|
||||
|
||||
def __init__(self, bound: Bound):
|
||||
self.bound = bound
|
||||
# The last number that ARRIVED, or None if the most recent read did not
|
||||
# produce one. This is what `reading()` hands back when the gate is open,
|
||||
# so a read failure is still an immediate None exactly as it is today.
|
||||
self.last = None
|
||||
# The number the reading is currently HOLDING, and the monotonic instant
|
||||
# of the arrival that last carried a different one. `held` survives a
|
||||
# failed read; the unchanged clock is about the value, not about our
|
||||
# ability to fetch it.
|
||||
self.held = None
|
||||
self.changed_mono = None
|
||||
# The standing command at that same instant. The conjunction compares
|
||||
# against THIS, not against the previous cycle's command: what matters
|
||||
# is whether the register has followed anything since it last moved.
|
||||
self.standing_at_change = None
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.arrivals = 0
|
||||
|
||||
def submit(self, value, standing_w, now_mono) -> None:
|
||||
"""One READ of this signal. `value` None means nothing arrived.
|
||||
|
||||
`value` the number `hass.number()` produced, or None.
|
||||
`standing_w` what this controller currently has standing on this unit's
|
||||
wire, signed, + = discharge. None when nothing is known.
|
||||
`now_mono` the caller's monotonic clock, in seconds.
|
||||
|
||||
⚠️ A FAILED READ IS NOT AN ARRIVAL AND IS NOT AN AGE EITHER. It clears
|
||||
`last` immediately - which is byte-for-byte what shipping code does
|
||||
today, a missing reading being a missing reading - and it leaves the
|
||||
unchanged clock running, because the value has not changed and our
|
||||
inability to fetch it is not evidence that it has.
|
||||
"""
|
||||
if value is None:
|
||||
self.last = None
|
||||
return
|
||||
value = float(value)
|
||||
self.arrivals += 1
|
||||
if self.changed_mono is None or value != self.held:
|
||||
self.held = value
|
||||
self.changed_mono = float(now_mono)
|
||||
self.standing_at_change = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
# Movement is the ONLY thing that clears the latch. See the class
|
||||
# docstring on the 1 Hz oscillation this prevents.
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.last = value
|
||||
self._grade(standing_w, now_mono)
|
||||
|
||||
def _grade(self, standing_w, now_mono) -> None:
|
||||
"""The conjunction. Sets the latch; never clears it."""
|
||||
if self.stale or self.changed_mono is None:
|
||||
return
|
||||
held_s = self.unchanged_s(now_mono)
|
||||
if held_s < self.bound.unchanged_s:
|
||||
return
|
||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
|
||||
# always answers a float, so neither branch is reachable from the wired
|
||||
# consumer today. They are here for TEL-08, whose obligation term is an
|
||||
# energy integral that has nothing to say until it has a window - and
|
||||
# "nothing to say" must withhold the verdict, not manufacture one.
|
||||
if standing_w is None or self.standing_at_change is None:
|
||||
return
|
||||
standing_w = float(standing_w)
|
||||
if abs(standing_w) < self.bound.move_w:
|
||||
return
|
||||
moved_w = abs(standing_w - self.standing_at_change)
|
||||
if moved_w < self.bound.move_w:
|
||||
return
|
||||
self.stale = True
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
|
||||
f"while the standing command moved {moved_w:.0f} W to "
|
||||
f"{standing_w:+.0f} W - the reading is not following, so it is "
|
||||
f"reported as MISSING rather than as a measurement (limits: "
|
||||
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
|
||||
f"{self.bound.move_w:.0f} W and moved by over "
|
||||
f"{self.bound.move_w:.0f} W)")
|
||||
|
||||
def unchanged_s(self, now_mono) -> float | None:
|
||||
"""Seconds since an arrival carried a different number. Status page."""
|
||||
if self.changed_mono is None:
|
||||
return None
|
||||
return max(0.0, float(now_mono) - self.changed_mono)
|
||||
|
||||
def reading(self):
|
||||
"""What the consumers get: the value, or None when it cannot be trusted.
|
||||
|
||||
⚠️ `None`, NEVER A HELD NUMBER, and never a zero. Both consumers already
|
||||
do the right thing with None and neither could ever receive one:
|
||||
`watchdog.SignWatchdog.evaluate` CLEARS its window on a None measurement
|
||||
rather than accumulating it, and `circulation.circulating_w` returns None
|
||||
for a roster with a missing term, which clears the circulation window
|
||||
too. This module is the thing that was missing between them.
|
||||
"""
|
||||
return None if self.stale else self.last
|
||||
|
||||
def note(self, unit_name: str) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
return f"{unit_name}: {self.reason}" if self.stale else ""
|
||||
|
||||
|
||||
def monotonic() -> float:
|
||||
"""The clock this module's callers use, in one place so tests can find it.
|
||||
|
||||
⚠️ `time.monotonic()`, not the wall clock, for `p1.P1Sample.age_s`'s reason:
|
||||
an NTP step on a Raspberry Pi that has just found the network is routinely
|
||||
minutes, and a freshness verdict computed across one would either fake a
|
||||
stale reading or, worse, hide a real one.
|
||||
"""
|
||||
return time.monotonic()
|
||||
@@ -122,6 +122,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():
|
||||
|
||||
@@ -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) \
|
||||
|
||||
@@ -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")
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user