diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index c1da1b6..0ce550c 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -27,6 +27,7 @@ if it has, it needs 0.10.0. Whoever deploys decides. | _this branch_ | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest | | _this branch_ | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** | | _this branch_ | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero | +| _this branch_ | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** | ⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and @@ -62,6 +63,80 @@ separate card. *(Annotated 2026-09-13: that card is **TARIFF-15**, below, and it shipped on this branch as `custom_formula`. It is still not an alias — the operator types their own coefficients; nothing is invented on their behalf.)* +### SAFETY-45 - the trade is sized by the capacity guard, not switched by it + +**A price-driven charge now asks the peak guard HOW HARD it may pull, instead of +only whether it may pull at all.** On any site where `load + max_w` reaches +`peak_cap_w` - at the shipped `peak_cap_w: 3500` and `max_w: 2000`, any house +drawing over 1500 W in the cheapest quarter - arbitrage and the capacity-tariff +guard were driving each other in a sustained limit cycle. Measured on TEST-23 +stage 3's closed rig at `peak_cap_w: 1500` against a 1200 W house: **28 charge +onsets in one hour, mean period 131 s, the grid swinging −456 to +3200 W**, and +every billed quarter in the three affected configurations finishing **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."* + +**What was happening.** The guard computes `wanted = batt_w + excess` - the +battery discharge that would land the quarter exactly on the ceiling - and files +a claim only while that number is positive. While a trade charges at `max_w` +the number is negative for the first minute or so of every cycle, so the guard +files **nothing** and the trade is not constrained at all. `wanted` then crosses +zero, the guard engages on a floor of a few watts, and that flips +`arbitrage.guard_state()` - which cancels **the whole trade**. The +grid-following loop takes the actuator, drives the meter to zero and past it, +the projection collapses, the guard releases, and the trade comes back at +`max_w`. 115 s later, again. + +**The fix, and it is a shape this add-on already had.** +`control.maintenance_charge_floor()` has always capped 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 stopped being true when OPT-04 landed: a trade commands `max_w` +for a whole tariff interval, deliberately, against the meter. +`peakguard.charge_headroom_w()` publishes the same quantity off the **measured** +guard rather than off the optional `peak_forecast_entity`, and +`arbitrage.decide()` sizes the buy leg to it. + +**No new arithmetic and no new constant.** The number is `wanted_w` read in the +other direction: a negative `wanted` is not "nothing to say", it is a permitted +charge of `−wanted`. `peakguard.decide()` is **unchanged** - byte-identical to +`release/1.0`, `sha256 = 200090d0835a3a55` - so 2026-08-20's anti-bang-bang rung +is exactly where it was, and `tools/replay_ac3.py` reproduces the same 190/190 +guarded quarters, 110 engaged cycles and 878 Wh on this branch as on the tip. + +**After, on the same rig, same command:** + +| `peak_cap_w` / house | onsets per hour | grid swing | energy bought | billed quarters | +|---|---|---|---|---| +| 1500 / 1200 W | 28 → **1** | 3656 → **282 W** | 180 → **285 Wh** | 1490/1497/**1509** → **1500/1500/1500** | +| 3500 / 1800 W | 8 → **1** | 3673 → 1688 W | 1653 → 1616 Wh | **3511/3536/3559** → **3500/3500/3500** | +| 3500 / 3000 W | 32 → **1** | 3798 → **475 W** | 483 → 475 Wh | **3519/3542/3557** → **3500/3500/3500** | +| 5000 / 1200 W | 1 → 1 | *identical* | *identical* | *identical* | + +One onset means the trade starts once and runs for the whole window - the +battery is now trading 100 % of the cheap hour where it traded 43 %, 92 % and +54 %. **Every billed quarter now lands on the cap and none over it.** At the +aggressive cap the site buys 58 % MORE energy, because a continuous 300 W trade +beats a 2000 W trade that is cancelled for well over half the hour; at the two +shipped-default rows it buys about 2 % less and the worst quarter drops from +59 W over the cap to zero. The last row is the negative control - `load + max_w` +never reaches the cap there, and the two runs are bit-identical. **This costs +nothing on the installs it is not about.** + +⚠️ **NOT A CONFIG CHANGE.** No option is added, removed or re-defaulted, and +arbitrage remains off in the shipped configuration. A site that has not armed +`arbitrage_enabled` is unaffected in every respect. + +⚠️ **WHAT IS NOT FIXED, AND IT IS NAMED RATHER THAN IMPLIED.** TEST-23 finding 1 +- the export overshoot at the END of any trade, 356-938 W, about 0.37 × `max_w` +- is untouched and is a different card. A house whose load ALONE exceeds +`peak_cap_w` still has the guard engage and the trade refused outright, which is +correct, and the grid-following loop then drives to grid = 0 rather than to the +ceiling. See `DECISIONS/SAFETY-45-sizing-the-trade.md` §5, and §2 there for the +negative-`lo` design that was rejected because it creates a new `lo > hi` +contradiction against the maintenance charge shaping. + ### TARIFF-16 - the injection leg stops being one number **`price_export_formula`: what a site is PAID for export, as `factor × index + diff --git a/goodwe_controller/DECISIONS/SAFETY-45-sizing-the-trade.md b/goodwe_controller/DECISIONS/SAFETY-45-sizing-the-trade.md new file mode 100644 index 0000000..72899ca --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-45-sizing-the-trade.md @@ -0,0 +1,221 @@ +# 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. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 28ac313..d20779a 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -979,6 +979,7 @@ What exists today, and what does not: |---|---| | `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude | | Maintenance charging capped under `peak_cap_w` | ✅ | +| **Arbitrage charging capped under `peak_cap_w`** | ✅ — SAFETY-45, sized to the guard's own MEASURED headroom rather than to `peak_forecast_entity` | | **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` | | Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** | | Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen | @@ -1041,6 +1042,19 @@ projected over — raises one notification (`goodwe_peak_saturated`), because th is the one state where the money will be spent and shedding load is the only thing left that changes the bill. +**And it sizes a price-driven charge, not only a forced discharge (SAFETY-45).** +The same arithmetic read the other way answers *"how hard may this site charge +without putting the quarter over"* — `peakguard.charge_headroom_w()` — and +`arbitrage.decide()` sizes its buy leg to it. Before that, a trade commanded +`max_w` until the projection rose far enough to engage the guard, at which point +the interlock cancelled the trade outright, the loop took the actuator, the +projection collapsed and the trade came back at `max_w`: **28 charge onsets in +one hour** on TEST-23 stage 3's closed rig, with the grid swinging −456 to ++3200 W and every billed quarter finishing over the cap. It is one onset now, +the trade runs continuously at the affordable rate, and the billed quarters land +**on** the cap. ⚠️ The guard is what changed *around* arbitrage; `decide()` +itself is untouched, so nothing above this line is different. + **No new option for the CEILING.** It is `peak_cap_w`, which already shipped. The coverage threshold is `meter_max_age_s`, the freeze predicate is `meter_freeze_s`, and the achievable discharge comes from `max_w` and the device diff --git a/goodwe_controller/app/arbitrage.py b/goodwe_controller/app/arbitrage.py index cb11a58..e5a367a 100644 --- a/goodwe_controller/app/arbitrage.py +++ b/goodwe_controller/app/arbitrage.py @@ -184,6 +184,17 @@ IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour" IDLE_NO_SIZE = ( "no pack's state of charge permits this side of the trade, so it is sized " "at 0 W - and a 0 W arbitrage claim is a park order, not a trade") +# SAFETY-45. THE BUY LEG'S SIZE, AND ONLY THE BUY LEG'S - §9.1: "Discharge and +# export are never capped by the peak guard; only charging and import are." +IDLE_NO_HEADROOM = ( + "the capacity-tariff guard has measured this billed quarter and it can " + "afford no charging at all, so a trade of any size would push it over the " + "ceiling - and a 0 W arbitrage claim is a park order, not a trade") +IDLE_HEADROOM_UNMEASURED = ( + "the capacity-tariff guard did not size this quarter's charging headroom " + "this cycle, and an UNMEASURED headroom is not an unlimited one - charging " + "creates the billed quantity (§9.1) and nothing is measuring the quarter " + "this one would land in") NEGATIVE_PRICE = ( "§17: the sell hour is priced BELOW ZERO, so discharging into it would give " "energy away and pay for the privilege. Arbitrage must not export at a " @@ -303,7 +314,7 @@ def _point_at(plan, when: datetime): return None -def decide(plan, now: datetime, *, peak_guard_active, soc, +def decide(plan, now: datetime, *, peak_guard_active, charge_headroom_w, soc, min_spread_eur_kwh, uncertainty, charge_w, discharge_w, soc_floor_pct, soc_ceiling_pct) -> ArbitrageDecision: """Should the battery trade this cycle, and how hard. @@ -314,6 +325,18 @@ def decide(plan, now: datetime, *, peak_guard_active, soc, None refuses, because the alternative is a strategy that creates the billed quantity while the thing that would have limited it is switched off. + ⚠️ `charge_headroom_w` IS SAFETY-45's SECOND, CONTINUOUS ANSWER FROM THE + SAME GUARD, AND IT IS NOT A DUPLICATE OF THE FIRST. `peak_guard_active` is a + SWITCH - it says whether the guard is holding - and a switch keyed on a + continuous quantity is what made the loop oscillate: the guard engaged at a + 5 W floor, this module refused the whole trade, the loop took the actuator, + the meter fell, the guard released and the trade re-armed AT FULL POWER + 130 s later. 28 times an hour, measured. `peakguard.charge_headroom_w()` is + the watts the billed quarter can still afford; sizing the buy leg to it means + the trade is CLAMPED rather than switched, which is the same shape + `control.maintenance_charge_floor()` has always given a maintenance charge. + ⚠️ None IS NOT UNLIMITED here either - see IDLE_HEADROOM_UNMEASURED. + ⚠️ EVERY OTHER ARGUMENT IS KEYWORD-ONLY AND HAS NO DEFAULT, for `TariffPlan.arbitrage()`'s reason. A defaulted `soc_floor_pct` is a reserve floor this module asserted rather than one the operator configured, and the @@ -330,10 +353,12 @@ def decide(plan, now: datetime, *, peak_guard_active, soc, return verdict.match( refused=lambda v: _idle(v.reason), allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w, - soc_floor_pct, soc_ceiling_pct)) + soc_floor_pct, soc_ceiling_pct, + charge_headroom_w)) -def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct): +def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct, + headroom_w): """The permitted case: which hour is this, and may the pack do it.""" here = _point_at(plan, now) if here is None: @@ -403,13 +428,44 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct): f"nowhere to put the cheap energy", **common) if not abs(float(charge_w)) > 0.0: return _idle(IDLE_NO_SIZE, **common) + # SAFETY-45. THE BILLED QUARTER IS ASKED LAST, AND THE PLACEMENT IS + # DELIBERATE: every refusal that existed before this card keeps the + # precedence it had, so no sentence an operator already reads changes when + # it fires, and the two added here are placed where they cannot displace + # one. The pack's reasons come first because they are properties of the + # hardware in the cupboard; this one is a property of the fifteen minutes + # the trade would land in. + if headroom_w is None: + # ⚠️ NOT "the guard is clear". This is `guard_state()`'s None argument + # one layer down: a headroom nothing measured is not an unlimited one, + # and charging at max_w creates the billed quantity (§9.1). + return _idle(IDLE_HEADROOM_UNMEASURED, **common) + asked_w = abs(float(charge_w)) + size_w = min(asked_w, max(0.0, float(headroom_w))) + if not size_w > 0.0: + # ⚠️ NOT IDLE_NO_SIZE, WHICH IS THE LINE ABOVE AND BLAMES THE PACKS. + # `asked_w > 0` is already established here, so the pack may be empty + # and willing and it is the billed quarter that has run out. A refusal + # naming the wrong subsystem sends an operator to the wrong page + # (§10.2), and these two are a whole quarter-hour apart in what they + # ask a human to do about them. + return _idle(IDLE_NO_HEADROOM, **common) + # ⚠️ THE SENTENCE CARRIES BOTH NUMBERS IN THEIR OWN SLOTS, not one figure + # twice. MUTATION.md habit 14: two values in one sentence can be transposed + # by a ZERO-BYTE edit that no byte delta and no shape pin can see, so the + # trade's own size and the guard's headroom are each named by the literal + # text around them. + capped = size_w < asked_w return ArbitrageDecision( - action=CHARGE, power_w=-abs(float(charge_w)), + action=CHARGE, power_w=-size_w, reason=(f"{CHARGE}: this is the cheapest firm hour at " f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest " f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle " - f"had to cover; charging {abs(float(charge_w)):.0f} W from " - f"{soc:g} %"), + f"had to cover; charging {size_w:.0f} W from {soc:g} %" + + (f" - SIZED BY THE CAPACITY-TARIFF GUARD: the trade asked for " + f"{asked_w:.0f} W and this billed quarter can afford " + f"{float(headroom_w):.0f} W (§9.1, §10.1 priority 2)" + if capped else "")), spread_eur_kwh=v.spread_eur_kwh, required_eur_kwh=v.required_spread_eur_kwh, price_now_eur_kwh=price) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index ce8e7ca..13b183b 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -2726,6 +2726,22 @@ class Controller: self.arb = arbitrage.decide( self.fresh_tariff_plan(now), now, peak_guard_active=arbitrage.guard_state(self.peak_guard), + # SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS + # ONE. `guard_state()` is a switch and it was the whole loop: + # the guard engaged on a 5 W floor, the trade was refused + # outright, the loop took the actuator, the meter fell, the + # guard released and the trade came back AT max_w. Measured on + # TEST-23 stage 3 at the shipped peak_cap_w/max_w: 28 charge + # onsets an hour, grid -456 <-> +3200 W. This is the watts the + # billed quarter can still afford, so the buy leg is CLAMPED + # instead - the shape control.maintenance_charge_floor() has + # always given a maintenance charge, off the MEASURED guard + # rather than off the optional peak_forecast_entity. + # ⚠️ SAME ORDERING ARGUMENT AS THE LINE ABOVE: this reads the + # peak block's conclusion, ~50 lines up, so it must stay after + # it. Evaluated earlier it would size this quarter's trade from + # the previous cycle's quarter. + charge_headroom_w=peakguard.charge_headroom_w(self.peak_guard), # The emptiest pack again, and for the sell side that is the # right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there # is one `soc` argument - `pack_floor_soc` names that residual diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index 524c4df..2fed3f8 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -822,6 +822,145 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor, f"only give {ceil_top:.0f} W" if saturated else ""))) +def charge_headroom_w(decision) -> float | None: + """SAFETY-45. The hardest this SITE may charge without putting the billed + quarter over the ceiling, in W, positive - or None when nothing measured it. + + THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason + `discharge_permitted()`, `shaping_suspended()`, `guard_state()` and + `looks_frozen()` are: the check that grades it has to assert on THE SHIPPED + EXPRESSION, and a predicate transcribed into its own test is one copy plus a + future disagreement about what the check means. + + ------------------------------------------------------------------------ + THE DEFECT IT PREVENTS, AND IT IS NOT A NEW IDEA IN THIS REPOSITORY + + `control.maintenance_charge_floor()` already caps MAINTENANCE charging at + "the headroom left under the site's cap", and its docstring says why in + words this card only has to widen: + + "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. Measured on TEST-23 stage 3's closed rig at the shipped + `peak_cap_w: 3500` / `max_w: 2000`: 28 charge onsets in one hour, mean period + 131 s, grid swinging -456 <-> +3200 W, on any house drawing 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 IS THE **MEASURED** GUARD, NOT `peak_forecast_entity`. + `maintenance_charge_floor()` sizes itself from the forecast sensor, which is + OPTIONAL and empty on a default install - the exact hole SAFETY-25 had to + close in `shaping_suspended()`, where a suspension gated on the forecast + alone could not fire on the very site that needed it. This number comes off + `decide()`, which runs wherever P1 does. + + ------------------------------------------------------------------------ + WHERE THE NUMBER COMES FROM: IT IS `wanted_w`, READ IN THE OTHER DIRECTION + + `decide()` already computes + + wanted = batt_w + excess = (the offtake there would be with the battery + at zero) - (the level this quarter can still afford) + + as the battery DISCHARGE that would land the quarter exactly on the ceiling. + A negative `wanted` is not "nothing to say" - it is a permitted CHARGE of + `-wanted` watts, and throwing it away is SAFETY-45's whole defect. So this + function invents no arithmetic; it reads the sign the guard already had. + + ⚠️ AND THE QUANTITY IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE + TO IT CANNOT OSCILLATE. 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. It is a FEED-FORWARD number - a function of the house, the ceiling and + the seconds already banked - so consuming it adds no feedback path around the + plant. The fixed point is `charge = ceiling - load`, with the meter sitting + ON the ceiling and `wanted` parked at `load - ceiling`, a whole `ceiling - + load` clear of the engagement boundary rather than chattering across it. + + ⚠️ AND A QUARTER BOUNDARY DOES NOT HAND IT BACK AT FULL POWER, WHICH IS THE + OTHER HALF OF SAFETY-45 AND IS NOT THE SAME CLAIM AS "IT DOES NOT CHANGE". + It DOES change - it is `(900 / remaining) * (ceiling - projected)`, so it is + largest in the last seconds of a block and collapses at the boundary. What + matters is WHAT IT COLLAPSES TO: at `elapsed = 0` the projection IS the held + level, so the answer is `ceiling - (load - pv)` - the house and the cap, and + nothing else. A trade refused at 14:59 therefore resumes at 15:00 at the rate + the new quarter can afford, not at `max_w`, and §10.1's answer survives the + boundary because the arithmetic does: no timer, no backoff constant, no state + carried between calls. + + ⚠️ AND THE AMPLIFICATION IS BOUNDED IN ITS EFFECT, NOT MERELY LARGE. Hold a + charge of exactly this size for the rest of the block and the block's average + lands on `ceiling` EXACTLY, wherever in the block it started: + + (acc + (held + headroom) * remaining) / 900 + = projected + (900/remaining) * (ceiling - projected) * remaining/900 + = ceiling + + so 162 kW of headroom with 5 s left and 900 W of it with the whole quarter to + go are the same statement about the bill. `test_peakguard.py` part 13 sweeps + that identity across the block rather than arguing it here. + + ------------------------------------------------------------------------ + THE THREE ANSWERS + + `None` nothing measured it: the guard never ran (`meter_source: off`), or + it ran with no battery reading to size a floor from, or the block + had no seconds left to influence. ⚠️ None IS NOT UNLIMITED. A caller + that treats it as "charge away" is `arbitrage.guard_state()`'s + collapse of None into False, one layer down - a strategy creating + the billed quantity while nothing measures the quarter it lands in. + `0.0` measured, and the answer is no charging: the guard is BLIND (its + claim already says so at `floor 0 W` and this agrees with it rather + than restating it), or it is holding and `wanted >= 0`. + `> 0` the watts a charge may pull this cycle. + + ⚠️ HOLDING IMPLIES ZERO, BY CONSTRUCTION AND NOT BY COINCIDENCE. Every state + that files a claim with `engaged=True` has `wanted > 0`, `wanted is None` or + `blind`, so this answers 0.0 or None there and can never hand a strategy + permission the guard's own `lo` is at that instant refusing. + + ------------------------------------------------------------------------ + ⚠️ WHY THIS IS A SIZING AND NOT A CLAIM, AND DO NOT "SIMPLIFY" IT INTO ONE + + The obvious tidier answer is to delete `decide()`'s `wanted <= 0` gate and + let it file `lo = wanted` unconditionally - a NEGATIVE floor, meaning "you + may charge, but no harder than this". The arithmetic is identical, it is + continuous, and it would bind every claimant rather than only arbitrage. It + was measured, considered and REJECTED, and the reason is not style: + + * `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`: a CONTRADICTION, which resolves to 0 W - the battery PARKED, + in the one quarter of the month that sets the bill. That is the exact + failure `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 suspends the maintenance charge-only + shaping PERMANENTLY. + * and it would change what `engaged` MEANS 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 reads it as "the guard is HOLDING". + + `control.maintenance_charge_floor()` is a sizing and not a limit for the same + reason, which is why this is the shape that already fits. + DECISIONS/SAFETY-45-sizing-the-trade.md §2b carries the full argument. + """ + if decision is None: + return None + if decision.blind: + return 0.0 + if decision.wanted_w is None: + return None + return max(0.0, -float(decision.wanted_w)) + + def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool: """Must the maintenance charge-only shaping stand down this cycle? diff --git a/goodwe_controller/test_arbitrage.py b/goodwe_controller/test_arbitrage.py index d2a8a7c..5b40b25 100644 --- a/goodwe_controller/test_arbitrage.py +++ b/goodwe_controller/test_arbitrage.py @@ -73,6 +73,7 @@ Read MUTATION.md before you report a survivor. import ast import inspect +import math import os import sys from datetime import datetime, timedelta, timezone @@ -84,6 +85,11 @@ from app.arbiter import ( P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve, ) +# SAFETY-45 section 3c. The shipped loop, the shipped billed-quarter +# accumulator and the shipped projection - nothing here is transcribed. +from app.control import Tuning, compute +from app.p1 import QUARTER_S, QuarterAverager, make_sample, split_signed +from app.tariff import PeakTracker from app.arbitrage import ( CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds, threshold_eur_kwh, @@ -203,10 +209,19 @@ def hour(i, *, start=D0): return start + timedelta(hours=i, minutes=30) +# SAFETY-45. THE HEADROOM EVERY CHECK THAT IS NOT ABOUT THE HEADROOM USES, and +# it is deliberately NOT binding: `min(MAX_W, this)` is MAX_W, so every existing +# check in this file grades what it always graded and the new rung is decoration +# in all of them. Section 3b is where it is made to bind, and 3b asserts the +# precondition that it does (MUTATION.md habit 1) rather than hoping. +HEADROOM_CLEAR = MAX_W * 10.0 + + def act(plan, i, *, peak=False, soc=50.0, thresh=0.0, unc=frozenset(), - floor=20.0, ceiling=95.0, start=D0): + floor=20.0, ceiling=95.0, start=D0, headroom=HEADROOM_CLEAR): """`decide()` with the fixture's constants filled in. One call shape.""" return decide(plan, hour(i, start=start), peak_guard_active=peak, soc=soc, + charge_headroom_w=headroom, min_spread_eur_kwh=thresh, uncertainty=unc, charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=floor, soc_ceiling_pct=ceiling) @@ -721,6 +736,7 @@ check("a peak floor does NOT cap an arbitrage DISCHARGE - §9.1 bills offtake " # MECHANISM 3, which is neither of the above: the guard that never ran. _blind = decide(PLAN, hour(CHEAP_H), peak_guard_active=None, soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER, + charge_headroom_w=HEADROOM_CLEAR, charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0) check("a peak guard that NEVER RAN is a third state, and it refuses: `None` is " @@ -750,6 +766,323 @@ check("...and cycle() calls it rather than inlining the conjunction, so a " encoding="utf-8").read(), "cycle"))) +# --------------------------------------------------------------------------- # +print() +print("3b. SAFETY-45 - the buy leg is SIZED by the guard, not switched by it") +# --------------------------------------------------------------------------- +# AC3 above is the SWITCH: `peak_guard_active` refuses the whole trade while +# the guard holds. That is right and it stays. What was missing is the other +# half - the guard measures a CONTINUOUS number, "the watts this billed quarter +# can still afford", and until this card nothing consumed it. So a trade sat at +# max_w through the whole window in which the guard was silent, blew the +# quarter, tripped the switch, was cancelled outright, and came back at max_w +# the moment the meter fell far enough to release it. Measured on TEST-23 +# stage 3's closed rig at peak_cap_w 1500 / max_w 2000 / a 1200 W house: +# 28 charge onsets in one hour, mean period 131 s, grid -456 <-> +3200 W. +# +# `control.maintenance_charge_floor()` has always given a MAINTENANCE charge +# exactly this shape - "capped by the headroom left under the site's cap" - and +# its own docstring says why: "a maintenance charge is the only thing this +# system does that is big enough and long enough to set that peak". OPT-04 is +# the second such thing and was given no cap. This section grades the cap. +# --------------------------------------------------------------------------- + +# ⚠️ THE PRECONDITION, AND IT IS THE HABIT-1 ONE. The headroom must be strictly +# between 0 and the size the trade would otherwise ask for, or "sized by the +# guard" is indistinguishable from "not sized at all" and from "refused". +S45_HEADROOM = 300.0 +check(f"PRECONDITION: the headroom under test ({S45_HEADROOM:.0f} W) is " + f"strictly inside the trade's own sizing ({MAX_W:.0f} W) and strictly " + f"positive, so a cap that did nothing and a cap that refused everything " + f"are both distinguishable from the answer below", + 0.0 < S45_HEADROOM < MAX_W) +_s45_capped = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, + headroom=S45_HEADROOM) +_s45_clear = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER) +print(f" headroom {S45_HEADROOM:.0f} W -> {_s45_capped.power_w:+.0f} W; " + f"headroom clear -> {_s45_clear.power_w:+.0f} W") +print(f" {_s45_capped.reason}") +check("the trade still HAPPENS - §10.1 ranks peak shaving above arbitrage, it " + "does not abolish it - and it is sized to the watts the billed quarter " + "can afford rather than to max_w", + _s45_capped.action == CHARGE + and near(_s45_capped.power_w, -S45_HEADROOM)) +check("...and the claim the arbiter receives carries the SIZED number, so the " + "cap reaches the actuator and is not merely printed", + near(claim(_s45_capped).value, -S45_HEADROOM) + and claim(_s45_capped).priority == P_ARBITRAGE) +check("...and the SAME call with the headroom clear asks for max_w, so the " + "check above is graded against a difference this fixture can actually " + "produce (habit 7: one thing varied, from an accepted baseline)", + _s45_clear.action == CHARGE and near(_s45_clear.power_w, -MAX_W)) + +# ⚠️ THE ORDERED PAIR (MUTATION.md habit 14). Two numbers in one sentence can +# always be transposed by a ZERO-BYTE edit, and this sentence is what an +# operator reads when the battery buys less than the price says it should. So +# the check asserts each value inside the literal text that names it, and +# asserts that the TRANSPOSED sentence is not what it says. +_S45_PAIR = (f"the trade asked for {MAX_W:.0f} W and this billed quarter can " + f"afford {S45_HEADROOM:.0f} W") +_S45_SWAP = (f"the trade asked for {S45_HEADROOM:.0f} W and this billed " + f"quarter can afford {MAX_W:.0f} W") +check("the reason names WHAT WAS ASKED FOR and WHAT WAS AFFORDABLE in their " + "own slots, so an operator can tell a small trade from a small " + "opportunity", _S45_PAIR in _s45_capped.reason) +check("...and the transposed sentence is NOT what it says, so the check above " + "discriminates rather than decorates - both numbers are in the string " + "either way and a substring test would pass under the swap", + _S45_SWAP not in _s45_capped.reason) +check("...and it names the guard and the two spec sections, so the sentence " + "cannot be read as a pack limit or a price limit", + "CAPACITY-TARIFF GUARD" in _s45_capped.reason + and "§9.1" in _s45_capped.reason and "§10.1" in _s45_capped.reason) +check("...and an UNCAPPED trade does not carry that clause at all: a sentence " + "that always says 'sized by the guard' tells nobody anything", + "CAPACITY-TARIFF GUARD" not in _s45_clear.reason + and f"charging {MAX_W:.0f} W" in _s45_clear.reason) + +# --- the two refusals, and they are told apart from the ones that existed --- +_s45_none = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=None) +_s45_zero = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0) +print(f" headroom None -> {_s45_none.action}; headroom 0 W -> " + f"{_s45_zero.action}") +check("a headroom nothing MEASURED refuses. This is guard_state()'s None " + "argument one layer down: charging creates the billed quantity (§9.1) " + "and an unmeasured quarter is not a clear one", + not _s45_none.trading + and _s45_none.reason == _arb.IDLE_HEADROOM_UNMEASURED) +check("...and it is DISTINGUISHABLE from the guard never having run at all, so " + "the status page does not report a missing battery sensor as a missing " + "meter", _s45_none.reason != _arb.IDLE_NOT_COVERED) +check("a headroom of ZERO refuses rather than filing a 0 W claim - " + "`Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the " + "loop, which is the failure claim()'s own note is written against", + not _s45_zero.trading and claim(_s45_zero) is None + and _s45_zero.reason == _arb.IDLE_NO_HEADROOM) +# ⚠️ TEST-16's RULE: TWO RUNGS THAT CAN ANSWER THE SAME STIMULUS MUST BE +# SEPARATED. `charge_w = 0` and `headroom = 0` both produce a 0 W sizing, and +# grading only `kind == "refused"` would let either absorb the other's work - +# after which weakening either one reports nothing. So the REASONS are graded, +# and each rung is reached with the other one clear. +_s45_nosize = decide(PLAN, hour(CHEAP_H), peak_guard_active=False, soc=50.0, + charge_headroom_w=HEADROOM_CLEAR, + min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER, + charge_w=0.0, discharge_w=MAX_W, + soc_floor_pct=20.0, soc_ceiling_pct=95.0) +check("...and the PACK being sized at 0 W still reports OPT-09's own sentence, " + "not this card's: the two rungs answer the same stimulus and a refusal " + "that named the wrong subsystem would send an operator to the wrong page", + not _s45_nosize.trading + and _s45_nosize.reason == _arb.IDLE_NO_SIZE + and _s45_zero.reason != _s45_nosize.reason) + +# --- §9.1: the SELL leg is untouched --------------------------------------- +_s45_sell = act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0) +check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export " + "are never capped by the peak guard; only charging and import are' - the " + "capacity tariff bills offtake, and a sell creates none", + _s45_sell.action == DISCHARGE and near(_s45_sell.power_w, MAX_W)) + +# --- and main.py actually passes it, pinned on the AST ---------------------- +# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (MUTATION.md habit 15). Nothing +# behavioural in this file can see main.py drop the argument - every check +# above calls decide() itself - and a required keyword-only parameter that no +# caller fills is a TypeError only on the one path a suite does not run. Same +# pin, same reason, as `weights=` in test_packshare.py part 10. +_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read() +_s45_calls = [n for n in ast.walk(ast.parse(_S45_MAIN)) + if isinstance(n, ast.Call) + and ast.unparse(n.func).endswith("arbitrage.decide")] +_s45_arg = [ast.unparse(k.value) for c in _s45_calls for k in c.keywords + if k.arg == "charge_headroom_w"] +print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); " + f"charge_headroom_w= is {_s45_arg}") +check("main.py passes the guard's OWN measured headroom at its one " + "arbitrage.decide() call site - the shipped expression, not a " + "transcription of it, so a mutation back to a literal or to max_w is red " + "rather than silent", + _s45_arg == ["peakguard.charge_headroom_w(self.peak_guard)"]) + + +# --------------------------------------------------------------------------- # +print() +print("3c. SAFETY-45 - THE REGRESSION: the closed loop SETTLES") +# --------------------------------------------------------------------------- +# ⚠️ THIS IS THE CHECK THE CARD IS ACTUALLY ABOUT, AND A UNIT TEST ON decide() +# IS NOT IT. The defect is an INTERACTION - two strategies, a 900 s integrator +# and a plant that answers back - and every individual decision in it was +# correct. So the loop is closed here: prices decide, the battery model +# responds, the shipped QuarterAverager reports the consequence, and the +# shipped guard reacts to that. +# +# WHAT IS REAL, AND WHAT IS MODELLED - the same discipline TEST-23 stage 3's +# harness holds itself to, because a transcription proves what somebody typed: +# +# REAL peakguard.decide/claim/charge_headroom_w, arbitrage.decide/claim/ +# guard_state, control.compute + Tuning, arbiter.resolve, and +# p1.QuarterAverager - the billed-quarter accumulator itself, so the +# quarters reported below are the ones a meter would bill. +# MODEL ONE cycle's ORDER (peak block, then arbitrage reading its +# conclusion, then the loop claim, then resolve) - twelve lines +# transcribed from main.py:2660-2900 and pinned above in 3b; the +# PLANT, an 800 W/s ramp into a 10 s first-order lag, which is +# plantsim's; and a flat synthetic house. +# +# ⚠️ AND THE HEADLINE NUMBERS WERE NOT TAKEN HERE. They were taken on the +# closed rig at /home/homelab/test23s3 against the shipped container's own +# checkout, which is where a divergence between this model and main.py's real +# event loop would show up. This section exists so CI can go red. +# --------------------------------------------------------------------------- +S45_CAP_W, S45_LOAD_W, S45_CYCLE_S, S45_RUN_S = 1500.0, 1200.0, 5.0, 2700.0 +S45_RAMP_W_S, S45_LAG_S = 800.0, 10.0 +# ⚠️ THE RUN STARTS ON A QUARTER BOUNDARY AND ENDS INSIDE THE SAME CHEAP HOUR, +# and both halves matter. Starting on the boundary makes the three closed +# blocks below whole billed quarters. Ending inside the hour keeps TEST-23 +# finding 1 - the export overshoot at the END of any trade, ~0.37 x max_w, a +# separate and already-recorded property - out of a swing figure this card is +# grading. The run still crosses TWO quarter boundaries, which is the half of +# the defect the card says arbitrage must learn from. +S45_T0 = D0 + timedelta(hours=CHEAP_H) + + +def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W, + seconds=S45_RUN_S): + """One closed hour. `sized=False` is the behaviour this card removes. + + ⚠️ THE COUNTERFACTUAL IS ONE ARGUMENT, NOT A SECOND CODE PATH. `sized=False` + hands `decide()` a headroom nothing can bind - which is exactly what the + tree did before this card, because nothing was passing one - so the two runs + below differ in one value and share every line of shipped code. Habit 7. + """ + avg = QuarterAverager(phases=1, max_hold_s=900.0) + tuning = Tuning(gain=0.6, max_w=MAX_W, slew_w=0.0) + t0 = S45_T0 + target = i_w = 0.0 + sat = 0 + loop_w = None + batt_cmd = batt = 0.0 # + discharge, as main.py's site_batt_w + onsets = blocks = 0 + prev_action = "" + grid_lo, grid_hi = None, None + billed = [] + acc = 0.0 + t = 0.0 + while t <= seconds: + grid = load_w - batt + imp, exp = split_signed(grid) + for b in avg.add(make_sample("ha_signed", imp, exp, phases=1, + ingest_ts=t0 + timedelta(seconds=t), + ingest_mono=(t0.timestamp() + t))): + billed.append(round(float(b.offtake_avg_w), 1)) + blocks += 1 + if acc <= 1e-9: + acc = S45_CYCLE_S + now = t0 + timedelta(seconds=t) + claims = [] + pk = _pg.decide(PeakTracker.project(avg, now), batt_w=batt, + cap_w=cap_w, reading=None, achievable_w=MAX_W, + soc=50.0, soc_floor=20.0, max_hold_s=900.0, + frozen=False) + pc = _pg.claim(pk) + if pc is not None: + claims.append(pc) + arb = decide(PLAN, now, + peak_guard_active=_arb.guard_state(pk), + charge_headroom_w=(_pg.charge_headroom_w(pk) if sized + else HEADROOM_CLEAR), + soc=50.0, min_spread_eur_kwh=_shipped, + uncertainty=NO_WEATHER, charge_w=MAX_W, + discharge_w=MAX_W, soc_floor_pct=20.0, + soc_ceiling_pct=95.0) + ac = claim(arb) + if ac is not None: + claims.append(ac) + if arb.action == CHARGE and prev_action != CHARGE: + onsets += 1 + prev_action = arb.action + if loop_w is None or target != loop_w: + i_w = target + d = compute(prev_w=target, grid_w=grid, actual_w=batt, + tuning=tuning, sat_count=sat, i_w=i_w) + sat, i_w, loop_w = d.sat_count, d.i_w, d.target_w + claims.append(Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W, + "max_w")) + claims.append(Claim.set("loop", P_LOOP, d.target_w, d.reason)) + target = resolve(claims).target_w + # THE PLANT, MODELLED: an 800 W/s ramp into a 10 s first-order lag. + step = min(1.0, acc) + delta = target - batt_cmd + batt_cmd += math.copysign(min(abs(delta), S45_RAMP_W_S * step), delta) + batt += (batt_cmd - batt) * (step / S45_LAG_S) + acc -= step + t += step + grid_lo = grid if grid_lo is None else min(grid_lo, grid) + grid_hi = grid if grid_hi is None else max(grid_hi, grid) + return dict(onsets=onsets, grid_lo=round(grid_lo, 1), + grid_hi=round(grid_hi, 1), swing=round(grid_hi - grid_lo, 1), + billed=billed, blocks=blocks) + +S45_BEFORE = derived(lambda: s45_loop(sized=False)) +S45_AFTER = derived(lambda: s45_loop(sized=True)) +for _lab, _r in (("unsized (the shipped behaviour before this card)", S45_BEFORE), + ("sized by the guard", S45_AFTER)): + print(f" {_lab}: {_r['onsets']} charge onsets/h, grid " + f"{_r['grid_lo']:.0f} .. {_r['grid_hi']:.0f} W (swing " + f"{_r['swing']:.0f} W), billed quarters {_r['billed']} W") + +# ⚠️ THE NEGATIVE CONTROL FIRST. If the unsized run did NOT limit-cycle, every +# assertion below would be a statement about a loop that was never unstable - +# MUTATION.md's "a check whose data cannot reach the failure it names", which +# has now appeared in eleven cards in this repository. +check("PRECONDITION: with the trade UNSIZED this loop really does run away - " + "many charge onsets in one hour and a grid swing of kilowatts. Without " + "this, everything below grades a loop that was never unstable", + S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0) +check("...and it is the bang-bang shape and not a slow drift: the meter " + "reaches both a hard import and an EXPORT, so the actuator is being " + "handed back and forth rather than converging", + S45_BEFORE["grid_hi"] > S45_CAP_W + 1000.0 + and S45_BEFORE["grid_lo"] < 0.0) +check(f"THE REGRESSION: sizing the trade to the guard's measured headroom " + f"settles the loop. {S45_BEFORE['onsets']} charge onsets an hour become " + f"{S45_AFTER['onsets']}, and the grid swing falls from " + f"{S45_BEFORE['swing']:.0f} W to {S45_AFTER['swing']:.0f} W", + S45_AFTER["onsets"] <= 2 + and S45_AFTER["swing"] < 0.25 * S45_BEFORE["swing"]) +check("...and the battery is still TRADING for the whole hour rather than " + "settling by giving up: §10.1 ranks peak shaving above arbitrage, it " + "does not cancel it, and a fix that simply stopped buying would pass an " + "onset count and fail the card", + S45_AFTER["onsets"] == 1) +check(f"...and every BILLED quarter comes in at or under the " + f"{S45_CAP_W:.0f} W cap, which the unsized run does not manage - the " + f"oscillation was not merely ugly, it was losing the money it was " + f"oscillating about (§10.2, §9.1)", + S45_AFTER["blocks"] >= 3 + and all(v <= S45_CAP_W + 0.5 for v in S45_AFTER["billed"]) + and any(v > S45_CAP_W for v in S45_BEFORE["billed"])) +check("...and the two runs are the same code on the same fixture: one " + "argument differs, and the loop, the guard, the arbiter, the averager " + "and the plant are shared line for line", + S45_BEFORE["blocks"] == S45_AFTER["blocks"]) + +# ⚠️ AND IT IS INERT WHERE THE DEFECT IS INERT, WHICH IS THE OTHER HALF OF A +# FIX. The rig's sweep found the bite rule `load + max_w > peak_cap_w`; below +# it, the trade never threatens the quarter and this card must change NOTHING. +S45_WIDE = derived(lambda: s45_loop(sized=False, cap_w=5000.0, load_w=1200.0, + seconds=900.0)) +S45_WIDE_S = derived(lambda: s45_loop(sized=True, cap_w=5000.0, load_w=1200.0, + seconds=900.0)) +print(f" peak_cap_w 5000 W, 1200 W house (load + max_w = " + f"{S45_LOAD_W + MAX_W:.0f} W, under the cap): unsized " + f"{S45_WIDE['billed']} W, sized {S45_WIDE_S['billed']} W") +check("on a site where the trade cannot reach the cap the two runs are " + "IDENTICAL to the watt - so this card costs nothing on the installs it " + "is not about, and the check above is not passing because the cap " + "dominates everything", + S45_WIDE == S45_WIDE_S and S45_WIDE["onsets"] == 1) + + # --------------------------------------------------------------------------- # print() print("4. §8.3 - no irreversible action on a provisional price") @@ -822,7 +1155,8 @@ _ticks = [] for _t in range(24 * 120): # 24 h at the 30 s control cadence _d = decide(_flap_plan, D0 + timedelta(seconds=30 * _t), peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, - uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W, + uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR, + charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0) _ticks.append(_d.action) _transitions = sum(1 for a, b in zip(_ticks, _ticks[1:]) if a != b) @@ -844,7 +1178,8 @@ _no_plan = act(None, CHEAP_H, thresh=_shipped) _no_soc = act(PLAN, CHEAP_H, thresh=_shipped, soc=None) _uncovered = decide(PLAN, D0 + timedelta(days=9), peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, - uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W, + uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR, + charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0) _reasons = [_no_plan.reason, _no_soc.reason, _uncovered.reason, _blind.reason, _held.reason] @@ -1218,7 +1553,8 @@ def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0): d = decide(plan, p.start + timedelta(minutes=30), peak_guard_active=False, soc=soc, min_spread_eur_kwh=threshold, uncertainty=unc, - charge_w=MAX_W, discharge_w=MAX_W, + charge_headroom_w=HEADROOM_CLEAR, + charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=floor, soc_ceiling_pct=ceiling) if d.action is None: continue @@ -1952,10 +2288,12 @@ check("...and the same at the sell probe, against the verdict's dearest", _pa_sell is derived(lambda: _qv.dearest)) _qbuy = decide(QPLAN, qprobe(Q_CHEAP_I), peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER, - charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, + charge_headroom_w=HEADROOM_CLEAR, + charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0) _qsell = decide(QPLAN, qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER, + charge_headroom_w=HEADROOM_CLEAR, charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0) check("...so decide() CHARGES in the cheapest quarter and DISCHARGES in the " @@ -1980,7 +2318,8 @@ check(f"the off-peak probe's own price differs from its hour's :00 price " and _QOFF_I not in (Q_CHEAP_I, Q_DEAR_I)) _qoff = decide(QPLAN, qprobe(_QOFF_I), peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER, - charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, + charge_headroom_w=HEADROOM_CLEAR, + charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0) check("a quarter that is neither extremum still idles, for the reason that " "names the right rule, and reports ITS OWN price - not the price of the " @@ -2007,7 +2346,8 @@ check(f"the negative day's dearest quarter is slot {Q_DEAR_I} and it is BELOW " NO_WEATHER)) _qneg_sell = decide(QNEG_PLAN, qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, - uncertainty=NO_WEATHER, charge_w=MAX_W, discharge_w=MAX_W, + uncertainty=NO_WEATHER, charge_headroom_w=HEADROOM_CLEAR, + charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0) check("selling into a negative-price QUARTER is refused, and the reason names " "§17 and THAT QUARTER's price", @@ -2020,7 +2360,8 @@ check("...while the SAME quarter-hourly series with that ONE price's SIGN " "FLIPPED discharges instead", decide(qplan_of(QPOS), qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER, - charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, + charge_headroom_w=HEADROOM_CLEAR, + charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0).action == DISCHARGE and sum(1 for a, b in zip(QNEG, QPOS) if a != b) == 1) # §8.3's flag, read off the covering point. `_act`'s own provisional guard @@ -2077,11 +2418,13 @@ check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at " "their own prices", decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER, - charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, + charge_headroom_w=HEADROOM_CLEAR, + charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0).action == CHARGE and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER, - charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, + charge_headroom_w=HEADROOM_CLEAR, + charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0).action == DISCHARGE) # An instant inside a CARRIED hour of the same series. The covering point is # the carried one; the shipped scan answered a point from the FIRM block @@ -2096,7 +2439,8 @@ check("an instant inside a CARRIED hour of the mixed series matches the " and MIX_PTS[MIX_N_H - 1].provisional is True and decide(MIX_PLAN, _mix_carried_t, peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER, - charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, + charge_headroom_w=HEADROOM_CLEAR, + charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0).action is None) print() diff --git a/goodwe_controller/test_packshare.py b/goodwe_controller/test_packshare.py index b7b5462..19dfcc3 100644 --- a/goodwe_controller/test_packshare.py +++ b/goodwe_controller/test_packshare.py @@ -1067,14 +1067,19 @@ class _V: _here = _Pt(_NOW) _other = _Pt(_NOW - timedelta(hours=3)) +# SAFETY-45's `headroom_w` is the last positional. It is set to a value that +# CANNOT BIND on either call - larger than every sizing below - so this part +# goes on grading OPT-09's per-pack sizing and not the capacity-tariff cap that +# now sits beside it. test_arbitrage.py section 3b is where it is made to bind. +_CLEAR_W = 1e9 _sell = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0, - 0.0, 0.0, 20.0, 90.0) + 0.0, 0.0, 20.0, 90.0, _CLEAR_W) _sell_ok = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0, - 0.0, 4600.0, 20.0, 90.0) + 0.0, 4600.0, 20.0, 90.0, _CLEAR_W) _buy = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0, - 0.0, 0.0, 20.0, 90.0) + 0.0, 0.0, 20.0, 90.0, _CLEAR_W) _buy_ok = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0, 5000.0, - 0.0, 20.0, 90.0) + 0.0, 20.0, 90.0, _CLEAR_W) print(f" sized at 0 W: sell -> {_sell.action}, buy -> {_buy.action}; " f"sized at a rating: sell -> {_sell_ok.action}, buy -> {_buy_ok.action}") check("⚠️ A TRADE NOTHING CAN TAKE IS NOT A TRADE. A 0 W sizing IDLES on both " diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 25e6f48..a90b20d 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -138,8 +138,8 @@ from app.peakguard import ( BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP, CEILING_MONTH_PEAK, FLOOR_AT_SOC_FLOOR, FLOOR_SOC_UNREADABLE, PEAK_OPTION_DEFAULTS, Pack, PeakDecision, binding_soc_floor, blindness, - decide, discharge_permitted, excess_w, pack_floor_pct, pack_permissions, - quarter_ceiling_w, uncovered_s, + charge_headroom_w, decide, discharge_permitted, excess_w, pack_floor_pct, + pack_permissions, quarter_ceiling_w, uncovered_s, ) from app.validate import validate_startup_config from app.tariff import ( @@ -379,8 +379,17 @@ check("at the engagement boundary the excess is zero, so the floor is exactly " released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0 +# ⚠️ `floor_w` IS FORMATTED DEFENSIVELY, AND MUTATION.md's OWN LEDGER IS WHY. +# The mutation this check exists for - reverting the `floor > 0` rung to +# `excess > 0` - makes `floor_w` None HERE, so a bare `:.0f` raises TypeError +# and ABORTS THE WHOLE FILE on the line after the check that just went red. +# Parts 2-13 are then ungraded and the recorded coverage of that mutation is +# smaller than the truth: exactly what MAINT-05's `delinquent(...)['b']` +# subscript did to test_delivery.py, and habit 5 ("a check whose failure mode +# is a hang - or a crash - needs a deadline, or a name") in the other costume. print(f" at the boundary: excess {e_edge:.3f} W (an `excess > 0` test would " - f"RELEASE: {would_release_on_excess}), floor {released.floor_w:.0f} W " + f"RELEASE: {would_release_on_excess}), floor " + f"{'(none)' if released.floor_w is None else f'{released.floor_w:.0f} W'} " f"(the shipped `floor > 0` test holds: {released.engaged})") check("...and the claim is held there, not released. `excess` falls to zero as " "soon as the battery is already taking the watts, so thresholding IT is " @@ -5947,6 +5956,281 @@ check("...and the row is green and un-warned, which is what makes the " "operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"] and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0) +# ============================================================================== +print() +print("13. SAFETY-45 - charge_headroom_w(): the number decide() used to throw " + "away") +# --------------------------------------------------------------------------- +# `decide()` computes `wanted = batt_w + excess` - the DISCHARGE that lands the +# quarter exactly on the ceiling - and returns `engaged=False, floor_w=None` +# the moment that number is not positive. A negative `wanted` is not "nothing +# to say": it is a permitted CHARGE of `-wanted` W, and discarding it is what +# let an arbitrage charge sit at max_w against a quarter it was blowing. +# +# ⚠️ NOTHING IN decide() CHANGES IN THIS CARD, AND THAT IS THE POINT. The +# `wanted <= 0` branch is 2026-08-20's anti-bang-bang rung and part 6's replay +# is what proves it still holds; this part grades a PURE READ of the number it +# already carried. 13g asserts the branch is still there rather than trusting +# the diff. +# --------------------------------------------------------------------------- + +def _proj_at(net_w, elapsed): + return PeakTracker.project(averager_at(net_w, elapsed), + B + timedelta(seconds=elapsed)) + + +def _decide_on(projection, *, batt_w, soc=SOC_OK, cap_w=CAP_W, + achievable_w=LIVE_MAX_W, reading=None, frozen=False): + return decide(projection, batt_w=batt_w, cap_w=cap_w, reading=reading, + achievable_w=achievable_w, soc=soc, soc_floor=SOC_FLOOR, + max_hold_s=MAX_HOLD_S, frozen=frozen) + + +def _decide_at(net_w, elapsed, **kw): + return _decide_on(_proj_at(net_w, elapsed), **kw) + + +def _avg_held(base_w, elapsed, held_w, *, start=B, step=5.0): + """`elapsed` seconds BANKED at `base_w`, then `held_w` standing at the edge. + + ⚠️ THE HISTORY AND THE HELD VALUE ARE SEPARATE FIXTURE AXES, and 13c is why + this helper exists rather than `averager_at(load - batt, ...)`. The averager + credits a sample from its own timestamp to the NEXT one, so the last sample + contributes ZERO accumulated seconds and only becomes `held`. That is the + real shape of a battery whose command changed a moment ago - the quarter's + banked seconds do not retroactively change - and a fixture that moved the + whole history with it would be grading a different quarter each time. + """ + a = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) + t = 0.0 + while t < elapsed: + a.add(sample(start + timedelta(seconds=t), base_w)) + t += step + a.add(sample(start + timedelta(seconds=elapsed), held_w)) + return a + + +# --- 13a. the number, and it is DERIVED rather than read back --------------- +# The house is flat at 2600 W under a 3500 W cap, the battery is idle, and 300 s +# of the block are banked. 600 s remain, so the block can still absorb +# 900 W x 900/600 = 1350 W of extra offtake and finish exactly on the ceiling. +_H45_GAP = 900.0 +_H45_LOAD = CAP_W - _H45_GAP +_H45_EL, _H45_REM = 300.0, QUARTER_S - 300.0 +_h45_idle = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0) +_h45_head = charge_headroom_w(_h45_idle) +# The CONSEQUENCE, computed from the fixture's own literals and not from the +# module's arithmetic: hold that charge for the rest of the block and the +# BILLED AVERAGE is the ceiling. This is the meaning of the number, which is a +# different statement from the expression that produces it (habit 2). +_H45_FINAL = (_H45_LOAD * _H45_EL + + (_H45_LOAD + _h45_head) * _H45_REM) / QUARTER_S +print(f" house {_H45_LOAD:.0f} W under a {CAP_W:.0f} W cap, battery 0 W, " + f"{_H45_EL:.0f} s banked: engaged={_h45_idle.engaged}, " + f"wanted={_h45_idle.wanted_w:+.0f} W, headroom={_h45_head:.0f} W") +print(f" holding that charge for the remaining {_H45_REM:.0f} s bills the " + f"block at {_H45_FINAL:.1f} W against a {CAP_W:.0f} W ceiling") +check("the guard files NOTHING here - this is the shipped `wanted <= 0` branch, " + "untouched, and the state SAFETY-45 is about", + not _h45_idle.engaged and _h45_idle.floor_w is None + and _h45_idle.wanted_w is not None and _h45_idle.wanted_w < 0.0) +check(f"...and yet the quarter can afford {_h45_head:.0f} W of charging, which " + f"is the number that was being discarded. Graded by its CONSEQUENCE - " + f"the block lands on the ceiling to the watt - rather than by " + f"re-deriving -wanted_w with the module's own formula", + near(_H45_FINAL, CAP_W, 0.5)) +check(f"...and it is none of the other numbers this fixture has: not the cap " + f"({CAP_W:.0f}), not max_w ({LIVE_MAX_W:.0f}), not the house " + f"({_H45_LOAD:.0f}) and not the raw gap under the ceiling " + f"({_H45_GAP:.0f}) - the 900/remaining gain is 1.5 here and a check that " + f"could not tell those apart would be decoration", + not any(near(_h45_head, v, 1.0) + for v in (CAP_W, LIVE_MAX_W, _H45_LOAD, _H45_GAP))) + +# --- 13a2. AND THE IDENTITY HOLDS ACROSS THE WHOLE BLOCK -------------------- +# ⚠️ THE AMPLIFICATION IS REAL AND IT IS REPORTED WITH ITS SIZE (habit 13). +# 900/remaining reaches 180 in the last five seconds, so the headroom there is +# enormous - and it is bounded in EFFECT, not in magnitude: at every elapsed, +# holding it bills the block at exactly the ceiling. +H45_ID = [] +for _el in (0.0, 60.0, 300.0, 600.0, 870.0, 895.0): + _rem = QUARTER_S - _el + _hd = charge_headroom_w(_decide_at(_H45_LOAD, _el, batt_w=0.0)) + H45_ID.append((_el, _hd, + (_H45_LOAD * _el + (_H45_LOAD + _hd) * _rem) / QUARTER_S)) +print(" headroom across the block (elapsed, headroom W, billed W): " + + ", ".join(f"({a:.0f}, {b:.0f}, {c:.1f})" for a, b, c in H45_ID)) +check("at EVERY point in the block, holding the headroom for the rest of it " + "bills the block at exactly the ceiling - so the 162 kW answer five " + "seconds from the boundary and the 900 W answer at the top of it are the " + "same statement about the bill", + all(near(c, CAP_W, 0.5) for _, _, c in H45_ID)) +check("...and the fixture SPANS the amplification rather than sitting in one " + f"corner of it: the headroom moves by a factor of " + f"{max(b for _, b, _ in H45_ID) / min(b for _, b, _ in H45_ID):.0f} " + f"across those six points", + max(b for _, b, _ in H45_ID) > 100.0 * min(b for _, b, _ in H45_ID)) + +# --- 13b. HOLDING implies ZERO OR UNKNOWN, swept - the family, not one case - +# ⚠️ THE PROPERTY THAT MAKES THIS SAFE TO HAND A STRATEGY. Every state that +# files a claim has `wanted > 0`, `wanted is None` or `blind`, so this function +# can never hand a caller permission the guard's own `lo` is at that instant +# refusing. Swept over the same grid part 5's no-negative-floor proof uses, +# widened with the two ways a cycle can fail to measure at all. +H45_N = H45_BAD = H45_POS = H45_NONE = H45_ZERO = 0 +for _load in (0.0, CAP_W - 500.0, CAP_W, CAP_W + 300.0, CAP_W + 3000.0, + LIVE_MAX_W * 3): + for _el in (30.0, 450.0, 870.0): + for _bw in (-LIVE_MAX_W, -2000.0, 0.0, 400.0, 4000.0, None): + for _sc in (None, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0): + for _fz in (False, True): + _d45 = _decide_at(_load, _el, batt_w=_bw, soc=_sc, + frozen=_fz) + _hr = charge_headroom_w(_d45) + H45_N += 1 + if _hr is None: + H45_NONE += 1 + elif _hr > 0.0: + H45_POS += 1 + else: + H45_ZERO += 1 + if _d45.engaged and _hr not in (None, 0.0): + H45_BAD += 1 +print(f" swept {H45_N} (load x elapsed x battery x SoC x frozen) " + f"combinations: {H45_BAD} ENGAGED decisions answering a positive " + f"headroom; {H45_POS} permit charging, {H45_ZERO} answer 0 W, " + f"{H45_NONE} unmeasured") +check(f"NOT ONE of the {H45_N} combinations answers a positive headroom while " + f"the guard is ENGAGED. A decision whose `lo` is at that instant " + f"forbidding charging can never be read as permission to charge", + H45_BAD == 0 and H45_N > 800) +check("...and the sweep is vacuous in no direction: it reaches decisions that " + f"permit charging ({H45_POS}), decisions that permit none ({H45_ZERO}) " + f"and decisions nothing measured ({H45_NONE}), so all three answers are " + f"exercised", H45_POS > 0 and H45_ZERO > 0 and H45_NONE > 0) + +# --- 13c. IT IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE TO IT +# CANNOT OSCILLATE ------------------------------------------------- +# 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. The headroom is a +# FEED-FORWARD number - a function of the house, the ceiling and the seconds +# already banked - so consuming it adds no feedback path around the plant. +# Without this property the whole fix would be one loop replacing another. +H45_INV, H45_EX = [], [] +for _bw in (-LIVE_MAX_W, -2000.0, -500.0, 0.0, 500.0, 2000.0): + # ONE banked history, and the battery's change visible only in the HELD + # value - which is what a command given a moment ago actually looks like. + _d = _decide_on(PeakTracker.project( + _avg_held(_H45_LOAD, _H45_EL, _H45_LOAD - _bw), + B + timedelta(seconds=_H45_EL)), batt_w=_bw) + H45_INV.append(charge_headroom_w(_d)) + H45_EX.append(round(_d.excess_w, 1)) +print(f" same house, same banked quarter, battery {-LIVE_MAX_W:+.0f}.." + f"{2000.0:+.0f} W: headroom {[round(v, 3) for v in H45_INV]}") +print(f" ...while the EXCESS those runs see is {H45_EX}") +check("the headroom does NOT move when the battery moves across the whole " + "range of what it can do - it is a property of the HOUSE and the " + "CEILING, not of what the battery happens to be doing, which is what " + "makes a trade sized from it feed-forward rather than a second loop " + "around the plant", + max(H45_INV) - min(H45_INV) < 1.0) +check(f"...and the fixture SEPARATES it from the two quantities it is built " + f"out of: the battery moved {LIVE_MAX_W + 2000.0:.0f} W and the excess " + f"with it, over {len(set(H45_EX))} distinct values, so an implementation " + f"answering `-batt_w` or `abs(excess)` would give six different answers " + f"here (habit 4)", + len(set(H45_EX)) == len(H45_EX) + and len(set(round(v, 3) for v in H45_INV)) == 1) + +# --- 13d. AND A BOUNDARY HANDS IT BACK AT THE AFFORDABLE RATE, NOT AT max_w -- +# ⚠️ SAFETY-45's SECOND HALF, AND THE CLAIM IS NOT "IT DOES NOT CHANGE". +# `project()` resets to the held level at `elapsed = 0`, and the headroom +# collapses with it - from 162 kW to 900 W here. What the card is about is WHAT +# IT COLLAPSES TO: the house and the cap, and nothing else. The behaviour being +# removed re-armed at `max_w` regardless of either. +_H45_END = charge_headroom_w(_decide_at(_H45_LOAD, 895.0, batt_w=0.0)) +_H45_TOP = charge_headroom_w(_decide_at(_H45_LOAD, 0.0, batt_w=0.0)) +print(f" across a boundary, same house: {_H45_END:.0f} W with 5 s of the " + f"old quarter left -> {_H45_TOP:.0f} W with the new one 0 s old, against " + f"a max_w of {LIVE_MAX_W:.0f} W") +check(f"at the top of the new quarter the headroom is exactly the ceiling less " + f"the house ({CAP_W:.0f} - {_H45_LOAD:.0f} = {_H45_GAP:.0f} W). A trade " + f"refused at 14:59 resumes at 15:00 at the rate the new quarter can " + f"afford - the projection resets, this does not reset TO max_w", + near(_H45_TOP, _H45_GAP, 1.0) and _H45_TOP < LIVE_MAX_W) +check("...and the check is not passing because nothing moved: the headroom on " + "the other side of that boundary is two orders of magnitude larger, and " + "the PROJECTION really did reset", + _H45_END > 100.0 * _H45_TOP and _proj_at(_H45_LOAD, 0.0).elapsed_s < 1.0) + +# --- 13e. the unmeasured answers, and None is NOT unlimited ----------------- +_h45_nobatt = _decide_at(_H45_LOAD, _H45_EL, batt_w=None) +_h45_blind = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0, frozen=True) +print(f" no battery reading: {charge_headroom_w(_h45_nobatt)}; " + f"BLIND: {charge_headroom_w(_h45_blind)}; no decision at all: " + f"{charge_headroom_w(None)}") +check("a guard that never ran answers None, not a number - `meter_source: off` " + "reaches this with no decision at all and a caller that read it as a " + "large headroom would be guard_state()'s None-is-not-False collapse one " + "layer down", charge_headroom_w(None) is None) +check("...and a cycle with NO BATTERY MEASUREMENT answers None too: there is " + "nothing to size a floor from, so there is nothing to size a trade from " + "either", charge_headroom_w(_h45_nobatt) is None + and _h45_nobatt.wanted_w is None) +check("...and BLIND answers 0.0 - a NUMBER, and the number is no charging. " + "The claim it files already says exactly that at `floor 0 W`, and this " + "agrees with it rather than restating it", + charge_headroom_w(_h45_blind) == 0.0 and _h45_blind.blind + and _h45_blind.floor_w == 0.0) +check("...and the three are TOLD APART: blind is 0.0 and the two unmeasured " + "states are None, so a caller can refuse for the reason that is true " + "rather than for whichever fired first", + charge_headroom_w(_h45_blind) is not None + and charge_headroom_w(_h45_nobatt) is None) + +# --- 13f. the SoC floor does not become charging permission ----------------- +# ⚠️ THE ONE ENGAGED STATE WHERE THE GUARD IS DELIBERATELY NOT DISCHARGING. +# SAFETY-24's floor-limited decision files `lo = 0.0` - "charging stays +# blocked" - on a quarter that IS over the ceiling. If that read as headroom, +# an empty pack in a blown quarter would be told to buy. +_h45_lim = _decide_at(CAP_W + 2000.0, _H45_EL, batt_w=0.0, soc=SOC_FLOOR - 1.0) +print(f" floor-limited over a blown quarter: engaged={_h45_lim.engaged}, " + f"floor_limited={_h45_lim.floor_limited}, wanted=" + f"{_h45_lim.wanted_w:+.0f} W, headroom={charge_headroom_w(_h45_lim)}") +check("a FLOOR-LIMITED decision - the guard wanted a discharge and SoC refused " + "to size it - answers 0.0 headroom. The quarter is over the ceiling; the " + "pack being empty is not a reason to fill it from the grid in it", + _h45_lim.floor_limited and _h45_lim.engaged + and charge_headroom_w(_h45_lim) == 0.0) + +# --- 13g. decide() ITSELF IS UNCHANGED, PINNED ----------------------------- +# ⚠️ MUTATION.md habit 4, in the direction a diff cannot cover. The whole safety +# argument of this card is "the 2026-08-20 rung is still there and part 6 still +# grades it". That is a claim about the SOURCE, so it is pinned on the source - +# and on the BEHAVIOUR, because a shape pin alone would pass a rung that had +# been inverted. +_H45_SRC = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8") +_H45_TREE = ast.parse(_H45_SRC) +_h45_fn = next(n for n in ast.walk(_H45_TREE) + if isinstance(n, ast.FunctionDef) and n.name == "decide") +_H45_RUNG = [n for n in ast.walk(_h45_fn) if isinstance(n, ast.If) + and ast.unparse(n.test) == "wanted <= 0.0"] +check("the `wanted <= 0.0` rung is still in decide(), spelled exactly that " + "way, on the AST rather than as a substring - it is the rung whose " + "removal put 2026-08-20's quarter 49 W over the limit after seventeen " + "engage/release cycles in 900 s", len(_H45_RUNG) == 1) +check("...and it still DISENGAGES rather than merely reporting: the fixture " + "13a is built on reaches it and files no claim, so the pin above is not " + "grading a rung that has been inverted under it", + peakguard.claim(_h45_idle) is None) +_h45_head_fn = next(n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef) + and n.name == "charge_headroom_w") +check("...and charge_headroom_w() is a PURE READ of the decision - one " + "positional parameter, no projection, no clock and no options - so it " + "cannot become a second answer to the question decide() already answers", + [a.arg for a in _h45_head_fn.args.args] == ["decision"] + and not _h45_head_fn.args.kwonlyargs) + # ============================================================================== print() shutil.rmtree(TMP, ignore_errors=True)