Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1448bd909b | ||
|
|
b21e229adf | ||
|
|
2271e147c6 | ||
|
|
6719bd882f |
@@ -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
|
**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**
|
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
|
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
|
UNAFFECTED**: `allocator.plan()` returns before weights are read at all.
|
||||||
closure is card SAFETY-43.
|
⚠️ **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
|
**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
|
RELEASE** - it is what caught the fact that 0.8.0 had five runtime commits
|
||||||
@@ -97,6 +99,65 @@ 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-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_ | 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_ | 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.
|
||||||
|
⚠️ **IT MUST CLOSE BEFORE INVERTER B IS COMMISSIONED (~2026-09-12)**, not before
|
||||||
|
the next deploy: the band is UNREACHABLE at N=1 - `delinquent()` answers `{}` for
|
||||||
|
every input at a roster of one, so no `delivery_hold_s` can false-trip on the
|
||||||
|
install running today - and commissioning B is the moment it becomes reachable.
|
||||||
|
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE** - it exists as a card
|
||||||
|
reference here and in SAFETY-43's record and nowhere else, so whoever takes it
|
||||||
|
writes `DECISIONS/CFG-07-*.md` first.
|
||||||
|
**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
|
⚠️ **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
|
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
|
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.
|
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
|
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWED SAFETY-42's DETECTION WINDOW.
|
||||||
IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE.** An earlier draft of
|
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
|
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
|
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
|
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
|
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
|
its headroom differs from its sibling's - which is the ordinary case this card
|
||||||
exists to exploit. And `delivery.delinquent()` (`app/delivery.py:484`)
|
exists to exploit. And `delivery.delinquent()`
|
||||||
names a unit only while its `short_by()` gap exceeds `min_w =
|
named a unit only while its `short_by()` gap exceeded `min_w =
|
||||||
Tuning.saturation_w` - the comparison is `app/delivery.py:531`, and it is
|
Tuning.saturation_w`, and that was **500 W in absolute watts** with nothing
|
||||||
**500 W in absolute watts**. Shrinking a unit's share shrinks its gap. Shrink the
|
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
|
share far enough and the gap drops below 500 W while the unit is still delivering
|
||||||
nothing, and the alarm stops firing.
|
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
|
`_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.
|
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,
|
`test_packshare.py` part 8b graded all of it and **pinned the unflattering
|
||||||
unflattering behaviour**, so that the day it changes a named check moves. The
|
behaviour**, so that the day it changed a named check would move. It has moved.
|
||||||
closure - `delinquent()` measured against the ask rather than a flat 500 W - is
|
|
||||||
card **SAFETY-43**.
|
|
||||||
|
|
||||||
So the seam, stated accurately: **OPT-09 stops the ask AND narrows SAFETY-42's
|
### ⚠️ CLOSED BY SAFETY-43 - THIS IS NO LONGER A DISCLOSED RESIDUAL
|
||||||
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
|
This section used to end *"until then this is a disclosed residual [...] at two
|
||||||
means making `delinquent()` relative to the ask rather than absolute, which is
|
inverters, a wedged unit with materially less energy headroom than its sibling
|
||||||
SAFETY-42's code and a design change - it is filed as its own card, deliberately
|
can go unreported at ordinary house loads"*. That sentence is **retired, not
|
||||||
not done here, and this paragraph is its input. Until then this is a **disclosed
|
softened**, and what replaces it is a measurement rather than an intention.
|
||||||
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
|
`delivery.delinquent()` is now relative to the ask **as well as** absolute:
|
||||||
less energy headroom than its sibling can go unreported at ordinary house loads.
|
|
||||||
|
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
|
⚠️ **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
|
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
|
count of naming samples, because the window is cleared by any cycle that does
|
||||||
not.
|
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
|
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
|
||||||
command standing before the write" is the **controller's** standing command —
|
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
|
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
⚠️ **THE TABLE BELOW WAS PRODUCED BY TWO PASSES; `FRACTIONS` NOW HOLDS ALL
|
||||||
|
TWENTY SO IT TAKES ONE.** The derivation ran the shipped ten
|
||||||
|
`(0.25 … 0.99)` and then the ten that resolve the low end
|
||||||
|
`(0.05 … 0.65)` with only that tuple edited between them. **The absolute-arm
|
||||||
|
control row came out identical in both** (30.0 / 0.0 / 35.0 / 0.0, same argmax),
|
||||||
|
which is what says the two passes are one campaign. A table a reviewer cannot
|
||||||
|
reproduce without knowing to edit a line nobody wrote down is PROC-01's shape in
|
||||||
|
miniature, so the committed `FRACTIONS` was widened to all twenty in review: the
|
||||||
|
command above now reproduces every row below.
|
||||||
|
|
||||||
|
### 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 | B's share | named | why it is silent |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B wedged at a healthy 15 % (the fault) | 77–154 W | **200 / 200** | — it is the fault |
|
||||||
|
| B at its own 10 % cut-out | **0 W, all 200 cycles** | **0 / 200** | it is never **asked** |
|
||||||
|
| B delivering **half** its share | 120 W | **0 / 200** | short by 50 %, inside both arms |
|
||||||
|
|
||||||
|
### ⚠️ The cut-out row does NOT grade `account()`, and this record said it did
|
||||||
|
|
||||||
|
**Corrected in review.** The first draft of this section claimed the wedged and
|
||||||
|
cut-out arms had *"identical power traces — 0 W against a positive share, 100 %
|
||||||
|
of the ask missing"* and that *"what separates them is `account()`"*. **Every
|
||||||
|
part of that was false**, and the check that carried it was **vacuous**:
|
||||||
|
|
||||||
|
* at its own cut-out the pack's usable energy **above that cut-out is zero**, so
|
||||||
|
`packshare.weights()` answers **0.0** for it, `_fill()`'s preferred pass gives
|
||||||
|
it nothing, the healthy sibling covers the load alone so the last-resort pass
|
||||||
|
never runs, and its share is **0 W on all 200 cycles**;
|
||||||
|
* a unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns `{}`
|
||||||
|
**before either arm is evaluated** and `account()` is **never consulted**;
|
||||||
|
* the traces are not identical — the cut-out arm's share is **0 W on every
|
||||||
|
cycle** (asserted in part 8b and printed on its line) while the wedged arm's
|
||||||
|
is **125 W** at the last cycle (asserted in the GREEN check) and takes four
|
||||||
|
distinct values 77/125/133/154 W across the 200, measured by re-running the
|
||||||
|
same loop with per-cycle recording during review — and "100 % of the ask" is
|
||||||
|
0/0.
|
||||||
|
|
||||||
|
Measured, by deleting the gate the claim rested on:
|
||||||
|
|
||||||
|
cut-out arm, account() live named 0/200, distinct B shares: [0]
|
||||||
|
cut-out arm, account() DELETED named 0/200, distinct B shares: [0]
|
||||||
|
wedged arm, account() live named 200/200, shares [77,125,133,154]
|
||||||
|
|
||||||
|
⚠️ **The fact that replaces it is better, not weaker.** At its own cut-out
|
||||||
|
**OPT-09 stops asking the pack at all**, so SAFETY-43's relative arm is never
|
||||||
|
reached — a **second, independent protection sitting upstream of `account()`**:
|
||||||
|
the allocator declines to ask before the detector has to decline to alarm. Part
|
||||||
|
8b now asserts the 0 W share and prints it, and runs the arm a second time with
|
||||||
|
`account()` deleted to show which mechanism is doing the work.
|
||||||
|
|
||||||
|
⚠️ **It was invisible for one reason worth keeping**: that line was the only one
|
||||||
|
in part 8b that did not print its shares. `inverter-b+0` would have been on
|
||||||
|
screen from the first run. Every line in that part prints them now, and the
|
||||||
|
derated control's vacuity guard now asserts **its own** unit is asked, rather
|
||||||
|
than re-asserting a different run's 200/200 — which is exactly why the old guard
|
||||||
|
could not see this.
|
||||||
|
|
||||||
|
**The account gate is graded where it is real**, on a positive 125 W share, in
|
||||||
|
`test_delivery.py` part 1 (*"THE ACCOUNT GATE IS UNTOUCHED BY IT"* — the same
|
||||||
|
0 of 125 W with the shortfall explained names nobody). That part also carries
|
||||||
|
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 `test_packshare.py` part 11
|
||||||
|
as M12–M14.
|
||||||
|
|
||||||
|
| mutation | named checks | where |
|
||||||
|
|---|---:|---|
|
||||||
|
| **M12** drop the relative arm, keep `gap > min_w` — *the card itself reverted* | **7** | `test_delivery.py` part 1 (3: the 0-of-125 W naming, the proportion in the sentence, the strict relative bound) + `test_packshare.py` 8b (4: GREEN back to 0/200, the equal arm's 200 back to 199, the 6000 W arm back to 195/199, the derated control's guard). `test_alloc.py` / `test_control.py` stay green — nothing outside this check moves. |
|
||||||
|
| **M13** `DELIVERY_SHARE_FRACTION` → **1.0** — *too loose* | **7** | part 1 (2) + 8b (5), including the 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.05** — *too sensitive* | **6** | part 1 (2: the strict bound, and *"IT TOOK NOTHING AWAY"* stops being true) + part **4b** (3: the `saturation_w` pin goes **vacuous**, the relative arm naming B at every threshold) + 8b's derated control, where a unit delivering half its share starts being named. |
|
||||||
|
|
||||||
|
M13 and M14 are the pair that matters: the constant cannot be moved in **either**
|
||||||
|
direction without a named check moving with it.
|
||||||
|
|
||||||
|
### ⚠️ Two of these counts were wrong, and the reason is worth keeping
|
||||||
|
|
||||||
|
**Corrected in review.**
|
||||||
|
|
||||||
|
* **M12 and M13 read 1 and 1 in `test_delivery.py`, not 3 and 2.** Part 1 printed
|
||||||
|
the alarm sentence with a bare subscript, `delinquent(_tiny, 500)["b"]`. On any
|
||||||
|
tree where the relative arm is broken or absent that raises `KeyError` and
|
||||||
|
**aborts the file at part 1**, leaving parts 2–8 ungraded — so the recorded
|
||||||
|
coverage of a mutation run read *smaller* than the truth. The direction is safe
|
||||||
|
(an abort is a red, and MUTATION.md's defect can only fabricate a green), but a
|
||||||
|
figure that does not reproduce is a figure that does not reproduce. It is a
|
||||||
|
`.get` now, with the reason written beside it, and the counts above are from
|
||||||
|
re-runs with the guard in place.
|
||||||
|
* **M14 was recorded as 4 named checks in `test_delivery.py` and it is 5** — part
|
||||||
|
1's pre-existing *"a shortfall AT the threshold is not one"* also goes red. That
|
||||||
|
one under-claimed, so it never inflated the evidence; it is still a recorded
|
||||||
|
number that did not reproduce.
|
||||||
|
|
||||||
|
## 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.**
|
||||||
|
|
||||||
|
⚠️ **WHEN IT HAS TO CLOSE: BEFORE INVERTER B IS COMMISSIONED (~2026-09-12).**
|
||||||
|
Not before the next deploy. The band is **unreachable at N=1** — `delinquent()`
|
||||||
|
returns `{}` for every input at a roster of one, so no value of
|
||||||
|
`delivery_hold_s` can false-trip on the install that is running today — and the
|
||||||
|
live install is N=1 until B exists. Commissioning B is the moment the band
|
||||||
|
becomes reachable, and that is the deadline this card hands it.
|
||||||
|
|
||||||
|
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE.** It exists as a
|
||||||
|
`[[CFG-07]]` mention in `CHANGELOG.md` and in this section, and nowhere else —
|
||||||
|
so there is no file for the 40.0 s to be recorded in, and a reader who goes
|
||||||
|
looking will find a card reference with no record behind it. Whoever takes
|
||||||
|
CFG-07 writes `DECISIONS/CFG-07-*.md` first; the number it has to answer to is
|
||||||
|
**40.0 s**, measured here, and the harness that produces it is
|
||||||
|
`admin/goodwe:sim/delivery_window.py --campaign --fractions`.
|
||||||
|
|
||||||
|
## 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, and B's 0 W share at the cut-out | part 8b, run and asserted |
|
||||||
|
| 0 W cut-out share / 125 W wedged share | part 8b, asserted and printed |
|
||||||
|
| 77/125/133/154 W (the wedged arm's four distinct shares) | the same loop re-run in review with per-cycle recording — NOT part 8b's printed line, which shows the last cycle only |
|
||||||
|
| M12/M13/M14 named-check counts (7 / 7 / 6) | re-run in review on fresh `:ro` trees, after the `KeyError` abort in part 1 was guarded |
|
||||||
|
| 84 single-unit rosters | part 8b, run |
|
||||||
|
|
||||||
|
⚠️ Every two-inverter figure above is **two copies of inverter A**.
|
||||||
@@ -414,6 +414,94 @@ DELIVERY_OPTION_DEFAULTS = {
|
|||||||
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
|
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, the most sensitive
|
||||||
|
# fraction measured and far below anything this card would ship - 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)
|
@dataclass(frozen=True)
|
||||||
class DeliveryConfig:
|
class DeliveryConfig:
|
||||||
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
||||||
@@ -532,12 +620,30 @@ def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None:
|
|||||||
return 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.
|
"""Which units are short with NO ACCOUNT, when the roster disagrees with them.
|
||||||
|
|
||||||
`units` one `UnitDelivery` per inverter, in roster order.
|
`units` one `UnitDelivery` per inverter, in roster order.
|
||||||
`min_w` the shortfall that counts - `control.Tuning.saturation_w`. See
|
`min_w` the ABSOLUTE shortfall that counts - `control.Tuning.
|
||||||
`MIN_W_IS_SATURATION_W`.
|
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
|
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
|
is the operator's first line and it carries the peer clause, because "B is
|
||||||
@@ -579,7 +685,13 @@ def delinquent(units, min_w: float) -> dict:
|
|||||||
gaps = {}
|
gaps = {}
|
||||||
for u in asked:
|
for u in asked:
|
||||||
gap = short_by(u.share_w, u.measured_w)
|
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
|
gaps[u.name] = gap
|
||||||
|
|
||||||
unaccounted = {u.name: gaps[u.name] for u in asked
|
unaccounted = {u.name: gaps[u.name] for u in asked
|
||||||
@@ -595,7 +707,13 @@ def delinquent(units, min_w: float) -> dict:
|
|||||||
out[u.name] = (
|
out[u.name] = (
|
||||||
f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W "
|
f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W "
|
||||||
f"and delivering {float(u.measured_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"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
|
||||||
f"that state. Nothing this add-on reads explains it")
|
f"that state. Nothing this add-on reads explains it")
|
||||||
return out
|
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
|
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.
|
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
|
⚠️ AND IT WAS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROWED SAFETY-42's
|
||||||
DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2
|
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
|
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
|
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
|
moves whenever its headroom differs from its sibling's - the ordinary case this
|
||||||
module exists to exploit.
|
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
|
`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
|
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
|
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
|
||||||
own 10 % floor, so `envelope()` does not close and `account()` explains
|
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 name
|
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 name it on NONE. The
|
the wedged unit on 199 of 200 cycles and these weights named it on NONE. The
|
||||||
suppression window widens from `site target < 2 * min_w` = 1000 W to
|
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 has
|
`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
|
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
|
⚠️ THAT IS NO LONGER A RESIDUAL. SAFETY-43 made `delinquent()` relative to
|
||||||
**AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That
|
the ask AS WELL AS absolute - `gap > min_w OR gap > frac * share`, and a
|
||||||
is a disclosed residual, not a fixed thing. Closing it means making
|
unit delivering 0 of 125 W is as delinquent as one delivering 0 of 1500 W.
|
||||||
`delinquent()` relative to the ask rather than absolute - SAFETY-42's code
|
On the SAME fixture, under the SAME weights, the wedged unit is now named
|
||||||
and a design change, filed as its own card and deliberately NOT done here.
|
on 200 of 200 cycles. Part 8b grades both rules on one run:
|
||||||
Do not "fix" part 8b by making it assert the alarm still fires; it does not,
|
`share_frac=None` is SAFETY-42's absolute arm alone and still says nothing,
|
||||||
and a green check that says otherwise would be worse than the gap.
|
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
|
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.
|
# 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 -
|
# 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
|
# short by more than `saturation_w`, OR short by more than a fixed FRACTION
|
||||||
# that state, before the add-on names it. It REPORTS: nothing is commanded
|
# 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
|
# 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
|
# that unit's share until somebody looks. app/delivery.py carries why, with
|
||||||
# the rig measurements on both sides of the decision.
|
# 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.control import Tuning
|
||||||
from app.delivery import (
|
from app.delivery import (
|
||||||
DELIVERY_OPTION_DEFAULTS, REASON_CODE, DeliveryConfig, DeliveryWatchdog,
|
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
|
||||||
UnitDelivery, account, delinquent, short_by,
|
DeliveryConfig, DeliveryWatchdog, UnitDelivery, account, delinquent,
|
||||||
|
short_by,
|
||||||
)
|
)
|
||||||
from app.inverter import LINK_STALE_S
|
from app.inverter import LINK_STALE_S
|
||||||
from app.main import Controller
|
from app.main import Controller
|
||||||
@@ -151,6 +152,77 @@ 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",
|
"a peer either - with only one unit asked there is nothing to compare",
|
||||||
delinquent([U("a", 0, 0), U("b", 2000, 0)], 500) == {})
|
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"])
|
||||||
|
# ⚠️ `.get`, NOT `["b"]`, AND THAT IS NOT STYLE. A bare subscript here raises
|
||||||
|
# KeyError on any tree where the relative arm is broken or absent, which ABORTS
|
||||||
|
# THE FILE at part 1 and leaves parts 2-8 UNGRADED. The direction is safe - an
|
||||||
|
# abort is a red, and MUTATION.md's defect can only fabricate a green - but the
|
||||||
|
# recorded coverage of a mutation run then reads larger than it was, which is
|
||||||
|
# the shape M11's entry in test_packshare.py part 11 already discloses. Found
|
||||||
|
# in review of this card, on M12 and M13.
|
||||||
|
_tiny_said = delinquent(_tiny, 500).get("b", "(not named - see the check above)")
|
||||||
|
print(f" {_tiny_said}")
|
||||||
|
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 _tiny_said)
|
||||||
|
|
||||||
|
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()
|
||||||
print(" AC 5: AT ONE INVERTER IT IS INERT, AND BY ALGEBRA RATHER THAN BY A")
|
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")
|
print(" ROSTER GUARD - `len(asked) < 2` is unreachable-by-construction there")
|
||||||
@@ -323,9 +395,14 @@ class _DeliveryHass:
|
|||||||
same in both.
|
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.load_w = float(load_w)
|
||||||
self.b_soc = float(b_soc)
|
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.writes = []
|
||||||
self.calls = []
|
self.calls = []
|
||||||
self.ctrl = None
|
self.ctrl = None
|
||||||
@@ -343,8 +420,11 @@ class _DeliveryHass:
|
|||||||
# A obeys. B produces NOTHING, whatever it is told - and whether that is
|
# 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
|
# a wedged power stage or an empty pack is not a question its power
|
||||||
# register can answer.
|
# 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)
|
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):
|
async def number(self, entity, invert=False):
|
||||||
m = self.measured()
|
m = self.measured()
|
||||||
@@ -630,15 +710,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,
|
# 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
|
# 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 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_RAISED = 1800
|
||||||
_SAT_LOAD_W = 1600.0
|
_SAT_LOAD_W = 1600.0
|
||||||
|
_SAT_B_FRAC = 0.5
|
||||||
_SAT_SHIPPED = int(read_option("saturation_w"))
|
_SAT_SHIPPED = int(read_option("saturation_w"))
|
||||||
_sat_lo, _sat_hi = (int(x) for x in re.match(
|
_sat_lo, _sat_hi = (int(x) for x in re.match(
|
||||||
r"int\((\d+),(\d+)\)", read_option("saturation_w", "schema")).groups())
|
r"int\((\d+),(\d+)\)", read_option("saturation_w", "schema")).groups())
|
||||||
|
|
||||||
|
|
||||||
async def _at_saturation_w(sat):
|
async def _at_saturation_w(sat, b_frac=_SAT_B_FRAC):
|
||||||
hass = _DeliveryHass(load_w=_SAT_LOAD_W)
|
hass = _DeliveryHass(load_w=_SAT_LOAD_W, b_frac=b_frac)
|
||||||
c = _ctrl(hass, saturation_w=sat)
|
c = _ctrl(hass, saturation_w=sat)
|
||||||
return c, await _drive(c, watch=_sample)
|
return c, await _drive(c, watch=_sample)
|
||||||
|
|
||||||
@@ -674,6 +766,17 @@ check("the raised arm REACHED the comparison: B was asked for a discharge "
|
|||||||
and not _b_up.sign_wd.tripped
|
and not _b_up.sign_wd.tripped
|
||||||
and account(live=True, parked=False, soc=_b_up.soc,
|
and account(live=True, parked=False, soc=_b_up.soc,
|
||||||
bms_floor=_b_up.bms_soc_floor) is None)
|
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 "
|
check("...and its shortfall lands STRICTLY BETWEEN the two configured "
|
||||||
"thresholds, which is the one place the two answers can differ. Part 4's "
|
"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",
|
"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,
|
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve,
|
||||||
)
|
)
|
||||||
from app.control import Tuning, compute
|
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
|
from app.peakguard import discharge_permitted
|
||||||
|
|
||||||
fails = []
|
fails = []
|
||||||
@@ -605,67 +607,87 @@ 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 "
|
"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, "
|
"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 "
|
"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)
|
not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding)
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
print()
|
print()
|
||||||
print("8b. ⚠️ AND WHAT THE WEIGHTS DO TO SAFETY-42's DETECTION WINDOW, WHICH IS "
|
print("8b. ⚠️ WHAT THE WEIGHTS DID TO SAFETY-42's DETECTION WINDOW, AND WHAT "
|
||||||
"A REGRESSION AND IS PINNED HERE AS ONE")
|
"SAFETY-43 DID ABOUT IT - GRADED UNDER BOTH RULES, ON ONE FIXTURE")
|
||||||
|
|
||||||
# ⚠️ READ THIS BEFORE READING THE CHECKS: THEY ASSERT THE UNFLATTERING ANSWER ON
|
# ⚠️ THIS PART USED TO PIN THE UNFLATTERING ANSWER ON PURPOSE, AND SAFETY-43 IS
|
||||||
# PURPOSE. An earlier draft of §5 of the decision said a wedged unit's weight
|
# THE CARD THAT INVERTED IT. What it said - and it was true when it was written
|
||||||
# does not move, so OPT-09 "cannot see it at all". The first clause is about the
|
# - was: a wedged unit's weight MOVES whenever its energy headroom differs from
|
||||||
# ENVELOPE and is true; the second is FALSE. A wedged unit's weight moves
|
# its sibling's (it is a pack fact, not a delivery fact), `delivery.delinquent()`
|
||||||
# whenever its energy headroom differs from its sibling's - it is a pack fact,
|
# named a unit only while its `short_by()` gap exceeded `min_w`
|
||||||
# not a delivery fact - and `delivery.delinquent()` names a unit only while its
|
# (`Tuning.saturation_w`, 500 W) IN ABSOLUTE WATTS, and shrinking a unit's share
|
||||||
# `short_by()` gap exceeds `min_w` (`Tuning.saturation_w`, 500 W) IN ABSOLUTE
|
# shrinks its gap. Shrink it below 500 W and the alarm stopped firing on a unit
|
||||||
# WATTS. Shrinking a unit's share shrinks its gap. Shrink it below 500 W and the
|
# that was still delivering nothing. The old text ended "if it ever goes red
|
||||||
# alarm stops firing on a unit that is still delivering nothing.
|
# 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
|
# ⚠️ AND THE EVIDENCE IS THE DIFF BETWEEN TWO RULES ON ONE RUN, NOT A NUMBER
|
||||||
# narrows SAFETY-42's detection window on the same unit. At equal weights the
|
# THAT CHANGED IN A COMMIT. `delinquent()` takes `share_frac`, and
|
||||||
# window is `site target < 2 * min_w` = 1000 W; at OPT-09's weights it is
|
# `share_frac=None` is SAFETY-42's rule EXACTLY - the absolute arm alone. So
|
||||||
# `site target < min_w * (w_A + w_B) / w_B`, which on this fixture is 6500 W.
|
# `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
|
# A future reader who wants to know what this card did runs this file.
|
||||||
# `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
|
# ⚠️ WHAT SAFETY-43 DID NOT DO IS LOWER `min_w`, and the check on the window
|
||||||
# CURRENT BEHAVIOUR so that the day it changes, a named check moves.
|
# 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,
|
# ⚠️ 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
|
# 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,
|
# 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
|
# 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
|
# 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
|
# ⚠️ 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.
|
# 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
|
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
|
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, no_account=False):
|
||||||
"""The closed loop with unit B WEDGED at a healthy SoC. Real everything.
|
"""The closed loop with unit B WEDGED at a healthy SoC. Real everything.
|
||||||
|
|
||||||
Real `compute()`, real `plan()`, real `packshare.weights()`, real
|
Real `compute()`, real `plan()`, real `packshare.weights()`, real
|
||||||
`delivery.account()` and real `delivery.delinquent()`. The only model is the
|
`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
|
plant: A obeys within one cycle inside its rating, B delivers `b_delivers`
|
||||||
told and delivers 0 W - which is the wedged box, and is what makes this a
|
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.
|
SAFETY-42 case rather than an OPT-09 one.
|
||||||
|
|
||||||
`weighted=False` is equal weights - what shipped. Envelopes are left OPEN in
|
`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
|
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.
|
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.
|
||||||
|
|
||||||
|
`no_account=True` DELETES the account gate - every unit is handed
|
||||||
|
`accounted=None` - and it exists to answer "would this arm still be silent
|
||||||
|
if `account()` did nothing at all?". A control that answers the same way
|
||||||
|
with the gate removed is not grading the gate, and the cut-out arm below is
|
||||||
|
exactly that case. See it there; it is the point of that block.
|
||||||
|
|
||||||
Returns `(cycles on which delinquent() named somebody, last cycle)`.
|
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)
|
tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=A_W + B_W)
|
||||||
prev, i_w, sat = 0.0, None, 0
|
prev, i_w, sat = 0.0, None, 0
|
||||||
power = [0.0, 0.0]
|
power = [0.0, 0.0]
|
||||||
@@ -678,13 +700,15 @@ 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)
|
r = plan(d.target_w, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=w)
|
||||||
assert r.ok, r.refused
|
assert r.ok, r.refused
|
||||||
# THE MODELLED PLANT: A within one cycle inside its rating; B WEDGED.
|
# 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)
|
grid = load_w - sum(power)
|
||||||
who = delinquent(
|
who = delinquent(
|
||||||
[UnitDelivery(p.name, r.shares[p.name], m,
|
[UnitDelivery(p.name, r.shares[p.name], m,
|
||||||
|
None if no_account else
|
||||||
account(live=True, parked=False, soc=p.soc,
|
account(live=True, parked=False, soc=p.soc,
|
||||||
bms_floor=p.bms_floor))
|
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)
|
named += bool(who)
|
||||||
last = (d.target_w, dict(r.shares), grid, who)
|
last = (d.target_w, dict(r.shares), grid, who)
|
||||||
return named, last
|
return named, last
|
||||||
@@ -698,75 +722,271 @@ check("PRECONDITION: the wedged unit is a SAFETY-42 TRUE POSITIVE and NOT an "
|
|||||||
not packshare.envelope(_WEDGED_PACK).binding
|
not packshare.envelope(_WEDGED_PACK).binding
|
||||||
and account(live=True, parked=False, soc=WEDGED_B_SOC,
|
and account(live=True, parked=False, soc=WEDGED_B_SOC,
|
||||||
bms_floor=FLOOR) is None)
|
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 = {}
|
_wedge = {}
|
||||||
for _load in (1500.0, 6000.0):
|
for _load in (1500.0, 6000.0):
|
||||||
for _weighted in (False, True):
|
for _weighted in (False, True):
|
||||||
_n, _t = wedged_loop(_load, _weighted)
|
for _frac in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION):
|
||||||
_wedge[(_load, _weighted)] = (_n, _t)
|
_n, _t = wedged_loop(_load, _weighted, share_frac=_frac)
|
||||||
print(f" load {_load:.0f} W, B wedged at 15 %, "
|
_wedge[(_load, _weighted, _frac)] = (_n, _t)
|
||||||
f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} "
|
print(f" load {_load:.0f} W, B wedged at 15 %, "
|
||||||
f"named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W, shares "
|
f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} "
|
||||||
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items()))
|
+ ("absolute arm only" if _frac is ABSOLUTE_ONLY
|
||||||
+ f", grid {_t[2]:+.0f} W, named="
|
else f"frac {_frac:.2f} ")
|
||||||
+ (str(sorted(_t[3])) if _t[3] else "(nobody)"))
|
+ 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 "
|
check("⚠️ THE CARD, RED: UNDER SAFETY-42's RULE AND OPT-09's WEIGHTS THE WEDGED "
|
||||||
"cycle but the first, at a load its sibling can carry on its own. The "
|
"UNIT IS NAMED ON NONE OF THE 200. Same wedged box, same healthy SoC, "
|
||||||
"loop winds the site target to twice the load, the 50/50 split stands "
|
"same load, same detector: the split moves to 1505/125, the gap on the "
|
||||||
"+1500 W on a box producing nothing, and 1500 W > 500 W is what "
|
"wedged unit falls to 125 W, and 125 W < 500 W so the absolute arm "
|
||||||
"`delinquent()` reads",
|
"returns {}. This is the defect SAFETY-43 was filed for, and it is "
|
||||||
_wedge[(1500.0, False)][0] == 199
|
"measured here rather than remembered",
|
||||||
and sorted(_wedge[(1500.0, False)][1][3]) == [Bee]
|
_wedge[(1500.0, True, ABSOLUTE_ONLY)][0] == 0
|
||||||
and _wedge[(1500.0, False)][1][1] == {A: 1500, Bee: 1500})
|
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 "
|
check("⚠️ THE CARD, GREEN: THE SHIPPED RULE NAMES IT ON ALL 200, ON THE SAME "
|
||||||
"box, same healthy SoC, same load, same detector: the split moves to "
|
"RUN. The unit is standing at +125 W and delivering 0 W - 100 % of its "
|
||||||
"1505/125, the gap on the wedged unit falls to 125 W, and 125 W < 500 W "
|
"ask is not arriving - and `gap > frac * share` does not care that 125 W "
|
||||||
"so `delinquent()` returns {}. THIS CHECK PINS A REGRESSION - it asserts "
|
"is a small number. The ONLY input that moved between this check and the "
|
||||||
"what the code DOES, not what anyone wants, and if it ever goes red "
|
"one above is `share_frac`",
|
||||||
"because the alarm fired, read the card that made it fire before "
|
_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||||
"'fixing' this line",
|
and sorted(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee]
|
||||||
_wedge[(1500.0, True)][0] == 0
|
and _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][1] \
|
||||||
and _wedge[(1500.0, True)][1][3] == {}
|
== {A: 1505, Bee: 125})
|
||||||
and _wedge[(1500.0, True)][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 "
|
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 "
|
"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 "
|
"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 "
|
"action-log line fires. The site is quietly running one inverter, and "
|
||||||
"one instrument built to say so has gone quiet with it",
|
"before this card the one instrument built to say so had gone quiet with "
|
||||||
abs(_wedge[(1500.0, True)][1][2]) <= 5.0
|
"it",
|
||||||
and abs(_wedge[(1500.0, False)][1][2]) <= 5.0)
|
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)
|
_w = packshare.weights([PE(A, WEDGED_A_SOC, A_KWH), _WEDGED_PACK], 1000.0)
|
||||||
_window_equal = MIN_W * 2.0
|
_window_equal = MIN_W * 2.0
|
||||||
_window_opt09 = MIN_W * sum(_w.values()) / _w[Bee]
|
_window_opt09 = MIN_W * sum(_w.values()) / _w[Bee]
|
||||||
print(f" suppression window on this fixture: equal weights, site target "
|
print(f" the ABSOLUTE arm's suppression window is UNCHANGED by SAFETY-43: "
|
||||||
f"< {_window_equal:.0f} W; OPT-09 weights, site target "
|
f"equal weights, site target < {_window_equal:.0f} W; OPT-09 weights, "
|
||||||
f"< {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site")
|
f"site target < {_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 "
|
check("⚠️ AND `min_w` WAS NOT LOWERED, WHICH IS THE HALF OF THIS CARD THAT IS "
|
||||||
"fixture: `delinquent()` reads an ABSOLUTE gap, so a unit weighted to a "
|
"EASIEST TO GET WRONG. The absolute arm still suppresses below 1000 W at "
|
||||||
"fraction f of the site target is invisible while `f * target < min_w`. "
|
"equal weights and below 6500 W at these ones - the same arithmetic this "
|
||||||
"Equal weights suppress below 1000 W; these weights suppress below "
|
"part has always printed - because SAFETY-43 ADDED a second way to be "
|
||||||
"6500 W of a 9600 W site. ⚠️ BOTH ARE PROPORTIONAL-REGIME BOUNDS AND "
|
"named rather than loosening the first. Lowering `saturation_w` would "
|
||||||
"NEITHER IS REACHABLE: the healthy pack's own rating binds first, so the "
|
"have reported normal saturation on every unit at once, which is the "
|
||||||
"alarm returns BELOW 6500 W (swept: 0/200 to a 3000 W load, 41/200 at "
|
"false-trip regime SAFETY-41 spent four attempts in",
|
||||||
"4900 W, 195/200 at 5000 W) and the equal arm never suppresses at all. "
|
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0
|
||||||
"That is the regression, and it is a property "
|
and MIN_W == Tuning().saturation_w == 500.0)
|
||||||
"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)
|
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.
|
||||||
|
|
||||||
|
# ⚠️ AND THE FIRST DRAFT OF THIS BLOCK WAS VACUOUS, WHICH IS WHY IT NOW PRINTS
|
||||||
|
# ITS SHARES. It ran the cut-out arm, saw 0/200, and concluded "`account()`
|
||||||
|
# explains it" and "identical trace to the wedged arm". BOTH WERE FALSE. At its
|
||||||
|
# own cut-out the pack's usable energy ABOVE that cut-out is zero, so
|
||||||
|
# `weights()` gives it 0.0, `_fill()`'s preferred pass gives it nothing, the
|
||||||
|
# healthy sibling covers the load alone so the LAST-RESORT pass never runs, and
|
||||||
|
# its share is 0 W on ALL 200 CYCLES. A unit with a 0 W share is not in `asked`,
|
||||||
|
# so `len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()`
|
||||||
|
# is never consulted - measured below by deleting the gate and getting the same
|
||||||
|
# answer. The wedged arm's share is the 125 W printed on its own line above;
|
||||||
|
# this one's is 0, printed and asserted below. Nothing was identical and nothing
|
||||||
|
# reached the comparison.
|
||||||
|
#
|
||||||
|
# ⚠️ THE FACT THAT REPLACES IT IS BETTER, NOT WEAKER: at its own cut-out OPT-09
|
||||||
|
# STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached.
|
||||||
|
# That is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the
|
||||||
|
# allocator declines to ask before the detector has to decline to alarm. The
|
||||||
|
# account gate itself is graded where it is REAL, on a positive 125 W share, in
|
||||||
|
# `test_delivery.py` part 1 ("THE ACCOUNT GATE IS UNTOUCHED BY IT").
|
||||||
|
#
|
||||||
|
# ⚠️ AND IT WAS INVISIBLE FOR ONE REASON: this was the only line in part 8b that
|
||||||
|
# did not print its shares. `inverter-b+0` would have been on screen from the
|
||||||
|
# first run. Every line in this part prints them now.
|
||||||
|
_sat_named, _sat_last = wedged_loop(1500.0, True, b_soc=FLOOR)
|
||||||
|
_sat_nogate, _sat_nogate_last = wedged_loop(1500.0, True, b_soc=FLOOR,
|
||||||
|
no_account=True)
|
||||||
|
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, shares "
|
||||||
|
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_sat_last[1].items()))
|
||||||
|
+ ", named="
|
||||||
|
+ (str(sorted(_sat_last[3])) if _sat_last[3] else "(nobody)")
|
||||||
|
+ f"; with account() DELETED: {_sat_nogate:3d}/200")
|
||||||
|
# ⚠️ AND THE `no_account=True` SWITCH ITSELF WAS GRADED BY NOTHING. It is used
|
||||||
|
# in exactly one place - the fixture above - where it PROVABLY cannot change the
|
||||||
|
# answer, because a cut-out pack is never asked and the gate is never reached.
|
||||||
|
# Review neutered the switch so it removed nothing: the file stayed GREEN and
|
||||||
|
# printed "with account() DELETED: 0/200" while `account()` had not been
|
||||||
|
# deleted. A line asserting a mechanism that did not happen - B1's shape, one
|
||||||
|
# level down, in the fix for B1.
|
||||||
|
#
|
||||||
|
# The switch DOES bite, on a fixture already reachable from this helper: at the
|
||||||
|
# cut-out with EQUAL weights, `plan()` hands B a 50/50 share, so the gate is the
|
||||||
|
# only thing keeping it silent. Measured: gate on 0/200, gate deleted 200/200.
|
||||||
|
# That proves the switch removes the gate, AND gives this part the end-to-end
|
||||||
|
# account-gate control on a POSITIVE share that it otherwise has to outsource to
|
||||||
|
# test_delivery.py part 1.
|
||||||
|
_eq_gate, _eq_last = wedged_loop(1500.0, False, b_soc=FLOOR)
|
||||||
|
_eq_nogate, _ = wedged_loop(1500.0, False, b_soc=FLOOR, no_account=True)
|
||||||
|
print(f" ...and the SWITCH is real: same cut-out, EQUAL weights so B IS "
|
||||||
|
f"asked ({_eq_last[1][Bee]:+d} W last cycle) - gate on {_eq_gate:3d}/200, "
|
||||||
|
f"gate DELETED {_eq_nogate:3d}/200")
|
||||||
|
check("⭐ THE `no_account` SWITCH ACTUALLY REMOVES THE GATE, on a fixture where "
|
||||||
|
"the gate is the only thing keeping the pack silent. Without this the "
|
||||||
|
"line above prints 'with account() DELETED' about a run in which nothing "
|
||||||
|
"was deleted - which is exactly the defect this block was rewritten for",
|
||||||
|
_eq_gate == 0 and _eq_nogate == 200 and _eq_last[1][Bee] > 0)
|
||||||
|
|
||||||
|
check("A PACK AT ITS OWN CUT-OUT IS NEVER ASKED, SO THE RELATIVE ARM IS NEVER "
|
||||||
|
"REACHED. Its usable energy above its own cut-out is zero, `weights()` "
|
||||||
|
"answers 0.0 for it, the healthy sibling covers the load so `_fill()`'s "
|
||||||
|
"last-resort pass never runs, and its share is 0 W on every one of the "
|
||||||
|
"200 cycles - so it is not in `asked` and `len(asked) < 2` returns {} "
|
||||||
|
"before either arm is compared. ⚠️ THE SHARE IS ASSERTED, not inferred "
|
||||||
|
"from the silence: a control that cannot say WHY it is quiet is a control "
|
||||||
|
"that cannot fail",
|
||||||
|
_sat_named == 0 and _sat_last[3] == {} and _sat_last[1][Bee] == 0)
|
||||||
|
check("⚠️ AND THAT IS A SECOND, INDEPENDENT PROTECTION - NOT THE ACCOUNT GATE, "
|
||||||
|
"WHICH THIS ARM DOES NOT TOUCH. Delete `account()` entirely, hand every "
|
||||||
|
"unit `accounted=None`, and this arm is STILL silent on all 200 cycles, "
|
||||||
|
"because the protection is UPSTREAM of it: the allocator declines to ask "
|
||||||
|
"before the detector has to decline to alarm. ⚠️ SO DO NOT READ THIS "
|
||||||
|
"BLOCK AS EVIDENCE ABOUT `account()` - it is evidence about the ask. The "
|
||||||
|
"account gate on a POSITIVE share is graded in test_delivery.py part 1",
|
||||||
|
_sat_nogate == 0 and _sat_nogate_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 DERATED control is not vacuous, which is asserted on the ONE "
|
||||||
|
"thing that could make it so: unlike the cut-out arm above, its unit IS "
|
||||||
|
"asked - a positive share on every cycle - so it really does reach both "
|
||||||
|
"arms and is turned away by the size of its shortfall. ⚠️ THIS GUARD USED "
|
||||||
|
"TO RE-ASSERT THE WEDGED ARM's 200/200 INSTEAD, WHICH IS WHY IT DID NOT "
|
||||||
|
"CATCH THE CUT-OUT ARM BEING VACUOUS: a check on a DIFFERENT run cannot "
|
||||||
|
"say anything about whether THIS one reached the comparison",
|
||||||
|
_derate_last[1][Bee] > 0
|
||||||
|
and _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()
|
print()
|
||||||
@@ -978,9 +1198,12 @@ print(" a preference and are honest about being one.")
|
|||||||
print(" * A WEDGED UNIT IS NOT REACHABLE BY THE ENVELOPE. Its SoC reads")
|
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(" healthy, so nothing closes, and SAFETY-42 owns the detection.")
|
||||||
print(" ⚠️ BUT IT IS REACHABLE BY THE WEIGHTS, WHICH MOVE ON ENERGY")
|
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(" HEADROOM AND NOT ON DELIVERY. That narrowed SAFETY-42's")
|
||||||
print(" to SAFETY-42's window, and the answer is a REGRESSION that is")
|
print(" detection window - a regression this file PINNED rather than")
|
||||||
print(" pinned rather than fixed.")
|
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()
|
print()
|
||||||
@@ -1023,13 +1246,41 @@ MUT = [
|
|||||||
"mutation took it down with a TypeError three lines into part 1"),
|
"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 "
|
("M11 weights(): keep the shape, force every weight to 1.0 <- THE PROOF "
|
||||||
"THAT PART 8b CAN GO RED",
|
"THAT PART 8b CAN GO RED",
|
||||||
"test_packshare 8b, three named checks: the wedged unit goes back to being "
|
"test_packshare 8b, FIVE named checks - re-run for SAFETY-43 rather than "
|
||||||
"named on 199/200 at 1500 W, the split reverts to 1500/1500 and the "
|
"carried across, because the part it grades was rewritten: the split "
|
||||||
"window collapses from 6500 W to 1000 W. ⚠️ RUN, NOT REASONED ABOUT - a "
|
"reverts to 1500/1500 so the absolute arm names the unit on 199/200 "
|
||||||
"check that pins an UNFLATTERING behaviour has to be shown capable of "
|
"(taking the card's RED check down), the shipped rule's 200/200 comes "
|
||||||
"failing, or it is indistinguishable from a check that pins nothing. "
|
"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 "
|
"(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)"),
|
"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",
|
||||||
|
"SEVEN named checks: test_delivery 1 (THREE - the 0-of-125 W naming, the "
|
||||||
|
"proportion in the sentence, and the strict relative bound) and "
|
||||||
|
"test_packshare 8b (FOUR - the GREEN check goes back to 0/200 under "
|
||||||
|
"OPT-09's weights, the equal arm's 200 back to 199, the 6000 W arm back "
|
||||||
|
"to 195/199, and the derated control's vacuity guard 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",
|
||||||
|
"SEVEN named checks: test_delivery 1 (TWO) and test_packshare 8b (FIVE), "
|
||||||
|
"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",
|
||||||
|
"SIX named checks: test_delivery 1 (TWO - the strict bound, and 'IT TOOK "
|
||||||
|
"NOTHING AWAY' stops being true) and 4b (THREE - the `saturation_w` pin "
|
||||||
|
"goes VACUOUS, because the relative arm names B at every threshold), plus "
|
||||||
|
"test_packshare 8b's DERATED positive control, where a unit delivering "
|
||||||
|
"half its share starts being named. ⚠️ 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:
|
for _n, _w in MUT:
|
||||||
print(f" caught {_n}")
|
print(f" caught {_n}")
|
||||||
@@ -1039,8 +1290,17 @@ 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 "
|
"M6: the whole defect this card fixes - a parameter nothing passes - is "
|
||||||
"invisible to every behavioural fixture in this repository, because they "
|
"invisible to every behavioural fixture in this repository, because they "
|
||||||
"all build their own claim list and call `resolve()` themselves. That is "
|
"all build their own claim list and call `resolve()` themselves. That is "
|
||||||
"why the call site is pinned statically as well",
|
"why the call site is pinned statically as well. ⚠️ M12-M14 ARE "
|
||||||
len(MUT) == 11)
|
"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. "
|
||||||
|
"⚠️ AND THEIR COUNTS WERE RE-RUN IN REVIEW AND TWO OF THEM MOVED: "
|
||||||
|
"test_delivery.py part 1 used to subscript `delinquent(...)['b']` for a "
|
||||||
|
"print, which raised KeyError under M12 and M13 and ABORTED THE FILE at "
|
||||||
|
"part 1, leaving parts 2-8 ungraded and the recorded coverage smaller "
|
||||||
|
"than the truth. It is a `.get` now. M14's count was simply wrong: five "
|
||||||
|
"named checks in test_delivery, not four",
|
||||||
|
len(MUT) == 14)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
|
|||||||
Reference in New Issue
Block a user