# 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.