OPT-09: the weights DO move on a wedged unit, and that narrows SAFETY-42
checks / unit (pull_request) Successful in 6m11s
checks / unit (pull_request) Successful in 6m11s
B1, the blocking review finding. §5 of the decision said:
"A wedged unit reads a healthy SoC, so no envelope closes and no weight
moves - OPT-09 cannot see it at all."
The first clause is true and is about the ENVELOPE. The second clause is
FALSE, and it was load-bearing for the claim that OPT-09 and SAFETY-42 meet
without overlapping. A weight is a function of a 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 card exists to
exploit. And delivery.delinquent() (app/delivery.py:484, threshold at :531)
names a unit only while its short_by() gap exceeds min_w = Tuning.saturation_w
= 500 W in ABSOLUTE WATTS. Shrinking the share shrinks the gap below the
threshold, and the alarm stops firing on a unit still delivering nothing.
So the seam, stated accurately: OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit. That is a DETECTION REGRESSION at
N=2. It is disclosed here, not fixed: making delinquent() relative to the ask
rather than absolute is SAFETY-42's code and a design change, filed as its own
card.
1. THE CORRECTION, in the three places that carried the claim
* DECISIONS/OPT-09-soc-aware-weights.md §5 - rewritten. States the seam
accurately, quotes the sentence it is replacing, carries the measured
rows and the window arithmetic, and names the residual in the same voice
as SAFETY-13's "2 of 272" and SAFETY-42's "the discriminator does not
exist". Also drops "they meet and do not overlap" from the opening.
* app/packshare.py - a new section in the module docstring, immediately
after "weights are a comfort choice for stability and not for delivery",
which is where the over-read starts.
* CHANGELOG.md - a new paragraph in the SAFETY-42 entry, ending on the
operator-facing sentence: a wedged inverter with less energy headroom
than its sibling can now go unreported at ordinary house loads.
test_packshare.py part 8's closing check and its "what this file cannot
see" list repeated the same false clause; both now say the ENVELOPE cannot
reach a wedged unit and point at part 8b for what the WEIGHTS do to it.
2. THE GRADED CHECK - test_packshare.py part 8b, 6 named checks
The reviewer's probe, reproduced: real compute(), real plan(), real
packshare.weights(), real delivery.account(), real delivery.delinquent();
the only model is the plant (A obeys within one cycle inside its rating,
B accepts its share and delivers 0 W). Unit B is WEDGED at 15 % against
its own 10 % floor, so envelope() does not close and account() explains
nothing - a SAFETY-42 true positive by that card's own definition, which
a PRECONDITION check asserts before anything is concluded from it.
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 1500 W, B wedged at 15 %, OPT-09 weights named on 0/200 cycles,
site target +1630 W, shares a+1505, b+125, grid -5 W, named=(nobody)
load 6000 W, B wedged at 15 %, equal weights named on 199/200 cycles
load 6000 W, B wedged at 15 %, OPT-09 weights named on 195/200 cycles
suppression window: equal weights, site target < 1000 W; OPT-09
weights, site target < 6500 W of a 9600 W site
THE CHECKS ASSERT THE CURRENT, UNFLATTERING BEHAVIOUR ON PURPOSE - that
the weighted arm names NOBODY where the equal arm names the wedged unit.
A check asserting the alarm still fires would fail, and papering over it
would be worse than the original defect. The 6000 W row is there so the
part is not pinning "the weighted arm never names anybody": past the
healthy pack's own rating, _fill()'s last-resort pass hands the wedged
unit 3000 W and both arms name it. The window is real and it has an edge.
MUTATION - packshare.weights() keeps its shape and forces every weight to
1.0 (M11 in part 11's table, which now has 11 rows). RUN on a purged tree
with python3 -B, not reasoned about:
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 1500 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 6000 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
site target +4000 W, shares a+2000, b+2000, grid +4000 W, named=[b]
suppression window: equal weights < 1000 W; OPT-09 weights < 1000 W
FAIL AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200 [...]
FAIL ...and the arithmetic behind it, stated as a window [...]
FAIL AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY [...]
test_packshare.py exits 1. Restored: exits 0.
3. THREE PROC-01 PROSE DEFECTS
* DECISIONS/OPT-09-soc-aware-weights.md:3 said "Base: release/1.0 @
464d398". The branch was rebased; git merge-base HEAD 3368231 is
3368231. Corrected, and it says why the old number was there.
* Same file:13 cited main.py:2272 for "resolution = resolve(...)".
git show 3368231:goodwe_controller/app/main.py | grep -n says 2422.
Corrected. The sibling citation arbiter.py:535 is right and is left.
* DOCS.md:174 said "Since 0.7.x the split is driven by two separate
things", which tells an operator the fix already shipped. config.yaml
is 0.8.0 and the live install is 0.7.0, where the split is 50/50 - this
card's own premise. Now "from 0.8.0 onwards", with an explicit warning
that 0.7.0 and earlier are a fixed 50/50.
NOT TOUCHED, deliberately: delinquent()'s absolute threshold (SAFETY-42's
code, its own card), and app/main.py:2699's double-logged scope refusal (N4,
filed separately).
INVERTER B DOES NOT EXIST (expected ~2026-09-12). Every two-unit figure above
is TWO COPIES OF A through a plant modelled in test_packshare.py; B's 4600 W /
20.48 kWh / 10 % floor are from the datasheet and the serial. Nothing here was
measured on hardware, and the added disclaimers say so in each place.
Verified with the real harness per .gitea/workflows/checks.yml
(goodwe-addon-checks:1): COLLECTED 24 files, ALLGREEN. Harness positive
control run first - test_validate.py deliberately broken, harness reported
"FAILED: test_validate.py" and exited 1; restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
co-authored by
Claude Opus 5
parent
f8e501393f
commit
8d83ef0dfb
@@ -267,6 +267,35 @@ the conclusion is unchanged and is now stated on the predicate's own inputs.
|
||||
hazard from opposite sides: **OPT-09 stops the site ASKING a pack whose SoC says
|
||||
it cannot deliver; SAFETY-42 alarms when a pack that said it could does not.**
|
||||
|
||||
⚠️ **AND THIS CARD NARROWS SAFETY-42's DETECTION WINDOW. IT IS A DETECTION
|
||||
REGRESSION AT TWO INVERTERS AND IT IS DISCLOSED RATHER THAN FIXED.** An earlier
|
||||
draft of this entry and of `DECISIONS/OPT-09-soc-aware-weights.md` §5 said a
|
||||
wedged unit's weight does not move, so OPT-09 could not see the case at all. The
|
||||
ENVELOPE half is true - a wedged box reads a healthy SoC and nothing closes. The
|
||||
WEIGHT half was **false**: a weight is a function of a 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. And `delivery.delinquent()` names a unit
|
||||
only while its shortfall exceeds `saturation_w` - **500 W in absolute watts** -
|
||||
so shrinking the share shrinks the gap below the threshold and the alarm stops
|
||||
firing. Measured end to end with the real loop, the real allocator and the real
|
||||
detector, on a unit wedged at a healthy 15 % against its own 10 % floor (a
|
||||
SAFETY-42 true positive by that card's own definition): at a 1500 W load the
|
||||
healthy pack can carry alone, equal weights name it on **199 of 200** cycles and
|
||||
these weights name it on **none of the 200** - shares 1505/125, gap 125 W, and
|
||||
125 W is under the threshold. The suppression window widens from a site target
|
||||
below 1000 W to a site target below `saturation_w * (w_A + w_B) / w_B`, which on
|
||||
that fixture is **6500 W of a 9600 W site**; above it the healthy pack hits its
|
||||
own rating, the allocator's last-resort pass hands the wedged unit a big share
|
||||
again and the alarm returns. So in the quiet regime - where `unmet_w` is 0, the
|
||||
meter is satisfied and no action-log line fires - **a wedged inverter with less
|
||||
energy headroom than its sibling can now go unreported at ordinary house
|
||||
loads.** `test_packshare.py` part 8b grades it and pins the CURRENT behaviour, so
|
||||
the day it changes a named check moves. Closing it means making `delinquent()`
|
||||
relative to the ask rather than absolute, which is SAFETY-42's code and a design
|
||||
change; that is filed as its own card and is deliberately not done here.
|
||||
⚠️ Every two-unit figure above is **two copies of inverter A** through a modelled
|
||||
plant - inverter B does not exist yet - and B's ratings are datasheet and serial.
|
||||
|
||||
⚠️ **AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME
|
||||
([[SAFETY-42]]) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.**
|
||||
`delivery_hold_s: 185` joins `options:` and `schema:` (`int(30,900)`). The
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# OPT-09 - the weights parameter, and what it is honestly for
|
||||
|
||||
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `464d398`.
|
||||
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3368231`
|
||||
(`git merge-base HEAD 3368231`; the branch was rebased and this line used to name
|
||||
the pre-rebase base, `464d398`).
|
||||
|
||||
## 0. What was actually wrong
|
||||
|
||||
@@ -9,7 +11,7 @@ consumes it in two passes, and **nothing had ever passed one**. Confirmed on the
|
||||
base commit:
|
||||
|
||||
arbiter.py:535 def resolve(claims, *, units=None, weights=None, live=None, ...)
|
||||
main.py:2272 resolve(claims, units=self.units, live=self.live_units(), ...)
|
||||
main.py:2422 resolve(claims, units=self.units, live=self.live_units(), ...)
|
||||
|
||||
So `weights` was None on every tick of every install, `plan()` turned that into
|
||||
1.0 per unit, and the site target was split **50/50 regardless of what either
|
||||
@@ -147,16 +149,67 @@ deferred option, and the BMS is what stops it.
|
||||
|
||||
## 5. SAFETY-42 - the same hazard from the opposite side
|
||||
|
||||
They meet and do not overlap, and the seam is **the state of charge**:
|
||||
The seam is **the state of charge** - and they overlap in one direction, which
|
||||
is what the rest of this section is about:
|
||||
|
||||
* **OPT-09 stops the site ASKING** a pack whose own SoC says it cannot deliver.
|
||||
* **SAFETY-42 alarms** when a pack that said it could does not.
|
||||
|
||||
A wedged unit reads a healthy SoC, so no envelope closes and no weight moves -
|
||||
OPT-09 cannot see it at all. A pack at its floor is `account()`'s explained case,
|
||||
so SAFETY-42 stays silent - and after this card that ask does not arrive in the
|
||||
first place, which removes the benign arm from the detector's input rather than
|
||||
teaching it a new discriminator.
|
||||
A wedged unit reads a healthy SoC, so **no envelope closes** - that half is
|
||||
absolute, and it is why this card cannot stop the ask on a wedged box. A pack at
|
||||
its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after
|
||||
this card that ask does not arrive in the first place, which removes the benign
|
||||
arm from the detector's input rather than teaching it a new discriminator.
|
||||
|
||||
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWS SAFETY-42's DETECTION WINDOW. THIS
|
||||
IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE.** An earlier draft of
|
||||
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
|
||||
at all". The second clause was **false**, and it was load-bearing for the claim
|
||||
that the two cards do not overlap. A weight is a function of the pack's ENERGY
|
||||
HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever
|
||||
its headroom differs from its sibling's - which is the ordinary case this card
|
||||
exists to exploit. And `delivery.delinquent()` (`app/delivery.py:484`)
|
||||
names a unit only while its `short_by()` gap exceeds `min_w =
|
||||
Tuning.saturation_w` - the comparison is `app/delivery.py:531`, and it is
|
||||
**500 W in absolute watts**. Shrinking a unit's share shrinks its gap. Shrink the
|
||||
share far enough and the gap drops below 500 W while the unit is still delivering
|
||||
nothing, and the alarm stops firing.
|
||||
|
||||
Measured on the closed loop - real `compute()`, real `plan()`, real
|
||||
`delinquent()`, modelled plant, B wedged at 15 % against its own 10 % floor so
|
||||
nothing closes and `account()` explains nothing, i.e. a SAFETY-42 **true
|
||||
positive** by that card's own definition:
|
||||
|
||||
load 1500 W equal weights (shipped) named on 199/200 cycles
|
||||
shares {a:1500, b:1500} grid +0 W named=['inverter-b']
|
||||
load 1500 W OPT-09 weights named on 0/200 cycles
|
||||
shares {a:1505, b: 125} grid -5 W named=(nobody)
|
||||
|
||||
⚠️ That plant is modelled in `test_packshare.py`, and **inverter B does not
|
||||
exist** (expected ~2026-09-12): it is two copies of A driven with different
|
||||
numbers, and B's 4600 W / 20.48 kWh / 10 % floor are from the datasheet and the
|
||||
serial. The claim is about the arithmetic of the split and the threshold, which
|
||||
needs no hardware, and not about a GW5000S-BP.
|
||||
|
||||
At a load the healthy pack can cover - the regime where nothing else tells the
|
||||
operator anything, `unmet_w` is 0, the meter is satisfied and no action-log line
|
||||
fires - a wedged inverter goes from named on essentially every cycle to **never
|
||||
named**. The suppression window widens from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B`, which on that fixture is **6500 W** of
|
||||
a 9600 W site; above it - once the healthy pack is at its own rating and
|
||||
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
|
||||
returns. `test_packshare.py` part 8b grades all of it and **pins the current,
|
||||
unflattering behaviour**, so that the day it changes a named check moves.
|
||||
|
||||
So the seam, stated accurately: **OPT-09 stops the ask AND narrows SAFETY-42's
|
||||
detection window for a low-headroom unit.** The two cards do not merely meet from
|
||||
opposite sides; this one takes something away from the other one. Closing the gap
|
||||
means making `delinquent()` relative to the ask rather than absolute, which is
|
||||
SAFETY-42's code and a design change - it is filed as its own card, deliberately
|
||||
not done here, and this paragraph is its input. Until then this is a **disclosed
|
||||
residual**, in the same voice as SAFETY-13's "2 of 272" and SAFETY-42's own "the
|
||||
discriminator does not exist": at two inverters, a wedged unit with materially
|
||||
less energy headroom than its sibling can go unreported at ordinary house loads.
|
||||
|
||||
⚠️ **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
|
||||
|
||||
@@ -234,9 +234,12 @@ single-inverter entity back in that field. **Before downgrading, write down the
|
||||
|
||||
#### How the site target is divided between the packs (OPT-09)
|
||||
|
||||
Two packs do not get half each. Since 0.7.x the split is driven by two separate
|
||||
things, and it is worth knowing which is which, because only one of them is a
|
||||
rule:
|
||||
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
|
||||
version before it the split is a fixed 50/50 regardless of what either pack can
|
||||
do** — that is the defect this section describes the fix for, so if the add-on
|
||||
you are running reports 0.7.0, none of what follows is happening yet. From 0.8.0
|
||||
the split is driven by two separate things, and it is worth knowing which is
|
||||
which, because only one of them is a rule:
|
||||
|
||||
* **The envelope — a permission.** A pack whose own state of charge is at or
|
||||
under its own `bms_soc_floor` is **not asked to discharge**; a pack at 100 %
|
||||
|
||||
@@ -57,6 +57,37 @@ deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
|
||||
never rises. That module DETECTS it; this one cannot fix it, and saying so
|
||||
is worth more than a weighting scheme that looks like it might.
|
||||
|
||||
⚠️ AND IT IS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROW SAFETY-42's
|
||||
DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2
|
||||
=======================================================================
|
||||
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
|
||||
case at all. THE ENVELOPE HALF IS TRUE: a wedged box reads a healthy SoC and
|
||||
nothing here closes. THE WEIGHT HALF WAS FALSE. `weights()` is a function of a
|
||||
pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight
|
||||
moves whenever its headroom differs from its sibling's - the ordinary case this
|
||||
module exists to exploit.
|
||||
|
||||
`delivery.delinquent()` names a unit only while its `short_by()` gap exceeds
|
||||
`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's
|
||||
share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real
|
||||
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
|
||||
own 10 % floor, so `envelope()` does not close and `account()` explains
|
||||
nothing): at a 1500 W load the healthy pack can carry alone, equal weights name
|
||||
the wedged unit on 199 of 200 cycles and these weights name it on NONE. The
|
||||
suppression window widens from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it has
|
||||
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.
|
||||
|
||||
⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK
|
||||
**AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That
|
||||
is a disclosed residual, not a fixed thing. Closing it means making
|
||||
`delinquent()` relative to the ask rather than absolute - SAFETY-42's code
|
||||
and a design change, filed as its own card and deliberately NOT done here.
|
||||
Do not "fix" part 8b by making it assert the alarm still fires; it does not,
|
||||
and a green check that says otherwise would be worse than the gap.
|
||||
|
||||
WHY ENERGY AND NOT POWER
|
||||
========================
|
||||
The two packs at this site are asymmetric IN OPPOSITE DIRECTIONS. From the
|
||||
|
||||
@@ -41,6 +41,7 @@ from app.arbiter import (
|
||||
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve,
|
||||
)
|
||||
from app.control import Tuning, compute
|
||||
from app.delivery import UnitDelivery, account, delinquent
|
||||
from app.peakguard import discharge_permitted
|
||||
|
||||
fails = []
|
||||
@@ -599,14 +600,168 @@ check("...so: WEIGHTS ARE A COMFORT CHOICE FOR STABILITY AND NOT FOR DELIVERY, "
|
||||
weights={A: 1.0, Bee: 0.0}).shares == {A: 2000, Bee: 1000})
|
||||
check("...and SAFETY-42 is where the case this card CANNOT reach is handled. A "
|
||||
"WEDGED unit - one that accepts its share and does not produce it - has a "
|
||||
"healthy state of charge, so no envelope closes and no weight moves; that "
|
||||
"module detects it, latches and notifies, and deliberately does not act. "
|
||||
"OPT-09 stops the site ASKING a pack whose own SoC says it cannot "
|
||||
"deliver; SAFETY-42 alarms when a pack that said it could does not. Same "
|
||||
"hazard, opposite sides, and the seam is the state of charge",
|
||||
"healthy state of charge, so NO ENVELOPE CLOSES; that module detects it, "
|
||||
"latches and notifies, and deliberately does not act. OPT-09 stops the "
|
||||
"site ASKING a pack whose own SoC says it cannot deliver; SAFETY-42 "
|
||||
"alarms when a pack that said it could does not. ⚠️ THE WEIGHT, HOWEVER, "
|
||||
"DOES MOVE - this check used to say it did not - and part 8b grades what "
|
||||
"that costs the alarm",
|
||||
not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("8b. ⚠️ AND WHAT THE WEIGHTS DO TO SAFETY-42's DETECTION WINDOW, WHICH IS "
|
||||
"A REGRESSION AND IS PINNED HERE AS ONE")
|
||||
|
||||
# ⚠️ READ THIS BEFORE READING THE CHECKS: THEY ASSERT THE UNFLATTERING ANSWER ON
|
||||
# PURPOSE. An earlier draft of §5 of the decision said a wedged unit's weight
|
||||
# does not move, so OPT-09 "cannot see it at all". The first clause is about the
|
||||
# ENVELOPE and is true; the second is FALSE. A wedged unit's weight moves
|
||||
# whenever its energy headroom differs from its sibling's - it is a pack fact,
|
||||
# not a delivery fact - and `delivery.delinquent()` names a unit only while its
|
||||
# `short_by()` gap exceeds `min_w` (`Tuning.saturation_w`, 500 W) IN ABSOLUTE
|
||||
# WATTS. Shrinking a unit's share shrinks its gap. Shrink it below 500 W and the
|
||||
# alarm stops firing on a unit that is still delivering nothing.
|
||||
#
|
||||
# So the seam is not "OPT-09 cannot see it". It is: OPT-09 stops the ask AND
|
||||
# narrows SAFETY-42's detection window on the same unit. At equal weights the
|
||||
# window is `site target < 2 * min_w` = 1000 W; at OPT-09's weights it is
|
||||
# `site target < min_w * (w_A + w_B) / w_B`, which on this fixture is 6500 W.
|
||||
#
|
||||
# ⚠️ THIS IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE. Making
|
||||
# `delinquent()` relative to the ask rather than absolute is SAFETY-42's code and
|
||||
# a design change; it is filed as its own card. What this part does is GRADE THE
|
||||
# CURRENT BEHAVIOUR so that the day it changes, a named check moves.
|
||||
#
|
||||
# ⚠️ AND THE FIXTURE IS A SAFETY-42 TRUE POSITIVE BY THAT CARD'S OWN DEFINITION,
|
||||
# checked below before anything is concluded from it: B is WEDGED - it accepts
|
||||
# its share and produces nothing - at a HEALTHY 15 % against its own 10 % floor,
|
||||
# so `envelope()` does not close and `account()` explains nothing. This is
|
||||
# exactly the case §5 says the envelope cannot reach, which is why it is the
|
||||
# case where the weights' side effect matters.
|
||||
#
|
||||
# ⚠️ TWO COPIES OF A. Inverter B does not exist; the plant below is modelled in
|
||||
# this file the way part 8's is, and B's ratings are datasheet.
|
||||
|
||||
WEDGED_A_SOC, WEDGED_B_SOC = 90.0, 15.0 # both healthy, both above their floor
|
||||
MIN_W = Tuning().saturation_w # what `delinquent()` is handed
|
||||
|
||||
|
||||
def wedged_loop(load_w, weighted, cycles=200):
|
||||
"""The closed loop with unit B WEDGED at a healthy SoC. Real everything.
|
||||
|
||||
Real `compute()`, real `plan()`, real `packshare.weights()`, real
|
||||
`delivery.account()` and real `delivery.delinquent()`. The only model is the
|
||||
plant: A obeys within one cycle inside its rating, B accepts whatever it is
|
||||
told and delivers 0 W - which is the wedged box, and is what makes this a
|
||||
SAFETY-42 case rather than an OPT-09 one.
|
||||
|
||||
`weighted=False` is equal weights - what shipped. Envelopes are left OPEN in
|
||||
both arms deliberately: neither pack's SoC closes one here, so filing them
|
||||
would change nothing and would blur which half of the card is being graded.
|
||||
|
||||
Returns `(cycles on which delinquent() named somebody, last cycle)`.
|
||||
"""
|
||||
packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, WEDGED_B_SOC, B_KWH)]
|
||||
tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=A_W + B_W)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
power = [0.0, 0.0]
|
||||
grid = load_w
|
||||
named, last = 0, None
|
||||
for _c in range(cycles):
|
||||
d = compute(prev, grid, sum(power), tuning, sat, i_w)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
w = packshare.weights(packs, d.target_w) if weighted else None
|
||||
r = plan(d.target_w, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=w)
|
||||
assert r.ok, r.refused
|
||||
# THE MODELLED PLANT: A within one cycle inside its rating; B WEDGED.
|
||||
power = [max(-A_W, min(A_W, float(r.shares[A]))), 0.0]
|
||||
grid = load_w - sum(power)
|
||||
who = delinquent(
|
||||
[UnitDelivery(p.name, r.shares[p.name], m,
|
||||
account(live=True, parked=False, soc=p.soc,
|
||||
bms_floor=p.bms_floor))
|
||||
for p, m in zip(packs, power)], MIN_W)
|
||||
named += bool(who)
|
||||
last = (d.target_w, dict(r.shares), grid, who)
|
||||
return named, last
|
||||
|
||||
|
||||
_WEDGED_PACK = PE(Bee, WEDGED_B_SOC, B_KWH)
|
||||
check("PRECONDITION: the wedged unit is a SAFETY-42 TRUE POSITIVE and NOT an "
|
||||
"OPT-09 case - at 15 % against its own 10 % floor no envelope closes and "
|
||||
"`account()` explains nothing, so anything the weights do to the alarm "
|
||||
"here is done to a fault the operator is entitled to be told about",
|
||||
not packshare.envelope(_WEDGED_PACK).binding
|
||||
and account(live=True, parked=False, soc=WEDGED_B_SOC,
|
||||
bms_floor=FLOOR) is None)
|
||||
|
||||
_wedge = {}
|
||||
for _load in (1500.0, 6000.0):
|
||||
for _weighted in (False, True):
|
||||
_n, _t = wedged_loop(_load, _weighted)
|
||||
_wedge[(_load, _weighted)] = (_n, _t)
|
||||
print(f" load {_load:.0f} W, B wedged at 15 %, "
|
||||
f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} "
|
||||
f"named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W, shares "
|
||||
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items()))
|
||||
+ f", grid {_t[2]:+.0f} W, named="
|
||||
+ (str(sorted(_t[3])) if _t[3] else "(nobody)"))
|
||||
|
||||
check("AT EQUAL WEIGHTS - WHAT SHIPPED - the wedged unit is named on every "
|
||||
"cycle but the first, at a load its sibling can carry on its own. The "
|
||||
"loop winds the site target to twice the load, the 50/50 split stands "
|
||||
"+1500 W on a box producing nothing, and 1500 W > 500 W is what "
|
||||
"`delinquent()` reads",
|
||||
_wedge[(1500.0, False)][0] == 199
|
||||
and sorted(_wedge[(1500.0, False)][1][3]) == [Bee]
|
||||
and _wedge[(1500.0, False)][1][1] == {A: 1500, Bee: 1500})
|
||||
|
||||
check("⚠️ AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200. Same wedged "
|
||||
"box, same healthy SoC, same load, same detector: the split moves to "
|
||||
"1505/125, the gap on the wedged unit falls to 125 W, and 125 W < 500 W "
|
||||
"so `delinquent()` returns {}. THIS CHECK PINS A REGRESSION - it asserts "
|
||||
"what the code DOES, not what anyone wants, and if it ever goes red "
|
||||
"because the alarm fired, read the card that made it fire before "
|
||||
"'fixing' this line",
|
||||
_wedge[(1500.0, True)][0] == 0
|
||||
and _wedge[(1500.0, True)][1][3] == {}
|
||||
and _wedge[(1500.0, True)][1][1] == {A: 1505, Bee: 125})
|
||||
|
||||
check("...and this is the regime where NOTHING ELSE TELLS THE OPERATOR "
|
||||
"ANYTHING: the meter is satisfied (grid within 5 W of zero), `unmet_w` is "
|
||||
"0 because both units accepted their shares, no envelope closed and no "
|
||||
"action-log line fires. The site is quietly running one inverter and the "
|
||||
"one instrument built to say so has gone quiet with it",
|
||||
abs(_wedge[(1500.0, True)][1][2]) <= 5.0
|
||||
and abs(_wedge[(1500.0, False)][1][2]) <= 5.0)
|
||||
|
||||
_w = packshare.weights([PE(A, WEDGED_A_SOC, A_KWH), _WEDGED_PACK], 1000.0)
|
||||
_window_equal = MIN_W * 2.0
|
||||
_window_opt09 = MIN_W * sum(_w.values()) / _w[Bee]
|
||||
print(f" suppression window on this fixture: equal weights, site target "
|
||||
f"< {_window_equal:.0f} W; OPT-09 weights, site target "
|
||||
f"< {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site")
|
||||
check("...and the arithmetic behind it, stated as a window rather than as one "
|
||||
"fixture: `delinquent()` reads an ABSOLUTE gap, so a unit weighted to a "
|
||||
"fraction f of the site target is invisible while `f * target < min_w`. "
|
||||
"Equal weights suppress below 1000 W; these weights suppress below "
|
||||
"6500 W of a 9600 W site. That is the regression, and it is a property "
|
||||
"of the threshold being absolute rather than of any number chosen here",
|
||||
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0)
|
||||
|
||||
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("9. PER-PACK ARBITRAGE SIZING")
|
||||
@@ -814,8 +969,12 @@ print(" policy; `arbitrage_soc_ceiling` is a trade preference and using")
|
||||
print(" it here would be SAFETY-25's deferred option in the charge")
|
||||
print(" direction. Everything between is left to the weights, which are")
|
||||
print(" a preference and are honest about being one.")
|
||||
print(" * A WEDGED UNIT IS NOT REACHABLE FROM HERE AT ALL. Its SoC reads")
|
||||
print(" healthy, so no envelope closes. SAFETY-42 owns it.")
|
||||
print(" * A WEDGED UNIT IS NOT REACHABLE BY THE ENVELOPE. Its SoC reads")
|
||||
print(" healthy, so nothing closes, and SAFETY-42 owns the detection.")
|
||||
print(" ⚠️ BUT IT IS REACHABLE BY THE WEIGHTS, WHICH MOVE ON ENERGY")
|
||||
print(" HEADROOM AND NOT ON DELIVERY - part 8b grades what that does")
|
||||
print(" to SAFETY-42's window, and the answer is a REGRESSION that is")
|
||||
print(" pinned rather than fixed.")
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
@@ -856,6 +1015,15 @@ MUT = [
|
||||
"test_packshare 1's PRECONDITION. ⚠️ IT WAS A TRACEBACK BEFORE THAT LINE "
|
||||
"EXISTED - the first draft of this file had no precondition and the "
|
||||
"mutation took it down with a TypeError three lines into part 1"),
|
||||
("M11 weights(): keep the shape, force every weight to 1.0 <- THE PROOF "
|
||||
"THAT PART 8b CAN GO RED",
|
||||
"test_packshare 8b, three named checks: the wedged unit goes back to being "
|
||||
"named on 199/200 at 1500 W, the split reverts to 1500/1500 and the "
|
||||
"window collapses from 6500 W to 1000 W. ⚠️ RUN, NOT REASONED ABOUT - a "
|
||||
"check that pins an UNFLATTERING behaviour has to be shown capable of "
|
||||
"failing, or it is indistinguishable from a check that pins nothing. "
|
||||
"(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)"),
|
||||
]
|
||||
for _n, _w in MUT:
|
||||
print(f" caught {_n}")
|
||||
@@ -866,7 +1034,7 @@ check("every mutation in the table above was RUN and every one of them is "
|
||||
"invisible to every behavioural fixture in this repository, because they "
|
||||
"all build their own claim list and call `resolve()` themselves. That is "
|
||||
"why the call site is pinned statically as well",
|
||||
len(MUT) == 10)
|
||||
len(MUT) == 11)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
Reference in New Issue
Block a user