checks / unit (pull_request) Successful in 8m44s
Checks and prints that indexed an empty list, exhausted next(), or formatted None killed the rest of their file (FCST-06). Read them through the new named_absence.py (nth/first/field/given/present) and conjoin a presence term. - every site in the brief, plus the family the new scan and a sweep found - test_named_absence.py: scans every test_*.py for unguarded filtered-list indexing, next() without a default and numeric formats on maybe-None names; proven to bite; prints what it cannot see, counted - test_price.py 16a: the `_T17_CAPS is _T17_CAPS` tautology now grades the two assemble() calls' arguments - test_control.py: coverage table re-measured on runs graded to the end Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1316 lines
74 KiB
Python
1316 lines
74 KiB
Python
"""Runnable check for OPT-09. `python3 test_packshare.py`
|
|
|
|
THE CARD IN ONE SENTENCE: the architecture's one free parameter - the allocator's
|
|
`weights` - was never passed by anything, so the site target was split 50/50
|
|
regardless of what either pack could do, and a pack that could not deliver its
|
|
share parked the site with a standing grid error and no alarm.
|
|
|
|
⚠️ INVERTER B DOES NOT EXIST (expected ~2026-09-12), AND EVERY TWO-UNIT NUMBER
|
|
IN THIS FILE IS THEREFORE ARITHMETIC AND NOT HARDWARE. B's figures - 4600 W,
|
|
20.48 kWh, 10 % floor - are FROM THE DATASHEET AND THE SERIAL (NOTES.md 1.1b);
|
|
A's 5000 W / 15.36 kWh / 10 % are measured. `sim/plantsim.py`'s INVERTER_B is
|
|
inverter A copied with `measured=False` on it, so anything driven through a
|
|
modelled plant here - part 8 - is TWO COPIES OF A given different numbers. What
|
|
this file grades is the SPLIT, which is arithmetic over intervals and weights
|
|
and needs no plant at all; where a plant appears it is modelled IN THIS FILE
|
|
(`lag_s=0`, obeys within one cycle, bounded by its rating) exactly as
|
|
test_alloc.py part 8's is, and the claim is about the control law rather than
|
|
about a GW5000S-BP.
|
|
|
|
⚠️ THE FIXTURE IS THE HARD PART, AND PART 1 IS WHERE IT IS EARNED. Two packs
|
|
that differ in ONLY ONE of SoC and capacity cannot tell a SoC-aware weighting
|
|
from a capacity-aware one, and two identical packs cannot tell ANY weighting
|
|
from any other - every scheme agrees on equal packs, so a weighting bug is
|
|
invisible and the checks stay green. That is this repository's recurring defect
|
|
(MUTATION.md: "a check whose data cannot reach the failure it names") and part 1
|
|
grades the fixture itself against it before anything else is asserted.
|
|
|
|
⚠️ MUTATING THE MODULES THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
|
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you.
|
|
"""
|
|
|
|
import ast
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from math import inf
|
|
from pathlib import Path
|
|
|
|
from app import arbitrage, packshare
|
|
from named_absence import first, nth, present
|
|
from app.allocator import plan
|
|
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 (
|
|
DELIVERY_SHARE_FRACTION, UnitDelivery, account, delinquent,
|
|
)
|
|
from app.peakguard import discharge_permitted
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
# --- the two packs, and every number's provenance -----------------------------
|
|
A, Bee = "inverter-a", "inverter-b"
|
|
A_W, B_W = 5000.0, 4600.0 # A measured; B FROM DATASHEET
|
|
A_KWH, B_KWH = 15.36, 20.48 # A measured; B FROM DATASHEET
|
|
FLOOR = 10.0 # both, register 0x0560 / datasheet
|
|
A_SOC, B_SOC = 20.0, 80.0 # the fixture's own choice - see part 1
|
|
|
|
|
|
def PE(name, soc, kwh, floor=FLOOR, max_w=0.0):
|
|
return packshare.PackEnergy(name, soc, floor, kwh, max_w)
|
|
|
|
|
|
PACKS = [PE(A, A_SOC, A_KWH), PE(Bee, B_SOC, B_KWH)]
|
|
|
|
|
|
def envelopes(packs):
|
|
"""`{unit: (lo, hi)}` for a roster, the way main.py files them."""
|
|
return {p.name: packshare.envelope(p) for p in packs}
|
|
|
|
|
|
def site_claims(packs, target_w, ratings=(A_W, B_W), envelope=True):
|
|
"""The claim list main.py builds for one cycle, at a roster of two.
|
|
|
|
`envelope=False` is WHAT SHIPPED: the `pack` claim is the half of this card
|
|
that constrains, so a baseline that kept it would be comparing OPT-09
|
|
against itself.
|
|
"""
|
|
out = []
|
|
for p, rating in zip(packs, ratings):
|
|
out.append(Claim.limit("supervised", P_SAFETY, -rating, rating, "max_w",
|
|
actuator=p.name))
|
|
env = packshare.envelope(p)
|
|
if envelope and env.binding:
|
|
out.append(Claim.limit("pack", P_SAFETY, env.lo, env.hi, env.why,
|
|
actuator=p.name))
|
|
out.append(Claim.set("loop", P_LOOP, target_w, "grid",
|
|
actuator=ACTUATOR_SITE))
|
|
return out
|
|
|
|
|
|
def cycle(packs, target_w, weights="soc", ratings=(A_W, B_W), envelope=True):
|
|
"""One `resolve()`, the way main.py calls it. `weights` is the variable.
|
|
|
|
`weights="shipped"` reverts BOTH halves - no weighting and no envelope -
|
|
which is the baseline every "before this card" line below is measured
|
|
against.
|
|
"""
|
|
if weights == "soc":
|
|
w = lambda t: packshare.weights(packs, t) # noqa: E731 - OPT-09
|
|
elif weights in ("equal", "shipped"):
|
|
w = None
|
|
envelope = envelope and weights != "shipped"
|
|
else:
|
|
w = weights
|
|
return resolve(site_claims(packs, target_w, ratings, envelope),
|
|
units=tuple(p.name for p in packs), weights=w, tick=1)
|
|
|
|
|
|
# ==============================================================================
|
|
print("1. THE FIXTURE, GRADED BEFORE ANYTHING IS ASSERTED WITH IT")
|
|
|
|
# Four weighting schemes anybody might write. On the RIGHT fixture they give
|
|
# four different splits; on a wrong one they collapse into each other and every
|
|
# check downstream is green whatever the code does.
|
|
def split(weights, target=1000.0, packs=None):
|
|
r = plan(target, {p.name: _B(-A_W, A_W) for p in (packs or PACKS)},
|
|
weights=weights)
|
|
assert r.ok, r.refused
|
|
return r.shares
|
|
|
|
|
|
class _B:
|
|
"""One unit's interval, the way ARB-03 stage 1 hands it over."""
|
|
def __init__(self, lo=-inf, hi=inf):
|
|
self.lo, self.hi = lo, hi
|
|
self.gate = self.pinned_w = self.pinned_by = None
|
|
self.contradiction = False
|
|
|
|
|
|
def schemes(packs, ratings=(A_W, B_W)):
|
|
"""The four candidate weightings, as `{name: weights dict}`."""
|
|
return {
|
|
"equal": {p.name: 1.0 for p in packs},
|
|
"power": {p.name: r for p, r in zip(packs, ratings)},
|
|
"soc only": {p.name: max(0.0, p.soc - p.bms_floor) for p in packs},
|
|
"energy": packshare.weights(packs, 1000.0),
|
|
}
|
|
|
|
|
|
# ⚠️ THE PRECONDITION, WRITTEN BEFORE THE FIXTURE IS USED FOR ANYTHING. A
|
|
# `weights()` that answered None on this roster would make every scheme below
|
|
# collapse to `equal` - or, as an earlier draft found, take this file down with a
|
|
# TypeError instead of a FAIL. A mutation that crashes is a weaker gate than one
|
|
# that fails a named check, so the named check is written first.
|
|
check("PRECONDITION: `packshare.weights()` HAS AN OPINION about this roster - "
|
|
"both packs state a capacity and a reading, so the answer is a mapping "
|
|
"over both units and not the None that means 'weigh everything 1.0'",
|
|
isinstance(packshare.weights(PACKS, 1000.0), dict)
|
|
and set(packshare.weights(PACKS, 1000.0)) == {A, Bee}
|
|
and set(packshare.weights(PACKS, -1000.0)) == {A, Bee})
|
|
|
|
_sch = schemes(PACKS)
|
|
_splits = {k: split(v) for k, v in _sch.items()}
|
|
for _k, _v in _splits.items():
|
|
print(f" {_k:<9} weights "
|
|
+ ", ".join(f"{u}={_sch[_k][u]:g}" for u in sorted(_sch[_k]))
|
|
+ " -> " + ", ".join(f"{u}{s:+d}" for u, s in sorted(_v.items())))
|
|
check("⚠️ THE FIXTURE SEPARATES ALL FOUR SCHEMES. Two packs at 20 %/15.36 kWh "
|
|
"and 80 %/20.48 kWh - DIFFERENT IN BOTH SoC AND CAPACITY - give four "
|
|
"distinct splits of the same 1000 W, so a weighting bug has somewhere to "
|
|
"show. This is AC 2, and it is the check the other parts rest on",
|
|
len({tuple(sorted(v.items())) for v in _splits.values()}) == 4)
|
|
|
|
_ident = [PE(A, 50.0, A_KWH), PE(Bee, 50.0, A_KWH)]
|
|
_ident_splits = {k: split(v, packs=_ident)
|
|
for k, v in schemes(_ident, (A_W, A_W)).items()}
|
|
print(" the SAME four schemes on two IDENTICAL packs (50 %, 15.36 kWh, "
|
|
"5000 W each): "
|
|
+ "; ".join(f"{k}: " + ", ".join(f"{u}{s:+d}" for u, s in sorted(v.items()))
|
|
for k, v in _ident_splits.items()))
|
|
check("...and the counterfactual that says why the fixture had to be built this "
|
|
"way: on two IDENTICAL packs every one of the four schemes gives the SAME "
|
|
"split, so a fixture of equal packs cannot distinguish the shipped "
|
|
"50/50 from any weighting whatever. That is the defect this repository "
|
|
"has found eleven times, and it is why the two packs here differ in both "
|
|
"dimensions rather than in one",
|
|
len({tuple(sorted(v.items())) for v in _ident_splits.values()}) == 1)
|
|
|
|
_same_soc = [PE(A, 50.0, A_KWH), PE(Bee, 50.0, B_KWH)]
|
|
_same_cap = [PE(A, A_SOC, A_KWH), PE(Bee, B_SOC, A_KWH)]
|
|
check("...and ONE dimension is not enough either, in EITHER direction, which "
|
|
"is the sharper half. Two packs at the SAME SoC cannot tell an "
|
|
"energy-aware weighting from a SoC-only one being wrong about capacity - "
|
|
"`soc only` collapses onto `equal` while `energy` follows the capacity "
|
|
"alone. Two packs of the SAME CAPACITY cannot tell `energy` from "
|
|
"`soc only` AT ALL: with capacity constant the energy headroom IS the SoC "
|
|
"headroom scaled, so the two give the identical split and a capacity bug "
|
|
"is invisible. Only differing in BOTH separates them",
|
|
split(schemes(_same_soc)["soc only"], packs=_same_soc)
|
|
== split(schemes(_same_soc)["equal"], packs=_same_soc)
|
|
!= split(schemes(_same_soc)["energy"], packs=_same_soc)
|
|
and split(schemes(_same_cap)["energy"], packs=_same_cap)
|
|
== split(schemes(_same_cap)["soc only"], packs=_same_cap)
|
|
and split(schemes(PACKS)["energy"]) != split(schemes(PACKS)["soc only"]))
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("2. THE ENVELOPE: what a pack's own state of charge FORBIDS")
|
|
|
|
_empty = PE(A, FLOOR, A_KWH) # exactly ON its own cut-out
|
|
_below = PE(A, 5.0, A_KWH)
|
|
_full = PE(Bee, 100.0, B_KWH)
|
|
_mid = PE(Bee, 55.0, B_KWH)
|
|
for _p in (_empty, _below, _full, _mid):
|
|
_e = packshare.envelope(_p)
|
|
print(f" {_p.name} at {_p.soc:g} % -> "
|
|
f"[{_e.lo:g}, {_e.hi:g}] {_e.why or '(no opinion)'}")
|
|
check("a pack AT its own BMS cut-out is not asked to discharge: `hi` closes at "
|
|
"0 W. ⚠️ AT, not below - `discharge_permitted()`'s boundary is `<=`, "
|
|
"because a pack sitting exactly on the floor has already spent everything "
|
|
"there was, and permitting one more cycle there makes the floor mean 'one "
|
|
"cycle below the floor'",
|
|
packshare.envelope(_empty).hi == 0.0
|
|
and packshare.envelope(_below).hi == 0.0),
|
|
check("...and it may still be CHARGED - `lo` is untouched. A closed envelope is "
|
|
"a direction, not a park: parking the pack would be a claim about what it "
|
|
"may do in the direction that would fix it",
|
|
packshare.envelope(_empty).lo == -inf)
|
|
check("a pack at 100 % is not asked to charge: `lo` closes at 0 W, and it may "
|
|
"still discharge",
|
|
packshare.envelope(_full).lo == 0.0
|
|
and packshare.envelope(_full).hi == inf)
|
|
check("a pack with room in both directions gets NO OPINION - `(-inf, +inf)`, "
|
|
"`binding` False - and main.py files no claim for it. A limit filed on "
|
|
"every unit every tick would put an open interval in the arbiter's "
|
|
"considered list forever and bury the one cycle that means something",
|
|
packshare.envelope(_mid) == packshare.PackEnvelope()
|
|
and not packshare.envelope(_mid).binding
|
|
and packshare.envelope(_empty).binding)
|
|
check("⚠️ AND THE DISCHARGE HALF ASKS `peakguard.discharge_permitted()` RATHER "
|
|
"THAN WRITING `soc <= floor` A THIRD TIME. It is the predicate SAFETY-32 "
|
|
"made public and SAFETY-42's `account()` already reuses; a third "
|
|
"transcription is two future disagreements about what it means - and in "
|
|
"particular the BOUNDARY (`<=`) is that function's, so a change to it "
|
|
"moves both. Graded by agreement over the whole percent range, on two "
|
|
"different floors, at every readable input",
|
|
all((packshare.envelope(PE(A, s, A_KWH, f)).hi == 0.0)
|
|
== (discharge_permitted(s, f) is not None)
|
|
for f in (10.0, 25.0) for s in [x / 2.0 for x in range(0, 201)]))
|
|
check("⚠️ AND A MISSING READING OR A MISSING FLOOR WITHHOLDS THE CLOSURE, NOT "
|
|
"THE DISCHARGE - THE OPPOSITE DIRECTION FROM `discharge_permitted()`'s "
|
|
"OWN, AND DELIBERATELY SO. That predicate decides whether to SPEND a pack "
|
|
"for money, where a full battery is exactly the value that would disarm "
|
|
"the floor. This decides whether to take a unit's share AWAY and hand it "
|
|
"to a sibling, and an absent measurement is not evidence that a battery "
|
|
"is flat: closing on it would park a healthy unit's discharge on every "
|
|
"tick for as long as its sensor was missing. Both absences have a "
|
|
"stronger answer arriving first - `inputs_ok` stops the whole site on a "
|
|
"missing SoC, and rule 8 plus the schema refuse an entry with no "
|
|
"`bms_soc_floor` at startup",
|
|
not packshare.envelope(PE(A, None, A_KWH)).binding
|
|
and not packshare.envelope(PE(A, 5.0, A_KWH, None)).binding
|
|
and packshare.envelope(PE(A, 5.0, A_KWH, 10.0)).hi == 0.0)
|
|
check("⚠️ AND THE FLOOR IS THE PACK'S OWN `bms_soc_floor`, NOT "
|
|
"`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`. The "
|
|
"EMS floor bounds the PEAK GUARD ONLY (SAFETY-25, decided Option A on "
|
|
"2026-09-02): ordinary self-consumption discharges straight past it, and "
|
|
"closing an envelope at it would turn an operator preference into a hard "
|
|
"stop on the grid-following loop - the memo's DEFERRED Option B, built by "
|
|
"accident. A pack at 15 % with a 10 % cut-out is NOT closed here, though "
|
|
"a 20 % `peak_soc_floor` would have closed it",
|
|
not packshare.envelope(PE(A, 15.0, A_KWH, 10.0)).binding
|
|
and discharge_permitted(15.0, 20.0) is not None)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("3. THE WEIGHTS: energy headroom, and the direction it is measured in")
|
|
|
|
_dis = packshare.weights(PACKS, +1000.0)
|
|
_chg = packshare.weights(PACKS, -1000.0)
|
|
print(f" discharging: " + ", ".join(f"{u}={v:.3f} kWh" for u, v in sorted(_dis.items())))
|
|
print(f" charging: " + ", ".join(f"{u}={v:.3f} kWh" for u, v in sorted(_chg.items())))
|
|
check("discharging, the weight is the energy ABOVE the pack's own cut-out: "
|
|
"15.36 kWh x (20-10) % = 1.536 kWh for A, 20.48 kWh x (80-10) % = "
|
|
"14.336 kWh for B",
|
|
abs(_dis[A] - 1.536) < 1e-9 and abs(_dis[Bee] - 14.336) < 1e-9)
|
|
check("charging, it is the room BELOW full: 15.36 x 80 % = 12.288 kWh for A, "
|
|
"20.48 x 20 % = 4.096 kWh for B",
|
|
abs(_chg[A] - 12.288) < 1e-9 and abs(_chg[Bee] - 4.096) < 1e-9)
|
|
check("⚠️ SO THE TWO DIRECTIONS DISAGREE ABOUT WHICH PACK SHOULD CARRY MORE - "
|
|
"B by 9:1 on the way out, A by 3:1 on the way in - which is why the "
|
|
"weighting cannot be computed before the site target is known and why "
|
|
"`resolve()` takes a CALLABLE. A direction-free weighting is not a "
|
|
"compromise between these two, it is wrong in one of them on every tick",
|
|
(_dis[Bee] > _dis[A]) and (_chg[A] > _chg[Bee]))
|
|
check("⚠️ AND IT IS ENERGY, NOT POWER, WHICH IS THE WHOLE OF DELIVERABLE 2. The "
|
|
"packs are asymmetric in OPPOSITE directions - B has ~33 % more energy "
|
|
"and ~8 % less power - so a power-rating weighting hands the discharge to "
|
|
"A (5000 > 4600) and an energy weighting hands it to B. The question the "
|
|
"split answers is 'which pack can sustain its share for the hour'",
|
|
A_W > B_W and B_KWH > A_KWH
|
|
and split(_sch["power"])[A] > split(_sch["power"])[Bee]
|
|
and split(_dis)[Bee] > split(_dis)[A])
|
|
check("a roster where ANY pack cannot state its headroom gets NO OPINION for "
|
|
"the WHOLE roster - None, i.e. 1.0 each, which is what shipped. A pack "
|
|
"weighed in kWh against a sibling weighing a dimensionless 1.0 is not a "
|
|
"comparison, and half a weighting is worse than none",
|
|
packshare.weights([PE(A, A_SOC, None), PE(Bee, B_SOC, B_KWH)], 1000.0) is None
|
|
and packshare.weights([PE(A, None, A_KWH), PE(Bee, B_SOC, B_KWH)], 1000.0) is None
|
|
and packshare.weights([PE(A, A_SOC, A_KWH, None), PE(Bee, B_SOC, B_KWH)],
|
|
1000.0) is None)
|
|
check("...and so does a roster on which NOBODY has headroom in this direction. "
|
|
"All-zero weights would drop the whole roster into `_fill()`'s LAST-RESORT "
|
|
"pass, where the shares come out equal anyway and every plan reads 'last "
|
|
"resort: the preferred units were full' - a true sentence about the wrong "
|
|
"thing",
|
|
packshare.weights([PE(A, FLOOR, A_KWH), PE(Bee, FLOOR, B_KWH)],
|
|
+1000.0) is None
|
|
and packshare.weights([PE(A, 100.0, A_KWH), PE(Bee, 100.0, B_KWH)],
|
|
-1000.0) is None)
|
|
check("...and at a roster of one there is no opinion to have: `plan()` is a "
|
|
"pass-through at N<=1 and a weight there is arithmetic nobody reads",
|
|
packshare.weights([PE(A, A_SOC, A_KWH)], 1000.0) is None
|
|
and packshare.weights([], 1000.0) is None)
|
|
check("every weight is non-negative and finite, which `allocator.plan()` "
|
|
"REFUSES to accept otherwise - a pack below its own floor is at zero "
|
|
"usable energy, not at a negative preference",
|
|
all(v >= 0.0 for v in packshare.weights(
|
|
[PE(A, 2.0, A_KWH), PE(Bee, B_SOC, B_KWH)], 1000.0).values()))
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("4. AC 1, END TO END: a pack at its floor is not allocated discharge")
|
|
|
|
_at_floor = [PE(A, FLOOR, A_KWH), PE(Bee, B_SOC, B_KWH)]
|
|
_r_now = cycle(_at_floor, 1500.0, weights="shipped")
|
|
_r_opt = cycle(_at_floor, 1500.0)
|
|
print(f" shipped (no weights, no envelope): {_r_now.allocation.explain()}")
|
|
print(f" OPT-09: {_r_opt.allocation.explain()}")
|
|
check("⚠️ AC 1. A pack sitting on its own BMS cut-out is allocated NO "
|
|
"discharge, and its share moves to the sibling WITHIN THAT SIBLING'S "
|
|
"ENVELOPE. Before this card the same cycle handed it half the site "
|
|
"target - 750 W into a battery that answers no watts to any command",
|
|
_r_opt.shares[A] == 0 and _r_opt.shares[Bee] == 1500
|
|
and _r_opt.allocation.unmet_w == 0)
|
|
check("...and the shipped behaviour really was the other one, so the line "
|
|
"above is not vacuous: an equal split of 1500 W is 750/750, and 750 W is "
|
|
"what a pack at its cut-out was being told to produce",
|
|
_r_now.shares == {A: 750, Bee: 750})
|
|
_plan_a = first((p for p in _r_opt.allocation.plans if p.unit == A), "no plan for A")
|
|
print(f" {_plan_a.explain()}")
|
|
check("...and the RECORD says why, in the line an operator is already reading. "
|
|
"`UnitPlan.within_envelope` is asked rather than assumed, and SAFETY-28's "
|
|
"write gate refuses a record that contradicts itself",
|
|
present(_plan_a) and _plan_a.within_envelope and _plan_a.share_w == 0
|
|
and _plan_a.hi == 0.0 and _r_opt.allocated)
|
|
check("a pack at 100 % is not allocated a CHARGE either, and the sibling takes "
|
|
"the whole of it - the same sentence in the other direction",
|
|
cycle([PE(A, 100.0, A_KWH), PE(Bee, B_SOC, B_KWH)], -1200.0).shares
|
|
== {A: 0, Bee: -1200})
|
|
check("⚠️ AND THE SIGN-COHERENCE REFUSAL IS NOT TRIPPED BY ANY OF THIS. One "
|
|
"envelope closed at 0 and the other open cannot produce two shares of "
|
|
"opposite sign, because the fill moves every share in ONE direction from "
|
|
"a base that is still 0 for the closed unit. Circulating power stays "
|
|
"inexpressible",
|
|
all(cycle([PE(A, s, A_KWH), PE(Bee, 100.0 - s, B_KWH)], t).allocated
|
|
for s in (0.0, FLOOR, 50.0, 100.0) for t in (-2000.0, 0.0, 2000.0)))
|
|
check("a unit whose envelope is closed is still IN the split - it is not "
|
|
"dropped. `dropped` means the master is stale and the record says so; a "
|
|
"full pack is present, commandable, and told 0 W",
|
|
not cycle(_at_floor, 1500.0).allocation.dropped
|
|
and A in cycle(_at_floor, 1500.0).shares)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("5. AC 3: unmet_w when NEITHER unit can take the remainder")
|
|
|
|
_both = [PE(A, FLOOR, A_KWH), PE(Bee, 8.0, B_KWH)]
|
|
_r_both = cycle(_both, 1500.0)
|
|
print(f" both packs at or under their cut-out: {_r_both.allocation.explain()}")
|
|
check("⚠️ AC 3. With every pack at or under its own cut-out the discharge has "
|
|
"nowhere to go: both shares are 0 and the whole 1500 W is reported as "
|
|
"`unmet_w`. Not refused - `Allocation` is explicit that fusing `unmet_w` "
|
|
"into `ok` would let one saturated inverter park the site - and not "
|
|
"silently absorbed either",
|
|
_r_both.allocated and _r_both.shares == {A: 0, Bee: 0}
|
|
and _r_both.allocation.unmet_w == 1500)
|
|
check("...and the identity still holds over integers: shares + unmet == sum_w, "
|
|
"which is what makes `unmet_w` a measurement rather than an estimate",
|
|
sum(_r_both.shares.values()) + _r_both.allocation.unmet_w
|
|
== _r_both.allocation.sum_w)
|
|
check("...and it is VISIBLE: `explain()` prints it in the line the action log "
|
|
"carries, and main.py edge-logs the transition (`_unmet_last`). Before "
|
|
"this card nothing anywhere reported a watt that could not be placed",
|
|
"UNMET" in _r_both.allocation.explain())
|
|
_partial = [PE(A, FLOOR, A_KWH), PE(Bee, B_SOC, B_KWH)]
|
|
check("...and a partial shortfall is reported partially rather than all-or- "
|
|
"nothing: one pack closed and the sibling's own rating short of the ask "
|
|
"places what it can and names the rest",
|
|
cycle(_partial, 6000.0).allocation.unmet_w == 6000 - int(B_W))
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("6. AC 4: at N=1 the allocation is BYTE-IDENTICAL to what shipped")
|
|
|
|
# ⚠️ THE WHOLE OF AC 4 IS THAT `main.py` DOES NOT FILE THE `pack` CLAIM AT N=1,
|
|
# and the reason is `actuator_for()`: at one inverter every source routes to the
|
|
# SITE, so a unit interval filed there would bound THE SITE TARGET rather than a
|
|
# share. That is a behaviour change on the live single-inverter install and it is
|
|
# SAFETY-25's decided Option A. What this part grades is the arithmetic on both
|
|
# sides of that `if`.
|
|
_one = [PE(A, FLOOR, A_KWH)]
|
|
_n1_claims = [Claim.limit("supervised", P_SAFETY, -A_W, A_W, "max_w"),
|
|
Claim.set("loop", P_LOOP, 1234.5, "grid")]
|
|
_n1_now = resolve(_n1_claims, units=(A,), tick=1)
|
|
_n1_opt = resolve(_n1_claims, units=(A,),
|
|
weights=lambda t: packshare.weights(_one, t), tick=1)
|
|
print(f" N=1 shipped: {_n1_now.shares} OPT-09: {_n1_opt.shares}")
|
|
check("the pass-through is untouched: the share IS the site target, as a float, "
|
|
"unrounded and unclamped, with or without the weights argument",
|
|
_n1_now.shares == _n1_opt.shares == {A: 1234.5}
|
|
and _n1_opt.allocation.passthrough)
|
|
check("...and that holds across the whole range, sign for sign, because "
|
|
"`weights()` answers None at a roster of one and `plan()` never reaches "
|
|
"its weighted path there anyway",
|
|
all(resolve([Claim.limit("supervised", P_SAFETY, -A_W, A_W, "max_w"),
|
|
Claim.set("loop", P_LOOP, t, "grid")], units=(A,),
|
|
weights=lambda x: packshare.weights(_one, x),
|
|
tick=1).shares
|
|
== resolve([Claim.limit("supervised", P_SAFETY, -A_W, A_W, "max_w"),
|
|
Claim.set("loop", P_LOOP, t, "grid")], units=(A,),
|
|
tick=1).shares
|
|
for t in (-5000.0, -1999.5, -1.0, 0.0, 1.0, 999.25, 5000.0)))
|
|
check("⚠️ AND A CALLABLE THAT ANSWERS None IS THE SAME AS NO ARGUMENT AT ALL, "
|
|
"at every roster size. That is what makes the new parameter inert on any "
|
|
"install that has not stated its capacities",
|
|
resolve(site_claims(PACKS, 1000.0), units=(A, Bee),
|
|
weights=lambda t: None, tick=1).shares
|
|
== resolve(site_claims(PACKS, 1000.0), units=(A, Bee), tick=1).shares)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("7. AC 5: verified BY BREAKING IT - equal weights on unequal packs")
|
|
|
|
# ⚠️ THE CARD ASKS FOR THIS EXPLICITLY: "force equal weights on unequal packs and
|
|
# observe red". The point is that the checks above are not passing for a reason
|
|
# unrelated to the weighting - so here the SAME cycles are run with the weighting
|
|
# reverted to what shipped, and what they produce is printed beside what OPT-09
|
|
# produces.
|
|
_cases = [
|
|
("1500 W out, A on its cut-out", _at_floor, 1500.0),
|
|
("1200 W in, A full", [PE(A, 100.0, A_KWH), PE(Bee, B_SOC, B_KWH)], -1200.0),
|
|
("1000 W out, both with room", PACKS, 1000.0),
|
|
]
|
|
_broken = []
|
|
for _why, _pk, _t in _cases:
|
|
_o, _e = cycle(_pk, _t), cycle(_pk, _t, weights="shipped")
|
|
_broken.append((_why, _o.shares, _e.shares))
|
|
print(f" {_why:<32} OPT-09 "
|
|
+ ", ".join(f"{u}{s:+d}" for u, s in sorted(_o.shares.items()))
|
|
+ " equal-weights "
|
|
+ ", ".join(f"{u}{s:+d}" for u, s in sorted(_e.shares.items())))
|
|
check("every one of these cycles gives a DIFFERENT answer with BOTH halves "
|
|
"reverted to what shipped - equal weights, no envelope - so each check "
|
|
"above is separated from its own mutation rather than passing for a "
|
|
"reason unrelated to the weighting",
|
|
all(o != e for _w, o, e in _broken))
|
|
_env_only = cycle(_at_floor, 1500.0, weights="equal")
|
|
print(f" and with the ENVELOPE alone (equal weights, `pack` claim filed): "
|
|
+ ", ".join(f"{u}{s:+d}" for u, s in sorted(_env_only.shares.items())))
|
|
check("...and the two halves are separated from EACH OTHER as well: on a pack "
|
|
"sitting at its cut-out the envelope alone already gives the whole ask to "
|
|
"the sibling, because a closed `cap` is obeyed absolutely, so this case "
|
|
"grades the ENVELOPE. Case three - both packs with room, nothing closed - "
|
|
"is the one that can only be the WEIGHTS",
|
|
_env_only.shares == {A: 0, Bee: 1500}
|
|
and len(_broken) > 2 and _broken[2][1] != _broken[2][2])
|
|
check("...and the third case is the one that isolates the WEIGHTS from the "
|
|
"ENVELOPE: both packs have room, no interval is closed, nothing is "
|
|
"refused - and the split still moves from 500/500 to 97/903 because the "
|
|
"energy headroom says so",
|
|
nth(_broken, 2)[1] == {A: 97, Bee: 903} and nth(_broken, 2)[2] == {A: 500, Bee: 500})
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("8. ⚠️ WHAT THE WEIGHTS CANNOT DO, WHICH IS THE HALF THAT IS EASY TO "
|
|
"OVER-READ")
|
|
|
|
# ARCH-01: the gain seen at the meter is `sum(a_i * g_i)`, a CONVEX COMBINATION
|
|
# of the per-unit gains, so a bad split cannot DESTABILISE the site. That says
|
|
# nothing about whether the site can REGULATE. A unit at its energy limit has
|
|
# g_i = 0; the combination is still inside [min g, max g] and the load it was
|
|
# asked to carry simply does not move.
|
|
RATING = 2000.0 # per unit, so the SITE rail is 4000 W
|
|
|
|
|
|
def close_loop(load_w, mode, cycles=200, rating=RATING):
|
|
"""One integrator on the meter, two units, each told its OWN share.
|
|
|
|
`mode` is what varies. "shipped" is equal weights and no envelope; "weights"
|
|
is OPT-09's preference with the envelope left open; "envelope" is OPT-09
|
|
entire. Unit B is AT ITS FLOOR throughout and therefore answers 0 W to any
|
|
discharge command - the benign case `delivery.py` measures as
|
|
indistinguishable, IN THE POWER DOMAIN, from a wedged box.
|
|
|
|
⚠️ `max_w` IS THE SITE RAIL - THE SUM OF THE UNIT RATINGS - because that is
|
|
what main.py builds `Tuning` with (`max_w=self.site_max_w`). Handing the
|
|
loop one unit's rating would model an add-on that does not exist and would
|
|
hide the wind-up this part is about: the first draft of this check did that
|
|
and reported a 500 W standing error where the shipped law reaches zero.
|
|
"""
|
|
tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=2.0 * rating)
|
|
prev, i_w, sat = 0.0, None, 0
|
|
power = [0.0, 0.0]
|
|
grid = load_w
|
|
packs = [PE(A, 50.0, A_KWH), PE(Bee, FLOOR, B_KWH)]
|
|
trace = []
|
|
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
|
|
bounds = {p.name: _B(-rating, rating) for p in packs}
|
|
if mode == "envelope":
|
|
for p in packs:
|
|
e = packshare.envelope(p)
|
|
bounds[p.name] = _B(max(-rating, e.lo), min(rating, e.hi))
|
|
w = None if mode == "shipped" else packshare.weights(packs, d.target_w)
|
|
r = plan(d.target_w, bounds, weights=w)
|
|
assert r.ok, r.refused
|
|
# THE MODELLED PLANT. A obeys within one cycle inside its rating; B is at
|
|
# its floor and produces NOTHING in the discharge direction whatever it
|
|
# is told - which is `plantsim.Plant.limit()`'s own answer for a pack at
|
|
# its floor, and is why SAFETY-42 cannot read this case from power alone.
|
|
power = [max(-rating, min(rating, float(r.shares[A]))),
|
|
min(0.0, float(r.shares[Bee]))]
|
|
grid = load_w - sum(power)
|
|
trace.append((round(d.target_w, 1), dict(r.shares), round(grid, 1),
|
|
r.unmet_w, d.reason))
|
|
return trace
|
|
|
|
|
|
_MODES = ("shipped", "weights", "envelope")
|
|
_row = {}
|
|
for _load in (1500.0, 3000.0):
|
|
for _m in _MODES:
|
|
_t = close_loop(_load, _m)[-1]
|
|
_row[(_load, _m)] = _t
|
|
print(f" load {_load:.0f} W, B at its floor, {_m:<9} "
|
|
f"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, unmet {_t[3]:+d} W, {_t[4]}")
|
|
|
|
check("AT A LOAD THE HEALTHY PACK CAN COVER, ALL THREE ARMS REGULATE TO ZERO "
|
|
"GRID ERROR - and the shipped one is NOT thereby fine. It gets there by "
|
|
"winding the SITE TARGET to 3000 W for a 1500 W load and standing a "
|
|
"+1500 W command on a battery sitting on its own cut-out, permanently "
|
|
"saturation-frozen; OPT-09 reaches the same meter reading with a 1500 W "
|
|
"target, nothing standing on the empty pack, and the loop in its "
|
|
"deadband. The improvement here is not watts, it is that the site has "
|
|
"stopped lying to itself - a loop wound to twice the load has no headroom "
|
|
"left for the next one",
|
|
abs(_row[(1500.0, "shipped")][2]) < 1.0
|
|
and abs(_row[(1500.0, "weights")][2]) < 1.0
|
|
and abs(_row[(1500.0, "envelope")][2]) < 1.0
|
|
and _row[(1500.0, "shipped")][0] == 3000.0
|
|
and _row[(1500.0, "shipped")][1][Bee] == 1500
|
|
and _row[(1500.0, "shipped")][4] == "saturated-freeze"
|
|
and _row[(1500.0, "envelope")][0] == 1500.0
|
|
and _row[(1500.0, "envelope")][1][Bee] == 0)
|
|
check("⚠️ AND AT A LOAD IT CANNOT COVER, THE WEIGHTS STOP WORKING ALTOGETHER. "
|
|
"The weighted arm and the shipped arm command the SAME +2000 W into the "
|
|
"empty pack and deliver the SAME 2000 W, because once the preferred unit "
|
|
"is at its cap `_fill()`'s LAST-RESORT PASS hands the remainder to the "
|
|
"zero-weighted one - deliberately, so that a preference can never "
|
|
"manufacture a standing error at the meter. A weight of 0 is 'please do "
|
|
"not', never 'you may not'",
|
|
_row[(3000.0, "weights")][1] == _row[(3000.0, "shipped")][1]
|
|
== {A: 2000, Bee: 2000}
|
|
and _row[(3000.0, "weights")][2] == _row[(3000.0, "shipped")][2])
|
|
check("⚠️ AND THE ENVELOPE DOES NOT RESCUE THE DELIVERY EITHER - IT NAMES THE "
|
|
"SHORTFALL. Same 2000 W delivered, same 1000 W standing at the meter, "
|
|
"because the sibling is already at its rating and there is nowhere for "
|
|
"the rest to go; what changes is that the empty pack is commanded 0 W "
|
|
"instead of 2000 W and 2000 W of site target is REPORTED as unplaceable "
|
|
"instead of appearing to have been placed. THIS IS THE WHOLE OF "
|
|
"DELIVERABLE 3: ARCH-01's convex combination guarantees a bad split "
|
|
"cannot DESTABILISE the site and says nothing about whether the site can "
|
|
"REGULATE. A unit at its energy limit has g_i = 0, `compute()` freezes on "
|
|
"the resulting error, and the load DOES NOT SHIFT to the sibling by any "
|
|
"mechanism in this add-on",
|
|
_row[(3000.0, "envelope")][2] == _row[(3000.0, "weights")][2]
|
|
and _row[(3000.0, "envelope")][1] == {A: 2000, Bee: 0}
|
|
and _row[(3000.0, "envelope")][3] == 2000
|
|
and _row[(3000.0, "weights")][3] == 0)
|
|
check("...so: WEIGHTS ARE A COMFORT CHOICE FOR STABILITY AND NOT FOR DELIVERY, "
|
|
"and the same sentence stated as arithmetic on one plan rather than as a "
|
|
"200-cycle run",
|
|
plan(3000.0, {A: _B(-RATING, RATING), Bee: _B(-RATING, RATING)},
|
|
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; 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 cost the alarm, and what SAFETY-43 did about it",
|
|
not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("8b. ⚠️ WHAT THE WEIGHTS DID TO SAFETY-42's DETECTION WINDOW, AND WHAT "
|
|
"SAFETY-43 DID ABOUT IT - GRADED UNDER BOTH RULES, ON ONE FIXTURE")
|
|
|
|
# ⚠️ THIS PART USED TO PIN THE UNFLATTERING ANSWER ON PURPOSE, AND SAFETY-43 IS
|
|
# THE CARD THAT INVERTED IT. What it said - and it was true when it was written
|
|
# - was: a wedged unit's weight MOVES whenever its energy headroom differs from
|
|
# its sibling's (it is a pack fact, not a delivery fact), `delivery.delinquent()`
|
|
# named a unit only while its `short_by()` gap exceeded `min_w`
|
|
# (`Tuning.saturation_w`, 500 W) IN ABSOLUTE WATTS, and shrinking a unit's share
|
|
# shrinks its gap. Shrink it below 500 W and the alarm stopped firing on a unit
|
|
# that was still delivering nothing. The old text ended "if it ever goes red
|
|
# because the alarm fired, read the card that made it fire before 'fixing' this
|
|
# line". SAFETY-43 is that card, this is that red, and the checks below are the
|
|
# same three measurements with their answers the other way round.
|
|
#
|
|
# ⚠️ AND THE EVIDENCE IS THE DIFF BETWEEN TWO RULES ON ONE RUN, NOT A NUMBER
|
|
# THAT CHANGED IN A COMMIT. `delinquent()` takes `share_frac`, and
|
|
# `share_frac=None` is SAFETY-42's rule EXACTLY - the absolute arm alone. So
|
|
# `wedged_loop` grades every cycle twice, once under each rule, and the red and
|
|
# the green sit side by side in one output:
|
|
#
|
|
# 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
|
|
#
|
|
# A future reader who wants to know what this card did runs this file.
|
|
#
|
|
# ⚠️ WHAT SAFETY-43 DID NOT DO IS LOWER `min_w`, and the check on the window
|
|
# arithmetic below is what holds that open: the ABSOLUTE arm's suppression
|
|
# window is still 500 W wide and still widens to 6500 W under these weights.
|
|
# Nothing that was quiet became noisy; a SECOND way to be named was added, and
|
|
# it is a fraction of the unit's OWN ask, so it fires on a unit delivering none
|
|
# of 125 W exactly as it fires on one delivering none of 1500 W.
|
|
#
|
|
# ⚠️ AND THE FIXTURE IS A SAFETY-42 TRUE POSITIVE BY THAT CARD'S OWN DEFINITION,
|
|
# checked below before anything is concluded from it: B is WEDGED - it accepts
|
|
# its share and produces nothing - at a HEALTHY 15 % against its own 10 % floor,
|
|
# so `envelope()` does not close and `account()` explains nothing. This is
|
|
# exactly the case §5 says the envelope cannot reach, which is why it is the
|
|
# case where the weights' side effect mattered.
|
|
#
|
|
# ⚠️ TWO COPIES OF A. Inverter B does not exist; the plant below is modelled in
|
|
# this file the way part 8's is, and B's ratings are datasheet.
|
|
|
|
WEDGED_A_SOC, WEDGED_B_SOC = 90.0, 15.0 # both healthy, both above their floor
|
|
MIN_W = Tuning().saturation_w # what `delinquent()` is handed
|
|
ABSOLUTE_ONLY = None # `share_frac=None` - SAFETY-42's rule
|
|
|
|
|
|
def wedged_loop(load_w, weighted, cycles=200, 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.
|
|
|
|
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 delivers `b_delivers`
|
|
of whatever it is told - 0.0 is the wedged box, and is what makes this a
|
|
SAFETY-42 case rather than an OPT-09 one.
|
|
|
|
`weighted=False` is equal weights - what shipped. Envelopes are left OPEN in
|
|
both arms deliberately: neither pack's SoC closes one here, so filing them
|
|
would change nothing and would blur which half of the card is being graded.
|
|
|
|
`share_frac` is SAFETY-43's relative arm. `ABSOLUTE_ONLY` grades the same
|
|
cycles under SAFETY-42's rule, which is how the red and the green below come
|
|
out of ONE run over ONE tree instead of out of two commits.
|
|
|
|
`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)`.
|
|
"""
|
|
packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, b_soc, B_KWH)]
|
|
tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=A_W + B_W)
|
|
prev, i_w, sat = 0.0, None, 0
|
|
power = [0.0, 0.0]
|
|
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]))),
|
|
b_delivers * max(-B_W, min(B_W, float(r.shares[Bee])))]
|
|
grid = load_w - sum(power)
|
|
who = delinquent(
|
|
[UnitDelivery(p.name, r.shares[p.name], m,
|
|
None if no_account else
|
|
account(live=True, parked=False, soc=p.soc,
|
|
bms_floor=p.bms_floor))
|
|
for p, m in zip(packs, power)], MIN_W, share_frac)
|
|
named += bool(who)
|
|
last = (d.target_w, dict(r.shares), grid, who)
|
|
return named, last
|
|
|
|
|
|
_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)
|
|
check("PRECONDITION: the shipped fraction is a fraction, and it is STRICTLY "
|
|
"below 1.0 - at 1.0 the relative arm could only fire on a unit IMPORTING "
|
|
"while asked to discharge, which is SAFETY-30's fault and not this one, "
|
|
"and this card's own case (a unit delivering NONE of its share, gap over "
|
|
"share exactly 1.0) would be missed",
|
|
0.0 < DELIVERY_SHARE_FRACTION < 1.0)
|
|
|
|
_wedge = {}
|
|
for _load in (1500.0, 6000.0):
|
|
for _weighted in (False, True):
|
|
for _frac in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION):
|
|
_n, _t = wedged_loop(_load, _weighted, share_frac=_frac)
|
|
_wedge[(_load, _weighted, _frac)] = (_n, _t)
|
|
print(f" load {_load:.0f} W, B wedged at 15 %, "
|
|
f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} "
|
|
+ ("absolute arm only" if _frac is ABSOLUTE_ONLY
|
|
else f"frac {_frac:.2f} ")
|
|
+ f", named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W,"
|
|
+ " shares "
|
|
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items()))
|
|
+ f", grid {_t[2]:+.0f} W, named="
|
|
+ (str(sorted(_t[3])) if _t[3] else "(nobody)"))
|
|
|
|
check("⚠️ THE CARD, RED: UNDER SAFETY-42's RULE AND OPT-09's WEIGHTS THE WEDGED "
|
|
"UNIT IS NAMED ON NONE OF THE 200. Same wedged box, same healthy SoC, "
|
|
"same load, same detector: the split moves to 1505/125, the gap on the "
|
|
"wedged unit falls to 125 W, and 125 W < 500 W so the absolute arm "
|
|
"returns {}. This is the defect SAFETY-43 was filed for, and it is "
|
|
"measured here rather than remembered",
|
|
_wedge[(1500.0, True, ABSOLUTE_ONLY)][0] == 0
|
|
and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][3] == {}
|
|
and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][1] == {A: 1505, Bee: 125})
|
|
|
|
check("⚠️ THE CARD, GREEN: THE SHIPPED RULE NAMES IT ON ALL 200, ON THE SAME "
|
|
"RUN. The unit is standing at +125 W and delivering 0 W - 100 % of its "
|
|
"ask is not arriving - and `gap > frac * share` does not care that 125 W "
|
|
"is a small number. The ONLY input that moved between this check and the "
|
|
"one above is `share_frac`",
|
|
_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200
|
|
and sorted(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee]
|
|
and _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][1] \
|
|
== {A: 1505, Bee: 125})
|
|
|
|
check("AT EQUAL WEIGHTS - WHAT SHIPPED BEFORE OPT-09 - the wedged unit was "
|
|
"already named under the absolute rule on every cycle but the first, at "
|
|
"a load its sibling can carry on its own. The loop winds the site target "
|
|
"to twice the load, the 50/50 split stands +1500 W on a box producing "
|
|
"nothing, and 1500 W > 500 W is what the absolute arm reads",
|
|
_wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 199
|
|
and sorted(_wedge[(1500.0, False, ABSOLUTE_ONLY)][1][3]) == [Bee]
|
|
and _wedge[(1500.0, False, ABSOLUTE_ONLY)][1][1] == {A: 1500, Bee: 1500})
|
|
|
|
check("...and the shipped rule adds exactly the FIRST cycle to that arm and "
|
|
"nothing else - 199 becomes 200. On cycle one the slew limit holds the "
|
|
"split at 500/500, a 500 W gap is NOT over a 500 W threshold (the "
|
|
"absolute bound is strict), and the relative arm names a box delivering "
|
|
"none of its 500 W. That one cycle is the whole of what this card costs "
|
|
"an arm that was already firing",
|
|
_wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0] == 200
|
|
and _wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0]
|
|
- _wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 1)
|
|
|
|
check("...and this is the regime where NOTHING ELSE TELLS THE OPERATOR "
|
|
"ANYTHING: the meter is satisfied (grid within 5 W of zero), `unmet_w` is "
|
|
"0 because both units accepted their shares, no envelope closed and no "
|
|
"action-log line fires. The site is quietly running one inverter, and "
|
|
"before this card the one instrument built to say so had gone quiet with "
|
|
"it",
|
|
abs(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0
|
|
and abs(_wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0)
|
|
|
|
_w = packshare.weights([PE(A, WEDGED_A_SOC, A_KWH), _WEDGED_PACK], 1000.0)
|
|
_window_equal = MIN_W * 2.0
|
|
_window_opt09 = MIN_W * sum(_w.values()) / _w[Bee]
|
|
print(f" the ABSOLUTE arm's suppression window is UNCHANGED by SAFETY-43: "
|
|
f"equal weights, site target < {_window_equal:.0f} W; OPT-09 weights, "
|
|
f"site target < {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site")
|
|
check("⚠️ AND `min_w` WAS NOT LOWERED, WHICH IS THE HALF OF THIS CARD THAT IS "
|
|
"EASIEST TO GET WRONG. The absolute arm still suppresses below 1000 W at "
|
|
"equal weights and below 6500 W at these ones - the same arithmetic this "
|
|
"part has always printed - because SAFETY-43 ADDED a second way to be "
|
|
"named rather than loosening the first. Lowering `saturation_w` would "
|
|
"have reported normal saturation on every unit at once, which is the "
|
|
"false-trip regime SAFETY-41 spent four attempts in",
|
|
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0
|
|
and MIN_W == Tuning().saturation_w == 500.0)
|
|
|
|
check("⚠️ AND THE ABSOLUTE ARM WAS NEVER SILENT EVERYWHERE - which is what a "
|
|
"check that only ever ran the quiet load would be pinning. Push the load "
|
|
"past the healthy pack's OWN RATING and `_fill()`'s last-resort pass "
|
|
"hands the wedged unit 3000 W anyway, the gap goes back over 500 W, and "
|
|
"both arms named it under SAFETY-42's rule too. The window was real and "
|
|
"it had an upper edge; the hole was the quiet regime below that edge, "
|
|
"and the shipped rule now names the unit on every cycle of all four runs",
|
|
_wedge[(6000.0, True, ABSOLUTE_ONLY)][0] == 195
|
|
and _wedge[(6000.0, False, ABSOLUTE_ONLY)][0] == 199
|
|
and _wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][0] == 200
|
|
and _wedge[(6000.0, False, DELIVERY_SHARE_FRACTION)][0] == 200
|
|
and sorted(_wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee])
|
|
|
|
print()
|
|
print(" THE POSITIVE CONTROL: what the relative arm must STILL NOT say")
|
|
|
|
# ⚠️ A CARD THAT ADDS A WAY TO RAISE AN ALARM HAS TO SHOW WHAT IT DID NOT ADD,
|
|
# or it has traded a missed alarm for a false one - which in this system is not
|
|
# a rhetorical risk. SAFETY-41 took four attempts on notification cadence and
|
|
# the owner's words were "i'm getting really annoyed by the notifications in my
|
|
# HA". So: the same fixture, the same weights, the same tiny 125 W share, with
|
|
# the ONE fact that makes the shortfall legitimate changed.
|
|
|
|
# ⚠️ 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))
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("9. PER-PACK ARBITRAGE SIZING")
|
|
|
|
# ⚠️ THE FUNCTION UNDER TEST IS THE SHIPPED ONE, NOT A TRANSCRIPTION OF IT.
|
|
# `Controller.trade_power_w()` is one line - the roster view handed to
|
|
# `packshare.trade_power_w()` - and this calls that same function. An earlier
|
|
# draft of this part re-implemented the two sums here, which is the defect this
|
|
# repository keeps finding: the copy that drifts is always the grading one.
|
|
trade_power_w = packshare.trade_power_w
|
|
|
|
_near_full = [PE(A, 50.0, A_KWH, max_w=A_W), PE(Bee, 95.0, B_KWH, max_w=B_W)]
|
|
_c, _d = trade_power_w(_near_full, 20.0, 90.0)
|
|
print(f" A 50 %, B 95 %, arbitrage bounds 20/90 %: charge_w {_c:.0f} W, "
|
|
f"discharge_w {_d:.0f} W (site rating {A_W + B_W:.0f} W)")
|
|
check("⚠️ THE BUY SIDE IS THE ONE THAT BITES. With B over the arbitrage ceiling "
|
|
"the trade is sized at A's rating alone, where before this card it was "
|
|
"the sum of the ratings - the site would buy at 9600 W with one pack able "
|
|
"to absorb it, and the watts that could not be placed would come back as "
|
|
"`unmet_w` for the site integrator to chase for the rest of the hour",
|
|
_c == A_W and _c < A_W + B_W)
|
|
check("...and the sell side is unchanged here, because B is well above the "
|
|
"floor. Sizing is a REDUCTION of the ask and never an increase",
|
|
_d == A_W + B_W)
|
|
check("⚠️ AND THE SELL SIDE IS A NO-OP ON EVERY ROSTER TODAY, WHICH IS SAID "
|
|
"RATHER THAN LEFT FOR A REVIEWER TO FIND. `arbitrage.decide()` is handed "
|
|
"`pack_floor_soc` - the EMPTIEST pack - so a sell is refused outright "
|
|
"whenever ANY pack is at or under the floor, i.e. in exactly the states "
|
|
"where this sum could be smaller than the site rating. The arithmetic is "
|
|
"graded here; it becomes reachable the day that gate becomes per-pack, "
|
|
"and moving it is NOT this card's - it would let a sell be sized off the "
|
|
"fullest pack while the weights, which are only a preference, decided "
|
|
"where the watts went",
|
|
trade_power_w([PE(A, 15.0, A_KWH, max_w=A_W),
|
|
PE(Bee, B_SOC, B_KWH, max_w=B_W)], 20.0, 90.0)[1] == B_W
|
|
and min(15.0, B_SOC) <= 20.0)
|
|
check("a pack whose SoC did not read counts on NEITHER side - withholding is "
|
|
"the direction that cannot over-size, and `inputs_ok` has already stopped "
|
|
"the site by then",
|
|
trade_power_w([PE(A, None, A_KWH, max_w=A_W),
|
|
PE(Bee, B_SOC, B_KWH, max_w=B_W)], 20.0, 90.0)
|
|
== (B_W, B_W))
|
|
|
|
|
|
# ⚠️ AND THE SIZING MUST NEVER REACH `claim()` AS A ZERO, which is a hazard the
|
|
# sizing itself created: `arbitrage.claim()`'s own note says a `Claim.set(..., 0)`
|
|
# at P_ARBITRAGE is a PARK ORDER that outranks the loop, i.e. it would silently
|
|
# disable self-consumption for the whole hour. Graded on `_act()` directly - the
|
|
# branch that builds the decision - because a fixture that reached it through a
|
|
# real `TariffPlan` would be grading the plan.
|
|
_NOW = datetime(2026, 9, 6, 12, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
class _Pt:
|
|
def __init__(self, start, price=0.30):
|
|
self.start, self.eur_per_kwh, self.provisional = start, price, False
|
|
|
|
|
|
class _Plan:
|
|
"""The smallest object `_point_at()` accepts: `plan.read.match(series=...)`."""
|
|
def __init__(self, pt):
|
|
self.read = self
|
|
self._pt = pt
|
|
|
|
def match(self, series, no_data):
|
|
return series(self)
|
|
|
|
def at(self, when):
|
|
return self._pt
|
|
|
|
|
|
class _V:
|
|
def __init__(self, cheapest, dearest):
|
|
self.spread_eur_kwh, self.required_spread_eur_kwh = 0.20, 0.05
|
|
self.cheapest, self.dearest = cheapest, dearest
|
|
|
|
|
|
_here = _Pt(_NOW)
|
|
_other = _Pt(_NOW - timedelta(hours=3))
|
|
# SAFETY-45's `headroom_w` is the last positional. It is set to a value that
|
|
# CANNOT BIND on either call - larger than every sizing below - so this part
|
|
# goes on grading OPT-09's per-pack sizing and not the capacity-tariff cap that
|
|
# now sits beside it. test_arbitrage.py section 3b is where it is made to bind.
|
|
_CLEAR_W = 1e9
|
|
_sell = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
|
|
0.0, 0.0, 20.0, 90.0, _CLEAR_W)
|
|
_sell_ok = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
|
|
0.0, 4600.0, 20.0, 90.0, _CLEAR_W)
|
|
_buy = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0,
|
|
0.0, 0.0, 20.0, 90.0, _CLEAR_W)
|
|
_buy_ok = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0, 5000.0,
|
|
0.0, 20.0, 90.0, _CLEAR_W)
|
|
print(f" sized at 0 W: sell -> {_sell.action}, buy -> {_buy.action}; "
|
|
f"sized at a rating: sell -> {_sell_ok.action}, buy -> {_buy_ok.action}")
|
|
check("⚠️ A TRADE NOTHING CAN TAKE IS NOT A TRADE. A 0 W sizing IDLES on both "
|
|
"sides rather than becoming a 0 W claim, because a `Claim.set(..., 0.0)` "
|
|
"at P_ARBITRAGE is a PARK ORDER that outranks the loop - the failure "
|
|
"`arbitrage.claim()`'s own note is written against. The sizing above can "
|
|
"never be zero on a tick that trades TODAY, and this makes that "
|
|
"structural instead of a coincidence of the sell gate",
|
|
not _sell.trading and not _buy.trading
|
|
and arbitrage.claim(_sell) is None and arbitrage.claim(_buy) is None
|
|
and arbitrage.IDLE_NO_SIZE in _sell.reason
|
|
and arbitrage.IDLE_NO_SIZE in _buy.reason)
|
|
check("...and the SAME two calls with a real sizing DO trade, so the check "
|
|
"above is separated from a fixture that simply never reaches the branch",
|
|
_sell_ok.action == arbitrage.DISCHARGE
|
|
and _buy_ok.action == arbitrage.CHARGE
|
|
and _sell_ok.power_w == 4600.0 and _buy_ok.power_w == -5000.0)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("10. THE CLAIM, THE SCOPE, AND WHAT THIS FILE CANNOT SEE")
|
|
|
|
check("the `pack` source is classified in `CLAIM_SCOPE` as a UNIT claim, so "
|
|
"`scope_violations()` complains if it is ever filed on the site while the "
|
|
"site has named units. It is about one battery in one cupboard, exactly "
|
|
"as `device`'s rating is about one box",
|
|
CLAIM_SCOPE[packshare.SOURCE] == SCOPE_UNIT)
|
|
_sc = resolve(site_claims(_at_floor, 1500.0), units=(A, Bee),
|
|
weights=lambda t: packshare.weights(_at_floor, t), tick=1)
|
|
check("...and a real cycle files it without a scope complaint",
|
|
_sc.scope_errors == [])
|
|
_mis = resolve([Claim.limit("pack", P_SAFETY, -inf, 0.0, "misfiled",
|
|
actuator=ACTUATOR_SITE),
|
|
Claim.limit("supervised", P_SAFETY, -A_W, A_W, "max_w",
|
|
actuator=A),
|
|
Claim.set("loop", P_LOOP, 1000.0, "grid")],
|
|
units=(A, Bee), tick=1)
|
|
check("...and misfiling it ON THE SITE is caught and REFUSED, not consumed. A "
|
|
"per-pack interval in the site list is the cross-inverter `lo > hi` "
|
|
"ARB-03's partition exists to make inexpressible",
|
|
_mis.scope_errors and not _mis.allocated)
|
|
|
|
# ⚠️ AND THE ARGUMENT IS ACTUALLY PASSED, WHICH IS THE WHOLE CARD AND WHICH
|
|
# NOTHING BEHAVIOURAL IN THIS FILE CAN SEE. Every cycle above builds its own
|
|
# claim list and calls `resolve()` itself, so deleting `weights=` from main.py's
|
|
# ONE call site would leave this entire file green - which is exactly the state
|
|
# release/1.0 was in, for the whole life of the parameter. It is pinned on
|
|
# main.py's own AST, the way test_peakguard.py part 12i pins the arguments of
|
|
# `arbitrage.decide()`, and for the same reason: a call site that stopped
|
|
# passing it is invisible to every fixture.
|
|
_main_src = (Path(__file__).resolve().parent / "app" / "main.py").read_text(
|
|
encoding="utf-8")
|
|
_resolve_calls = [n for n in ast.walk(ast.parse(_main_src))
|
|
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
|
and n.func.id == "resolve"]
|
|
_w_arg = [ast.unparse(k.value) for c in _resolve_calls for k in c.keywords
|
|
if k.arg == "weights"]
|
|
print(f" main.py has {len(_resolve_calls)} `resolve(` call site(s); "
|
|
f"weights= is {_w_arg}")
|
|
check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHER "
|
|
"THAN A DICT. THIS IS THE CARD: the parameter has existed since ALLOC-01 "
|
|
"and nothing ever filled it, so the split was 50/50 whatever either pack "
|
|
"could do - and no fixture anywhere could see that, because every check "
|
|
"calls `resolve()` for itself. A method, not a dict, because the "
|
|
"weighting is direction-dependent and the direction is stage 2's output",
|
|
len(_resolve_calls) == 1 and _w_arg == ["self.pack_weights"])
|
|
|
|
# ⚠️ AC 3 SAYS `unmet_w` MUST BE **VISIBLE**, AND `Allocation.explain()` IS ONLY
|
|
# HALF OF THAT. The other half is main.py putting it in the action log, which no
|
|
# check in this file can reach without building a whole Controller - so it is
|
|
# pinned STATICALLY, on `Controller.cycle()`'s own AST, the way test_peakguard.py
|
|
# part 10f pins the claim sources. What this grades is that the branch EXISTS,
|
|
# reads the allocation's own `unmet_w`, is EDGE-TRIGGERED against the previous
|
|
# cycle's value, and logs. What it does NOT grade is the sentence it logs or
|
|
# that a real cycle reaches it; that is stated here rather than implied.
|
|
_cycle_src = first(
|
|
f for cls in ast.walk(ast.parse(
|
|
(Path(__file__).resolve().parent / "app" / "main.py")
|
|
.read_text(encoding="utf-8")))
|
|
if isinstance(cls, ast.ClassDef) and cls.name == "Controller"
|
|
for f in cls.body
|
|
if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle")
|
|
_unmet_ifs = [n for n in ast.walk(_cycle_src) if isinstance(n, ast.If)
|
|
and ast.unparse(n.test) == "_unmet != self._unmet_last"]
|
|
_unmet_src = [ast.unparse(n) for n in ast.walk(_cycle_src)
|
|
if isinstance(n, ast.Assign) and len(n.targets) == 1
|
|
and ast.unparse(n.targets[0]) == "_unmet"]
|
|
print(f" main.py computes `_unmet` as {_unmet_src} and branches on it "
|
|
f"{len(_unmet_ifs)} time(s)")
|
|
check("...and main.py REPORTS an unplaceable watt rather than only recording "
|
|
"it: `Controller.cycle()` reads `self.allocation.unmet_w`, compares it "
|
|
"against the previous cycle's value - edge-triggered, because an empty "
|
|
"pack stays empty and a line a second would flush the 40-row action log "
|
|
"of the context that explains it - and calls `log_event` on the change. "
|
|
"⚠️ THIS IS A SHAPE PIN: it says the branch exists and what it reads, "
|
|
"never what it says or that a live cycle reaches it",
|
|
len(_unmet_ifs) == 1
|
|
and len(_unmet_src) == 1 and "unmet_w" in _unmet_src[0]
|
|
and any(isinstance(n, ast.Call)
|
|
and ast.unparse(n.func) == "self.log_event"
|
|
for n in ast.walk(_unmet_ifs[0])))
|
|
|
|
print()
|
|
print(" ⚠️ WHAT THIS FILE CANNOT SEE, said plainly:")
|
|
print(" * NOTHING HERE IS EVIDENCE ABOUT INVERTER B. It does not exist;")
|
|
print(" every B figure is datasheet, and part 8's plant is modelled in")
|
|
print(" this file - two copies of A given different numbers.")
|
|
print(" * IT DOES NOT GRADE THE TAPER. `spare_kwh()` says a pack at 95 %")
|
|
print(" has room; a real pack in constant-voltage taper takes far less")
|
|
print(" than it is told for tens of minutes, and this add-on has no")
|
|
print(" per-pack charge ceiling to describe that with. `delivery.py`")
|
|
print(" says the same thing and calls it a card - it is why SAFETY-42")
|
|
print(" is armed on discharge shares only.")
|
|
print(" * THE CHARGE ENVELOPE CLOSES AT 100 % AND NOWHERE ELSE. That is")
|
|
print(" the only ceiling that is a property of a PACK rather than of a")
|
|
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 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. That narrowed SAFETY-42's")
|
|
print(" detection window - a regression this file PINNED rather than")
|
|
print(" fixed - and SAFETY-43 closed it by making delinquency relative")
|
|
print(" to the ask as well as absolute. Part 8b now grades both rules")
|
|
print(" on one fixture: the absolute arm still says nothing, and the")
|
|
print(" shipped one names the unit on all 200 cycles.")
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("11. MUTATION: what defeats this card, and which check bites")
|
|
|
|
# ⚠️ EVERY ROW BELOW WAS RUN, on a tree rsynced from this branch with
|
|
# `__pycache__` purged and `python3 -B`, not reasoned about. MUTATION.md is the
|
|
# method. The two columns that matter are WHICH FILE went red and WHICH CHECK -
|
|
# a mutation caught only by a crash is recorded as such, because a traceback is
|
|
# a weaker gate than a named failure and the distinction is the point of the
|
|
# table.
|
|
MUT = [
|
|
("M1 envelope(): never close `hi` (the discharge end)",
|
|
"test_packshare 2 and 4, and test_delivery 4 - SAFETY-42's cold arm goes "
|
|
"back to being ASKED for a share it cannot deliver"),
|
|
("M2 envelope(): never close `lo` (the charge end)",
|
|
"test_packshare 2, and test_peakguard 10g - the two-inverter cycle's "
|
|
"resolved envelope for a full pack reverts to +/-rating"),
|
|
("M3 usable_kwh(): drop the capacity, weigh by SoC headroom alone",
|
|
"test_packshare 1 (the four schemes stop separating) and 3"),
|
|
("M4 weights(): ignore the direction, always use usable_kwh",
|
|
"test_packshare 3 - the charge side reports the discharge headroom"),
|
|
("M5 envelope(): `soc < floor` instead of discharge_permitted()'s `<=`",
|
|
"test_packshare 2 - the boundary and the agreement sweep"),
|
|
("M6 main.py: stop passing `weights=` to resolve() <- THE CARD ITSELF",
|
|
"test_packshare 10, the AST pin. ⚠️ NOTHING BEHAVIOURAL CATCHES THIS, in "
|
|
"this file or any other, because every fixture calls resolve() for "
|
|
"itself - which is exactly the state release/1.0 was in"),
|
|
("M7 main.py: file the `pack` claim at N=1 too",
|
|
"test_peakguard 10f's SoC sweep - and NOT test_packshare, which is "
|
|
"recorded rather than fixed: this file grades the arithmetic, and 'which "
|
|
"claims a real one-inverter cycle files' is that file's instrument"),
|
|
("M8 trade_power_w(): drop the SoC filter, size at the site rating",
|
|
"test_packshare 9"),
|
|
("M9 main.py: delete the unmet_w action-log branch",
|
|
"test_packshare 10, the AST pin - a shape check, and it says so"),
|
|
("M10 weights(): answer None for every roster",
|
|
"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, FIVE named checks - re-run for SAFETY-43 rather than "
|
|
"carried across, because the part it grades was rewritten: the split "
|
|
"reverts to 1500/1500 so the absolute arm names the unit on 199/200 "
|
|
"(taking the card's RED check down), the shipped rule's 200/200 comes "
|
|
"from the wrong shares (taking the GREEN one down), the OPT-09 window "
|
|
"collapses from 6500 W to 1000 W, the 6000 W arm's 195 becomes 199, and "
|
|
"the derated positive control moves from 0/200 to 3/200. ⚠️ RUN, NOT "
|
|
"REASONED ABOUT - a check that pins a behaviour has to be shown capable "
|
|
"of failing, or it is indistinguishable from a check that pins nothing. "
|
|
"(It also takes parts 1, 3, 7 and 8 down, which is M3's territory; 8b is "
|
|
"the part that names the SAFETY-42 interaction)"),
|
|
# --- SAFETY-43's three, and they pin the new arm from BOTH sides --------
|
|
("M12 delinquent(): drop the relative arm, keep `gap > min_w` <- THE "
|
|
"CARD ITSELF REVERTED",
|
|
"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:
|
|
print(f" caught {_n}")
|
|
print(f" {_w}")
|
|
check("every mutation in the table above was RUN and every one of them is "
|
|
"caught by a named check on this branch. ⚠️ AND THE ROW WORTH READING IS "
|
|
"M6: the whole defect this card fixes - a parameter nothing passes - is "
|
|
"invisible to every behavioural fixture in this repository, because they "
|
|
"all build their own claim list and call `resolve()` themselves. That is "
|
|
"why the call site is pinned statically as well. ⚠️ M12-M14 ARE "
|
|
"SAFETY-43's and were run the same way, on trees copied fresh and "
|
|
"mounted :ro so no `.pyc` could go stale - MUTATION.md's defect. All "
|
|
"three are REDS, which is the direction that defect cannot fabricate. "
|
|
"⚠️ 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()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|