SAFETY-13: the bound came from the wrong half of a two-sided measurement
checks / unit (pull_request) Successful in 5m34s

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
This commit is contained in:
glenn schrooyen
2026-09-06 18:32:03 +02:00
co-authored by Claude Opus 5
parent 6c4565659b
commit f156fadac0
5 changed files with 648 additions and 150 deletions
+58 -17
View File
@@ -473,7 +473,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
| `step_w` | 10 | Quantisation |
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1**, and **do not set it to 9 or 10** either: at 9 the freeze cannot engage before its own exit is due, so the no-start block stops existing (SAFETY-13, below) |
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1**. Every value this file accepts (1-10) is below the freeze's own release bound of 12 cycles, so no accepted setting can stop the block engaging - see SAFETY-13, below |
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
| `cycle_floor_s` | 15 | The longest the loop may go without evaluating. A deadline on the gap between cycles, reset by every cycle — on a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than ~10 s; it must stay under `meter_max_age_s`, past which there is no reading to evaluate anyway |
@@ -645,35 +645,76 @@ one is the decision:
| the inverter is… | can it clear itself? | what happens now |
|---|---|---|
| doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap | **yes.** The command is pinned at something the inverter can reach, so it catches up and the freeze clears | unchanged: frozen for as long as it lasts, with no exit |
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **no.** Clearing the freeze needs the inverter to track *our* command, and the other writer is what stops it | the freeze releases once the divergence has outlasted the plant that could otherwise have explained it |
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **not necessarily.** If another writer is on the actuator, clearing the freeze needs the inverter to track *our* command and the other writer is what stops it, so nothing ends it | the freeze releases once the divergence has outlasted the plant that could otherwise have explained it — see *what `freeze-released` actually tells you*, below, because this row is also where our own ramp lands |
The second row's threshold is not a new number. `PLANT_SETTLE_S` (18 s, measured) is how
long this plant takes to settle, so a divergence still standing after that is not this
plant catching up; at the measured 2 s meter tick that is **9 cycles**, which is the
same relationship the slew limit already uses (`control.py`, `SETTLE_CYCLES`).
The second row's threshold is the plant's own measured settling time — **the slower of
the two directions it was measured in.** The 2026-08-22 step test timed the same 1.78 kW
step both ways: about 18 s to settle going up, about 23 s coming back down. 23 s is the
one that belongs here, because the question this bound answers is *how long may the plant
still be the explanation*, and the safe answer is to keep believing the plant for as long
as the plant has ever been seen to take. At the measured 2 s meter tick that is
**12 cycles** (`control.py`, `SETTLE_CYCLES`).
⚠️ **The first version of this feature used 18 s, and 9 cycles was wrong in the direction
that accuses.** Nine cycles is three short of the plant's own down-ramp, so an ordinary
load step on a **single** inverter — the command falling through zero while the pack is
still coasting the old way — could produce `freeze-released` with nothing else on the bus
at all. Driven in the container against a model of the measured plant, one inverter and
no second writer anywhere: over 272 load-step pairs the 9-cycle bound produced a release
in 62 of them and the shipped 12-cycle bound in 2.
**Releasing the output freeze does not unfreeze the loop.** The *integrator* freeze is
untouched, so the command is free to move only as far as an accumulator that stopped
accumulating cycles ago, one slew step per cycle. Measured on the simulator 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 and 2 kW of export standing: the command holds at 0 W while
the counter climbs, starts at the ninth saturated cycle, and settles at **−1190 W**
against a frozen integrator of −1194 W — not at the 5000 W rail. Before this change the
same run held 0.0 W on every cycle, for as long as the other writer stayed.
the counter climbs, starts on the **twelfth** saturated cycle, walks 0 → −400 → −800, and
settles at **−1190 W** against a frozen integrator of −1194 W — not at the 5000 W rail.
Before this change the same run held 0.0 W on every cycle, for as long as the other
writer stayed.
⚠️ **The site still does not reach its target in that state, and it is not supposed
to.** What the exit buys is that the battery stops being idle while the site exports;
what it does not buy is correct regulation against an actuator somebody else is also
writing to. A standing grid error with another writer on the bus is the symptom of a
configuration this add-on refuses to support, not a tuning problem. If you see
`freeze-released` in the action log, look for the second writer.
configuration this add-on refuses to support, not a tuning problem.
⚠️ **One configuration removes the block entirely, silently.** `saturation_cycles` is
accepted up to 10. Set it at or above 9 and the freeze does not engage until the bound
has already passed, so the no-start block never happens at all. This is not refused at
startup — a controller that will not boot over a tuning nuance is worse than the nuance
— but it is why the default is 3 and why the table above says **do not set to 1** at one
end and leaves you this note at the other.
#### What `freeze-released` in the action log actually tells you
**It says: the inverter's power did not match the command for longer than this plant has
ever been measured to take to settle.** That is all it says. It is a reason to go
looking; it is not a finding, and in particular it is **not** proof that a second writer
exists.
The test behind it compares the reading against **one cycle's** command, and the pack
carries more than one cycle of history. So our own power, arriving late, can look like
somebody else's:
- **A big, fast load step is the ordinary innocent cause.** The command falls through
zero while the pack is still coasting in the old direction, and for a few cycles the
reading has the sign the command *used* to have. Measured, one inverter, nothing else
on the bus: eight cycles after a +1990 W command the command was 0 and the pack still
read +522.3 W.
- **A plant slower than the one that was measured does it too.** The 12-cycle bound is
derived from a 23 s down-ramp. A colder or larger pack that takes longer than that will
produce releases on its own, and no constant read off the 2026-08-22 measurement can
prevent it. In the same container sweep, holding the bound at 12 and lengthening only
the plant's down-ramp, single-inverter releases went from 8 of 320 scenarios at the
measured 23 s to 59 of 320 at 34 s and 268 of 320 at 50 s.
**So, in order:** if you see `freeze-released` **once, during or just after a large load
or PV swing**, that is most likely the loop's own ramp and needs nothing. If you see it
**standing** — cycle after cycle, at a steady load, with a standing grid error the
battery is not correcting — then look for the second writer: another controller, a
scheduler in the inverter's own app, or an installer tool left connected. That is the
configuration §1.5 of the specification rules out, and it is the one this exit exists to
stop the loop stalling on forever.
⚠️ **No `saturation_cycles` you can set removes the block.** This file accepts 1-10, and
all of those are below the 12-cycle release bound, so the freeze always engages before
its own exit is due. (The first version of this feature had the bound at 9, where a
setting of 9 or 10 *did* silently delete the block; moving the bound to 12 closed that
by construction rather than by warning about it.)
#### Why `target_grid_w` is not zero