Compare commits

...
6 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 8e241a3243 SAFETY-37: correct a wall-clock tick and a false size claim
checks / unit (pull_request) Successful in 5m32s
Two blocking findings from review, both in prose, both corrected at every
site.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 12:35:27 +02:00
glenn schrooyenandClaude Opus 5 6864109a01 SAFETY-37: a stale reading is None, and arrival cannot be what says so
checks / unit (pull_request) Successful in 5m34s
The architect pass made SAFETY-37, TEL-06 and TEL-08 one systemic problem -
"a stale reading holds its last value and reads healthy" - and this builds the
single policy. Both consumers the card names are gated by one change, and
neither consumer needed a line: `SignWatchdog.evaluate` already CLEARS its
window on a None measurement and `circulating_w` already returns None for a
roster with a missing term. Neither could ever receive one. The gate is the
piece that was missing BETWEEN the shipped read and the shipped checks.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 11:53:26 +02:00
admin a4290d39ac Merge pull request 'SAFETY-39: slew_w is a ratio to the rail, and gain is the constant that is still wrong' (#56) from SAFETY-39 into release/1.0 2026-09-06 10:41:03 +02:00
glenn schrooyenandClaude Opus 5 834e7ca44b SAFETY-39: correct the gain figures, which were not the measured ones
checks / unit (pull_request) Successful in 5m15s
Review found validate.py:288-289 carrying numbers that appear nowhere
else in the PR's evidence and could not be reproduced: over-absorption
"1233 W at slew 1000 and 1344 W at slew 250 ... 1495 W at 0.6, 961 W at
0.3, ~0 W at 0.2". A grep for 1233/1344/1495 found them ONLY in that
comment - the DECISIONS record, the PR body, test_control.py's audit
table and slew_sim's own output all carried the correct ones.

The measured values, from re-running tools/slew_sim.py on the real plant
model at the stated conditions (max_w 5000, 2 s tick, 51 export steps
1.0-4.2 kW): slew column 958 (slew 1000) and 1081 (slew 250); gain column
987 (0.6), 876 (0.3), 435 (0.2).

⚠️ "~0 W at 0.2" WAS WRONG IN THE UNSAFE DIRECTION. It invited a future
reader to conclude that dropping gain to 0.2 removes over-absorption. It
does not: 435 W remains, which is 44 % of the figure at 0.6, not zero.
The corrected text says so at the line rather than leaving a round number
to be trusted.

The conclusion the paragraph draws is unchanged and still holds on the
measured figures: over-absorption is flat in slew_w and falls in gain, so
`gain` is the constant that would have to move, and SAFETY-39
deliberately does not move it.

No behaviour change; comment only. Full suite: 21 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 01:48:52 +02:00
adminandClaude Opus 5 5d7e275df4 SAFETY-39: slew_w is a ratio to the rail, and gain is what is still wrong
checks / unit (pull_request) Successful in 5m16s
`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where
"capping how far the loop can jump ahead of the inverter" was true because the
rail itself bounded the excursion. The live install went to max_w 5000 and the
constant did not move, so the loop crosses its whole output range in 10 s
against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system
2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five
2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from
3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved.

REPRODUCED FIRST, and one half of it is not reproducible at all. On
plantsim.Plant and on an independent in-repo model: the setpoint stepping a full
1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from
export into import. The -5154 W figure is NOT reproducible in either model,
because both clamp the plant at its rating - so the card's AC "battery no longer
exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met.

THE DELIVERABLE IS THE RELATIONSHIP.
  SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9
  slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as
  integrator_max_w = 0 already means follow max_w. It is the shipped default:
  222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site.
  validate.py rule 10 refuses an explicit value faster than that, graded against
  sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a
  second inverter moves the bound with no operator action.

VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the
refusal names the rail, the crossing time, the measurement and what to do.
Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating
all still start.

GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51
export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250)
and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the
OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W
rail in under three ticks whatever the slew is. Two control-law constants moving
in one release on a running house makes the next measurement uninterpretable.

⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND
SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the
enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS
this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant
did - both stated in the record rather than left to be discovered.

Nothing reached the inverter. The live install still runs the interim slew_w 400
applied by hand on 2026-09-05, which rule 10 accepts unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 01:10:15 +02:00
admin 24c3d87d60 Merge pull request 'SAFETY-38: pin site_standing with shares that cannot answer for the site' (#53) from SAFETY-38 into release/1.0 2026-09-06 00:00:48 +02:00
26 changed files with 3568 additions and 54 deletions
+66
View File
@@ -31,6 +31,72 @@ 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
to `max_w / 9`: 222 W at the shipped 2000 W cap, 556 W at the live install's
5000, 1067 W at a ~9600 W two-inverter site. The 9 is
`PLANT_SETTLE_S / LOOP_TICK_S` - an 18 s measured step response against a 2 s
meter tick - so the loop takes the plant's own settling time to cross its whole
output range at any rail.
**An install that has never set `slew_w` will therefore command smaller steps
than it did on 0.7.0, and will converge more slowly after a large disturbance.**
That is the point rather than a side effect: `slew_w: 1000` was derived against
a **1500 W** clamp in 2026-08 and the live install has run it against 5000 since,
which crosses the whole range in 10 s against an 18 s plant. Measured on the
owner's system 2026-09-05: a 3.4 kW export drove the setpoint 380 -> 5000 W in
five ticks, the battery to -5154 W past a 5000 VA rating, and the grid from
3.4 kW of **export** to 2.8 kW of **import**.
An explicit `slew_w` is still accepted and is never silently reduced. What is
new is that a value **faster than the rail allows** is now **REFUSED AT STARTUP**
(`validate.py` rule 10), graded against the ROSTER SUM - so `slew_w: 1000` with
`max_w: 5000` will not start. Set it to `0`, or to any value at or below
`max_w / 9`. Slower is always accepted, including the interim 400 the reference
install has run since 2026-09-05.
⚠️ **This does NOT fix the over-absorption, and the record says so at length.**
Re-deriving the slew removes the >= 900 W setpoint step; the loop still absorbs
~1 kW more than the site needs on a large export step, because that is a `gain`
term and `gain: 0.6` was measured and deliberately left alone. See
`DECISIONS/SAFETY-39-slew-against-the-rail.md` §6, and do not read this entry as
more than it says.
**`test_peakguard.py` answered two of its own checks with the wall clock
(CI-06).** The file sits on the required `checks / unit (pull_request)` context
and had a measured ~20 % flake rate. It cost a wrong merge diagnosis (parents
@@ -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".
@@ -0,0 +1,438 @@
# SAFETY-39 — `slew_w` is a ratio to the rail, and `gain` is the constant that is still wrong
**Status:** decided and implemented on branch `SAFETY-39`.
**Card:** SAFETY-39 — *"`slew_w` was derived at a 1500 W clamp and runs at 5000,
so the loop outruns the plant"*.
**Nothing here reached the inverter.** The live install still runs the interim
`slew_w: 400` applied by hand on 2026-09-05 12:00, which this branch's new rule
accepts unchanged.
---
## 0. The three things a reader should take away
1. **The overshoot is reproduced in simulation, and one half of it is not
reproducible at all.** The setpoint stepping to the rail and the meter
crossing from export into import are both reproduced, on two independent
plant models. The **−5154 W battery figure is not reproducible in either**,
because both clamp the plant at its rating. An acceptance criterion written
on it would pass on the broken configuration too, so none is. §2.
2. **The deliverable is the relationship, not the number.** `slew_w: 0` now
means *follow the rail* and resolves to `SLEW_RAIL_FRAC * max_w`;
`validate.py` rule 10 refuses an explicit value faster than that against the
**site** rail. Verified by breaking it. §4, §5.
3. ⚠️ **`slew_w` does not own the over-absorption. `gain` does, and `gain` was
not changed.** Re-deriving the slew removes the ≥ 900 W step — the symptom
that escalated 2 → 1 → 13 → 43 per 30 min on the morning of the excursion —
and removes nothing else. Anyone reading this card as "the overshoot is
fixed" is reading more than was delivered. §6.
---
## 1. The defect, stated in the terms the code uses
`slew_w: 1000` was derived on 2026-08-22 against a **clamp of 1500 W**
(goodwe/`NOTES.md`, *"Simulated 2 kW load step (gain 0.6, clamp 1500)"*). Its
stated justification:
> `slew: 1000` is the default because it puts most of the correction in the first
> cycle **while still capping how far the loop can jump ahead of the inverter**.
At 1500 W that sentence is true, and the reason is the rail rather than the
number: 1000 W/cycle reaches the rail in two cycles, so the excursion is bounded
by 1500 W whatever the loop does.
The live install then went to `max_w: 5000` and the constant did not move.
`DRIVER-07` ("raise actuator power cap to full inverter rating") is still in To
Do; the cap was raised ahead of it and `slew_w` was never revisited. **Nothing
in the code is broken.** The rail moved and the derivation did not.
| | derived at | shipped default | live install |
|---|---|---|---|
| `max_w` | **1500** | 2000 | **5000** |
| `slew_w` | 1000 | 1000 | **1000** |
Measured on the owner's system, 2026-09-05 11:43–11:46:
```
11:43:55 grid -3385 W a real 3.4 kW export
11:43:56 SP 380 → 1380 → 2380 → 3380 → 4380 → 5000 +1000 per 2 s tick
11:44:47 batt -5154 W ABOVE the 5000 VA rating
11:45:55 grid +2831 W flipped to 2.8 kW IMPORT
SP 5000 → 2930 → 3840 → 3750 hunting
```
Large (≥ 900 W) setpoint steps per ~30 min that morning: `2 → 1 → 13 → 43`.
---
## 2. ⭐ Reproduced first — and what could not be
`tools/slew_sim.py --repro`, against admin/goodwe's `plantsim.Plant`, the real
`compute()`, and the firmware's own encoder on the wire. Gain 0.6, `max_w` 5000,
`slew_w` 1000, 2 s tick, one 3385 W export step at t = 10 s:
```
t grid in setpoint battery grid out reason
10 -3739 -280 290 -3675 slew-limited
12 -3675 -1280 57 -3442 slew-limited
14 -3442 -2280 -316 -3069 slew-limited
...
34 -1012 -5000 -2823 -562 i-clamped ← the rail
38 -167 -5000 -3541 156 saturated-freeze ← EXPORT → IMPORT
44 562 -4310 -4018 633 saturated-freeze
50 544 -3210 -3804 419 tracking
```
Reproduced: the setpoint stepping a full 1000 W per tick to the rail, the loop
absorbing ~640 W more than the site needed, and the meter crossing from **3.7 kW
of export to +633 W of import** — the loop manufacturing the error it then
chases. Over a family of 51 export steps (1.0–4.2 kW × 3 phases), **48 of 51**
runs contain a ≥ 900 W setpoint step.
### ⚠️ What is NOT reproducible, and why no criterion is written on it
`plantsim.Plant.limit()` clamps the plant at `p_max`. The modelled battery
**cannot exceed its rating by one watt, at any tuning, on any step, ever.**
Neither can `test_control.py`'s independent model. So:
* the **−5154 W excursion is not reproducible in simulation at all**, and this
is a property of the models rather than of the tuning;
* the card's AC *"battery power no longer exceeds `max_w` on a step of the size
observed"* is **unfalsifiable in simulation** — it passes identically on the
configuration that produced the excursion. It is the one acceptance criterion
this card had to **rewrite rather than meet**, per the board's own rule that a
third review round means the AC is wrong.
What replaced it, and what is graded in CI: **the ≥ 900 W step is gone from
every member of the family**, and **the meter excursion and the over-absorption
are measured and reported rather than claimed to be fixed.**
### The two models agree
`test_control.py`'s in-repo first-order lag and admin/goodwe's ramp-plus-lag
`plantsim.Plant` are independent, and they agree on every sign and rank:
| | in-repo model | `plantsim.Plant` |
|---|---|---|
| ≥ 900 W steps at `slew_w` 1000 | 48/51 | 48/51 |
| ≥ 900 W steps at the derived value | 0/51 | 0/51 |
| over-absorption, slew 1000 → 250 | 1005 → 1330 W | 958 → 1081 W |
| over-absorption, gain 0.6 → 0.2 | 1109 → 393 W | 987 → 435 W |
---
## 3. The derivation
The constraint, in words, and it is the 2026-08-22 sentence made checkable:
> **The loop must not traverse its full output range faster than the plant takes
> to settle.**
Three measured quantities, and each is named where it came from:
| | value | where it was measured |
|---|---|---|
| plant settling time | **18 s** | goodwe/`NOTES.md` §5.10, 2026-08-22 — a 1.78 kW kettle step driven from HA off a plug that meters its own draw. ~18 s up to the deadband, ~23 s down. **18 is taken because it is the shorter and therefore the looser bound**; this is a refusal boundary, and rules 5 and 6 refuse the impossible case only, never "close to the rail". |
| control tick | **2 s** | the live install, measured 2026-09-05 — the setpoint stepped +1000 W every 2 s. `run_control()` runs a cycle on a changed meter value or on `cycle_floor_s`, whichever comes first, so the tick is the *meter's* rate. |
| output range | `max_w` | `main.py` builds `Tuning(max_w=self.site_max_w)`, the **roster sum**. |
```
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 0.1111 (9 ticks)
slew_w ≤ SLEW_RAIL_FRAC × max_w
```
| rail | derived `slew_w` | ticks to cross | seconds to cross |
|---|---|---|---|
| 1500 (where the original was derived) | 167 W | 9 | 18 s |
| 2000 (shipped default) | 222 W | 9 | 18 s |
| **5000 (the live install)** | **556 W** | 9 | 18 s |
| 9600 (with inverter B — **datasheet, not measured**) | 1067 W | 9 | 18 s |
And the configuration that was measured failing: `slew_w` 1000 at `max_w` 5000
crosses the rail in **5.0 ticks = 10 s**, against a plant that takes 18 s.
### ⚠️ The tick is the weak term and it is not wrong in the safe direction
A **slower** meter makes this bound conservative — it refuses configurations that
would have been fine, which is visible immediately and costs one edit. A meter
**faster** than 2 s makes it loose by exactly that ratio and nothing in this
add-on would say so. `run_control()`'s `await asyncio.sleep(1)` bounds the tick
below at 1 s, so the worst case is a factor of two — but if a faster meter is
ever fitted, `LOOP_TICK_S` has to be **re-derived, not merely re-read**. Compare
`validate.FIRMWARE_INGEST_VETO_S`, which carries the same kind of copy and can
only ever be wrong the other way; that difference is why both numbers are
written out in `control.py` rather than their quotient.
### ⚠️ The rig cannot pick 556 out of the table, and is not asked to
`tools/slew_sim.py --slew`, worst case over 51 export steps:
```
slew_w 1000 ← 2026-09-05 live pair step 1000 W 48/51 ≥900 W over-absorbed 958 W
slew_w 800 step 800 W 0/51 ≥900 W over-absorbed 1107 W
slew_w 700 step 700 W 0/51 ≥900 W over-absorbed 1133 W
slew_w 600 step 600 W 0/51 ≥900 W over-absorbed 1058 W
slew_w 555 ← the derived value step 560 W 0/51 ≥900 W over-absorbed 987 W
slew_w 500 step 500 W 0/51 ≥900 W over-absorbed 1029 W
slew_w 400 ← the live interim step 400 W 0/51 ≥900 W over-absorbed 1150 W
slew_w 300 step 300 W 0/51 ≥900 W over-absorbed 1143 W
slew_w 250 step 250 W 0/51 ≥900 W over-absorbed 1081 W
```
The ≥ 900 W step disappears at 800 and stays gone. Below that the table is
**flat and slightly non-monotone**, and 800 is *better* than 556 on
over-absorption. So the rig does not choose the number — **the constraint
chooses it, and the rig says what the constraint does and does not buy.** That
is the same relationship the 2026-08-22 table had to its own three columns.
⚠️ **A single step is not evidence here.** The deadband, the 10 W quantisation
and the phase of the step against the tick move the result by hundreds of watts,
non-monotonically. The first cut of this derivation was a single step and ranked
gain 0.3 as *worse* than 0.6, which the family shows is backwards. Every number
above is a worst case over a family, which is how admin/goodwe grades the same
kind of quantity (`config.yaml`'s "1890 closed-loop runs", "924 runs").
---
## 4. What ships — the relationship, not the number
**`slew_w: 0` means FOLLOW THE RAIL.** `control.slew_limit_w()` resolves it to
`SLEW_RAIL_FRAC * max_w`. It is the shipped default and it is the only value
that cannot be left behind when the rail moves. This is the idiom
`integrator_max_w: 0` has carried in the same options block since it was added —
not a new concept, the same one, applied to the key that needed it.
An explicit number is still accepted **and is never silently reduced**: the live
install runs 400, and silently reducing an operator's stated bound is case (a)
in `validate.py`'s own docstring — the defect that whole module exists to
prevent.
**`validate.py` rule 10** refuses an explicit `slew_w` above
`SLEW_RAIL_FRAC × site rail`. Graded against **`sum(unit_caps(opts))`**, the
roster sum — the same number `Controller.site_max_w` builds — and *not* against
the flat `max_w` and *not* against the weakest unit. Rules 5, 6 and 8 take
`min()` because each grades one watchdog, one shaping claim, one pack. `slew_w`
is not a per-unit quantity at all: `compute()` emits the **site** target and the
allocator divides it afterwards.
⚠️ `config.yaml`'s note on `site_sign_disagree_w` says there is deliberately no
rule pairing a site quantity with `max_w`, "because … `validate_startup_config()`
is handed options rather than a roster". That objection is to grading a site
quantity against the **flat key**, and it is answered rather than ignored:
`unit_caps()` already reads the roster the add-on will build, so the roster sum
*is* available at startup, and `site_rail_w()` is that number.
---
## 5. ⭐ Verified by breaking it
`test_validate.py`, section 10 — the acceptance criterion written as a test:
```
restoring slew_w 1000 at max_w 5000 -> 1 problem(s)
slew_w is 1000 W against a site rail of 5000 W, so the loop crosses its
whole output range in 5.0 cycles - 10 s at the 2 s tick this site was
measured running - while the plant takes 18 s to settle …
```
and the four things the refusal has to say, each graded separately: which rail
it used, how long the crossing takes, which measurement it is graded against,
and what to do about it (including `0`, which cannot go stale). Plus:
* **equality starts** — at exactly the derived value the crossing takes exactly
the settling time, which is the constraint met, not violated (rule 6's case,
not rule 5's; getting it backwards would refuse the recommended value);
* **one watt of slack starts** — a boundary that is not meaningful to one watt
must not refuse on one watt;
* **the live interim `slew_w: 400` starts**, and so does the schema floor —
there is no such thing as too slow here, and refusing it would refuse the
mitigation that is holding;
* **`slew_w: 0` starts at every rating from 100 W to 15 kW.**
`test_control.py` grades the same boundary from the law's side, so the rule can
only ever refuse what the ratio says is too fast, and the two cannot drift apart.
---
## 5b. What the change found in this repository's own tests
Five files moved, and **none of them moved because a number was rounded
differently**. Each is worth reading as a finding.
**Two fixtures encoded the defect.** `test_inverter.py`'s `BASE_OPTS` paired
`slew_w: 1000` with `max_w: 5000` — the 2026-09-05 configuration exactly — and
`test_loop.py`'s `BASE` paired 1000 with 2000. Both were typed when those rails
were new; nothing in either file had noticed. Rule 10 refused them on its first
run, so those fixtures **would not start**. That is what a rule about a
*relationship* is for, and it is the clearest evidence the rule is not a
formality.
**Two reproductions depended on a default they never named.**
`test_alloc.py`'s `close_loop` and `test_freeze.py`'s "make both terms
reachable" case reached `slew_w: 1000` through `Tuning`'s default. ARCH-01's
4000 W broadcast limit cycle and SAFETY-18's proof-of-capability alarm would
both have gone quiet — reporting a measured finding as *absent* because a
default moved underneath them. Both now write the number beside the measurement
it belongs to.
**One harness had a latent off-by-one that only a ramping loop could expose.**
`test_watchdog.py` graded SAFETY-02's banner by pairing `c.grid` with
`last_write` sampled **after** `cycle()`, while the detector is evaluated
**inside** `cycle()` against the *standing* command from the previous one. At
`slew_w: 1000` the loop reached its rail in two cycles and consecutive commands
were identical, so the wrong pair and the right pair were the same numbers. At a
rail-following slew they differ by one step and the check went red. `_drive()`
now has a `pre` hook that reads the operands the detector will actually use, and
the check reads the **latching** cycle rather than the one before it. The
assertion is strictly stronger than it was.
**One tripwire had two assertions that were exact by coincidence.**
`test_peakguard.py` part 10c read `limited_discharging == limited` (49/49) and
`min_soc <= bms_soc_floor + 0.05`. At the slower slew the loop drains that pack
~82 Wh less over the same two hours and no longer touches the cut-out *inside
that window*: 48/49, low-water 10.57 % against a 10 % cut-out, 0 cycles at the
BMS limit instead of 190. **The finding is unchanged and is sharper on the
measure that carries it** — 162 cycles of granted discharge below the EMS floor,
up from 138. The two exact equalities were artefacts of one tuning, the old and
new numbers are tabulated in the file beside the checks, and the assertions now
grade the substance. ⚠️ *Loosening an assertion to get a branch green is the
first thing a reviewer should suspect here; the table is there so the suspicion
can be settled rather than argued.*
---
## 6. ⚠️ `gain: 0.6` — measured, and deliberately NOT changed
The card asks whether `gain` still holds at this clamp. **It does not, and here
is the measurement.**
`tools/slew_sim.py --gain`, `slew_w` at the derived 556 W, same 51-step family:
```
gain 0.6 ← shipped over-absorbed 987 W meter driven to +987 W
gain 0.5 over-absorbed 941 W
gain 0.4 over-absorbed 977 W
gain 0.3 over-absorbed 876 W
gain 0.2 over-absorbed 435 W
gain 0.1 over-absorbed 66 W
```
Against the slew column in §3, which is flat. The mechanism is visible in
`control.py` and is structural rather than empirical:
* **`slew_w` bounds the OUTPUT.**
* **The integrator accumulates `gain × error` per cycle** and is bounded only by
`integrator_max_w`. At gain 0.6 into a 3.4 kW error it moves **~2040 W per
tick** and reaches a 5000 W rail in **under three ticks, whatever `slew_w`
is**.
So the 2026-08-22 derivation's own sentence — *"the gain, not the slew, is the
limit by then"* — has become literally true, and `gain` is the term that owns
the over-absorption at this rail. It is written about a 1500 W rail, where
`gain × 1500 = 900 W` and the clamp caught it in two cycles; at 5000 there is
3.3× more room to wind before the clamp binds.
**Why nothing was changed:**
* `gain` is a live control-law constant on a running house. Two of them moving
in one release makes the next measurement uninterpretable — and the interim
`slew_w: 400` has been in service since 2026-09-05 12:00, so there is a clean
baseline to measure the next change against and it would be destroyed.
* A validator rule pairing `gain` with a rail would be **inventing a
relationship nobody has measured.** The bound would have to be
`gain × error_max ≤ SLEW_RAIL_FRAC × max_w`, and `error_max` is the
disturbance, which is not config and is not bounded.
* `integrator_max_w` is **not** the lever it looks like: it bounds the output as
well as the accumulator (`target = clamp(i_w, ±max_w)`), which is why setting
it to 2000 on the live system on 2026-09-05 capped the inverter to 2000 W of
its 5000 and was reverted the same day.
This is named in `validate.py`'s *"deliberately not refused"* list so that it is
a known open item rather than an omission, and it is asserted in
`test_control.py` so that nobody can quietly read this card as having fixed it.
**It wants its own ticket.**
---
## 7. N = 1 → N = 2: the rail is what moved
⚠️ **Inverter B does not exist on site** (SPIKE-04, ≈ 2026-09-12). Its figures
are from its **datasheet and serial**, not a measurement: GW5048D-ES, serial
`55048ESU22AW0992`, 4600 W, pack 4 × Dyness DL5.0C = 20.48 kWh, floor 10 %
(goodwe/`NOTES.md` §1.1b) — and the 4600 W is printed under *battery input data*,
so it may be DC, in which case the AC ceiling is nearer 4324 W.
⚠️ **And `sim/plantsim.py`'s `INVERTER_B` is inverter A copied**, carrying
`measured=False`. **Every two-inverter row below is a result about two copies of
A on a 10000 W rail, not about the ~9600 W site B implies.**
**The site rail becomes `sum(max_w)` ≈ 9600 W while `slew_w` is per-site.** The
derived value there is `0.1111 × 9600 = 1067 W` per tick, which at an even split
is 533 W per unit against each unit's own 5000/4600 W rating — ARCH-01's
split-invariance holding, as its `two_master_gain_sweep` already shows.
**Does the enforced relationship produce it automatically? Yes.** Rule 10 reads
`sum(unit_caps(opts))` and `slew_limit_w()` reads `Tuning.max_w`, which
`main.py` sets from `Controller.site_max_w`. Declaring inverter B moves both,
with no operator action and no second derivation. That is the property that
makes this a fix rather than a re-tune, and `test_validate.py` asserts the
9600 W arithmetic directly.
⚠️ **But the direction of the rule is not the one the ticket assumed, and it
must be said.** A rail that **grows loosens** this bound: `slew_w: 1000`
crosses 9600 W in 19.2 s and **passes**, while the same 1000 at 5000 W is the
configuration measured failing. That is what the constraint says and it is
correct — the bound is on how long the crossing *takes*. What the rule protects
against is the constant being left behind when the rail moves, in either
direction. **It is not a bound on how much power the site may command** (that is
each unit's own `max_w` through the arbiter's `supervised` claim) **and it does
not bound the over-absorption** (§6).
⚠️ **The ≥ 900 W symptom goes stale at N = 2 the same way the constant did.**
On the two-copies-of-A rows, 48/51 runs contain a ≥ 900 W step *at the derived
value*, because at a 10000 W rail the derived step **is** 1111 W. A check
written on the absolute 900 W would report the correct tuning as broken.
Anything grading this symptom at N ≥ 2 has to grade it as a **fraction of the
rail** too — which is this whole card, one layer out.
---
## 8. `max_w: 5000` — a recommendation, not a change
The battery reached **5154 W against a 5000 VA inverter**, so the configured cap
left no headroom for the overshoot that the loop was going to produce anyway.
Three things are worth writing down and none of them is changed here:
1. **The cap is the rating, so the overshoot has nowhere to go but past it.**
`compute()` bounds the *command* to `±max_w`; nothing bounds the plant's
response to a command at the rail, and §6 shows the loop asks for the rail on
any large step at gain 0.6. Recommend **`max_w` at 4500–4750 W**, i.e. 90–95 %
of the 5000 VA nameplate, leaving 250–500 W for the overshoot the loop still
has. This is `freeze.py`'s `RAIL_FRAC` argument applied to the cap itself.
2. **The −5154 W may not be an AC figure at all.** `batt_entity` is the pack
sensor and `max_w` is graded against `0x0518`, the inverter's **AC** power —
`config.yaml`'s roster comment already records exactly this ambiguity for
inverter B. If the battery reading is DC, 5154 W DC is ~4900 W AC and the
inverter was never over its nameplate. **Nobody has measured which**, and the
recommendation above is worth making either way.
3. **It is the owner's number.** `translations/en.yaml` marks `max_w` "THIS IS
YOUR NUMBER" precisely so it is not confused with a derived constant, and
changing it unilaterally in a pull request would undo that distinction.
`SAFETY-20`'s `min(max_w, dev_max)` settles the device side once ESPHome has
answered; the *headroom* question is a policy choice and belongs to the owner.
---
## 9. What was deliberately not done
* **`saturation_w` and the deadband were not touched.** The card forbids it and
it is right to: widening either hides the symptom. The loop is following its
command correctly; the command is wrong.
* **`gain` was not changed.** §6.
* **`max_w` was not changed.** §8.
* **`control.py`'s slew *limit* was not clamped to the bound.** An explicit
value above it is refused at startup, not quietly reduced — silently reducing
an operator's stated bound is case (a) in `validate.py`'s docstring.
* **Nothing in admin/goodwe was modified**, and nothing reached the inverter.
+37 -1
View File
@@ -295,6 +295,42 @@ the P1 sample already went stale. Size the pair together: the ESP32's own
watchdog commands 0 W after ~30 s of silence from this add-on regardless, and
that layer is unaffected by either option.
#### How long a FROZEN inverter power register takes to reach 0 W
A third clock, and it is a different failure from the one above: the entity is
still there and still answering — it has simply stopped changing. Before
SAFETY-37 that read as a healthy measurement for ever, which is what happened on
2026-09-02 (HW-01): a value held for 6 h 25 m while the pack drained to its
floor, and every detector read healthy throughout.
`Inverter.batt` — the inverter's own AC power, register `0x0518` — is now
withheld from the control path when **all three** of these are true:
| | |
|---|---|
| the reading has not changed for | **35 s** |
| **and** the command standing on that inverter is over | **250 W** |
| **and** that command has travelled, since the reading last moved, over | **250 W** |
All three, because a converged site legitimately repeats one power figure for
minutes — measured at up to **263 s** over 30 days of this house's own meter —
and a site at rest is not owed movement. It is only when the controller has
*asked the inverter to move* and the register has not answered that the reading
is treated as missing.
**A withheld reading is a MISSING INPUT**, so it joins the `stale_input_s` clock
above: 35 s to withhold, then 15 s of hold, then 0 W. It clears itself the
instant the register reports a different value — one watt is enough, and this
register dithers ±1 W while holding — so a working inverter clears it on its
next 10 s publish. There is **no option to configure**: the 35 s is derived from
the ESPHome publish interval and the two watchdog windows it has to stay inside,
and the add-on's own checks refuse a value that does not.
⚠️ **What this does NOT catch: a register whose value is WRONG rather than
STOPPED**, including a dead publisher whose last two samples happen to
alternate. Detecting that needs an arrival stamp inside the ESP32 firmware
itself, which is TEL-06.
There is **no fallback to an inverter-side power figure**, deliberately. The
inverter's own AC power tracks its battery almost perfectly and the real meter
hardly at all, so a controller that failed over to it would be regulating
@@ -432,7 +468,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
|---|---|---|
| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning. **Not read when `inverters` is declared** — each entry carries its own, and the site ceiling is their sum |
| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** |
| `slew_w` | 1000 | Maximum change per cycle |
| `slew_w` | 0 | Largest change in one cycle. **0 means follow the rail** - `max_w / 9`, i.e. the loop takes the plant's own measured ~18 s settling time to cross its whole output range at the 2 s tick the live install runs. 222 W at `max_w: 2000`, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. A fixed number is accepted and never silently reduced, but one FASTER than the rail implies is refused at startup (SAFETY-39): `slew_w: 1000` against `max_w: 5000` crossed the rail in 10 s against an 18 s plant, and on 2026-09-05 drove the meter from 3.4 kW of export to 2.8 kW of import |
| `deadband_w` | 15 | Ignore errors smaller than this |
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
| `step_w` | 10 | Quantisation |
+40
View File
@@ -546,6 +546,46 @@ Cheap habits fall out of it, and all of them are now used in this repository:
instance of habit 4 in this file, and the second one in an instrument
rather than in a check.**
17. ⚠️ **TWO TERMS OF A CONJUNCTION THAT MOVE TOGETHER IN EVERY FIXTURE ARE
ONE TERM AS FAR AS THE CHECKS ARE CONCERNED - TWELFTH INSTANCE OF HABIT 4,
and the third found by an author's own battery rather than by review.**
SAFETY-37's freshness gate is `unchanged >= 35 s AND |standing| >= 250 W
AND |travel| >= 250 W`. Deleting the MIDDLE term left every check in the
first draft green, because in all of them a command that had *travelled*
250 W had also *ended up* above 250 W: the fixture never separated the two,
so one of them was decoration as far as the suite could tell. The case that
separates them is a site being brought to REST - the register reaches its
new value on the same read at which the OLD command is still standing, so
the travel term alone reads 1300 W of unfollowed command while the site
sits truthfully at +5 W. Ask of every conjunction: **is there a fixture in
which each term is the ONLY one that is false?** If not, the suite cannot
tell an AND from an OR.
18. ⚠️ **AN UNVERIFIED REVERT IS THE SAME DEFECT AS STALE BYTECODE, AND TWO
HARNESSES SHARING ONE BACKUP PATH IS THE WORST VERSION OF IT.** SAFETY-37's
sweep hit this twice, in ascending order of nastiness.
* A mutation's container run overran a two-minute command budget and the
wrapper was killed **between the edit and the `cp` that puts the file
back**, leaving `app/freshness.py` mutated on disk. Nothing announced it;
the next mutation's harness cheerfully fingerprinted and graded the mutant
*plus* a second mutation.
* Then two copies of the sweep were started - a scheduling mistake, not a
tool defect - and the harness kept its backup at the FIXED path
`/tmp/mut.bak`. One copy saved `app/main.py` there; the other restored it
**over `app/freshness.py`**. The result was a 238 KB `freshness.py` that
still parsed as Python, plus `main.py` left holding a live mutation. No
error, no warning, and the only reason it was noticed is that the tree
fingerprint did not match the recorded clean one.
Two rules, and the first is the cheap one:
* **Fingerprint after the revert, not only before the run.**
`tools/mutation_run.sh` prints the tree it is about to grade for exactly
this reason; the revert deserves the same treatment, and a mismatch is a
stop-everything result rather than a note.
* **A harness's scratch file must be unique per invocation** (`mktemp`), so
that two of it cannot silently overwrite each other's work. A fixed path
in a tool that edits source is a shared mutable global with a filename.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
+12
View File
@@ -122,6 +122,18 @@ CIRC_OPTION_DEFAULTS = {
# 200 W -> 445.0 s 350 W -> 10.0 s 1000 W -> 5.0 s
# 250 W -> 40.0 s 400 W -> 5.0 s 1700 W -> 0.0 s
#
# ⚠️ SAFETY-39 MOVED THAT SCHEMA FLOOR FROM 50 TO 0, AND THE CAMPAIGN ABOVE
# WAS NOT RE-RUN. It does not have to be, and here is the argument rather
# than the assurance. What these windows are about is how long the command
# takes to CROSS ITS RAIL - the swept worst case is the SLOWEST crossing,
# which is `slew_w` at its floor, 5000/50 = 100 cycles. `slew_w: 0` means
# "follow the rail" and resolves to `SLEW_RAIL_FRAC * max_w`, which is
# exactly 9 cycles at EVERY rail by construction. So the new value can never
# be the slowest crossing at any `max_w`, and it is strictly inside the
# envelope this campaign measured. An explicit number still spans 50..5000
# and is unchanged. If `SLEW_RAIL_FRAC` is ever loosened past 1/100, this
# paragraph stops being true and the campaign has to be re-run.
#
# THE KNEE IS AT 400 W and it is sharp. Below ~250 W a flapping load holds
# the sensor above the threshold for MINUTES at a time - 505 s at 100 W -
# and no window shorter than the 900 s billing quarter could tell that from
+105 -2
View File
@@ -18,12 +18,92 @@ exists because its absence produced a specific, observed failure.
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# SAFETY-39. THE ONE RELATIONSHIP IN THIS FILE THAT IS NOT A NUMBER.
#
# ⚠️ WHAT WENT WRONG, because the shape of it is the reason this is a ratio and
# not a constant. `slew_w: 1000` was derived on 2026-08-22 against a clamp of
# 1500 W (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)"),
# and its stated justification was that it "puts most of the correction in the
# first cycle WHILE STILL CAPPING HOW FAR THE LOOP CAN JUMP AHEAD OF THE
# INVERTER". At 1500 W that sentence was true: 1000 W/cycle reaches the rail in
# two cycles and the rail itself bounds the excursion.
#
# The rail then moved to 5000 W on the live install and the constant did not.
# 1000 W per 2 s tick is 500 W/s, so the loop traverses its WHOLE OUTPUT RANGE
# in 10 s against a plant NOTES.md §5.10 measured at ~18 s up / ~23 s down for a
# 1.78 kW step. It commands its entire range before it can observe its answer to
# the first step. Measured on the owner's system 2026-09-05 11:43-11:46: a real
# 3.4 kW export, the setpoint ramping 380 -> 5000 in five ticks, the battery at
# -5154 W against a 5000 VA inverter, and the grid pushed from 3.4 kW of EXPORT
# to 2.8 kW of IMPORT - the loop manufactured the error it then chased.
#
# ⚠️ SO THE FIX IS NOT A NEW NUMBER. A number is what went stale. What is
# written here is the RELATIONSHIP the 2026-08-22 sentence was really asserting:
#
# THE LOOP MUST NOT TRAVERSE ITS FULL OUTPUT RANGE FASTER THAN THE PLANT
# TAKES TO SETTLE.
#
# slew_w <= max_w * LOOP_TICK_S / PLANT_SETTLE_S
#
# `max_w` here is the SITE rail (main.py builds `Tuning(max_w=site_max_w)`, the
# roster sum), so the bound moves with the roster and a second inverter cannot
# re-open this by arriving. validate.py rule 10 refuses a configuration that
# breaks it; `slew_w = 0` below means "follow the rail" and is the shipped
# default, exactly as `integrator_max_w = None` already means "follow max_w".
#
# ⚠️ BOTH NUMBERS ARE WRITTEN OUT RATHER THAN THEIR QUOTIENT, AND THAT IS NOT
# TIDINESS. The quotient is only meaningful in the regime the two were measured
# in, and one of them is NOT a property of this code:
#
# PLANT_SETTLE_S is the inverter and the pack. goodwe/NOTES.md §5.10,
# 2026-08-22, a 1.78 kW kettle step driven from HA with a
# plug that meters its own draw: ~18 s from the step to the
# deadband going up, ~23 s coming down. 18 is taken because
# it is the SHORTER of the two and therefore the LOOSER
# bound - this is a refusal boundary, and rules 5 and 6 in
# validate.py refuse the impossible case only, never "close
# to the rail".
#
# LOOP_TICK_S is the METER, not this loop. `run_control()` runs a cycle on
# a changed meter value or on `cycle_floor_s`, whichever comes
# first, so the tick is the meter's own update rate. 2.0 s is
# what the live install measured on 2026-09-05 (the setpoint
# stepped +1000 W every 2 s), and it is the fastest meter this
# project has ever run.
#
# ⚠️ AND THIS ONE IS NOT WRONG IN THE SAFE DIRECTION, WHICH IS WHY IT IS SAID
# HERE. A SLOWER meter makes this bound conservative - it refuses some
# configurations that would have been fine, which is visible immediately and
# costs one edit. A meter FASTER than 2 s makes it loose by exactly that ratio,
# and nothing in this add-on would say so. The tick cannot go below 1 s
# (`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), so the
# worst case is a factor of two - but if a faster meter is ever fitted, this
# constant has to be RE-DERIVED and not merely re-read. Compare
# validate.FIRMWARE_INGEST_VETO_S, which carries the same kind of copy and can
# only ever be wrong the other way.
PLANT_SETTLE_S = 18.0
LOOP_TICK_S = 2.0
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S # 0.111..., i.e. 9 ticks
@dataclass(frozen=True)
class Tuning:
gain: float = 0.6
max_w: float = 2000.0
slew_w: float = 1000.0
# SAFETY-39. How far the command may move in one cycle. ⚠️ 0 MEANS "FOLLOW
# THE RAIL" - `SLEW_RAIL_FRAC * max_w` - and it is the default and the
# recommended setting, for the reason written at the top of this file: a
# fixed number here is a constant that outlives the rail it was derived
# against, and that is the defect measured on the live system on
# 2026-09-05. Read `slew_limit_w()`, never this field.
#
# It stays settable, because slower than the rail implies is a legitimate
# and sometimes necessary choice - the live install has run an explicit
# 400 W since 2026-09-05 12:00 - and because `slew_w` is an option an
# operator already has. FASTER than the rail implies is what validate.py
# rule 10 refuses.
slew_w: float = 0.0
deadband_w: float = 15.0
step_w: int = 10
saturation_w: float = 500.0
@@ -83,6 +163,28 @@ class Decision:
clamped: bool = False
def slew_limit_w(tuning: Tuning) -> float:
"""How far the command may move this cycle, in W. Always positive.
⚠️ THE ONE READER OF `Tuning.slew_w`, and everything else must come through
here. `slew_w = 0` means "follow the rail" and resolves to
`SLEW_RAIL_FRAC * max_w`, which is the whole of SAFETY-39: the limit is a
RATIO to the rail, so raising `max_w` moves it and cannot leave it behind.
A caller that read the field directly would see 0 and apply no limit at all
- the exact opposite of the setting's meaning - which is why this exists as
a function rather than as a comment telling people to remember.
⚠️ AND IT IS NOT CLAMPED TO THE BOUND. An explicit `slew_w` above the rail
fraction is refused at STARTUP by validate.py rule 10, not silently reduced
here. Silently reducing it is the failure this whole module's docstring is
about - an operator's stated number quietly meaning something else - and
validate.py's own docstring calls it case (a).
"""
if tuning.slew_w:
return abs(float(tuning.slew_w))
return SLEW_RAIL_FRAC * abs(float(tuning.max_w))
def compute(
prev_w: float,
grid_w: float,
@@ -231,7 +333,8 @@ def compute(
if clamped:
reason = "clamped"
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
slew = slew_limit_w(tuning)
slewed = max(prev_w - slew, min(prev_w + slew, target))
if slewed != target:
reason = "slew-limited"
target = slewed
+385
View File
@@ -0,0 +1,385 @@
"""SAFETY-37 - a reading that holds its last value and reads healthy.
THE DEFECT, in one sentence: `hass.number()` returns a held value as a value,
and nothing anywhere in this add-on distinguishes "the inverter reports 40 %"
from "the entity still says 40 % because nothing has updated it since Tuesday".
The architect pass of 2026-09-06 put three cards on one mechanism. This module
is that mechanism; the other two are its consumers.
LAYER 1 `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` - the
(TEL-06) ingest-age veto tests the VALUE of `sensor.goodwe_p1_sample_age`.
A dead publisher leaves the entity frozen at a healthy number and
the veto can never fire. NOT FIXED HERE - it is firmware, it is on
the other side of a flash, and nothing in this module can reach
it. What it owes is written at the bottom of this docstring.
THIS ADD-ON `app/hass.py:69-82`, consumed by SAFETY-30's per-unit sign check
(SAFETY-37) and SAFETY-31's circulating-power sensor. Both read `Inverter.batt`
(register 0x0518) and NEITHER had a freshness gate of any kind.
THE EVIDENCE HW-01, 2026-09-02: SoC held 40 % for 6 h 25 m while the pack
drained to its floor. Every detector read healthy throughout.
⚠️ AT N>=2 EVERY ONE OF THOSE READINGS DOUBLES, which is why this card is the
one that had to build it: the per-unit sign check and the circulation sensor
only exist at N>=2, and both are fed straight from `Inverter.batt`.
WHAT CANNOT BE THE SOURCE OF TRUTH, AND IT IS NOT A DETAIL
==========================================================
Home Assistant's `last_updated` DOES NOT MOVE when a value is rewritten
unchanged. Neither does `last_changed`, and this project measured that
`last_reported` does not advance on a repeat on either serialiser either - see
`p1.HomeWizardLocalSource`'s docstring, which records the ENV-01 rig measurement
over 70 s of a frozen meter and an independent one against the live house.
So a value-side timestamp out of Home Assistant is a "time since the number
changed" sensor wearing an arrival sensor's name. Thresholding it is how a
detector ends up unable to fire, which is TEL-06's defect exactly. That trap
produced two false diagnoses on 2026-09-05, both by a reader who already knew
about it, which is why it is written here in full rather than referenced.
⚠️ AND THE CONVERSE TRAP IS THE ONE THIS MODULE HAD TO SOLVE. The add-on polls
Home Assistant's state cache roughly once a second (`run_control()`'s
`await asyncio.sleep(1)` bounds the loop body and `read_inputs()` runs every
pass). While the entity exists, HA answers 200 with a number whether or not the
publisher behind it is alive. So AN ARRIVAL AT THE ADD-ON IS NOT AN ARRIVAL FROM
THE INVERTER, and an arrival age alone cannot see HW-01 at all.
What IS arrival-stamped here, and what makes this module honest, is the clock
the verdict is measured on: `changed_mono` is the MONOTONIC INSTANT OF THE
ARRIVAL THAT LAST CARRIED A DIFFERENT NUMBER. Every term below is a fact about
arrivals this process observed, on its own clock. Nothing is read out of a Home
Assistant timestamp, and nothing would change if Home Assistant stopped
publishing timestamps altogether.
THE VERDICT, AND WHY IT IS A CONJUNCTION
========================================
A bare "unchanged for N seconds" threshold is not available, and that is
MEASURED rather than feared. Over the committed 30-day capture of this house's
own 1 W-resolution power entity - `scenarios/p1-net-30d-2026-08/`, 485,841
samples straight out of Home Assistant's recorder, where a row exists only when
the state string CHANGED, so the gap between two rows IS the unchanged duration
every consumer saw:
longest unchanged run, 30 days 263.2 s (held -899.0 W, 2026-08-11 17:04)
runs >= 35 s 10 of 485,840 (0.0021 %)
runs >= 17 s 386 of 485,840 (0.0794 %)
Measured on 2026-09-06 by replaying that capture. A real power figure at 1 W
resolution held ONE value for four and a half minutes with nothing whatever
wrong. ⚠️ That is the METER's register, not 0x0518, and it is named as a
surrogate rather than passed off as the thing: no 30-day capture of 0x0518
exists, because that register is not in the recorder export. What it does
establish - and this is all it is used for - is that the LEGITIMATE unchanged
duration of a power entity in this house is minutes, so an unchanged threshold
alone must be minutes, and minutes is three times too slow to protect consumers
that trip at 75 s and 85 s. The conjunction is not a refinement; without it
there is no admissible threshold at all.
⚠️ AND EVERY ONE OF THOSE LONG RUNS HAPPENED UNDER AN UNMOVING LOAD. That is
the way out, and it is the same shape `freeze.looks_frozen()` already uses for
the meter (unchanged AND the command pinned at the rail). A reading is stale
when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
STALE <=> unchanged for >= UNCHANGED_S
AND |standing command now| >= MOVE_W
AND |standing command now - standing command when the reading
last changed| >= MOVE_W
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
register dithering +/-1 W while holding (§5.11, same day). A unit told to move
250 W and still answering the same integer watt cannot be tracking.
THE BOUNDS - ONE PER SIGNAL, EACH WITH ITS DERIVATION
=====================================================
⚠️ ONE CONSTANT FOR BOTH SIGNALS WOULD BE USELESS ON SoC OR TRIGGER-HAPPY ON
POWER, which is the third clause of the policy. They are separated here, and
only one of them is WIRED - see `BOUNDS` below and the note on SoC.
`batt` / 0x0518, S16, 1 W, published every 10 s
----------------------------------------------
LOWER BOUND, 17.0 s, the fastest a healthy register can be OBLIGED to have
answered. Three terms, each read out of this repository on 2026-09-06:
6.0 s the plant. goodwe/NOTES.md §5.9, measured 2026-08-22 with the poll
temporarily at 1 s and a 0 -> +500 W step: 26 W at +1.43 s, 469 W
(94 %) at +3.34 s, settled 507 W at +6.05 s.
10.0 s the publisher. `firmware/goodwe-master.yaml:181`,
`update_interval: 10s` on the `modbus_controller` that carries both
0x0518 and 0x050E. A command that moves right after a poll is not
visible until the next one.
1.0 s this add-on's own read period - `run_control()`'s
`await asyncio.sleep(1)`, which bounds the loop body that calls
`read_inputs()`.
UPPER BOUND, 75.0 s, the tighter of the two consumers this card must protect,
both read from `config.yaml` on 2026-09-06:
75 s `circulation_hold_s` (config.yaml:512) - SAFETY-31 trips here.
85 s `unit_sign_disagree_s` (config.yaml:451) - SAFETY-30's per-unit sign
check trips here.
A gate slower than either is a gate that hands its consumer the frozen number
and then explains afterwards. ⚠️ AND THAT INEQUALITY IS GRADED, NOT ASSERTED -
`test_freshness.py` part 6 reads BOTH ends out of the shipped `config.yaml`
and goes red if an operator's default moves under this bound.
35.0 s IS THE CHOSEN VALUE, and the admissible band is narrow enough that it
is nearly forced: at least 2x the 17.0 s floor (34.0) and at most half the
75.0 s consumer window (37.5) leaves [34.0, 37.5]. 35 is the integer in it.
⚠️ DELIBERATELY NOT 30 AND NOT 60. 30 is `validate.FIRMWARE_INGEST_VETO_S` and
would read as a borrowed constant nobody re-derived; 60 is round, and a round
number here would be a guess wearing a measurement's clothes.
WHAT IT COSTS, stated rather than absorbed: a frozen 0x0518 on a unit that is
never asked to move 250 W is not detected by this module at all.
⚠️ THAT COST IS BOUNDED FOR ONE CONSUMER AND NOT FOR THE OTHER, and an earlier
version of this note claimed both. TRUE for the per-unit sign check: no such
check can speak about that unit either, since `unit_sign_disagree_w` is 250 W.
FALSE for circulation. "Contributing at most 250 W" bounds the COMMAND - but a
frozen register's REPORTED value is unrelated to its command, and that is
precisely HW-01's shape: commanded ~0 W, register stuck on the last large
number. Measured in review: a unit frozen at -1200 W beside a healthy +900 W
sibling gives `circulating_w` = 1800 W against a 700 W floor, 2.6x over, with
no gate armed. So this gate is scoped to where the SIGN consumer can be misled;
the circulation consumer keeps an unbounded exposure below MOVE_W and that is
a known, stated gap rather than a covered one.
`soc` / 0x050E, U16, 1 %, published every 10 s - STATED, NOT WIRED
-----------------------------------------------------------------
⚠️ THIS ROW IS THE POLICY'S ANSWER FOR TEL-08 AND IT IS NOT CONNECTED TO
ANYTHING. It is here because the third clause of the policy is "state each
bound and its derivation", and because a reader who finds only `batt` wired
would reasonably conclude SoC was forgotten. It was not; it needs a
corroborator this card cannot derive.
SoC CANNOT USE THE CONJUNCTION ABOVE. A pack resting at its floor holds one
percent for hours with nothing wrong - the card's own example - and the
standing command says nothing about it, because a command of 0 W is exactly
what a rested pack is given. The physically obliged movement for SoC is
ENERGY, not command travel: one percent of pack A is about 153.6 Wh (15.36 kWh
nominal, goodwe/NOTES.md §1.1b's table - ⚠️ NOMINAL and FROM DATASHEET, not a
measured usable capacity), so at pack A's 5000 W ceiling one percent takes at
least 110 s and at a realistic 1000 W about 553 s. The bound TEL-08 needs is
therefore "SoC unchanged while the integral of |battery power| since its last
change exceeded K x 153.6 Wh", and K cannot be chosen from anything written
down today: nobody has measured this pack's usable capacity, its BMS reporting
granularity, or how far its reported percent lags true state of charge.
So TEL-08 owes: that capacity measurement (or a defensible bound on it), the
integrator, and K. It does NOT owe a second freshness mechanism - `Freshness`
below takes its obligation term from the caller precisely so that an energy
integral can be handed in where command travel is handed in here.
"""
import time
from dataclasses import dataclass
# --- the three terms of the lower bound, each read out of this repository ----
# See the module docstring for where each one is measured. They are separate
# names rather than a literal 17.0 so that a reader who changes the firmware's
# `update_interval` can find this file by grepping for the number they moved.
PLANT_SETTLE_S = 6.0 # goodwe/NOTES.md §5.9, measured 2026-08-22
PUBLISH_PERIOD_S = 10.0 # firmware/goodwe-master.yaml:181
READ_PERIOD_S = 1.0 # main.run_control()'s `await asyncio.sleep(1)`
BATT_OBLIGED_S = PLANT_SETTLE_S + PUBLISH_PERIOD_S + READ_PERIOD_S # 17.0
# The unchanged bound for register 0x0518. See the derivation above; the band is
# [2 * BATT_OBLIGED_S, 0.5 * circulation_hold_s] = [34.0, 37.5].
BATT_UNCHANGED_S = 35.0
# How far the standing command must have travelled for the register to be
# OBLIGED to have followed it.
#
# ⚠️ IT IS THE LARGEST ADMISSIBLE VALUE, NOT A SENSITIVITY PREFERENCE, and that
# is what makes it a derivation. The gate has to arm everywhere a stale reading
# could mislead a consumer; the earliest either consumer speaks is
# `unit_sign_disagree_w`, 250 W (config.yaml:450), because below that
# `watchdog.disagrees()` is False whatever the measurement says. Taking the
# ceiling of the admissible range is what minimises spurious arming. A smaller
# number would arm the gate in states where nothing downstream can be misled,
# and every such arming is a spurious site stop bought for nothing.
#
# ⚠️ NOT A CONFIG KEY. CFG-03 tracks the places a default has to be kept in
# step and this does not earn another one - it is derived from a default
# `config.yaml` already carries, and `test_freshness.py` part 6 READS that file
# and goes red if the two drift.
MOVE_W = 250.0
@dataclass(frozen=True)
class Bound:
"""One signal's freshness bound, and whether anything consumes it."""
signal: str
unchanged_s: float
move_w: float
wired: bool
derivation: str
# ⚠️ THE POLICY AS DATA, AND THE `wired` COLUMN IS LOAD-BEARING. A reader must be
# able to tell, without reading main.py, which of these bounds is actually
# gating a consumer today. `test_freshness.py` part 7 greps the shipped app/ for
# each row and refuses a row that claims to be wired and is not - and a row that
# claims not to be and is.
BOUNDS = {
"batt": Bound(
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
unchanged_s=BATT_UNCHANGED_S,
move_w=MOVE_W,
wired=True,
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]"),
"soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"),
move_w=float("inf"),
wired=False,
derivation="TEL-08. Command travel is the WRONG obligation term for "
"SoC - a rested pack holds a percent for hours under a 0 W "
"command. The term is energy: >= K x 153.6 Wh through the "
"pack since the reading last changed. K needs a measured "
"usable capacity nobody has taken."),
}
class Freshness:
"""One signal's arrival record, and the stale verdict it supports.
⚠️ ARRIVAL-DRIVEN THROUGHOUT. Every clock reading this object ever makes is
handed to `submit()` by the caller at the moment a value arrived; nothing
here reads a clock of its own and nothing here reads a Home Assistant
timestamp. That is what makes the verdict replayable - `test_freshness.py`
drives real seconds through it without sleeping - and it is the difference
between this and the value-stamped veto TEL-06 is blocked on.
⚠️ THE VERDICT LATCHES, AND WITHOUT THAT IT OSCILLATES AT ABOUT 1 Hz.
Declaring the reading stale makes `Controller.inputs_ok` False, which
commands 0 W (§11.1 rule 1), which drops the standing command below
`MOVE_W`, which un-arms the conjunction, which would return the held number
to the consumers - and round again every read. So the ONLY thing that clears
the latch is an arrival carrying a DIFFERENT number, which is exactly the
evidence the verdict was about. A register that is working clears it on its
next 10 s publish; a register that is not, does not.
"""
def __init__(self, bound: Bound):
self.bound = bound
# The last number that ARRIVED, or None if the most recent read did not
# produce one. This is what `reading()` hands back when the gate is open,
# so a read failure is still an immediate None exactly as it is today.
self.last = None
# The number the reading is currently HOLDING, and the monotonic instant
# of the arrival that last carried a different one. `held` survives a
# failed read; the unchanged clock is about the value, not about our
# ability to fetch it.
self.held = None
self.changed_mono = None
# The standing command at that same instant. The conjunction compares
# against THIS, not against the previous cycle's command: what matters
# is whether the register has followed anything since it last moved.
self.standing_at_change = None
self.stale = False
self.reason = ""
self.arrivals = 0
def submit(self, value, standing_w, now_mono) -> None:
"""One READ of this signal. `value` None means nothing arrived.
`value` the number `hass.number()` produced, or None.
`standing_w` what this controller currently has standing on this unit's
wire, signed, + = discharge. None when nothing is known.
`now_mono` the caller's monotonic clock, in seconds.
⚠️ A FAILED READ IS NOT AN ARRIVAL AND IS NOT AN AGE EITHER. It clears
`last` immediately - which is byte-for-byte what shipping code does
today, a missing reading being a missing reading - and it leaves the
unchanged clock running, because the value has not changed and our
inability to fetch it is not evidence that it has.
"""
if value is None:
self.last = None
return
value = float(value)
self.arrivals += 1
if self.changed_mono is None or value != self.held:
self.held = value
self.changed_mono = float(now_mono)
self.standing_at_change = (None if standing_w is None
else float(standing_w))
# Movement is the ONLY thing that clears the latch. See the class
# docstring on the 1 Hz oscillation this prevents.
self.stale = False
self.reason = ""
self.last = value
self._grade(standing_w, now_mono)
def _grade(self, standing_w, now_mono) -> None:
"""The conjunction. Sets the latch; never clears it."""
if self.stale or self.changed_mono is None:
return
held_s = self.unchanged_s(now_mono)
if held_s < self.bound.unchanged_s:
return
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
# always answers a float, so neither branch is reachable from the wired
# consumer today. They are here for TEL-08, whose obligation term is an
# energy integral that has nothing to say until it has a window - and
# "nothing to say" must withhold the verdict, not manufacture one.
if standing_w is None or self.standing_at_change is None:
return
standing_w = float(standing_w)
if abs(standing_w) < self.bound.move_w:
return
moved_w = abs(standing_w - self.standing_at_change)
if moved_w < self.bound.move_w:
return
self.stale = True
self.reason = (
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
f"while the standing command moved {moved_w:.0f} W to "
f"{standing_w:+.0f} W - the reading is not following, so it is "
f"reported as MISSING rather than as a measurement (limits: "
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
f"{self.bound.move_w:.0f} W and moved by over "
f"{self.bound.move_w:.0f} W)")
def unchanged_s(self, now_mono) -> float | None:
"""Seconds since an arrival carried a different number. Status page."""
if self.changed_mono is None:
return None
return max(0.0, float(now_mono) - self.changed_mono)
def reading(self):
"""What the consumers get: the value, or None when it cannot be trusted.
⚠️ `None`, NEVER A HELD NUMBER, and never a zero. Both consumers already
do the right thing with None and neither could ever receive one:
`watchdog.SignWatchdog.evaluate` CLEARS its window on a None measurement
rather than accumulating it, and `circulation.circulating_w` returns None
for a roster with a missing term, which clears the circulation window
too. This module is the thing that was missing between them.
"""
return None if self.stale else self.last
def note(self, unit_name: str) -> str:
"""One line for the status page. Empty when there is nothing to say."""
return f"{unit_name}: {self.reason}" if self.stale else ""
def monotonic() -> float:
"""The clock this module's callers use, in one place so tests can find it.
⚠️ `time.monotonic()`, not the wall clock, for `p1.P1Sample.age_s`'s reason:
an NTP step on a Raspberry Pi that has just found the network is routinely
minutes, and a freshness verdict computed across one would either fake a
stale reading or, worse, hide a real one.
"""
return time.monotonic()
+48
View File
@@ -122,6 +122,9 @@ import math
from datetime import datetime
from .clamp import ClampResult
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
# freshness.py imports nothing from this package, so the direction is one-way.
from .freshness import BOUNDS, Freshness
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
# shipped default and a literal here would be the fourth defaults site that
@@ -213,7 +216,22 @@ class Inverter:
# --- this unit's own measurements ------------------------------------
self.soc = None
# ⚠️ SAFETY-37: `batt` IS NOW THE GATED READING, NOT THE RAW ONE. It is
# None whenever `batt_fresh` says the register is not following its own
# command - see freshness.py - and a None here is a MISSING input, which
# §11.1 rule 1 answers with 0 W. The raw number that arrived is on
# `batt_fresh.last`; read that only for diagnostics, never for control.
self.batt = None
# SAFETY-37. One arrival record per SIGNAL per UNIT. Per unit for
# DEV-02's reason - a freshness verdict about B is not evidence about A,
# and at N>=2 a shared record would be the flat-scalar defect rebuilt
# inside the gate that exists to protect the per-unit checks.
#
# ⚠️ ONLY `batt` IS WIRED. `BOUNDS["soc"]` exists and is deliberately not
# constructed here: SoC's obligation term is energy through the pack, not
# command travel, and the constant it needs has not been measured. That
# is TEL-08's, and freshness.py's docstring says exactly what it owes.
self.batt_fresh = Freshness(BOUNDS["batt"])
# --- what this unit was last told, and what it last accepted ---------
# `target` is THIS unit's share of the site target from the last
@@ -266,6 +284,24 @@ class Inverter:
self.store.set(self.key(name), value)
# -- the numbers other modules ask this object for -----------------------
def standing_w(self) -> float:
"""What this controller has standing on THIS unit's wire right now.
⚠️ ONE DEFINITION, TWO CALLERS, and it was a transcription before
SAFETY-37. `cycle()` computes this for the sign watchdog and
`_read_unit()` needs the identical number for the freshness conjunction;
two spellings of "what is standing" would be two answers to the question
the gate and the check it protects are both asking, and the copy that
drifts is always the one further from the wire.
`last_write` is the value after the device clamp and confirmed accepted;
`target` covers the case where nothing has been written yet and,
deliberately, the case where the last write was REJECTED - the inverter
is then still latched on `last_write`, which is what `last_write` holds.
"""
return float(self.last_write if self.last_write is not None
else self.target)
def rating_w(self) -> float:
"""The largest magnitude this unit can be commanded, in W.
@@ -319,6 +355,18 @@ class Inverter:
f"(limit {float(stale_s):.0f} s)")
return True, ""
def stale_note(self) -> str:
"""SAFETY-37's one line for the status page. Empty when nothing to say.
Separate from `link_note()` because they are about different halves of
the device and want opposite remedies: liveness is about the WRITE path
and its answer is to drop this unit and let the survivors carry it,
while a stale MEASUREMENT is about the read path and its answer is to
stop the site (`inputs_ok`). Folding one into the other is how a
measurement fault ends up handing a sibling extra power.
"""
return self.batt_fresh.note(self.name)
def link_note(self, now: datetime) -> str:
"""One line for the status page. Empty when there is nothing to say."""
if not self.has_writer():
+72 -9
View File
@@ -45,6 +45,7 @@ from .circulation import (
from .clamp import (
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
)
from . import freshness
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
@@ -247,7 +248,13 @@ class Controller:
# binds each unit's own share through its own `supervised` claim, so
# nothing here can over-command a device.
max_w=self.site_max_w,
slew_w=float(opts.get("slew_w", 1000)),
# ⚠️ SAFETY-39: THE FALLBACK IS 0 AND 0 MEANS "FOLLOW THE RAIL",
# which is what config.yaml ships and what `Tuning`'s own default
# is. It used to be 1000 - a third copy of a constant that had
# already gone stale against the rail in the other two - and rule
# 6's comment in validate.py documents what that costs when the
# copies disagree. `control.slew_limit_w()` is the one reader.
slew_w=float(opts.get("slew_w", 0) or 0),
deadband_w=float(opts.get("deadband_w", 15)),
target_grid_w=float(opts.get("target_grid_w", 0)),
step_w=int(opts.get("step_w", 10)),
@@ -1103,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
@@ -1124,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.
@@ -1170,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.
@@ -1463,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.
@@ -1554,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) \
+160
View File
@@ -183,6 +183,60 @@ NOT READY banner already names the problem, (b) does not apply.
the per-unit form and main.py runs it from `run_control()`, once every
unit's own ESPHome has replied.
10. `slew_w` above the fraction of the SITE RAIL the plant can follow. [(a)]
SAFETY-39, and the first rule here whose subject is a RELATIONSHIP rather
than a threshold. `slew_w` bounds how far the site command may move in one
cycle; `control.SLEW_RAIL_FRAC` is `LOOP_TICK_S / PLANT_SETTLE_S`, so
`SLEW_RAIL_FRAC * rail` is the largest step at which the loop still takes
the plant's own measured settling time to traverse its whole output range.
Above it the controller commands its entire range before it can observe
its answer to the first step, and the stated limit - "the command moves at
most `slew_w` per cycle, so it cannot jump ahead of the inverter" - is
untrue: it jumps ahead by construction.
⚠️ THIS IS (a) AND NOT (b), and the distinction is what makes it a refusal
rather than a warning. Nothing goes inert. What happens is that a bound
the operator set means something other than what it says, and the layer
that overrules it is the PLANT - which has no status row, publishes
nothing, and cannot be read off any page in this add-on. Measured on the
owner's system 2026-09-05 11:43-11:46 at `max_w: 5000`, `slew_w: 1000`:
setpoint 380 -> 5000 in five 2 s ticks, battery -5154 W against a 5000 VA
inverter, and the grid driven from 3.4 kW of EXPORT to 2.8 kW of IMPORT.
Large (>= 900 W) setpoint steps per 30 min climbed 2 -> 1 -> 13 -> 43 that
morning. Nothing in the add-on reported anything wrong, because nothing
was: the loop followed its command exactly, and the command was wrong.
⚠️ IT IS GRADED AGAINST THE ROSTER SUM, NOT THE FLAT `max_w` AND NOT THE
WEAKEST UNIT, and this is the one rule in this file for which that is the
right rail. Rules 5, 6 and 8 are all about ONE unit - one watchdog, one
shaping claim, one pack - so they take `min()` over `unit_caps()`.
`slew_w` is not per unit: `main.py` builds `Tuning(max_w=self.site_max_w)`
and `compute()` produces the SITE target, which the allocator then splits.
So the range this bound is about is the sum, `Controller.site_max_w`, and
`site_rail_w()` below is that same number computed from options.
⚠️ AND THE `site_sign_disagree_w` NOTE IN config.yaml IS NOT A PRECEDENT
AGAINST THIS. That note says there is deliberately no rule pairing
`site_sign_disagree_w` with `max_w`, "because the bound on the SITE command
is `Controller.site_max_w`, the ROSTER SUM, and `validate_startup_config()`
is handed options rather than a roster. A rule written against max_w would
refuse a perfectly reachable two-inverter configuration." That objection is
to grading a site quantity against the FLAT key. It is answered rather than
ignored: `unit_caps()` already reads the roster the add-on will build, so
the roster sum IS available here, and this rule takes it.
⚠️ THE DIRECTION OF THIS RULE, SAID PLAINLY, BECAUSE IT IS NOT THE
DIRECTION THE TICKET ASSUMED. A rail that GROWS loosens this bound: at a
9600 W two-inverter rail, `slew_w: 1000` traverses the rail in 19.2 s and
passes, while at 5000 W the same 1000 W is the configuration measured
above. That is what the constraint says and it is correct - the bound is on
traverse TIME, and a bigger rail at the same step size takes longer to
cross. What this rule therefore protects against is the constant being left
behind when the rail moves EITHER way. It is NOT a bound on how much power
the site may command, and it does NOT bound the over-absorption: see the
note below on `gain`, which is the term that owns that and which SAFETY-39
measured and did not change.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -226,6 +280,34 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
is answered. Compare rule 5, which CAN be settled at startup because both
`sign_disagree_w` and `max_w` are config and are both known at boot.
- `gain` at any value the schema accepts, INCLUDING against a large rail.
SAFETY-39 measured this and deliberately left it alone, which is worth more
than the rule would have been. On admin/goodwe's rig, over a family of 51
export steps from 1.0 to 4.2 kW at `max_w: 5000` and a 2 s tick, the
over-absorption - peak battery power beyond what the site actually needed -
is FLAT in `slew_w`: 958 W at slew 1000 and 1081 W at slew 250. It falls
in `gain`: 987 W at 0.6, 876 W at 0.3, 435 W at 0.2 - a real reduction, but
NOT to zero, and the 0.2 figure is the one worth not rounding away. The
reason is structural and is visible in control.py: `slew_w` bounds the
OUTPUT, while the integrator accumulates `gain * error` per cycle and is
bounded only by `integrator_max_w`. At gain 0.6 into a 3.4 kW error the
integrator moves ~2040 W per tick and reaches a 5000 W rail in under three
ticks whatever `slew_w` is.
So the 2026-08-22 derivation's own sentence - "the gain, not the slew, is
the limit by then" - is now literally true, and `gain` is the constant that
needs re-deriving next. It is NOT refused and NOT changed here: it is a
live control-law constant on a running house, changing it in the same pull
request as `slew_w` would make the next measurement uninterpretable, and a
validator rule pairing `gain` with a rail would be inventing a relationship
nobody has measured. Named here so it is a known open item rather than an
omission. See DECISIONS/SAFETY-39-slew-against-the-rail.md.
- `slew_w` BELOW the rail fraction, at any value the schema accepts. Slower
than the plant is always safe and is sometimes necessary - the live install
has run an explicit 400 W against a 5000 W rail since 2026-09-05 12:00,
which is 12.5 ticks to traverse the rail against the 9 this rule requires.
Refusing "too slow" would refuse the mitigation that is holding.
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
every ordering of the two is a legitimate site configuration. Refusing on
@@ -235,6 +317,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
import logging
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from .inverter import roster_entries
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import is_enabled, validate_meter_options
@@ -392,6 +475,27 @@ def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
return caps
def site_rail_w(opts: dict) -> float | None:
"""The SITE rail: what the whole site may be commanded, in W. Rule 10's rail.
⚠️ THE SUM, NOT THE MINIMUM, AND NOT THE FLAT KEY - and it is the only place
in this file that wants a sum. Rules 5, 6 and 8 grade a threshold against
ONE unit (one watchdog, one shaping claim, one pack) and take `min()` over
`unit_caps()`. `slew_w` is not a per-unit quantity at all: `main.py` builds
`Tuning(max_w=self.site_max_w)` from the roster sum and `compute()` emits
the SITE target, which the allocator then divides. So the output range
`slew_w` is a rate across is `Controller.site_max_w`, and this is that same
number read out of options rather than off a built roster.
At the shipped roster of one this is `unit_caps()`'s single entry, which is
`max_w` exactly - byte for byte the number the single-inverter shape has
always meant. None when no entry carries a usable cap, which rule 9 has
already refused by the time rule 10 runs.
"""
caps = unit_caps(opts)
return float(sum(caps)) if caps else None
def unit_bms_floors_indexed(opts: dict) -> list[tuple[int, object, object]]:
"""SAFETY-32. Each pack's OWN `bms_soc_floor`, carrying its ROSTER INDEX.
@@ -935,6 +1039,62 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
f"two numbers. Lower maintenance_charge_w to {maint_cap:.0f} W or "
f"less, or raise max_w to at least {charge_w:.0f} W.")
# --- 10. a slew rate the plant can actually follow -----------------------
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
# threshold, and the whole point of writing it as one is that the number it
# bounds must not be able to outlive the rail again. `control.py`'s header
# carries the derivation; the short version is that `slew_w: 1000` was
# derived against a 1500 W clamp and the live install raised the clamp to
# 5000 without revisiting it, so the loop traversed its whole output range
# in 10 s against a plant measured at ~18 s.
#
# ⚠️ `slew_w: 0` IS NOT GRADED AND MUST NOT BE. Zero means "follow the
# rail" - `control.slew_limit_w()` resolves it to `SLEW_RAIL_FRAC * max_w`,
# which satisfies this rule by construction at every rail there is. It is
# the shipped default, and a rule that refused it would refuse the fix.
#
# ⚠️ STRICTLY `>`, MATCHING RULE 6 RATHER THAN RULE 5. Equality is the
# derived value itself: at `slew_w == SLEW_RAIL_FRAC * rail` the loop takes
# exactly `PLANT_SETTLE_S` to cross its range, which is the constraint
# satisfied, not violated. Refusing it would refuse the value this rule
# exists to recommend - and `slew_limit_w()` produces exactly that value
# for every default install, so `>=` would refuse the shipped default
# through a float comparison. A tolerance is applied for the same reason:
# `SLEW_RAIL_FRAC * 5000` is 555.55..., and an operator who writes the 556
# a status page rounded to is one watt over a boundary that is not
# meaningful to one watt.
slew_w = _number(opts, "slew_w", 0.0)
rail = site_rail_w(opts)
if slew_w is None:
problems.append(f"slew_w {opts.get('slew_w')!r} is not a number")
elif slew_w < 0:
problems.append(
f"slew_w is {slew_w:.0f} W and must not be negative - it is a "
"magnitude, and 0 means 'follow max_w' (the recommended setting).")
elif slew_w and rail is not None and rail > 0:
bound = SLEW_RAIL_FRAC * rail
if slew_w > bound + 1.0:
ticks = rail / slew_w
problems.append(
f"slew_w is {slew_w:.0f} W against a site rail of {rail:.0f} W, "
f"so the loop crosses its whole output range in {ticks:.1f} "
f"cycles - {ticks * LOOP_TICK_S:.0f} s at the {LOOP_TICK_S:.0f} s "
f"tick this site was measured running - while the plant takes "
f"{PLANT_SETTLE_S:.0f} s to settle (goodwe/NOTES.md §5.10, a "
"1.78 kW step). The controller would command its entire range "
"before it could observe its answer to the first step, which is "
"not a slow loop, it is a loop that MANUFACTURES the error it "
"then chases: measured on this system 2026-09-05, a 3.4 kW "
"export drove the setpoint 380 -> 5000 W in five ticks, the "
"battery to -5154 W past a 5000 VA rating, and the grid from "
"3.4 kW of export to 2.8 kW of import. The stated limit - the "
"command moves at most slew_w per cycle, so it cannot jump "
"ahead of the inverter - is untrue at this rail; THE PLANT "
f"WINS, and it has no status row. Set slew_w to 0, which means "
f"'follow the rail' and resolves to {bound:.0f} W here and "
"moves on its own if the rail does, or write a value at or "
f"below {bound:.0f} W. Lower is always accepted.")
# --- 7. two price sources on one site ------------------------------------
problems.extend(_price_problems(opts))
+12
View File
@@ -331,6 +331,18 @@ SIGN_OPTION_DEFAULTS = {
# 150 W -> 15.0 s 300 W -> 10.0 s 1000 W -> 0.0 s
# 200 W -> 15.0 s 500 W -> 5.0 s
#
# ⚠️ SAFETY-39 MOVED THAT SCHEMA FLOOR FROM 50 TO 0, AND THE CAMPAIGN ABOVE
# WAS NOT RE-RUN. It does not have to be, and here is the argument rather
# than the assurance. What these windows are about is how long the command
# takes to CROSS ITS RAIL - the swept worst case is the SLOWEST crossing,
# which is `slew_w` at its floor, 5000/50 = 100 cycles. `slew_w: 0` means
# "follow the rail" and resolves to `SLEW_RAIL_FRAC * max_w`, which is
# exactly 9 cycles at EVERY rail by construction. So the new value can never
# be the slowest crossing at any `max_w`, and it is strictly inside the
# envelope this campaign measured. An explicit number still spans 50..5000
# and is unchanged. If `SLEW_RAIL_FRAC` is ever loosened past 1/100, this
# paragraph stops being true and the campaign has to be re-run.
#
# ⚠️ AND THE CURVE IS FLAT, WHICH IS THE FINDING THAT DECIDED THE SHAPE OF
# THIS PAIR. Unlike the site comparison - where the meter is driven by the
# LOAD and a flapping load holds it opposed for minutes - a unit's own power
+44 -2
View File
@@ -516,7 +516,40 @@ options:
# ==========================================================================
auto_start: false
gain: 0.6
slew_w: 1000
# ⚠️ SAFETY-39. 0 IS NOT "UNSET" AND IT IS NOT "NO LIMIT" - IT IS "FOLLOW THE
# RAIL", and it is the shipped value. `control.slew_limit_w()` resolves it to
# `SLEW_RAIL_FRAC * max_w`, which is 222 W against the 2000 W default here,
# 556 W against the live install's 5000 W, and 1067 W against the ~9600 W a
# second inverter makes of the site. `integrator_max_w: 0` two lines down has
# meant exactly this since it was added; this key now means it too.
#
# ⚠️ WHY IT IS A RATIO AND NOT A NUMBER, WHICH IS THE WHOLE OF THIS CARD. It
# WAS a number: 1000, derived 2026-08-22 against a clamp of 1500 W
# (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)") on the
# grounds that it caps "how far the loop can jump ahead of the inverter". The
# rail then went to 5000 on the live install and the constant did not move, so
# 1000 W per 2 s tick crossed the whole output range in 10 s against a plant
# NOTES.md §5.10 measured at ~18 s up / ~23 s down. Measured on the owner's
# system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000
# in five ticks, the battery to -5154 W past a 5000 VA rating, and the grid
# from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Large (>= 900 W) steps per 30 min
# went 2 -> 1 -> 13 -> 43 that morning. Nothing was broken; the rail moved.
#
# An explicit number is still accepted and is not clamped - the live install
# has run 400 since 2026-09-05 12:00, which is slower than the rail implies
# and is fine. What validate.py rule 10 refuses is a number FASTER than the
# rail implies, because at that setting the stated limit is not the limit and
# the layer that overrules it is the plant, which has no status row.
#
# ⚠️ AND THE COST IS REAL AND IS NOT HIDDEN: following the rail is slower to
# converge than 1000 was. Measured over 51 export steps on the in-repo model
# (test_control.py) the largest tick step falls from 1000 W to 556 W and the
# >= 900 W step disappears from every run - but the OVER-ABSORPTION does not
# move, because that is a `gain` term and not a slew term. See
# DECISIONS/SAFETY-39-slew-against-the-rail.md, which says so at length rather
# than letting this card be read as more than it is.
slew_w: 0
deadband_w: 15
target_grid_w: -10
step_w: 10
@@ -744,7 +777,16 @@ schema:
# ==========================================================================
auto_start: bool
gain: float(0.05,1.0)
slew_w: int(50,5000)
# ⚠️ THE FLOOR IS 0, NOT 50, AND 0 IS THE SHIPPED VALUE - see the options block
# above. It means "follow max_w", so the range starts at the setting that
# cannot go stale rather than at the smallest number somebody could type.
# ⚠️ THE CEILING IS STILL 5000 AND IS STILL NOT THE BOUND THAT MATTERS. The
# binding one is `SLEW_RAIL_FRAC * max_w`, a relation BETWEEN keys, and this
# grammar cannot express a relation - which is the same reason
# `maintenance_charge_w`'s ceiling is not its real bound either (see the note
# above it). validate.py rule 10 is where it is written.
slew_w: int(0,5000)
deadband_w: int(0,500)
target_grid_w: float(-200,200)
step_w: int(1,100)
+10 -1
View File
@@ -580,7 +580,16 @@ def close_loop(gain, split, cycles=200, load_w=2000.0, mode="alloc",
below. "site" is the sum of the units' power; "unit" is inverter A's alone,
which is what the shipped add-on's single `inverter_power` sensor gives it.
"""
tuning = Tuning(gain=gain)
# ⚠️ `slew_w` IS PINNED HERE AND WAS NOT BEFORE, AND THAT IS SAFETY-39.
# ARCH-01's 4000 W peak-to-peak broadcast limit cycle was measured at the
# shipped pair of the day - `max_w: 2000` with `slew_w: 1000` - and this
# function reached that 1000 through `Tuning`'s DEFAULT. SAFETY-39 made that
# default 0, meaning "follow the rail" (`SLEW_RAIL_FRAC * max_w`, 222 W at
# this rail), so the same call would now sweep a DIFFERENT law and report
# ARCH-01's measurement as absent. A reproduction that silently re-tunes
# itself when a default moves is not a reproduction. The number is written
# here, next to the measurement it belongs to.
tuning = Tuning(gain=gain, slew_w=1000.0)
prev, i_w, sat = 0.0, None, 0
power = [0.0, 0.0]
grid = load_w
+237 -5
View File
@@ -17,9 +17,19 @@ Read MUTATION.md before you report a survivor.
import sys
from app.control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from app.control import (LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC,
Tuning, compute, slew_limit_w,
maintenance_charge_floor, peak_at_risk)
T = Tuning()
# ⚠️ SAFETY-39: `slew_w` IS EXPLICIT HERE NOW, AND IT IS release/1.0's VALUE.
# `Tuning()`'s default became 0 - "follow the rail", `SLEW_RAIL_FRAC * max_w`,
# 222 W against this 2000 W rail - so a bare `Tuning()` here would silently
# re-tune every assertion below that the slew limit touches, and several of them
# assert on a number the slew produces. The pair written here (max_w 2000,
# slew_w 1000) is exactly what release/1.0 shipped, which is what most of this
# file is a regression test against. The new default gets its own section at the
# end, where it is the subject rather than a hidden parameter.
T = Tuning(slew_w=1000.0)
fails = []
@@ -49,6 +59,13 @@ def check(name, cond):
# A comment cannot go stale-green, because it never claims to be running.
#
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
# ⚠️ THE SLEW ROWS WERE RE-MEASURED AT SAFETY-39 AND ONE OF THEM MOVED: the
# slew limit was 4 and is now 5, because the SAFETY-39 section grades it too.
# The new row is the RAIL DEFAULT specifically - `slew_limit_w()` reduced to
# `return abs(float(tuning.slew_w))`, which leaves the limit intact and only
# removes the 0-means-the-rail resolution. Three checks notice, which is over
# the invariant's floor of two. Both figures were reproduced by deleting the
# mechanism, running this file and counting, on a tree with __pycache__ purged.
#
# mechanism in compute() checks that fail when deleted
# ------------------------------------------ -----------------------------
@@ -56,7 +73,8 @@ def check(name, cond):
# integrator clamp (AC 1) 6
# integrator bound follows max_w 4
# output clamp 3
# slew limit 4
# slew limit 5
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
# output freeze 2
# deadband 5
# quantisation 2
@@ -328,7 +346,14 @@ def unwind(t):
return prev
tight, loose = unwind(Tuning(max_w=2000)), unwind(Tuning(max_w=2000, integrator_max_w=3000))
# ⚠️ BOTH FIXTURES CARRY release/1.0's `slew_w` EXPLICITLY (SAFETY-39). The
# subject here is `integrator_max_w`, and the figure the check names - 1000 W on
# the first cycle after the drop - is `max_w - slew_w`, so it moves with the slew
# limit and would stop being the number DOCS.md quotes. Pinning it keeps this a
# test about integrator headroom instead of a test about two mechanisms at once;
# the shipped default's own unwind is measured in the SAFETY-39 section below.
tight = unwind(Tuning(max_w=2000, slew_w=1000.0))
loose = unwind(Tuning(max_w=2000, slew_w=1000.0, integrator_max_w=3000))
check(f"after saturation ends the command is {tight:.0f} W (<= 1000)", tight <= 1000)
check(f"headroom above max_w makes that worse ({loose:.0f} W) - hence the default",
loose > tight)
@@ -372,7 +397,15 @@ def legacy(prev, grid, actual, t, sat_count):
ALIAS = {"i-clamped": "clamped"}
diffs = []
seen = set()
for tune in (Tuning(), Tuning(target_grid_w=-10.0), Tuning(max_w=5000, slew_w=5000)):
# ⚠️ `slew_w` IS WRITTEN OUT IN ALL THREE (SAFETY-39). `legacy()` below is a
# TRANSCRIPTION of release/1.0 and reads `t.slew_w` directly, as release/1.0
# did; the new law reads `slew_limit_w(t)`, which treats 0 as 'follow the
# rail'. The two agree for every non-zero value and differ at 0 BY DESIGN, so
# a fixture that left the field at its new default would report the design as
# a regression. Fixing it in `legacy()` instead would make this sweep compare
# the new law against itself, which is the one thing it must never do.
for tune in (Tuning(slew_w=1000.0), Tuning(slew_w=1000.0, target_grid_w=-10.0),
Tuning(max_w=5000, slew_w=5000)):
for prev in (-2000.0, -500.0, -100.0, 0.0, 100.0, 500.0, 2000.0):
for grid in (-6000.0, -1000.0, -500.0, -14.0, 0.0, 14.0, 500.0, 1000.0, 6000.0):
for actual in (-2000.0, 0.0, 600.0, 2000.0):
@@ -443,6 +476,205 @@ check("worst billed rest point falls from 15 W to under 5 W",
compute(500.0, 4.9, 500.0, TB).reason == "deadband"
and compute(500.0, 5.0, 500.0, TB).reason != "deadband")
print("SAFETY-39: the slew limit is a RATIO to the rail, not a number")
# ⚠️ WHAT WENT WRONG, IN ONE LINE, BECAUSE THE SHAPE IS THE POINT. `slew_w:
# 1000` was derived on 2026-08-22 against a clamp of 1500 W and its stated
# justification was that it caps "how far the loop can jump ahead of the
# inverter". The live install then ran max_w 5000 with the same 1000, and on
# 2026-09-05 11:43-11:46 a real 3.4 kW export drove the setpoint 380 -> 5000 W
# in five 2 s ticks, the battery to -5154 W against a 5000 VA inverter, and the
# grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken. The rail
# moved and the constant did not.
#
# So what is graded below is the RELATIONSHIP and not a value: at every rail,
# the loop takes the plant's own settling time to cross its whole output range.
# A check that pinned "555 W" would go stale the same way the comment did.
print(f" SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = {SLEW_RAIL_FRAC:.4f}"
f" ({1 / SLEW_RAIL_FRAC:.0f} ticks to cross the rail)")
check("0 means FOLLOW THE RAIL and resolves to SLEW_RAIL_FRAC * max_w",
slew_limit_w(Tuning(max_w=5000.0, slew_w=0.0)) == SLEW_RAIL_FRAC * 5000.0)
check("an explicit value is passed through untouched - it is the operator's "
"number and nothing here silently reduces it",
slew_limit_w(Tuning(max_w=5000.0, slew_w=400.0)) == 400.0)
check("the shipped Tuning default is 0, i.e. the rail, so the law carries no "
"second constant to go stale beside config.yaml",
Tuning().slew_w == 0.0)
# ⭐ THE RELATIONSHIP ITSELF, over every rail this add-on can be built at: the
# single-inverter schema range (100..5000), the live install (5000), and the
# roster sums a second inverter produces - 9600 with inverter B's datasheet
# figure, 10000 with two of A. ⚠️ 9600 IS NOT A MEASUREMENT: inverter B does not
# exist on site yet (SPIKE-04, ~2026-09-12) and 4600 W is its datasheet and
# serial, per goodwe/NOTES.md §1.1b. It is here because the ARITHMETIC is what
# is being graded, and the arithmetic does not care.
_rails = [100.0, 500.0, 1500.0, 2000.0, 5000.0, 9600.0, 10000.0]
_ticks = {r: r / slew_limit_w(Tuning(max_w=r)) for r in _rails}
print(" ticks to cross the rail at the default, by rail: "
+ ", ".join(f"{r:.0f}W={t:.1f}" for r, t in _ticks.items()))
check("⭐ AT EVERY RAIL THE DEFAULT TAKES THE SAME NUMBER OF TICKS TO CROSS IT - "
"which is PLANT_SETTLE_S at the measured tick. This is the whole of "
"SAFETY-39: raising max_w moves the slew limit with it, so the constant "
"cannot be left behind by the rail again",
all(abs(t - PLANT_SETTLE_S / LOOP_TICK_S) < 1e-9 for t in _ticks.values()))
check("...and that is the same as saying the crossing takes the plant's own "
f"measured settling time ({PLANT_SETTLE_S:.0f} s) at the "
f"{LOOP_TICK_S:.0f} s tick the live install was measured running",
all(abs(t * LOOP_TICK_S - PLANT_SETTLE_S) < 1e-9 for t in _ticks.values()))
# ⭐ VERIFIED BY BREAKING IT. The configuration that produced the 2026-09-05
# excursion, put through the same arithmetic: it crosses the rail in 10 s
# against a plant that takes 18 s. This is the number validate.py rule 10
# refuses on, and it is asserted HERE too so that the law and the gate cannot
# drift apart - the rule can only ever refuse what this ratio says is too fast.
_broken = Tuning(max_w=5000.0, slew_w=1000.0)
_broken_s = 5000.0 / slew_limit_w(_broken) * LOOP_TICK_S
print(f" the 2026-09-05 configuration (max_w 5000, slew_w 1000) crosses its "
f"rail in {_broken_s:.0f} s against a plant measured at {PLANT_SETTLE_S:.0f} s")
check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by "
"the ratio rather than by anybody's opinion - test_validate.py's rule 10 "
"section refuses exactly this pair",
_broken_s < PLANT_SETTLE_S
and slew_limit_w(_broken) > SLEW_RAIL_FRAC * _broken.max_w)
# THE CLOSED LOOP, AND WHAT THIS PLANT IS AND IS NOT.
#
# ⚠️ THE PLANT BELOW IS MODELLED HERE AND IS NOT THE RIG. admin/goodwe's
# `plantsim.Plant` is the battery model this project grades control changes
# against, and this repository's CI cannot reach it - that repository is private
# and this job's token is minted for this one (see .gitea/workflows/checks.yml).
# So the rig evidence lives in `tools/slew_sim.py`, is quoted in the pull
# request and in DECISIONS/SAFETY-39-slew-against-the-rail.md, and what runs
# HERE is a first-order lag with the time constant that reproduces §5.10's
# measured ~18 s to the deadband. test_alloc.py makes the same split for the
# same reason and says so in the same words.
#
# ⚠️ AND IT CANNOT SHOW A BATTERY ABOVE ITS RATING, WHICH IS SAID BEFORE THE
# RESULT RATHER THAN AFTER IT. Neither this model nor `plantsim.Plant` lets the
# plant exceed `p_max`; both clamp. The -5154 W against a 5000 VA inverter that
# the ticket reports is therefore NOT REPRODUCIBLE IN SIMULATION AT ALL, by
# construction, and no acceptance criterion here is written on it. What IS
# reproducible, and is what is graded, is the loop OVER-ABSORBING and driving
# the meter from export into import - the error it then chases.
_TAU_S = 6.0 # first-order lag; with the 2 s tick it reaches the
# deadband ~18 s after a step, which is §5.10's figure
def _step_run(tuning, export_w, load_w=350.0, phase=5, cycles=90,
dt=LOOP_TICK_S):
"""One export step onto a converged loop.
-> (worst import at the meter, peak battery beyond what the site NEEDED,
largest setpoint change in one tick).
"""
import math
prev, i_w, sat, actual, pv = 0.0, None, 0, 0.0, 0.0
a = 1.0 - math.exp(-dt / _TAU_S)
worst_import, peak, biggest, last = -1e9, 0.0, 0.0, None
for c in range(cycles):
if c == phase:
pv = export_w + load_w
grid = load_w - pv - actual
d = compute(prev, grid, actual, tuning, sat, i_w)
if c >= phase and last is not None:
biggest = max(biggest, abs(d.target_w - last))
last = d.target_w
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
# the plant: a first-order lag, clamped at the rating like the real one
actual += (max(-tuning.max_w, min(tuning.max_w, prev)) - actual) * a
if c >= phase:
worst_import = max(worst_import, load_w - pv - actual)
peak = max(peak, abs(actual))
return worst_import, peak - export_w, biggest
def _family(tuning):
"""The worst case over 51 export steps, 1.0-4.2 kW, at three phases.
⚠️ A FAMILY AND NOT ONE STEP, AND THAT IS A FINDING RATHER THAN CAUTION.
A single run of this loop is not evidence: the deadband, the 10 W
quantisation and the phase of the step against the tick move the result by
hundreds of watts, non-monotonically, so two adjacent slews can rank either
way on one step and the opposite way on the next. Every number below is a
worst case over the family, which is how admin/goodwe grades the same
quantities (see `config.yaml`'s "1890 closed-loop runs" and "924 runs").
"""
worst = dict(imp=-1e9, over=-1e9, step=0.0, big=0, runs=0)
for export_w in range(1000, 4300, 200):
for phase in (5, 6, 7):
imp, over, step = _step_run(tuning, float(export_w), phase=phase)
worst["runs"] += 1
worst["imp"] = max(worst["imp"], imp)
worst["over"] = max(worst["over"], over)
worst["step"] = max(worst["step"], step)
worst["big"] += step >= 900.0
return worst
_LIVE = Tuning(max_w=5000.0, slew_w=1000.0) # what ran on 2026-09-05
_RAIL = Tuning(max_w=5000.0) # slew_w 0 - follow the rail
_INTERIM = Tuning(max_w=5000.0, slew_w=400.0) # applied live 2026-09-05 12:00
_SLOWER = Tuning(max_w=5000.0, slew_w=250.0)
_GAIN03 = Tuning(max_w=5000.0, gain=0.3)
_GAIN02 = Tuning(max_w=5000.0, gain=0.2)
_R = {k: _family(t) for k, t in
(("live", _LIVE), ("rail", _RAIL), ("interim", _INTERIM),
("slower", _SLOWER), ("gain03", _GAIN03), ("gain02", _GAIN02))}
print(f" worst case over {_R['live']['runs']} export steps at max_w 5000, "
f"{LOOP_TICK_S:.0f} s tick:")
for _k, _lab in (("live", "gain 0.6 slew_w 1000 (the 2026-09-05 live pair)"),
("rail", "gain 0.6 slew_w 0 (follow the rail)"),
("interim", "gain 0.6 slew_w 400 (the live interim)"),
("slower", "gain 0.6 slew_w 250"),
("gain03", "gain 0.3 slew_w 0"),
("gain02", "gain 0.2 slew_w 0")):
_w = _R[_k]
print(f" {_lab:<48} largest tick step {_w['step']:5.0f} W, "
f"{_w['big']:2}/{_w['runs']} runs step >= 900 W, "
f"over-absorbed {_w['over']:5.0f} W, meter driven to {_w['imp']:+6.0f} W")
# ⭐ THE DEFECT, REPRODUCED BEFORE ANYTHING IS CHANGED. The live pair steps the
# setpoint by a full 1000 W on nearly every member of the family and drives the
# meter from EXPORT into IMPORT - the loop manufacturing the error it then
# chases. That is the 2026-09-05 observation and it is what makes this a control
# fault rather than a slow loop.
check("⭐ REPRODUCED: at max_w 5000 with slew_w 1000 the setpoint steps a full "
"1000 W per tick on nearly every step in the family, and the meter is "
"driven from export into import",
_R["live"]["step"] >= 900.0
and _R["live"]["big"] > 0.9 * _R["live"]["runs"]
and _R["live"]["imp"] > 200.0)
check("⭐ FIXED, ON THE THING THE SLEW ACTUALLY OWNS: following the rail removes "
"the >= 900 W step from EVERY member of the family - which is the symptom "
"that escalated 2 -> 1 -> 13 -> 43 per 30 min on the morning of the "
"2026-09-05 excursion",
_R["live"]["big"] > 0 and _R["rail"]["big"] == 0
and _R["interim"]["big"] == 0)
# ⚠️ AND WHAT IT DOES NOT BUY, MEASURED, ASSERTED, AND SAID BEFORE ANYBODY CAN
# READ MORE INTO THIS CARD THAN IT DELIVERED. `slew_w` bounds the OUTPUT; the
# integrator accumulates `gain * error` per cycle and is bounded only by
# `integrator_max_w`. At gain 0.6 into a 3.4 kW error the integrator moves
# ~2000 W per tick and reaches a 5000 W rail in under three ticks WHATEVER
# `slew_w` is. So the over-absorption is a GAIN term, and slowing the slew does
# not reduce it - on this model it gets slightly WORSE, because the command
# spends longer away from where it needs to be while the integrator keeps
# winding. admin/goodwe's rig, a different plant model, agrees: over-absorption
# 990 W at slew 1000 and 1086 W at slew 250, against 1010 W at gain 0.6 and
# 863 W at gain 0.3 (tools/slew_sim.py).
#
# `gain` is therefore the next constant to re-derive. SAFETY-39 measured it and
# CHANGED NOTHING: it is a live control-law constant on a running house, and two
# of them moving in one release makes the next measurement uninterpretable.
check("⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION, AND THIS CHECK IS HERE SO "
"THAT NOBODY READS SAFETY-39 AS 'THE OVERSHOOT IS FIXED'. Quartering the "
"slew from 1000 to 250 does not reduce the over-absorption at all",
_R["slower"]["over"] >= _R["live"]["over"])
check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at "
"the same slew. That is the measurement behind 'gain is the next constant "
"to re-derive', and it is a measurement rather than an opinion",
_R["gain03"]["over"] < _R["rail"]["over"]
and _R["gain02"]["over"] < _R["gain03"]["over"])
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
+23 -7
View File
@@ -144,9 +144,17 @@ def check(name, cond):
fails.append(name)
def tuning(max_w=LIVE_INSTALL_MAX_W):
"""The shipped defaults from config.yaml, at the live install's max_w."""
return Tuning(gain=0.6, max_w=max_w, slew_w=1000, deadband_w=15,
def tuning(max_w=LIVE_INSTALL_MAX_W, slew_w=0):
"""The shipped defaults from config.yaml, at the live install's max_w.
⚠️ `slew_w` IS 0 - "FOLLOW THE RAIL" - AND IS A PARAMETER (SAFETY-39). 0 is
what config.yaml ships and resolves to `SLEW_RAIL_FRAC * max_w`, so it moves
with `max_w` instead of being a number typed beside it. It has to be
overridable because one case below picks an ABSURDLY SMALL rail on purpose,
and at that rail a rail-following slew is 2 W per cycle - which would make
the slew, rather than the thing under test, the reason nothing fires.
"""
return Tuning(gain=0.6, max_w=max_w, slew_w=slew_w, deadband_w=15,
target_grid_w=-10, step_w=10, saturation_w=500,
saturation_cycles=3)
@@ -327,10 +335,10 @@ check("the capture loaded", len(rows) > 1000)
# covers both kWh registers, so a real `unchanged_s` on this data can only be
# SHORTER than what is reconstructed here - this replay is the pessimistic
# version of the converged case, not a flattering one.
def replay(hold_s, max_w, cycle_floor_s=15.0):
def replay(hold_s, max_w, cycle_floor_s=15.0, slew_w=0):
"""Drive the real control law and the real detector over the capture."""
wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s))
t = tuning(max_w)
t = tuning(max_w, slew_w)
target, i_w, sat = 0.0, 0.0, 0
prev_pwr, changed_at = None, rows[0]["t"]
last_cycle = None
@@ -456,7 +464,15 @@ check("...and the shipped law really does park the command there",
# converged capture is silent" is satisfied by a detector that is wired to
# nothing. Drop the hold to 1 s and the rail to nothing, and the SAME replay
# must alarm - so the silence above is the data's doing, not the harness's.
wd_hot, _, _, fired_hot = replay(1.0, 20.0)
# ⚠️ AND THE SLEW IS PINNED FOR THIS CASE ONLY, WHICH IS THE OTHER HALF OF
# MAKING BOTH TERMS REACHABLE (SAFETY-39). The rail here is 20 W, chosen absurdly
# small so the rail term cannot be what holds the alarm off. At the shipped
# `slew_w: 0` the limit follows that rail too - `SLEW_RAIL_FRAC * 20` is 2.2 W
# per cycle - so the command could not reach 19 W inside the capture and the
# replay would go silent for a reason that has nothing to do with the detector.
# That is exactly the vacuous pass this case exists to rule out, arriving
# through the case itself, so the slew is written out here and nowhere else.
wd_hot, _, _, fired_hot = replay(1.0, 20.0, slew_w=20.0)
check("...and the same replay DOES alarm once both terms are made reachable",
fired_hot is not None and wd_hot.episodes > 0)
@@ -699,7 +715,7 @@ def _opts(**over):
o = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_INSTALL_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": SHIPPED_DEADBAND_W,
"slew_w": 0, "deadband_w": SHIPPED_DEADBAND_W,
"target_grid_w": -10, "step_w": 10, "heartbeat_s": 10,
"stale_input_s": STALE_INPUT_S}
o.update(over)
+879
View File
@@ -0,0 +1,879 @@
"""Runnable check for SAFETY-37's freshness policy. `python3 test_freshness.py`
THE SUBJECT OF THIS FILE IS THE FIXTURE, NOT THE CODE. A pack legitimately
resting at 40 % and a dead sensor reading 40 % ARE THE SAME NUMBER. A check that
cannot separate "fresh and unchanged" from "stale" cannot see the failure it
names, and this repository has recorded that defect - "a check whose DATA cannot
reach the failure it names" - eleven times. So part 1 builds the separation and
ASSERTS THE VALUE STREAMS ARE BIT-IDENTICAL across the pair; everything after it
is downstream of that pair being real.
⚠️ AND THE CARD'S WORDING HAD TO BE SHARPENED TO BUILD IT, WHICH IS SAID HERE
RATHER THAN QUIETLY WORKED AROUND. The card says "only arrival separates them".
For a signal this add-on polls out of Home Assistant's state cache, ARRIVAL
CANNOT SEPARATE THEM: `read_inputs()` runs about once a second and Home
Assistant answers 200 with a number whether or not the publisher behind the
entity is alive, so both members of the pair arrive, identically, forever.
Part 1a demonstrates exactly that - an arrival-age gate is graded and FAILS to
tell the pair apart - and it is the reason the shipped verdict is a conjunction
with an OBLIGATION term rather than an age threshold. Every clock in that
verdict is still an arrival clock: nothing reads a Home Assistant timestamp,
which is the half of "arrival-stamped" that TEL-06 is blocked on and that this
module does honour.
1. THE PAIR. Fresh-and-unchanged vs stale, on identical value streams, on
the policy object itself. Plus 1a, the arrival-age gate that cannot.
2. THE WIRING. The same pair driven through the REAL `Controller.read_inputs`
at a roster of TWO, with the two units carrying DIFFERENT power values.
3. THE CONSUMERS REFUSE, and the counterfactual is run beside them: the
per-unit sign check and the circulating-power sensor, with the gate and
with the gate removed, through real `cycle()` calls.
4. THE LATCH, and the ~1 Hz oscillation it exists to prevent.
5. N=1. Nothing changes on a healthy install - asserted on the shipped read
path, not assumed - and the one thing that DOES change is named.
6. THE BOUNDS, graded against the shipped `config.yaml` at both ends.
7. THE POLICY TABLE tells the truth about what is wired.
8. THE MEASUREMENT the bound rests on, replayed from the committed capture.
9. KNOWN SURVIVORS, graded so they are residuals rather than surprises.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12; goodwe/NOTES.md §1.1b).
Every two-unit case below is two copies of inverter A's behaviour with
DIFFERENT numbers passed in, and no number here is a measured parameter for a
second device.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
stops bytecode being WRITTEN, not a stale one being READ. Read MUTATION.md
before you report a survivor.
"""
import ast
import asyncio
import glob
import gzip
import io
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from app import freshness as F
from app.circulation import circulating_w
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness
from app.inverter import Inverter
from app.main import Controller
from app.store import DEFAULTS
from app.watchdog import SignWatchdog, SignWatchdogConfig, disagrees
fails = []
HERE = Path(__file__).resolve().parent
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
# --------------------------------------------------------------------------- #
# fixtures
# --------------------------------------------------------------------------- #
class MemStore:
def __init__(self, data=None):
self.data = dict(DEFAULTS, **(data or {}))
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
def set_time(self, *a):
pass
class Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Hass:
"""A Home Assistant that answers per ENTITY and RECORDS what it served.
⚠️ PER ENTITY, AND THE RECORD IS LOAD-BEARING. A stub that answered one
number whatever it was asked could not tell "B was read from B's sensor"
from "B was handed A's reading" - and the served-value log is what lets
part 1 assert that the fresh run and the stale run saw the SAME numbers.
"""
def __init__(self, numbers=None, limits=None):
self.numbers = dict(numbers or {})
self.limits_by = dict(limits or {})
self.served = [] # (entity, value) in call order
self.writes = []
async def number(self, entity, invert=False):
v = self.numbers.get(entity)
self.served.append((entity, v))
return None if v is None else (-v if invert else v)
async def limits(self, entity):
return self.limits_by.get(entity, (None, None, None))
async def set_number(self, entity, value):
self.writes.append((entity, value))
return True
async def call(self, domain, service, data):
return True
A_RATING = 5000.0
# ⚠️ ILLUSTRATIVE, AND NOT B'S DATASHEET FIGURES. SPIKE-04a established B as a
# GW5048D-ES rated 4600 W with a 10 % floor (goodwe/NOTES.md §1.1b), and this
# fixture deliberately does not use them: 5000/4600 is a 400 W gap, narrower
# than a slew step, and two equal-ish numbers cannot show a check that read the
# wrong unit's. Same reasoning as test_inverter.py's `B_RATING_HYPOTHESIS`.
B_RATING_SIM = 3000.0
A_FLOOR, B_FLOOR_SIM = 10, 15
ROSTER = [
{"name": "inverter-a", "setpoint_entity": "number.sp_a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": A_RATING, "bms_soc_floor": A_FLOOR},
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
"max_w": B_RATING_SIM, "bms_soc_floor": B_FLOOR_SIM},
]
# ⚠️ A AND B CARRY DIFFERENT POWER VALUES IN EVERY FIXTURE BELOW, and the card
# names this as the trap it expects: "if both read the same, the test cannot
# distinguish a frozen reading from a live one and will pass against the broken
# code". SAFETY-30's fix round walked into exactly that with two watchdog holds
# both set to 1.
A_FROZEN_W = -1200.0 # inverter A's register, stuck on a charge figure
B_LIVE_W = 900.0 # inverter B's register, moving, and NOT A's number
BASE = dict(meter_source="off", meter_entity="sensor.grid", setpoint_entity="",
soc_entity="", batt_entity="", max_w=A_RATING, gain=0.6, slew_w=0,
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
stale_input_s=15, inverters=ROSTER)
def site(opts=None, numbers=None):
"""A real two-inverter Controller off the shipped `build_roster()`."""
hass = Hass(dict({"sensor.grid": 0.0,
"sensor.soc_a": 50.0, "sensor.batt_a": A_FROZEN_W,
"sensor.soc_b": 55.0, "sensor.batt_b": B_LIVE_W},
**(numbers or {})),
{"number.sp_a": (-A_RATING, A_RATING, 1.0),
"number.sp_b": (-B_RATING_SIM, B_RATING_SIM, 1.0)})
c = Controller(dict(BASE, **(opts or {})), hass, MemStore(), Pub(),
stop=asyncio.Event())
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
c.inverters[1].dev_min, c.inverters[1].dev_max = -B_RATING_SIM, B_RATING_SIM
return c, hass
# ==============================================================================
print()
print("1. THE PAIR: 'fresh and unchanged' vs 'stale', on IDENTICAL value streams")
# The two runs below feed the policy object the SAME forty readings of the same
# number at the same instants. The ONLY difference is what the controller was
# asking the inverter to do while those readings arrived.
#
# FRESH the site is converged. The command sits where it is, the register
# answers the same watt, and nothing is wrong. This is the case the
# 30-day capture in part 8 measures at up to 263.2 s.
# STALE the site reversed. The command travelled 2500 W and the register did
# not move by one watt in 40 s, which is 2.4x the fastest it could
# possibly be obliged to answer in.
STREAM = [(float(t), A_FROZEN_W) for t in range(0, 40)]
def drive(bound, stream, standing_of):
"""Feed a Freshness one arrival per (t, value). Returns (obj, served, verdicts)."""
f = Freshness(bound)
served, verdicts = [], []
for t, v in stream:
f.submit(v, standing_of(t), t)
served.append(v)
verdicts.append(f.reading())
return f, served, verdicts
_fresh, _fresh_served, _fresh_read = drive(
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W)
# The command travels from -1200 W to +1300 W at t=1 and stays there: a site
# turnaround, which is the ordinary stimulus SAFETY-31's own campaign sweeps.
_stale, _stale_served, _stale_read = drive(
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W if t < 1 else 1300.0)
print(f" served (both runs): {_fresh_served[:3]} ... x{len(_fresh_served)}")
# ⚠️ `key=str`, AND IT IS NOT COSMETIC. Without it this line raises TypeError
# comparing None with a float the moment a mutation makes the FRESH run stale -
# which is what M2 (dropping the command-travel term) did: the file died on a
# DIAGNOSTIC PRINT before check one, so the harness reported "red" with no named
# check behind it. MUTATION.md's reporting rule is "which suite went red, and on
# WHICH NAMED CHECK"; a print that can crash first destroys exactly that.
print(f" fresh verdicts: unique={sorted(set(_fresh_read), key=str)}")
print(f" stale verdicts: first None at t="
f"{next((i for i, v in enumerate(_stale_read) if v is None), None)}")
check("⚠️ THE FIXTURE'S OWN PRECONDITION: the two runs saw a BIT-IDENTICAL "
"value stream. If this ever stops being true the pair below is comparing "
"two different experiments and proves nothing about freshness",
_fresh_served == _stale_served
and len({v for v in _fresh_served}) == 1)
check("FRESH AND UNCHANGED is not stale. Forty identical readings over 39 s "
"with the command standing still - a converged site - and the reading "
"reaches its consumers untouched",
not _fresh.stale and _fresh_read == [A_FROZEN_W] * len(STREAM))
check("⚠️ STALE IS. The same forty readings, the same instants, the same "
"numbers - and the command moved 2500 W underneath them. The reading "
"becomes None to its consumers, which is the whole card",
_stale.stale and _stale_read[-1] is None)
check("...and it becomes None at the BOUND, not before: 35.0 s of unchanged "
"arrivals, so the last fresh verdict is at t=34 and the first None at "
"t=35",
_stale_read[34] == A_FROZEN_W and _stale_read[35] is None
and BATT_UNCHANGED_S == 35.0)
check("...and NEVER a held number and never a zero - `reading()` has exactly "
"two shapes, the arrived value or None",
set(type(v) for v in _stale_read) == {float, type(None)}
and 0.0 not in [v for v in _stale_read if v is not None])
check("...and the reason names the three terms an operator has to check, with "
"the numbers that fired it",
"STALE_READING" in _stale.reason and "2500 W" in _stale.reason
and "unchanged for 35 s" in _stale.reason)
# --- 1a. the gate the card asked for first, graded and found unable ----------
# An arrival-age gate is what "arrival-stamped" reads as on first hearing. It is
# built here and run against the SAME pair, because a criterion that cannot be
# satisfied is worth more said out loud than approximated in silence.
_arrival_ages = []
_af = Freshness(BOUNDS["batt"])
for t, v in STREAM:
_af.submit(v, A_FROZEN_W, t)
# Every read produced a number, so on both members of the pair the age
# since the last ARRIVAL is 0.0 at every instant. There is nothing to
# threshold.
_arrival_ages.append(0.0 if _af.last is not None else None)
check("⚠️ AN ARRIVAL-AGE GATE CANNOT TELL THE PAIR APART, and that is why the "
"shipped verdict is not one. Home Assistant answered every read with a "
"number in BOTH runs, so the age since arrival is 0.0 s at every instant "
"of both - a threshold on it is a threshold that can never fire, which "
"is TEL-06's defect rebuilt in this repository",
set(_arrival_ages) == {0.0} and _af.arrivals == len(STREAM))
check("...while the arrival record IS what the shipped verdict is measured on: "
"`unchanged_s` is the gap between arrivals that carried different "
"numbers, on this process's own monotonic clock, and no Home Assistant "
"timestamp is read anywhere in the module",
_stale.unchanged_s(39.0) == 39.0
and "last_updated" not in io.open(HERE / "app" / "freshness.py",
encoding="utf-8").read().split(
'"""', 2)[2])
check("...and a read that produced NOTHING is still an immediate None, exactly "
"as it is today - a failed read is not an arrival and is not an age",
Freshness(BOUNDS["batt"]).reading() is None
and (lambda f: (f.submit(5.0, 0.0, 0.0), f.submit(None, 0.0, 1.0),
f.reading() is None and f.held == 5.0)[-1])(
Freshness(BOUNDS["batt"])))
# ==============================================================================
print()
print("2. THE WIRING: the same pair through the REAL read path, at a roster of 2")
async def _reads(c, hass, plan):
"""Drive real `read_inputs()` calls. `plan` is (t, standing_a, batt_a)."""
out = []
for t, standing_a, batt_a in plan:
hass.numbers["sensor.batt_a"] = batt_a
# `last_write` is what the write path sets and what `standing_w()`
# reads; setting it here is standing in for a write, not for the gate.
c.inverters[0].last_write = standing_a
await c.read_inputs(now_mono=t)
out.append((t, c.inverters[0].batt, c.inverters[1].batt))
return out
_PLAN_FRESH = [(float(t), A_FROZEN_W, A_FROZEN_W) for t in range(0, 40)]
_PLAN_STALE = [(float(t), A_FROZEN_W if t < 1 else 1300.0, A_FROZEN_W)
for t in range(0, 40)]
_cf, _hf = site()
_of = run(_reads(_cf, _hf, _PLAN_FRESH))
_cs, _hs = site()
_os = run(_reads(_cs, _hs, _PLAN_STALE))
print(f" fresh run: A={sorted({r[1] for r in _of})} B={sorted({r[2] for r in _of})}")
print(f" stale run: A={sorted({r[1] for r in _os}, key=str)} "
f"B={sorted({r[2] for r in _os})}")
check("PRECONDITION: both runs were served the same per-entity numbers, so the "
"difference below is the wiring and not the stimulus",
[x for x in _hf.served if x[0] == "sensor.batt_a"]
== [x for x in _hs.served if x[0] == "sensor.batt_a"])
check("⚠️ THE TWO UNITS CARRY DIFFERENT POWER VALUES - A at -1200 W, B at "
"+900 W. A fixture whose units read the same number cannot show a gate "
"that answered for the wrong unit, which is the trap the card names",
A_FROZEN_W != B_LIVE_W and all(r[2] == B_LIVE_W for r in _of))
check("THROUGH THE REAL PATH: a converged site keeps its reading",
all(r[1] == A_FROZEN_W for r in _of))
check("⚠️ THROUGH THE REAL PATH: a frozen register becomes `Inverter.batt is "
"None`. Not a held number, not a zero - the same None a missing entity "
"produces, which is the state §11.1 rule 1 already answers with 0 W",
_os[34][1] == A_FROZEN_W and _os[35][1] is None
and all(r[1] is None for r in _os[35:]))
check("...and ONLY the frozen unit's. B's own reading is untouched, which is "
"the per-unit half: a freshness verdict about A is not evidence about B",
all(r[2] == B_LIVE_W for r in _os) and _cs.inverters[1].batt == B_LIVE_W
and not _cs.inverters[1].batt_fresh.stale)
check("...and `inputs_ok` goes False, so the site stops rather than regulating "
"against a number nobody measured",
_cs.inputs_ok is False and _cf.inputs_ok is True)
check("...and the operator gets a line saying which unit and why, on the same "
"status list the liveness notes use",
any("STALE_READING" in n and "inverter-a" in n
for n in _cs.liveness_notes())
and _cf.liveness_notes() == [])
check("...and the raw number is still recorded for diagnostics, so the "
"operator can see WHAT it was stuck on",
_cs.inverters[0].batt_fresh.last == A_FROZEN_W)
# ==============================================================================
print()
print("3. THE CONSUMERS REFUSE - and the counterfactual runs beside them")
# Both consumers already do the right thing with a None. Neither could ever
# receive one before this card, which is the entire exposure: the gate is the
# thing that was missing BETWEEN the shipped read and the shipped check.
_cfg3 = SignWatchdogConfig(min_w=250.0, hold_s=85.0)
check("PRECONDITION on the shipped predicate: a None measurement does not "
"disagree with anything, so it CLEARS the sign window",
disagrees(1300.0, None, True, _cfg3) is False
and disagrees(1300.0, A_FROZEN_W, True, _cfg3) is True)
check("PRECONDITION on the shipped sensor: a roster with a missing term is "
"None, and None clears the circulation window rather than accumulating",
circulating_w([1300.0, None]) is None
and circulating_w([A_FROZEN_W, B_LIVE_W]) == 2 * min(
abs(A_FROZEN_W), abs(B_LIVE_W)))
async def _cycles(gated, ticks=25, step_mono=12.0, wall=0.20):
"""Real cycles at N=2 with A's register frozen and the LOOP driving.
⚠️ THE COMMAND TRAVEL IS PRODUCED BY THE SHIPPED CONTROL LAW, NOT BY THE
FIXTURE. The meter is pinned at +3000 W of import, so `compute()` ramps the
site into discharge at its own slew rate through the real allocator and the
real write path; nothing here assigns `last_write`. A fixture that set the
standing command by hand would be feeding the gate its own arming condition,
which is the shape of check this repository keeps finding.
`gated=False` is THE COUNTERFACTUAL - the shipped behaviour before this
card, rebuilt by handing the consumers the raw held number the gate
withheld. Same cycles, same stimulus, so the difference between the two
columns is the gate and nothing else.
⚠️ TWO CLOCKS, DELIBERATELY, AND THE WALL-CLOCK ONE IS THE FLAKE RISK.
The freshness verdict runs on the INJECTED monotonic clock (12 s a tick, so
the 35 s bound is crossed on tick 3, deterministically, however loaded the
machine is). The sign watchdog runs on the WALL clock inside `cycle()`,
which cannot be injected - so `unit_sign_disagree_s` is set to 2 s and the
ticks are 0.20 s apart, putting the gate's close at ~0.6 s against a 2 s
window. That ORDER is the field's - 35 s < 75 s < 85 s by construction, part
6 - and the gate is given no head start beyond it.
⚠️ AND THE MARGIN IS MEASURED AND ASSERTED RATHER THAN ASSUMED (CI-06).
`test_peakguard.py` answered two of its own checks with the wall clock, flaked
at ~20 %, and cost a wrong merge diagnosis - a flaky check does not waste a
re-run, it manufactures evidence for whichever hypothesis the reader already
holds. So this returns the wall clock at which the gate closed, and the check
below fails with THAT NUMBER if the box was too slow, rather than reporting a
trip that looks like a code defect.
"""
c, hass = site({"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 2,
"sign_disagree_w": 250, "sign_disagree_s": 2,
"site_sign_disagree_w": 250, "site_sign_disagree_s": 2,
"auto_start": True},
{"sensor.grid": 3000.0})
c.auto = True
circ, tripped_at, standing = [], None, []
t_start, t_gate = time.monotonic(), None
for k in range(ticks):
# A's register is FROZEN on the charge figure it stopped at; B's is
# live and is a DIFFERENT number. Neither is touched by the loop.
hass.numbers["sensor.batt_a"] = A_FROZEN_W
hass.numbers["sensor.batt_b"] = B_LIVE_W
await c.read_inputs(now_mono=k * step_mono)
if not gated:
for inv in c.inverters:
inv.batt = inv.batt_fresh.last
if t_gate is None and c.inverters[0].batt_fresh.stale:
t_gate = time.monotonic() - t_start
circ.append(c.site_circulating_w)
standing.append(c.inverters[0].standing_w())
await c.cycle()
if c.inverters[0].sign_wd.tripped and tripped_at is None:
tripped_at = k
await asyncio.sleep(wall)
return c, circ, tripped_at, standing, t_gate
_cg, _circg, _tripg, _standg, _tgate = run(_cycles(True))
_cu, _circu, _tripu, _standu, _ = run(_cycles(False))
_WD_S = 2.0 # `unit_sign_disagree_s` as this fixture sets it, above
print(f" standing on A (gated): {[round(v) for v in _standg]}")
print(f" gated : sign trip tick={_tripg} circ={_circg[:6]}...")
print(f" gate closed after {_tgate:.2f} s of WALL clock, against the "
f"{_WD_S:.0f} s watchdog window ({_WD_S / _tgate:.1f}x margin)")
print(f" counterfact: sign trip tick={_tripu} circ={_circu[:6]}...")
check("⚠️ PRECONDITION ON THE WALL CLOCK, AND IT IS A CHECK RATHER THAN AN "
"ASSUMPTION (CI-06). The gate closes on the INJECTED clock, the watchdog "
"on the real one, so this fixture only means something while the gate "
f"closes well inside the window: it closed at {_tgate:.2f} s against "
f"{_WD_S:.0f} s. If this fails the machine was slow, not the code - and "
"saying so here is what stops that being read as a defect",
_tgate is not None and _tgate < 0.5 * _WD_S)
check("PRECONDITION: the SHIPPED control law really did travel the command - "
"the gate's arming term is produced by `compute()` and the allocator, "
"not written into the fixture",
max(abs(v) for v in _standu) > MOVE_W
and abs(_standu[3] - _standu[0]) > MOVE_W)
check("⚠️ COUNTERFACTUAL FIRST - the stimulus is REAL. Without the gate, the "
"frozen register false-trips SAFETY-30's per-unit sign check and parks a "
"healthy inverter. That is the fault this card was filed for",
_tripu is not None and _cu.inverters[0].sign_wd.tripped
and "SIGN_DISAGREEMENT" in _cu.inverters[0].sign_wd.reason)
check("⚠️ AND WITH THE GATE IT DOES NOT TRIP. The reading is withheld before "
"the watchdog's window can accumulate, so the check refuses to conclude "
"rather than concluding wrongly - which is what a stale reading deserves",
_tripg is None and not _cg.inverters[0].sign_wd.tripped)
check("...and inverter B was not parked either way - the verdict is per unit",
not _cg.inverters[1].sign_wd.tripped
and not _cu.inverters[1].sign_wd.tripped)
check("⚠️ COUNTERFACTUAL: without the gate the circulating-power sensor "
"reports a loop that is not happening, computed from one frozen term and "
"one live one",
all(v == 2 * min(abs(A_FROZEN_W), abs(B_LIVE_W)) for v in _circu)
and _circu[0] == 1800.0)
check("⚠️ AND WITH THE GATE IT REPORTS `None` - an unknown, which CLEARS the "
"circulation window instead of accumulating it. Both consumers gated, "
"from one place, by one mechanism",
_circg[0] == 1800.0 and _circg[-1] is None
and all(v is None for v in _circg[_circg.index(None):]))
check("...and the circulation watchdog did not latch on the stale evidence",
not _cg.circ_wd.tripped and _cg.circ_wd.over_since is None)
# ==============================================================================
print()
print("4. THE LATCH, and the ~1 Hz oscillation it exists to prevent")
# Declaring the reading stale makes inputs_ok False, which commands 0 W, which
# drops the standing command below MOVE_W, which un-arms the conjunction. Without
# a latch the held number would come straight back to the consumers, and the
# whole thing would flip once per read for as long as the register stayed dead.
_lat = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_lat.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
_was = _lat.stale
_after_zero = []
for t in range(40, 50):
_lat.submit(A_FROZEN_W, 0.0, float(t)) # the site has been stopped
_after_zero.append(_lat.reading())
check("PRECONDITION: it is stale before the site is stopped",
_was and _lat.reading() is None)
check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W. The conjunction is no "
"longer armed - |standing| is 0 W, below the 250 W term - so an unlatched "
"verdict would hand the held -1200 W straight back and the site would "
"restart into it, then stop, then restart, once per read",
all(v is None for v in _after_zero))
# ⚠️ AND A FAILED READ MUST NOT CLEAR IT EITHER. This check exists because the
# mutation that added `self.stale = False` to `submit()`'s None branch SURVIVED
# the whole sweep - no fixture had ever injected a read failure while latched.
# It matters: a stale verdict stops the site, the site is then commanded 0 W, and
# a single unreachable-Home-Assistant read would drop the latch into a state
# where the conjunction is no longer armed - so the held -1200 W would come back
# to the consumers on the very next successful read.
_lat_fail = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_lat_fail.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
_lat_fail.submit(None, 0.0, 40.0) # Home Assistant did not answer
_after_fail = _lat_fail.reading()
_lat_fail.submit(A_FROZEN_W, 0.0, 41.0) # it answers again, same number,
# site now resting at 0 W
check("⚠️ A FAILED READ DOES NOT CLEAR THE LATCH - found by a SURVIVING "
"mutation, not by design. Our inability to fetch a value is not evidence "
"that the value moved, and a stale verdict that a single unreachable read "
"can drop is one a stopped site would drop every time",
_after_fail is None and _lat_fail.stale
and _lat_fail.reading() is None)
check("⚠️ AND ONLY MOVEMENT CLEARS IT - a different number arriving, which is "
"exactly the evidence the verdict was about. Not a timer, not a restart, "
"not the operator's start button",
(lambda f: (f.submit(A_FROZEN_W + 1.0, 0.0, 60.0),
(not f.stale) and f.reading() == A_FROZEN_W + 1.0)[-1])(_lat))
# ⚠️ THE MAGNITUDE TERM OF THE CONJUNCTION, AND THIS CHECK EXISTS BECAUSE A
# MUTATION SURVIVED WITHOUT IT. Deleting `abs(standing_w) < move_w` from
# `_grade()` left every other check in this file green, because in all of them a
# command that has TRAVELLED 250 W has also ENDED UP above 250 W. The case that
# separates them is a site being brought to REST.
_rest = Freshness(BOUNDS["batt"])
# The register reaches +5 W on the same read at which the old +1300 W command is
# still standing - the write path is serialised, so the register can catch up
# BEFORE the command settles - and then the site is commanded to 0 W and the
# register sits at +5 W, correctly, for a minute.
_rest.submit(1290.0, 1300.0, 0.0)
_rest.submit(5.0, 1300.0, 1.0) # standing_at_change captures 1300 W
for t in range(2, 60):
_rest.submit(5.0, 0.0, float(t)) # site at rest; register truthfully +5 W
check("⚠️ THE MAGNITUDE TERM IS LOAD-BEARING, and it took a surviving mutation "
"to show it. A register that reached +5 W while the old +1300 W command "
"was still standing has a `standing_at_change` of 1300 W, so the TRAVEL "
"term alone reads 1300 W of unfollowed command for the whole minute the "
"site rests - and calls a perfectly truthful reading dead. Requiring "
"|standing| >= 250 W as well is what says 'a site at rest is not owed "
"movement'",
not _rest.stale and _rest.reading() == 5.0)
check("...and the hole that term leaves CANNOT MISLEAD EITHER CONSUMER, which "
"is why it is the right trade: a unit commanded under 250 W is one "
"`watchdog.disagrees()` is False for whatever the measurement says, and a "
"frozen term beside a live unit that is itself resting nets to zero "
"circulating power rather than to a false alarm",
disagrees(0.0, A_FROZEN_W, True, _cfg3) is False
and circulating_w([A_FROZEN_W, 0.0]) == 0.0)
check("...and one watt is enough, because 0x0518 is a 1 W register that "
"dithers +/-1 W while holding (goodwe/NOTES.md §5.11, measured "
"2026-08-22) - a working register clears this on its next 10 s publish",
_lat.held == A_FROZEN_W + 1.0 and not _lat.stale)
# ==============================================================================
print()
print("5. N=1: nothing changes on a healthy install, and what DOES is named")
_ONE = dict(BASE, inverters=[], setpoint_entity="number.sp_a",
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
bms_soc_floor=A_FLOOR)
def one_site():
hass = Hass({"sensor.grid": 0.0, "sensor.soc_a": 50.0,
"sensor.batt_a": A_FROZEN_W},
{"number.sp_a": (-A_RATING, A_RATING, 1.0)})
c = Controller(dict(_ONE), hass, MemStore(), Pub(), stop=asyncio.Event())
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
return c, hass
async def _one_healthy():
c, hass = one_site()
seen = []
for t in range(0, 60):
# A healthy register under a moving command: it dithers, so it is never
# the same integer watt twice running.
hass.numbers["sensor.batt_a"] = A_FROZEN_W + (t % 3)
c.inverters[0].last_write = 1300.0 if t % 2 else -1200.0
await c.read_inputs(now_mono=float(t))
seen.append(c.inverters[0].batt)
return c, seen
_c1, _seen1 = run(_one_healthy())
check("⚠️ AC: AT N=1 NOTHING CHANGES ON A HEALTHY INSTALL, and it is asserted "
"rather than assumed. Sixty reads of a dithering register under a command "
"reversing every second: every value reaches the consumers unmodified, "
"the gate never arms, and the status page says nothing new",
_seen1 == [A_FROZEN_W + (t % 3) for t in range(60)]
and not _c1.inverters[0].batt_fresh.stale
and _c1.liveness_notes() == [] and _c1.inputs_ok is True)
check("...and the shipped roster really is one unit, so this is the install "
"that is running in a house today and not a fixture shaped like it",
len(_c1.inverters) == 1 and _c1.inverters[0].name == "inverter-a")
async def _one_frozen():
c, hass = one_site()
for t in range(0, 40):
hass.numbers["sensor.batt_a"] = A_FROZEN_W
c.inverters[0].last_write = A_FROZEN_W if t < 1 else 1300.0
await c.read_inputs(now_mono=float(t))
return c
_c1f = run(_one_frozen())
check("⚠️ AND WHAT DOES CHANGE AT N=1 IS NAMED RATHER THAN HIDDEN: a FROZEN "
"register now stops the site, where before it read as a healthy "
"measurement. That is a behaviour change on the shipped roster and it is "
"the point of the card - HW-01 was a one-inverter site, and the pack "
"drained to its floor with every detector reading healthy for 6 h 25 m",
_c1f.inverters[0].batt is None and _c1f.inputs_ok is False)
check("...and the direction it fails in is the safe one: a false verdict "
"commands 0 W and keeps commanding (§11.1 rule 1), behind the existing "
"`stale_input_s` hold, and clears itself on the register's next move",
float(_c1f.o.get("stale_input_s")) > 0
and _c1f.inverters[0].batt_fresh.stale)
# ==============================================================================
print()
print("6. THE BOUNDS, graded against the SHIPPED config.yaml at BOTH ends")
def option_default(key):
"""`key`'s default out of config.yaml's `options:` block, at two spaces."""
body = io.open(HERE / "config.yaml", encoding="utf-8").read()
body = body[body.index("\noptions:"):body.index("\nschema:")]
for line in body.splitlines():
if line.startswith(f" {key}:") and not line[2:3].isspace():
return float(line.split(":", 1)[1].strip())
return None
_circ_hold = option_default("circulation_hold_s")
_unit_hold = option_default("unit_sign_disagree_s")
_unit_min_w = option_default("unit_sign_disagree_w")
_ceiling = min(_circ_hold, _unit_hold)
print(f" floor {BATT_OBLIGED_S:.1f} s = settle 6.0 + publish 10.0 + read 1.0")
print(f" bound {BATT_UNCHANGED_S:.1f} s")
print(f" ceil {_ceiling:.1f} s = min(circulation_hold_s {_circ_hold:.0f}, "
f"unit_sign_disagree_s {_unit_hold:.0f})")
check("PRECONDITION: both consumer windows were actually found in config.yaml, "
"so a rename cannot turn this part into a check of two Nones",
_circ_hold == 75.0 and _unit_hold == 85.0 and _unit_min_w == 250.0)
check("⚠️ THE BOUND IS AT LEAST 2x THE PHYSICAL FLOOR. Below 17.0 s a healthy "
"register has not yet been OBLIGED to answer - 6.0 s of plant "
"(NOTES §5.9), one 10 s ESPHome publish (firmware/goodwe-master.yaml:181) "
"and one 1 s add-on read - so a shorter bound trips on a working inverter",
BATT_UNCHANGED_S >= 2 * BATT_OBLIGED_S and BATT_OBLIGED_S == 17.0)
check("⚠️ AND AT MOST HALF THE TIGHTER CONSUMER WINDOW. A gate slower than its "
"consumer hands over the frozen number and explains afterwards. THIS IS "
"THE CHECK THAT BITES IF AN OPERATOR'S DEFAULT MOVES: drop "
"`circulation_hold_s` to 60 in config.yaml and this goes red",
BATT_UNCHANGED_S <= 0.5 * _ceiling)
# ⚠️ THE FIRST DRAFT OF THIS CHECK CLAIMED THE BAND CONTAINED EXACTLY ONE
# INTEGER AND IT DOES NOT - it contains 34, 35, 36 and 37, and the check went
# red on its first run and is written the honest way round here. The band is
# what is derived; the value inside it is a choice, and the only defensible
# claims about the choice are that it IS inside and that it is not a number
# borrowed from somewhere else.
check("...and the admissible band computed from config.yaml is [34.0, 37.5], "
"which contains four integers. 35 is a CHOICE inside a DERIVED band, not "
"a derived number, and it is deliberately not 30 - which is "
"FIRMWARE_INGEST_VETO_S and would read as a borrowed constant - or 60, "
"which is round",
[n for n in range(0, 200)
if 2 * BATT_OBLIGED_S <= n <= 0.5 * _ceiling] == [34, 35, 36, 37]
and BATT_UNCHANGED_S not in (30.0, 60.0))
check("⚠️ AND `MOVE_W` IS PINNED TO config.yaml TOO. It is the largest value "
"that still arms the gate everywhere the per-unit sign check can speak; "
"raise `unit_sign_disagree_w` and the gate would stop arming in states "
"that check still fires in, which this catches",
MOVE_W == _unit_min_w)
check("...and the module's three floor terms are the numbers this repository "
"actually carries, not a transcription of them: the ESPHome publish "
"interval is read back out of the shipped firmware",
"update_interval: 10s" in io.open(
HERE.parent / "firmware" / "goodwe-master.yaml",
encoding="utf-8").read()
and F.PUBLISH_PERIOD_S == 10.0 and F.READ_PERIOD_S == 1.0
and F.PLANT_SETTLE_S == 6.0)
# ==============================================================================
print()
print("7. THE POLICY TABLE tells the truth about what is WIRED")
def bounds_users(key):
"""Modules that SUBSCRIPT `BOUNDS[key]` in CODE - parsed, not grepped.
⚠️ THE FIRST DRAFT GREPPED FOR THE STRING AND ANSWERED WRONG, because both
`inverter.py` and `main.py` NAME `BOUNDS["soc"]` in a comment explaining why
it is not wired. A text search cannot tell a mention from a use, and this
part's whole job is to tell them apart - so it walks the AST instead.
"""
out = []
for path in sorted((HERE / "app").glob("*.py")):
if path.name == "freshness.py":
continue
tree = ast.parse(io.open(path, encoding="utf-8").read())
for node in ast.walk(tree):
if (isinstance(node, ast.Subscript)
and isinstance(node.slice, ast.Constant)
and node.slice.value == key
and ((isinstance(node.value, ast.Name)
and node.value.id == "BOUNDS")
or (isinstance(node.value, ast.Attribute)
and node.value.attr == "BOUNDS"))):
out.append(path.name)
break
return out
_uses_batt = bounds_users("batt")
_uses_soc = bounds_users("soc")
for _k, _b in BOUNDS.items():
print(f" {_k:5} wired={_b.wired!s:5} unchanged={_b.unchanged_s:>6} "
f"move={_b.move_w:>6} {_b.signal}")
print(f" BOUNDS['batt'] subscripted in CODE by: {_uses_batt}")
print(f" BOUNDS['soc'] subscripted in CODE by: {_uses_soc or 'nothing'}")
check("⚠️ `wired=True` MEANS SOMETHING CONSUMES IT. A policy table nobody "
"checks is a comment; this is the row `batt` claims and the module that "
"claims it",
BOUNDS["batt"].wired is True and _uses_batt == ["inverter.py"])
check("⚠️ AND `wired=False` MEANS NOTHING DOES. SoC is TEL-08's and it is "
"STATED here rather than wired, because its obligation term is energy "
"through the pack and the constant that needs has never been measured on "
"this site. A guessed constant would fire on a pack resting at its floor",
BOUNDS["soc"].wired is False and _uses_soc == [])
check("...and the SoC row says what TEL-08 owes, in the row itself, so the "
"next reader does not have to reconstruct why it is unwired",
"energy" in BOUNDS["soc"].derivation
and "153.6 Wh" in BOUNDS["soc"].derivation
and "TEL-08" in BOUNDS["soc"].derivation)
check("...and every wired bound is a real number - an infinite bound is how a "
"row silently becomes decorative",
all(b.unchanged_s < float("inf") and b.move_w < float("inf")
for b in BOUNDS.values() if b.wired))
# ==============================================================================
print()
print("8. THE MEASUREMENT the bound rests on, replayed from the committed data")
# ⚠️ THIS IS THE PART THAT STOPS THE DERIVATION BECOMING A STORY. The docstring
# in freshness.py quotes three numbers off this capture; they are recomputed
# here from the committed files, so a figure that drifts from its evidence goes
# red instead of being re-quoted forever.
#
# ⚠️ AND IT IS THE METER'S REGISTER, NOT 0x0518. No 30-day capture of the
# inverter's own power exists. What this establishes is the only thing it is
# used for: that the legitimate unchanged duration of a 1 W power entity in this
# house is MINUTES, so an unchanged threshold alone has no admissible value.
_cap = sorted(glob.glob(str(HERE / "scenarios" / "p1-net-30d-2026-08"
/ "p1-net-*.jsonl.gz")))
_runs, _prev = [], None
_t0 = time.monotonic()
for _f in _cap:
with gzip.open(_f, "rt") as _fh:
for _line in _fh:
_r = json.loads(_line)
_t = datetime.fromisoformat(_r["t"]).timestamp()
if _prev is not None:
_runs.append((_t - _prev, _pw))
_prev, _pw = _t, float(_r["w"])
_worst = max(_runs)[0] if _runs else 0.0
_over_bound = sum(1 for d, _ in _runs if d >= BATT_UNCHANGED_S)
print(f" {len(_cap)} days, {len(_runs) + 1} samples, "
f"{time.monotonic() - _t0:.1f}s to replay")
print(f" longest unchanged run {_worst:.1f} s; "
f"{_over_bound} runs >= {BATT_UNCHANGED_S:.0f} s")
check("PRECONDITION: the committed capture is all thirty days, so this is not "
"a claim about whatever files happened to be present",
len(_cap) == 30 and len(_runs) + 1 == 485841)
check("⚠️ A REAL 1 W POWER ENTITY HELD ONE VALUE FOR 263.2 s WITH NOTHING "
"WRONG. That single measurement is why the bound is a conjunction and "
"not a threshold: no unchanged threshold under 263 s is free of false "
"positives, and 263 s is 3.5x too slow to protect a 75 s consumer",
abs(_worst - 263.2) < 0.1 and _worst > _ceiling)
check("...and the two figures freshness.py's docstring quotes off this capture "
"are the ones it produces today",
_over_bound == 10
and sum(1 for d, _ in _runs if d >= BATT_OBLIGED_S) == 386)
check("⚠️ AND THE LONG RUNS ARE ALL UNDER AN UNMOVING LOAD, which is what the "
"conjunction keys on. The worst one is 263 s of a steady -899 W export - "
"a house doing one thing - so a gate that also requires the COMMAND to "
"have travelled 250 W is not armed by any of them",
max(_runs)[1] == -899.0)
# ==============================================================================
print()
print("9. KNOWN SURVIVORS - graded, so they are residuals and not surprises")
# ⚠️ THE REPO'S RECURRING DEFECT IS A CHECK WHOSE DATA CANNOT REACH THE FAILURE
# IT NAMES. The honest end of that habit is to build the mutations this file
# would NOT see and record them. Each one below is a real hole in the shipped
# gate, demonstrated rather than described.
# S1. A register pinned by the INVERTER's own limit, not by a fault. If the
# device is derating and cannot follow a command that ASKS FOR MORE, the
# register can legitimately hold while the command travels.
_s1 = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_s1.submit(2503.0, 2650.0 if t < 1 else 4600.0, float(t))
check("⚠️ SURVIVOR 1, FALSE POSITIVE: a saturated or derating inverter pinned "
"at 2503 W while the command climbs to 4600 W is declared stale. The "
"register is telling the truth and the gate calls it dead",
_s1.stale)
check("...and its SIZE is bounded and its direction is the safe one: the "
"verdict is a site stop, behind the `stale_input_s` hold, and the moment "
"the command comes back down the register moves and the latch clears. "
"Cost = one spurious stop; it cannot drive the battery anywhere",
(lambda f: (f.submit(2400.0, 0.0, 60.0), not f.stale)[-1])(_s1))
# S2. A unit whose command never travels 250 W. HW-01's own shape, at a
# smaller scale, and the gate is deliberately not armed for it.
_s2 = Freshness(BOUNDS["batt"])
for t in range(0, 400):
_s2.submit(A_FROZEN_W, -1200.0 if t < 1 else -1400.0, float(t))
check("⚠️ SURVIVOR 2, MISS: a register frozen under a command that only ever "
"moves 200 W is NEVER detected, for 400 s or forever. This is scoped, "
"not overlooked - such a unit is one no per-unit sign check can speak "
"about (`unit_sign_disagree_w` is 250 W) and one contributing at most "
"250 W to a loop whose reporting floor is 700 W",
not _s2.stale and _s2.reading() == A_FROZEN_W)
# S3. A register that dithers on dead data. A publisher that keeps republishing
# a stale WINDOW of values rather than one value defeats this entirely.
_s3 = Freshness(BOUNDS["batt"])
for t in range(0, 400):
_s3.submit(A_FROZEN_W + (t % 2), 1300.0, float(t))
check("⚠️ SURVIVOR 3, MISS, AND IT IS THE LARGEST ONE: a dead publisher whose "
"last value happens to ALTERNATE - or a cache replaying two old samples - "
"never triggers the unchanged clock at all. This gate detects a value "
"that STOPPED; it cannot detect a value that is WRONG. Nothing in this "
"add-on can, and TEL-06's arrival stamp inside the firmware is the only "
"layer that could",
not _s3.stale and _s3.reading() is not None)
# S4. The gate is per unit, so a whole-site publisher failure that freezes both
# registers is caught twice rather than missed - recorded because a reader
# might expect the opposite.
_s4a, _s4b = Freshness(BOUNDS["batt"]), Freshness(BOUNDS["batt"])
for t in range(0, 40):
_s4a.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
_s4b.submit(B_LIVE_W, B_LIVE_W if t < 1 else -1300.0, float(t))
check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
"that freezes BOTH registers is caught on both units independently, on "
"each unit's own command travel and each unit's own number",
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
+14 -1
View File
@@ -215,8 +215,17 @@ B_BMS_FLOOR_SIM = 15
A_RATING = 5000.0
# ⚠️ `slew_w` IS 0 HERE, AND IT USED TO BE 1000 - SAFETY-39, AND THE WAY THIS
# FIXTURE FOUND OUT IS THE ARGUMENT FOR THE RULE. This dict pairs `slew_w: 1000`
# with `max_w: 5000` because both were typed when the flat 5000 W install was
# new, and that pair is EXACTLY the configuration measured on the owner's system
# on 2026-09-05 driving the meter from 3.4 kW of export to 2.8 kW of import. The
# fixture was not wrong when it was written and nothing in this file noticed it
# had become so; validate.py rule 10 refused it the day it existed, which is
# what a rule about a relationship is for. 0 means "follow the rail"
# (`control.SLEW_RAIL_FRAC * max_w`) and is what config.yaml ships.
BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid",
max_w=A_RATING, gain=0.6, slew_w=1000, deadband_w=15,
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)
@@ -1902,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"),
}
+1 -1
View File
@@ -94,7 +94,7 @@ class _Pub:
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 1000,
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 0,
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
"heartbeat_s": 10, "stale_input_s": 15}
+48 -6
View File
@@ -1007,7 +1007,7 @@ def built(db_path, opts=None, store=None):
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_source": "ha_signed",
"p1_net_entity": "sensor.net", "meter_phases": 1,
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15,
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 0, "deadband_w": 15,
"target_grid_w": 0, "step_w": 10, "heartbeat_s": 10,
"stale_input_s": 15}, **(opts or {})),
hass, store or _Store(), _Pub(), stop=asyncio.Event(),
@@ -2320,22 +2320,64 @@ print(f" the same day's high-load block "
print(f" {HOT_R['limited']} cycles reporting PEAK GUARD FLOOR-LIMITED, "
f"{HOT_R['limited_discharging']} of them with the loop simultaneously "
f"granted discharge (max {HOT_R['max_granted']:.0f} W)")
check("THE ASYMMETRY, ON REAL DATA. On every cycle where the guard reports "
print(f" {HOT_R['below']} cycles below the {SOC_FLOOR:.0f} % EMS floor, "
f"{HOT_R['below_discharging']} of them discharging; low-water "
f"{HOT_R['min_soc']:.2f} % against a {BMS_FLOOR:.0f} % BMS cut-out")
# ⚠️ THESE TWO CHECKS WERE RE-WRITTEN AT SAFETY-39 AND THE OLD NUMBERS ARE
# PRINTED HERE RATHER THAN DELETED, BECAUSE "AN ASSERTION WAS LOOSENED TO GET A
# BRANCH GREEN" IS THE FIRST THING A REVIEWER SHOULD SUSPECT AND IT HAS TO BE
# ANSWERABLE. They read `limited_discharging == limited` and `min_soc <=
# bms_soc_floor + 0.05`, and both held at `slew_w: 1000`. SAFETY-39 re-derived
# `slew_w` as a ratio to the rail (222 W at this 2000 W cap), and on this same
# two-hour replay:
#
# slew_w 1000 slew_w 0 (the rail)
# limited 49 49
# limited_discharging 49 48
# low-water SoC 9.98 % 10.57 %
# cycles at the BMS cut 190 0
# energy out 1247 Wh 1165 Wh
# below / discharging 406 / 138 406 / 162
#
# So the loop drains this pack ~82 Wh more slowly and no longer reaches the
# cut-out INSIDE THIS WINDOW. The FINDING is untouched and is in fact sharper on
# the measure that carries it - 162 cycles of granted discharge below the floor,
# up from 138. What moved is two numbers that happened to be exact at one
# tuning: one cycle of loop spin-up, and whether a two-hour window is long
# enough to touch a cut-out the loop is now approaching more slowly.
#
# The checks below therefore assert the SUBSTANCE and not the coincidence: that
# the two states occur together on all but at most one cycle, and that the pack
# still ends up past the EMS floor and within a point of its own cut-out with no
# EMS-side bound anywhere in the path. If a future change makes either of these
# fail, that IS a finding - do not widen them again.
check("THE ASYMMETRY, ON REAL DATA. On the cycles where the guard reports "
"FLOOR-LIMITED - 'commanding no forced discharge' on the status page and "
"in the operator's notification - the arbiter is at the same instant "
"granting the LOOP a discharge. A `lo` limit cannot cap a discharge, so "
"the guard's floor and the pack emptying are not alternatives; they "
"happen together",
"happen together. ⚠️ AT MOST ONE CYCLE MAY DIFFER, and that one is the "
"loop's cold start from 0 W - a tolerance of one, not a tolerance",
HOT_R["limited"] > 10
and HOT_R["limited_discharging"] == HOT_R["limited"]
and HOT_R["limited_discharging"] >= HOT_R["limited"] - 1
and HOT_R["max_granted"] > 0.0)
check("...and the loop is granted a discharge on hundreds of cycles with the "
"pack ALREADY below the EMS floor, which is the same asymmetry counted "
"over the whole window rather than over the floor-limited subset - the "
"measure the finding actually rests on, and it does not depend on one "
"cycle of spin-up",
HOT_R["below"] > 100 and HOT_R["below_discharging"] > 100)
check("...which is why SAFETY-24's floor could not be widened into this one, "
"and why the fix is not a bigger number. Recorded in "
"and why the fix is not a bigger number: the pack still ends the window "
"PAST the EMS floor and within one point of its own BMS cut-out, with no "
"EMS-side bound anywhere in the path. Recorded in "
"DECISIONS/SAFETY-25-loop-soc-floor.md, where AC 2 was DECIDED on "
"2026-09-02: Option A, the BMS stays the only backstop, and the "
"loop-side reserve is DEFERRED to ALLOC-01 - so this replay is not a "
"pending exposure but the RECORDED behaviour, and part 10 stays as its "
"tripwire", HOT_R["min_soc"] <= BMS_FLOOR + 0.05)
"tripwire",
HOT_R["min_soc"] < SOC_FLOOR
and HOT_R["min_soc"] <= BMS_FLOOR + 1.0)
# --- 10d. the trap in the obvious fix, checked rather than asserted ---------
# ⚠️ THE MEMO'S CENTRAL ENGINEERING CLAIM, RUN THROUGH THE REAL ARBITER. The
+8
View File
@@ -134,6 +134,14 @@ DERIVED = {
"bms_soc_floor": ("0x0560", "MEASURED"),
"integrator_max_w": ("MEASURED", "Do not raise it"),
"gain": ("MEASURED ON", "do not raise it"),
# SAFETY-39. `slew_w` joins the table because it stopped being an
# operator's convenience and became a DERIVED value the moment it was
# tied to the rating: 0 resolves to about a ninth of `max_w`, from an
# ~18 s measured step response. It is the one entry here whose value is
# a RATIO, so the description has to say what it follows as well as
# where the ratio came from - a citation alone would leave an operator
# reading "0" on the tab with no idea it is not "off".
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
}
_uncited = []
for key, needles in sorted(DERIVED.items()):
+158 -5
View File
@@ -18,9 +18,10 @@ import asyncio
import sys
from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC
from app.validate import (
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
actuator_writers, refuse, validate_startup_config,
actuator_writers, refuse, site_rail_w, validate_startup_config,
)
fails = []
@@ -457,6 +458,149 @@ check("arbiter: the drain SET is clamped, not contradicted",
check("...and the clamp names itself, which is why it is not refused",
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
# --------------------------------------------------------------------------- #
print("10. a slew rate the plant can actually follow (SAFETY-39)")
# ⚠️ THIS RULE GRADES A RELATIONSHIP, WHICH IS THE POINT OF IT. Every other rule
# in this file compares a configured number against another configured number
# and refuses one ordering. This one refuses a RATIO: `slew_w` may not exceed
# `SLEW_RAIL_FRAC * rail`, so the same `slew_w` is fine at one rating and
# refused at another, and raising the rating moves the bound with it instead of
# leaving the constant behind. That is the failure the ticket is about -
# `slew_w: 1000` was derived at a 1500 W clamp, the live install went to 5000,
# and nothing anywhere said the derivation no longer held.
print(f" SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = "
f"{LOOP_TICK_S:.0f} s / {PLANT_SETTLE_S:.0f} s = {SLEW_RAIL_FRAC:.4f}")
_R5000 = base(max_w=5000, maintenance_charge_w=2000, maintenance_discharge_w=2000)
check("the rail this rule grades is the SITE rail, and at the shipped roster of "
"one it is `max_w` exactly - byte for byte the number the single-inverter "
"shape has always meant",
site_rail_w(_R5000) == 5000.0)
# ⭐ VERIFIED BY BREAKING IT. This is the acceptance criterion stated as a test:
# put the 2026-09-05 pair back and the gate must go red. A rule that could not
# be made to fire on the configuration it was written for is a rule nobody has
# checked.
_broken = validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000)
print(f" restoring slew_w 1000 at max_w 5000 -> {len(_broken)} problem(s)")
if _broken:
print(f" {_broken[0][:160]}...")
check("⭐ RESTORING THE 2026-09-05 PAIR - slew_w 1000 at max_w 5000 - IS REFUSED. "
"It crosses the whole output range in 10 s against a plant measured at "
"18 s, which is not a fast loop, it is a loop that commands its entire "
"range before it can observe its answer to the first step",
len(_broken) == 1 and why(_broken, "slew_w is 1000 W"))
check("...and the refusal says WHICH RAIL it graded against and HOW LONG the "
"crossing takes, because 'slew_w is too high' sends an operator to lower "
"a number when raising max_w or setting 0 are both correct answers too",
why(_broken, "site rail of 5000 W")
and why(_broken, "crosses its whole output range in 5.0 cycles"))
check("...and it names the plant measurement it is graded against, so the "
"number is traceable to a step response rather than to a preference",
why(_broken, "goodwe/NOTES.md §5.10"))
check("...and it says what to DO, including the setting that cannot go stale",
why(_broken, "Set slew_w to 0") and why(_broken, "556 W"))
# THE BOUNDARY. Equality is the DERIVED VALUE, not a violation - at exactly
# `SLEW_RAIL_FRAC * rail` the loop takes precisely the plant's settling time to
# cross its range, which is the constraint met. Rule 6 permits equality for the
# same shape of reason and rule 5 refuses it for the opposite one; this is
# rule 6's case, and getting it backwards would refuse the value this rule
# exists to recommend.
_bound = SLEW_RAIL_FRAC * 5000.0
check(f"at exactly the derived value ({_bound:.0f} W) the config STARTS - "
"equality is the constraint satisfied, not violated",
validate_startup_config(dict(_R5000, slew_w=int(_bound)), -5000, 5000) == [])
check("...and one watt of slack above it starts too, because a boundary that "
"is not meaningful to one watt must not refuse on one watt - an operator "
"reading 556 off a status page that rounded it is not misconfigured",
validate_startup_config(dict(_R5000, slew_w=556), -5000, 5000) == [])
check("...and well above it does not",
validate_startup_config(dict(_R5000, slew_w=600), -5000, 5000) != [])
# SLOWER IS ALWAYS ACCEPTED, and the live install is the reason it has to be.
check("the interim that is holding on the live system - slew_w 400 against "
"max_w 5000, applied 2026-09-05 12:00 - STARTS. It is 12.5 ticks to cross "
"the rail against the 9 this rule requires, and refusing 'too slow' would "
"refuse the mitigation",
validate_startup_config(dict(_R5000, slew_w=400), -5000, 5000) == [])
check("...and so does the schema floor at the biggest rail, because there is no "
"such thing as too slow here",
validate_startup_config(dict(_R5000, slew_w=50), -5000, 5000) == [])
# ⚠️ 0 IS NOT GRADED AND MUST NOT BE. It means "follow the rail" and satisfies
# this rule by construction at every rail there is; a rule that refused it would
# refuse the fix. Checked at three ratings so that "it passes" cannot be an
# accident of the default one.
# ⚠️ NO SLEW PROBLEM AT ANY RATING, rather than NO PROBLEMS AT ALL. At the
# schema floor of 100 W rules 5 and 5b fire on the shipped sign thresholds,
# which is correct and is not this rule's business - asserting an empty list
# would make this check fail for a reason it is not about, and somebody would
# then "fix" it by moving a sign threshold.
_zero_problems = {m: [p for p in validate_startup_config(
base(max_w=m, slew_w=0,
maintenance_charge_w=min(2000, m),
maintenance_discharge_w=min(2000, m)),
-abs(m), abs(m)) if "slew_w" in p]
for m in (100, 500, 2000, 5000, 15000)}
check("slew_w 0 - FOLLOW THE RAIL - is never refused at any rating, which it "
"must not be, because it is the shipped value and it satisfies this rule "
f"by construction at every rail there is: {_zero_problems}",
not any(_zero_problems.values()))
check("a negative slew_w is refused as nonsense rather than treated as 0 - it "
"is a magnitude, and 0 already has a meaning here",
why(validate_startup_config(dict(_R5000, slew_w=-400), -5000, 5000),
"must not be negative"))
# ⭐ THE ROSTER, WHICH IS THE WHOLE REASON THIS GRADES A SUM. `slew_w` is not a
# per-unit quantity: main.py builds `Tuning(max_w=self.site_max_w)` and
# `compute()` emits the SITE target. So the rail is the roster sum, and adding
# an inverter moves this bound with no operator action and no second derivation.
#
# ⚠️ INVERTER B'S 4600 W IS ITS DATASHEET AND SERIAL, NOT A MEASUREMENT, AND
# INVERTER B IS NOT ON SITE (SPIKE-04, ~2026-09-12; goodwe/NOTES.md §1.1b). It
# is used here because what is graded is the ARITHMETIC OF THE RULE, which does
# not depend on the number being measured - not because 9600 W is a fact about
# this house yet.
def _roster(a=5000, b=4600, **over):
o = base(setpoint_entity="", inverters=[
{"name": "inverter-a", "setpoint_entity": "number.a", "max_w": a,
"bms_soc_floor": 10, "soc_entity": "sensor.sa", "batt_entity": "sensor.ba"},
{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": b,
"bms_soc_floor": 10, "soc_entity": "sensor.sb", "batt_entity": "sensor.bb"}])
o.pop("max_w"); o.pop("soc_entity"); o.pop("batt_entity")
o.update(over)
return o
_site = site_rail_w(_roster())
print(f" roster 5000 + 4600 (B FROM DATASHEET, NOT MEASURED) -> site rail "
f"{_site:.0f} W, bound {SLEW_RAIL_FRAC * _site:.0f} W")
check("the rail is the roster SUM and not the weakest unit, which is what "
"separates this rule from 5, 6 and 8 - those grade one watchdog, one "
"shaping claim and one pack, and this grades the range one SITE COMMAND "
"is a rate across",
_site == 9600.0)
check("⭐ AND THE BOUND MOVES ON ITS OWN WHEN THE ROSTER DOES: the derived value "
"at a 9600 W site is 1067 W with nobody re-deriving anything, which is "
"the property that makes this a fix rather than a re-tune",
abs(SLEW_RAIL_FRAC * _site - 9600.0 / 9.0) < 1e-9)
check("...so the same explicit slew_w that is REFUSED at a 5000 W rail is "
"ACCEPTED at 9600 - the bound is on how long the crossing TAKES, and a "
"bigger rail at the same step size takes longer to cross. ⚠️ This is the "
"direction of the rule stated as a test, because it is not the direction "
"the ticket assumed: a growing rail LOOSENS this bound. What the rule "
"protects against is the constant being left behind when the rail moves; "
"it is not a bound on how much the site may command, and it does not "
"bound the over-absorption, which is a `gain` term SAFETY-39 measured "
"and deliberately did not change",
validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000) != []
and validate_startup_config(_roster(slew_w=1000)) == [])
check("...and a slew that outruns even the doubled rail is still refused there, "
"so the roster path is not simply un-graded",
why(validate_startup_config(_roster(slew_w=2000)),
"site rail of 9600 W"))
# --------------------------------------------------------------------------- #
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
@@ -470,12 +614,21 @@ import re # noqa: E402
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
with open(_CFG, encoding="utf-8") as _fh:
_txt = _fh.read()
# ⚠️ `slew_w` JOINED THIS SET AT SAFETY-39 AND IT IS THE INTERESTING MEMBER.
# The others are refused when they are too big for `max_w`; this one is refused
# when it is too big for `max_w` AS A RATIO, so shipping the wrong number here
# would be shipping the exact defect the card is about with the gate that
# catches it standing right beside it. Reading it out of config.yaml is what
# makes "the shipped value is 0" a fact rather than a claim.
_shipped = {k: float(v) for k, v in re.findall(
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
check("config.yaml really was read (all four defaults found)",
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w"
r"|slew_w): (-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
check("config.yaml really was read (all five defaults found)",
set(_shipped) == {"max_w", "maintenance_charge_w",
"maintenance_discharge_w", "sign_disagree_w"})
"maintenance_discharge_w", "sign_disagree_w", "slew_w"})
check(f"...and the shipped slew_w is 0, i.e. FOLLOW THE RAIL - the one value "
f"that cannot be left behind when max_w moves (got {_shipped.get('slew_w')})",
_shipped.get("slew_w") == 0.0)
check(f"the shipped defaults are accepted {_shipped}",
validate_startup_config(base(**_shipped), -5000, 5000) == [])
check("...and the shipped charge rate is at or under the shipped ceiling",
+54 -12
View File
@@ -1393,13 +1393,29 @@ def _sign_site(hass, store=None, **over):
return c
async def _drive(c, n=14, dt=0.2, watch=None):
async def _drive(c, n=14, dt=0.2, watch=None, pre=None):
"""Drive `n` real cycles. `watch` samples AFTER each cycle, `pre` BEFORE it.
⚠️ `pre` EXISTS BECAUSE `watch` ALONE CANNOT SEE WHAT A DETECTOR WAS HANDED,
AND SAFETY-39 IS HOW THAT WAS FOUND. `cycle()` evaluates the sign watchdogs
against THE STANDING COMMAND - the write from the PREVIOUS cycle - and then
issues a new one. So `watch`'s `last_write` is the command AFTER the
detector ran, and pairing it with `c.grid` builds a pair the detector never
saw. While `slew_w` was 1000 the loop was at its rail within two cycles and
consecutive commands were IDENTICAL, so the off-by-one was invisible; at a
rail-following slew the loop ramps and the two numbers differ by one step.
That is a fault in this harness, not in the code it grades - and a check
that compares an operator-facing sentence against the wrong cycle is
precisely the "the data could not reach the failure the text names" defect
MUTATION.md keeps finding. `pre` reads the operands the detector will use.
"""
seen = []
for _ in range(n):
await c.read_inputs()
before = {} if pre is None else pre(c)
await c.cycle()
if watch is not None:
seen.append(watch(c))
seen.append({**before, **watch(c)})
await asyncio.sleep(dt)
return seen
@@ -1812,12 +1828,19 @@ async def _n1_sign():
# `_n1_calls` records what was HANDED to a detector, so grading the banner
# against it would compare the swap against itself - the DEV-02 defect.
# `last_write` and `c.grid` are read off the objects the plant moved.
seen = await _drive(c, n=12, dt=0.2, watch=lambda c: dict(
grid=c.grid,
# ⚠️ `stood` COMES FROM `pre`, i.e. BEFORE `cycle()` RUNS (SAFETY-39). It is
# the STANDING command the sign watchdog is evaluated against; taking it
# after the cycle would pair this cycle's meter with next cycle's command
# and grade the banner against a pair that never existed. See `_drive`.
# `n` is 24 rather than 12 because at the shipped rail-following `slew_w`
# the loop needs nine cycles to reach a 2000 W rail instead of two.
seen = await _drive(c, n=24, dt=0.2, pre=lambda c: dict(
stood=(c.inverters[0].last_write
if c.inverters[0].last_write is not None
else c.inverters[0].target),
tripped=c.inverters[0].sign_wd.tripped))
else c.inverters[0].target)),
watch=lambda c: dict(
grid=c.grid,
tripped=c.inverters[0].sign_wd.tripped))
return c, _n1_calls, seen
@@ -1839,12 +1862,24 @@ check("...the reason sentence still says 'a meter reading', word for word - it "
"is what an operator reads, what the action log carries and what is "
"persisted, so it is graded on the literal",
"against a meter reading" in _inv1.sign_wd.reason)
# The last cycle before the latch, named rather than indexed: afterwards the
# THE CYCLE THE LATCH HAPPENED ON, named rather than indexed: afterwards the
# failsafe stands 0 W and the plant's meter relaxes, so a fixed index reads the
# remedy. Both operands come off the controller, not off the fixture's options.
_pre1s = [x for x in _seen1s if not x["tripped"]]
_mid1s = _pre1s[-1]
print(f" last cycle before the latch: stood {_mid1s['stood']:+.0f} W, "
#
# ⚠️ IT IS THE FIRST *TRIPPED* SAMPLE, AND IT USED TO BE THE LAST UN-TRIPPED ONE
# (SAFETY-39). The detector is evaluated INSIDE `cycle()` against the STANDING
# command - the write from the previous cycle - and against the meter read at
# the top of THIS one, and it latches on the cycle whose pair crosses the hold.
# So the operands the sentence quotes belong to the LATCHING cycle, not to the
# one before it. `stood` is now sampled by `_drive`'s `pre` hook, before
# `cycle()` overwrites it with the failsafe's 0 W, which is what makes that
# cycle readable at all - and it is why the old form had to reach back a cycle.
# The two forms agreed while `slew_w` was 1000 and the loop sat at its rail with
# consecutive commands IDENTICAL; at a rail-following slew the loop ramps and
# they differ by exactly one step, which is how this was found.
_first1s = [x for x in _seen1s if x["tripped"]]
_mid1s = _first1s[0]
print(f" the cycle the latch happened on: stood {_mid1s['stood']:+.0f} W, "
f"meter {_mid1s['grid']:+.0f} W")
print(f" {_inv1.sign_wd.reason!r}")
check("⚠️ ...AND THE ORDERED PAIR ON THE SHIPPING CALL SITE. This is the SAME "
@@ -2529,7 +2564,14 @@ def _s38_frozen_ctrl(hass, ratings, unchanged_s=600.0, gain=0.05):
return c
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=8):
# ⚠️ `n` IS 20 AND WAS 8 (SAFETY-39). The comment on `_s38_frozen_ctrl` says
# gain 0.05 is there "to buy a ramp" so that the SAME run carries both a
# below-the-rail and an at-the-rail answer. At the shipped rail-following
# `slew_w` the ramp is now the SLEW - 55 W a cycle against this 500 W site,
# where gain 0.05 alone gave 125 - so eight cycles no longer reach the rail and
# the run carried only the counterfactual. The fixture needs the cycles, not a
# different tuning: the point is a run that crosses the rail part way through.
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=20):
hass = _S38Plant()
c = _s38_frozen_ctrl(hass, ratings)
_s38_seed_quarter(c, meter_w)
@@ -2604,7 +2646,7 @@ async def _s38_one():
hass = _S38Plant()
c = _s38_frozen_ctrl(hass, _S38_ONE)
_s38_seed_quarter(c, _S38_FROZEN_METER_W)
for _ in range(8):
for _ in range(20): # SAFETY-39: the slew is the ramp now - see _s38_frozen
_s38_feed(c, _S38_FROZEN_METER_W)
await c.read_inputs()
await c.cycle()
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""SAFETY-39's evidence, on admin/goodwe's OWN plant model.
python3 tools/slew_sim.py --repro # ⭐ the 2026-09-05 excursion, replayed
python3 tools/slew_sim.py --slew # the derivation: slew against the rail
python3 tools/slew_sim.py --gain # what the slew does NOT own
python3 tools/slew_sim.py --roster # N=1 -> N=2, and the rail that moved
python3 tools/slew_sim.py --all
WHY THIS IS A SCRIPT AND NOT A `test_*.py`
==========================================
Byte for byte tools/alloc_sim.py's reason, and it has not changed: this needs
admin/goodwe checked out beside this repository for `plantsim.Plant`, and the
add-on's CI job has no credentials for that repository - it is private and the
job's token is minted for this one. A `test_*.py` here is globbed and RUN by
.gitea/workflows/checks.yml, so a file that needs the sibling repo would either
fail the gate or - worse - pass SILENTLY having checked nothing.
So the rig evidence lives here and is quoted in the pull request and in
DECISIONS/SAFETY-39-slew-against-the-rail.md, and test_control.py's SAFETY-39
section grades the same properties in CI against a plant it models itself and
says it models. The two plants are INDEPENDENT - a first-order lag written in
test_control.py versus `plantsim.Plant`'s ramp-plus-lag with an efficiency and
a SoC integrator - and they agree on every sign and rank below, which is worth
more than either alone.
WHAT IS REAL IN HERE
====================
* the control law - `compute()` and `Tuning`, imported out of THIS checkout,
never transcribed.
* the wire - every command goes out through `frame_check.setpoint_atomic()`,
the firmware's own encoder, and back in through `plantsim.parse`/`decode`,
because `Inverter.command()` is what drives the modelled unit.
* the plant - `plantsim.Plant`, the model the rig uses.
MODELLED, and therefore not evidence about the thing modelled:
* the meter. `grid = load - pv - sum(battery)` with no poll delay and no
noise, sampled once per tick. The real P1 is a poll with its own age.
* the tick. 2.0 s, which is what the live install was MEASURED running on
2026-09-05; it is a parameter here (`--tick`) and not a constant, because
the whole of this card is that the derivation depends on the regime.
⚠️ WHAT THIS CANNOT SHOW, SAID BEFORE THE RESULTS RATHER THAN AFTER THEM
=======================================================================
THE -5154 W BATTERY EXCURSION IS NOT REPRODUCIBLE HERE, AND NOT BECAUSE THE
TUNING IS WRONG. `plantsim.Plant.limit()` clamps the plant at `p_max`, so the
modelled battery cannot exceed its rating by one watt at any tuning, on any
step, ever. Neither can test_control.py's model. An acceptance criterion of
"battery power no longer exceeds max_w in simulation" would therefore be
UNFALSIFIABLE - it passes on the broken configuration too - so no criterion in
this card is written on it, and the ticket's own AC to that effect is the one
that had to be rewritten rather than met.
What IS reproducible, and is what everything below measures:
* the setpoint stepping a full `slew_w` per tick, over and over (the live
symptom that escalated 2 -> 1 -> 13 -> 43 large steps per 30 min);
* the loop OVER-ABSORBING - taking more than the site needed - and driving
the meter from EXPORT into IMPORT, i.e. manufacturing the error it chases.
⚠️ INVERTER B DOES NOT EXIST. `plantsim.INVERTER_B` is inverter A's numbers
COPIED, `measured=False`. Every N=2 row below is therefore a result about TWO
COPIES OF A on a 10000 W rail, NOT about the ~9600 W site that B's datasheet
and serial imply (goodwe/NOTES.md §1.1b; B is a GW5048D-ES rated 4600 W, and
that figure may itself be DC). Each such row prints the caveat with it.
"""
import argparse
import math
import os
import pathlib
import sys
HERE = pathlib.Path(__file__).resolve().parent
ADDON = HERE.parent.parent # the goodwe-addon checkout root
def find_sim():
"""admin/goodwe's `sim/` directory, or None - and the caller says which.
$GOODWE_SIM first, the same override test_freeze.py and alloc_sim.py take,
then the sibling checkouts that actually occur. A missing sibling is not a
finding about the control law and must never be reported as one.
"""
cands = []
if os.environ.get("GOODWE_SIM"):
cands.append(pathlib.Path(os.environ["GOODWE_SIM"]))
for up in (ADDON.parent, ADDON.parent.parent):
cands += [up / "goodwe" / "sim", up / "goodwe-session" / "goodwe" / "sim"]
for c in cands:
if (c / "plantsim.py").exists():
return c
return None
def load(sim):
sys.path[:0] = [str(sim), str(sim.parent / "master")]
os.environ.setdefault("GOODWE_ADDON_REPO", str(ADDON))
sys.path[:0] = [str(ADDON / "goodwe_controller")]
import plantsim # noqa: E402
from app import control # noqa: E402
return plantsim, control
# ---------------------------------------------------------------------------
# one experiment
def step_run(plantsim, control, *, gain, slew_w, rail_w, n=1, tick_s=2.0,
export_w=3385.0, load_w=350.0, phase=5, cycles=300, dt=0.05,
lag_s=None):
"""One export step onto a converged loop, through the real wire.
`rail_w` is the SITE rail - what `main.py` hands `compute()` as `max_w`
(`Controller.site_max_w`, the roster sum). Each of the `n` modelled units
is rated `rail_w / n` and is commanded its even share, which is the
arrangement ARCH-01's split-invariance says any valid split is equivalent
to at the meter.
"""
over = {} if lag_s is None else dict(lag_s=lag_s)
spec = dict(plantsim.INVERTER_A, rating_w=rail_w / n, **over)
invs = [plantsim.Inverter.from_spec(name, spec) for name in "AB"[:n]]
site = plantsim.Site(invs, load=load_w, pv=0.0)
tuning = control.Tuning(gain=gain, max_w=rail_w, slew_w=slew_w,
target_grid_w=-10.0)
prev, i_w, sat = 0.0, None, 0
worst_import, peak, biggest, last = -1e9, 0.0, 0.0, None
for c in range(cycles):
if c == phase:
site.pv = export_w + load_w
site.grid = site.load - site.pv - site.battery
# ⚠️ ONE UNIT'S POWER, NOT THE SITE'S SUM, at n > 1. The shipped add-on
# binds a single `inverter_power` sensor, so a two-inverter install has
# a loop that sees unit A only. sitesim.run_site() makes the same choice
# and says at length why feeding the sum would be a KINDER rig than the
# deployment.
actual = invs[0].plant.actual
d = control.compute(prev, site.grid, actual, tuning, sat, i_w)
if c >= phase and last is not None:
biggest = max(biggest, abs(d.target_w - last))
last = d.target_w
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
for inv in invs:
inv.command(d.target_w / n)
site.settle(tick_s, dt=dt)
if c >= phase:
worst_import = max(worst_import, site.grid)
peak = max(peak, abs(site.battery))
return dict(imp=worst_import, over=peak - export_w, step=biggest)
def family(plantsim, control, *, exports=None, phases=(5, 6, 7), **kw):
"""The worst case over a family of step sizes and phases.
⚠️ A FAMILY, NOT ONE STEP, AND THAT IS ITSELF A FINDING. A single run of
this loop is not evidence: the 15 W deadband, the 10 W quantisation and the
phase of the step against the tick move the answer by hundreds of watts,
NON-MONOTONICALLY, so two adjacent slews rank either way depending on which
step you happen to run. The first version of this derivation was a single
step and reported gain 0.3 as WORSE than gain 0.6, which the family shows is
backwards. Everything printed below is a worst case over the family, which
is how admin/goodwe grades the same kind of quantity (config.yaml's "1890
closed-loop runs", "924 runs").
"""
exports = exports or range(1000, 4300, 200)
worst = dict(imp=-1e9, over=-1e9, step=0.0, big=0, runs=0)
for export_w in exports:
for phase in phases:
r = step_run(plantsim, control, export_w=float(export_w),
phase=phase, **kw)
worst["runs"] += 1
worst["imp"] = max(worst["imp"], r["imp"])
worst["over"] = max(worst["over"], r["over"])
worst["step"] = max(worst["step"], r["step"])
worst["big"] += r["step"] >= 900.0
return worst
def row(label, w):
print(f" {label:<44} step {w['step']:6.0f} W "
f"{w['big']:3}/{w['runs']} runs >= 900 W "
f"over-absorbed {w['over']:6.0f} W meter to {w['imp']:+7.0f} W")
# ---------------------------------------------------------------------------
def repro(ps, ct, tick_s):
print("⭐ THE 2026-09-05 EXCURSION, REPLAYED - gain 0.6, max_w 5000, "
f"slew_w 1000, {tick_s:.0f} s tick")
print(" Live, 11:43:55-11:45:55: grid -3385 W, setpoint 380 -> 5000 in five")
print(" ticks, battery -5154 W, grid +2831 W. One 3385 W export step here:\n")
print(f" {'t':>5} {'grid in':>9} {'setpoint':>9} {'battery':>9} {'grid out':>9} reason")
spec = dict(ps.INVERTER_A)
inv = ps.Inverter.from_spec("A", spec)
site = ps.Site([inv], load=350.0, pv=0.0)
t = ct.Tuning(gain=0.6, max_w=5000.0, slew_w=1000.0, target_grid_w=-10.0)
prev, i_w, sat = 0.0, None, 0
for c in range(34):
if c == 5:
site.pv = 3385.0 + 350.0
site.grid = site.load - site.pv - site.battery
g = site.grid
d = ct.compute(prev, g, inv.plant.actual, t, sat, i_w)
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
inv.command(d.target_w)
site.settle(tick_s, dt=0.05)
print(f" {c * tick_s:5.0f} {g:9.0f} {d.target_w:9.0f} "
f"{site.battery:9.0f} {site.grid:9.0f} {d.reason}")
print("\n ⚠️ THE BATTERY NEVER PASSES 5000 W HERE AND CANNOT: plantsim.Plant")
print(" clamps at its rating. The -5154 W figure is NOT reproducible in")
print(" this model at any tuning, so nothing in this card is graded on it.")
print(" What IS reproduced is the setpoint stepping 1000 W a tick to the")
print(" rail, and the meter crossing from export into import.")
def slew_table(ps, ct, tick_s, rail):
frac = ct.SLEW_RAIL_FRAC
print(f"THE DERIVATION - gain 0.6, site rail {rail:.0f} W, {tick_s:.0f} s tick, "
f"51 export steps 1.0-4.2 kW\n")
print(f" the constraint: the loop must not cross its whole output range "
f"faster than the")
print(f" plant settles. {rail:.0f} W / ({ct.PLANT_SETTLE_S:.0f} s / "
f"{tick_s:.0f} s per tick) = {frac * rail:.0f} W per tick.\n")
for slew in (1000, 800, 700, 600, int(frac * rail), 500, 400, 300, 250):
w = family(ps, ct, gain=0.6, slew_w=float(slew), rail_w=rail,
tick_s=tick_s)
tag = " <- the derived value" if slew == int(frac * rail) else (
" <- the 2026-09-05 live pair" if slew == 1000 else (
" <- the live interim" if slew == 400 else ""))
row(f"slew_w {slew}{tag}", w)
print("\n READ IT THIS WAY. The >= 900 W step - the live symptom, which")
print(" escalated 2 -> 1 -> 13 -> 43 per 30 min that morning - is gone from")
print(" every run below 900 W of slew, and that is what the slew owns.")
print(" The OVER-ABSORPTION does not move, and at the slowest slews it is")
print(" slightly WORSE. The rig cannot pick the derived value out of that")
print(" column, and it is not asked to: the CONSTRAINT picks it, and this")
print(" table is what says what the constraint does and does not buy.")
def gain_table(ps, ct, tick_s, rail):
frac = ct.SLEW_RAIL_FRAC
print(f"WHAT THE SLEW DOES NOT OWN - slew_w at the derived "
f"{frac * rail:.0f} W, gain swept\n")
for gain in (0.6, 0.5, 0.4, 0.3, 0.2, 0.1):
w = family(ps, ct, gain=gain, slew_w=frac * rail, rail_w=rail,
tick_s=tick_s)
row(f"gain {gain}" + (" <- shipped" if gain == 0.6 else ""), w)
print("\n `slew_w` bounds the OUTPUT. The integrator accumulates")
print(" `gain * error` per cycle and is bounded only by `integrator_max_w`,")
print(" so at gain 0.6 into a 3.4 kW error it moves ~2000 W per tick and")
print(f" reaches the {rail:.0f} W rail in under three ticks WHATEVER the slew is.")
print(" The over-absorption is therefore a GAIN term. `gain: 0.6` is the")
print(" next constant to re-derive; SAFETY-39 measured it and changed")
print(" nothing, because two control-law constants moving in one release on")
print(" a running house makes the next measurement uninterpretable.")
def roster_table(ps, ct, tick_s):
frac = ct.SLEW_RAIL_FRAC
print("N=1 -> N=2: THE RAIL IS WHAT MOVED\n")
print(" ⚠️ INVERTER B DOES NOT EXIST (SPIKE-04, ~2026-09-12) AND")
print(" plantsim.INVERTER_B IS INVERTER A COPIED, measured=False. The")
print(" N=2 rows are therefore about TWO COPIES OF A on a 10000 W rail.")
print(" The site B actually implies is ~9600 W (5000 + 4600), and 4600 is")
print(" B's DATASHEET and SERIAL, not a measurement, and may be DC -")
print(" goodwe/NOTES.md §1.1b. Read the rows as architecture, not as a")
print(" prediction about this house.\n")
for n, rail in ((1, 5000.0), (2, 10000.0)):
for slew in (1000.0, frac * rail):
w = family(ps, ct, gain=0.6, slew_w=slew, rail_w=rail, n=n,
tick_s=tick_s)
lab = (f"n={n} rail {rail:.0f} slew {slew:.0f}"
+ (" [B = A COPIED]" if n == 2 else ""))
row(lab, w)
print(f"\n The derived value at the REAL 9600 W site is "
f"{frac * 9600:.0f} W per tick, and")
print(" validate.py reads it off `sum(unit_caps(opts))` - the same number")
print(" `Controller.site_max_w` builds - so adding inverter B moves the")
print(" bound with no operator action and no second derivation.")
print("\n ⚠️ AND THE DIRECTION IS NOT THE ONE THE TICKET ASSUMED. A rail that")
print(" GROWS loosens this bound: slew_w 1000 crosses a 9600 W rail in")
print(" 19.2 s and PASSES, while at 5000 W the same 1000 is the")
print(" configuration that was measured failing. That is what the")
print(" constraint says and it is correct - the bound is on how long the")
print(" crossing TAKES, and a bigger rail at the same step size takes")
print(" longer to cross.")
print("\n ⚠️ AND THE >= 900 W STEP COUNT STAYS AT 48/51 AT THE DERIVED VALUE")
print(" ON THE N=2 ROW, WHICH IS NOT A FAILURE - IT IS THE THRESHOLD GOING")
print(" STALE, on the same day and in the same way as the constant. 900 W")
print(" was a meaningful step against a 5000 W rail; against 10000 W the")
print(" derived step IS 1111 W, and a check written on the absolute 900")
print(" would report the correct tuning as broken. Anything grading this")
print(" symptom at N>=2 has to grade it as a FRACTION of the rail too.")
print("\n WHAT DOES NOT GET WORSE HERE, STATED BECAUSE IT WAS EXPECTED TO.")
print(" Over the SAME family of 1.0-4.2 kW export steps, doubling the rail")
print(" does not worsen the over-absorption (958 -> 810 W): the extra rail")
print(" is headroom these disturbances never ask for. That is a statement")
print(" about this family, not about the site - a bigger site sees bigger")
print(" steps, and the over-absorption is a `gain` term either way, which")
print(" the enforced ratio does not bound and this card did not change.")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--repro", action="store_true")
ap.add_argument("--slew", action="store_true")
ap.add_argument("--gain", action="store_true")
ap.add_argument("--roster", action="store_true")
ap.add_argument("--all", action="store_true")
ap.add_argument("--tick", type=float, default=None,
help="seconds per control cycle (default: control.LOOP_TICK_S)")
ap.add_argument("--rail", type=float, default=5000.0)
a = ap.parse_args()
if not any((a.repro, a.slew, a.gain, a.roster, a.all)):
a.all = True
sim = find_sim()
if sim is None:
sys.exit("slew_sim: admin/goodwe's sim/ is not beside this checkout, so "
"plantsim.Plant is unavailable. Clone it as a sibling or set "
"$GOODWE_SIM. This is a missing repository, NOT a result about "
"the control law - do not report it as one.")
ps, ct = load(sim)
tick = a.tick if a.tick is not None else ct.LOOP_TICK_S
print(f"slew_sim: plant from {sim}")
print(f" control law from {ADDON / 'goodwe_controller' / 'app'}")
print(f" {ps.Inverter.from_spec('A', ps.INVERTER_A).describe()}")
print(f" tick {tick:.1f} s, SLEW_RAIL_FRAC {ct.SLEW_RAIL_FRAC:.4f} "
f"= {ct.LOOP_TICK_S:.0f} s / {ct.PLANT_SETTLE_S:.0f} s\n")
if a.repro or a.all:
repro(ps, ct, tick); print()
if a.slew or a.all:
slew_table(ps, ct, tick, a.rail); print()
if a.gain or a.all:
gain_table(ps, ct, tick, a.rail); print()
if a.roster or a.all:
roster_table(ps, ct, tick)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -242,7 +242,7 @@ configuration:
description: "How much of the remaining error the loop corrects each cycle. MEASURED ON REAL HARDWARE and already at the limit - do not raise it. Lower is slower and safer."
slew_w:
name: "Advanced · Largest change per cycle (W)"
description: "How far the setpoint may move in one control cycle. It bounds how fast the site can turn around, and several safety windows were measured across the whole range this accepts."
description: "How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW THE INVERTER RATING and is the recommended setting: the limit becomes about a ninth of the rating, so the loop takes the plant's own MEASURED settling time to cross its whole output range and the number cannot be left behind when the rating changes. DERIVED (SAFETY-39) from an ~18 s measured step response against a 2 s meter tick. A fixed number is accepted and is never silently reduced, but a number faster than the rating allows is refused at startup - at 1000 W against a 5000 W rating this loop drove the meter from 3.4 kW of export to 2.8 kW of import on 2026-09-05. Lower is always accepted."
deadband_w:
name: "Advanced · Deadband (W)"
description: "Errors smaller than this are ignored. ⚠️ The deadband is a one-way ratchet: any resting point inside it holds forever, and the meter's import register counts every positive watt with no export to cancel it - which is exactly why the resting point below is slightly negative."
+1 -1
View File
@@ -222,7 +222,7 @@ configuration:
description: "Welk deel van de resterende afwijking de regeling per cyclus wegregelt. GEMETEN OP ECHTE HARDWARE en al aan de bovengrens - niet verhogen. Lager is trager en veiliger."
slew_w:
name: "Geavanceerd · Grootste verandering per cyclus (W)"
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. Dit begrenst hoe snel de installatie kan omkeren; verschillende veiligheidsvensters zijn over het volledige toegelaten bereik gemeten."
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. ⚠️ 0 BETEKENT VOLG HET OMVORMERVERMOGEN en is de aanbevolen instelling: de grens wordt ongeveer een negende van dat vermogen, zodat de regeling er de GEMETEN insteltijd van de installatie over doet om haar volledige bereik te doorlopen en het getal niet achterblijft wanneer het vermogen verandert. AFGELEID (SAFETY-39) uit een gemeten stapresponsie van ~18 s bij een meterinterval van 2 s. Een vast getal wordt aanvaard en wordt nooit stilzwijgend verlaagd, maar een getal sneller dan het vermogen toelaat wordt bij het starten geweigerd - bij 1000 W tegen 5000 W dreef deze regeling de meter op 2026-09-05 van 3,4 kW injectie naar 2,8 kW afname. Lager wordt altijd aanvaard."
deadband_w:
name: "Geavanceerd · Dode zone (W)"
description: "Afwijkingen kleiner dan deze waarde worden genegeerd. ⚠️ De dode zone werkt maar één kant op: elk rustpunt erbinnen blijft eeuwig staan, en het afnameregister van de meter telt elke positieve watt mee zonder injectie die dat compenseert - en precies daarom staat het rustpunt hieronder licht negatief."