Files
glenn schrooyenandClaude Opus 5 e1b9254626
checks / unit (pull_request) Successful in 7m42s
SAFETY-45: a price-driven charge is SIZED by the capacity guard, not switched by it
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
2026-09-14 16:10:34 +02:00

13 KiB
Raw Permalink Blame History

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:

  1. the trade charges at −2000 W; the meter sits at 3200 W against a 1500 W ceiling; wanted = batt_w + excess is negative (−132 → −8 W), so decide() files nothing and arbitrage is not constrained at all;
  2. wanted drifts up as the quarter's own history worsens and the 900 / remaining gain grows, and crosses zero at t = 1055 with floor_w = 5.3 W;
  3. that 5 W floor flips arbitrage.guard_state() to True, 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;
  4. the projection collapses, wanted goes negative, the guard releases, and arbitrage re-arms at max_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:

  1. ⚠️ IT CREATES A NEW lo > hi CONTRADICTION CLASS AGAINST THE MAINTENANCE SHAPING. main.py's cheap-window maintenance charge files Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor) — a NEGATIVE hi. arbiter.py clause 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 peak lo = −300 W against a shaping hi = −1000 W is lo > hi, which resolves to 0 W — the battery parked, in the one quarter of the month that sets the bill. That is the exact failure peakguard.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.
  2. It changes the meaning of PeakDecision.engaged at five call sites outside this module — arbitrage.guard_state(), shaping_suspended(), claim(), main.py's edge-logged action line and main.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 excess rises by exactly dW while batt_w falls by dW: wanted does not move. Measured across batt_w from −5000 W to +2000 W on one banked quarter — the headroom is 1350.0 W at every one of the six points while the excess those 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 is charge = ceiling − load, with the meter ON the ceiling and wanted parked a whole ceiling − load clear 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 lo is 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 puts wanted > 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 have load < 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's bound_by. Pre-existing; not widened.
  • The container leg was not re-run. hadrive.py needs test23s3-addon started, 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 by test_arbitrage.py 3b's AST check on main.py's call site, which is the line this card adds to them.