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 against478ba93, 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 on478ba93- 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
13 KiB
SAFETY-45 — the trade is SIZED by the capacity guard, not switched by it
Status: closed. peakguard.charge_headroom_w() publishes the watts the open
billed quarter can still afford; arbitrage.decide() takes it as a required
keyword-only argument and sizes the BUY leg to it; main.py passes the measured
guard's own number at its one call site. peakguard.decide() is not changed —
byte-identical to release/1.0 at 478ba93, sha256(decide) = 200090d0835a3a55,
7252 B on both trees.
⚠️ THE HOUSE LOAD IN EVERY FIGURE BELOW IS SYNTHETIC. TEST-23 stage 3's
priceloop.py drives a flat modelled house against a modelled pack
(plantsim.Plant, 800 W/s ramp into a 10 s lag, 15 360 Wh). The PRICES are real,
the decision code is the shipped code, the billed-quarter accumulator is
app/p1.py's own — the load is not. Nothing here is a measurement of the live
site at 192.168.2.158, which this work did not touch.
0. The defect, measured
TEST-23 stage 3 found a sustained limit cycle with arbitrage armed and a charge
decision active. Re-measured here on the same rig against the same checkout,
metrics recomputed rather than copied — a charge onset is a cycle at which
arbitrage.decide() transitions into ARBITRAGE_CHARGE, which is the edge the
rig already logs:
peak_cap_w |
house | onsets/h | mean period | grid swing | billed quarters (W) |
|---|---|---|---|---|---|
| 1500 | 1200 W | 28 | 131.1 s | −455.5 … +3200.0 W | 1490.1 / 1496.5 / 1508.8 |
| 3500 (shipped) | 1800 W | 8 | 509.3 s | +127.5 … +3800.0 W | 3511.3 / 3536.0 / 3559.1 |
| 3500 (shipped) | 3000 W | 32 | 114.8 s | +1202.0 … +5000.0 W | 3518.9 / 3542.2 / 3556.7 |
| 5000 | 1200 W | 1 | — | +1240.6 … +3200.0 W | 3169.5 / 3200.0 / 3200.0 |
The last row is the negative control: load + max_w = 3200 W never reaches
the cap, so there is no cycle to remove. The bite rule is load + max_w > peak_cap_w, which at the shipped peak_cap_w: 3500 / max_w: 2000 is any house
drawing over 1500 W in the cheapest quarter.
§10.1's ordering did win on the money — the guard kept the quarters within tens of watts of the cap — but every billed quarter in the three biting rows finished OVER it, which is the thing §9.1 bills. §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."
1. ⚠️ The mechanism is NOT mainly the quarter boundary, and the card says it is
The card, and .cache/s45.md before it, describe the loop as "at each quarter
boundary the projection resets to 0, the guard disengages, and arbitrage re-arms
at full power." That is not what the 28 onsets are. Their timestamps, off
the run above:
1, 231, 351, 471, 581, 696, 806, 896, 1136, 1256, 1371, 1486, 1596,
1711, 1811, 2061, 2176, 2296, 2411, 2521, 2626, 2716, 2971, 3091,
3206, 3321, 3431, 3541 (s, quarter boundaries at 900/1800/2700)
23 of the 27 gaps are 90–120 s, mean 111.5 s, entirely inside one quarter.
The four long gaps — 230, 240, 250, 255 s — are precisely the four that SPAN a
boundary, so the boundary lengthens the interval rather than causing an
onset. Only three onsets land within 15 s of a boundary and one of those is
t = 1, the start of the run.
The real cycle, read off the recorded fields at t = 1000…1180 s:
- the trade charges at
−2000 W; the meter sits at 3200 W against a 1500 W ceiling;wanted = batt_w + excessis negative (−132 → −8 W), sodecide()files nothing and arbitrage is not constrained at all; wanteddrifts up as the quarter's own history worsens and the900 / remaininggain grows, and crosses zero at t = 1055 withfloor_w = 5.3 W;- that 5 W floor flips
arbitrage.guard_state()toTrue, which refuses the whole trade — so the setpoint goes from −2000 W to +5 W and the grid-following loop, which targets grid = 0 and not grid = ceiling, takes the actuator and discharges to +1870 W, exporting 420 W; - the projection collapses,
wantedgoes negative, the guard releases, and arbitrage re-arms atmax_w. 115 s later, again.
So the defect is a binary interlock keyed on a continuous quantity, and the
quarter boundary is one of the ways it is crossed rather than the cause. The
card's other claim — "projection 3092 W against a 1509 W ceiling, engaged=False,
for 170 consecutive seconds" — reproduces, but it is the first such window
(first_engaged_t_s = 151.0); the steady-state silent window is ~40–65 s.
None of this changes what the fix has to do. It is recorded because a card that says "the boundary is the trigger" invites a fix in the boundary handling, and such a fix would have removed four of twenty-eight onsets.
2. What was rejected, and why
2a. Reverting decide()'s wanted <= 0.0 rung — rejected, and it is a trap
That rung is 2026-08-20's anti-bang-bang. Its own comment records the cost of not
having it: 17 engage/release cycles in 900 s and a quarter finishing 49 W over
the limit it had spent a quarter of an hour holding. It is untouched, pinned on
the AST in test_peakguard.py 13g, and tools/replay_ac3.py reproduces the same
190/190, 110 engaged cycles, 5 blind, 878 Wh on this branch as on 478ba93.
2b. Filing the affordable charge as a NEGATIVE lo — rejected
Arithmetically this is the prettiest answer: lo = wanted with no sign gate is
continuous, is exactly the control law decide() already computes, and would
have put the constraint on the arbiter where it binds every claimant rather than
only arbitrage. It was rejected on two grounds, in this order:
- ⚠️ IT CREATES A NEW
lo > hiCONTRADICTION CLASS AGAINST THE MAINTENANCE SHAPING.main.py's cheap-window maintenance charge filesClaim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)— a NEGATIVEhi.arbiter.pyclause 2 applies every limit at or above the winner's priority and takes the most restrictive, with no notion of one limit out-ranking another; a peaklo = −300 Wagainst a shapinghi = −1000 Wislo > hi, which resolves to 0 W — the battery parked, in the one quarter of the month that sets the bill. That is the exact failurepeakguard.shaping_suspended()exists to prevent, and the only way to close it would be to key that suppression on "a claim exists" — which, on every site this card is about, means the maintenance charge-only shaping is suspended permanently. A silent, large change to a different subsystem is not a price this card should pay. - It changes the meaning of
PeakDecision.engagedat five call sites outside this module —arbitrage.guard_state(),shaping_suspended(),claim(),main.py's edge-logged action line andmain.py's status row — each of which today means "the guard is HOLDING" and would silently start meaning "a claim exists".
test_peakguard.py part 5's sweep asserts no negative floor, ever, and the
reason it gives ("a lo limit is incapable of capping discharge or export only
while it is non-negative") is, strictly, not the true reason — a lo of any sign
caps neither. The invariant is still right and is still worth holding; §2b above
is the argument that actually supports it, and it is recorded here rather than
left for the next person to re-derive.
2c. A backoff, cooldown or hysteresis constant in arbitrage.py — rejected
The card asks whether the second half of the fix belongs in arbitrage: "a trade
refused at 14:59 and re-armed at 15:00 has learned nothing from the refusal."
It does belong there, but as a size, not as a memory. arbitrage.py's
hysteresis note is explicit that the switching decision is "which hour is it" and
that no timer is needed and none is invented; peakguard.py's design note
spends a paragraph on why it carries no hysteresis constant. A cooldown would be
a second control loop compensating for a limit the first one declined to file,
and its constant would outlive whatever it was tuned against. The trade now
re-arms at ceiling − load instead of at max_w, which is what "learning from
the refusal" means when the refusal is a measurement.
3. What was done, and the precedent it follows
control.maintenance_charge_floor() has capped MAINTENANCE charging at "the
headroom left under the site's cap" since the loop was written, and its
docstring says why:
"A maintenance charge is the only thing this system does that is big enough and long enough to set that peak, so it is capped by the headroom left under the site's cap."
⚠️ THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED. Price arbitrage is a
second thing that is big enough and long enough — it commands max_w for a whole
tariff interval, deliberately, against the meter — and it was given no such cap.
This card gives it the same shape, with one difference that matters:
maintenance_charge_floor() sizes itself from peak_forecast_entity, which is
OPTIONAL and empty on a default install — the exact hole SAFETY-25 had to close
in shaping_suspended(). charge_headroom_w() comes off decide(), which runs
wherever P1 does.
The number is wanted_w read in the other direction. decide() already
computes wanted = batt_w + excess as the DISCHARGE that lands the quarter on the
ceiling; a negative wanted is a permitted CHARGE of −wanted. No new
arithmetic, no new constant.
Two properties make it safe to hand a strategy, and both are swept rather than
argued (test_peakguard.py part 13):
- ⚠️ IT IS INVARIANT IN THE BATTERY. Raise the charge by dW and the meter
rises by dW, so
excessrises by exactly dW whilebatt_wfalls by dW:wanteddoes not move. Measured acrossbatt_wfrom −5000 W to +2000 W on one banked quarter — the headroom is 1350.0 W at every one of the six points while theexcessthose runs see moves through six distinct values. It is a feed-forward number, so consuming it adds no feedback path around the plant. The fixed point ischarge = ceiling − load, with the meter ON the ceiling andwantedparked a wholeceiling − loadclear of the engagement boundary. - HOLDING IMPLIES ZERO OR UNKNOWN. Across 1080 swept combinations, not one
ENGAGED decision answers a positive headroom, so the function can never hand a
caller permission the guard's own
lois at that instant refusing.
4. After, on the same rig, same command, same checkout
peak_cap_w |
house | onsets/h | grid swing | energy bought | billed quarters (W) |
|---|---|---|---|---|---|
| 1500 | 1200 W | 28 → 1 | 3655.5 → 282.0 W | 180.0 → 285.2 Wh | 1500.0 / 1500.0 / 1500.0 |
| 3500 | 1800 W | 8 → 1 | 3672.5 → 1687.6 W | 1652.9 → 1616.1 Wh | 3500.0 / 3500.0 / 3500.0 |
| 3500 | 3000 W | 32 → 1 | 3798.0 → 474.7 W | 482.9 → 475.3 Wh | 3500.0 / 3500.0 / 3500.0 |
| 5000 | 1200 W | 1 → 1 | identical | identical | identical |
- One onset means the trade starts once and runs for the whole window. The battery is trading 100 % of the hour where it used to trade 43 %, 92 % and 54 %.
- Every billed quarter lands exactly on the cap and none over it, where every quarter in the three biting rows used to finish over.
- The money. At 1500/1200 the site buys 58 % MORE energy, because a continuous 300 W trade beats a 2000 W trade that is cancelled for 57 % of the hour. At the two shipped-default rows it buys 2.2 % and 1.6 % less — and in exchange the worst quarter drops from 59 W over the cap to 0 W over it. The capacity tariff bills the month's single worst quarter with no averaging and no forgiveness (specs.md:583), so that is not a trade-off, it is the ordering §10.1 asks for.
- The remaining 1687.6 W at 3500/1800 is the opening ramp, not a cycle: the pack goes from 0 to 1700 W of charge in about ten seconds and the meter with it. One onset, no export, no reversal.
- The last row is bit-identical before and after. This card costs nothing on the installs it is not about.
5. What this does NOT fix, with sizes
- TEST-23 finding 1 — the export overshoot at the END of a trade (356–938 W,
≈0.37 ×
max_w) is untouched and is a different card. It happens when the cheap interval ends and the grid-following loop takes back an actuator that was holding a charge; nothing here changes the handover. - A house whose load ALONE exceeds the cap (
load > peak_cap_w) still putswanted > 0, still engages the guard, and still refuses the trade outright through the interlock — correctly, since you cannot buy in a quarter you are already over on. The loop then drives to grid = 0 rather than to grid = ceiling, which is more discharge than the guard asked for. Not swept here: all four (cap, load) pairs on the record haveload < cap. arbitrage.claim()still files the SIZED number, but the ARBITER can clamp it further and the strategy is not told. The reason line names the guard's headroom, not the arbiter'sbound_by. Pre-existing; not widened.- The container leg was not re-run.
hadrive.pyneedstest23s3-addonstarted, and that container is deliberately stopped because it is a second writer on the setpoint entity under measurement. The in-process rig's twelve transcribed lines are pinned bytest_arbitrage.py3b's AST check onmain.py's call site, which is the line this card adds to them.