TEST-23 stage 3's closed rig, driven against 478ba93, metrics recomputed
rather than copied from the card. A charge onset is a cycle at which arbitrage.decide() transitions into ARBITRAGE_CHARGE — 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
1490.1 / 1496.5 / 1508.8
3500 (shipped)
1800 W
8
509.3 s
+127.5 … +3800.0
3511.3 / 3536.0 / 3559.1
3500 (shipped)
3000 W
32
114.8 s
+1202.0 … +5000.0
3518.9 / 3542.2 / 3556.7
5000
1200 W
1
—
+1240.6 … +3200.0
3169.5 / 3200.0 / 3200.0
The last row is the negative control: load + max_w = 3200 W never reaches the
cap. The bite rule is load + max_w > peak_cap_w, i.e. at the shipped peak_cap_w: 3500 / max_w: 2000, any house drawing over 1500 W in the
cheapest quarter. Every billed quarter in the three biting rows finished over the cap it was oscillating about. §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."
⚠️ The card's stated mechanism is wrong, and the correction is on the record
The card says the loop is "at each quarter boundary the projection resets to 0,
the guard disengages, and arbitrage re-arms at full power." It is not. Onset
timestamps, quarter boundaries at 900 / 1800 / 2700 s:
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. A fix aimed at boundary handling would have removed four of twenty-eight.
The real cycle, read off recorded fields at t = 1000…1180 s:
the trade charges at −2000 W, the meter sits at 3200 W against a 1500 W
ceiling, and wanted = batt_w + excess is negative — so decide() files nothing and arbitrage is not constrained at all;
wanted drifts up as the quarter's own history worsens and the 900 / remaining gain grows, crossing zero at floor_w = 5.3 W;
that 5 W floor flips arbitrage.guard_state(), which refuses the whole
trade — so 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, the guard releases, the trade re-arms at max_w.
115 s later, again.
A binary interlock keyed on a continuous quantity. The card's other figure —
projection 3092 W against a 1509 W ceiling, engaged=False, 170 s — reproduces,
but it is the first such window (first_engaged_t_s = 151.0); the
steady-state silent window is 40–65 s.
Where the fix went, and what it follows
control.maintenance_charge_floor() has capped a MAINTENANCE charge at "the
headroom left under the site's cap" since the loop was written, because "a
maintenance charge is the only thing this system does that is big enough and
long enough to set that peak". ⚠️That stopped being true when OPT-04 landed.
A trade commands max_w for a whole tariff interval, deliberately, against the
meter, and it was given no such cap.
peakguard.charge_headroom_w(decision) — new, pure, public. The watts the
open billed quarter can still afford, off the measured guard rather than
off the optional peak_forecast_entity (SAFETY-25's hole).
arbitrage.decide(..., charge_headroom_w=...) — keyword-only, no default.
Sizes the buy leg only (§9.1: discharge and export are never capped).
main.py passes the guard's own expression at its one call site.
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.app/peakguard.py is +139 / −0; 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, tools/replay_ac3.py
reproduces 190/190 guarded quarters, 110 engaged cycles, 5 blind and 878 Wh on
both trees, and test_peakguard.py part 6's replay grades what it always graded.
Two properties that make it safe to hand a strategy, both swept
Invariant in the battery. Raise the charge by d W and the meter rises by
d W, so excess rises by d W while batt_w falls by d W: wanted does not
move. Measured at 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. The fixed point is charge = ceiling − load, 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.
What was rejected
A negative lo (delete the wanted <= 0 gate, file lo = wanted
unconditionally). Prettier arithmetically, and rejected: main.py's
cheap-window maintenance charge files a negative hi, arbiter.py
clause 2 has no notion of one limit out-ranking another, so lo > hi is a contradiction → 0 W — the battery parked in the quarter that sets the bill.
Closing that means keying shaping_suspended() on "a claim exists", which
suspends the maintenance charge shaping permanently on every site this
card is about. It would also change what engaged means at five call sites.
A backoff, cooldown or hysteresis constant in arbitrage.py. Both
modules' design notes are explicit that no timer is invented. 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.
Both are recorded in charge_headroom_w()'s own docstring and in DECISIONS/SAFETY-45-sizing-the-trade.md §2.
After, same rig, same command
peak_cap_w / house
onsets/h
grid swing
energy bought
billed quarters
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 = the trade starts once and runs the whole window. The battery
trades 100 % of the cheap hour where it traded 43 %, 92 % and 54 %.
Every billed quarter lands on the cap and none over it.
The money. At the aggressive cap the site buys 58 % more energy — a
continuous 300 W trade beats a 2000 W trade cancelled for 57 % of the hour. At
the two shipped-default rows it buys 2.2 % and 1.6 % less, and the worst
quarter drops from 59 W over the cap to zero. The capacity tariff bills the
month's single worst quarter with no averaging and no forgiveness
(specs.md:583), so that is §10.1's ordering, not a trade-off against it.
The remaining 1687.6 W at 3500/1800 is the opening ramp (0 → 1700 W of
charge in ~10 s), not a cycle: one onset, no export, no reversal.
The last row is bit-identical. This costs nothing on installs it is not
about.
The regression is the loop settling, not a unit test on decide()
test_arbitrage.py3c closes the loop over the shipped peakguard.decide, arbitrage.decide, arbiter.resolve, control.compute and — importantly — p1.QuarterAverager, so the quarters it reports are the ones a meter would bill.
Modelled: one cycle's order (twelve lines transcribed from main.py, pinned by
3b's AST check on the call site) and the plant (800 W/s ramp into a 10 s lag, plantsim's). It asserts both directions from one changed argument:
plus a third pair asserting the card is inert where the defect is inert. The
headline numbers were not taken here — they were taken on the closed rig,
which is where a divergence from main.py's real event loop would show up.
Mutations — 14, all red, no survivors
Every run purged, python3 -B, tree mounted :ro, the mutated line grepped off
disk before the run, a unique backup per invocation, and the tree fingerprinted
after the revert.
drops max(0.0, …) → negative headroom on an engaged decision
−10 B
peakguard 13b sweep, 13f
M3
a guard that never ran answers 0.0
−1 B
peakguard 13e
M4
BLIND answers None instead of 0.0
+1 B
peakguard 13e ×2
M5
no battery measurement answers 0.0
−1 B
peakguard 13b, 13e ×2
M6
the cap removed: size_w = asked_w
−34 B
arbitrage 3b ×5, 3c (24 → 24 onsets)
M7
min() → max(): the headroom becomes a floor
+0 B
arbitrage AC3 + 3b ×5
M8
an unmeasured headroom read as unlimited
−246 B
arbitrage 3b
M9
no-headroom refused with the pack's sentence
−4 B
arbitrage 3b ×2
M10
the sized-trade sentence's two operands transposed
+0 B
arbitrage 3b ordered pair ×2
M11
capped = True — the clause always printed
−12 B
arbitrage 3b
M12
the call site re-arms at max_w
−12 B
arbitrage 3b AST pin
M13
2026-08-20 reverted: wanted <= 0.0 → ex <= 0.0
−4 B
8 named checks in peakguard (parts 1, 5, 6, 13b, 13g) + 3 in arbitrage 3c
M14
both zero-sizing rungs weakened together (TEST-16 masking)
−63 B
arbitrage 3b ×2, packshare part 9
Two are zero bytes — MUTATION.md's risky delta — and both are red.
⚠️Two defects were found by this discipline rather than by review.
In the battery's own harness. M14 edits one file twice; the backups were
restored in list order, so edit 1 went back on top of the original and the
tree was left mutated. The fingerprint after the revert caught it —
MUTATION.md habit 18, in a tool written the same afternoon habit 18 was read.
In test_peakguard.py part 1. The mutation it exists to catch (M13) makes floor_w None, and the print on the line after the check that goes red
formatted it with a bare :.0f → TypeError → the whole file aborted,
leaving parts 2–13 ungraded. M13's recorded coverage was one check; it is eight now. Same shape as MAINT-05's delinquent(...)['b'] subscript in test_delivery.py.
Not fixed, and named rather than implied
TEST-23 finding 1 — the export overshoot at the END of a trade (356–938 W,
≈ 0.37 × max_w). Different card; untouched.
A house whose load alone exceeds the cap.wanted > 0, the guard engages,
the interlock refuses the trade outright — correct — and the loop then drives
to grid = 0 rather than to the ceiling. No swept pair reaches that regime.
The container leg (hadrive.py) was not re-run.test23s3-addon is
deliberately stopped as a second writer on the setpoint entity under
measurement, and this session does not start it.
No option is added, removed or re-defaulted, and arbitrage remains off in
the shipped configuration.
Nothing was deployed. No contact with 192.168.2.158 or the inverter at
192.168.2.221. 29/29 green.
## The instability, re-measured here
TEST-23 stage 3's closed rig, driven against `478ba93`, metrics recomputed
rather than copied from the card. A **charge onset** is a cycle at which
`arbitrage.decide()` transitions into `ARBITRAGE_CHARGE` — 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** | 1490.1 / 1496.5 / **1508.8** |
| 3500 *(shipped)* | 1800 W | 8 | 509.3 s | +127.5 … +3800.0 | **3511.3 / 3536.0 / 3559.1** |
| 3500 *(shipped)* | 3000 W | 32 | 114.8 s | +1202.0 … +5000.0 | **3518.9 / 3542.2 / 3556.7** |
| 5000 | 1200 W | 1 | — | +1240.6 … +3200.0 | 3169.5 / 3200.0 / 3200.0 |
The last row is the negative control: `load + max_w = 3200 W` never reaches the
cap. The bite rule is `load + max_w > peak_cap_w`, i.e. at the shipped
`peak_cap_w: 3500` / `max_w: 2000`, **any house drawing over 1500 W in the
cheapest quarter**. Every billed quarter in the three biting rows finished
**over** the cap it was oscillating about. §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."*
## ⚠️ The card's stated mechanism is wrong, and the correction is on the record
The card says the loop is *"at each quarter boundary the projection resets to 0,
the guard disengages, and arbitrage re-arms at full power."* It is not. Onset
timestamps, quarter boundaries at 900 / 1800 / 2700 s:
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
**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. A fix aimed at boundary handling would have removed four of twenty-eight.
The real cycle, read off 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, and `wanted = batt_w + excess` is **negative** — 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, crossing zero at **`floor_w = 5.3 W`**;
3. that 5 W floor flips `arbitrage.guard_state()`, which refuses the **whole
trade** — so 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, the guard releases, the trade re-arms at **`max_w`**.
115 s later, again.
**A binary interlock keyed on a continuous quantity.** The card's other figure —
projection 3092 W against a 1509 W ceiling, `engaged=False`, 170 s — reproduces,
but it is the *first* such window (`first_engaged_t_s = 151.0`); the
steady-state silent window is 40–65 s.
## Where the fix went, and what it follows
`control.maintenance_charge_floor()` has capped a MAINTENANCE charge at *"the
headroom left under the site's cap"* since the loop was written, because *"a
maintenance charge is the only thing this system does that is big enough and
long enough to set that peak"*. ⚠️ **That stopped being true when OPT-04 landed.**
A trade commands `max_w` for a whole tariff interval, deliberately, against the
meter, and it was given no such cap.
* `peakguard.charge_headroom_w(decision)` — new, pure, public. The watts the
open billed quarter can still afford, off the **measured** guard rather than
off the optional `peak_forecast_entity` (SAFETY-25's hole).
* `arbitrage.decide(..., charge_headroom_w=...)` — keyword-only, no default.
Sizes the **buy leg only** (§9.1: discharge and export are never capped).
* `main.py` passes the guard's own expression at its one call site.
**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.** `app/peakguard.py` is **+139 / −0**;
`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, `tools/replay_ac3.py`
reproduces 190/190 guarded quarters, 110 engaged cycles, 5 blind and 878 Wh on
both trees, and `test_peakguard.py` part 6's replay grades what it always graded.
### Two properties that make it safe to hand a strategy, both swept
* **Invariant in the battery.** Raise the charge by d W and the meter rises by
d W, so `excess` rises by d W while `batt_w` falls by d W: `wanted` does not
move. Measured at **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. The fixed point is `charge = ceiling − load`, 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.
### What was rejected
* **A negative `lo`** (delete the `wanted <= 0` gate, file `lo = wanted`
unconditionally). Prettier arithmetically, and rejected: `main.py`'s
cheap-window maintenance charge files a **negative `hi`**, `arbiter.py`
clause 2 has no notion of one limit out-ranking another, so `lo > hi` is a
**contradiction → 0 W — the battery parked in the quarter that sets the bill**.
Closing that means keying `shaping_suspended()` on "a claim exists", which
suspends the maintenance charge shaping **permanently** on every site this
card is about. It would also change what `engaged` means at five call sites.
* **A backoff, cooldown or hysteresis constant in `arbitrage.py`.** Both
modules' design notes are explicit that no timer is invented. 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.
Both are recorded in `charge_headroom_w()`'s own docstring and in
`DECISIONS/SAFETY-45-sizing-the-trade.md` §2.
## After, same rig, same command
| `peak_cap_w` / house | onsets/h | grid swing | energy bought | billed quarters |
|---|---|---|---|---|
| 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** = the trade starts once and runs the whole window. The battery
trades 100 % of the cheap hour where it traded 43 %, 92 % and 54 %.
* **Every billed quarter lands on the cap and none over it.**
* **The money.** At the aggressive cap the site buys **58 % more** energy — a
continuous 300 W trade beats a 2000 W trade cancelled for 57 % of the hour. At
the two shipped-default rows it buys 2.2 % and 1.6 % less, and the worst
quarter drops from **59 W over the cap to zero**. The capacity tariff bills the
month's single worst quarter with no averaging and no forgiveness
(specs.md:583), so that is §10.1's ordering, not a trade-off against it.
* The remaining 1687.6 W at 3500/1800 is the opening **ramp** (0 → 1700 W of
charge in ~10 s), not a cycle: one onset, no export, no reversal.
* The last row is **bit-identical**. This costs nothing on installs it is not
about.
## The regression is the loop settling, not a unit test on `decide()`
`test_arbitrage.py` **3c** closes the loop over the shipped `peakguard.decide`,
`arbitrage.decide`, `arbiter.resolve`, `control.compute` and — importantly —
`p1.QuarterAverager`, so the quarters it reports are the ones a meter would bill.
Modelled: one cycle's *order* (twelve lines transcribed from `main.py`, pinned by
3b's AST check on the call site) and the plant (800 W/s ramp into a 10 s lag,
`plantsim`'s). It asserts **both directions** from one changed argument:
unsized: 24 charge onsets/h, grid -414 .. 3200 W, billed 1495.9/1513.5/1516.6
sized: 1 charge onset /h, grid 1200 .. 1503 W, billed 1500.0/1500.0/1500.0
plus a third pair asserting the card is **inert** where the defect is inert. The
headline numbers were **not** taken here — they were taken on the closed rig,
which is where a divergence from `main.py`'s real event loop would show up.
## Mutations — 14, all red, no survivors
Every run purged, `python3 -B`, tree mounted `:ro`, the mutated line grepped off
disk before the run, a unique backup per invocation, and **the tree fingerprinted
after the revert**.
| # | edit | Δ | red on |
|---|---|---|---|
| M1 | `charge_headroom_w` drops the SIGN | −1 B | peakguard 13a, 13a2; arbitrage 3c (the trade stops entirely — 0 onsets) |
| M2 | drops `max(0.0, …)` → negative headroom on an engaged decision | −10 B | peakguard 13b sweep, 13f |
| M3 | a guard that never ran answers `0.0` | −1 B | peakguard 13e |
| M4 | BLIND answers `None` instead of `0.0` | +1 B | peakguard 13e ×2 |
| M5 | no battery measurement answers `0.0` | −1 B | peakguard 13b, 13e ×2 |
| M6 | **the cap removed**: `size_w = asked_w` | −34 B | arbitrage 3b ×5, 3c (24 → 24 onsets) |
| M7 | `min()` → `max()`: the headroom becomes a floor | **+0 B** | arbitrage AC3 + 3b ×5 |
| M8 | an unmeasured headroom read as unlimited | −246 B | arbitrage 3b |
| M9 | no-headroom refused with the **pack's** sentence | −4 B | arbitrage 3b ×2 |
| M10 | the sized-trade sentence's two operands **transposed** | **+0 B** | arbitrage 3b ordered pair ×2 |
| M11 | `capped = True` — the clause always printed | −12 B | arbitrage 3b |
| M12 | **the call site** re-arms at `max_w` | −12 B | arbitrage 3b AST pin |
| M13 | **2026-08-20 reverted**: `wanted <= 0.0` → `ex <= 0.0` | −4 B | **8 named checks** in peakguard (parts 1, 5, 6, 13b, 13g) + 3 in arbitrage 3c |
| M14 | **both** zero-sizing rungs weakened together (TEST-16 masking) | −63 B | arbitrage 3b ×2, packshare part 9 |
Two are **zero bytes** — MUTATION.md's risky delta — and both are red.
⚠️ **Two defects were found by this discipline rather than by review.**
1. **In the battery's own harness.** M14 edits one file twice; the backups were
restored in list order, so edit 1 went back on top of the original and the
tree was left mutated. The **fingerprint after the revert** caught it —
MUTATION.md habit 18, in a tool written the same afternoon habit 18 was read.
2. **In `test_peakguard.py` part 1.** The mutation it exists to catch (M13) makes
`floor_w` None, and the `print` on the line *after* the check that goes red
formatted it with a bare `:.0f` → `TypeError` → **the whole file aborted**,
leaving parts 2–13 ungraded. M13's recorded coverage was **one** check; it is
**eight** now. Same shape as MAINT-05's `delinquent(...)['b']` subscript in
`test_delivery.py`.
## Not fixed, and named rather than implied
* **TEST-23 finding 1 — the export overshoot at the END of a trade** (356–938 W,
≈ 0.37 × `max_w`). Different card; untouched.
* **A house whose load alone exceeds the cap.** `wanted > 0`, the guard engages,
the interlock refuses the trade outright — correct — and the loop then drives
to grid = 0 rather than to the ceiling. No swept pair reaches that regime.
* **The container leg (`hadrive.py`) was not re-run.** `test23s3-addon` is
deliberately stopped as a second writer on the setpoint entity under
measurement, and this session does not start it.
* **No option is added, removed or re-defaulted**, and arbitrage remains off in
the shipped configuration.
Nothing was deployed. No contact with 192.168.2.158 or the inverter at
192.168.2.221. `29/29` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
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
admin
merged commit 8f82dd4e06 into release/1.02026-09-14 16:20:40 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The instability, re-measured here
TEST-23 stage 3's closed rig, driven against
478ba93, metrics recomputedrather than copied from the card. A charge onset is a cycle at which
arbitrage.decide()transitions intoARBITRAGE_CHARGE— the edge the rigalready logs.
peak_cap_wThe last row is the negative control:
load + max_w = 3200 Wnever reaches thecap. The bite rule is
load + max_w > peak_cap_w, i.e. at the shippedpeak_cap_w: 3500/max_w: 2000, any house drawing over 1500 W in thecheapest quarter. Every billed quarter in the three biting rows finished
over the cap it was oscillating about. §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."
⚠️ The card's stated mechanism is wrong, and the correction is on the record
The card says the loop is "at each quarter boundary the projection resets to 0,
the guard disengages, and arbitrage re-arms at full power." It is not. Onset
timestamps, quarter boundaries at 900 / 1800 / 2700 s:
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. A fix aimed at boundary handling would have removed four of twenty-eight.
The real cycle, read off recorded fields at t = 1000…1180 s:
−2000 W, the meter sits at 3200 W against a 1500 Wceiling, and
wanted = batt_w + excessis negative — sodecide()filesnothing and arbitrage is not constrained at all;
wanteddrifts up as the quarter's own history worsens and the900 / remaininggain grows, crossing zero atfloor_w = 5.3 W;arbitrage.guard_state(), which refuses the wholetrade — so the grid-following loop, which targets grid = 0 and not
grid = ceiling, takes the actuator and discharges to +1870 W, exporting 420 W;
max_w.115 s later, again.
A binary interlock keyed on a continuous quantity. The card's other figure —
projection 3092 W against a 1509 W ceiling,
engaged=False, 170 s — reproduces,but it is the first such window (
first_engaged_t_s = 151.0); thesteady-state silent window is 40–65 s.
Where the fix went, and what it follows
control.maintenance_charge_floor()has capped a MAINTENANCE charge at "theheadroom left under the site's cap" since the loop was written, because "a
maintenance charge is the only thing this system does that is big enough and
long enough to set that peak". ⚠️ That stopped being true when OPT-04 landed.
A trade commands
max_wfor a whole tariff interval, deliberately, against themeter, and it was given no such cap.
peakguard.charge_headroom_w(decision)— new, pure, public. The watts theopen billed quarter can still afford, off the measured guard rather than
off the optional
peak_forecast_entity(SAFETY-25's hole).arbitrage.decide(..., charge_headroom_w=...)— keyword-only, no default.Sizes the buy leg only (§9.1: discharge and export are never capped).
main.pypasses the guard's own expression at its one call site.No new arithmetic and no new constant. The number is
wanted_wread in theother direction:
decide()already computes the discharge that lands the quarteron the ceiling, and a negative one is a permitted charge of
−wanted.⚠️
peakguard.decide()IS UNCHANGED.app/peakguard.pyis +139 / −0;sha256(decide) = 200090d0835a3a55, 7252 B on this branch and on478ba93. So2026-08-20's anti-bang-bang rung is exactly where it was,
tools/replay_ac3.pyreproduces 190/190 guarded quarters, 110 engaged cycles, 5 blind and 878 Wh on
both trees, and
test_peakguard.pypart 6's replay grades what it always graded.Two properties that make it safe to hand a strategy, both swept
d W, so
excessrises by d W whilebatt_wfalls by d W:wanteddoes notmove. Measured at 1350.0 W at every point across
batt_w−5000 … +2000 Won one banked quarter, while the
excessthose runs see takes six distinctvalues. It is feed-forward, so consuming it adds no feedback path around
the plant. The fixed point is
charge = ceiling − load, a wholeceiling − loadclear of the engagement boundary.ENGAGED decision answers a positive headroom.
What was rejected
lo(delete thewanted <= 0gate, filelo = wantedunconditionally). Prettier arithmetically, and rejected:
main.py'scheap-window maintenance charge files a negative
hi,arbiter.pyclause 2 has no notion of one limit out-ranking another, so
lo > hiis acontradiction → 0 W — the battery parked in the quarter that sets the bill.
Closing that means keying
shaping_suspended()on "a claim exists", whichsuspends the maintenance charge shaping permanently on every site this
card is about. It would also change what
engagedmeans at five call sites.arbitrage.py. Bothmodules' design notes are explicit that no timer is invented. The trade now
re-arms at
ceiling − loadinstead of atmax_w, which is what "learningfrom the refusal" means when the refusal is a measurement.
Both are recorded in
charge_headroom_w()'s own docstring and inDECISIONS/SAFETY-45-sizing-the-trade.md§2.After, same rig, same command
peak_cap_w/ housetrades 100 % of the cheap hour where it traded 43 %, 92 % and 54 %.
continuous 300 W trade beats a 2000 W trade cancelled for 57 % of the hour. At
the two shipped-default rows it buys 2.2 % and 1.6 % less, and the worst
quarter drops from 59 W over the cap to zero. The capacity tariff bills the
month's single worst quarter with no averaging and no forgiveness
(specs.md:583), so that is §10.1's ordering, not a trade-off against it.
charge in ~10 s), not a cycle: one onset, no export, no reversal.
about.
The regression is the loop settling, not a unit test on
decide()test_arbitrage.py3c closes the loop over the shippedpeakguard.decide,arbitrage.decide,arbiter.resolve,control.computeand — importantly —p1.QuarterAverager, so the quarters it reports are the ones a meter would bill.Modelled: one cycle's order (twelve lines transcribed from
main.py, pinned by3b's AST check on the call site) and the plant (800 W/s ramp into a 10 s lag,
plantsim's). It asserts both directions from one changed argument:plus a third pair asserting the card is inert where the defect is inert. The
headline numbers were not taken here — they were taken on the closed rig,
which is where a divergence from
main.py's real event loop would show up.Mutations — 14, all red, no survivors
Every run purged,
python3 -B, tree mounted:ro, the mutated line grepped offdisk before the run, a unique backup per invocation, and the tree fingerprinted
after the revert.
charge_headroom_wdrops the SIGNmax(0.0, …)→ negative headroom on an engaged decision0.0Noneinstead of0.00.0size_w = asked_wmin()→max(): the headroom becomes a floorcapped = True— the clause always printedmax_wwanted <= 0.0→ex <= 0.0Two are zero bytes — MUTATION.md's risky delta — and both are red.
⚠️ Two defects were found by this discipline rather than by review.
restored in list order, so edit 1 went back on top of the original and the
tree was left mutated. The fingerprint after the revert caught it —
MUTATION.md habit 18, in a tool written the same afternoon habit 18 was read.
test_peakguard.pypart 1. The mutation it exists to catch (M13) makesfloor_wNone, and theprinton the line after the check that goes redformatted it with a bare
:.0f→TypeError→ the whole file aborted,leaving parts 2–13 ungraded. M13's recorded coverage was one check; it is
eight now. Same shape as MAINT-05's
delinquent(...)['b']subscript intest_delivery.py.Not fixed, and named rather than implied
≈ 0.37 ×
max_w). Different card; untouched.wanted > 0, the guard engages,the interlock refuses the trade outright — correct — and the loop then drives
to grid = 0 rather than to the ceiling. No swept pair reaches that regime.
hadrive.py) was not re-run.test23s3-addonisdeliberately stopped as a second writer on the setpoint entity under
measurement, and this session does not start it.
the shipped configuration.
Nothing was deployed. No contact with 192.168.2.158 or the inverter at
192.168.2.221.
29/29green.🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU