SAFETY-13: the output freeze gets an exit, and only where it needs one
checks / unit (pull_request) Successful in 5m30s
checks / unit (pull_request) Successful in 5m30s
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 pristinec8dcb38first), 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
This commit is contained in:
@@ -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** |
|
||||
| `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) |
|
||||
| `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 |
|
||||
@@ -628,6 +628,53 @@ correct toward that wrong value instead of holding a stale command. It is bounde
|
||||
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
|
||||
safety property.
|
||||
|
||||
#### When the freeze lets go, and when it deliberately never does (SAFETY-13)
|
||||
|
||||
While the loop is frozen the command's **magnitude may fall but never rise**. That is
|
||||
the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with
|
||||
nothing commanded there is no magnitude left to fall, so a charge cannot begin. That
|
||||
refusal is deliberate. Commanding 0 W while the inverter reports hundreds of watts
|
||||
means something is driving the actuator that this add-on did not ask, and starting to
|
||||
push power the other way into it is how two controllers end up fighting over one
|
||||
actuator — the configuration §1.5 of the specification rules out.
|
||||
|
||||
**What was missing was an end to it.** Nothing cleared the block: not the error growing,
|
||||
not the integrator moving, not time. The exit is now explicit, and *which* freezes get
|
||||
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 |
|
||||
|
||||
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`).
|
||||
|
||||
**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 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.
|
||||
|
||||
⚠️ **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.
|
||||
|
||||
#### Why `target_grid_w` is not zero
|
||||
|
||||
The deadband is a one-way ratchet: any resting point inside it holds until
|
||||
|
||||
Reference in New Issue
Block a user