From e1b92546268663eec7353790cf53168b7920612d Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 14 Sep 2026 16:10:34 +0200 Subject: [PATCH] SAFETY-45: a price-driven charge is SIZED by the capacity guard, not switched by it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 75 ++++ .../DECISIONS/SAFETY-45-sizing-the-trade.md | 221 +++++++++++ goodwe_controller/DOCS.md | 14 + goodwe_controller/app/arbitrage.py | 68 +++- goodwe_controller/app/main.py | 16 + goodwe_controller/app/peakguard.py | 139 +++++++ goodwe_controller/test_arbitrage.py | 366 +++++++++++++++++- goodwe_controller/test_packshare.py | 13 +- goodwe_controller/test_peakguard.py | 290 +++++++++++++- 9 files changed, 1178 insertions(+), 24 deletions(-) create mode 100644 goodwe_controller/DECISIONS/SAFETY-45-sizing-the-trade.md 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) -- 2.54.0