SAFETY-13: the bound came from the wrong half of a two-sided measurement
checks / unit (pull_request) Successful in 5m34s
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:
co-authored by
Claude Opus 5
parent
6c4565659b
commit
f156fadac0
@@ -47,20 +47,35 @@ than *how long*:
|
|||||||
command is pinned at something the inverter can reach. It gets no exit, and
|
command is pinned at something the inverter can reach. It gets no exit, and
|
||||||
that is release/1.0's behaviour kept unchanged.
|
that is release/1.0's behaviour kept unchanged.
|
||||||
- A divergence a limit could **not** have produced - the inverter overshooting
|
- A divergence a limit could **not** have produced - the inverter overshooting
|
||||||
the command, or producing the opposite sign - cannot clear itself, because
|
the command, or producing the opposite sign - may not clear itself at all, so
|
||||||
clearing it needs the inverter to track *our* command. That one releases once
|
it gets one. It releases once the divergence has outlasted the plant's own
|
||||||
the divergence has outlasted `PLANT_SETTLE_S`, i.e. `SETTLE_CYCLES` = 9 cycles
|
measured settling time: `SETTLE_CYCLES` = **12** cycles at the measured tick.
|
||||||
at the measured tick. No new constant: it is the reciprocal of SAFETY-39's
|
|
||||||
`SLEW_RAIL_FRAC`.
|
⚠️ **THE BOUND COMES FROM THE SLOWER OF THE TWO DIRECTIONS THE PLANT WAS
|
||||||
|
MEASURED IN.** `NOTES.md` §5.10 timed the same 1.78 kW step both ways - ~18 s
|
||||||
|
up, ~23 s down - so `PLANT_SETTLE_DOWN_S` (23 s) joins `PLANT_SETTLE_S` (18 s)
|
||||||
|
as a named constant. SAFETY-39 uses 18 correctly, because its bound is a
|
||||||
|
*refusal* and the looser number refuses less; this bound asks how long the plant
|
||||||
|
may still be the *explanation*, and there the longer number is the safe one. A
|
||||||
|
bound of 9 derived from 18 fires the exit on a **single inverter with nothing
|
||||||
|
else on the bus** - measured in the container, 62 of 272 load-step pairs at the
|
||||||
|
plant's own measured shape, against 2 at 12.
|
||||||
|
|
||||||
|
⚠️ **`freeze-released` IS A PLACE TO START LOOKING, NOT A FINDING.** It says the
|
||||||
|
divergence outlasted the plant's measured settling time. It is **not** proof of
|
||||||
|
a second writer: a large load or PV swing can leave our own power coasting
|
||||||
|
outside our own command's interval, and a plant slower than the one that was
|
||||||
|
measured does the same. DOCS.md now gives the innocent causes first and the
|
||||||
|
second writer only when the state *stands*.
|
||||||
|
|
||||||
⚠️ **RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP.** The integrator
|
⚠️ **RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP.** The integrator
|
||||||
freeze is untouched, so the command can only walk, one slew step per cycle,
|
freeze is untouched, so the command can only walk, one slew step per cycle,
|
||||||
toward an accumulator that stopped accumulating when the freeze engaged.
|
toward an accumulator that stopped accumulating when the freeze engaged.
|
||||||
Measured at the live install's shape (`max_w 5000`, `slew_w 400`,
|
Measured 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
|
`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, starts on the ninth saturated cycle
|
export standing: the command holds at 0 W, starts on the twelfth saturated
|
||||||
and settles at **-1190 W** against a frozen integrator of -1194 W, not at the
|
cycle and settles at **-1190 W** against a frozen integrator of -1194 W, not at
|
||||||
5000 W rail. On `release/1.0` @ `c8dcb38` the same run held **0.0 W on every
|
the 5000 W rail. On `release/1.0` @ `c8dcb38` the same run held **0.0 W on every
|
||||||
cycle**, for as long as the other writer stayed.
|
cycle**, for as long as the other writer stayed.
|
||||||
|
|
||||||
⚠️ **THIS IS NOT A REGRESSION FIX AND IT ESCALATES AT TWO INVERTERS.** The
|
⚠️ **THIS IS NOT A REGRESSION FIX AND IT ESCALATES AT TWO INVERTERS.** The
|
||||||
@@ -68,13 +83,19 @@ unbounded block is `release/1.0` behaviour, confirmed cycle for cycle against
|
|||||||
SAFETY-04's branch. At a roster of two, `actual_w` is the SITE SUM, so the
|
SAFETY-04's branch. At a roster of two, `actual_w` is the SITE SUM, so the
|
||||||
comparison is a site command against two units' power and one unit still moving
|
comparison is a site command against two units' power and one unit still moving
|
||||||
after the other has stopped can put the site into the block with no external
|
after the other has stopped can put the site into the block with no external
|
||||||
actor at all.
|
actor at all. ⚠️ That escalation is read from the code and from single-unit
|
||||||
|
runs; **it is not measured on two inverters.** Inverter B does not physically
|
||||||
|
exist yet and `sim/plantsim.py`'s `INVERTER_B` is `INVERTER_A` copied with
|
||||||
|
`measured=False`, so any two-inverter figure would be two copies of A.
|
||||||
|
|
||||||
Graded against `release/1.0` over the 3024-case sweep in `test_control.py`:
|
Graded against `release/1.0` over the 3780-case sweep in `test_control.py`:
|
||||||
**2592 cases bit-identical, 432 releases, and 27 of those change the command** -
|
**3267 cases bit-identical, 432 releases, 27 of those change the command** -
|
||||||
every one of them a charge from `prev_w == 0` that `release/1.0` refused to
|
every one a charge from `prev_w == 0` that `release/1.0` refused to start - and
|
||||||
start. `saturation_cycles` at 9 or 10 removes the block entirely; DOCS.md says
|
**81 differ in the saturation counter alone**, because its ceiling moved with
|
||||||
so.
|
the bound from 10 to 13. That ceiling change also removes a degenerate setting:
|
||||||
|
`config.yaml` accepts `saturation_cycles` up to 10, which is now strictly below
|
||||||
|
the release bound, so **no accepted configuration can stop the block engaging**.
|
||||||
|
At the old bound of 9 a setting of 9 or 10 deleted it silently.
|
||||||
|
|
||||||
⚠️ **A PER-INVERTER POWER READING THAT HAS STOPPED FOLLOWING ITS OWN COMMAND IS
|
⚠️ **A PER-INVERTER POWER READING THAT HAS STOPPED FOLLOWING ITS OWN COMMAND IS
|
||||||
NOW `None`, NOT A HELD NUMBER ([[SAFETY-37]]).** `Inverter.batt` (register
|
NOW `None`, NOT A HELD NUMBER ([[SAFETY-37]]).** `Inverter.batt` (register
|
||||||
|
|||||||
@@ -24,10 +24,14 @@ law run in a container against a plant model, on this branch and on a pristine
|
|||||||
magnitude is the whole of what the battery could have done. §3.
|
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*
|
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.
|
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
|
A divergence a limit could not have produced may not, and gets one. The
|
||||||
one. The predicate is physical, not statistical. §4.
|
predicate is physical, not statistical — and it is **not** a second-writer
|
||||||
|
detector; §4.1 and §8 say what it can and cannot tell apart.
|
||||||
4. **Releasing the output freeze does not unfreeze the loop**, and that is what
|
4. **Releasing the output freeze does not unfreeze the loop**, and that is what
|
||||||
makes the release bounded rather than merely different. §6.
|
makes the release bounded rather than merely different. §6.
|
||||||
|
5. **The bound comes from the SLOWER half of a two-sided measurement, and the
|
||||||
|
first version of this branch took the faster half.** §8 is the correction and
|
||||||
|
the measurement behind it; it is the reason `SETTLE_CYCLES` is 12 and not 9.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -154,8 +158,16 @@ So the rule implemented is:
|
|||||||
inverter do **less** than the command, in the command's own direction. It cannot
|
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.
|
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
|
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
|
`prev_w`, widened by `saturation_w` for noise, ramp lag and idle draw.
|
||||||
outside was put there by something else.
|
|
||||||
|
⚠️ **What it does NOT establish is that anything outside was put there by
|
||||||
|
something else.** The first version of this branch said exactly that, in the
|
||||||
|
docstring, in `control.py` and in DOCS.md, and it is false: the predicate
|
||||||
|
compares against **one cycle's** command while the pack carries every command
|
||||||
|
before it, so our own coast-down through zero lands outside our own interval.
|
||||||
|
§8 measures it. What the predicate guarantees is only its own first sentence —
|
||||||
|
*this cycle's command does not account for this cycle's reading* — and the time
|
||||||
|
bound below is what turns that into a reason to act.
|
||||||
|
|
||||||
| prev | actual | interval | verdict |
|
| prev | actual | interval | verdict |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -175,51 +187,87 @@ 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
|
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.
|
deliberately a different test with a different threshold.
|
||||||
|
|
||||||
### 4.2 The bound is not a new number
|
### 4.2 The bound, and which half of the measurement it comes from
|
||||||
|
|
||||||
`SETTLE_CYCLES = ceil(PLANT_SETTLE_S / LOOP_TICK_S)` = **9**.
|
`SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)` = **12**.
|
||||||
|
|
||||||
`PLANT_SETTLE_S` (18.0 s) and `LOOP_TICK_S` (2.0 s) are SAFETY-39's two measured
|
⚠️ **`PLANT_SETTLE_DOWN_S` is new, and adding it is the point rather than a
|
||||||
constants, already in this file. A divergence still standing after the plant's
|
cost.** `NOTES.md` §5.10 did not measure *a* settling time; it measured the same
|
||||||
own settling time is not this plant catching up — so after `SETTLE_CYCLES` the
|
1.78 kW step in **both directions** — "Up: ~18 s from step to deadband. Down:
|
||||||
plant has run out of alibi. It is the reciprocal of `SLEW_RAIL_FRAC`: the slew
|
~23 s from a −1784 W export excursion" (`NOTES.md:1550`). So the plant's
|
||||||
limit takes exactly this many ticks to cross the rail, for exactly this reason.
|
settling time is a **pair**, and which half is the safe one depends on which way
|
||||||
|
being wrong hurts:
|
||||||
|
|
||||||
|
| the bound | the question | the safe half | why |
|
||||||
|
|---|---|---|---|
|
||||||
|
| SAFETY-39's `SLEW_RAIL_FRAC` | may this configuration be **refused**? | **18 s**, the shorter | a looser bound refuses fewer working configurations |
|
||||||
|
| this one | may the plant still be the **explanation**? | **23 s**, the longer | a shorter bound stops believing the plant too early, and then it accuses |
|
||||||
|
|
||||||
|
The first version of this branch reused `PLANT_SETTLE_S` and got 9 — three
|
||||||
|
cycles short of the plant's own measured down-ramp, on the side that produces a
|
||||||
|
false positive. §8 is the measurement and the correction.
|
||||||
|
|
||||||
|
⚠️ **It is no longer the reciprocal of `SLEW_RAIL_FRAC`, and it never should
|
||||||
|
have been read as one.** The two were equal only while both were being derived
|
||||||
|
from the same half of the pair, and that coincidence read as a derivation. They
|
||||||
|
are 9 and 12 now; `test_control.py` asserts the inequality so they cannot be
|
||||||
|
quietly re-fused.
|
||||||
|
|
||||||
⚠️ **It inherits SAFETY-39's caveat about `LOOP_TICK_S`, in the opposite
|
⚠️ **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
|
direction.** A meter **slower** than 2 s makes 12 cycles cover more than 23 s, so
|
||||||
the freeze holds longer than the plant needs: that costs energy, never safety. A
|
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
|
meter **faster** makes them cover less, so the freeze can release while the
|
||||||
plant is still moving — bounded by §6 rather than by luck.
|
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
|
**`_SAT_COUNT_CAP` moved with it, from 10 to 13**, because it is written as
|
||||||
(10, exactly as shipped); writing it against the bound stops a counter capped
|
`SETTLE_CYCLES + 1` — a counter capped below the longest bound read from it
|
||||||
below the longest bound read from it, which would silently delete the exit.
|
would silently delete the exit. That has a consequence worth stating on its own:
|
||||||
|
`config.yaml` accepts `saturation_cycles` as `int(1,10)`, so at the old bound of
|
||||||
|
9 a legal setting could sit **above** the exit and delete the block. At 12 the
|
||||||
|
whole accepted range is strictly below the bound, and the degenerate
|
||||||
|
configuration this branch used to document no longer exists. §7.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. What actually changed, graded against `release/1.0`
|
## 5. What actually changed, graded against `release/1.0`
|
||||||
|
|
||||||
`test_control.py`'s 3024-case sweep still compares this law against a
|
`test_control.py`'s sweep still compares this law against a transcription of
|
||||||
transcription of `release/1.0` — the transcription was **not** taught the new
|
`release/1.0` — the transcription was **not** taught the new rule, because an
|
||||||
rule, because an oracle taught the change compares the law against itself. What
|
oracle taught the change compares the law against itself. What changed is the
|
||||||
changed is the assertion: every case must be identical, or be a release with
|
assertion: every case must be identical, or be a release with both of its
|
||||||
both of its conditions true.
|
conditions true, or differ **in the saturation counter alone**.
|
||||||
|
|
||||||
|
⚠️ **The sweep had to be widened to keep grading anything.** Its `sat_count`
|
||||||
|
fixtures ran to 9; with the bound at 12 the counter never reached the exit and
|
||||||
|
every release silently vanished from the table, which is what the assertion on
|
||||||
|
the count caught. It runs to 12 now, at 3780 cases.
|
||||||
|
|
||||||
| | cases |
|
| | cases |
|
||||||
|---|---|
|
|---|---|
|
||||||
| total | 3024 |
|
| total | 3780 |
|
||||||
| **bit-identical to `release/1.0`** | **2592** |
|
| **bit-identical to `release/1.0`** | **3267** |
|
||||||
| releases | 432 |
|
| releases | 432 |
|
||||||
| …of those, reason-only (the freeze was not binding) | 405 |
|
| …of those, reason-only (the freeze was not binding) | 405 |
|
||||||
| …of those, **the command changes** | **27** |
|
| …of those, **the command changes** | **27** |
|
||||||
|
| differ in the **saturation counter alone** | 81 |
|
||||||
|
|
||||||
All 27 are at `prev_w == 0`, all with `release/1.0` commanding exactly `0.0 W`,
|
All 27 command changes are at `prev_w == 0`, all with `release/1.0` commanding
|
||||||
and every new command is **negative** — a charge start that was refused. Nothing
|
exactly `0.0 W`, and every new command is **negative** — a charge start that was
|
||||||
else in the sweep moved.
|
refused.
|
||||||
|
|
||||||
|
⚠️ **The 81 are the counter's ceiling moving, and they reach nothing.**
|
||||||
|
`_SAT_COUNT_CAP` went from 10 to 13 with the bound, so above `release/1.0`'s own
|
||||||
|
ceiling the two counters differ. Nothing else may differ in those cases, and the
|
||||||
|
sweep asserts that: `frozen` is `sat_count >= saturation_cycles`, `config.yaml`
|
||||||
|
accepts that at most 10, and no other line in `compute()` reads the counter's
|
||||||
|
value — so a counter reading 13 instead of 10 cannot change what is commanded.
|
||||||
|
The 81 all sit at an `sc` above 10, which is a state `release/1.0` could never
|
||||||
|
hold at all.
|
||||||
|
|
||||||
The behaviour after the fix, on §2's run: the command holds at 0 W through
|
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
|
`sat_count` 3–11, releases on the **twelfth** saturated cycle, and walks
|
||||||
0 → −400 → −800 → **−1190 W**, where it stops.
|
0 → −400 → −800 → **−1190 W**, where it stops — the same settling point as at
|
||||||
|
the old bound, three cycles later.
|
||||||
|
|
||||||
The genuine-limit case, driven for 30 cycles with the inverter curtailed to
|
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
|
±1000 W against a 4 kW import: `freeze-released` never appears and the command
|
||||||
@@ -233,9 +281,13 @@ stays pinned at +2400 W. Unchanged, deliberately.
|
|||||||
derived from `i_w`, then clamped, then slew-limited from `prev_w` — so a
|
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
|
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
|
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
|
it. `test_control.py` sweeps 107 625 states, of which **67 500 release**: no
|
||||||
interval between the previous command and the frozen integrator, and no case
|
released case leaves the interval between the previous command and the frozen
|
||||||
moves further than one slew step.
|
integrator, and none moves further than one slew step.
|
||||||
|
|
||||||
|
⚠️ **107 625 was previously reported as the number of released states. It is
|
||||||
|
the number of states swept**, and the assertion only ever graded the subset that
|
||||||
|
released. Both numbers are counted and printed now.
|
||||||
|
|
||||||
On §2's run that shows up as the settling point: **−1190 W against a frozen
|
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
|
integrator of −1194 W**, not the 5000 W rail. The tech lead's note on SAFETY-04
|
||||||
@@ -261,16 +313,84 @@ 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
|
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.
|
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
|
**`freeze-released` is a place to start looking, not a finding.** It says the
|
||||||
carried on `Decision.reason`, which nothing after the freeze overwrites. No new
|
divergence outlasted the plant's own measured settling time, and that is all it
|
||||||
alarm was added: SAFETY-41 removed one that narrated normal anti-windup, and
|
says — §8. DOCS.md now tells the operator the innocent causes first (a large
|
||||||
this state is not yet known to be worth waking anybody for.
|
load or PV swing, a plant slower than the one that was measured) and the second
|
||||||
|
writer only when the state **stands**. 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
|
**No accepted configuration removes the block any more.** `config.yaml` accepts
|
||||||
accepted up to 10 by `config.yaml`. At 9 or above the freeze cannot engage
|
`saturation_cycles` as `int(1,10)`; the bound is 12, so every accepted value is
|
||||||
before its own exit is due, so the no-start block never happens. This is
|
strictly below it and the freeze always engages before its exit is due. This
|
||||||
**not** refused at startup — a controller that will not boot over a tuning
|
paragraph used to say the opposite — at a bound of 9, settings of 9 and 10
|
||||||
nuance is worse than the nuance — but it is asserted in `test_control.py` and
|
deleted the block silently and the branch documented that as a known
|
||||||
written in DOCS.md so that it is a known consequence and not a surprise.
|
consequence. Moving the bound closed it by construction. `test_control.py` pins
|
||||||
|
it against `config.yaml`'s own schema line rather than a transcription of it.
|
||||||
|
|
||||||
**Nothing was measured on two inverters.** See the warning in §3.
|
**Nothing was measured on two inverters.** See the warning in §3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. ⚠️ ROUND 2: the bound was derived from the wrong half, and it accused
|
||||||
|
|
||||||
|
**What was wrong.** `SETTLE_CYCLES` was `ceil(PLANT_SETTLE_S / LOOP_TICK_S)` = 9.
|
||||||
|
`PLANT_SETTLE_S` is 18 s, the **up**-ramp of `NOTES.md` §5.10's kettle step;
|
||||||
|
the same measurement recorded ~23 s coming back **down** (`NOTES.md:1550`).
|
||||||
|
SAFETY-39 takes 18 correctly — its bound is a refusal, and the looser number
|
||||||
|
refuses less. This bound asks the opposite question, and the looser number stops
|
||||||
|
believing the plant three cycles too early.
|
||||||
|
|
||||||
|
**Why that is not a tuning quibble.** The predicate has no memory. `prev_w` is
|
||||||
|
one cycle's command; the pack carries every command before it. A command that
|
||||||
|
falls **through zero** — the ordinary end of a load or PV step — leaves the pack
|
||||||
|
coasting in the old direction, so the reading has the sign the command *used* to
|
||||||
|
have and lands outside the interval spanned by 0 and the new `prev_w`.
|
||||||
|
`unasked_power()` calls that "unasked". With the bound three cycles short of the
|
||||||
|
plant's own down-ramp, the counter reaches it while the coast-down is still
|
||||||
|
going, and the law emits `freeze-released` on **one inverter with nothing else
|
||||||
|
on the bus.**
|
||||||
|
|
||||||
|
**Measured, in the container, N = 1.** One `compute()`, one plant model, one
|
||||||
|
command; no second writer anywhere in the harness. The plant is a first-order
|
||||||
|
lag whose two time constants come from the two measured settling times
|
||||||
|
(tau = t/3, since a first-order step is 95 % done at 3 tau) with
|
||||||
|
`sim/plantsim.py`'s measured 800 W/s ramp cap. It is a **model**, and it is
|
||||||
|
labelled one wherever it is used.
|
||||||
|
|
||||||
|
| | bound 9 | bound 12 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1280 closed-loop scenarios (a grid of plant pairs, slews, `saturation_cycles`, load steps) | **113 fire** | **24 fire** |
|
||||||
|
| 272 load-step pairs at the measured 18 s / 23 s plant, `slew_w 400` | **62 fire** | **2 fire** |
|
||||||
|
|
||||||
|
The single case now pinned in `test_control.py`: a 4 kW export falling to
|
||||||
|
2.5 kW. At cycle 34 the command is −310 W, 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, `sat_count` is 9, and the law says `freeze-released`. At 12 the
|
||||||
|
same run never releases.
|
||||||
|
|
||||||
|
**What the fix does not fix.** The bound makes the false positive rare on *the
|
||||||
|
plant that was measured*. It cannot make it impossible, because it is a
|
||||||
|
measurement and the 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 residual is why the
|
||||||
|
three statements below were corrected rather than merely re-numbered.
|
||||||
|
|
||||||
|
**Three shipped statements were falsified by this and are now rewritten:**
|
||||||
|
|
||||||
|
1. `unasked_power()`'s docstring — *"Anything outside that interval was put
|
||||||
|
there by something other than this loop."* It was our own power. The
|
||||||
|
docstring now says what the predicate does guarantee (this cycle's command
|
||||||
|
does not account for this cycle's reading) and states the coast-down as its
|
||||||
|
known blind spot.
|
||||||
|
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 a section that gives the innocent causes first and the second
|
||||||
|
writer only when the state stands.
|
||||||
|
|||||||
+58
-17
@@ -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 |
|
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||||||
| `step_w` | 10 | Quantisation |
|
| `step_w` | 10 | Quantisation |
|
||||||
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| 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 **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
|
The second row's threshold is the plant's own measured settling time — **the slower of
|
||||||
long this plant takes to settle, so a divergence still standing after that is not this
|
the two directions it was measured in.** The 2026-08-22 step test timed the same 1.78 kW
|
||||||
plant catching up; at the measured 2 s meter tick that is **9 cycles**, which is the
|
step both ways: about 18 s to settle going up, about 23 s coming back down. 23 s is the
|
||||||
same relationship the slew limit already uses (`control.py`, `SETTLE_CYCLES`).
|
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
|
**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
|
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
|
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
|
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
|
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**
|
the counter climbs, starts on the **twelfth** saturated cycle, walks 0 → −400 → −800, and
|
||||||
against a frozen integrator of −1194 W — not at the 5000 W rail. Before this change the
|
settles at **−1190 W** against a frozen integrator of −1194 W — not at the 5000 W rail.
|
||||||
same run held 0.0 W on every cycle, for as long as the other writer stayed.
|
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
|
⚠️ **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;
|
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
|
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
|
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
|
configuration this add-on refuses to support, not a tuning problem.
|
||||||
`freeze-released` in the action log, look for the second writer.
|
|
||||||
|
|
||||||
⚠️ **One configuration removes the block entirely, silently.** `saturation_cycles` is
|
#### What `freeze-released` in the action log actually tells you
|
||||||
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
|
**It says: the inverter's power did not match the command for longer than this plant has
|
||||||
startup — a controller that will not boot over a tuning nuance is worse than the nuance
|
ever been measured to take to settle.** That is all it says. It is a reason to go
|
||||||
— but it is why the default is 3 and why the table above says **do not set to 1** at one
|
looking; it is not a finding, and in particular it is **not** proof that a second writer
|
||||||
end and leaves you this note at the other.
|
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
|
#### Why `target_grid_w` is not zero
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ from dataclasses import dataclass
|
|||||||
# validate.py refuse the impossible case only, never "close
|
# validate.py refuse the impossible case only, never "close
|
||||||
# to the rail".
|
# to the rail".
|
||||||
#
|
#
|
||||||
|
# ⚠️ AND THAT CHOICE IS LOCAL TO THIS BOUND. §5.10 MEASURED THE STEP IN BOTH
|
||||||
|
# DIRECTIONS, so "the plant's settling time" is a PAIR and not a number, and
|
||||||
|
# which half is the safe one depends on which way being wrong hurts. Taking
|
||||||
|
# 18 for a REFUSAL is right: the looser number refuses less. Taking 18 for a
|
||||||
|
# question of the form "how long does the plant remain a candidate
|
||||||
|
# explanation" is WRONG, because the looser number stops believing the plant
|
||||||
|
# too early - and there the answer is a false accusation. SAFETY-13 asks that
|
||||||
|
# second question and so reads PLANT_SETTLE_DOWN_S below, deliberately not
|
||||||
|
# this constant. Anything else added here has to say which of the two it
|
||||||
|
# means and why.
|
||||||
|
#
|
||||||
# LOOP_TICK_S is the METER, not this loop. `run_control()` runs a cycle on
|
# 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
|
# 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
|
# first, so the tick is the meter's own update rate. 2.0 s is
|
||||||
@@ -87,6 +98,13 @@ PLANT_SETTLE_S = 18.0
|
|||||||
LOOP_TICK_S = 2.0
|
LOOP_TICK_S = 2.0
|
||||||
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S # 0.111..., i.e. 9 ticks
|
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S # 0.111..., i.e. 9 ticks
|
||||||
|
|
||||||
|
# The other half of the same measurement: the descent of the same 1.78 kW step,
|
||||||
|
# ~23 s from a -1784 W export excursion back into the deadband (goodwe/NOTES.md
|
||||||
|
# §5.10, the same run, the same day, the same plug). It is slower than the ramp
|
||||||
|
# up, which is the ordinary asymmetry of a pack that is pushed one way and
|
||||||
|
# coasts the other. Nothing above this line reads it; SAFETY-13 below does.
|
||||||
|
PLANT_SETTLE_DOWN_S = 23.0
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SAFETY-13. HOW LONG THE PLANT STAYS A CANDIDATE EXPLANATION.
|
# SAFETY-13. HOW LONG THE PLANT STAYS A CANDIDATE EXPLANATION.
|
||||||
#
|
#
|
||||||
@@ -100,42 +118,78 @@ SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S # 0.111..., i.e. 9 ticks
|
|||||||
# at something the inverter can do, so the inverter catches up to it and
|
# at something the inverter can do, so the inverter catches up to it and
|
||||||
# `saturated_now` goes false.
|
# `saturated_now` goes false.
|
||||||
#
|
#
|
||||||
|
# ⚠️ AND (a) INCLUDES THE CASE `unasked_power()` BELOW CANNOT SEE AS (a):
|
||||||
|
# the command has fallen THROUGH ZERO and the pack is still coasting in
|
||||||
|
# the old direction, so the reading has the sign the command used to have
|
||||||
|
# and not the one it has now. That is this loop's own power, arriving
|
||||||
|
# late. It is the reason the time bound is what does the work here and
|
||||||
|
# the predicate alone is not enough.
|
||||||
|
#
|
||||||
# (b) SOMETHING ELSE IS DRIVING THE ACTUATOR - the §1.5 non-goal, two writers
|
# (b) SOMETHING ELSE IS DRIVING THE ACTUATOR - the §1.5 non-goal, two writers
|
||||||
# on one actuator. The freeze CANNOT clear itself here, because clearing
|
# on one actuator. The freeze CANNOT clear itself here, because clearing
|
||||||
# it needs the inverter to track OUR command and the other writer is what
|
# it needs the inverter to track OUR command and the other writer is what
|
||||||
# stops it. Nothing ends this but the other writer going away.
|
# stops it. Nothing ends this but the other writer going away.
|
||||||
#
|
#
|
||||||
# ⚠️ SO THE BOUND BELONGS TO (b) AND ONLY TO (b), and this constant is what
|
# ⚠️ SO THE BOUND IS ONLY EVER A REASON TO STOP BELIEVING (a). It does not
|
||||||
# separates them in TIME rather than by guessing. Case (a) is bounded by the
|
# establish (b) and nothing below claims it does: what it says is that the
|
||||||
# plant: PLANT_SETTLE_S is how long this plant takes to settle, measured, and
|
# plant has run out of alibi, and (b) is then the only explanation this loop
|
||||||
# after that the plant has run out of alibi. A divergence still standing after
|
# has left - not the only one there is. Everything downstream of the release is
|
||||||
# PLANT_SETTLE_S is not this plant catching up.
|
# written to be safe when that inference is wrong, because sometimes it is.
|
||||||
#
|
#
|
||||||
# SETTLE_CYCLES = ceil(PLANT_SETTLE_S / LOOP_TICK_S)
|
# Case (a) is bounded by the plant, and the plant was measured:
|
||||||
#
|
#
|
||||||
# ⚠️ THE SAME TWO MEASURED NUMBERS SAFETY-39 USES, AND DELIBERATELY NOT A NEW
|
# SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)
|
||||||
# ONE. It is the reciprocal of SLEW_RAIL_FRAC - the slew limit takes exactly
|
|
||||||
# this many ticks to traverse the rail, for exactly this reason. A new constant
|
|
||||||
# here would be a second thing to re-derive when the plant is re-measured, and
|
|
||||||
# a number that outlives its derivation is the defect SAFETY-39 was about.
|
|
||||||
#
|
#
|
||||||
# ⚠️ AND IT INHERITS SAFETY-39's CAVEAT ABOUT LOOP_TICK_S, in the opposite
|
# ⚠️ THE **DOWN** NUMBER, AND THE DIRECTION IS THE WHOLE POINT. §5.10 measured
|
||||||
|
# the same step both ways - 18 s up, 23 s down - and SAFETY-39 takes 18 because
|
||||||
|
# 18 is the LOOSER bound for a REFUSAL. This is the opposite job. Here the
|
||||||
|
# question is "how long may the plant still be the explanation", and the
|
||||||
|
# conservative answer is the LONGER of the two: believe the plant for as long
|
||||||
|
# as the plant has ever been observed to take. Deriving this from 18 gives 9,
|
||||||
|
# which is three cycles SHORT of the plant's own measured down-ramp - and being
|
||||||
|
# short here does not refuse anything, it ACCUSES. Measured, single inverter,
|
||||||
|
# no second writer anywhere, 1280 closed-loop scenarios: at 9 the exit fires in
|
||||||
|
# 113 of them and at 12 in 24. The direction of that error is why the pair is
|
||||||
|
# now two named constants instead of one.
|
||||||
|
#
|
||||||
|
# ⚠️ AND IT IS NO LONGER THE RECIPROCAL OF SLEW_RAIL_FRAC. It was, at 9, and
|
||||||
|
# that coincidence read as a derivation - two different questions that happened
|
||||||
|
# to share an answer while both were being asked of the same half of a two-
|
||||||
|
# sided measurement. They are 9 and 12 now. If they ever coincide again it is
|
||||||
|
# arithmetic, not agreement.
|
||||||
|
#
|
||||||
|
# ⚠️ IT INHERITS SAFETY-39's CAVEAT ABOUT LOOP_TICK_S, in the opposite
|
||||||
# direction, which is why it is repeated rather than cross-referenced. A meter
|
# direction, which is why it is repeated rather than cross-referenced. A meter
|
||||||
# SLOWER than 2 s makes these 9 cycles cover MORE than 18 s, so the freeze
|
# SLOWER than 2 s makes these 12 cycles cover MORE than 23 s, so the freeze
|
||||||
# holds longer than the plant needs: that costs energy - see the case in
|
# holds longer than the plant needs: that costs energy - see the case in
|
||||||
# DOCS.md - and never safety. A meter FASTER than 2 s makes them cover LESS,
|
# DOCS.md - and never safety. A meter FASTER than 2 s makes them cover LESS,
|
||||||
# so the freeze can release while the plant is genuinely still moving. That one
|
# so the freeze can release while the plant is genuinely still moving. That one
|
||||||
# is bounded twice over and by neither this constant nor luck: the INTEGRATOR
|
# is bounded twice over and by neither this constant nor luck: the INTEGRATOR
|
||||||
# freeze is not released with the output freeze, so the command can only reach
|
# freeze is not released with the output freeze, so the command can only reach
|
||||||
# the integrator's frozen value, and the output clamp bounds that in turn.
|
# the integrator's frozen value, and the output clamp bounds that in turn.
|
||||||
SETTLE_CYCLES = math.ceil(PLANT_SETTLE_S / LOOP_TICK_S) # 9
|
#
|
||||||
|
# ⚠️ AND A PLANT SLOWER THAN THE ONE THAT WAS MEASURED DOES THE SAME THING,
|
||||||
|
# which no constant derived from a measurement can fix. In the same 1280
|
||||||
|
# scenarios, holding the bound at 12 and lengthening only the plant's down-ramp
|
||||||
|
# past the measured 23 s, the exit starts firing again on a single inverter:
|
||||||
|
# 8/320 at 23 s, 59/320 at 34 s, 268/320 at 50 s. A re-measured plant means a
|
||||||
|
# re-derived constant here, exactly as for the two above it.
|
||||||
|
SETTLE_CYCLES = math.ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) # 12
|
||||||
|
|
||||||
# The saturation counter's ceiling. It has always been 10 and still is; it is
|
# The saturation counter's ceiling, one above the longest bound read from it. A
|
||||||
# written against SETTLE_CYCLES rather than as a literal so that a counter
|
# counter capped BELOW that bound would silently make the bound unreachable -
|
||||||
# capped BELOW the longest bound read from it - which would silently make that
|
# i.e. delete the exit condition - so it is written against SETTLE_CYCLES and
|
||||||
# bound unreachable, i.e. delete the exit condition - cannot happen by editing
|
# not as a literal, and it moves when SETTLE_CYCLES moves.
|
||||||
# one of the two.
|
#
|
||||||
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 10
|
# ⚠️ IT WAS 10, WHICH IS ALSO config.yaml's MAXIMUM FOR `saturation_cycles`,
|
||||||
|
# AND THAT COLLISION IS GONE. At a bound of 9 the largest accepted
|
||||||
|
# `saturation_cycles` (10) sat ABOVE the exit, so a legal configuration could
|
||||||
|
# push the freeze's engagement past its own release and the no-start block
|
||||||
|
# stopped existing. At 12 every accepted value 1..10 is strictly below the
|
||||||
|
# bound, so the freeze always engages before its exit is due and no accepted
|
||||||
|
# configuration can delete the block. `test_control.py` pins that at the schema
|
||||||
|
# edge rather than in prose, because it is a property of two files agreeing.
|
||||||
|
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -237,11 +291,11 @@ def slew_limit_w(tuning: Tuning) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def unasked_power(prev_w: float, actual_w: float, saturation_w: float) -> bool:
|
def unasked_power(prev_w: float, actual_w: float, saturation_w: float) -> bool:
|
||||||
"""True when `actual_w` cannot be explained by the command not being met.
|
"""True when `actual_w` cannot be explained by THIS cycle's command not being met.
|
||||||
|
|
||||||
SAFETY-13. The one question the saturation magnitude test cannot answer:
|
SAFETY-13. The one question the saturation magnitude test cannot answer:
|
||||||
is the inverter FAILING TO REACH what we asked, or is it doing something we
|
is the inverter FAILING TO REACH what we asked, or is it doing something
|
||||||
never asked for at all?
|
the command in front of it does not account for?
|
||||||
|
|
||||||
⚠️ THE ANSWER IS PHYSICAL, NOT STATISTICAL, WHICH IS WHY IT IS A PREDICATE
|
⚠️ THE ANSWER IS PHYSICAL, NOT STATISTICAL, WHICH IS WHY IT IS A PREDICATE
|
||||||
AND NOT A THRESHOLD. A limit can only make the inverter do LESS than the
|
AND NOT A THRESHOLD. A limit can only make the inverter do LESS than the
|
||||||
@@ -249,8 +303,28 @@ def unasked_power(prev_w: float, actual_w: float, saturation_w: float) -> bool:
|
|||||||
command, and it cannot make it produce power of the OPPOSITE sign. So the
|
command, and it cannot make it produce power of the OPPOSITE sign. So the
|
||||||
whole set of readings a limit can produce is the closed interval spanned by
|
whole set of readings a limit can produce is the closed interval spanned by
|
||||||
0 and `prev_w`, widened by `saturation_w` for measurement noise, ramp lag
|
0 and `prev_w`, widened by `saturation_w` for measurement noise, ramp lag
|
||||||
and the inverter's own idle draw. Anything outside that interval was put
|
and the inverter's own idle draw.
|
||||||
there by something other than this loop.
|
|
||||||
|
⚠️ WHAT IT DOES NOT SAY - AND AN EARLIER VERSION OF THIS DOCSTRING DID -
|
||||||
|
IS THAT ANYTHING OUTSIDE THE INTERVAL WAS PUT THERE BY SOMETHING OTHER THAN
|
||||||
|
THIS LOOP. THAT IS FALSE AND IT WAS MEASURED FALSE. This predicate has no
|
||||||
|
memory: `prev_w` is one cycle's command, while the plant carries every
|
||||||
|
command before it. Drive ONE inverter, with nothing else on the bus, from a
|
||||||
|
discharge down through zero, and the pack is still coasting in the old
|
||||||
|
direction after the command has crossed. Measured in the container, one
|
||||||
|
`compute()` against one plant: eight cycles after a +1990 W command the
|
||||||
|
command is 0 and the pack still reads +522.3 W, so
|
||||||
|
`unasked_power(0, 522.3, 500)` is True - and every watt of that 522.3 was
|
||||||
|
asked for by this loop. In 1280 single-inverter closed-loop scenarios with
|
||||||
|
no second writer in them at all, the exit built on this predicate fires in
|
||||||
|
24 of them.
|
||||||
|
|
||||||
|
So what it guarantees is exactly the sentence at the top: this cycle's
|
||||||
|
command does not account for this cycle's reading. What it CANNOT do is
|
||||||
|
tell "somebody else is writing" from "we are". That gap is the reason
|
||||||
|
SETTLE_CYCLES is derived from the plant's slower direction, why the caller
|
||||||
|
checks both, and why the reason string it produces is a place to start
|
||||||
|
looking rather than a finding.
|
||||||
|
|
||||||
Worked, at `saturation_w = 500`:
|
Worked, at `saturation_w = 500`:
|
||||||
|
|
||||||
@@ -262,16 +336,19 @@ def unasked_power(prev_w: float, actual_w: float, saturation_w: float) -> bool:
|
|||||||
⚠️ AT `prev_w == 0` THIS IS EXACTLY `saturated_now`, and that is not a
|
⚠️ AT `prev_w == 0` THIS IS EXACTLY `saturated_now`, and that is not a
|
||||||
coincidence to be tidied away - it is the reason the freeze's zero case is
|
coincidence to be tidied away - it is the reason the freeze's zero case is
|
||||||
the one the card was written about. With nothing commanded there is no
|
the one the card was written about. With nothing commanded there is no
|
||||||
interval left for a limit to live in, so every saturated cycle at zero is a
|
interval left for a LIMIT to live in, so at zero this test cannot be
|
||||||
cycle in which the actuator is being driven by someone else.
|
answered by "the inverter is not reaching what we asked" at all. It can
|
||||||
|
still be answered by "the inverter has not finished leaving what we asked
|
||||||
|
for LAST time", which is the coast-down above and is why zero needs the
|
||||||
|
time bound as much as any other `prev_w` does.
|
||||||
|
|
||||||
⚠️ IT IS NOT A DETECTOR AND MUST NOT BE ALARMED ON. A saturated inverter
|
⚠️ IT IS NOT A DETECTOR AND MUST NOT BE ALARMED ON. A saturated inverter
|
||||||
whose reading is stale, wrong-signed or simply noisy lands outside the
|
whose reading is stale, wrong-signed or simply noisy lands outside the
|
||||||
interval too. All this predicate decides is whether the output freeze gets
|
interval too, and so does our own ramp, above. All this predicate decides
|
||||||
an EXIT; being wrong about it costs at most a bounded command that the
|
is whether the output freeze gets an EXIT; being wrong about it costs at
|
||||||
frozen integrator and the output clamp both still bound. SAFETY-30's
|
most a bounded command that the frozen integrator and the output clamp both
|
||||||
`unit_sign_disagree_w` is the thing that alarms on sign, and it is
|
still bound. SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on
|
||||||
deliberately a different test with a different threshold.
|
sign, and it is deliberately a different test with a different threshold.
|
||||||
"""
|
"""
|
||||||
lo = min(0.0, float(prev_w)) - abs(float(saturation_w))
|
lo = min(0.0, float(prev_w)) - abs(float(saturation_w))
|
||||||
hi = max(0.0, float(prev_w)) + abs(float(saturation_w))
|
hi = max(0.0, float(prev_w)) + abs(float(saturation_w))
|
||||||
@@ -465,11 +542,27 @@ def compute(
|
|||||||
# END ON ITS OWN. See SETTLE_CYCLES and `unasked_power()` at the top:
|
# END ON ITS OWN. See SETTLE_CYCLES and `unasked_power()` at the top:
|
||||||
# a divergence a LIMIT could produce clears itself once the inverter
|
# a divergence a LIMIT could produce clears itself once the inverter
|
||||||
# catches up to the pinned command, so it needs no exit and gets none -
|
# catches up to the pinned command, so it needs no exit and gets none -
|
||||||
# unbounded, exactly as shipped. A divergence a limit CANNOT produce is
|
# unbounded, exactly as shipped. A divergence a limit CANNOT produce
|
||||||
# another writer, which by construction stops our command ever being
|
# does not clear itself by being waited on, so it gets one.
|
||||||
# tracked, so it can only end when the other writer leaves. That is the
|
#
|
||||||
# one this releases, and only after the divergence has outlasted the
|
# ⚠️ AND THAT IS NOT THE SAME SENTENCE AS "THE OTHER WRITER". An
|
||||||
# plant that would otherwise have explained it.
|
# earlier version of this comment said the two conditions below fire
|
||||||
|
# only in case (b); they do not, and the sweep says so. Our own plant
|
||||||
|
# coasting through a command that has just crossed zero satisfies
|
||||||
|
# `unasked_power()` too, because that predicate compares against ONE
|
||||||
|
# cycle's command and the pack remembers more than one - measured, a
|
||||||
|
# single inverter with nothing else on the bus reaching this branch in
|
||||||
|
# 24 of 1280 closed-loop scenarios. `SETTLE_CYCLES` is what keeps that
|
||||||
|
# rare rather than routine (113 of the same 1280 at the old bound of
|
||||||
|
# 9), and it does it by believing the plant for as long as the plant
|
||||||
|
# has ever been measured to take. It is a bound on how long we wait,
|
||||||
|
# not a proof about what we found.
|
||||||
|
#
|
||||||
|
# ⚠️ SO THE RELEASE IS WRITTEN TO BE SURVIVABLE WHEN IT IS WRONG, and
|
||||||
|
# that is the paragraph below, not this one: what a false release costs
|
||||||
|
# is one slew step per cycle toward a frozen integrator. If it ever has
|
||||||
|
# to cost less than that, the thing to change is what happens after the
|
||||||
|
# release, not the confidence of the sentence before it.
|
||||||
#
|
#
|
||||||
# ⚠️ RELEASING IS NOT UNFREEZING. The INTEGRATOR freeze above is not
|
# ⚠️ RELEASING IS NOT UNFREEZING. The INTEGRATOR freeze above is not
|
||||||
# lifted, and that is what makes this bounded rather than merely
|
# lifted, and that is what makes this bounded rather than merely
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ Read MUTATION.md before you report a survivor.
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from app.control import (LOOP_TICK_S, PLANT_SETTLE_S, SETTLE_CYCLES,
|
from app.control import (LOOP_TICK_S, PLANT_SETTLE_DOWN_S, PLANT_SETTLE_S,
|
||||||
SLEW_RAIL_FRAC, Tuning, compute, slew_limit_w,
|
SETTLE_CYCLES, SLEW_RAIL_FRAC, Tuning, compute,
|
||||||
unasked_power,
|
slew_limit_w, unasked_power,
|
||||||
maintenance_charge_floor, peak_at_risk)
|
maintenance_charge_floor, peak_at_risk)
|
||||||
|
|
||||||
# ⚠️ SAFETY-39: `slew_w` IS EXPLICIT HERE NOW, AND IT IS release/1.0's VALUE.
|
# ⚠️ SAFETY-39: `slew_w` IS EXPLICIT HERE NOW, AND IT IS release/1.0's VALUE.
|
||||||
@@ -60,16 +60,33 @@ def check(name, cond):
|
|||||||
# A comment cannot go stale-green, because it never claims to be running.
|
# A comment cannot go stale-green, because it never claims to be running.
|
||||||
#
|
#
|
||||||
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
|
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
|
||||||
# ⚠️ THE OUTPUT FREEZE ROW WAS RE-MEASURED AT SAFETY-13 AND IT MOVED FROM 2 TO
|
# ⚠️ THE SAFETY-13 ROWS WERE RE-MEASURED IN ROUND 2 AND THREE OF THEM WERE
|
||||||
# 11, because the SAFETY-13 section grades that branch too. Both figures are
|
# WRONG AS FIRST WRITTEN. Every figure below marked (S13) was taken by deleting
|
||||||
# from the SAME mutation form - the whole `target = min(...)/max(...)` line
|
# the mechanism, purging __pycache__, running this file in the CI image and
|
||||||
# removed, `reason` left alone - and 2 was reproduced on a pristine c8dcb38
|
# counting `FAIL` lines, on THIS tree; the release/1.0 comparison figures were
|
||||||
# tree before the new figure was taken, so the row moved because checks were
|
# taken the same way on a pristine c8dcb38 worktree.
|
||||||
# added and not because the mutation changed shape. The two new rows below it
|
#
|
||||||
# are that section's own mechanisms: removing the EXIT restores release/1.0's
|
# - `output freeze` was written as 11. The mutation form its own note
|
||||||
# unbounded freeze, and making `unasked_power()` return True gives an exit to
|
# describes - the whole `target = min(...)/max(...)` line removed, `reason`
|
||||||
# the limit case as well, which is the half of the decision that has to stay
|
# left alone - gives 6 on this tree AND 6 on the first SAFETY-13 commit,
|
||||||
# unbounded.
|
# against 2 on release/1.0. 11 is not reproducible by that form, or by
|
||||||
|
# removing the line and its `reason` (4), or by swapping min/max (12). The
|
||||||
|
# row is 6.
|
||||||
|
# - `saturation detector, saturated_now = False` was left at 11 while the
|
||||||
|
# SAFETY-13 section added checks that grade it. It is 15.
|
||||||
|
# - the footnote's `frozen = False` figure was left at 10 for the same
|
||||||
|
# reason. It is 13.
|
||||||
|
#
|
||||||
|
# The rows below `output freeze` are this ticket's own mechanisms: removing the
|
||||||
|
# EXIT restores release/1.0's unbounded freeze; making `unasked_power()` return
|
||||||
|
# True gives an exit to the limit case as well, which is the half of the
|
||||||
|
# decision that has to stay unbounded; deriving SETTLE_CYCLES from
|
||||||
|
# PLANT_SETTLE_S (18 s, the UP ramp) instead of PLANT_SETTLE_DOWN_S (23 s) is
|
||||||
|
# the round-1 defect itself; and pinning `_SAT_COUNT_CAP` back at release/1.0's
|
||||||
|
# literal 10 caps the counter below the bound and deletes the exit silently.
|
||||||
|
#
|
||||||
|
# ⚠️ ONLY THE ROWS THIS BRANCH MOVES WERE RE-MEASURED. Everything not marked
|
||||||
|
# (S13) still carries 389d9ec's figures and was not re-run here.
|
||||||
#
|
#
|
||||||
# ⚠️ THE SLEW ROWS WERE RE-MEASURED AT SAFETY-39 AND ONE OF THEM MOVED: the
|
# ⚠️ 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.
|
# slew limit was 4 and is now 5, because the SAFETY-39 section grades it too.
|
||||||
@@ -87,12 +104,14 @@ def check(name, cond):
|
|||||||
# output clamp 3
|
# output clamp 3
|
||||||
# slew limit 5
|
# slew limit 5
|
||||||
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
|
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
|
||||||
# output freeze 11
|
# output freeze (S13) 6
|
||||||
# the output freeze's EXIT (SAFETY-13), freeze made unconditional again 4
|
# the output freeze's EXIT (S13), freeze made unconditional again 4
|
||||||
# `unasked_power()` discriminates, replaced by `return True` 6
|
# `unasked_power()` discriminates, replaced by `return True` (S13) 6
|
||||||
|
# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9
|
||||||
|
# `_SAT_COUNT_CAP` pinned back at release/1.0's 10 (S13) 6
|
||||||
# deadband 5
|
# deadband 5
|
||||||
# quantisation 2
|
# quantisation 2
|
||||||
# saturation detector, `saturated_now = False` 11
|
# saturation detector, `saturated_now = False` (S13) 15
|
||||||
# saturation duration (AC 2), fires instantly 2
|
# saturation duration (AC 2), fires instantly 2
|
||||||
# sat counter reset on a good cycle 6
|
# sat counter reset on a good cycle 6
|
||||||
# target_grid_w bias 3
|
# target_grid_w bias 3
|
||||||
@@ -100,7 +119,8 @@ def check(name, cond):
|
|||||||
#
|
#
|
||||||
# The detector figure is for the `saturated_now = False` form specifically;
|
# The detector figure is for the `saturated_now = False` form specifically;
|
||||||
# disabling it further down as `frozen = False` is a weaker mutation and gives
|
# disabling it further down as `frozen = False` is a weaker mutation and gives
|
||||||
# 10. Reproduce the same form or the number will not match.
|
# 13 on this tree (10 on release/1.0). Reproduce the same form or the number
|
||||||
|
# will not match.
|
||||||
#
|
#
|
||||||
# ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in
|
# ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in
|
||||||
# for the mechanism under test. Deleting the integrator freeze once failed
|
# for the mechanism under test. Deleting the integrator freeze once failed
|
||||||
@@ -416,12 +436,30 @@ same = all(unasked_power(0.0, a, 500.0) == (abs(a) > 500.0)
|
|||||||
check("at prev_w == 0 unasked_power IS the saturation test, over 801 readings",
|
check("at prev_w == 0 unasked_power IS the saturation test, over 801 readings",
|
||||||
same)
|
same)
|
||||||
|
|
||||||
# --- 2. the bound is derived, not chosen ------------------------------------
|
# --- 2. the bound is derived, and from WHICH HALF of the measurement --------
|
||||||
check(f"SETTLE_CYCLES is ceil(PLANT_SETTLE_S / LOOP_TICK_S) = {SETTLE_CYCLES}",
|
# ⚠️ THE DIRECTION IS THE ASSERTION. goodwe/NOTES.md 5.10 measured the same
|
||||||
SETTLE_CYCLES == 9 and SETTLE_CYCLES * LOOP_TICK_S >= PLANT_SETTLE_S
|
# 1.78 kW step both ways: ~18 s up, ~23 s down. SAFETY-39 takes the SHORTER
|
||||||
and (SETTLE_CYCLES - 1) * LOOP_TICK_S < PLANT_SETTLE_S)
|
# one because its bound is a REFUSAL and the looser number refuses less. This
|
||||||
check("...which is the reciprocal of SAFETY-39's SLEW_RAIL_FRAC",
|
# bound asks the opposite question - how long the plant may still be the
|
||||||
abs(SETTLE_CYCLES - 1.0 / SLEW_RAIL_FRAC) < 1e-9)
|
# explanation for a divergence - and for that the conservative half is the
|
||||||
|
# LONGER one. The first version of this file derived it from 18, got 9, and
|
||||||
|
# that is three cycles short of the plant's own measured down-ramp, in the
|
||||||
|
# direction that ACCUSES rather than refuses (section 8 below drives it).
|
||||||
|
check(f"SETTLE_CYCLES is ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) ="
|
||||||
|
f" {SETTLE_CYCLES}",
|
||||||
|
SETTLE_CYCLES == 12
|
||||||
|
and SETTLE_CYCLES * LOOP_TICK_S >= PLANT_SETTLE_DOWN_S
|
||||||
|
and (SETTLE_CYCLES - 1) * LOOP_TICK_S < PLANT_SETTLE_DOWN_S)
|
||||||
|
check("...from the LONGER of the two measured settle times, so it is strictly"
|
||||||
|
" above what the shorter one would have given",
|
||||||
|
PLANT_SETTLE_DOWN_S > PLANT_SETTLE_S
|
||||||
|
and SETTLE_CYCLES > -(-PLANT_SETTLE_S // LOOP_TICK_S))
|
||||||
|
# ...and it is NOT SLEW_RAIL_FRAC's reciprocal any more. It was, while both
|
||||||
|
# were being derived from the same half of the pair, and that coincidence read
|
||||||
|
# as a derivation. Asserted as an INEQUALITY so the two cannot silently be
|
||||||
|
# re-fused into one number again.
|
||||||
|
check("the slew's rail crossing and this bound are now different numbers",
|
||||||
|
SETTLE_CYCLES > 1.0 / SLEW_RAIL_FRAC)
|
||||||
# The counter has to be able to REACH the bound or the exit does not exist.
|
# The counter has to be able to REACH the bound or the exit does not exist.
|
||||||
# Driven, not read off the constant: run a saturated state and see how high the
|
# Driven, not read off the constant: run a saturated state and see how high the
|
||||||
# counter actually goes.
|
# counter actually goes.
|
||||||
@@ -529,41 +567,179 @@ check(f"the card's case: no cycle moves more than the slew limit "
|
|||||||
# that built up while the output was blocked cannot arrive as a jump - it is
|
# that built up while the output was blocked cannot arrive as a jump - it is
|
||||||
# spent one slew step at a time, against an integrator that stopped
|
# spent one slew step at a time, against an integrator that stopped
|
||||||
# accumulating when the freeze engaged and is not released with the output.
|
# accumulating when the freeze engaged and is not released with the output.
|
||||||
|
# ⚠️ AND THE COUNT BELOW IS THE RELEASED STATES, NOT THE SWEPT ONES. The first
|
||||||
|
# version of this check named 41*25*21*5 - the size of the sweep - while
|
||||||
|
# grading only the subset that actually releases, so the number in the message
|
||||||
|
# was not the number the assertion was about. It is counted now.
|
||||||
step = float(T.step_w)
|
step = float(T.step_w)
|
||||||
past, jumped = [], []
|
swept, past, jumped = 0, [], []
|
||||||
|
n_released = 0
|
||||||
for prev in [x * 250.0 for x in range(-20, 21)]:
|
for prev in [x * 250.0 for x in range(-20, 21)]:
|
||||||
for grid in [x * 500.0 for x in range(-12, 13)]:
|
for grid in [x * 500.0 for x in range(-12, 13)]:
|
||||||
for actual in [x * 500.0 for x in range(-10, 11)]:
|
for actual in [x * 500.0 for x in range(-10, 11)]:
|
||||||
for i0 in (-3000.0, -500.0, 0.0, 500.0, 3000.0):
|
for i0 in (-3000.0, -500.0, 0.0, 500.0, 3000.0):
|
||||||
|
swept += 1
|
||||||
d = compute(prev, grid, actual, TL, SETTLE_CYCLES, i0)
|
d = compute(prev, grid, actual, TL, SETTLE_CYCLES, i0)
|
||||||
if d.reason != "freeze-released":
|
if d.reason != "freeze-released":
|
||||||
continue
|
continue
|
||||||
|
n_released += 1
|
||||||
lo, hi = min(prev, d.i_w), max(prev, d.i_w)
|
lo, hi = min(prev, d.i_w), max(prev, d.i_w)
|
||||||
if not (lo - step <= d.target_w <= hi + step):
|
if not (lo - step <= d.target_w <= hi + step):
|
||||||
past.append((prev, grid, actual, i0, d.target_w, d.i_w))
|
past.append((prev, grid, actual, i0, d.target_w, d.i_w))
|
||||||
if abs(d.target_w - prev) > slew_limit_w(TL) + step:
|
if abs(d.target_w - prev) > slew_limit_w(TL) + step:
|
||||||
jumped.append((prev, grid, actual, i0, d.target_w))
|
jumped.append((prev, grid, actual, i0, d.target_w))
|
||||||
check(f"over {41*25*21*5} released states the command stays between the"
|
check(f"of {swept} states swept, the {n_released} that release keep the command"
|
||||||
f" previous command and the frozen integrator"
|
f" between the previous command and the frozen integrator"
|
||||||
+ (f" (e.g. {past[0]})" if past else ""), not past)
|
+ (f" (e.g. {past[0]})" if past else ""), not past and n_released > 0)
|
||||||
check("...and no released cycle moves further than one slew step"
|
check("...and no released cycle moves further than one slew step"
|
||||||
+ (f" (e.g. {jumped[0]})" if jumped else ""), not jumped)
|
+ (f" (e.g. {jumped[0]})" if jumped else ""), not jumped)
|
||||||
|
|
||||||
# --- 7. the one configuration in which the block does not exist ------------
|
# --- 7. NO ACCEPTED CONFIGURATION CAN DELETE THE BLOCK ---------------------
|
||||||
# ⚠️ SAID OUT LOUD BECAUSE IT IS A SILENT NO-OP OTHERWISE. `saturation_cycles`
|
# ⚠️ THIS CHECK REPLACES ITS OWN OPPOSITE, AND THE REASON IS WORTH KEEPING.
|
||||||
# is accepted up to 10 by config.yaml. Set it at or above SETTLE_CYCLES and the
|
# While the bound was 9, `saturation_cycles` - which config.yaml accepts up to
|
||||||
# freeze does not engage until the bound has already passed, so the no-start
|
# 10 - could be set AT OR ABOVE it, and then the freeze did not engage until
|
||||||
# block never happens at all. It is not refused at startup - a controller that
|
# its own exit was already due: the no-start block silently stopped existing,
|
||||||
# will not boot over a tuning nuance is worse than the nuance - but it must be
|
# and this file asserted that as a known consequence. Moving the bound to 12
|
||||||
# a known consequence rather than a surprise.
|
# put the whole accepted range strictly below it, so the degenerate
|
||||||
TN = Tuning(slew_w=1000.0, saturation_cycles=SETTLE_CYCLES)
|
# configuration is gone rather than documented.
|
||||||
|
#
|
||||||
|
# It is pinned against config.yaml ITSELF and not a transcription, because the
|
||||||
|
# property is "two files agree" and a transcription is what lets them drift.
|
||||||
|
# Regex, not PyYAML: the add-on image ships no PyYAML (test_validate.py says
|
||||||
|
# the same thing at more length).
|
||||||
|
import os # noqa: E402
|
||||||
|
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:
|
||||||
|
_m = re.search(r"^ saturation_cycles: int\((\d+),(\d+)\)\s*$",
|
||||||
|
_fh.read(), re.M)
|
||||||
|
check("config.yaml's saturation_cycles range really was read", _m is not None)
|
||||||
|
_sc_lo, _sc_hi = int(_m.group(1)), int(_m.group(2))
|
||||||
|
check(f"every accepted saturation_cycles ({_sc_lo}..{_sc_hi}) is strictly"
|
||||||
|
f" below the bound ({SETTLE_CYCLES}), so no accepted configuration can"
|
||||||
|
f" push the freeze's engagement past its own exit",
|
||||||
|
_sc_hi < SETTLE_CYCLES)
|
||||||
|
# Driven at the schema's edge rather than argued: the block still engages, and
|
||||||
|
# it engages before the exit is due.
|
||||||
|
TN = Tuning(slew_w=1000.0, saturation_cycles=_sc_hi)
|
||||||
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
|
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
|
||||||
|
first_freeze = None
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
d = compute(prev, -2000.0, 700.0, TN, sat, i_w)
|
d = compute(prev, -2000.0, 700.0, TN, sat, i_w)
|
||||||
reasons.add(d.reason)
|
reasons.add(d.reason)
|
||||||
|
if d.reason == "saturated-freeze" and first_freeze is None:
|
||||||
|
first_freeze = d.sat_count
|
||||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||||
check(f"saturation_cycles >= {SETTLE_CYCLES} means the block never engages",
|
check(f"at the largest accepted saturation_cycles ({_sc_hi}) the block still"
|
||||||
"saturated-freeze" not in reasons)
|
f" engages, at sat_count {first_freeze}, below the bound",
|
||||||
|
"saturated-freeze" in reasons and first_freeze is not None
|
||||||
|
and first_freeze < SETTLE_CYCLES)
|
||||||
|
|
||||||
|
# --- 8. THE BOUND'S DIRECTION, DRIVEN ON ONE INVERTER ----------------------
|
||||||
|
# ⚠️ WHY THIS SECTION EXISTS. The exit's two conditions are read as "another
|
||||||
|
# writer is on the actuator". They are not that, and the first shipped form of
|
||||||
|
# this file said they were. `unasked_power()` compares a reading against ONE
|
||||||
|
# cycle's command while the pack carries every command before it, so a command
|
||||||
|
# that has fallen THROUGH ZERO - or simply fallen faster than the pack coasts -
|
||||||
|
# leaves our own power outside our own interval. With the bound derived from
|
||||||
|
# the 18 s UP measurement that is not a corner case: it is what a normal load
|
||||||
|
# step does.
|
||||||
|
#
|
||||||
|
# The plant here is a MODEL and is labelled one. It is a first-order lag whose
|
||||||
|
# two time constants come from the two measured settling times (a first-order
|
||||||
|
# step is 95 % done at 3 tau, so tau = t / 3), with the ramp cap
|
||||||
|
# sim/plantsim.py measured on inverter A. It is not the rig and it is not the
|
||||||
|
# house; what it is for is that the two settling times ENTER the model in the
|
||||||
|
# same direction they enter the constant, so the check moves if either does.
|
||||||
|
#
|
||||||
|
# ⚠️ AND THERE IS NO SECOND WRITER IN THIS SECTION. One `compute()`, one
|
||||||
|
# plant, one command. Every `freeze-released` below is therefore false.
|
||||||
|
import math # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _Plant:
|
||||||
|
"""One inverter, obeying our command and nothing else. A MODEL."""
|
||||||
|
|
||||||
|
RAMP_W_S = 800.0 # sim/plantsim.py INVERTER_A, measured=True
|
||||||
|
|
||||||
|
def __init__(self, up_s, down_s):
|
||||||
|
self.a_up = 1.0 - math.exp(-LOOP_TICK_S / (up_s / 3.0))
|
||||||
|
self.a_dn = 1.0 - math.exp(-LOOP_TICK_S / (down_s / 3.0))
|
||||||
|
self.w = 0.0
|
||||||
|
|
||||||
|
def tick(self, cmd):
|
||||||
|
a = self.a_up if abs(cmd) > abs(self.w) else self.a_dn
|
||||||
|
cap = self.RAMP_W_S * LOOP_TICK_S
|
||||||
|
self.w += max(-cap, min(cap, a * (cmd - self.w)))
|
||||||
|
|
||||||
|
|
||||||
|
from app import control as _control # noqa: E402
|
||||||
|
|
||||||
|
TS8 = Tuning(max_w=5000.0, slew_w=400.0, target_grid_w=-10.0,
|
||||||
|
saturation_w=500.0, saturation_cycles=3)
|
||||||
|
|
||||||
|
|
||||||
|
def _drive(bound, load1, load2, cycles=60, switch=25):
|
||||||
|
"""Run one closed loop at an arbitrary bound. -> (release cycles, rows)."""
|
||||||
|
was_b, was_c = _control.SETTLE_CYCLES, _control._SAT_COUNT_CAP
|
||||||
|
# ⚠️ BOTH, OR THE PROBE LIES: `_SAT_COUNT_CAP` is what `compute()` caps the
|
||||||
|
# counter with, so moving the bound without it caps the counter BELOW the
|
||||||
|
# bound and the exit quietly becomes unreachable - which reads as "no false
|
||||||
|
# positives". That is the hazard `_SAT_COUNT_CAP`'s own comment names.
|
||||||
|
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = bound, bound + 1
|
||||||
|
try:
|
||||||
|
p = _Plant(PLANT_SETTLE_S, PLANT_SETTLE_DOWN_S)
|
||||||
|
prev, i_w, sat, rows = 0.0, 0.0, 0, []
|
||||||
|
for c in range(cycles):
|
||||||
|
load = load1 if c < switch else load2
|
||||||
|
d = compute(prev, load - p.w, p.w, TS8, sat, i_w)
|
||||||
|
rows.append((c, prev, p.w, d.target_w, d.sat_count, d.reason))
|
||||||
|
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||||
|
p.tick(prev)
|
||||||
|
return [r for r in rows if r[5] == "freeze-released"], rows
|
||||||
|
finally:
|
||||||
|
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = was_b, was_c
|
||||||
|
|
||||||
|
|
||||||
|
_SHORT = int(-(-PLANT_SETTLE_S // LOOP_TICK_S)) # what 18 s would give: 9
|
||||||
|
|
||||||
|
# A 4 kW export falling to 2.5 kW. Nothing exotic; no second writer.
|
||||||
|
_bad, _rows = _drive(_SHORT, -4000.0, -2500.0)
|
||||||
|
check(f"a bound derived from the SHORTER measurement ({_SHORT} cycles) fires"
|
||||||
|
f" the exit on ONE inverter with nothing else on the bus"
|
||||||
|
+ (f" - {len(_bad)} cycle(s), first at cycle {_bad[0][0]}" if _bad else ""),
|
||||||
|
bool(_bad))
|
||||||
|
if _bad:
|
||||||
|
_c, _prev, _act, _tgt, _sat, _ = _bad[0]
|
||||||
|
check(f"...and what it called unasked is our own coast-down: command"
|
||||||
|
f" {_prev:.0f} W, pack still at {_act:.1f} W, and the pack was"
|
||||||
|
f" commanded {min(r[1] for r in _rows[:_c]):.0f} W a few cycles"
|
||||||
|
f" earlier",
|
||||||
|
unasked_power(_prev, _act, TS8.saturation_w)
|
||||||
|
and abs(_act) > abs(_prev)
|
||||||
|
and min(r[1] for r in _rows[:_c]) < _act)
|
||||||
|
_good, _ = _drive(SETTLE_CYCLES, -4000.0, -2500.0)
|
||||||
|
check(f"the shipped bound ({SETTLE_CYCLES} cycles, the LONGER measurement)"
|
||||||
|
f" does not fire on the same run", not _good)
|
||||||
|
# ...and not on any of these, either. The whole grid of load steps the same
|
||||||
|
# plant can be given, at the tuning every headline figure in this ticket is
|
||||||
|
# quoted from.
|
||||||
|
_L = [x * 500.0 for x in range(-8, 9)]
|
||||||
|
_fired = {}
|
||||||
|
for _b in (_SHORT, SETTLE_CYCLES):
|
||||||
|
_fired[_b] = sum(1 for _a in _L for _z in _L
|
||||||
|
if _a != _z and _drive(_b, _a, _z)[0])
|
||||||
|
_pairs = len(_L) * (len(_L) - 1)
|
||||||
|
check(f"over {_pairs} load-step pairs on the measured plant, one inverter and"
|
||||||
|
f" no second writer: the short bound fires in {_fired[_SHORT]} of them,"
|
||||||
|
f" the shipped bound in {_fired[SETTLE_CYCLES]}",
|
||||||
|
_fired[SETTLE_CYCLES] < _fired[_SHORT])
|
||||||
|
# ⚠️ AND THE SHIPPED FIGURE IS NOT ZERO IN GENERAL. It is small here because
|
||||||
|
# the plant in this model is the one that was measured. A plant slower than
|
||||||
|
# that brings the false release back, and no constant derived from a
|
||||||
|
# measurement can fix that - it is why DOCS.md tells the operator what
|
||||||
|
# `freeze-released` is evidence OF, rather than what it proves.
|
||||||
|
|
||||||
print("SAFETY-04: the i_w=None path is still release/1.0, exactly")
|
print("SAFETY-04: the i_w=None path is still release/1.0, exactly")
|
||||||
|
|
||||||
@@ -611,9 +787,27 @@ def legacy(prev, grid, actual, t, sat_count):
|
|||||||
# that permit it both true. Anything else is a regression, and the counts below
|
# that permit it both true. Anything else is a regression, and the counts below
|
||||||
# say how much of the sweep each answer covers.
|
# say how much of the sweep each answer covers.
|
||||||
ALIAS = {"i-clamped": "clamped"}
|
ALIAS = {"i-clamped": "clamped"}
|
||||||
|
# ⚠️ SAFETY-13 ROUND 2 MOVED THE COUNTER'S CEILING, SO THE SWEEP NOW HAS A
|
||||||
|
# THIRD ANSWER. `_SAT_COUNT_CAP` is `SETTLE_CYCLES + 1`, which was 10 - the
|
||||||
|
# same literal release/1.0 used - and is now 13. Above release/1.0's own
|
||||||
|
# ceiling the two counters therefore differ, and NOTHING ELSE MAY: `frozen` is
|
||||||
|
# `sat_count >= saturation_cycles` and config.yaml accepts that at most 10, so
|
||||||
|
# a counter reading 13 instead of 10 cannot change it, and no other line in
|
||||||
|
# `compute()` reads the counter's value. That is asserted rather than asserted-
|
||||||
|
# by-omission, in `cap_diffs` below.
|
||||||
|
#
|
||||||
|
# ⚠️ AND THE SWEEP HAS TO REACH THE BOUND OR IT GRADES NOTHING. `sc` runs to 12
|
||||||
|
# now; at 9 the counter tops out one short of the exit and every release
|
||||||
|
# silently disappears from this table, which is exactly what happened when the
|
||||||
|
# bound moved and is why the count is asserted and not merely printed. 12 is a
|
||||||
|
# state release/1.0 could never hold - its counter stopped at 10 - so
|
||||||
|
# `legacy()` is being fed one input outside its own reachable domain, on
|
||||||
|
# purpose, and `cap_diffs` is where that shows up.
|
||||||
|
LEGACY_SAT_CAP = 10 # release/1.0's literal, transcribed in legacy()
|
||||||
diffs = []
|
diffs = []
|
||||||
released = []
|
released = []
|
||||||
value_diffs = []
|
value_diffs = []
|
||||||
|
cap_diffs = []
|
||||||
seen = set()
|
seen = set()
|
||||||
# ⚠️ `slew_w` IS WRITTEN OUT IN ALL THREE (SAFETY-39). `legacy()` below is a
|
# ⚠️ `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
|
# TRANSCRIPTION of release/1.0 and reads `t.slew_w` directly, as release/1.0
|
||||||
@@ -627,7 +821,7 @@ for tune in (Tuning(slew_w=1000.0), Tuning(slew_w=1000.0, target_grid_w=-10.0),
|
|||||||
for prev in (-2000.0, -500.0, -100.0, 0.0, 100.0, 500.0, 2000.0):
|
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 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):
|
for actual in (-2000.0, 0.0, 600.0, 2000.0):
|
||||||
for sc in (0, 2, 3, 9):
|
for sc in (0, 2, 3, 9, 12):
|
||||||
d = compute(prev, grid, actual, tune, sc) # i_w defaults to None
|
d = compute(prev, grid, actual, tune, sc) # i_w defaults to None
|
||||||
seen.add(d.reason)
|
seen.add(d.reason)
|
||||||
got = (d.target_w, d.sat_count, d.frozen,
|
got = (d.target_w, d.sat_count, d.frozen,
|
||||||
@@ -635,6 +829,19 @@ for tune in (Tuning(slew_w=1000.0), Tuning(slew_w=1000.0, target_grid_w=-10.0),
|
|||||||
was = legacy(prev, grid, actual, tune, sc)
|
was = legacy(prev, grid, actual, tune, sc)
|
||||||
if got == was:
|
if got == was:
|
||||||
continue
|
continue
|
||||||
|
# everything that reaches the wire, without the counter
|
||||||
|
wire_same = (got[0], got[2], got[3]) == (was[0], was[2], was[3])
|
||||||
|
if wire_same:
|
||||||
|
# the ceiling moved and nothing else did. Only legal
|
||||||
|
# above release/1.0's own cap, and only with both
|
||||||
|
# counters standing at their own ceilings.
|
||||||
|
if (sc >= LEGACY_SAT_CAP
|
||||||
|
and was[1] == LEGACY_SAT_CAP
|
||||||
|
and got[1] == _control._SAT_COUNT_CAP):
|
||||||
|
cap_diffs.append((prev, grid, actual, sc, got[1], was[1]))
|
||||||
|
continue
|
||||||
|
diffs.append((prev, grid, actual, sc, got, was))
|
||||||
|
continue
|
||||||
explained = (
|
explained = (
|
||||||
d.reason == "freeze-released"
|
d.reason == "freeze-released"
|
||||||
and was[3] == "saturated-freeze"
|
and was[3] == "saturated-freeze"
|
||||||
@@ -646,12 +853,28 @@ for tune in (Tuning(slew_w=1000.0), Tuning(slew_w=1000.0, target_grid_w=-10.0),
|
|||||||
released.append((prev, grid, actual, sc))
|
released.append((prev, grid, actual, sc))
|
||||||
if got[0] != was[0]:
|
if got[0] != was[0]:
|
||||||
value_diffs.append((prev, got[0], was[0]))
|
value_diffs.append((prev, got[0], was[0]))
|
||||||
TOTAL = 3 * 7 * 9 * 4 * 4
|
TOTAL = 3 * 7 * 9 * 4 * 5
|
||||||
check(f"i_w=None reproduces release/1.0 over {TOTAL} cases except where"
|
check(f"i_w=None reproduces release/1.0 over {TOTAL} cases except where"
|
||||||
f" SAFETY-13 releases the freeze, reason included"
|
f" SAFETY-13 releases the freeze, reason included"
|
||||||
+ (f" (first unexplained diff {diffs[0]})" if diffs else ""), not diffs)
|
+ (f" (first unexplained diff {diffs[0]})" if diffs else ""), not diffs)
|
||||||
check(f"{TOTAL - len(released)} of {TOTAL} cases are bit-identical to"
|
# ⚠️ EXACT COUNTS, NOT INEQUALITIES. A weaker form of this line ("some
|
||||||
f" release/1.0; {len(released)} are releases", len(released) == 432)
|
# releases happened") was written while the bound was moving, and the coverage
|
||||||
|
# audit caught it immediately: a `unasked_power()` that stops discriminating
|
||||||
|
# went from 6 checks to 5. The three numbers partition the sweep, so any one of
|
||||||
|
# them moving is a change somebody has to explain.
|
||||||
|
check(f"{TOTAL - len(released) - len(cap_diffs)} of {TOTAL} cases are"
|
||||||
|
f" bit-identical to release/1.0; {len(released)} are releases;"
|
||||||
|
f" {len(cap_diffs)} differ in the saturation counter alone",
|
||||||
|
len(released) == 432 and len(cap_diffs) == 81
|
||||||
|
and TOTAL - len(released) - len(cap_diffs) == 3267)
|
||||||
|
# ⚠️ THE COUNTER-ONLY DIFFERENCES ARE NOT A LOOPHOLE, so they are pinned twice:
|
||||||
|
# they may only occur at an `sc` above release/1.0's ceiling, and the largest
|
||||||
|
# `saturation_cycles` config.yaml accepts is below BOTH ceilings, so no
|
||||||
|
# accepted configuration can see the difference at all.
|
||||||
|
check(f"the {len(cap_diffs)} counter-only differences all sit above"
|
||||||
|
f" release/1.0's cap of {LEGACY_SAT_CAP}, where no accepted"
|
||||||
|
f" saturation_cycles can read them",
|
||||||
|
all(r[3] >= LEGACY_SAT_CAP for r in cap_diffs) and _sc_hi <= LEGACY_SAT_CAP)
|
||||||
# ⚠️ AND THE RELEASES ARE MOSTLY A LABEL. Only these change what reaches the
|
# ⚠️ AND THE RELEASES ARE MOSTLY A LABEL. Only these change what reaches the
|
||||||
# wire; the rest report "freeze-released" on a command the freeze was not
|
# wire; the rest report "freeze-released" on a command the freeze was not
|
||||||
# binding on anyway. Every one of them is a charge that release/1.0 refused to
|
# binding on anyway. Every one of them is a charge that release/1.0 refused to
|
||||||
|
|||||||
Reference in New Issue
Block a user