OPT-09: the weights parameter nothing ever passed, and what it cannot do
`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:
* a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
`bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
pack is not asked to discharge and its share goes to the sibling;
* SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
`_fill()`'s last-resort pass will override the moment the sibling saturates.
Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.
Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.
SAFETY-42 and this card meet at one 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 its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.
Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.
⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.
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
3d2999337d
commit
f8e501393f
@@ -0,0 +1,875 @@
|
||||
"""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 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.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 = next(p for p in _r_opt.allocation.plans if p.unit == 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",
|
||||
_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 _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",
|
||||
_broken[2][1] == {A: 97, Bee: 903} and _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 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",
|
||||
not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
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))
|
||||
_sell = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
|
||||
0.0, 0.0, 20.0, 90.0)
|
||||
_sell_ok = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
|
||||
0.0, 4600.0, 20.0, 90.0)
|
||||
_buy = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0,
|
||||
0.0, 0.0, 20.0, 90.0)
|
||||
_buy_ok = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0, 5000.0,
|
||||
0.0, 20.0, 90.0)
|
||||
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 = next(
|
||||
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 FROM HERE AT ALL. Its SoC reads")
|
||||
print(" healthy, so no envelope closes. SAFETY-42 owns it.")
|
||||
|
||||
# ==============================================================================
|
||||
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"),
|
||||
]
|
||||
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",
|
||||
len(MUT) == 10)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
Reference in New Issue
Block a user