diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 754a4a1..bd2141a 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -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 diff --git a/goodwe_controller/DECISIONS/SAFETY-37-freshness-policy.md b/goodwe_controller/DECISIONS/SAFETY-37-freshness-policy.md new file mode 100644 index 0000000..ec8e2d3 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-37-freshness-policy.md @@ -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". diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 888522b..a4d28f8 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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 diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index 226a792..9f3c565 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -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 diff --git a/goodwe_controller/app/freshness.py b/goodwe_controller/app/freshness.py new file mode 100644 index 0000000..f4116bc --- /dev/null +++ b/goodwe_controller/app/freshness.py @@ -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() diff --git a/goodwe_controller/app/inverter.py b/goodwe_controller/app/inverter.py index d4e43ad..d1e0b8b 100644 --- a/goodwe_controller/app/inverter.py +++ b/goodwe_controller/app/inverter.py @@ -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(): diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 003bf82..b685ba7 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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) \ diff --git a/goodwe_controller/test_freshness.py b/goodwe_controller/test_freshness.py new file mode 100644 index 0000000..ab6affd --- /dev/null +++ b/goodwe_controller/test_freshness.py @@ -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") diff --git a/goodwe_controller/test_inverter.py b/goodwe_controller/test_inverter.py index db4f734..8aea87c 100644 --- a/goodwe_controller/test_inverter.py +++ b/goodwe_controller/test_inverter.py @@ -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"), }