SAFETY-45: a price-driven charge is SIZED by the capacity guard, not switched by it
checks / unit (pull_request) Successful in 7m42s

With arbitrage armed at the shipped defaults, arbitrage and the capacity-tariff
guard drove each other in a sustained limit cycle. Re-measured on TEST-23 stage
3's closed rig against 478ba93, metrics recomputed rather than taken from the
card: `peak_cap_w` 1500 / 1200 W house -> 28 charge onsets an hour, mean period
131.1 s, the grid swinging -455.5 to +3200.0 W; at the SHIPPED
`peak_cap_w: 3500` with an 1800 W house -> 8/h, with a 3000 W house -> 32/h.
The bite rule is `load + max_w > peak_cap_w`, i.e. any house over 1500 W in the
cheapest quarter. §10.2: "an EMS that oscillates is worse than no EMS, both for
hardware wear and - per §9.1 - for the peak it is trying to avoid." And it was
losing the argument it was having: EVERY billed quarter in the three affected
configurations finished OVER the cap.

⚠️ THE CARD'S STATED MECHANISM IS WRONG AND THE CORRECTION IS ON THE RECORD.
The card, and .cache/s45.md before it, say the loop is the quarter boundary
resetting the projection and arbitrage re-arming at full power. It is not. Of
the 27 gaps between the 28 onsets, 23 are 90-120 s (mean 111.5 s) and lie
ENTIRELY INSIDE one quarter; the four long gaps - 230, 240, 250, 255 s - are
exactly the four that SPAN a boundary, so the boundary LENGTHENS the interval
rather than causing an onset. The real cycle, off the recorded fields: the trade
charges at max_w while `wanted = batt_w + excess` is negative and the guard
files NOTHING; `wanted` crosses zero; the guard engages on a floor of 5.3 W;
that flips `arbitrage.guard_state()`, which cancels the WHOLE trade; the
grid-following loop - which targets grid = 0, not grid = ceiling - takes the
actuator and discharges to +1870 W, exporting 420 W; the projection collapses;
the guard releases; the trade re-arms at max_w. A binary interlock keyed on a
continuous quantity. A fix aimed at the boundary would have removed four of
twenty-eight onsets.

THE FIX IS A SHAPE THIS ADD-ON ALREADY HAD. `control.maintenance_charge_floor()`
caps a MAINTENANCE charge at "the headroom left under the site's cap", because
"a maintenance charge is the only thing this system does that is big enough and
long enough to set that peak". ⚠️ That sentence stopped being true when OPT-04
landed. `peakguard.charge_headroom_w()` publishes the same quantity off the
MEASURED guard rather than off the optional `peak_forecast_entity` - the hole
SAFETY-25 had to close in `shaping_suspended()` - and `arbitrage.decide()` sizes
its BUY leg to it, keyword-only and with no default.

NO NEW ARITHMETIC AND NO NEW CONSTANT. The number is `wanted_w` read in the
other direction: `decide()` already computes the DISCHARGE that lands the
quarter on the ceiling, and a negative one is a permitted CHARGE of `-wanted`.
⚠️ `peakguard.decide()` IS UNCHANGED - sha256(decide) = 200090d0835a3a55, 7252 B
on this branch and on 478ba93 - so 2026-08-20's anti-bang-bang rung is exactly
where it was, and tools/replay_ac3.py reproduces 190/190 guarded quarters, 110
engaged cycles, 5 blind and 878 Wh on both trees.

TWO PROPERTIES MAKE IT SAFE TO HAND A STRATEGY, BOTH SWEPT RATHER THAN ARGUED.
It is INVARIANT IN THE BATTERY - raise the charge by dW, the meter rises by dW,
so `excess` rises by dW while `batt_w` falls by dW and `wanted` does not move:
1350.0 W at every point across batt_w -5000..+2000 W on one banked quarter,
while the `excess` those runs see takes six distinct values. It is feed-forward,
so consuming it adds no feedback path around the plant. And HOLDING IMPLIES ZERO
OR UNKNOWN: across 1080 swept combinations not one ENGAGED decision answers a
positive headroom.

AFTER, same rig, same command. 1500/1200: 28 onsets -> 1, grid swing 3655.5 ->
282.0 W, energy bought 180.0 -> 285.2 Wh (+58%, because a continuous 300 W trade
beats a 2000 W trade cancelled for 57% of the hour), billed quarters
1490/1497/1509 -> 1500.0/1500.0/1500.0. 3500/1800: 8 -> 1, 3511/3536/3559 ->
3500/3500/3500. 3500/3000: 32 -> 1, 3519/3542/3557 -> 3500/3500/3500. The two
shipped-default rows buy 2.2% and 1.6% LESS energy and in exchange the worst
quarter goes from 59 W over the cap to zero - which is the ordering §10.1 asks
for, since the capacity tariff bills the month's single worst quarter.
⚠️ AND THE NEGATIVE CONTROL: at peak_cap_w 5000 with a 1200 W house, where
`load + max_w` never reaches the cap, the two runs are BIT-IDENTICAL.

⚠️ A NEGATIVE `lo` WAS CONSIDERED AND REJECTED, AND THE REASON IS IN THE CODE.
Deleting `decide()`'s `wanted <= 0` gate and filing `lo = wanted` is the tidier
answer arithmetically. It creates a new `lo > hi` CONTRADICTION against
main.py's cheap-window maintenance shaping, which files a NEGATIVE `hi`;
arbiter.py clause 2 has no notion of one limit out-ranking another, so it
resolves to 0 W - the battery PARKED in the quarter that sets the bill - and the
only way to close that suspends the maintenance charge shaping PERMANENTLY on
every site this card is about. A backoff or cooldown in arbitrage was rejected
too: both modules' design notes say no timer is invented, and the trade now
re-arms at `ceiling - load` rather than at max_w, which is what learning from a
refusal means when the refusal is a measurement.

THE REGRESSION IS THE LOOP SETTLING, NOT A UNIT TEST ON decide().
test_arbitrage.py 3c closes the loop over the shipped guard, strategy, arbiter,
control law and QuarterAverager with a modelled plant, and asserts BOTH
directions: unsized it must run away (24 onsets, -414..+3200 W, a quarter over
the cap) and sized it must settle (1 onset, 303 W swing, three quarters at
exactly 1500.0 W). The two runs differ in one argument. A third pair asserts the
card is INERT where the defect is inert.

Evidence: 29/29 green. 14 mutations, every one RED on a named check, no
survivors; two are ZERO BYTES - `min()` -> `max()` on the sizing, and the
sized-trade sentence's two operands transposed - and both are red. M13 reverts
the 2026-08-20 rung and is red on EIGHT named checks in test_peakguard and three
in test_arbitrage. Every revert verified by a tree fingerprint AFTER the revert,
which is how a defect in the battery's own harness was found: two edits to one
file, restored in list order, left the first one on disk (MUTATION.md habit 18).
test_peakguard.py part 1 also gained a None-safe format, because the mutation it
exists to catch made it raise TypeError one line later and ABORT the file -
parts 2-13 were ungraded and M13's recorded coverage was one check instead of
eight.

Not fixed, and named: TEST-23 finding 1's export overshoot at the END of a trade
(356-938 W, ~0.37 x max_w) is a different card; a house whose load ALONE exceeds
the cap still has the trade refused outright and the loop drive to grid = 0
rather than to the ceiling, and no swept pair reaches that regime; the container
leg (hadrive.py) was not re-run because test23s3-addon is deliberately stopped
as a second writer on the setpoint entity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-14 16:10:34 +02:00
co-authored by Claude Opus 5
parent 478ba93710
commit e1b9254626
9 changed files with 1178 additions and 24 deletions
+355 -11
View File
@@ -73,6 +73,7 @@ Read MUTATION.md before you report a survivor.
import ast
import inspect
import math
import os
import sys
from datetime import datetime, timedelta, timezone
@@ -84,6 +85,11 @@ from app.arbiter import (
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
resolve,
)
# SAFETY-45 section 3c. The shipped loop, the shipped billed-quarter
# accumulator and the shipped projection - nothing here is transcribed.
from app.control import Tuning, compute
from app.p1 import QUARTER_S, QuarterAverager, make_sample, split_signed
from app.tariff import PeakTracker
from app.arbitrage import (
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
threshold_eur_kwh,
@@ -203,10 +209,19 @@ def hour(i, *, start=D0):
return start + timedelta(hours=i, minutes=30)
# SAFETY-45. THE HEADROOM EVERY CHECK THAT IS NOT ABOUT THE HEADROOM USES, and
# it is deliberately NOT binding: `min(MAX_W, this)` is MAX_W, so every existing
# check in this file grades what it always graded and the new rung is decoration
# in all of them. Section 3b is where it is made to bind, and 3b asserts the
# precondition that it does (MUTATION.md habit 1) rather than hoping.
HEADROOM_CLEAR = MAX_W * 10.0
def act(plan, i, *, peak=False, soc=50.0, thresh=0.0, unc=frozenset(),
floor=20.0, ceiling=95.0, start=D0):
floor=20.0, ceiling=95.0, start=D0, headroom=HEADROOM_CLEAR):
"""`decide()` with the fixture's constants filled in. One call shape."""
return decide(plan, hour(i, start=start), peak_guard_active=peak, soc=soc,
charge_headroom_w=headroom,
min_spread_eur_kwh=thresh, uncertainty=unc,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
@@ -721,6 +736,7 @@ check("a peak floor does NOT cap an arbitrage DISCHARGE - §9.1 bills offtake "
# MECHANISM 3, which is neither of the above: the guard that never ran.
_blind = decide(PLAN, hour(CHEAP_H), peak_guard_active=None, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
check("a peak guard that NEVER RAN is a third state, and it refuses: `None` is "
@@ -750,6 +766,323 @@ check("...and cycle() calls it rather than inlining the conjunction, so a "
encoding="utf-8").read(), "cycle")))
# --------------------------------------------------------------------------- #
print()
print("3b. SAFETY-45 - the buy leg is SIZED by the guard, not switched by it")
# ---------------------------------------------------------------------------
# AC3 above is the SWITCH: `peak_guard_active` refuses the whole trade while
# the guard holds. That is right and it stays. What was missing is the other
# half - the guard measures a CONTINUOUS number, "the watts this billed quarter
# can still afford", and until this card nothing consumed it. So a trade sat at
# max_w through the whole window in which the guard was silent, blew the
# quarter, tripped the switch, was cancelled outright, and came back at max_w
# the moment the meter fell far enough to release it. Measured on TEST-23
# stage 3's closed rig at peak_cap_w 1500 / max_w 2000 / a 1200 W house:
# 28 charge onsets in one hour, mean period 131 s, grid -456 <-> +3200 W.
#
# `control.maintenance_charge_floor()` has always given a MAINTENANCE charge
# exactly this shape - "capped by the headroom left under the site's cap" - and
# its own docstring says why: "a maintenance charge is the only thing this
# system does that is big enough and long enough to set that peak". OPT-04 is
# the second such thing and was given no cap. This section grades the cap.
# ---------------------------------------------------------------------------
# ⚠️ THE PRECONDITION, AND IT IS THE HABIT-1 ONE. The headroom must be strictly
# between 0 and the size the trade would otherwise ask for, or "sized by the
# guard" is indistinguishable from "not sized at all" and from "refused".
S45_HEADROOM = 300.0
check(f"PRECONDITION: the headroom under test ({S45_HEADROOM:.0f} W) is "
f"strictly inside the trade's own sizing ({MAX_W:.0f} W) and strictly "
f"positive, so a cap that did nothing and a cap that refused everything "
f"are both distinguishable from the answer below",
0.0 < S45_HEADROOM < MAX_W)
_s45_capped = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER,
headroom=S45_HEADROOM)
_s45_clear = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER)
print(f" headroom {S45_HEADROOM:.0f} W -> {_s45_capped.power_w:+.0f} W; "
f"headroom clear -> {_s45_clear.power_w:+.0f} W")
print(f" {_s45_capped.reason}")
check("the trade still HAPPENS - §10.1 ranks peak shaving above arbitrage, it "
"does not abolish it - and it is sized to the watts the billed quarter "
"can afford rather than to max_w",
_s45_capped.action == CHARGE
and near(_s45_capped.power_w, -S45_HEADROOM))
check("...and the claim the arbiter receives carries the SIZED number, so the "
"cap reaches the actuator and is not merely printed",
near(claim(_s45_capped).value, -S45_HEADROOM)
and claim(_s45_capped).priority == P_ARBITRAGE)
check("...and the SAME call with the headroom clear asks for max_w, so the "
"check above is graded against a difference this fixture can actually "
"produce (habit 7: one thing varied, from an accepted baseline)",
_s45_clear.action == CHARGE and near(_s45_clear.power_w, -MAX_W))
# ⚠️ THE ORDERED PAIR (MUTATION.md habit 14). Two numbers in one sentence can
# always be transposed by a ZERO-BYTE edit, and this sentence is what an
# operator reads when the battery buys less than the price says it should. So
# the check asserts each value inside the literal text that names it, and
# asserts that the TRANSPOSED sentence is not what it says.
_S45_PAIR = (f"the trade asked for {MAX_W:.0f} W and this billed quarter can "
f"afford {S45_HEADROOM:.0f} W")
_S45_SWAP = (f"the trade asked for {S45_HEADROOM:.0f} W and this billed "
f"quarter can afford {MAX_W:.0f} W")
check("the reason names WHAT WAS ASKED FOR and WHAT WAS AFFORDABLE in their "
"own slots, so an operator can tell a small trade from a small "
"opportunity", _S45_PAIR in _s45_capped.reason)
check("...and the transposed sentence is NOT what it says, so the check above "
"discriminates rather than decorates - both numbers are in the string "
"either way and a substring test would pass under the swap",
_S45_SWAP not in _s45_capped.reason)
check("...and it names the guard and the two spec sections, so the sentence "
"cannot be read as a pack limit or a price limit",
"CAPACITY-TARIFF GUARD" in _s45_capped.reason
and "§9.1" in _s45_capped.reason and "§10.1" in _s45_capped.reason)
check("...and an UNCAPPED trade does not carry that clause at all: a sentence "
"that always says 'sized by the guard' tells nobody anything",
"CAPACITY-TARIFF GUARD" not in _s45_clear.reason
and f"charging {MAX_W:.0f} W" in _s45_clear.reason)
# --- the two refusals, and they are told apart from the ones that existed ---
_s45_none = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=None)
_s45_zero = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
print(f" headroom None -> {_s45_none.action}; headroom 0 W -> "
f"{_s45_zero.action}")
check("a headroom nothing MEASURED refuses. This is guard_state()'s None "
"argument one layer down: charging creates the billed quantity (§9.1) "
"and an unmeasured quarter is not a clear one",
not _s45_none.trading
and _s45_none.reason == _arb.IDLE_HEADROOM_UNMEASURED)
check("...and it is DISTINGUISHABLE from the guard never having run at all, so "
"the status page does not report a missing battery sensor as a missing "
"meter", _s45_none.reason != _arb.IDLE_NOT_COVERED)
check("a headroom of ZERO refuses rather than filing a 0 W claim - "
"`Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the "
"loop, which is the failure claim()'s own note is written against",
not _s45_zero.trading and claim(_s45_zero) is None
and _s45_zero.reason == _arb.IDLE_NO_HEADROOM)
# ⚠️ TEST-16's RULE: TWO RUNGS THAT CAN ANSWER THE SAME STIMULUS MUST BE
# SEPARATED. `charge_w = 0` and `headroom = 0` both produce a 0 W sizing, and
# grading only `kind == "refused"` would let either absorb the other's work -
# after which weakening either one reports nothing. So the REASONS are graded,
# and each rung is reached with the other one clear.
_s45_nosize = decide(PLAN, hour(CHEAP_H), peak_guard_active=False, soc=50.0,
charge_headroom_w=HEADROOM_CLEAR,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=0.0, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
check("...and the PACK being sized at 0 W still reports OPT-09's own sentence, "
"not this card's: the two rungs answer the same stimulus and a refusal "
"that named the wrong subsystem would send an operator to the wrong page",
not _s45_nosize.trading
and _s45_nosize.reason == _arb.IDLE_NO_SIZE
and _s45_zero.reason != _s45_nosize.reason)
# --- §9.1: the SELL leg is untouched ---------------------------------------
_s45_sell = act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
"are never capped by the peak guard; only charging and import are' - the "
"capacity tariff bills offtake, and a sell creates none",
_s45_sell.action == DISCHARGE and near(_s45_sell.power_w, MAX_W))
# --- and main.py actually passes it, pinned on the AST ----------------------
# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (MUTATION.md habit 15). Nothing
# behavioural in this file can see main.py drop the argument - every check
# above calls decide() itself - and a required keyword-only parameter that no
# caller fills is a TypeError only on the one path a suite does not run. Same
# pin, same reason, as `weights=` in test_packshare.py part 10.
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
_s45_calls = [n for n in ast.walk(ast.parse(_S45_MAIN))
if isinstance(n, ast.Call)
and ast.unparse(n.func).endswith("arbitrage.decide")]
_s45_arg = [ast.unparse(k.value) for c in _s45_calls for k in c.keywords
if k.arg == "charge_headroom_w"]
print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); "
f"charge_headroom_w= is {_s45_arg}")
check("main.py passes the guard's OWN measured headroom at its one "
"arbitrage.decide() call site - the shipped expression, not a "
"transcription of it, so a mutation back to a literal or to max_w is red "
"rather than silent",
_s45_arg == ["peakguard.charge_headroom_w(self.peak_guard)"])
# --------------------------------------------------------------------------- #
print()
print("3c. SAFETY-45 - THE REGRESSION: the closed loop SETTLES")
# ---------------------------------------------------------------------------
# ⚠️ THIS IS THE CHECK THE CARD IS ACTUALLY ABOUT, AND A UNIT TEST ON decide()
# IS NOT IT. The defect is an INTERACTION - two strategies, a 900 s integrator
# and a plant that answers back - and every individual decision in it was
# correct. So the loop is closed here: prices decide, the battery model
# responds, the shipped QuarterAverager reports the consequence, and the
# shipped guard reacts to that.
#
# WHAT IS REAL, AND WHAT IS MODELLED - the same discipline TEST-23 stage 3's
# harness holds itself to, because a transcription proves what somebody typed:
#
# REAL peakguard.decide/claim/charge_headroom_w, arbitrage.decide/claim/
# guard_state, control.compute + Tuning, arbiter.resolve, and
# p1.QuarterAverager - the billed-quarter accumulator itself, so the
# quarters reported below are the ones a meter would bill.
# MODEL ONE cycle's ORDER (peak block, then arbitrage reading its
# conclusion, then the loop claim, then resolve) - twelve lines
# transcribed from main.py:2660-2900 and pinned above in 3b; the
# PLANT, an 800 W/s ramp into a 10 s first-order lag, which is
# plantsim's; and a flat synthetic house.
#
# ⚠️ AND THE HEADLINE NUMBERS WERE NOT TAKEN HERE. They were taken on the
# closed rig at /home/homelab/test23s3 against the shipped container's own
# checkout, which is where a divergence between this model and main.py's real
# event loop would show up. This section exists so CI can go red.
# ---------------------------------------------------------------------------
S45_CAP_W, S45_LOAD_W, S45_CYCLE_S, S45_RUN_S = 1500.0, 1200.0, 5.0, 2700.0
S45_RAMP_W_S, S45_LAG_S = 800.0, 10.0
# ⚠️ THE RUN STARTS ON A QUARTER BOUNDARY AND ENDS INSIDE THE SAME CHEAP HOUR,
# and both halves matter. Starting on the boundary makes the three closed
# blocks below whole billed quarters. Ending inside the hour keeps TEST-23
# finding 1 - the export overshoot at the END of any trade, ~0.37 x max_w, a
# separate and already-recorded property - out of a swing figure this card is
# grading. The run still crosses TWO quarter boundaries, which is the half of
# the defect the card says arbitrage must learn from.
S45_T0 = D0 + timedelta(hours=CHEAP_H)
def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
seconds=S45_RUN_S):
"""One closed hour. `sized=False` is the behaviour this card removes.
⚠️ THE COUNTERFACTUAL IS ONE ARGUMENT, NOT A SECOND CODE PATH. `sized=False`
hands `decide()` a headroom nothing can bind - which is exactly what the
tree did before this card, because nothing was passing one - so the two runs
below differ in one value and share every line of shipped code. Habit 7.
"""
avg = QuarterAverager(phases=1, max_hold_s=900.0)
tuning = Tuning(gain=0.6, max_w=MAX_W, slew_w=0.0)
t0 = S45_T0
target = i_w = 0.0
sat = 0
loop_w = None
batt_cmd = batt = 0.0 # + discharge, as main.py's site_batt_w
onsets = blocks = 0
prev_action = "<start>"
grid_lo, grid_hi = None, None
billed = []
acc = 0.0
t = 0.0
while t <= seconds:
grid = load_w - batt
imp, exp = split_signed(grid)
for b in avg.add(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=t0 + timedelta(seconds=t),
ingest_mono=(t0.timestamp() + t))):
billed.append(round(float(b.offtake_avg_w), 1))
blocks += 1
if acc <= 1e-9:
acc = S45_CYCLE_S
now = t0 + timedelta(seconds=t)
claims = []
pk = _pg.decide(PeakTracker.project(avg, now), batt_w=batt,
cap_w=cap_w, reading=None, achievable_w=MAX_W,
soc=50.0, soc_floor=20.0, max_hold_s=900.0,
frozen=False)
pc = _pg.claim(pk)
if pc is not None:
claims.append(pc)
arb = decide(PLAN, now,
peak_guard_active=_arb.guard_state(pk),
charge_headroom_w=(_pg.charge_headroom_w(pk) if sized
else HEADROOM_CLEAR),
soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER, charge_w=MAX_W,
discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
ac = claim(arb)
if ac is not None:
claims.append(ac)
if arb.action == CHARGE and prev_action != CHARGE:
onsets += 1
prev_action = arb.action
if loop_w is None or target != loop_w:
i_w = target
d = compute(prev_w=target, grid_w=grid, actual_w=batt,
tuning=tuning, sat_count=sat, i_w=i_w)
sat, i_w, loop_w = d.sat_count, d.i_w, d.target_w
claims.append(Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W,
"max_w"))
claims.append(Claim.set("loop", P_LOOP, d.target_w, d.reason))
target = resolve(claims).target_w
# THE PLANT, MODELLED: an 800 W/s ramp into a 10 s first-order lag.
step = min(1.0, acc)
delta = target - batt_cmd
batt_cmd += math.copysign(min(abs(delta), S45_RAMP_W_S * step), delta)
batt += (batt_cmd - batt) * (step / S45_LAG_S)
acc -= step
t += step
grid_lo = grid if grid_lo is None else min(grid_lo, grid)
grid_hi = grid if grid_hi is None else max(grid_hi, grid)
return dict(onsets=onsets, grid_lo=round(grid_lo, 1),
grid_hi=round(grid_hi, 1), swing=round(grid_hi - grid_lo, 1),
billed=billed, blocks=blocks)
S45_BEFORE = derived(lambda: s45_loop(sized=False))
S45_AFTER = derived(lambda: s45_loop(sized=True))
for _lab, _r in (("unsized (the shipped behaviour before this card)", S45_BEFORE),
("sized by the guard", S45_AFTER)):
print(f" {_lab}: {_r['onsets']} charge onsets/h, grid "
f"{_r['grid_lo']:.0f} .. {_r['grid_hi']:.0f} W (swing "
f"{_r['swing']:.0f} W), billed quarters {_r['billed']} W")
# ⚠️ THE NEGATIVE CONTROL FIRST. If the unsized run did NOT limit-cycle, every
# assertion below would be a statement about a loop that was never unstable -
# MUTATION.md's "a check whose data cannot reach the failure it names", which
# has now appeared in eleven cards in this repository.
check("PRECONDITION: with the trade UNSIZED this loop really does run away - "
"many charge onsets in one hour and a grid swing of kilowatts. Without "
"this, everything below grades a loop that was never unstable",
S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0)
check("...and it is the bang-bang shape and not a slow drift: the meter "
"reaches both a hard import and an EXPORT, so the actuator is being "
"handed back and forth rather than converging",
S45_BEFORE["grid_hi"] > S45_CAP_W + 1000.0
and S45_BEFORE["grid_lo"] < 0.0)
check(f"THE REGRESSION: sizing the trade to the guard's measured headroom "
f"settles the loop. {S45_BEFORE['onsets']} charge onsets an hour become "
f"{S45_AFTER['onsets']}, and the grid swing falls from "
f"{S45_BEFORE['swing']:.0f} W to {S45_AFTER['swing']:.0f} W",
S45_AFTER["onsets"] <= 2
and S45_AFTER["swing"] < 0.25 * S45_BEFORE["swing"])
check("...and the battery is still TRADING for the whole hour rather than "
"settling by giving up: §10.1 ranks peak shaving above arbitrage, it "
"does not cancel it, and a fix that simply stopped buying would pass an "
"onset count and fail the card",
S45_AFTER["onsets"] == 1)
check(f"...and every BILLED quarter comes in at or under the "
f"{S45_CAP_W:.0f} W cap, which the unsized run does not manage - the "
f"oscillation was not merely ugly, it was losing the money it was "
f"oscillating about (§10.2, §9.1)",
S45_AFTER["blocks"] >= 3
and all(v <= S45_CAP_W + 0.5 for v in S45_AFTER["billed"])
and any(v > S45_CAP_W for v in S45_BEFORE["billed"]))
check("...and the two runs are the same code on the same fixture: one "
"argument differs, and the loop, the guard, the arbiter, the averager "
"and the plant are shared line for line",
S45_BEFORE["blocks"] == S45_AFTER["blocks"])
# ⚠️ AND IT IS INERT WHERE THE DEFECT IS INERT, WHICH IS THE OTHER HALF OF A
# FIX. The rig's sweep found the bite rule `load + max_w > peak_cap_w`; below
# it, the trade never threatens the quarter and this card must change NOTHING.
S45_WIDE = derived(lambda: s45_loop(sized=False, cap_w=5000.0, load_w=1200.0,
seconds=900.0))
S45_WIDE_S = derived(lambda: s45_loop(sized=True, cap_w=5000.0, load_w=1200.0,
seconds=900.0))
print(f" peak_cap_w 5000 W, 1200 W house (load + max_w = "
f"{S45_LOAD_W + MAX_W:.0f} W, under the cap): unsized "
f"{S45_WIDE['billed']} W, sized {S45_WIDE_S['billed']} W")
check("on a site where the trade cannot reach the cap the two runs are "
"IDENTICAL to the watt - so this card costs nothing on the installs it "
"is not about, and the check above is not passing because the cap "
"dominates everything",
S45_WIDE == S45_WIDE_S and S45_WIDE["onsets"] == 1)
# --------------------------------------------------------------------------- #
print()
print("4. §8.3 - no irreversible action on a provisional price")
@@ -822,7 +1155,8 @@ _ticks = []
for _t in range(24 * 120): # 24 h at the 30 s control cadence
_d = decide(_flap_plan, D0 + timedelta(seconds=30 * _t),
peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
_ticks.append(_d.action)
_transitions = sum(1 for a, b in zip(_ticks, _ticks[1:]) if a != b)
@@ -844,7 +1178,8 @@ _no_plan = act(None, CHEAP_H, thresh=_shipped)
_no_soc = act(PLAN, CHEAP_H, thresh=_shipped, soc=None)
_uncovered = decide(PLAN, D0 + timedelta(days=9), peak_guard_active=False,
soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
_reasons = [_no_plan.reason, _no_soc.reason, _uncovered.reason, _blind.reason,
_held.reason]
@@ -1218,7 +1553,8 @@ def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
d = decide(plan, p.start + timedelta(minutes=30),
peak_guard_active=False, soc=soc,
min_spread_eur_kwh=threshold, uncertainty=unc,
charge_w=MAX_W, discharge_w=MAX_W,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
if d.action is None:
continue
@@ -1952,10 +2288,12 @@ check("...and the same at the sell probe, against the verdict's dearest",
_pa_sell is derived(lambda: _qv.dearest))
_qbuy = decide(QPLAN, qprobe(Q_CHEAP_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
_qsell = decide(QPLAN, qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
check("...so decide() CHARGES in the cheapest quarter and DISCHARGES in the "
@@ -1980,7 +2318,8 @@ check(f"the off-peak probe's own price differs from its hour's :00 price "
and _QOFF_I not in (Q_CHEAP_I, Q_DEAR_I))
_qoff = decide(QPLAN, qprobe(_QOFF_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
check("a quarter that is neither extremum still idles, for the reason that "
"names the right rule, and reports ITS OWN price - not the price of the "
@@ -2007,7 +2346,8 @@ check(f"the negative day's dearest quarter is slot {Q_DEAR_I} and it is BELOW "
NO_WEATHER))
_qneg_sell = decide(QNEG_PLAN, qprobe(Q_DEAR_I), peak_guard_active=False,
soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER, charge_w=MAX_W, discharge_w=MAX_W,
uncertainty=NO_WEATHER, charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
check("selling into a negative-price QUARTER is refused, and the reason names "
"§17 and THAT QUARTER's price",
@@ -2020,7 +2360,8 @@ check("...while the SAME quarter-hourly series with that ONE price's SIGN "
"FLIPPED discharges instead",
decide(qplan_of(QPOS), qprobe(Q_DEAR_I), peak_guard_active=False,
soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == DISCHARGE
and sum(1 for a, b in zip(QNEG, QPOS) if a != b) == 1)
# §8.3's flag, read off the covering point. `_act`'s own provisional guard
@@ -2077,11 +2418,13 @@ check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
"their own prices",
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == CHARGE
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == DISCHARGE)
# An instant inside a CARRIED hour of the same series. The covering point is
# the carried one; the shipped scan answered a point from the FIRM block
@@ -2096,7 +2439,8 @@ check("an instant inside a CARRIED hour of the mixed series matches the "
and MIX_PTS[MIX_N_H - 1].provisional is True
and decide(MIX_PLAN, _mix_carried_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action is None)
print()