SAFETY-13: the output freeze gets an exit, and only where it needs one #58

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

Card: SAFETY-13 — Bound the no-start-while-saturated output block
Base: release/1.0 @ c8dcb38. Do not merge on my account.
Nothing here reached the inverter. Every figure below is the pure control law run in goodwe-addon-ci, mounted :ro, __pycache__ purged first. No rig, no ESP32, no Home Assistant.


What the card asked, and what was decided

The output freeze refuses to start a charge while saturated: at prev_w == 0 the rule max(target, prev_w) blocks any negative command. The rule is defensible and it is kept — the inverter reporting power we did not command means another writer is on the actuator, the §1.5 non-goal. The defect is that it had no end.

The card asked what the rule should be. The answer is not how long, it is which freezes get an exit at all:

the inverter is… can the freeze clear itself? decided
doing less than the command, in the command's own direction — a cold pack, a curtailment, an export cap, the plant still ramping Yes. The command is pinned at something the inverter can reach, so it catches up and saturated_now goes false no exit. release/1.0 unchanged
doing something outside what the command can explain — overshooting it, or the opposite sign No. Clearing it needs the inverter to track our command, and the other writer is what stops that releases once the divergence has outlasted the plant

A plain cycle bound was rejected because it bounds the wrong thing — one N treats a permanent-and-correct freeze the same as one that can never end. An export-magnitude exemption was rejected because it makes the block's end depend on how big the other actor's disturbance happens to be.

Full reasoning: goodwe_controller/DECISIONS/SAFETY-13-bounding-the-output-freeze.md.

⚠️ The card's route to the deadlock is not the one that works, and this corrects it

Driven at the live install's shape (max_w 5000, slew_w 400, target_grid_w -10), another writer holding the pack at +700 W:

  • Entering from an export does NOT reach the block. The slew limit moves the command off zero before sat_count reaches 3, so what gets applied is max(target, −800). The site charges at 800 W — a standing error, not an idle battery.
  • It is entered from the DEADBAND. A site resting at target_grid_w counts saturated cycles while nothing is wrong (saturated_now is computed before the deadband branch), reaching frozen with the command at 0. A 2 kW export arriving afterwards then leaves the command at exactly 0.0 W on every subsequent cycle — 0 W is a fixed point.

And prev_w == 0 is not where it stops. The first run above pins at −800 W against its own integrator standing at −2388 W, with the same absence of an exit. prev_w == 0 is only the instance where the pinned magnitude is the whole of what the battery could have done — so the change is written against the output freeze, not against a special case of it.

The predicate, and the bound

unasked_power(prev_w, actual_w, saturation_w) — physical, not statistical. A limit can only make the inverter do less than the command, in the command's own direction; it cannot overshoot the command and cannot produce the opposite sign. So everything a limit can produce lies in the interval spanned by 0 and prev_w, widened by saturation_w.

⚠️ At prev_w == 0 the predicate and the saturation detector are the same test (asserted over 801 readings): with nothing commanded there is no interval for a limit to live in, which is exactly why the zero case strands.

SETTLE_CYCLES = ceil(PLANT_SETTLE_S / LOOP_TICK_S) = 9. No new number — SAFETY-39's two measured constants, and the reciprocal of SLEW_RAIL_FRAC. A divergence still standing after the plant's own settling time is not this plant catching up. It inherits SAFETY-39's LOOP_TICK_S caveat, in the opposite direction: a slower meter makes the freeze hold longer than needed (costs energy, never safety); a faster one can release while the plant still moves — bounded below rather than by luck. _SAT_COUNT_CAP is now SETTLE_CYCLES + 1; its value is unchanged at 10.

AC 4 — the release cannot become windup

Releasing the output freeze does not unfreeze the loop. The integrator freeze is untouched, so a released cycle can only move the command toward an accumulator that stopped accumulating, by at most one slew step, and never past it. Swept over 107 625 released states: no case leaves the interval between the previous command and the frozen integrator, none moves further than one slew step.

On the card's run the command holds at 0 W through sat_count 3–8, starts on the ninth saturated cycle, and settles at −1190 W against a frozen integrator of −1194 W — not the 5000 W rail.

⚠️ The first form of that check was wrong and the sweep said so; it is left in the file. It asserted |target| <= |i_w| and produced prev −5000, i_w −3000, target −4600 — the command walking back toward the integrator and not yet arrived. Moving the right way, and the assertion still failed.

Graded against release/1.0

test_control.py's sweep still compares against a transcription of release/1.0 that was not taught the new rule (an oracle taught the change compares the law against itself). What changed is the assertion.

cases
total 3024
bit-identical to release/1.0 2592
releases 432
…reason-only (freeze was not binding) 405
…the command changes 27

All 27 are at prev_w == 0, release/1.0 commanding exactly 0.0 W, every new command negative — a charge start that was refused. Nothing else moved.

The genuine-limit case, 30 cycles with the inverter curtailed to ±1000 W against a 4 kW import: freeze-released never appears and the command stays pinned at +2400 W.

SAFETY-04's deliberate assertion

Kept, not inverted. Its fixture (sat_count=3 → 4) sits below the bound, so it still asserts the block; the release is asserted beside it on the other side of the bound. SAFETY-13 gave the block an exit, not a different opinion about its first cycles.

Coverage audit re-measured

Same mutation form as the table's, __pycache__ purged before every run:

mechanism before after
output freeze 2 (reproduced on a pristine c8dcb38 first) 11
the freeze's exit (made unconditional again) — 4
unasked_power() (replaced by return True) — 6

⚠️ What this does not buy, and what is not measured

  • The site still does not reach its target while another writer is on the bus. It settles at the frozen integrator. The exit stops the battery being idle; it cannot buy correct regulation against an actuator somebody else is also writing to. freeze-released in the action log means look for the second writer.
  • One configuration removes the block entirely. saturation_cycles is accepted up to 10; at 9 or above the freeze cannot engage before its own exit is due. Not refused at startup — a controller that will not boot over a tuning nuance is worse than the nuance — but asserted in the tests and written in DOCS.md.
  • Nothing was measured on two inverters. The N=2 escalation is stated from the code (actual_w is site_batt_w, the SUM) and from single-unit runs. Inverter B does not physically exist (NOTES.md §1.1b — datasheet and serial only) and sim/plantsim.py:216 INVERTER_B is INVERTER_A copied with measured=False; any two-inverter figure would be two copies of A.
  • No new alarm. SAFETY-41 just removed one that narrated normal anti-windup; this state is not yet known to be worth waking anybody for. It is carried on Decision.reason, which nothing after the freeze overwrites.

Test evidence

22/22 test files pass in goodwe-addon-ci, tree rsynced to a plain path, mounted :ro, python3 -B, 2 __pycache__ dirs / 17 .pyc purged first (0 remaining, printed).

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

**Card:** [SAFETY-13 — Bound the no-start-while-saturated output block](https://trello.com/c/uZjmuF7B) **Base:** `release/1.0` @ `c8dcb38`. **Do not merge on my account.** **Nothing here reached the inverter.** Every figure below is the pure control law run in `goodwe-addon-ci`, mounted `:ro`, `__pycache__` purged first. No rig, no ESP32, no Home Assistant. --- ## What the card asked, and what was decided The output freeze refuses to *start* a charge while saturated: at `prev_w == 0` the rule `max(target, prev_w)` blocks any negative command. **The rule is defensible and it is kept** — the inverter reporting power we did not command means another writer is on the actuator, the §1.5 non-goal. **The defect is that it had no end.** The card asked what the rule *should be*. The answer is not *how long*, it is **which freezes get an exit at all**: | the inverter is… | can the freeze clear itself? | decided | |---|---|---| | doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap, the plant still ramping | **Yes.** The command is pinned at something the inverter can reach, so it catches up and `saturated_now` goes false | **no exit.** `release/1.0` unchanged | | doing something **outside** what the command can explain — overshooting it, or the opposite sign | **No.** Clearing it needs the inverter to track *our* command, and the other writer is what stops that | **releases** once the divergence has outlasted the plant | A plain cycle bound was rejected because it bounds the wrong thing — one `N` treats a permanent-and-correct freeze the same as one that can never end. An export-magnitude exemption was rejected because it makes the block's end depend on how big the other actor's disturbance happens to be. Full reasoning: `goodwe_controller/DECISIONS/SAFETY-13-bounding-the-output-freeze.md`. ## ⚠️ The card's route to the deadlock is not the one that works, and this corrects it Driven at the live install's shape (`max_w 5000`, `slew_w 400`, `target_grid_w -10`), another writer holding the pack at **+700 W**: - **Entering from an export does NOT reach the block.** The slew limit moves the command off zero before `sat_count` reaches 3, so what gets applied is `max(target, −800)`. The site charges at 800 W — a standing error, not an idle battery. - **It is entered from the DEADBAND.** A site resting at `target_grid_w` counts saturated cycles while nothing is wrong (`saturated_now` is computed before the deadband branch), reaching `frozen` with the command at 0. A 2 kW export arriving *afterwards* then leaves the command at **exactly `0.0 W` on every subsequent cycle** — 0 W is a fixed point. **And `prev_w == 0` is not where it stops.** The first run above pins at **−800 W** against its own integrator standing at **−2388 W**, with the same absence of an exit. `prev_w == 0` is only the instance where the pinned magnitude is the whole of what the battery could have done — so the change is written against the output freeze, not against a special case of it. ## The predicate, and the bound `unasked_power(prev_w, actual_w, saturation_w)` — physical, not statistical. A limit can only make the inverter do **less** than the command, in the command's own direction; it cannot overshoot the command and cannot produce the opposite sign. So everything a limit can produce lies in the interval spanned by `0` and `prev_w`, widened by `saturation_w`. ⚠️ **At `prev_w == 0` the predicate and the saturation detector are the same test** (asserted over 801 readings): with nothing commanded there is no interval for a limit to live in, which is exactly why the zero case strands. `SETTLE_CYCLES = ceil(PLANT_SETTLE_S / LOOP_TICK_S)` = **9**. **No new number** — SAFETY-39's two measured constants, and the reciprocal of `SLEW_RAIL_FRAC`. A divergence still standing after the plant's own settling time is not this plant catching up. It inherits SAFETY-39's `LOOP_TICK_S` caveat, in the opposite direction: a *slower* meter makes the freeze hold longer than needed (costs energy, never safety); a *faster* one can release while the plant still moves — bounded below rather than by luck. `_SAT_COUNT_CAP` is now `SETTLE_CYCLES + 1`; its value is unchanged at **10**. ## AC 4 — the release cannot become windup **Releasing the output freeze does not unfreeze the loop.** The integrator freeze is untouched, so a released cycle can only move the command *toward* an accumulator that stopped accumulating, by at most one slew step, and never past it. Swept over **107 625** released states: no case leaves the interval between the previous command and the frozen integrator, none moves further than one slew step. On the card's run the command holds at 0 W through `sat_count` 3–8, starts on the **ninth** saturated cycle, and settles at **−1190 W** against a frozen integrator of **−1194 W** — not the 5000 W rail. ⚠️ The first form of that check was wrong and the sweep said so; it is left in the file. It asserted `|target| <= |i_w|` and produced `prev −5000, i_w −3000, target −4600` — the command walking *back* toward the integrator and not yet arrived. Moving the right way, and the assertion still failed. ## Graded against `release/1.0` `test_control.py`'s sweep still compares against a transcription of `release/1.0` that was **not** taught the new rule (an oracle taught the change compares the law against itself). What changed is the assertion. | | cases | |---|---| | total | 3024 | | **bit-identical to `release/1.0`** | **2592** | | releases | 432 | | …reason-only (freeze was not binding) | 405 | | …**the command changes** | **27** | All 27 are at `prev_w == 0`, `release/1.0` commanding exactly `0.0 W`, every new command **negative** — a charge start that was refused. Nothing else moved. The genuine-limit case, 30 cycles with the inverter curtailed to ±1000 W against a 4 kW import: `freeze-released` never appears and the command stays pinned at +2400 W. ## SAFETY-04's deliberate assertion Kept, not inverted. Its fixture (`sat_count=3` → 4) sits **below** the bound, so it still asserts the block; the release is asserted beside it on the other side of the bound. SAFETY-13 gave the block an exit, not a different opinion about its first cycles. ## Coverage audit re-measured Same mutation form as the table's, `__pycache__` purged before every run: | mechanism | before | after | |---|---|---| | output freeze | 2 (reproduced on a pristine `c8dcb38` first) | **11** | | the freeze's **exit** (made unconditional again) | — | **4** | | `unasked_power()` (replaced by `return True`) | — | **6** | ## ⚠️ What this does not buy, and what is not measured - **The site still does not reach its target while another writer is on the bus.** It settles at the frozen integrator. The exit stops the battery being *idle*; it cannot buy correct regulation against an actuator somebody else is also writing to. `freeze-released` in the action log means *look for the second writer*. - **One configuration removes the block entirely.** `saturation_cycles` is accepted up to 10; at 9 or above the freeze cannot engage before its own exit is due. **Not** refused at startup — a controller that will not boot over a tuning nuance is worse than the nuance — but asserted in the tests and written in DOCS.md. - **Nothing was measured on two inverters.** The N=2 escalation is stated from the code (`actual_w` is `site_batt_w`, the SUM) and from single-unit runs. Inverter B does not physically exist (`NOTES.md` §1.1b — datasheet and serial only) and `sim/plantsim.py:216` `INVERTER_B` is `INVERTER_A` copied with `measured=False`; any two-inverter figure would be two copies of A. - **No new alarm.** SAFETY-41 just removed one that narrated normal anti-windup; this state is not yet known to be worth waking anybody for. It is carried on `Decision.reason`, which nothing after the freeze overwrites. ## Test evidence **22/22 test files pass** in `goodwe-addon-ci`, tree rsynced to a plain path, mounted `:ro`, `python3 -B`, `2 __pycache__ dirs / 17 .pyc` purged first (0 remaining, printed). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-06 13:57:16 +02:00
While the loop is frozen the command's magnitude may fall but never rise. At
prev_w == 0 there is no magnitude left to fall, so a charge cannot START. That
refusal is deliberate and it is kept: the inverter reporting power we did not
command means another writer is on the actuator, which is the §1.5 non-goal.
What it did not have was an END - nothing cleared it but the other writer
leaving.

Reproduced on release/1.0 @ c8dcb38, at the live install's shape (max_w 5000,
slew_w 400, target_grid_w -10), with another writer holding the pack at +700 W.
The route is not the obvious one: entering from an export, the slew limit moves
the command off zero before the counter reaches 3. It is entered from the
DEADBAND - a site resting at its target counts saturated cycles while nothing is
wrong - and 0 W is then a fixed point: a 2 kW export arriving afterwards leaves
the command at exactly 0.0 W on every subsequent cycle.

And prev_w == 0 is not where it stops. The same tuning pins at -800 W against
its own integrator standing at -2388 W, with the same absence of an exit. So the
change is written against the output freeze, not against a special case of it.

THE DECISION IS WHICH FREEZES GET AN EXIT, NOT HOW LONG THE BLOCK LASTS.

  - A divergence a LIMIT could have produced - the inverter doing less than the
    command, in the command's own direction - clears itself, because the command
    is pinned at something the inverter can reach. No exit. release/1.0
    unchanged, and asserted so: a curtailed inverter is never released over 30
    cycles.
  - A divergence a limit could NOT have produced - overshooting the command, or
    the opposite sign - cannot clear itself, because clearing it needs the
    inverter to track OUR command. That one releases.

unasked_power() is that predicate and it is physical, not statistical: a limit
can only make the inverter do less, in the command's own direction, so
everything a limit can produce lies in the interval spanned by 0 and prev_w
widened by saturation_w. At prev_w == 0 it IS the saturation test, which is
exactly why the zero case is the one that strands.

The bound is not a new number. SETTLE_CYCLES = ceil(PLANT_SETTLE_S /
LOOP_TICK_S) = 9, SAFETY-39's two measured constants and the reciprocal of
SLEW_RAIL_FRAC: a divergence still standing after the plant's own settling time
is not this plant catching up. _SAT_COUNT_CAP is written as SETTLE_CYCLES + 1;
its value is unchanged at 10.

RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP, which is what makes this
bounded. The integrator freeze is untouched, so a released cycle can only move
the command toward an accumulator that stopped accumulating, by at most one slew
step, and never past it - swept over 107 625 released states. On the run above
the command starts on the ninth saturated cycle and settles at -1190 W against a
frozen integrator of -1194 W, not at the 5000 W rail.

Graded against a transcription of release/1.0 over the 3024-case sweep, which
was NOT taught the new rule: 2592 cases bit-identical, 432 releases, and 27 of
those change the command - every one a charge from prev_w == 0 that release/1.0
refused to start.

SAFETY-04's deliberate assertion is kept rather than inverted: its fixture sits
below the bound, so it still asserts the block, and the release is asserted
beside it on the other side of the bound.

Coverage audit re-measured, same mutation form, __pycache__ purged: output
freeze 2 -> 11 (2 reproduced on a pristine c8dcb38 first), the exit 4,
unasked_power 6.

saturation_cycles at 9 or 10 removes the block entirely, because the freeze
cannot engage before its own exit is due. Not refused at startup; asserted in
the tests and written in DOCS.md.

Nothing was measured on two inverters: inverter B does not physically exist
(NOTES.md §1.1b) and sim/plantsim.py:216 INVERTER_B is INVERTER_A copied with
measured=False. The N=2 escalation is stated from the code (actual_w is
site_batt_w, the SUM) and is labelled as such.

22/22 test files pass in goodwe-addon-ci, mounted :ro, bytecode purged first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-06 18:32:10 +02:00
NOTES.md 5.10 measured the 1.78 kW step BOTH ways - "Up: ~18 s from step to
deadband. Down: ~23 s" (NOTES.md:1550). SAFETY-39 takes 18 correctly: its
bound is a REFUSAL and the looser number refuses less. This branch reused
PLANT_SETTLE_S for the opposite job - how long the plant may still be the
EXPLANATION for a divergence - where the conservative half is the longer one.
ceil(23/2) = 12, not 9, and being short there does not refuse anything, it
accuses.

The false positive is real and is measured here. One inverter, one compute(),
one plant, no second writer anywhere in the harness: the command falls through
zero while the pack is still coasting the old way, unasked_power() calls our
own power "unasked", the counter reaches 9 while it is still coasting, and the
law emits "freeze-released". At the plant's own measured 18 s/23 s shape, over
272 load-step pairs, the 9-cycle bound fires in 62 and the 12-cycle bound in 2.

  PLANT_SETTLE_DOWN_S = 23.0 is added beside PLANT_SETTLE_S = 18.0, because
  the plant's settling time is a PAIR and each bound has to say which half it
  means and why.
  SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) = 12. It is no
  longer the reciprocal of SLEW_RAIL_FRAC; that was a coincidence of both
  reading the same half, and test_control.py now asserts the inequality.
  _SAT_COUNT_CAP moves with it, 10 -> 13. config.yaml accepts
  saturation_cycles as int(1,10), so the whole accepted range is now strictly
  BELOW the bound and the degenerate configuration this branch used to
  document no longer exists - pinned against config.yaml's own schema line.

Three shipped statements were falsified by that measurement and are rewritten,
not renumbered - at 12 the residual is smaller, not zero:

  unasked_power()'s docstring said anything outside the interval "was put
  there by something other than this loop". It was our own power: eight cycles
  after a +1990 W command the command is 0 and the pack still reads +522.3 W.
  control.py said "SO THE BOUND BELONGS TO (b) AND ONLY TO (b)". It fired in
  case (a).
  DOCS.md told an operator "if you see freeze-released, look for the second
  writer" - on a single-inverter install, hunting something that does not
  exist. Replaced by a section that gives the innocent causes first (a large
  load or PV swing; a plant slower than the one measured, 8/320 scenarios at
  23 s rising to 268/320 at 50 s) and the second writer only when the state
  STANDS.

Round 1's own results are kept and re-graded, not weakened. The release/1.0
sweep had to widen to reach the new bound (3780 cases): 3267 bit-identical,
432 releases, 27 command changes - all still charge starts from prev_w == 0 -
and 81 differing in the saturation counter alone, which nothing downstream can
read. The card's case releases on the twelfth saturated cycle instead of the
ninth and settles at the same -1190 W against the same frozen integrator.

Also corrected, because they were claims and did not reproduce: the coverage
table's "output freeze" row was 11, and the mutation form its own note
describes gives 6 on this tree, 6 on the first SAFETY-13 commit and 2 on
release/1.0; the detector row was left at 11 and is 15; the frozen = False
footnote was left at 10 and is 13. And section 6 reported 107 625 as "released
states" when it is the number of states SWEPT - 67 500 of them release, and
both are counted now.

Nothing here reached the inverter. 22/22 test files pass in goodwe-addon-ci,
tree rsynced to a plain path, mounted :ro, python3 -B, __pycache__ purged and
printed first (0 dirs / 0 .pyc; the worktree carries none).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Author
Owner

Round 2 — SETTLE_CYCLES came from the wrong half of a two-sided measurement

Head: f156fad on SAFETY-13, on top of round 1's 6c45656. Base release/1.0 @ c8dcb38. Do not merge on my account.
Nothing here reached the inverter. Every figure below is the pure control law run in goodwe-addon-ci, tree rsynced to a plain path, mounted :ro, python3 -B, __pycache__ purged and printed first. No rig, no ESP32, no Home Assistant.

Round 1's results are kept, re-graded where the constant moved them, and not weakened anywhere.


The blocker, confirmed at the source

NOTES.md:1550 — "Up: ~18 s from step to deadband. Down: ~23 s from a −1784 W export excursion." The plant's settling time is a pair, and which half is safe depends on which way being wrong hurts:

bound question safe half why
SAFETY-39's SLEW_RAIL_FRAC may this configuration be refused? 18 s the looser number refuses fewer working configurations
this one may the plant still be the explanation? 23 s a shorter bound stops believing the plant too early — and then it accuses

Round 1 reused PLANT_SETTLE_S and got 9, three cycles short of the plant's own down-ramp, on the side that produces a false positive.

The false positive, reproduced here at N=1

One compute(), one plant model, one command — no second writer anywhere in the harness, so every release it produces is false by construction. The plant is a first-order lag whose two time constants come from the two measured settling times (τ = t/3, a first-order step being 95 % done at 3 τ) with sim/plantsim.py's measured 800 W/s ramp cap. It is a model and is labelled one wherever it is used.

A 4 kW export falling to 2.5 kW, at max_w 5000 / slew_w 400 / target_grid_w −10:

 cyc  load    grid  actual     prev  command sat reason
  31 -2500  -131.2 -2368.8    -1230     -830   6 saturated-freeze
  32 -2500  -484.5 -2015.5     -830     -430   7 saturated-freeze
  33 -2500  -848.6 -1651.4     -430     -310   8 saturated-freeze
  34 -2500 -1156.6 -1343.4     -310     -310   9 freeze-released   <-- N=1

At cycle 34 the command is −310 W and the pack is still at −1343.4 W, coasting down from −4860 W a few cycles earlier. unasked_power(−310, −1343.4, 500) is True. There is no second writer. At SETTLE_CYCLES = 12 the same run never releases.

bound 9 bound 12
1280 closed-loop scenarios (plant pairs × slews × saturation_cycles × load steps), single inverter 113 fire 24 fire
272 load-step pairs at the plant's own measured 18 s / 23 s, slew_w 400 62 fire 2 fire

The second row is now a check in test_control.py, run every time.

The fix, and the two things that had to move with it

  • PLANT_SETTLE_DOWN_S = 23.0 is added beside PLANT_SETTLE_S = 18.0. Both are named, and the comment on each says which bound may read it and why. Adding a constant is the point here rather than a cost: one name for a two-sided measurement is what produced this defect.
  • SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) = 12. It is no longer the reciprocal of SLEW_RAIL_FRAC — that was a coincidence of both reading the same half, and it read as a derivation. test_control.py asserts the inequality so the two cannot be quietly re-fused.
  • _SAT_COUNT_CAP 10 → 13, since it is SETTLE_CYCLES + 1.

And that deletes the degenerate configuration rather than documenting it. config.yaml accepts saturation_cycles as int(1,10); at a bound of 9 a legal setting could sit above the exit and silently remove the no-start block. At 12 the whole accepted range is strictly below the bound. The three places that documented the degenerate case moved with the constant:

  • the test at test_control.py §7 now asserts the opposite property — no accepted configuration can stop the block engaging — pinned against config.yaml's own schema line by regex, not a transcription;
  • DOCS.md's "One configuration removes the block entirely" paragraph is replaced;
  • the report's honest-gap on it is replaced by the same property, with a note that this paragraph used to say the opposite.

⚠️ The three falsified statements, rewritten and not renumbered

At 12 the residual is smaller, not zero — so "put there by something other than this loop" would still be too strong. Each says what the predicate guarantees and what it cannot:

  1. unasked_power()'s docstring — "Anything outside that interval was put there by something other than this loop." It was our own power: measured, eight cycles after a +1990 W command the command is 0 and the pack still reads +522.3 W. The docstring now claims only its first sentence — this cycle's command does not account for this cycle's reading — and states the coast-down as its known blind spot: the predicate compares against one cycle's command while the pack carries every command before it.
  2. control.py, in compute() — "SO THE BOUND BELONGS TO (b) AND ONLY TO (b)." It fired in case (a). The comment now says the bound is a reason to stop believing (a), not a proof of (b), and that the release is written to be survivable when the inference is wrong.
  3. DOCS.md — "If you see freeze-released in the action log, look for the second writer." This is the one that reaches a person, and on a single-inverter install it sent them hunting something that does not exist. Replaced by What freeze-released in the action log actually tells you, which gives the innocent causes first — a large load or PV swing; a plant slower than the one that was measured — and the second writer only when the state stands.

What the bound cannot fix, said plainly. It is derived from a measurement, and a plant can be slower than the measurement. Same sweep, bound held at 12, lengthening only the plant's down-ramp: 8 of 320 scenarios at the measured 23 s, 59 of 320 at 34 s, 268 of 320 at 50 s. That is in control.py, in the report and in DOCS.md.

Round 1's results, re-graded

The release/1.0 sweep had to widen or it would have graded nothing: its sat_count fixtures stopped at 9, so with the bound at 12 the counter never reached the exit and every release silently vanished from the table. It runs to 12 now.

round 1 round 2
total cases 3024 3780
bit-identical to release/1.0 2592 3267
releases 432 432
…of those, the command changes 27 27 — all charge starts from prev_w == 0
differ in the saturation counter alone — 81

The 81 are the counter's ceiling moving. Nothing else may differ in them and the sweep asserts it: frozen is sat_count >= saturation_cycles, config.yaml caps that at 10, and no other line in compute() reads the counter's value — so 13 instead of 10 cannot change what is commanded. All 81 sit at an sc above 10, a state release/1.0 could never hold.

Everything the reviewer verified is intact: the block is bounded and exits with the other actor still present; §1.5 is preserved (the curtailed-inverter run still never releases, command pinned at +2400 W over 30 cycles); a unit merely ramping down is bit-identical to release/1.0; no reachable magnitude is added. The card's case now releases on the twelfth saturated cycle instead of the ninth and settles at the same −1190 W against the same frozen integrator of −1194 W.

⚠️ Three more numbers that did not reproduce, corrected here

These were claims in round 1's diff, and re-measuring them is how the round-2 checks were validated:

written measured (this tree, same form, __pycache__ purged)
coverage table, output freeze 11 6 — and 6 on 6c45656 too, against 2 on a pristine c8dcb38. 11 is not produced by the form the note describes, nor by removing the line with its reason (4), nor by swapping min/max (12)
coverage table, saturated_now = False 11 15 — left stale while round 1 added checks that grade it
footnote, frozen = False 10 13 on this tree (10 on release/1.0)
§6, "107 625 released states" — 107 625 is the number of states swept; 67 500 release. Both are counted and printed now

New coverage rows for this round's mechanisms, same form:

mechanism deleted checks that fail
SETTLE_CYCLES derived from PLANT_SETTLE_S instead of _DOWN_S (the round-1 defect) 9
_SAT_COUNT_CAP pinned back at release/1.0's literal 10 6
the output freeze's EXIT, made unconditional 4
unasked_power() replaced by return True 6

⚠️ One of those caught a check I had weakened: while the bound was moving I replaced the sweep's exact counts with an inequality, and unasked_power() → return True immediately dropped from 6 checks to 5. The exact counts are restored, with a note saying why.

Not measured

  • Nothing on two inverters. The N=2 escalation is read from the code (actual_w is the site SUM) and from single-unit runs. Inverter B does not physically exist (NOTES.md §1.1b — datasheet and serial only) and sim/plantsim.py:216 INVERTER_B is INVERTER_A copied with measured=False; any two-inverter figure would be two copies of A. Now labelled in CHANGELOG.md as well as in the report.
  • The plant is a model. Round 2's closed-loop figures come from a first-order lag calibrated to the two measured settling times, not from the rig and not from the house. The two measurements enter the model in the same direction they enter the constant, which is what the check is for; it is not evidence about any other plant.
  • No new alarm, and freeze-released is still carried on Decision.reason only.

Test evidence

22/22 test files pass in goodwe-addon-ci, tree rsynced to /home/homelab/s13r2-run, mounted :ro, python3 -B, PYTHONDONTWRITEBYTECODE=1. Bytecode purge printed 0 __pycache__ dirs / 0 .pyc before the run — the worktree carries none, and the mount is read-only so the run cannot create any.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## Round 2 — `SETTLE_CYCLES` came from the wrong half of a two-sided measurement **Head:** `f156fad` on `SAFETY-13`, on top of round 1's `6c45656`. Base `release/1.0` @ `c8dcb38`. **Do not merge on my account.** **Nothing here reached the inverter.** Every figure below is the pure control law run in `goodwe-addon-ci`, tree rsynced to a plain path, mounted `:ro`, `python3 -B`, `__pycache__` purged and printed first. No rig, no ESP32, no Home Assistant. Round 1's results are kept, re-graded where the constant moved them, and not weakened anywhere. --- ## The blocker, confirmed at the source `NOTES.md:1550` — *"**Up: ~18 s from step to deadband. Down: ~23 s** from a −1784 W export excursion."* The plant's settling time is a **pair**, and which half is safe depends on which way being wrong hurts: | bound | question | safe half | why | |---|---|---|---| | SAFETY-39's `SLEW_RAIL_FRAC` | may this configuration be **refused**? | **18 s** | the looser number refuses fewer working configurations | | this one | may the plant still be the **explanation**? | **23 s** | a shorter bound stops believing the plant too early — and then it *accuses* | Round 1 reused `PLANT_SETTLE_S` and got 9, three cycles short of the plant's own down-ramp, on the side that produces a false positive. ## The false positive, reproduced here at N=1 One `compute()`, one plant model, one command — **no second writer anywhere in the harness**, so every release it produces is false by construction. The plant is a first-order lag whose two time constants come from the two measured settling times (τ = t/3, a first-order step being 95 % done at 3 τ) with `sim/plantsim.py`'s measured 800 W/s ramp cap. **It is a model and is labelled one wherever it is used.** A 4 kW export falling to 2.5 kW, at `max_w 5000 / slew_w 400 / target_grid_w −10`: ``` cyc load grid actual prev command sat reason 31 -2500 -131.2 -2368.8 -1230 -830 6 saturated-freeze 32 -2500 -484.5 -2015.5 -830 -430 7 saturated-freeze 33 -2500 -848.6 -1651.4 -430 -310 8 saturated-freeze 34 -2500 -1156.6 -1343.4 -310 -310 9 freeze-released <-- N=1 ``` At cycle 34 the command is −310 W and the pack is still at −1343.4 W, coasting down from −4860 W a few cycles earlier. `unasked_power(−310, −1343.4, 500)` is True. There is no second writer. At `SETTLE_CYCLES = 12` the same run never releases. | | bound 9 | bound 12 | |---|---|---| | 1280 closed-loop scenarios (plant pairs × slews × `saturation_cycles` × load steps), single inverter | **113 fire** | **24 fire** | | 272 load-step pairs at the plant's own measured 18 s / 23 s, `slew_w 400` | **62 fire** | **2 fire** | The second row is now a check in `test_control.py`, run every time. ## The fix, and the two things that had to move with it - **`PLANT_SETTLE_DOWN_S = 23.0`** is added beside `PLANT_SETTLE_S = 18.0`. Both are named, and the comment on each says which bound may read it and why. Adding a constant is the point here rather than a cost: one name for a two-sided measurement is what produced this defect. - **`SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)` = 12.** It is **no longer the reciprocal of `SLEW_RAIL_FRAC`** — that was a coincidence of both reading the same half, and it read as a derivation. `test_control.py` asserts the *inequality* so the two cannot be quietly re-fused. - **`_SAT_COUNT_CAP` 10 → 13**, since it is `SETTLE_CYCLES + 1`. **And that deletes the degenerate configuration rather than documenting it.** `config.yaml` accepts `saturation_cycles` as `int(1,10)`; at a bound of 9 a legal setting could sit *above* the exit and silently remove the no-start block. At 12 the whole accepted range is strictly below the bound. The three places that documented the degenerate case moved with the constant: - the test at `test_control.py` §7 now asserts the opposite property — **no accepted configuration can stop the block engaging** — pinned against `config.yaml`'s own schema line by regex, not a transcription; - DOCS.md's *"One configuration removes the block entirely"* paragraph is replaced; - the report's honest-gap on it is replaced by the same property, with a note that this paragraph used to say the opposite. ## ⚠️ The three falsified statements, rewritten and not renumbered At 12 the residual is **smaller, not zero** — so "put there by something other than this loop" would still be too strong. Each says what the predicate guarantees and what it cannot: 1. **`unasked_power()`'s docstring** — *"Anything outside that interval was put there by something other than this loop."* It was our own power: measured, eight cycles after a +1990 W command the command is 0 and the pack still reads **+522.3 W**. The docstring now claims only its first sentence — *this cycle's command does not account for this cycle's reading* — and states the coast-down as its known blind spot: the predicate compares against **one** cycle's command while the pack carries every command before it. 2. **`control.py`, in `compute()`** — *"SO THE BOUND BELONGS TO (b) AND ONLY TO (b)."* It fired in case (a). The comment now says the bound is a reason to stop believing (a), not a proof of (b), and that the release is written to be survivable when the inference is wrong. 3. **DOCS.md — *"If you see `freeze-released` in the action log, look for the second writer."*** This is the one that reaches a person, and on a single-inverter install it sent them hunting something that does not exist. Replaced by *What `freeze-released` in the action log actually tells you*, which gives the innocent causes first — a large load or PV swing; a plant slower than the one that was measured — and the second writer only when the state **stands**. **What the bound cannot fix, said plainly.** It is derived from a measurement, and a plant can be slower than the measurement. Same sweep, bound held at 12, lengthening only the plant's down-ramp: **8 of 320** scenarios at the measured 23 s, **59 of 320** at 34 s, **268 of 320** at 50 s. That is in `control.py`, in the report and in DOCS.md. ## Round 1's results, re-graded The `release/1.0` sweep had to widen or it would have graded nothing: its `sat_count` fixtures stopped at 9, so with the bound at 12 the counter never reached the exit and every release silently vanished from the table. It runs to 12 now. | | round 1 | round 2 | |---|---|---| | total cases | 3024 | **3780** | | bit-identical to `release/1.0` | 2592 | **3267** | | releases | 432 | **432** | | …of those, the command changes | 27 | **27** — all charge starts from `prev_w == 0` | | differ in the **saturation counter alone** | — | **81** | The 81 are the counter's ceiling moving. Nothing else may differ in them and the sweep asserts it: `frozen` is `sat_count >= saturation_cycles`, `config.yaml` caps that at 10, and no other line in `compute()` reads the counter's value — so 13 instead of 10 cannot change what is commanded. All 81 sit at an `sc` above 10, a state `release/1.0` could never hold. Everything the reviewer verified is intact: the block is bounded and exits with the other actor still present; §1.5 is preserved (the curtailed-inverter run still never releases, command pinned at +2400 W over 30 cycles); a unit merely ramping down is bit-identical to `release/1.0`; no reachable magnitude is added. The card's case now releases on the **twelfth** saturated cycle instead of the ninth and settles at the **same −1190 W** against the **same** frozen integrator of −1194 W. ## ⚠️ Three more numbers that did not reproduce, corrected here These were claims in round 1's diff, and re-measuring them is how the round-2 checks were validated: | | written | measured (this tree, same form, `__pycache__` purged) | |---|---|---| | coverage table, `output freeze` | 11 | **6** — and 6 on `6c45656` too, against **2** on a pristine `c8dcb38`. 11 is not produced by the form the note describes, nor by removing the line with its `reason` (4), nor by swapping min/max (12) | | coverage table, `saturated_now = False` | 11 | **15** — left stale while round 1 added checks that grade it | | footnote, `frozen = False` | 10 | **13** on this tree (10 on `release/1.0`) | | §6, "107 625 **released** states" | — | 107 625 is the number of states **swept**; **67 500** release. Both are counted and printed now | New coverage rows for this round's mechanisms, same form: | mechanism deleted | checks that fail | |---|---| | `SETTLE_CYCLES` derived from `PLANT_SETTLE_S` instead of `_DOWN_S` (the round-1 defect) | **9** | | `_SAT_COUNT_CAP` pinned back at `release/1.0`'s literal 10 | **6** | | the output freeze's EXIT, made unconditional | 4 | | `unasked_power()` replaced by `return True` | 6 | ⚠️ One of those caught a check I had weakened: while the bound was moving I replaced the sweep's exact counts with an inequality, and `unasked_power() → return True` immediately dropped from 6 checks to 5. The exact counts are restored, with a note saying why. ## Not measured - **Nothing on two inverters.** The N=2 escalation is read from the code (`actual_w` is the site SUM) and from single-unit runs. Inverter B does not physically exist (`NOTES.md` §1.1b — datasheet and serial only) and `sim/plantsim.py:216` `INVERTER_B` is `INVERTER_A` copied with `measured=False`; **any two-inverter figure would be two copies of A.** Now labelled in CHANGELOG.md as well as in the report. - **The plant is a model.** Round 2's closed-loop figures come from a first-order lag calibrated to the two measured settling times, not from the rig and not from the house. The two measurements enter the model in the same direction they enter the constant, which is what the check is for; it is not evidence about any other plant. - **No new alarm**, and `freeze-released` is still carried on `Decision.reason` only. ## Test evidence **22/22 test files pass** in `goodwe-addon-ci`, tree rsynced to `/home/homelab/s13r2-run`, mounted `:ro`, `python3 -B`, `PYTHONDONTWRITEBYTECODE=1`. Bytecode purge printed **0 `__pycache__` dirs / 0 `.pyc`** before the run — the worktree carries none, and the mount is read-only so the run cannot create any. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin merged commit 0581648c73 into release/1.0 2026-09-06 19:19:07 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#58