SAFETY-43: delinquency measured against the ask, not against a flat 500 W
checks / unit (pull_request) Successful in 6m20s
checks / unit (pull_request) Successful in 6m20s
`delivery.delinquent()` named a unit whose `short_by()` gap exceeded an
ABSOLUTE `min_w = Tuning.saturation_w = 500 W`. OPT-09's SoC-aware weights
shrink a low-headroom unit's share, and shrinking the share shrinks the gap, so
a unit standing at +125 W and delivering 0 W - 100 % delinquent - was never
named. Measured end to end on the real loop (test_packshare.py part 8b):
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles
load 1500 W, B wedged at 15 %, OPT-09 weights named on 0/200 cycles
⚠️ The suppressed regime is the quiet one: at a load the healthy sibling covers,
the meter is satisfied, `unmet_w` is 0 and no action-log line fires, so this
alarm is the ONLY instrument that would have told the operator.
WHAT CHANGED, in one line:
named if short_by() > min_w OR short_by() > frac * share
⚠️ `min_w` WAS NOT LOWERED. It exists to stop normal saturation being reported
as delinquency, and lowering it trades a missed alarm for a false one on every
unit at once - SAFETY-41's four-attempt regime. This is a SECOND way to be
named, not a looser first one: all seven of test_delivery.py part 1's existing
fixtures answer identically under both rules, and the absolute arm's suppression
window is still exactly 1000 W / 6500 W, still asserted in part 8b.
THE FRACTION IS DERIVED, 0.85. admin/goodwe:sim/delivery_window.py gained a
`--fractions` axis and grades the constant over the SAME 2640 no-fault runs that
produced `delivery_hold_s`'s 35.0 s floor. Worst continuous legitimate episode,
worst of three families, twenty fractions:
frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
absolute arm alone: 35.0 s (SAFETY-42 §3.3, reproduced in the same runs)
⚠️ NO CLIFF ANYWHERE: 0.45-0.99 is flat within one control cycle, and it is not
monotone - §3.3's own mechanism (c). ⚠️ "Zero false namings" is NOT achievable
at any fraction and the record says so; what is zero is false ALARMS - 90.0 s
worst against a 185 s hold, so nothing comes within 95 s of notifying anybody.
N=1 control 0.0 s at every fraction in all 2640 one-inverter runs; flapping
0.0 s in all 840.
DRIVEN. Part 8b pinned the SUPPRESSED behaviour and said "if it ever goes red
because the alarm fired, read the card that made it fire". It now grades BOTH
rules on one run - `share_frac=None` is SAFETY-42's rule exactly:
OPT-09 weights, absolute arm alone named on 0/200
OPT-09 weights, the shipped rule named on 200/200
POSITIVE CONTROL. Same fixture, same weights, same 125 W share, one legitimate
fact changed: a pack at its own 10 % cut-out is named on 0/200 (account() still
gates it), and a unit delivering HALF its share is named on 0/200.
N=1 BYTE-IDENTICAL, asserted: `plan()` handed a `weights` mapping that raises on
any read still answers identically at a roster of one, and the same mapping
raises at a roster of two; `delinquent()` answers {} on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.
MUTATION, run on fresh :ro trees: M12 (drop the relative arm), M13 (frac -> 1.0)
and M14 (frac -> 0.05) are all red on named checks. The constant cannot move in
either direction unseen.
⚠️ CFG-07 gets ONE CONTROL CYCLE WORSE and is deliberately not fixed here: the
worst legitimate episode goes 35.0 -> 40.0 s, so the schema's `int(30,900)`
false-tripping band widens from [30,35] to [30,40]. CFG-07 should be re-derived
against 40.0 s.
No option value, no schema bound and no default changed. 27/27 suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -46,8 +46,10 @@ threshold. Measured: a wedged inverter goes from named on 199 of 200 cycles to
|
||||
**0 of 200** at a load its sibling can carry alone. The alarm returns once the
|
||||
healthy pack saturates - swept, that is a 5000 W load on the fixture, **below**
|
||||
the proportional-regime formula's 6500 W and not above it. ⚠️ **N=1 IS
|
||||
UNAFFECTED**: `allocator.plan()` returns before weights are read at all. The
|
||||
closure is card SAFETY-43.
|
||||
UNAFFECTED**: `allocator.plan()` returns before weights are read at all.
|
||||
⚠️ **CLOSED BY SAFETY-43 ON THIS BRANCH** - see its section below; the regression
|
||||
above is history, and the record of it is kept because the measurement that
|
||||
found it is what the fix is graded against.
|
||||
|
||||
**PKG-09** adds `tools/predeploy_check.py`. ⚠️ **RUN IT BEFORE DEPLOYING THIS
|
||||
RELEASE** - it is what caught the fact that 0.8.0 had five runtime commits
|
||||
@@ -97,6 +99,58 @@ what happens when nobody does.
|
||||
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
|
||||
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
|
||||
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
|
||||
| _this branch_ | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression |
|
||||
|
||||
⚠️ **SAFETY-43 CLOSES THE DETECTION REGRESSION OPT-09 DISCLOSED, AND IT IS A
|
||||
TWO-INVERTER FIX. N=1 IS BYTE-IDENTICAL.** `delivery.delinquent()` named a unit
|
||||
whose shortfall exceeded an **absolute** `min_w = saturation_w = 500 W`. OPT-09's
|
||||
weights shrink a low-headroom unit's share, and shrinking the share shrinks the
|
||||
gap - so a unit standing at +125 W and delivering **0 W**, 100 % delinquent, was
|
||||
never named. It now names a unit whose gap exceeds `min_w` **OR** exceeds
|
||||
`DELIVERY_SHARE_FRACTION` (0.85) of that unit's **own share**. Measured on the
|
||||
same closed-loop fixture, same weights, same detector:
|
||||
|
||||
load 1500 W OPT-09 weights the old rule named on 0/200 cycles
|
||||
load 1500 W OPT-09 weights the shipped one named on 200/200 cycles
|
||||
|
||||
⚠️ **`saturation_w` WAS NOT LOWERED, AND THAT IS THE POINT.** Lowering it would
|
||||
report normal saturation as delinquency on every unit at once - SAFETY-41's
|
||||
four-attempt false-trip regime, *"i'm getting really annoyed by the notifications
|
||||
in my HA"*. This ADDS a second way to be named: all seven of `test_delivery.py`
|
||||
part 1's existing fixtures answer identically under the old rule and the new one,
|
||||
and the absolute arm's suppression window is still exactly 1000 W / 6500 W.
|
||||
|
||||
**0.85 is derived, not chosen.** `admin/goodwe:sim/delivery_window.py --campaign
|
||||
--fractions` grades it over the SAME 2640 no-fault runs that produced
|
||||
`delivery_hold_s`'s floor. The worst continuous legitimate episode is **35.0 s**
|
||||
under the absolute arm alone (SAFETY-42 §3.3, reproduced in the same run) and
|
||||
**40.0 s** at 0.85. ⚠️ **THE CURVE HAS NO CLIFF AND THAT IS THE FINDING**: swept
|
||||
at twenty fractions from 0.05 to 0.99, everything from **0.45 to 0.99** is 40.0
|
||||
or 45.0 s - within one control cycle of every other - and below 0.45 it rises
|
||||
gradually to 90.0 s at 0.05. ⚠️ **"Zero false namings" is not achievable at any
|
||||
fraction and the record says so**; what is zero is false ALARMS - the longest
|
||||
legitimate episode in the whole sweep is 90.0 s against a 185 s hold, so nothing
|
||||
comes within 95 s of notifying anybody at any fraction measured, and 145 s at the
|
||||
shipped one. The N=1 control arm is 0.0 s at every fraction in all 2640
|
||||
one-inverter runs, and the flapping family is 0.0 s in all 840.
|
||||
|
||||
⚠️ **[[CFG-07]] GETS ONE CONTROL CYCLE WORSE AND IS NOT FIXED HERE.**
|
||||
`delivery_hold_s` is declared `int(30,900)` while the measured legitimate worst
|
||||
was 35.0 s; it is now 40.0 s, so the band of schema-legal false-tripping values
|
||||
widens from `[30, 35]` to `[30, 40]`. CFG-07 should be re-derived against 40.0 s.
|
||||
**SAFETY-43 CHANGES NO OPTION VALUE, NO SCHEMA BOUND AND NO DEFAULT** - the
|
||||
fraction is a derived constant with a committed harness behind it, deliberately
|
||||
not a knob, for the reason there is no `delivery_w`. The one edit to
|
||||
`config.yaml` is a COMMENT: the `delivery_hold_s` block described the check as
|
||||
"short by more than `saturation_w`", which is now only half of it.
|
||||
|
||||
**No behaviour change at one inverter, asserted rather than argued**: `plan()` is
|
||||
handed a `weights` mapping that raises if anything reads it and still answers
|
||||
identically at a roster of one (and the same mapping raises at a roster of two,
|
||||
so the tripwire is real), and `delinquent()` answers `{}` on all 84 single-unit
|
||||
(share, measured, rule) combinations including 0 of 125 W.
|
||||
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` carries the curve, the
|
||||
positive controls and the CFG-07 assessment.
|
||||
|
||||
⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD
|
||||
CLAIMED.** `delivery_hold_s: 185` shipped resting on *"30.0 s, the worst
|
||||
|
||||
@@ -161,17 +161,18 @@ its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after
|
||||
this card that ask does not arrive in the first place, which removes the benign
|
||||
arm from the detector's input rather than teaching it a new discriminator.
|
||||
|
||||
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWS SAFETY-42's DETECTION WINDOW. THIS
|
||||
IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE.** An earlier draft of
|
||||
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWED SAFETY-42's DETECTION WINDOW.
|
||||
THAT WAS A DETECTION REGRESSION AT N=2, IT WAS NOT FIXED HERE, AND SAFETY-43
|
||||
CLOSED IT - see the block at the end of this section.** An earlier draft of
|
||||
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
|
||||
at all". The second clause was **false**, and it was load-bearing for the claim
|
||||
that the two cards do not overlap. A weight is a function of the pack's ENERGY
|
||||
HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever
|
||||
its headroom differs from its sibling's - which is the ordinary case this card
|
||||
exists to exploit. And `delivery.delinquent()` (`app/delivery.py:484`)
|
||||
names a unit only while its `short_by()` gap exceeds `min_w =
|
||||
Tuning.saturation_w` - the comparison is `app/delivery.py:531`, and it is
|
||||
**500 W in absolute watts**. Shrinking a unit's share shrinks its gap. Shrink the
|
||||
exists to exploit. And `delivery.delinquent()`
|
||||
named a unit only while its `short_by()` gap exceeded `min_w =
|
||||
Tuning.saturation_w`, and that was **500 W in absolute watts** with nothing
|
||||
else beside it. Shrinking a unit's share shrinks its gap. Shrink the
|
||||
share far enough and the gap drops below 500 W while the unit is still delivering
|
||||
nothing, and the alarm stops firing.
|
||||
|
||||
@@ -200,20 +201,47 @@ a 9600 W site; above it - once the healthy pack is at its own rating and
|
||||
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
|
||||
returns. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
|
||||
`test_packshare.py` part 8b grades all of it and **pins the current,
|
||||
unflattering behaviour**, so that the day it changes a named check moves. The
|
||||
closure - `delinquent()` measured against the ask rather than a flat 500 W - is
|
||||
card **SAFETY-43**.
|
||||
`test_packshare.py` part 8b graded all of it and **pinned the unflattering
|
||||
behaviour**, so that the day it changed a named check would move. It has moved.
|
||||
|
||||
So the seam, stated accurately: **OPT-09 stops the ask AND narrows SAFETY-42's
|
||||
detection window for a low-headroom unit.** The two cards do not merely meet from
|
||||
opposite sides; this one takes something away from the other one. Closing the gap
|
||||
means making `delinquent()` relative to the ask rather than absolute, which is
|
||||
SAFETY-42's code and a design change - it is filed as its own card, deliberately
|
||||
not done here, and this paragraph is its input. Until then this is a **disclosed
|
||||
residual**, in the same voice as SAFETY-13's "2 of 272" and SAFETY-42's own "the
|
||||
discriminator does not exist": at two inverters, a wedged unit with materially
|
||||
less energy headroom than its sibling can go unreported at ordinary house loads.
|
||||
### ⚠️ CLOSED BY SAFETY-43 - THIS IS NO LONGER A DISCLOSED RESIDUAL
|
||||
|
||||
This section used to end *"until then this is a disclosed residual [...] at two
|
||||
inverters, a wedged unit with materially less energy headroom than its sibling
|
||||
can go unreported at ordinary house loads"*. That sentence is **retired, not
|
||||
softened**, and what replaces it is a measurement rather than an intention.
|
||||
|
||||
`delivery.delinquent()` is now relative to the ask **as well as** absolute:
|
||||
|
||||
named if short_by() > min_w OR short_by() > frac * share
|
||||
|
||||
with `frac = delivery.DELIVERY_SHARE_FRACTION`. A unit delivering 0 of 125 W is
|
||||
100 % delinquent and is named; a unit delivering 0 of 1500 W was already named
|
||||
and still is. On the **same fixture, under the same weights**, part 8b now
|
||||
grades both rules in one run - `share_frac=None` is SAFETY-42's absolute arm,
|
||||
exactly:
|
||||
|
||||
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
|
||||
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
|
||||
|
||||
⚠️ **`min_w` WAS NOT LOWERED, AND THAT IS THE HALF THAT MATTERS HERE.** The
|
||||
absolute arm's suppression window is still exactly the arithmetic above - 1000 W
|
||||
at equal weights, 6500 W at these - and part 8b still asserts both numbers.
|
||||
Lowering `saturation_w` would have traded a missed alarm for a false one on
|
||||
every unit at once, which is SAFETY-41's four-attempt regime. SAFETY-43 added a
|
||||
**second** way to be named and left the first where it was.
|
||||
|
||||
⚠️ **AND WHAT OPT-09 STILL DOES NOT DO IS UNCHANGED.** This card stops the site
|
||||
ASKING a pack whose own SoC says it cannot deliver; it does not re-allocate a
|
||||
wedged unit's watts, and SAFETY-43 did not make it. The alarm got louder; the
|
||||
loop did not move. The derivation of `frac` - swept over the same 2640 no-fault
|
||||
runs that produced `delivery_hold_s`'s floor - is in
|
||||
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md`.
|
||||
|
||||
So the seam, stated accurately **as of SAFETY-43**: OPT-09 stops the ask, and it
|
||||
still narrows the ABSOLUTE arm's window for a low-headroom unit exactly as
|
||||
described above - but it no longer narrows the DETECTOR, because the detector no
|
||||
longer reads only that arm.
|
||||
|
||||
⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without
|
||||
connecting it.** That module's rejection of re-allocation rests on *"the only
|
||||
|
||||
@@ -142,6 +142,16 @@ take the **longest run of consecutive cycles** on which it names anybody — not
|
||||
count of naming samples, because the window is cleared by any cycle that does
|
||||
not.
|
||||
|
||||
⚠️ **SINCE [[SAFETY-43]] THE ROWS OF §3.3's TABLE ARE GRADED WITH
|
||||
`share_frac=None`** — `delinquent()`'s ABSOLUTE arm alone, which is this card's
|
||||
rule exactly. Said here because it is the difference between §3.3 still meaning
|
||||
what it is labelled as and quietly becoming a table about a different predicate:
|
||||
SAFETY-43 added a SECOND way to be named, the harness grades it in its own rows
|
||||
(`--fractions`), and the numbers below are unchanged and were re-measured to
|
||||
confirm it. `DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` §3 carries
|
||||
those rows and what they cost — the worst legitimate episode goes from 35.0 s to
|
||||
40.0 s at the shipped fraction, which does not move `delivery_hold_s`.
|
||||
|
||||
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
|
||||
command standing before the write" is the **controller's** standing command —
|
||||
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
# SAFETY-43 — delinquency measured against the ask, not against a flat 500 W
|
||||
|
||||
**Status: closed.** `delivery.delinquent()` names a unit whose `short_by()` gap
|
||||
exceeds `min_w` **or** exceeds a fraction of that unit's own share. The fraction
|
||||
is `delivery.DELIVERY_SHARE_FRACTION = 0.85`, derived on the campaign in §3.
|
||||
|
||||
⚠️ **INVERTER B DOES NOT PHYSICALLY EXIST** (expected ~2026-09-12; NOTES.md
|
||||
§1.1b). `plantsim.INVERTER_B` is inverter A **copied**, `measured=False`, and
|
||||
`test_packshare.py`'s plant is modelled in that file. **Every two-inverter
|
||||
figure in this document is two copies of A.** Nothing here is a measurement of
|
||||
inverter B.
|
||||
|
||||
## 0. The defect, and why it is the quiet regime that matters
|
||||
|
||||
[[OPT-09]] passes SoC- and capacity-aware weights to the allocator. A weight is
|
||||
a function of a pack's **energy headroom**, not of what the pack delivers, so a
|
||||
**wedged** unit — one that accepts its share and produces nothing — is weighted
|
||||
down exactly like a legitimately low-headroom one. `delinquent()` read an
|
||||
**absolute** gap, `min_w = Tuning.saturation_w = 500 W`. Shrinking the share
|
||||
shrinks the gap. Measured end to end on the real loop (`test_packshare.py`
|
||||
part 8b: real `compute()`, real `plan()`, real `packshare.weights()`, real
|
||||
`delivery.account()`, real `delinquent()`; B wedged at a healthy 15 % against its
|
||||
own 10 % cut-out, so no envelope closes and `account()` explains nothing — a
|
||||
SAFETY-42 **true positive** by that card's own definition):
|
||||
|
||||
load 1500 W equal weights shares {a:1500, b:1500} named on 199/200
|
||||
load 1500 W OPT-09 weights shares {a:1505, b: 125} named on 0/200
|
||||
|
||||
A unit delivering **0 of 125 W** is 100 % delinquent and 125 W short. Only the
|
||||
absolute threshold disagreed.
|
||||
|
||||
⚠️ **THE SUPPRESSED REGIME IS THE DANGEROUS ONE.** At a load the healthy sibling
|
||||
covers, the meter is satisfied (grid within 5 W of zero on that fixture),
|
||||
`unmet_w` is 0 because both units *accepted* their shares, no envelope closes and
|
||||
no action-log line fires. The delinquency alarm is the **only** instrument that
|
||||
would have told the operator the site was quietly running one inverter.
|
||||
|
||||
## 1. What was NOT done, and it is the larger half of this card
|
||||
|
||||
⚠️ **`min_w` WAS NOT LOWERED.** It is `Tuning.saturation_w` — the gap at which
|
||||
the control law itself concludes the inverter is not following — and it exists to
|
||||
stop **normal saturation** being reported as delinquency. Lowering it trades a
|
||||
missed alarm for a false one on every unit at once. [[SAFETY-41]] spent **four
|
||||
attempts** on notification cadence in this system and the owner's words were
|
||||
*"i'm getting really annoyed by the notifications in my HA"*.
|
||||
|
||||
So the change is an **`or`**, never a looser first arm:
|
||||
|
||||
named if short_by() > min_w OR short_by() > frac * share
|
||||
|
||||
Everything `min_w` named before is still named. Nothing `min_w` refused becomes
|
||||
noisy **unless the unit is delivering almost none of its ask**, which is not
|
||||
saturation-shaped at all. `test_delivery.py` part 1 runs all seven of its
|
||||
existing `delinquent()` fixtures under **both** rules — `share_frac=None` is
|
||||
SAFETY-42's rule exactly — and requires identical answers on every one.
|
||||
|
||||
The absolute arm's suppression window is therefore **unchanged**: still
|
||||
`site target < 2·min_w` = 1000 W at equal weights, still
|
||||
`site target < min_w·(w_A+w_B)/w_B` = 6500 W under OPT-09's weights on part 8b's
|
||||
fixture. Part 8b still asserts both numbers, now as evidence that the first arm
|
||||
did not move.
|
||||
|
||||
## 2. The evidence is a diff between two rules on ONE run
|
||||
|
||||
`delinquent()` takes `share_frac`, and **`None` means the absolute arm alone**.
|
||||
That parameter exists for one reason: so the red and the green come out of one
|
||||
run over one tree instead of out of two commits that a reader has to trust.
|
||||
`test_packshare.py` part 8b grades every cycle twice:
|
||||
|
||||
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
|
||||
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
|
||||
|
||||
load 1500 W equal weights absolute arm alone named on 199/200 cycles
|
||||
load 1500 W equal weights the shipped rule named on 200/200 cycles
|
||||
|
||||
The equal-weights arm gains **exactly one cycle** — the first, where the slew
|
||||
limit holds the split at 500/500, a 500 W gap is not *over* a 500 W threshold
|
||||
(the absolute bound is strict) and the relative arm names a box delivering none
|
||||
of its 500 W. That single cycle is the whole of what this card costs an arm that
|
||||
was already firing.
|
||||
|
||||
⚠️ **THE PIN THAT INVERTED.** Part 8b was written by OPT-09 to pin the
|
||||
**suppressed** behaviour, and its comment said: *"if it ever goes red because the
|
||||
alarm fired, read the card that made it fire before 'fixing' this line."* This is
|
||||
that card. The pin now asserts the opposite answer and keeps the old one beside
|
||||
it, measured rather than remembered.
|
||||
|
||||
## 3. ⭐ Deriving the fraction
|
||||
|
||||
⚠️ **DERIVED ON THE SAME CAMPAIGN AS `delivery_hold_s`'s FLOOR**, not chosen.
|
||||
`admin/goodwe:sim/delivery_window.py` — the harness [[TEST-21]] committed —
|
||||
gained a second grading axis: the same **2640 no-fault runs at N=2** (three
|
||||
families × 6 gains × 5 slews × write staggers; every unit executes the share the
|
||||
allocator gave it) and the same **2640 N=1 control runs**, graded at the shipped
|
||||
`min_w = 500 W` for each candidate fraction, on every cycle of every run — so a
|
||||
pass grades every fraction it is given, and the absolute arm beside them, over
|
||||
one tree and one set of runs. Every row in a pass is comparable to every other
|
||||
row in it, run for run.
|
||||
|
||||
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
python3 -B delivery_window.py --selftest --campaign --fractions
|
||||
|
||||
⚠️ **`FRACTIONS` IN THAT FILE HOLDS TEN VALUES AND THE TABLE BELOW HAS TWENTY.**
|
||||
It was run twice - one pass over `(0.25, 0.50, 0.60, 0.70, 0.75, 0.80, 0.85,
|
||||
0.90, 0.95, 0.99)`, the shipped list, and one over `(0.05, 0.10, 0.15, 0.20,
|
||||
0.30, 0.35, 0.40, 0.45, 0.55, 0.65)` with only that tuple changed, to resolve
|
||||
the low end. **The absolute-arm control row came out identical in both**
|
||||
(30.0 / 0.0 / 35.0 / 0.0), which is what says the two passes are one campaign.
|
||||
|
||||
### 3.1 The curve
|
||||
|
||||
Worst **continuous** legitimate episode (the measure `DeliveryWatchdog` actually
|
||||
sees — its window is cleared by any cycle that does not name), runs naming in
|
||||
brackets:
|
||||
|
||||
| `share_frac` | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| absolute only | 30.0 s (460) | 0.0 s (0) | 35.0 s (403) | **0.0 s (0)** |
|
||||
| 0.05 | 85.0 s (826) | 0.0 s (0) | 90.0 s (616) | **0.0 s (0)** |
|
||||
| 0.10 | 75.0 s (824) | 0.0 s (0) | 90.0 s (607) | **0.0 s (0)** |
|
||||
| 0.15 | 70.0 s (836) | 0.0 s (0) | 70.0 s (609) | **0.0 s (0)** |
|
||||
| 0.20 | 70.0 s (839) | 0.0 s (0) | 60.0 s (629) | **0.0 s (0)** |
|
||||
| 0.25 | 55.0 s (832) | 0.0 s (0) | 60.0 s (611) | **0.0 s (0)** |
|
||||
| 0.30 | 55.0 s (840) | 0.0 s (0) | 50.0 s (614) | **0.0 s (0)** |
|
||||
| 0.35 | 55.0 s (840) | 0.0 s (0) | 55.0 s (618) | **0.0 s (0)** |
|
||||
| 0.40 | 50.0 s (840) | 0.0 s (0) | 60.0 s (658) | **0.0 s (0)** |
|
||||
| 0.45 | 45.0 s (840) | 0.0 s (0) | 45.0 s (627) | **0.0 s (0)** |
|
||||
| 0.50 | 45.0 s (840) | 0.0 s (0) | 45.0 s (636) | **0.0 s (0)** |
|
||||
| 0.55 | 40.0 s (840) | 0.0 s (0) | 40.0 s (636) | **0.0 s (0)** |
|
||||
| 0.60 | 40.0 s (840) | 0.0 s (0) | 40.0 s (645) | **0.0 s (0)** |
|
||||
| 0.65 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.70 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.75 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.80 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| **0.85** | **35.0 s (840)** | **0.0 s (0)** | **40.0 s (635)** | **0.0 s (0)** |
|
||||
| 0.90 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
| 0.95 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
| 0.99 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
|
||||
Worst of the three families, which is what `delivery_hold_s` has to cover:
|
||||
|
||||
frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
|
||||
worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
|
||||
|
||||
frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
|
||||
worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
|
||||
|
||||
⚠️ **THE FIRST ROW IS THE CONTROL AND IT REPRODUCED SAFETY-42 §3.3 EXACTLY** —
|
||||
30.0 s / 0.0 s / 35.0 s, and the same argmax (`gain=0.2 slew_w=1000 stagger=4
|
||||
split=0.10/0.90`, ±2200 W reversal). It is measured **in the same run** as every
|
||||
row below it, which is what makes those rows a comparison rather than a claim.
|
||||
|
||||
### 3.2 What the curve says
|
||||
|
||||
* ⚠️ **THERE IS NO CLIFF ANYWHERE IN THE CURVE, AND THAT IS THE FINDING.** From
|
||||
**0.45 to 0.99** every figure is 40.0 or 45.0 s — within **one control cycle
|
||||
(5.0 s)** of every other — and below 0.45 it rises **gradually**: 60.0 s at
|
||||
0.25, 70.0 s at 0.15, 90.0 s at 0.05. A reviewer asking "why 0.85 and not
|
||||
0.90?" is asking about a difference this campaign cannot resolve, and the
|
||||
honest answer is that it cannot.
|
||||
* ⚠️ **IT IS NOT MONOTONE** — 0.40 reports 60.0 s where 0.35 reports 55.0, and
|
||||
0.65–0.80 report 45.0 s where 0.55/0.60 and 0.85–0.99 report 40.0 — and that is
|
||||
SAFETY-42 §3.3's own mechanism (c) rather than noise in the plant: **a more
|
||||
sensitive rule puts MORE units into the short set, which can turn a proper
|
||||
subset into the WHOLE roster and END the episode.** The reduction is not
|
||||
monotone in its own threshold. Do not read this column as a sensitivity curve
|
||||
in the ordinary sense.
|
||||
* **The shoulder is at about 0.45**, and it is a shoulder rather than an edge:
|
||||
the reversal family's naming count saturates at 840 of 1050 runs from 0.30
|
||||
upward, so below the shoulder what grows is the LENGTH of an episode, not the
|
||||
number of runs that have one.
|
||||
* **The N=1 column is 0.0 s at every fraction, in all 2640 one-inverter runs.**
|
||||
The compatibility promise is measured for the new arm, not argued.
|
||||
* **The flapping family is 0.0 s at every fraction, in all 840 runs.** A common
|
||||
stimulus makes both units short at once and the subset stops being proper —
|
||||
the property that defeated SAFETY-30's site window still holds.
|
||||
|
||||
### 3.3 ⚠️ "Zero false namings" is not available at ANY fraction
|
||||
|
||||
The card asked for *the largest fraction that yields zero false namings across
|
||||
the whole no-fault campaign*. **There is none.** Every fraction adds naming
|
||||
cycles: the reversal family goes from 460 runs naming to 840, the weighted family
|
||||
from 403 to 635. A relative arm that never fired on a legitimate transient would
|
||||
be a relative arm that never fired at all — the write stagger lands B's number up
|
||||
to four cycles late, and a unit whose new command has not arrived is, for those
|
||||
cycles, delivering a small fraction of what is standing on its wire.
|
||||
|
||||
What **is** zero at every fraction is **false alarms**:
|
||||
|
||||
> the longest continuous legitimate episode anywhere in the 20-point sweep is
|
||||
> **90.0 s** — at 0.05, the most sensitive fraction measured — against a
|
||||
> `delivery_hold_s` of **185 s**. Nothing in 2640 no-fault runs comes within
|
||||
> **95 s** of notifying an operator, **at any fraction measured**. At the
|
||||
> shipped 0.85 it is 40.0 s, i.e. 145 s of slack.
|
||||
|
||||
That is the quantity SAFETY-41 cares about and it is the one this card had to
|
||||
protect. Stated as margin, at the shipped fraction: **185 s is 4.62×** the worst
|
||||
legitimate episode, where the absolute arm alone gave 5.29×.
|
||||
|
||||
### 3.4 The bounds 0.85 answers to
|
||||
|
||||
* **LOWER, from the campaign** — the shoulder at about **0.45**, below which the
|
||||
added episode starts to grow (60.0 s at 0.25, 90.0 s at 0.05). 0.85 is 0.40
|
||||
above it.
|
||||
* **UPPER, from the card's own case** — strictly **below 1.0**. At 1.0 the arm
|
||||
can only fire on a unit that is *importing* while asked to discharge, which is
|
||||
SAFETY-30's fault and not this one, so a unit delivering **none** of its share
|
||||
(gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
|
||||
wedged box reporting a small standby figure instead of a clean zero.
|
||||
* **UPPER, from the hold** — every fraction in the flat range leaves
|
||||
`delivery_hold_s` between **4.11×** (45.0 s) and **4.62×** (40.0 s) the worst
|
||||
legitimate episode, well inside the four bounds SAFETY-42 §3.3 requires of it.
|
||||
**`delivery_hold_s` IS NOT CHANGED BY THIS CARD.**
|
||||
* ⚠️ **NOT DERIVED, AND SAID SO: the exact value inside [0.45, 1.0).** The
|
||||
campaign cannot separate them. 0.85 is the midpoint of the argument above —
|
||||
0.40 of margin above the shoulder, 0.15 below the ceiling — and a
|
||||
re-derivation that moved it to 0.90 would be moving a number this campaign
|
||||
never resolved. It is not moved for that reason.
|
||||
|
||||
## 4. The positive control — what the relative arm must still NOT say
|
||||
|
||||
A card that adds a way to raise an alarm has to show what it did **not** add.
|
||||
`test_packshare.py` part 8b runs the same fixture, the same weights and the same
|
||||
125 W share with the one fact that makes the shortfall legitimate changed:
|
||||
|
||||
| fixture | named |
|
||||
|---|---|
|
||||
| B wedged at a healthy 15 % (the fault) | **200 / 200** |
|
||||
| B at its own 10 % cut-out, otherwise identical trace | **0 / 200** |
|
||||
| B delivering **half** its share | **0 / 200** |
|
||||
|
||||
The first and second have **identical power traces** — 0 W against a positive
|
||||
share, 100 % of the ask missing, the relative arm's exact shape — and what
|
||||
separates them is `account()`, the pack's own `bms_soc_floor`. SAFETY-43 did not
|
||||
go near that gate, and the third row is ordinary tracking on a small share, which
|
||||
neither arm reports.
|
||||
|
||||
`test_delivery.py` part 1 carries the same controls on the predicate itself,
|
||||
including that the relative bound is **strict** (a gap of exactly `frac · share`
|
||||
is not over it) and that the proper-subset rule still refuses a site-wide cause
|
||||
(both units delivering none of a 125 W share names nobody).
|
||||
|
||||
## 5. N=1 is byte-identical, and it is asserted rather than argued
|
||||
|
||||
The live install is **N=1**. Two independent statements, each graded against the
|
||||
thing that would break it (`test_packshare.py` part 8b):
|
||||
|
||||
1. **`allocator.plan()` returns before `weights` is read at all.** Graded by
|
||||
handing it a `weights` mapping that **raises on any read**: at a roster of one
|
||||
it answers identically to the same call with no weights; at a roster of two
|
||||
the same mapping is read and raises, so the tripwire is a real one.
|
||||
2. **`delinquent()` answers `{}` at a roster of one for every input** — 84
|
||||
combinations of (share, measured, rule), including **0 of 125 W**, the exact
|
||||
input this card added an arm for. `len(asked) < 2` returns before either
|
||||
threshold is compared, so the fraction is never even multiplied.
|
||||
|
||||
Nothing in this card is reachable at one inverter. It must land before inverter B
|
||||
is commissioned (~2026-09-12), not before the next deploy.
|
||||
|
||||
## 5b. Mutation — the fraction is pinned from BOTH directions
|
||||
|
||||
⚠️ **RUN, NOT REASONED ABOUT.** Each mutation was applied to a tree **copied
|
||||
fresh** from this branch and mounted `:ro` inside the checks image, so no `.pyc`
|
||||
could go stale — MUTATION.md's defect, and all three of these are **reds**, the
|
||||
direction that defect cannot fabricate. Recorded in full in `test_packshare.py`
|
||||
part 11 as M12–M14.
|
||||
|
||||
| mutation | caught by |
|
||||
|---|---|
|
||||
| **M12** drop the relative arm, keep `gap > min_w` — *the card itself reverted* | `test_delivery.py` part 1 (1 named check) and `test_packshare.py` 8b (4): the GREEN check goes back to 0/200, the equal arm back to 199, the 6000 W arm back to 195/199. `test_alloc.py` and `test_control.py` stay green — nothing outside this check moves. |
|
||||
| **M13** `DELIVERY_SHARE_FRACTION` 0.85 → **1.0** — *too loose* | 5 named checks, including part 8b's PRECONDITION that the fraction is strictly below 1.0. At 1.0 the card is a no-op: gap/share of exactly 1.0 is not *over* 1.0. |
|
||||
| **M14** `DELIVERY_SHARE_FRACTION` 0.85 → **0.05** — *too sensitive* | `test_delivery.py` parts 1 and **4b** (4 named checks) and part 8b's DERATED positive control: a unit delivering half its share starts being named, and 4b's `saturation_w` pin goes vacuous. |
|
||||
|
||||
M13 and M14 are the pair that matters: the constant cannot be moved in **either**
|
||||
direction without a named check moving with it.
|
||||
|
||||
## 6. ⚠️ [[CFG-07]] is adjacent and is NOT absorbed here
|
||||
|
||||
CFG-07 is that `delivery_hold_s` is declared `int(30,900)` in `config.yaml`'s
|
||||
schema while the measured worst legitimate episode at the shipped threshold is
|
||||
**35.0 s** — so the schema offers an operator a value (30) that is **below a
|
||||
measured legitimate episode** and would report on ordinary saturation.
|
||||
|
||||
**This card makes it worse, and by exactly one control cycle.** The relative arm
|
||||
takes the worst legitimate episode from **35.0 s to 40.0 s** at the shipped
|
||||
fraction, so the band of schema-legal but false-tripping values widens from
|
||||
`[30, 35]` to `[30, 40]`. The floor the schema should have is now **40 s**, not
|
||||
35 s. It is not fixed here: raising a schema floor is an option-surface change
|
||||
with its own compatibility argument (an existing config carrying `35` would start
|
||||
being refused), and folding it into a safety fix would hide it. **CFG-07 should
|
||||
be re-derived against 40.0 s, not 35.0 s.**
|
||||
|
||||
## 7. Provenance of every number in this document
|
||||
|
||||
| number | where it came from |
|
||||
|---|---|
|
||||
| 199/200, 0/200, 200/200, shares 1505/125 | `test_packshare.py` part 8b, run |
|
||||
| 35.0 s absolute-arm floor, and its argmax | `delivery_window.py --campaign`, re-run here, reproduces SAFETY-42 §3.3 |
|
||||
| every row of §3.1 | `delivery_window.py --campaign --fractions`, two passes over the same campaign with `FRACTIONS` changed between them |
|
||||
| 1000 W / 6500 W suppression windows | part 8b's arithmetic, asserted there |
|
||||
| 185 s, 5.29× | SAFETY-42 §3.3 / TEST-21, unchanged by this card |
|
||||
| 4.62× | 185 / 40.0, printed by the campaign |
|
||||
| 0/200 on both positive controls | part 8b, run |
|
||||
| 84 single-unit rosters | part 8b, run |
|
||||
|
||||
⚠️ Every two-inverter figure above is **two copies of inverter A**.
|
||||
@@ -414,6 +414,93 @@ DELIVERY_OPTION_DEFAULTS = {
|
||||
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-43. THE SECOND WAY TO BE NAMED, AND WHY IT IS A FRACTION OF THE ASK.
|
||||
#
|
||||
# `min_w` above is an ABSOLUTE gap, and OPT-09 made that a hole. `weights()`
|
||||
# shrinks a low-headroom unit's share, and shrinking the share shrinks the gap:
|
||||
# a unit standing at +125 W and delivering 0 W is 100 % delinquent and 125 W
|
||||
# short, so an absolute 500 W never names it. Measured end to end
|
||||
# (`test_packshare.py` part 8b, real `compute()`, real `plan()`, real
|
||||
# `delinquent()`, B wedged at a healthy 15 % against its own 10 % cut-out):
|
||||
#
|
||||
# load 1500 W, equal weights the wedged unit named on 199/200 cycles
|
||||
# load 1500 W, OPT-09 weights named on 0/200 cycles
|
||||
#
|
||||
# ⚠️ AND THE SUPPRESSED REGIME IS THE QUIET ONE. At a load the healthy sibling
|
||||
# covers, the meter is satisfied, `unmet_w` is 0 and no action-log line fires,
|
||||
# so this alarm is the ONLY instrument that would have told the operator.
|
||||
#
|
||||
# ⚠️ THE FIX IS NOT A SMALLER `min_w`, AND THAT IS THE WHOLE SHAPE OF THIS
|
||||
# CARD. `min_w` is `Tuning.saturation_w` and it exists to stop NORMAL
|
||||
# SATURATION being reported as delinquency - the crying-wolf failure the whole
|
||||
# module is written against, and the one SAFETY-41 spent four attempts on
|
||||
# ("i'm getting really annoyed by the notifications in my HA"). Lowering it
|
||||
# trades a missed alarm for a false one on every unit at once. A fraction of
|
||||
# the unit's OWN SHARE is a SECOND way to be named that leaves the first
|
||||
# exactly where it was: `gap > min_w OR gap > frac * share`. Nothing that was
|
||||
# silent before this card becomes noisy unless the unit is delivering almost
|
||||
# none of what it was asked for, which is not saturation-shaped at all.
|
||||
#
|
||||
# WHERE 0.85 COMES FROM - MEASURED ON THE SAME CAMPAIGN AS `delivery_hold_s`.
|
||||
# `admin/goodwe:sim/delivery_window.py --campaign --fractions` grades this
|
||||
# constant over the SAME 2640 no-fault runs (three families x 6 gains x 5 slews
|
||||
# x the write staggers, every unit executing the share it was given) that
|
||||
# produced the 35.0 s worst legitimate episode, at the shipped `min_w`, and
|
||||
# reports what the relative arm ADDS. Worst continuous legitimate episode,
|
||||
# worst of the three families:
|
||||
#
|
||||
# frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
|
||||
# worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
|
||||
#
|
||||
# frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
|
||||
# worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
|
||||
#
|
||||
# absolute arm alone: 35.0 s - SAFETY-42 §3.3's figure, re-measured in the
|
||||
# SAME runs, which is what makes every row above a comparison and not a claim.
|
||||
# N=1 control: 0.0 s at EVERY fraction, in all 2640 one-inverter runs.
|
||||
# Flapping family: 0.0 s at EVERY fraction, in all 840 runs.
|
||||
#
|
||||
# ⚠️ READ THE CURVE, NOT THE ROW. THERE IS NO CLIFF ANYWHERE IN IT. From 0.45
|
||||
# to 0.99 every figure is 40.0 or 45.0 s - within ONE CONTROL CYCLE (5.0 s) of
|
||||
# every other - and below 0.45 it rises gradually to 90.0 s at 0.05. It is not
|
||||
# monotone either (0.40 reports 60.0 s where 0.35 reports 55.0), which is
|
||||
# §3.3's own mechanism (c) rather than noise in the plant: a more sensitive rule
|
||||
# puts MORE units into the short set, which can turn a proper subset into the
|
||||
# WHOLE roster and END the episode. So this constant is INSENSITIVE over the
|
||||
# whole usable range, and that is the finding rather than a caveat on it. 0.85
|
||||
# sits in the flat part, 0.40 above the shoulder at ~0.45 and 0.15 below 1.0.
|
||||
#
|
||||
# ⚠️ AND "ZERO FALSE NAMINGS" IS NOT AVAILABLE AT ANY FRACTION - said here
|
||||
# because the card asked for exactly that. Every fraction adds naming CYCLES to
|
||||
# the no-fault campaign; a relative arm that never fired on a legitimate
|
||||
# transient would be one that never fired at all, because the modelled write
|
||||
# stagger lands B's number up to four cycles late and a unit whose new command
|
||||
# has not arrived IS delivering a small fraction of what is standing on its
|
||||
# wire. What is zero at every fraction is false ALARMS: the longest legitimate
|
||||
# episode anywhere in the 20-point sweep is 90.0 s (at 0.05, twelve times more
|
||||
# sensitive than shipped) against a `delivery_hold_s` of 185 s, so NOTHING in
|
||||
# 2640 no-fault runs comes within 95 s of notifying an operator at any fraction
|
||||
# measured. At 0.85 the hold is 4.62x the worst legitimate episode where the
|
||||
# absolute arm alone gave 5.29x. That is the price, in full.
|
||||
#
|
||||
# ⚠️ THE DIRECTION IS THE OPPOSITE OF `min_w`'s AND IT IS EASY TO GET BACKWARDS.
|
||||
# A SMALLER fraction names MORE units - it is the more sensitive setting and the
|
||||
# more dangerous one - so the campaign puts a LOWER bound on this number, not an
|
||||
# upper one. It is bounded ABOVE by the card's own case: at 1.0 the arm can only
|
||||
# fire on a unit that is IMPORTING while asked to discharge, which is
|
||||
# SAFETY-30's fault and not this one, so a unit delivering NONE of its share
|
||||
# (gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
|
||||
# wedged box reporting a small standby figure rather than a clean zero.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT AN OPTION, FOR `delivery_w`'s REASON ONE PARAGRAPH UP. An
|
||||
# operator has no instrument to derive it with, a knob here is a knob on how
|
||||
# loud this add-on is, and SAFETY-41 is the record of what that costs. It is a
|
||||
# derived constant with a committed harness behind it, and the way to move it
|
||||
# is to re-run the campaign.
|
||||
DELIVERY_SHARE_FRACTION = 0.85
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryConfig:
|
||||
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
||||
@@ -532,12 +619,30 @@ def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def delinquent(units, min_w: float) -> dict:
|
||||
def delinquent(units, min_w: float,
|
||||
share_frac: float = DELIVERY_SHARE_FRACTION) -> dict:
|
||||
"""Which units are short with NO ACCOUNT, when the roster disagrees with them.
|
||||
|
||||
`units` one `UnitDelivery` per inverter, in roster order.
|
||||
`min_w` the shortfall that counts - `control.Tuning.saturation_w`. See
|
||||
`MIN_W_IS_SATURATION_W`.
|
||||
`min_w` the ABSOLUTE shortfall that counts - `control.Tuning.
|
||||
saturation_w`. See `MIN_W_IS_SATURATION_W`.
|
||||
`share_frac` SAFETY-43. The RELATIVE shortfall that counts, as a fraction
|
||||
of that unit's own share. See `DELIVERY_SHARE_FRACTION`; it
|
||||
is a parameter rather than a read of the constant so the
|
||||
campaign that derived it can sweep it over the shipped code.
|
||||
⚠️ `None` MEANS THE ABSOLUTE ARM ALONE - SAFETY-42's rule,
|
||||
exactly - and it exists for ONE caller: the campaign in
|
||||
`admin/goodwe:sim/delivery_window.py`, which has to be able
|
||||
to measure what this card ADDS against what it replaced, run
|
||||
for run, over ONE tree. Nothing in the add-on passes it, and
|
||||
`main.py` passes nothing at all.
|
||||
|
||||
⚠️ THE TWO ARMS ARE AN `or` AND THE ORDER OF THAT SENTENCE MATTERS. The
|
||||
absolute arm is untouched by SAFETY-43: everything `min_w` named before it
|
||||
is still named, and nothing `min_w` refused becomes noisy unless the unit
|
||||
is delivering almost NONE of its ask. Lowering `min_w` would have been the
|
||||
other change - the one that reports normal saturation on every unit at once
|
||||
- and `DELIVERY_SHARE_FRACTION` says at length why it was refused.
|
||||
|
||||
Returns `{name: sentence}`, empty when there is nothing to say. The sentence
|
||||
is the operator's first line and it carries the peer clause, because "B is
|
||||
@@ -579,7 +684,13 @@ def delinquent(units, min_w: float) -> dict:
|
||||
gaps = {}
|
||||
for u in asked:
|
||||
gap = short_by(u.share_w, u.measured_w)
|
||||
if gap is not None and gap > float(min_w):
|
||||
if gap is None:
|
||||
continue
|
||||
# SAFETY-43: absolute OR relative. `share_w` is > 0 for every member
|
||||
# of `asked`, so the second comparison is always well formed.
|
||||
if gap > float(min_w) or (share_frac is not None
|
||||
and gap > float(share_frac)
|
||||
* float(u.share_w)):
|
||||
gaps[u.name] = gap
|
||||
|
||||
unaccounted = {u.name: gaps[u.name] for u in asked
|
||||
@@ -595,7 +706,13 @@ def delinquent(units, min_w: float) -> dict:
|
||||
out[u.name] = (
|
||||
f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W "
|
||||
f"and delivering {float(u.measured_w):+.0f} W - "
|
||||
f"{unaccounted[u.name]:.0f} W of its share is not arriving - while "
|
||||
f"{unaccounted[u.name]:.0f} W of its share "
|
||||
# SAFETY-43. The PROPORTION is in the sentence because the watts
|
||||
# alone stopped being the whole story the moment a unit could be
|
||||
# named on the relative arm: "125 W is not arriving" reads like a
|
||||
# rounding error, and "125 W (100 % of its share)" is the fault.
|
||||
f"({100.0 * unaccounted[u.name] / float(u.share_w):.0f} % of it) "
|
||||
f"is not arriving - while "
|
||||
f"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
|
||||
f"that state. Nothing this add-on reads explains it")
|
||||
return out
|
||||
|
||||
@@ -57,8 +57,8 @@ deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
|
||||
never rises. That module DETECTS it; this one cannot fix it, and saying so
|
||||
is worth more than a weighting scheme that looks like it might.
|
||||
|
||||
⚠️ AND IT IS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROW SAFETY-42's
|
||||
DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2
|
||||
⚠️ AND IT WAS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROWED SAFETY-42's
|
||||
DETECTION WINDOW - A DETECTION REGRESSION AT N=2, NOW CLOSED BY SAFETY-43
|
||||
=======================================================================
|
||||
This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said
|
||||
with it - that a wedged unit's weight does not move, so OPT-09 cannot see the
|
||||
@@ -68,27 +68,40 @@ pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight
|
||||
moves whenever its headroom differs from its sibling's - the ordinary case this
|
||||
module exists to exploit.
|
||||
|
||||
`delivery.delinquent()` names a unit only while its `short_by()` gap exceeds
|
||||
`delivery.delinquent()` named a unit only while its `short_by()` gap exceeded
|
||||
`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's
|
||||
share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real
|
||||
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
|
||||
own 10 % floor, so `envelope()` does not close and `account()` explains
|
||||
nothing): at a 1500 W load the healthy pack can carry alone, equal weights name
|
||||
the wedged unit on 199 of 200 cycles and these weights name it on NONE. The
|
||||
suppression window widens from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it has
|
||||
own 10 % cut-out, so `envelope()` does not close and `account()` explains
|
||||
nothing): at a 1500 W load the healthy pack can carry alone, equal weights named
|
||||
the wedged unit on 199 of 200 cycles and these weights named it on NONE. The
|
||||
suppression window widened from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it had
|
||||
an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass
|
||||
hands the wedged unit a big share again and the alarm returns.
|
||||
hands the wedged unit a big share again and the absolute arm names it.
|
||||
|
||||
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE - KEPT HERE BECAUSE IT IS A MEASUREMENT, EVEN THOUGH SAFETY-43 HAS SINCE MADE THE WINDOW IRRELEVANT TO THE DETECTOR. Swept on this fixture with the ABSOLUTE arm alone, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returned BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm named the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
|
||||
⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK
|
||||
**AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That
|
||||
is a disclosed residual, not a fixed thing. Closing it means making
|
||||
`delinquent()` relative to the ask rather than absolute - SAFETY-42's code
|
||||
and a design change, filed as its own card and deliberately NOT done here.
|
||||
Do not "fix" part 8b by making it assert the alarm still fires; it does not,
|
||||
and a green check that says otherwise would be worse than the gap.
|
||||
⚠️ THAT IS NO LONGER A RESIDUAL. SAFETY-43 made `delinquent()` relative to
|
||||
the ask AS WELL AS absolute - `gap > min_w OR gap > frac * share`, and a
|
||||
unit delivering 0 of 125 W is as delinquent as one delivering 0 of 1500 W.
|
||||
On the SAME fixture, under the SAME weights, the wedged unit is now named
|
||||
on 200 of 200 cycles. Part 8b grades both rules on one run:
|
||||
`share_frac=None` is SAFETY-42's absolute arm alone and still says nothing,
|
||||
which is what keeps the paragraph above honest rather than nostalgic.
|
||||
|
||||
⚠️ AND `min_w` WAS NOT LOWERED. The absolute arm's suppression window is
|
||||
still exactly the arithmetic above - 1000 W at equal weights, 6500 W at
|
||||
these - because a lower `saturation_w` would report NORMAL SATURATION as
|
||||
delinquency on every unit at once, which is the false-trip regime SAFETY-41
|
||||
spent four attempts in. What changed is that there is a SECOND way to be
|
||||
named; nothing that was quiet before became noisy.
|
||||
|
||||
⚠️ WHAT THIS MODULE STILL CANNOT DO IS UNCHANGED. OPT-09 stops the site
|
||||
ASKING a pack whose own SoC says it cannot deliver; it does not, and will
|
||||
not, re-allocate a wedged unit's watts to its sibling - `delivery.py`'s
|
||||
"IT REPORTS. IT DOES NOT ACT" block is the measurement behind that, and
|
||||
SAFETY-43 did not touch it. The alarm is louder; the loop is identical.
|
||||
|
||||
WHY ENERGY AND NOT POWER
|
||||
========================
|
||||
|
||||
@@ -548,8 +548,10 @@ options:
|
||||
# only ever ran at 500 could not tell this wiring from a hard-coded number.
|
||||
#
|
||||
# How long ONE inverter may stand at a discharge share it is not producing -
|
||||
# short by more than `saturation_w` - while at least one sibling is NOT in
|
||||
# that state, before the add-on names it. It REPORTS: nothing is commanded
|
||||
# short by more than `saturation_w`, OR short by more than a fixed FRACTION
|
||||
# of its own share (SAFETY-43; the fraction is derived and is not an option,
|
||||
# see app/delivery.py) - while at least one sibling is NOT in that state,
|
||||
# before the add-on names it. It REPORTS: nothing is commanded
|
||||
# differently, the loop is untouched, and the site goes on running short by
|
||||
# that unit's share until somebody looks. app/delivery.py carries why, with
|
||||
# the rig measurements on both sides of the decision.
|
||||
|
||||
@@ -33,8 +33,9 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.control import Tuning
|
||||
from app.delivery import (
|
||||
DELIVERY_OPTION_DEFAULTS, REASON_CODE, DeliveryConfig, DeliveryWatchdog,
|
||||
UnitDelivery, account, delinquent, short_by,
|
||||
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
|
||||
DeliveryConfig, DeliveryWatchdog, UnitDelivery, account, delinquent,
|
||||
short_by,
|
||||
)
|
||||
from app.inverter import LINK_STALE_S
|
||||
from app.main import Controller
|
||||
@@ -151,6 +152,70 @@ check("a unit given no share is not asked and cannot be named, and it is not "
|
||||
"a peer either - with only one unit asked there is nothing to compare",
|
||||
delinquent([U("a", 0, 0), U("b", 2000, 0)], 500) == {})
|
||||
|
||||
print()
|
||||
print(" SAFETY-43: THE RELATIVE ARM - short OF ITS OWN ASK, not just short")
|
||||
|
||||
# ⚠️ `share_frac=None` IS SAFETY-42's RULE EXACTLY, and every check in this part
|
||||
# above this line is run under BOTH rules below to show the card took nothing
|
||||
# away. What it ADDS is one thing: a unit can now be named for delivering almost
|
||||
# none of a SMALL share. OPT-09's weights make small shares ordinary - a
|
||||
# low-headroom unit is weighted down to 125 W of a 1630 W site target - so
|
||||
# before this card a wedged unit could be 100 % delinquent and 125 W short, and
|
||||
# 125 W is under a 500 W threshold. test_packshare.py part 8b measures that end
|
||||
# to end on the real loop; what is graded here is the predicate.
|
||||
_ABS = None # SAFETY-42's rule
|
||||
|
||||
_tiny = [U("a", 1505, 1505), U("b", 125, 0)]
|
||||
check("A UNIT DELIVERING 0 OF 125 W IS NAMED, AND UNDER THE OLD RULE IT WAS "
|
||||
"NOT. Same three units, same `min_w`, one input changed: the absolute arm "
|
||||
"alone answers {} because 125 W < 500 W, and the shipped rule names it "
|
||||
"because 100 % of its ask is not arriving",
|
||||
delinquent(_tiny, 500, _ABS) == {}
|
||||
and list(delinquent(_tiny, 500)) == ["b"])
|
||||
print(f" {delinquent(_tiny, 500)['b']}")
|
||||
check("...and the sentence carries the PROPORTION as well as the watts, because "
|
||||
"'125 W is not arriving' reads like a rounding error and '125 W (100 % of "
|
||||
"it)' is the fault",
|
||||
"125 W of its share (100 % of it) is not arriving"
|
||||
in delinquent(_tiny, 500)["b"])
|
||||
|
||||
check("THE ACCOUNT GATE IS UNTOUCHED BY IT - the same 0 of 125 W with the "
|
||||
"shortfall EXPLAINED names nobody. A relative criterion that could talk "
|
||||
"its way past `account()` would report every pack sitting at its own "
|
||||
"cut-out on a small share, which is the crying-wolf failure this module "
|
||||
"exists to avoid",
|
||||
delinquent([U("a", 1505, 1505), U("b", 125, 0, "its pack is empty")],
|
||||
500) == {})
|
||||
check("...and so is the PROPER-SUBSET rule: both units delivering none of a "
|
||||
"small share is a SITE condition and is still nobody's fault to name",
|
||||
delinquent([U("a", 125, 0), U("b", 125, 0)], 500) == {})
|
||||
check("...and the relative bound is STRICT, like the absolute one. A gap of "
|
||||
"EXACTLY the fraction of the share is not over it; one watt more is",
|
||||
delinquent([U("a", 2000, 2000),
|
||||
U("b", 1000, 1000 * (1.0 - DELIVERY_SHARE_FRACTION))],
|
||||
5000) == {}
|
||||
and list(delinquent([U("a", 2000, 2000),
|
||||
U("b", 1000,
|
||||
1000 * (1.0 - DELIVERY_SHARE_FRACTION) - 1)],
|
||||
5000)) == ["b"])
|
||||
|
||||
_both_rules = [(delinquent(u, w, _ABS), delinquent(u, w)) for u, w in (
|
||||
([U("a", 2000, 2000), U("b", 2000, 0)], 500),
|
||||
([U("a", 2000, 2000), U("b", 2000, 0, "its pack is empty")], 500),
|
||||
([U("a", 2000, 0), U("b", 2000, 0)], 500),
|
||||
([U("a", 2000, 2000), U("b", 2000, 0), U("c", 2000, 0)], 500),
|
||||
([U("a", 2000, 0, "at its floor"), U("b", 2000, 0)], 500),
|
||||
([U("a", 2000, 2000), U("b", 2000, 1500)], 500),
|
||||
([U("a", 0, 0), U("b", 2000, 0)], 500),
|
||||
)]
|
||||
check(f"⚠️ AND IT TOOK NOTHING AWAY: all {len(_both_rules)} fixtures above this "
|
||||
f"block answer IDENTICALLY under SAFETY-42's rule and the shipped one. "
|
||||
f"The relative arm is an `or`, so it can only ever ADD a naming - and on "
|
||||
f"every case this part already graded it adds none. That is the "
|
||||
f"difference between adding a second way to be named and lowering "
|
||||
f"`min_w`, which would have moved all seven",
|
||||
all(a == b for a, b in _both_rules))
|
||||
|
||||
print()
|
||||
print(" AC 5: AT ONE INVERTER IT IS INERT, AND BY ALGEBRA RATHER THAN BY A")
|
||||
print(" ROSTER GUARD - `len(asked) < 2` is unreachable-by-construction there")
|
||||
@@ -323,9 +388,14 @@ class _DeliveryHass:
|
||||
same in both.
|
||||
"""
|
||||
|
||||
def __init__(self, load_w=_LOAD_W, b_soc=50.0):
|
||||
def __init__(self, load_w=_LOAD_W, b_soc=50.0, b_frac=0.0):
|
||||
self.load_w = float(load_w)
|
||||
self.b_soc = float(b_soc)
|
||||
# SAFETY-43. What fraction of its command B actually delivers. 0.0 is a
|
||||
# WEDGED unit and is every arm in this file but part 4b(a) - see there
|
||||
# for why that one arm needs a unit that is short WITHOUT being 100 %
|
||||
# short.
|
||||
self.b_frac = float(b_frac)
|
||||
self.writes = []
|
||||
self.calls = []
|
||||
self.ctrl = None
|
||||
@@ -343,8 +413,11 @@ class _DeliveryHass:
|
||||
# A obeys. B produces NOTHING, whatever it is told - and whether that is
|
||||
# a wedged power stage or an empty pack is not a question its power
|
||||
# register can answer.
|
||||
b = self.ctrl.inverters[1]
|
||||
b_cmd = float((b.last_write if b.last_write is not None else b.target)
|
||||
or 0.0)
|
||||
return [float((a.last_write if a.last_write is not None else a.target)
|
||||
or 0.0), 0.0]
|
||||
or 0.0), self.b_frac * b_cmd]
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
m = self.measured()
|
||||
@@ -630,15 +703,27 @@ print(" schema. Same load, same wedged unit, two configured thresholds.")
|
||||
# load is what puts B's shortfall BETWEEN the two: above the shipped threshold,
|
||||
# below the raised one. The gap itself is printed by the run that produces it
|
||||
# and is never written down here; what is asserted is the ORDERING.
|
||||
#
|
||||
# ⚠️ AND B IS DERATED HERE RATHER THAN WEDGED, WHICH SAFETY-43 MADE NECESSARY.
|
||||
# `delinquent()` now names a unit whose gap exceeds `min_w` OR a fraction of its
|
||||
# own share, so a unit delivering NONE of its ask is named at every `min_w`
|
||||
# there is - which is the whole point of that card, and which would make the
|
||||
# raised arm below fire for a reason that has nothing to do with the watts. A
|
||||
# unit delivering HALF of what it is asked for is short in absolute watts and
|
||||
# comfortably inside the fraction, so it is the fixture that still isolates
|
||||
# `saturation_w`. `_SAT_B_FRAC` is asserted below to be inside the relative
|
||||
# arm's silence, so a future change to the fraction turns this red rather than
|
||||
# quietly turning the pin vacuous.
|
||||
_SAT_RAISED = 1800
|
||||
_SAT_LOAD_W = 1600.0
|
||||
_SAT_B_FRAC = 0.5
|
||||
_SAT_SHIPPED = int(read_option("saturation_w"))
|
||||
_sat_lo, _sat_hi = (int(x) for x in re.match(
|
||||
r"int\((\d+),(\d+)\)", read_option("saturation_w", "schema")).groups())
|
||||
|
||||
|
||||
async def _at_saturation_w(sat):
|
||||
hass = _DeliveryHass(load_w=_SAT_LOAD_W)
|
||||
async def _at_saturation_w(sat, b_frac=_SAT_B_FRAC):
|
||||
hass = _DeliveryHass(load_w=_SAT_LOAD_W, b_frac=b_frac)
|
||||
c = _ctrl(hass, saturation_w=sat)
|
||||
return c, await _drive(c, watch=_sample)
|
||||
|
||||
@@ -674,6 +759,17 @@ check("the raised arm REACHED the comparison: B was asked for a discharge "
|
||||
and not _b_up.sign_wd.tripped
|
||||
and account(live=True, parked=False, soc=_b_up.soc,
|
||||
bms_floor=_b_up.bms_soc_floor) is None)
|
||||
check("...and SAFETY-43's RELATIVE arm is silent on both arms of this fixture, "
|
||||
"which is what leaves the watts as the only moving part. B delivers half "
|
||||
"of what it is asked for, so its gap is half its share and that is "
|
||||
"inside `DELIVERY_SHARE_FRACTION`. ⚠️ A FIXTURE WHERE THE RELATIVE ARM "
|
||||
"FIRED WOULD NAME B AT EVERY `saturation_w` AND THIS PIN WOULD BE "
|
||||
"VACUOUS, so it is asserted rather than assumed",
|
||||
_gap_up / float(_b_up.standing_w()) <= DELIVERY_SHARE_FRACTION
|
||||
and _gap_ship / float(_b_ship.standing_w()) <= DELIVERY_SHARE_FRACTION
|
||||
and delinquent([U("a", 1000, 1000),
|
||||
U("b", float(_b_up.standing_w()), _b_up.batt)],
|
||||
_SAT_RAISED) == {})
|
||||
check("...and its shortfall lands STRICTLY BETWEEN the two configured "
|
||||
"thresholds, which is the one place the two answers can differ. Part 4's "
|
||||
"fixtures are all above both, which is exactly why they cannot see this",
|
||||
|
||||
@@ -41,7 +41,9 @@ from app.arbiter import (
|
||||
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve,
|
||||
)
|
||||
from app.control import Tuning, compute
|
||||
from app.delivery import UnitDelivery, account, delinquent
|
||||
from app.delivery import (
|
||||
DELIVERY_SHARE_FRACTION, UnitDelivery, account, delinquent,
|
||||
)
|
||||
from app.peakguard import discharge_permitted
|
||||
|
||||
fails = []
|
||||
@@ -605,67 +607,81 @@ check("...and SAFETY-42 is where the case this card CANNOT reach is handled. A "
|
||||
"site ASKING a pack whose own SoC says it cannot deliver; SAFETY-42 "
|
||||
"alarms when a pack that said it could does not. ⚠️ THE WEIGHT, HOWEVER, "
|
||||
"DOES MOVE - this check used to say it did not - and part 8b grades what "
|
||||
"that costs the alarm",
|
||||
"that cost the alarm, and what SAFETY-43 did about it",
|
||||
not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("8b. ⚠️ AND WHAT THE WEIGHTS DO TO SAFETY-42's DETECTION WINDOW, WHICH IS "
|
||||
"A REGRESSION AND IS PINNED HERE AS ONE")
|
||||
print("8b. ⚠️ WHAT THE WEIGHTS DID TO SAFETY-42's DETECTION WINDOW, AND WHAT "
|
||||
"SAFETY-43 DID ABOUT IT - GRADED UNDER BOTH RULES, ON ONE FIXTURE")
|
||||
|
||||
# ⚠️ READ THIS BEFORE READING THE CHECKS: THEY ASSERT THE UNFLATTERING ANSWER ON
|
||||
# PURPOSE. An earlier draft of §5 of the decision said a wedged unit's weight
|
||||
# does not move, so OPT-09 "cannot see it at all". The first clause is about the
|
||||
# ENVELOPE and is true; the second is FALSE. A wedged unit's weight moves
|
||||
# whenever its energy headroom differs from its sibling's - it is a pack fact,
|
||||
# not a delivery fact - and `delivery.delinquent()` names a unit only while its
|
||||
# `short_by()` gap exceeds `min_w` (`Tuning.saturation_w`, 500 W) IN ABSOLUTE
|
||||
# WATTS. Shrinking a unit's share shrinks its gap. Shrink it below 500 W and the
|
||||
# alarm stops firing on a unit that is still delivering nothing.
|
||||
# ⚠️ THIS PART USED TO PIN THE UNFLATTERING ANSWER ON PURPOSE, AND SAFETY-43 IS
|
||||
# THE CARD THAT INVERTED IT. What it said - and it was true when it was written
|
||||
# - was: a wedged unit's weight MOVES whenever its energy headroom differs from
|
||||
# its sibling's (it is a pack fact, not a delivery fact), `delivery.delinquent()`
|
||||
# named a unit only while its `short_by()` gap exceeded `min_w`
|
||||
# (`Tuning.saturation_w`, 500 W) IN ABSOLUTE WATTS, and shrinking a unit's share
|
||||
# shrinks its gap. Shrink it below 500 W and the alarm stopped firing on a unit
|
||||
# that was still delivering nothing. The old text ended "if it ever goes red
|
||||
# because the alarm fired, read the card that made it fire before 'fixing' this
|
||||
# line". SAFETY-43 is that card, this is that red, and the checks below are the
|
||||
# same three measurements with their answers the other way round.
|
||||
#
|
||||
# So the seam is not "OPT-09 cannot see it". It is: OPT-09 stops the ask AND
|
||||
# narrows SAFETY-42's detection window on the same unit. At equal weights the
|
||||
# window is `site target < 2 * min_w` = 1000 W; at OPT-09's weights it is
|
||||
# `site target < min_w * (w_A + w_B) / w_B`, which on this fixture is 6500 W.
|
||||
# ⚠️ AND THE EVIDENCE IS THE DIFF BETWEEN TWO RULES ON ONE RUN, NOT A NUMBER
|
||||
# THAT CHANGED IN A COMMIT. `delinquent()` takes `share_frac`, and
|
||||
# `share_frac=None` is SAFETY-42's rule EXACTLY - the absolute arm alone. So
|
||||
# `wedged_loop` grades every cycle twice, once under each rule, and the red and
|
||||
# the green sit side by side in one output:
|
||||
#
|
||||
# ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
# load 1500 W, OPT-09 weights, absolute arm only named on 0/200 cycles
|
||||
# load 1500 W, OPT-09 weights, the shipped rule named on 200/200 cycles
|
||||
#
|
||||
# ⚠️ THIS IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE. Making
|
||||
# `delinquent()` relative to the ask rather than absolute is SAFETY-42's code and
|
||||
# a design change; it is filed as card SAFETY-43. What this part does is GRADE THE
|
||||
# CURRENT BEHAVIOUR so that the day it changes, a named check moves.
|
||||
# A future reader who wants to know what this card did runs this file.
|
||||
#
|
||||
# ⚠️ WHAT SAFETY-43 DID NOT DO IS LOWER `min_w`, and the check on the window
|
||||
# arithmetic below is what holds that open: the ABSOLUTE arm's suppression
|
||||
# window is still 500 W wide and still widens to 6500 W under these weights.
|
||||
# Nothing that was quiet became noisy; a SECOND way to be named was added, and
|
||||
# it is a fraction of the unit's OWN ask, so it fires on a unit delivering none
|
||||
# of 125 W exactly as it fires on one delivering none of 1500 W.
|
||||
#
|
||||
# ⚠️ AND THE FIXTURE IS A SAFETY-42 TRUE POSITIVE BY THAT CARD'S OWN DEFINITION,
|
||||
# checked below before anything is concluded from it: B is WEDGED - it accepts
|
||||
# its share and produces nothing - at a HEALTHY 15 % against its own 10 % floor,
|
||||
# so `envelope()` does not close and `account()` explains nothing. This is
|
||||
# exactly the case §5 says the envelope cannot reach, which is why it is the
|
||||
# case where the weights' side effect matters.
|
||||
# case where the weights' side effect mattered.
|
||||
#
|
||||
# ⚠️ TWO COPIES OF A. Inverter B does not exist; the plant below is modelled in
|
||||
# this file the way part 8's is, and B's ratings are datasheet.
|
||||
|
||||
WEDGED_A_SOC, WEDGED_B_SOC = 90.0, 15.0 # both healthy, both above their floor
|
||||
MIN_W = Tuning().saturation_w # what `delinquent()` is handed
|
||||
ABSOLUTE_ONLY = None # `share_frac=None` - SAFETY-42's rule
|
||||
|
||||
|
||||
def wedged_loop(load_w, weighted, cycles=200):
|
||||
def wedged_loop(load_w, weighted, cycles=200, share_frac=DELIVERY_SHARE_FRACTION,
|
||||
b_delivers=0.0, b_soc=WEDGED_B_SOC):
|
||||
"""The closed loop with unit B WEDGED at a healthy SoC. Real everything.
|
||||
|
||||
Real `compute()`, real `plan()`, real `packshare.weights()`, real
|
||||
`delivery.account()` and real `delivery.delinquent()`. The only model is the
|
||||
plant: A obeys within one cycle inside its rating, B accepts whatever it is
|
||||
told and delivers 0 W - which is the wedged box, and is what makes this a
|
||||
plant: A obeys within one cycle inside its rating, B delivers `b_delivers`
|
||||
of whatever it is told - 0.0 is the wedged box, and is what makes this a
|
||||
SAFETY-42 case rather than an OPT-09 one.
|
||||
|
||||
`weighted=False` is equal weights - what shipped. Envelopes are left OPEN in
|
||||
both arms deliberately: neither pack's SoC closes one here, so filing them
|
||||
would change nothing and would blur which half of the card is being graded.
|
||||
|
||||
`share_frac` is SAFETY-43's relative arm. `ABSOLUTE_ONLY` grades the same
|
||||
cycles under SAFETY-42's rule, which is how the red and the green below come
|
||||
out of ONE run over ONE tree instead of out of two commits.
|
||||
|
||||
Returns `(cycles on which delinquent() named somebody, last cycle)`.
|
||||
"""
|
||||
packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, WEDGED_B_SOC, B_KWH)]
|
||||
packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, b_soc, B_KWH)]
|
||||
tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=A_W + B_W)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
power = [0.0, 0.0]
|
||||
@@ -678,13 +694,14 @@ def wedged_loop(load_w, weighted, cycles=200):
|
||||
r = plan(d.target_w, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=w)
|
||||
assert r.ok, r.refused
|
||||
# THE MODELLED PLANT: A within one cycle inside its rating; B WEDGED.
|
||||
power = [max(-A_W, min(A_W, float(r.shares[A]))), 0.0]
|
||||
power = [max(-A_W, min(A_W, float(r.shares[A]))),
|
||||
b_delivers * max(-B_W, min(B_W, float(r.shares[Bee])))]
|
||||
grid = load_w - sum(power)
|
||||
who = delinquent(
|
||||
[UnitDelivery(p.name, r.shares[p.name], m,
|
||||
account(live=True, parked=False, soc=p.soc,
|
||||
bms_floor=p.bms_floor))
|
||||
for p, m in zip(packs, power)], MIN_W)
|
||||
for p, m in zip(packs, power)], MIN_W, share_frac)
|
||||
named += bool(who)
|
||||
last = (d.target_w, dict(r.shares), grid, who)
|
||||
return named, last
|
||||
@@ -698,75 +715,204 @@ check("PRECONDITION: the wedged unit is a SAFETY-42 TRUE POSITIVE and NOT an "
|
||||
not packshare.envelope(_WEDGED_PACK).binding
|
||||
and account(live=True, parked=False, soc=WEDGED_B_SOC,
|
||||
bms_floor=FLOOR) is None)
|
||||
check("PRECONDITION: the shipped fraction is a fraction, and it is STRICTLY "
|
||||
"below 1.0 - at 1.0 the relative arm could only fire on a unit IMPORTING "
|
||||
"while asked to discharge, which is SAFETY-30's fault and not this one, "
|
||||
"and this card's own case (a unit delivering NONE of its share, gap over "
|
||||
"share exactly 1.0) would be missed",
|
||||
0.0 < DELIVERY_SHARE_FRACTION < 1.0)
|
||||
|
||||
_wedge = {}
|
||||
for _load in (1500.0, 6000.0):
|
||||
for _weighted in (False, True):
|
||||
_n, _t = wedged_loop(_load, _weighted)
|
||||
_wedge[(_load, _weighted)] = (_n, _t)
|
||||
for _frac in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION):
|
||||
_n, _t = wedged_loop(_load, _weighted, share_frac=_frac)
|
||||
_wedge[(_load, _weighted, _frac)] = (_n, _t)
|
||||
print(f" load {_load:.0f} W, B wedged at 15 %, "
|
||||
f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} "
|
||||
f"named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W, shares "
|
||||
+ ("absolute arm only" if _frac is ABSOLUTE_ONLY
|
||||
else f"frac {_frac:.2f} ")
|
||||
+ f", named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W,"
|
||||
+ " shares "
|
||||
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items()))
|
||||
+ f", grid {_t[2]:+.0f} W, named="
|
||||
+ (str(sorted(_t[3])) if _t[3] else "(nobody)"))
|
||||
|
||||
check("AT EQUAL WEIGHTS - WHAT SHIPPED - the wedged unit is named on every "
|
||||
"cycle but the first, at a load its sibling can carry on its own. The "
|
||||
"loop winds the site target to twice the load, the 50/50 split stands "
|
||||
"+1500 W on a box producing nothing, and 1500 W > 500 W is what "
|
||||
"`delinquent()` reads",
|
||||
_wedge[(1500.0, False)][0] == 199
|
||||
and sorted(_wedge[(1500.0, False)][1][3]) == [Bee]
|
||||
and _wedge[(1500.0, False)][1][1] == {A: 1500, Bee: 1500})
|
||||
check("⚠️ THE CARD, RED: UNDER SAFETY-42's RULE AND OPT-09's WEIGHTS THE WEDGED "
|
||||
"UNIT IS NAMED ON NONE OF THE 200. Same wedged box, same healthy SoC, "
|
||||
"same load, same detector: the split moves to 1505/125, the gap on the "
|
||||
"wedged unit falls to 125 W, and 125 W < 500 W so the absolute arm "
|
||||
"returns {}. This is the defect SAFETY-43 was filed for, and it is "
|
||||
"measured here rather than remembered",
|
||||
_wedge[(1500.0, True, ABSOLUTE_ONLY)][0] == 0
|
||||
and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][3] == {}
|
||||
and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][1] == {A: 1505, Bee: 125})
|
||||
|
||||
check("⚠️ AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200. Same wedged "
|
||||
"box, same healthy SoC, same load, same detector: the split moves to "
|
||||
"1505/125, the gap on the wedged unit falls to 125 W, and 125 W < 500 W "
|
||||
"so `delinquent()` returns {}. THIS CHECK PINS A REGRESSION - it asserts "
|
||||
"what the code DOES, not what anyone wants, and if it ever goes red "
|
||||
"because the alarm fired, read the card that made it fire before "
|
||||
"'fixing' this line",
|
||||
_wedge[(1500.0, True)][0] == 0
|
||||
and _wedge[(1500.0, True)][1][3] == {}
|
||||
and _wedge[(1500.0, True)][1][1] == {A: 1505, Bee: 125})
|
||||
check("⚠️ THE CARD, GREEN: THE SHIPPED RULE NAMES IT ON ALL 200, ON THE SAME "
|
||||
"RUN. The unit is standing at +125 W and delivering 0 W - 100 % of its "
|
||||
"ask is not arriving - and `gap > frac * share` does not care that 125 W "
|
||||
"is a small number. The ONLY input that moved between this check and the "
|
||||
"one above is `share_frac`",
|
||||
_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||
and sorted(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee]
|
||||
and _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][1] \
|
||||
== {A: 1505, Bee: 125})
|
||||
|
||||
check("AT EQUAL WEIGHTS - WHAT SHIPPED BEFORE OPT-09 - the wedged unit was "
|
||||
"already named under the absolute rule on every cycle but the first, at "
|
||||
"a load its sibling can carry on its own. The loop winds the site target "
|
||||
"to twice the load, the 50/50 split stands +1500 W on a box producing "
|
||||
"nothing, and 1500 W > 500 W is what the absolute arm reads",
|
||||
_wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 199
|
||||
and sorted(_wedge[(1500.0, False, ABSOLUTE_ONLY)][1][3]) == [Bee]
|
||||
and _wedge[(1500.0, False, ABSOLUTE_ONLY)][1][1] == {A: 1500, Bee: 1500})
|
||||
|
||||
check("...and the shipped rule adds exactly the FIRST cycle to that arm and "
|
||||
"nothing else - 199 becomes 200. On cycle one the slew limit holds the "
|
||||
"split at 500/500, a 500 W gap is NOT over a 500 W threshold (the "
|
||||
"absolute bound is strict), and the relative arm names a box delivering "
|
||||
"none of its 500 W. That one cycle is the whole of what this card costs "
|
||||
"an arm that was already firing",
|
||||
_wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||
and _wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0]
|
||||
- _wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 1)
|
||||
|
||||
check("...and this is the regime where NOTHING ELSE TELLS THE OPERATOR "
|
||||
"ANYTHING: the meter is satisfied (grid within 5 W of zero), `unmet_w` is "
|
||||
"0 because both units accepted their shares, no envelope closed and no "
|
||||
"action-log line fires. The site is quietly running one inverter and the "
|
||||
"one instrument built to say so has gone quiet with it",
|
||||
abs(_wedge[(1500.0, True)][1][2]) <= 5.0
|
||||
and abs(_wedge[(1500.0, False)][1][2]) <= 5.0)
|
||||
"action-log line fires. The site is quietly running one inverter, and "
|
||||
"before this card the one instrument built to say so had gone quiet with "
|
||||
"it",
|
||||
abs(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0
|
||||
and abs(_wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0)
|
||||
|
||||
_w = packshare.weights([PE(A, WEDGED_A_SOC, A_KWH), _WEDGED_PACK], 1000.0)
|
||||
_window_equal = MIN_W * 2.0
|
||||
_window_opt09 = MIN_W * sum(_w.values()) / _w[Bee]
|
||||
print(f" suppression window on this fixture: equal weights, site target "
|
||||
f"< {_window_equal:.0f} W; OPT-09 weights, site target "
|
||||
f"< {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site")
|
||||
check("...and the arithmetic behind it, stated as a window rather than as one "
|
||||
"fixture: `delinquent()` reads an ABSOLUTE gap, so a unit weighted to a "
|
||||
"fraction f of the site target is invisible while `f * target < min_w`. "
|
||||
"Equal weights suppress below 1000 W; these weights suppress below "
|
||||
"6500 W of a 9600 W site. ⚠️ BOTH ARE PROPORTIONAL-REGIME BOUNDS AND "
|
||||
"NEITHER IS REACHABLE: the healthy pack's own rating binds first, so the "
|
||||
"alarm returns BELOW 6500 W (swept: 0/200 to a 3000 W load, 41/200 at "
|
||||
"4900 W, 195/200 at 5000 W) and the equal arm never suppresses at all. "
|
||||
"That is the regression, and it is a property "
|
||||
"of the threshold being absolute rather than of any number chosen here",
|
||||
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0)
|
||||
print(f" the ABSOLUTE arm's suppression window is UNCHANGED by SAFETY-43: "
|
||||
f"equal weights, site target < {_window_equal:.0f} W; OPT-09 weights, "
|
||||
f"site target < {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site")
|
||||
check("⚠️ AND `min_w` WAS NOT LOWERED, WHICH IS THE HALF OF THIS CARD THAT IS "
|
||||
"EASIEST TO GET WRONG. The absolute arm still suppresses below 1000 W at "
|
||||
"equal weights and below 6500 W at these ones - the same arithmetic this "
|
||||
"part has always printed - because SAFETY-43 ADDED a second way to be "
|
||||
"named rather than loosening the first. Lowering `saturation_w` would "
|
||||
"have reported normal saturation on every unit at once, which is the "
|
||||
"false-trip regime SAFETY-41 spent four attempts in",
|
||||
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0
|
||||
and MIN_W == Tuning().saturation_w == 500.0)
|
||||
|
||||
check("⚠️ AND THE ABSOLUTE ARM WAS NEVER SILENT EVERYWHERE - which is what a "
|
||||
"check that only ever ran the quiet load would be pinning. Push the load "
|
||||
"past the healthy pack's OWN RATING and `_fill()`'s last-resort pass "
|
||||
"hands the wedged unit 3000 W anyway, the gap goes back over 500 W, and "
|
||||
"both arms named it under SAFETY-42's rule too. The window was real and "
|
||||
"it had an upper edge; the hole was the quiet regime below that edge, "
|
||||
"and the shipped rule now names the unit on every cycle of all four runs",
|
||||
_wedge[(6000.0, True, ABSOLUTE_ONLY)][0] == 195
|
||||
and _wedge[(6000.0, False, ABSOLUTE_ONLY)][0] == 199
|
||||
and _wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||
and _wedge[(6000.0, False, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||
and sorted(_wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee])
|
||||
|
||||
print()
|
||||
print(" THE POSITIVE CONTROL: what the relative arm must STILL NOT say")
|
||||
|
||||
# ⚠️ A CARD THAT ADDS A WAY TO RAISE AN ALARM HAS TO SHOW WHAT IT DID NOT ADD,
|
||||
# or it has traded a missed alarm for a false one - which in this system is not
|
||||
# a rhetorical risk. SAFETY-41 took four attempts on notification cadence and
|
||||
# the owner's words were "i'm getting really annoyed by the notifications in my
|
||||
# HA". So: the same fixture, the same weights, the same tiny 125 W share, with
|
||||
# the ONE fact that makes the shortfall legitimate changed.
|
||||
|
||||
_sat_named, _sat_last = wedged_loop(1500.0, True, b_soc=FLOOR)
|
||||
print(f" load 1500 W, B AT ITS OWN {FLOOR:.0f} % CUT-OUT, OPT-09 weights, "
|
||||
f"frac {DELIVERY_SHARE_FRACTION:.2f}: named on {_sat_named:3d}/200 "
|
||||
f"cycles, named="
|
||||
+ (str(sorted(_sat_last[3])) if _sat_last[3] else "(nobody)"))
|
||||
check("A PACK AT ITS OWN CUT-OUT DELIVERING NOTHING OF A SMALL SHARE IS STILL "
|
||||
"NOT DELINQUENT. Identical trace to the wedged arm - 0 W against a "
|
||||
"positive share, 100 % of the ask missing, the relative arm's exact "
|
||||
"shape - and `account()` explains it, so nobody is named on any of the "
|
||||
"200 cycles. The account gate is what keeps normal saturation out and "
|
||||
"SAFETY-43 did not go near it",
|
||||
_sat_named == 0 and _sat_last[3] == {})
|
||||
|
||||
_derate_named, _derate_last = wedged_loop(1500.0, True, b_delivers=0.5)
|
||||
print(f" load 1500 W, B delivering HALF its share, OPT-09 weights, frac "
|
||||
f"{DELIVERY_SHARE_FRACTION:.2f}: named on {_derate_named:3d}/200 cycles, "
|
||||
f"shares "
|
||||
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_derate_last[1].items()))
|
||||
+ ", named="
|
||||
+ (str(sorted(_derate_last[3])) if _derate_last[3] else "(nobody)"))
|
||||
check("...and ORDINARY TRACKING ON A SMALL SHARE IS NOT DELINQUENCY EITHER. "
|
||||
"The same unit at the same weights delivering HALF of what it is asked "
|
||||
"for is short by 50 % - well inside the shipped fraction - and its gap "
|
||||
"in watts is far under `min_w`, so NEITHER arm names it. A relative "
|
||||
"criterion that fired here would be the saturation-shaped alarm this "
|
||||
"whole module is written against",
|
||||
_derate_named == 0 and _derate_last[3] == {})
|
||||
|
||||
check("...and the two controls are not vacuous: the SAME loop with the SAME "
|
||||
"weights and a WEDGED B names it on all 200, so what separates them is "
|
||||
"the pack's own cut-out and the size of the shortfall, not something "
|
||||
"about the fixture that stopped the check reaching the comparison",
|
||||
_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200)
|
||||
|
||||
print()
|
||||
print(" N=1 IS BYTE-IDENTICAL, AND THAT IS ASSERTED RATHER THAN ARGUED")
|
||||
|
||||
# ⚠️ THE LIVE INSTALL IS N=1 AND THIS CARD MUST NOT REACH IT. Two independent
|
||||
# statements, and each is checked against the thing that would break it:
|
||||
# 1. `plan()` RETURNS BEFORE `weights` IS READ AT ALL, so no weighting - and
|
||||
# no change to how one is derived - can move a one-inverter share. Graded
|
||||
# with a `weights` object that RAISES if anything reads it.
|
||||
# 2. `delinquent()` returns {} at a roster of one for every input, including
|
||||
# the input the RELATIVE arm was built for. The proper-subset algebra runs
|
||||
# before either arm, so the fraction is never even multiplied.
|
||||
|
||||
|
||||
class _Poison(dict):
|
||||
"""A `weights` that cannot be read without saying so. Not a mock: a tripwire."""
|
||||
|
||||
def get(self, *a, **k):
|
||||
raise AssertionError("plan() read weights at a roster of one")
|
||||
|
||||
def __getitem__(self, k):
|
||||
raise AssertionError("plan() read weights at a roster of one")
|
||||
|
||||
|
||||
_n1_plain = plan(1234.5, {A: _B(-A_W, A_W)})
|
||||
_n1_poison = plan(1234.5, {A: _B(-A_W, A_W)}, weights=_Poison())
|
||||
check("`plan()` at a roster of ONE returns the site target unread, and it "
|
||||
"returns it WITHOUT TOUCHING `weights` - handed a mapping that raises on "
|
||||
"any read, it answers identically to the same call with no weights at "
|
||||
"all. The N=1 pass-through is above the weighting, so OPT-09 could not "
|
||||
"move it and SAFETY-43 does not either",
|
||||
_n1_poison.shares == _n1_plain.shares == {A: 1234.5}
|
||||
and _n1_poison.unmet_w == _n1_plain.unmet_w)
|
||||
try:
|
||||
plan(1234.5, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=_Poison())
|
||||
except AssertionError:
|
||||
_poison_bites = True
|
||||
else:
|
||||
_poison_bites = False
|
||||
check("...and the tripwire is a real one: the SAME poisoned mapping at a roster "
|
||||
"of TWO is read and raises. A check that could not fail would be "
|
||||
"measuring nothing",
|
||||
_poison_bites)
|
||||
|
||||
_n1_rel = [delinquent([UnitDelivery(A, s, m)], MIN_W, f)
|
||||
for s in (0, 125, 500, 2000, 5000, -2000, None)
|
||||
for m in (0, 60, 125, 2000, -2000, None)
|
||||
for f in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION)]
|
||||
check(f"...and `delinquent()` answers {{}} at a roster of one on all "
|
||||
f"{len(_n1_rel)} of (share, measured, rule) - INCLUDING 0 of 125 W, the "
|
||||
f"exact input this card added an arm for. `len(asked) < 2` returns "
|
||||
f"before either threshold is compared, so the one-inverter install this "
|
||||
f"add-on is running today cannot tell that SAFETY-43 happened",
|
||||
all(r == {} for r in _n1_rel))
|
||||
|
||||
check("⚠️ AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY - which is "
|
||||
"what a check that only ever ran the quiet load would be pinning. Push "
|
||||
"the load past the healthy pack's OWN RATING and `_fill()`'s last-resort "
|
||||
"pass hands the wedged unit 3000 W anyway, the gap goes back over 500 W, "
|
||||
"and BOTH arms name it. The window is real and it has an upper edge; the "
|
||||
"hole is the quiet regime below that edge",
|
||||
_wedge[(6000.0, True)][0] == 195
|
||||
and sorted(_wedge[(6000.0, True)][1][3]) == [Bee]
|
||||
and _wedge[(6000.0, False)][0] == 199
|
||||
and sorted(_wedge[(6000.0, False)][1][3]) == [Bee])
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
@@ -978,9 +1124,12 @@ print(" a preference and are honest about being one.")
|
||||
print(" * A WEDGED UNIT IS NOT REACHABLE BY THE ENVELOPE. Its SoC reads")
|
||||
print(" healthy, so nothing closes, and SAFETY-42 owns the detection.")
|
||||
print(" ⚠️ BUT IT IS REACHABLE BY THE WEIGHTS, WHICH MOVE ON ENERGY")
|
||||
print(" HEADROOM AND NOT ON DELIVERY - part 8b grades what that does")
|
||||
print(" to SAFETY-42's window, and the answer is a REGRESSION that is")
|
||||
print(" pinned rather than fixed.")
|
||||
print(" HEADROOM AND NOT ON DELIVERY. That narrowed SAFETY-42's")
|
||||
print(" detection window - a regression this file PINNED rather than")
|
||||
print(" fixed - and SAFETY-43 closed it by making delinquency relative")
|
||||
print(" to the ask as well as absolute. Part 8b now grades both rules")
|
||||
print(" on one fixture: the absolute arm still says nothing, and the")
|
||||
print(" shipped one names the unit on all 200 cycles.")
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
@@ -1023,13 +1172,38 @@ MUT = [
|
||||
"mutation took it down with a TypeError three lines into part 1"),
|
||||
("M11 weights(): keep the shape, force every weight to 1.0 <- THE PROOF "
|
||||
"THAT PART 8b CAN GO RED",
|
||||
"test_packshare 8b, three named checks: the wedged unit goes back to being "
|
||||
"named on 199/200 at 1500 W, the split reverts to 1500/1500 and the "
|
||||
"window collapses from 6500 W to 1000 W. ⚠️ RUN, NOT REASONED ABOUT - a "
|
||||
"check that pins an UNFLATTERING behaviour has to be shown capable of "
|
||||
"failing, or it is indistinguishable from a check that pins nothing. "
|
||||
"test_packshare 8b, FIVE named checks - re-run for SAFETY-43 rather than "
|
||||
"carried across, because the part it grades was rewritten: the split "
|
||||
"reverts to 1500/1500 so the absolute arm names the unit on 199/200 "
|
||||
"(taking the card's RED check down), the shipped rule's 200/200 comes "
|
||||
"from the wrong shares (taking the GREEN one down), the OPT-09 window "
|
||||
"collapses from 6500 W to 1000 W, the 6000 W arm's 195 becomes 199, and "
|
||||
"the derated positive control moves from 0/200 to 3/200. ⚠️ RUN, NOT "
|
||||
"REASONED ABOUT - a check that pins a behaviour has to be shown capable "
|
||||
"of failing, or it is indistinguishable from a check that pins nothing. "
|
||||
"(It also takes parts 1, 3, 7 and 8 down, which is M3's territory; 8b is "
|
||||
"the part that names the SAFETY-42 interaction)"),
|
||||
# --- SAFETY-43's three, and they pin the new arm from BOTH sides --------
|
||||
("M12 delinquent(): drop the relative arm, keep `gap > min_w` <- THE "
|
||||
"CARD ITSELF REVERTED",
|
||||
"test_delivery 1 (one named check) and test_packshare 8b (FOUR): the "
|
||||
"GREEN check goes back to 0/200 under OPT-09's weights, the equal arm "
|
||||
"goes back to 199, the 6000 W arm back to 195/199, and the vacuity guard "
|
||||
"on the positive controls falls with them. test_alloc and test_control "
|
||||
"stay green, which is the point - nothing outside this check moves"),
|
||||
("M13 DELIVERY_SHARE_FRACTION 0.85 -> 1.0 <- THE TOO-LOOSE DIRECTION",
|
||||
"test_delivery 1 and test_packshare 8b, five named checks including the "
|
||||
"PRECONDITION that the fraction is strictly below 1.0. At 1.0 the arm can "
|
||||
"only fire on a unit IMPORTING while asked to discharge, so the card's "
|
||||
"own case - 0 of 125 W, gap over share exactly 1.0 - is missed and the "
|
||||
"whole card is a no-op"),
|
||||
("M14 DELIVERY_SHARE_FRACTION 0.85 -> 0.05 <- THE TOO-SENSITIVE ONE",
|
||||
"test_delivery 1 and 4b (four named checks) and test_packshare 8b's "
|
||||
"DERATED positive control: a unit delivering half its share starts being "
|
||||
"named, and part 4b's `saturation_w` pin goes vacuous because the "
|
||||
"relative arm names B at every threshold. ⚠️ THIS IS THE ROW THAT MATTERS "
|
||||
"- the fraction is pinned from BOTH directions, so it is a derived number "
|
||||
"with checks around it rather than a literal nobody can move"),
|
||||
]
|
||||
for _n, _w in MUT:
|
||||
print(f" caught {_n}")
|
||||
@@ -1039,8 +1213,11 @@ check("every mutation in the table above was RUN and every one of them is "
|
||||
"M6: the whole defect this card fixes - a parameter nothing passes - is "
|
||||
"invisible to every behavioural fixture in this repository, because they "
|
||||
"all build their own claim list and call `resolve()` themselves. That is "
|
||||
"why the call site is pinned statically as well",
|
||||
len(MUT) == 11)
|
||||
"why the call site is pinned statically as well. ⚠️ M12-M14 ARE "
|
||||
"SAFETY-43's and were run the same way, on trees copied fresh and "
|
||||
"mounted :ro so no `.pyc` could go stale - MUTATION.md's defect. All "
|
||||
"three are REDS, which is the direction that defect cannot fabricate",
|
||||
len(MUT) == 14)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
Reference in New Issue
Block a user