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:
@@ -0,0 +1,276 @@
|
||||
# SAFETY-13 — the output freeze gets an exit, and only where it cannot end on its own
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-13`, off `release/1.0` @ `c8dcb38`.
|
||||
**Card:** SAFETY-13 — *"Bound the no-start-while-saturated output block"*
|
||||
([Trello](https://trello.com/c/uZjmuF7B)).
|
||||
**Nothing here reached the inverter.** Every figure below is from the pure control
|
||||
law run in a container against a plant model, on this branch and on a pristine
|
||||
`c8dcb38` for the before-figures. No rig, no ESP32, no Home Assistant.
|
||||
|
||||
---
|
||||
|
||||
## 0. The four things a reader should take away
|
||||
|
||||
1. **The card's headline case is real, and the route to it is not the obvious
|
||||
one.** The block does not engage on the way down into an export; the slew
|
||||
limit moves the command off zero before the counter reaches 3. It engages
|
||||
from the **deadband** — a site resting at its target with another writer on
|
||||
the bus, counting saturated cycles while nothing is wrong — and 0 W is then a
|
||||
fixed point. §2.
|
||||
2. **`prev_w == 0` is not a special case, so the fix is not one either.** The
|
||||
same run pins at −800 W against its own integrator standing at −2388 W, at a
|
||||
non-zero `prev_w`, with
|
||||
the same absence of an exit. Zero is only the instance where the pinned
|
||||
magnitude is the whole of what the battery could have done. §3.
|
||||
3. **The decision is not "how long", it is "which".** A divergence a *limit*
|
||||
could have produced clears itself and gets no exit — `release/1.0` unchanged.
|
||||
A divergence a limit could not have produced cannot clear itself and gets
|
||||
one. The predicate is physical, not statistical. §4.
|
||||
4. **Releasing the output freeze does not unfreeze the loop**, and that is what
|
||||
makes the release bounded rather than merely different. §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. What was there, in the terms the code uses
|
||||
|
||||
`control.py`, in `compute()`, after the clamp and the slew limit:
|
||||
|
||||
```python
|
||||
if frozen:
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
```
|
||||
|
||||
"Magnitude may fall, never rise." At `prev_w == 0` the magnitude is zero and
|
||||
`max(target, 0)` forbids any negative command, so a charge cannot **start**.
|
||||
|
||||
The reason is written in the file and it is a good one: `frozen` means the
|
||||
inverter's reported power has diverged from the command by more than
|
||||
`saturation_w` for `saturation_cycles` consecutive cycles, and at a command of
|
||||
zero that means the actuator is producing power nobody in this add-on asked for.
|
||||
Starting to push the other way into it is the configuration §1.5 of the
|
||||
specification rules out — *"any configuration in which two writers can reach one
|
||||
actuator"*.
|
||||
|
||||
**The defect is that it has no end.** Not the error growing, not the integrator
|
||||
moving, not time. `sat_count` stops rising at its cap and the state is a fixed
|
||||
point.
|
||||
|
||||
---
|
||||
|
||||
## 2. Reproducing it — and correcting the route
|
||||
|
||||
Driven at the live install's shape: `max_w 5000`, `slew_w 400`,
|
||||
`target_grid_w -10`, `saturation_w 500`, `saturation_cycles 3`, `gain 0.6`.
|
||||
Another writer holds the pack at **+700 W** whatever is commanded.
|
||||
|
||||
**The route that does NOT reach it.** Start at `prev_w = 0` with 2 kW of export
|
||||
already standing. On `c8dcb38` the loop moves off zero at the slew limit before
|
||||
the counter reaches 3 — commands −400, −800 — so by the time `frozen` is true
|
||||
`prev_w` is −800 and the block that gets applied is `max(target, −800)`. The
|
||||
site charges at 800 W, not 0 W, for as long as the run lasts.
|
||||
|
||||
**The route that does.** The site is *resting*. `grid == target_grid_w`, so the
|
||||
loop is in its deadband and the command is 0 — and `saturated_now` is computed
|
||||
**before** the deadband branch, so the counter climbs anyway. Three cycles later
|
||||
the loop is frozen at `prev_w == 0` with nothing wrong. Then the export arrives:
|
||||
|
||||
| cycle | grid | actual | prev | command | integrator | sat | reason |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 0–1 | −10 | +700 | 0 | 0 | 0 | 1–2 | `deadband` |
|
||||
| 2–5 | −10 | +700 | 0 | **0** | 0 | 3–6 | `saturated-freeze` |
|
||||
| 6 | −2000 | +700 | 0 | **0** | −1194 | 7 | `saturated-freeze` |
|
||||
| 7… | −2000 | +700 | 0 | **0** | −1194 | 8→10 | `saturated-freeze` |
|
||||
|
||||
On `c8dcb38` the command is `0.0` on **every** cycle from there on. The
|
||||
integrator has moved to −1194 W and stopped (its own freeze holds it), the site
|
||||
exports 2 kW, and the battery is idle until the other writer leaves.
|
||||
|
||||
That is the card's sentence — *"the site can sit exporting 2 kW with an idle
|
||||
battery indefinitely"* — reproduced, with the route corrected: it is entered
|
||||
from the **deadband**, not from a correction that ran out of room.
|
||||
|
||||
---
|
||||
|
||||
## 3. …and `prev_w == 0` is not where it stops
|
||||
|
||||
The same tuning, same writer, but the loop starts from zero with the export
|
||||
already standing (the route from §2 that "does not reach it"):
|
||||
|
||||
| | `c8dcb38` |
|
||||
|---|---|
|
||||
| command after the freeze engages | **−800 W**, on every cycle |
|
||||
| the loop's integrator, frozen | −2388 W |
|
||||
| what ends it | nothing |
|
||||
|
||||
So the missing exit is a property of the **output freeze**, not of the zero
|
||||
case. `prev_w == 0` is the instance where the pinned magnitude happens to be
|
||||
zero. A rule written for zero alone would have left −800 W exactly as unbounded
|
||||
as it found it — which is why the change below is written against the freeze and
|
||||
not against a special case of it.
|
||||
|
||||
**And this is the half that escalates at two inverters.** `actual_w` is
|
||||
`site_batt_w`, the SUM (ALLOC-01, DEV-02), so the comparison is a site command
|
||||
against two units' power. One unit still moving after the other has stopped is
|
||||
enough to put the site into the freeze with no external actor at all.
|
||||
|
||||
⚠️ **What that claim is and is not.** The escalation is stated from the code —
|
||||
`prev_w` is `self.target`, the site total, and `actual_w` is the site sum — and
|
||||
from the single-unit runs above. **It is not measured on two inverters.**
|
||||
Inverter B does not physically exist (`NOTES.md` §1.1b: on paper, from datasheet
|
||||
and serial), and `sim/plantsim.py:216` `INVERTER_B` is `INVERTER_A` copied with
|
||||
`measured=False`. Any two-inverter number would be two copies of A.
|
||||
|
||||
---
|
||||
|
||||
## 4. The decision: which freezes get an exit, not how long the block lasts
|
||||
|
||||
Three answers were on the table, and the card named them: lift after N frozen
|
||||
cycles, exempt above some export magnitude, or keep it unbounded and write down
|
||||
the cost.
|
||||
|
||||
**A plain cycle bound was rejected**, because it bounds the wrong thing. The
|
||||
freeze is right to be permanent in one case and cannot be permanent in the
|
||||
other, and a single N treats them alike:
|
||||
|
||||
| the inverter is… | can the freeze clear itself? |
|
||||
|---|---|
|
||||
| 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, `saturated_now` goes false, and the freeze ends. Asking for more would achieve nothing anyway. |
|
||||
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **No.** Clearing the freeze requires the inverter to track *our* command, and the other writer is precisely what stops that. |
|
||||
|
||||
**An export-magnitude exemption was rejected** for a narrower reason: it makes
|
||||
the block's end depend on how big the other actor's disturbance happens to be,
|
||||
which is not a property of the thing the block is about.
|
||||
|
||||
So the rule implemented is:
|
||||
|
||||
> The output freeze applies as before, **unless** the inverter's reported power
|
||||
> cannot be explained by the command not being met **and** the divergence has
|
||||
> outlasted the plant that would otherwise have explained it.
|
||||
|
||||
### 4.1 The predicate is physical
|
||||
|
||||
`unasked_power(prev_w, actual_w, saturation_w)`. A limit can only make the
|
||||
inverter do **less** than the command, in the command's own direction. It cannot
|
||||
make it overshoot the command and it cannot make it produce the opposite sign.
|
||||
So everything a limit can produce lies in the closed interval spanned by `0` and
|
||||
`prev_w`, widened by `saturation_w` for noise, ramp lag and idle draw. Anything
|
||||
outside was put there by something else.
|
||||
|
||||
| prev | actual | interval | verdict |
|
||||
|---|---|---|---|
|
||||
| +2000 | +700 | [0, 2000] | a limit |
|
||||
| −5000 | −700 | [−5000, 0] | a limit |
|
||||
| 0 | +700 | [−500, +500] | **unasked** |
|
||||
| −800 | +700 | [−1300, +500] | **unasked** |
|
||||
|
||||
⚠️ **At `prev_w == 0` this predicate and the saturation detector are the same
|
||||
test** — asserted in `test_control.py` over 801 readings. That is not a
|
||||
coincidence to tidy away: with nothing commanded there is no interval left for a
|
||||
limit to live in, which is exactly why the zero case is the one that strands.
|
||||
|
||||
⚠️ **It is not a detector and nothing alarms on it.** A stale, wrong-signed or
|
||||
noisy reading lands outside the interval too. All it decides is whether the
|
||||
freeze gets an exit; being wrong about it costs a bounded command (§6).
|
||||
SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on sign, and it is
|
||||
deliberately a different test with a different threshold.
|
||||
|
||||
### 4.2 The bound is not a new number
|
||||
|
||||
`SETTLE_CYCLES = ceil(PLANT_SETTLE_S / LOOP_TICK_S)` = **9**.
|
||||
|
||||
`PLANT_SETTLE_S` (18.0 s) and `LOOP_TICK_S` (2.0 s) are SAFETY-39's two measured
|
||||
constants, already in this file. A divergence still standing after the plant's
|
||||
own settling time is not this plant catching up — so after `SETTLE_CYCLES` the
|
||||
plant has run out of alibi. It is the reciprocal of `SLEW_RAIL_FRAC`: the slew
|
||||
limit takes exactly this many ticks to cross the rail, for exactly this reason.
|
||||
|
||||
⚠️ **It inherits SAFETY-39's caveat about `LOOP_TICK_S`, in the opposite
|
||||
direction.** A meter **slower** than 2 s makes 9 cycles cover more than 18 s, so
|
||||
the freeze holds longer than the plant needs: that costs energy, never safety. A
|
||||
meter **faster** makes them cover less, so the freeze can release while the
|
||||
plant is still moving — bounded by §6 rather than by luck.
|
||||
|
||||
`_SAT_COUNT_CAP` is now written as `SETTLE_CYCLES + 1`. Its value is unchanged
|
||||
(10, exactly as shipped); writing it against the bound stops a counter capped
|
||||
below the longest bound read from it, which would silently delete the exit.
|
||||
|
||||
---
|
||||
|
||||
## 5. What actually changed, graded against `release/1.0`
|
||||
|
||||
`test_control.py`'s 3024-case sweep still compares this law against a
|
||||
transcription of `release/1.0` — the transcription was **not** taught the new
|
||||
rule, because an oracle taught the change compares the law against itself. What
|
||||
changed is the assertion: every case must be identical, or be a release with
|
||||
both of its conditions true.
|
||||
|
||||
| | cases |
|
||||
|---|---|
|
||||
| total | 3024 |
|
||||
| **bit-identical to `release/1.0`** | **2592** |
|
||||
| releases | 432 |
|
||||
| …of those, reason-only (the freeze was not binding) | 405 |
|
||||
| …of those, **the command changes** | **27** |
|
||||
|
||||
All 27 are at `prev_w == 0`, all with `release/1.0` commanding exactly `0.0 W`,
|
||||
and every new command is **negative** — a charge start that was refused. Nothing
|
||||
else in the sweep moved.
|
||||
|
||||
The behaviour after the fix, on §2's run: the command holds at 0 W through
|
||||
`sat_count` 3–8, releases on the **ninth** saturated cycle, and walks
|
||||
0 → −400 → −800 → **−1190 W**, where it stops.
|
||||
|
||||
The genuine-limit case, driven for 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. Unchanged, deliberately.
|
||||
|
||||
---
|
||||
|
||||
## 6. Why the release cannot become windup (the card's fourth criterion)
|
||||
|
||||
**The integrator freeze is not lifted with the output freeze.** The command is
|
||||
derived from `i_w`, then clamped, then slew-limited from `prev_w` — so a
|
||||
released cycle can only move the command *toward* an accumulator that stopped
|
||||
accumulating when the freeze engaged, by at most one slew step, and never past
|
||||
it. Swept over 107 625 released states in `test_control.py`: no case leaves the
|
||||
interval between the previous command and the frozen integrator, and no case
|
||||
moves further than one slew step.
|
||||
|
||||
On §2's run that shows up as the settling point: **−1190 W against a frozen
|
||||
integrator of −1194 W**, not the 5000 W rail. The tech lead's note on SAFETY-04
|
||||
— that the carried `i_w` and `release/1.0`'s recomputed value land on the same
|
||||
slew-limited command — is preserved: the `i_w=None` path is still graded against
|
||||
the transcription, cycle for cycle, everywhere except the 432 releases.
|
||||
|
||||
⚠️ **The first form of this check was wrong and the sweep said so**, which is
|
||||
left in the test file. It asserted `|target| <= |i_w|` on a released cycle and
|
||||
produced `prev −5000, i_w −3000, target −4600`: the command was walking back
|
||||
from −5000 toward the integrator and had not arrived. It was moving the right
|
||||
way and the assertion still failed, because "never exceeds the integrator" is
|
||||
not what no-windup means here.
|
||||
|
||||
---
|
||||
|
||||
## 7. What this does NOT buy, said plainly
|
||||
|
||||
**The site still does not reach its target while another writer is on the bus.**
|
||||
It settles at the frozen integrator, not at the meter's target. What the exit
|
||||
buys is that the battery stops being *idle* while the site exports; it does not
|
||||
buy correct regulation against an actuator somebody else is also writing to, and
|
||||
nothing in a rules engine can. A standing grid error with a second writer
|
||||
present is the symptom of a configuration this add-on refuses to support.
|
||||
|
||||
**`freeze-released` in the action log means "look for the second writer."** It is
|
||||
carried on `Decision.reason`, which nothing after the freeze overwrites. No new
|
||||
alarm was added: SAFETY-41 removed one that narrated normal anti-windup, and
|
||||
this state is not yet known to be worth waking anybody for.
|
||||
|
||||
**One configuration removes the block entirely.** `saturation_cycles` is
|
||||
accepted up to 10 by `config.yaml`. At 9 or above the freeze cannot engage
|
||||
before its own exit is due, so the no-start block never happens. This is
|
||||
**not** refused at startup — a controller that will not boot over a tuning
|
||||
nuance is worse than the nuance — but it is asserted in `test_control.py` and
|
||||
written in DOCS.md so that it is a known consequence and not a surprise.
|
||||
|
||||
**Nothing was measured on two inverters.** See the warning in §3.
|
||||
Reference in New Issue
Block a user