SAFETY-45: a price-driven charge is sized by the capacity guard, not switched by it #88

Merged
admin merged 1 commits from SAFETY-45 into release/1.0 2026-09-14 16:20:40 +02:00
Owner

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.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## 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
admin added 1 commit 2026-09-14 16:10:49 +02:00
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.0 2026-09-14 16:20:40 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#88