From 182102eeaf0d14b94ad302e7af2a1758bc6ed572 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 03:46:43 +0200 Subject: [PATCH 1/2] SAFETY-25: say what peak_soc_floor bounds, and measure what it does not peak_soc_floor reads like a pack-wide reserve and is not one. The grid-following loop has no SoC guard of any kind, so on a sustained-load evening it discharges straight past that number and keeps going until the pack's own BMS cut-out stops it. The gap has now been written down four times without being filed - SAFETY-18's finding (freeze.py:18), SAFETY-24's module note, its PR body, and its honestly-uncaught 13th mutation - and never once measured. It is measured here, on the committed 2026-08-20 capture, driving the SHIPPED control.compute(), peakguard.decide(), peakguard.claim() and the real arbiter.resolve() in main.py's order: 19:00-22:00 UTC, 830 W mean, minimum 701 W, zero export in the window reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point above peak_soc_floor ends at 10.0 % - the bms_soc_floor cut-out 1525 Wh delivered by the LOOP over 587 cycles 549 cycles below peak_soc_floor, 343 of them still discharging 0 cycles on which the peak guard filed any floor at all AND IT IS A BEHAVIOUR, NOT A CONFIG VALUE. The same evening ends at 9.9715 % at peak_soc_floor = 5, 11, 20 and 60 - the shipped value and both ends of the schema range - identical to four decimal places. The option an operator would reach for cannot move the number by any amount at all. On the same day's high-load block the two states coexist: 49 cycles reporting PEAK GUARD FLOOR-LIMITED and, on all 49 of them, the arbiter simultaneously granting the loop up to 2000 W of discharge. WHY SAFETY-24 COULD NOT CLOSE IT. The peak guard files a `lo` limit - a LOWER bound on the setpoint. That asymmetry is exactly what makes it trustworthy: a reviewer proved across 19,800 combinations that it can never lower a target, cap a discharge or contradict the arbiter. The same property makes it structurally incapable of stopping a discharge the loop itself wants. A floor on the guard cannot become a floor on the pack by being raised, and the sweep above is that sentence measured. WHAT THIS COMMIT CHANGES IS THE HONESTY, on the four surfaces an operator actually reads. That half is required whichever way the open question is answered, and it is the real misstatement on the live system today: * config.yaml, beside the option itself - it BOUNDS THE PEAK GUARD ONLY and is NOT A PACK-WIDE RESERVE, with the replayed evening and the sweep; * DOCS.md, in the options table and in a new section carrying the figures; * the STATUS-PAGE ROW for a floor-limited guard. This is the one line a human reads at the moment the guard stops discharging, and read alone it says "the battery is being held at peak_soc_floor" - which the replay shows is false in every cycle that row is rendered; * the PERSISTENT NOTIFICATION, which outlives the page. It now names what has stopped (the guard buying a lower bill) and what has not (the pack being spent). peakguard.py and freeze.py carry the measurement beside the findings they already recorded, so the next reader does not have to take the caveat on trust. THE PRODUCT DECISION IS NOT TAKEN HERE, and must not be. Whether the loop gets a reserve floor of its own decides whether this house's battery may discharge to the BMS floor on a heavy evening; that moves real energy and it is the owner's call. DECISIONS/SAFETY-25-loop-soc-floor.md is the memo AC 1 asks for: the options, what each costs, what each protects, weighed against MAINT-01 (a pack repeatedly at the BMS floor is the SoC-drift condition the maintenance cycle exists to correct, and an over-tight reserve fights it from the other side) and against the fact that this would be the FIFTH SoC key - OPT-04's arbitrage_soc_floor fired SAFETY-24's "revisit if a third floor appears" trigger unremarked, so SAFETY-27's naming decision should land first. It carries a recommendation and says plainly that it is not a decision. It also records a trap found while writing it, and now CHECKED through the real arbiter rather than described: the obvious shape of a loop-side floor is a `hi` limit at P_LOOP, and filed while the peak guard is asking for a positive floor that is `lo > hi` - which arbiter.py resolves to a CONTRADICTION at 0 W, the battery PARKED in the one quarter of the month that sets the bill. It is the same crossing-limits hazard shaping_suspended() already exists to avoid. The ordering `bms_soc_floor < loop_soc_floor <= peak_soc_floor` removes it provably, because wherever the reserve binds the guard is already floor-limited and files lo = 0; both halves are run in part 10d. test_peakguard.py part 10 is a TRIPWIRE, not a guarantee. It records a defect and goes RED the day somebody builds a loop-side floor, so the exposure cannot change size under a green suite and closing it has to be deliberate. Part 11's mutation 13 stays honestly NOT CAUGHT - nothing prevents the loop discharging past this floor - but it now reports a number instead of a sentence. Verified: 16/16 suites green in the CI image. Eight mutations, each against a tree copied fresh with __pycache__ excluded and mounted :ro, so no .pyc existed to go stale (MUTATION.md's structural immunity, the same one it records for CI); byte deltas +61, -8, -465, -430, -1088, -46, file deleted, and -95; none is +0 B. All eight go red on the named check: M1 peakguard.claim() also files hi=floor_w -> 7 red, incl. 10c M2 control.compute() gain forced to 0 -> 6 red, incl. 10a/10b M3 scope sentence removed from the status row -> 10e red M4 scope sentence removed from the notification -> 10e red M5 scope paragraph removed from config.yaml -> 10e red M6 scope bullet weakened in DOCS.md -> 10e red M7 the decision memo deleted -> 10e red M8 arbiter clamps crossing limits instead of reporting a contradiction -> 5 red, incl. 10d AC 2 is left explicitly OPEN, pending the owner's answer. Co-Authored-By: Claude Opus 5 (1M context) --- goodwe_controller/CHANGELOG.md | 67 +++ .../DECISIONS/SAFETY-25-loop-soc-floor.md | 258 +++++++++++ goodwe_controller/DOCS.md | 58 ++- goodwe_controller/MUTATION.md | 1 + goodwe_controller/app/freeze.py | 8 + goodwe_controller/app/main.py | 42 +- goodwe_controller/app/peakguard.py | 22 + goodwe_controller/config.yaml | 20 + goodwe_controller/test_peakguard.py | 417 +++++++++++++++++- 9 files changed, 878 insertions(+), 15 deletions(-) create mode 100644 goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 5a6a68e..8191b38 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,6 +2,73 @@ ## Unreleased +**`peak_soc_floor` reads like a pack-wide reserve and is not one, and on a +sustained-load evening the grid-following loop discharges straight past it to +the BMS cut-out (SAFETY-25).** The gap has been written down four times without +being filed — SAFETY-18's finding (`freeze.py`), SAFETY-24's module note, its PR +body, and its honestly-uncaught 13th mutation — and never once measured. It is +measured now, on the committed 2026-08-20 capture, driving the shipped +`control.compute()`, `peakguard.decide()`, `peakguard.claim()` and the real +`arbiter.resolve()`: + + 19:00-22:00 UTC, 830 W mean, minimum 701 W, not one watt of export + reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point + above peak_soc_floor + + ends at 10.0 % - the bms_soc_floor cut-out + 1525 Wh delivered by the LOOP over 587 cycles + 549 cycles below peak_soc_floor, 343 of them still discharging + 0 cycles on which the peak guard filed any floor at all + +**And it is a behaviour, not a config value.** The same evening ends at +`9.9715 %` at `peak_soc_floor` = 5, 11, 20 **and** 60 — the shipped value and +both ends of the schema range — identical to four decimal places. The option an +operator would reach for cannot move the number by any amount. On the same day's +high-load block the two states coexist: 49 cycles reporting `PEAK GUARD +FLOOR-LIMITED` and, on **all 49**, the arbiter simultaneously granting the loop +up to 2000 W of discharge. + +**Why SAFETY-24 could not close it.** The peak guard files a `lo` limit — a +*lower* bound. That asymmetry is what makes it provably unable to lower a target, +cap a discharge or contradict the arbiter, and it is therefore equally unable to +stop a discharge the loop itself wants. A floor on the guard cannot become a +floor on the pack by being raised. + +**What changed is the honesty, on four operator-facing surfaces**, because that +half is required whichever way the open question is answered and it is the actual +misstatement on the live system today: + +- `config.yaml` says next to the option that it **bounds the peak guard only** and + is **not a pack-wide reserve**, with the replayed evening and the sweep; +- `DOCS.md` says it in the options table and in a new section carrying the + figures above; +- the **status-page row** for a floor-limited guard carries the scope sentence — + it is the one line a human reads at the moment the guard stops, and read alone + it implies a reserve the replay shows does not exist; +- the **persistent notification** says what has stopped (the guard buying a lower + bill) and what has not (the pack being spent). + +`peakguard.py` and `freeze.py` carry the measurement beside the findings they +already recorded. + +**The product decision is NOT taken here.** Whether the loop gets a reserve floor +of its own moves real energy on this house and belongs to the owner. +`DECISIONS/SAFETY-25-loop-soc-floor.md` records the options, what each costs and +protects, weighed against MAINT-01 and against the fact that this would be the +**fifth** SoC key (OPT-04's `arbitrage_soc_floor` fired SAFETY-24's "revisit if a +third floor appears" trigger unremarked), and a recommendation. It also records a +trap found while writing it and now checked through the real arbiter: the obvious +shape — a `hi` limit on the loop — crosses the peak guard's `lo` and resolves to a +**CONTRADICTION at 0 W**, the battery parked in the quarter that sets the bill, +unless `bms_soc_floor < loop_soc_floor <= peak_soc_floor` is enforced at startup. + +**`test_peakguard.py` part 10 is a tripwire, not a guarantee.** It records the +defect and goes **red** the day a loop-side floor is built, so the exposure cannot +change size under a green suite. Eight mutations were run against it, each +against a freshly copied tree with no `__pycache__` present at all, mounted +`:ro` — the same structural immunity `MUTATION.md` records for CI. All eight go +red on the named check. + **A setpoint device smaller than `max_w` silently disarmed the frozen-meter detector, and the status page reported it as armed (SAFETY-20).** SAFETY-18's conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the emitted command is diff --git a/goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md b/goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md new file mode 100644 index 0000000..ba286b4 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md @@ -0,0 +1,258 @@ +# SAFETY-25 — does the grid-following loop get a reserve floor? + +**Status: OPEN. This is a memo, not a decision.** It sets out the options, what +each costs, what each protects, and a recommendation. **The choice belongs to the +product owner**, because it decides whether this house's battery is allowed to +discharge to its BMS cut-out on a sustained-load evening, and that moves real +energy. + +Filed with SAFETY-25. The other half of that ticket — making `peak_soc_floor`'s +scope honest in the config, the docs and on the status page — **shipped in the +same PR** and did not wait on this, because it is required whichever way the +answer goes. + +*This directory exists so a recorded decision has somewhere to live that is not a +comment in a source file nobody reads and not a Trello card that scrolls away. +`SAFETY-27` asks for a second one (the naming of the floors); it belongs here +too.* + +--- + +## 1. The question + +> Does the grid-following (self-consumption) loop get an SoC floor of its own, or +> is the BMS cut-out **deliberately** the only backstop under demand-driven +> discharge? + +Either answer is defensible. What is not defensible is the state we have been in +for four tickets: **silence, plus a config key called `peak_soc_floor` that reads +like the answer is "yes".** + +## 2. What is actually exposed — replayed, not argued + +The exposure was written down three times (SAFETY-18's finding, SAFETY-24's +module note and PR body, and SAFETY-24's honestly-uncaught 13th mutation) and +never measured. SAFETY-25 measured it. The replay is `test_peakguard.py` part 10; +it drives the **shipped** `control.compute()`, `peakguard.decide()`, +`peakguard.claim()` and `arbiter.resolve()` over the committed capture +`scenarios/p1-net-2026-08-20.jsonl.gz`, with the battery and the state of charge +modelled (the captures carry meter power only — see §7). + +### 2a. A sustained-load evening takes the pack to the BMS cut-out + +19:00–22:00 UTC on 2026-08-20 — three hours of house load after the sun has gone, +**830 W mean, minimum 701 W, maximum 1223 W, not one watt of export in the whole +window.** The pack is the 13.8 kWh reference pack (300 Ah × 51.2 V × DoD 90), +started at **21 %** — one point above the shipped `peak_soc_floor`. + +| | | +|---|---| +| pack at the start of the evening | **21.0 %** | +| pack three hours later | **10.0 %** — `bms_soc_floor` | +| energy the loop delivered | **1525 Wh** | +| control cycles with the pack below `peak_soc_floor` | **549 of 587** | +| …of those, cycles still delivering discharge | **343** | +| cycles on which the peak guard filed *any* floor | **0** | + +### 2b. It is a behaviour, not a config value + +The same replay, same pack, same evening, with `peak_soc_floor` set to **5, 11, +20 and 60** — the shipped value and both ends of the schema range: + + peak_soc_floor = 5 -> ends at 9.9715 %, 1524.58 Wh out + peak_soc_floor = 11 -> ends at 9.9715 %, 1524.58 Wh out + peak_soc_floor = 20 -> ends at 9.9715 %, 1524.58 Wh out + peak_soc_floor = 60 -> ends at 9.9715 %, 1524.58 Wh out + +Identical to four decimal places. **Raising the option does nothing, because the +guard it bounds is not the thing doing the discharging.** + +### 2c. The two states coexist in the same cycle + +On the same day's high-load block (15:00–17:00 UTC), with the pack started one +point *below* the floor, the guard reports `PEAK GUARD FLOOR-LIMITED` — +*"commanding no forced discharge"* — on **49 cycles**, and on **all 49** the +arbiter simultaneously grants the loop a discharge of up to **2000 W**. The +status page tells the operator the truth about the guard while the pack empties. + +### 2d. Stated fairly, because overstating it is how a real finding gets ignored + +This is **not** a runaway. Self-consumption discharge is demand-driven and +self-limiting: it stops when the house stops drawing, and every watt of it is +serving load that would otherwise have been imported. It is not SAFETY-18's +frozen-meter rail, and nothing here is spending the pack for a reason of its own. +What it means is narrower and still real: + +- the **depth** the pack reaches on a heavy evening is set by the house, not by + anything an operator can configure; and +- `peak_soc_floor` **reads like a system-wide guarantee it is not.** A floor that + binds one consumer and not the other is worse than no floor if its name does + not say so — which is precisely what SAFETY-25's other half fixed. + +## 3. Why SAFETY-24's floor cannot be extended to cover this + +The peak guard files a `lo` limit — a **lower** bound on the setpoint. That +asymmetry is what makes the guard trustworthy: a reviewer proved across 19,800 +combinations that it can never lower a target, never cap a discharge, never +contradict the arbiter, and never file a negative floor. `peakguard.claim()` +leaves `hi` at `+inf` as a fact about the object rather than a rule someone has +to remember. + +**A `lo` limit is structurally incapable of stopping a discharge the loop itself +wants.** So the same property that makes the guard safe makes it useless here. +Raising `peak_soc_floor` cannot become the answer; §2b is that statement +measured. + +## 4. The floors we already have — and this would be the fifth key + +SAFETY-24 recorded a decision to keep `maintenance_soc_floor` separate with an +explicit revisit trigger: *"if a third floor appears."* **The trigger has already +fired, unremarked**, when OPT-04 shipped `arbitrage_soc_floor`. + +| option | default | bounds | priority of the consumer | +|---|---|---|---| +| `maintenance_soc_floor` | 11 | the monthly recalibration's **drain target** | `P_MAINTENANCE` 60 | +| `peak_soc_floor` | 20 | the peak guard's forced discharge | `P_PEAK` 50 (a `lo` limit) | +| `bms_soc_floor` | 10 | *nothing* — it describes the pack, so rule 8 can refuse a floor that cannot bind | — | +| `arbitrage_soc_floor` | 20 | arbitrage discharge | `P_ARBITRAGE` 30 | +| **the loop** | — | **nothing** | `P_LOOP` 10 | + +Two of them ship the same default (20) from different cards for different +reasons, which is how a value that looks deliberate becomes a coincidence nobody +dares change. **`SAFETY-27` owns the naming question and this memo does not +pre-empt it** — but any answer to §1 that adds a fifth key should land *after* +SAFETY-27's decision, or it adds a fifth accreted name to four. + +## 5. The options + +### Option A — record the BMS as the deliberate backstop; add nothing + +Say plainly, in `DOCS.md` and in `freeze.py`'s finding, that demand-driven +discharge is bounded by the pack's own cut-out on purpose, and close the +question. + +- **Protects:** the whole point of the product. Every watt the loop discharges is + self-consumption the house would otherwise have imported; a reserve is + literally energy bought at the import tariff and held back. +- **Costs:** on a heavy evening the pack ends at the BMS cut-out with no EMS-side + record of why. That is the SoC-drift condition **MAINT-01** exists to correct, + and the first symptom — a battery that is never available when it is wanted — + is indistinguishable from a coulomb counter that has drifted. It also means the + morning's peak-guard capacity is whatever last night left, which the peak guard + cannot influence. +- **Cost of building it:** zero. It is already the shipped behaviour, and after + SAFETY-25's honesty half it is already documented. + +### Option B — a `hi` limit at `P_LOOP`, with an enforced ordering *(recommended)* + +A new option `loop_soc_floor`. While `soc <= loop_soc_floor`, `main.py` files +`Claim.limit("reserve", P_LOOP, hi=0.0)`. Zero forbids **discharge** and permits +charging, which is the mirror image of the guard's `lo = 0` and one-directional +in the safe direction. + +**⚠️ This shape has one trap and it is fatal if missed.** `arbiter.resolve()` +applies **every** limit at or above the winning `set` claim's priority and takes +the most restrictive, *with no notion of one limit outranking another* — that is +deliberate and is what makes the safety bounds unconditional. So when the loop +wins at `P_LOOP`, a reserve `hi = 0` and the peak guard's `lo = excess > 0` are +**both** applied, `lo > hi`, and the arbiter resolves a **CONTRADICTION to 0 W**: +the battery parked, in the one quarter of the month that sets the bill. This is +the same crossing-limits hazard `shaping_suspended()` already exists to avoid for +the maintenance charge-only clamp. + +**The fix is an ordering constraint, and it is provable:** require + + bms_soc_floor < loop_soc_floor <= peak_soc_floor + +Then whenever the reserve binds (`soc <= loop_soc_floor <= peak_soc_floor`) the +guard is *already* floor-limited and files `lo = 0.0`, so `lo == hi == 0` and the +result is 0 W with no contradiction. Refuse any other ordering at startup, in +CFG-01's manner and with rule 8's message shape. **A `loop_soc_floor` above +`peak_soc_floor` must not be merely discouraged; it is the parked-battery bug.** + +- **Protects:** the pack from routinely sitting on its own cut-out, which is the + MAINT-01 drift condition; and it gives the operator one number that means what + its name says. +- **Costs:** a fifth SoC key (see §4), a validator rule, and — the real cost — + **every watt below the reserve becomes billed import.** Set too high it turns a + working battery into a smaller one. +- **What it does *not* touch, correctly:** a maintenance DRAIN sets at + `P_MAINTENANCE` 60 and arbitrage sets at `P_ARBITRAGE` 30, both above `P_LOOP` + 10, so a limit filed at `P_LOOP` is not applied to either. The monthly + recalibration still drains to `maintenance_soc_floor`; arbitrage is still + bounded by its own key. That is the right answer and it falls out of the + priorities rather than needing a special case. +- **Recommended starting value if this option is taken:** a *low* one — three + points above `bms_soc_floor`, i.e. **13 %**, well under `peak_soc_floor`'s 20. + The purpose is to keep the pack off its own cut-out, **not** to reserve energy + for anything, and an over-tight reserve is the version of this that fights + MAINT-01 from the other side: it shrinks the band the pack actually cycles in, + which is the condition a coulomb counter drifts in. It does not matter that 13 + sits above `maintenance_soc_floor`'s 11 — the maintenance drain sets at + `P_MAINTENANCE` 60 and a `P_LOOP` limit is not applied to it, so the two never + interact. **The number itself is part of the owner's answer**; what this memo + offers is the derivation and the ordering constraint that makes any number in + that range safe. + +### Option C — a `set` claim above `P_PEAK` + +File `Claim.set("reserve", 55, 0.0)` when the pack is under the reserve. Because +clause 2 applies only limits at or **above** the winner's priority, the guard's +`lo` at 50 would then be dropped and there is no contradiction to engineer +around. + +- **Protects:** the same as B, without the ordering rule. +- **Costs:** it is a **stop**, not a bound. It parks the battery outright rather + than merely forbidding discharge, so it also blocks the charging that would end + the condition, and it silently outranks peak shaving in a way that is invisible + on the priority table. This project's rule is that a floor about **energy** is + not a stop of last resort (`peakguard.py`, point 1). **Not recommended.** + +### Option D — clamp inside `control.compute()` + +- **Costs:** it puts precedence back inside a strategy, which is the exact thing + `arbiter.py` was built to end, and it would be invisible to `explain()`. **Not + recommended.** + +## 6. Recommendation + +**Option A is honest and cheap; Option B is what I would build.** Concretely: + +1. **Now (shipped with SAFETY-25):** the honesty half. `peak_soc_floor`, its + documentation and the status page say plainly that it bounds the peak guard + only. This is required under every option and it is the actual dishonesty on + the live system today. +2. **Owner's call:** A or B. If B, take it **after `SAFETY-27`'s naming decision** + so the fifth key lands into a scheme rather than onto a pile, and **after + `MAINT-01`** has run one real cycle — until then nobody has observed what this + pack's SoC reading does over a full cycle, and picking a reserve against a + reading that has never been recalibrated is picking a number against an + instrument we have not checked. +3. **Whichever way:** the tripwire in `test_peakguard.py` part 10 stays until the + question is answered. It **records the exposure**, so the day someone builds a + loop-side floor it goes red and has to be updated deliberately, rather than + the exposure quietly changing under a green suite. + +## 7. What the replay is and is not + +The committed captures carry **one** series — signed net power at the meter — so +the pack is modelled, and the model is stated rather than hidden: + +- the captured `w` is the load presented to the meter; the modelled battery's + delivered power is subtracted from it, one telegram late (an actuation delay); +- the loop's command is the **shipped** `control.compute()` at the shipped + tuning, resolved through the **shipped** `arbiter.resolve()` against the + **shipped** `peakguard.claim()`, at a `cycle_floor_s` tick; +- SoC falls by the delivered discharge integrated over the real telegram + timestamps, on a pack of 300 Ah × 51.2 V × DoD 90; +- the BMS is modelled as a hard cut: at or below `bms_soc_floor` the modelled + battery delivers 0 W whatever is asked of it. Without that the run would be + unphysical — the BMS really does stop the pack, and the whole finding is that + it is then the **only** thing that does; +- nothing recharges the pack inside the window. One evening, one direction. + +**The fixtures cannot express a low pack on their own** — there is no battery or +SoC telemetry in them — so the starting state of charge is a fixture parameter, +not a measurement. It is chosen as *one point above `peak_soc_floor`*, which is +derived from the shipped option rather than picked. Everything downstream of that +one number is the real capture and the real code. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 14f87da..a14385a 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -430,7 +430,7 @@ help - see "Why the tuning is what it is". |---|---| | `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap | | `peak_cap_w` | The site's capacity-tariff target | -| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** Not optional and not the same thing as `maintenance_soc_floor` — see below | +| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` | | `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start | | `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up | @@ -552,8 +552,13 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct. - **`peak_soc_floor` (20 %)** is where the forced discharge stops. Below it the guard files a **0 W** floor instead of a sized one: charging stays blocked (that can only ever lower your offtake), and no further discharge is asked for. -- **It does not stop the ordinary self-consumption loop.** That loop still has no - SoC guard of its own. This bounds what the *peak guard* spends for money. +- **⚠️ IT BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE, AND THE NAME + READS LIKE ONE.** The ordinary self-consumption loop — the grid-following + control law that holds your meter at zero — has **no SoC floor of any kind**. + It will discharge straight past this number and keep going until the pack's own + BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the + capacity-peak guard will not spend the pack for money below 20 %"*, and + **nothing more**. See below for what that costs on a real evening. - **When it binds you are told**, once per episode, with notification `goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is @@ -572,6 +577,53 @@ Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the BMS cut-out is about 1.6× the guard's worst measured day. If your pack is smaller, or your quarters are worse, raise it. +#### ⚠️ What `peak_soc_floor` does **not** protect (SAFETY-25) + +**On a sustained-load evening this add-on will take your battery to the BMS +cut-out, whatever `peak_soc_floor` says.** That is measured, not argued. Replayed +on the committed 2026-08-20 capture, 19:00–22:00 UTC — three hours of house load +after the sun has gone, 830 W average, not one watt of export in the window — with +the 13.8 kWh reference pack started at 21 %, one point above the shipped floor: + +| | | +|---|---| +| pack at the start of the evening | **21 %** (one point above `peak_soc_floor`) | +| pack three hours later | **10.0 %** — the `bms_soc_floor` cut-out | +| energy the loop took out | **1525 Wh** | +| control cycles below `peak_soc_floor` | **549 of 587**, and **343** of those still delivering discharge | +| cycles on which the peak guard filed *any* floor | **0** | +| same replay at `peak_soc_floor` = 5, 11, 20, 60 | **the same 10.0 %, to four decimal places** | + +That last row is the point: **this is a property of the control law, not a value +you can configure.** Raising `peak_soc_floor` to 60 % changes nothing at all, +because the guard it bounds is not the thing doing the discharging. + +**And the two can happen in the same cycle.** On the high-load block of the same +day (15:00–17:00 UTC) with the pack below the floor, the guard reports +`PEAK GUARD FLOOR-LIMITED` — *"commanding no forced discharge"* — on 49 cycles, +and on **all 49 of them** the loop is simultaneously being granted a discharge of +up to 2000 W by the arbiter. The status page is telling you the truth about the +guard while the battery empties. + +**Why it is built this way, and why SAFETY-24 could not fix it.** The peak guard +files a `lo` limit — a *lower* bound on the setpoint. That asymmetry is exactly +what makes the guard safe: it is provably incapable of lowering a target, capping +a discharge or contradicting the arbiter. The same property makes it incapable of +stopping a discharge the loop itself wants. A floor on the guard cannot become a +floor on the pack by being raised. + +**Is this a runaway?** No. Self-consumption discharge is demand-driven and +self-limiting: it stops when the house stops drawing, and every watt of it is +serving load you would otherwise have imported. It is not SAFETY-18's frozen-meter +rail. What it means is that the *depth* your pack reaches on a heavy evening is +set by your house, not by any number in this file, and the BMS is the only thing +underneath it. + +**Whether it should stay that way is an open product decision**, recorded with its +options, costs and a recommendation in +[`DECISIONS/SAFETY-25-loop-soc-floor.md`](DECISIONS/SAFETY-25-loop-soc-floor.md). +Until it is answered, this section is the honest statement of what you have. + ### The winding-loop alarm (the output clamp) Every setpoint this add-on writes is clamped to `max_w` before it leaves the diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index 54309cd..8c3c888 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -143,6 +143,7 @@ commit and PR bodies. | every **"all red, no survivors"** claim in the window — TARIFF-02 (24/0), TARIFF-03 (11), MAINT-04 (10), SAFETY-18 (6+4), SAFETY-03 (6), TEL-04 (10), TEL-05 (6) | **not at risk** | direction. Stale bytecode turns a red into a green survivor, never the reverse. A red is evidence that *something* changed under the check. | | **TEST-06**'s deleted assertion, "no new notification while parked at 0 W" (c34b5a2) | **not at risk / superseded** | it was recorded as undecidable and then *falsified* — `test_freeze.py` records the one-line mutation that takes the file red 9 times. The stronger later claim is a red one. | | **TARIFF-01** reviewer survivor: `record_block()` ignoring its `policy` argument (0dbd485) | **re-verified — still a survivor** | re-run here through `tools/mutation_run.sh` against the 15 suites that were on the record, all green with `billed_w(block, policy)` mutated to `billed_w(block, POLICY_NET)`. The recorded diagnosis holds: every block reaching the store is single-phase, where NET, SUM and MAX collapse. | +| **SAFETY-25** 8 mutations against `test_peakguard.py` part 10, all red | **not at risk** | each run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale - the same structural immunity recorded for CI above. Byte deltas recorded on the PR; none is `+0 B`. All eight are **reds**, which is the direction this defect cannot fabricate. | | **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. | | **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. | diff --git a/goodwe_controller/app/freeze.py b/goodwe_controller/app/freeze.py index 16a6f69..32f7a78 100644 --- a/goodwe_controller/app/freeze.py +++ b/goodwe_controller/app/freeze.py @@ -18,6 +18,14 @@ THE FAILURE NEEDS NO FAULT ANYWHERE ELSE. A frozen-but-talking meter is - there is no SoC guard on the grid-following loop at all. `soc_floor` belongs to the maintenance cycle; the BMS is the sole energy backstop. + ⚠️ STILL TRUE, AND NOW FILED. SAFETY-24 added `peak_soc_floor` and + OPT-04 added `arbitrage_soc_floor`, so the sentence above is no longer + "there is one SoC floor and it is maintenance's" - but neither of the new + ones binds THIS loop, and `peak_soc_floor`'s name reads as though it does. + SAFETY-25 replayed the exposure (test_peakguard.py part 10) and recorded + the options in DECISIONS/SAFETY-25-loop-soc-floor.md; whether the loop gets + a reserve of its own is an open product decision, not an oversight. + The loop reads a constant error and integrates it. Time from freeze to the rail, replaying the real `control.compute()` at 15 s cycles: diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index b416e7a..f9d74f1 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -985,6 +985,16 @@ class Controller: They cannot both be true: peakguard.decide()'s floor-limited branch returns before the `achievable_w` clamp that sets `saturated`. + ⚠️ SAFETY-25 - AND THE MESSAGE SAYS WHAT THE FLOOR IS NOT. Read on its + own, "the capacity-peak guard has run out of battery" invites exactly + one wrong inference: that the pack is now being held at + `peak_soc_floor`. It is not. `peak_soc_floor` bounds THIS GUARD only; + the grid-following loop has no SoC floor at all and, on the committed + 2026-08-20 replay, is being granted discharge in EVERY cycle that raises + this notification. An operator who reads the alarm and relaxes because + "the EMS has a 20 % reserve" has been misled by us, so the alarm carries + the correction itself rather than leaving it to DOCS.md. + Edge-triggered, and de-asserting arms it again - FreezeWatchdog's shape, for FreezeWatchdog's reason: one notification per episode, not one per cycle for a quarter of an hour. @@ -1010,6 +1020,13 @@ class Controller: # energy floor is not a saturated inverter and is not a frozen meter, # and an operator who has to dismiss three identical-looking cards # learns to dismiss the one that mattered. + # ⚠️ SAFETY-25's sentence must render on the unreadable-SoC branch too, + # where `soc_floor` is a number but naming it would invite the reader to + # think the pack is AT it. `decide()` guards its own reason string the + # same way; a f-string that formats a None here would turn the one + # notification that matters into a traceback. + _at_floor = ("at the floor" if g.soc_floor is None + else f"at {float(g.soc_floor):.0f} %") await self.hass.call("persistent_notification", "create", { "notification_id": "goodwe_peak_soc_floor", "title": "GoodWe: the capacity-peak guard has run out of battery", @@ -1023,7 +1040,13 @@ class Controller: f"deliberately above the pack's own BMS cut-out. Charging stays " f"blocked. Shedding load is the only thing that changes the bill " f"now; if this pack can afford a deeper discharge, lower " - f"peak_soc_floor."), + f"peak_soc_floor.\n\nSAFETY-25 - WHAT THIS FLOOR IS NOT: " + f"peak_soc_floor bounds THE PEAK GUARD ONLY. It is not a " + f"pack-wide reserve, and the battery is NOT being held " + f"{_at_floor} - ordinary self-consumption has no SoC " + f"floor at all and will go on discharging past it, down to the " + f"pack's own BMS cut-out. What has stopped is the guard buying " + f"you a lower bill, not the pack being spent."), }) async def check_meter_frozen(self) -> None: @@ -1421,8 +1444,23 @@ class Controller: # is going to be spent and a human is the only actuator left. A # separate row from saturation because the two ask for opposite # things - see check_peak_floor_limited(). + # + # ⚠️ SAFETY-25. THE SCOPE SENTENCE IS PART OF THE ROW, NOT A + # DOCS FOOTNOTE. This is the one line an operator reads at the + # exact moment the guard stops discharging, and read alone it + # says "the battery is being held at peak_soc_floor" - which is + # false, and measurably so: on the committed evening replay the + # loop goes on discharging in EVERY cycle this row is showing. + # A floor that binds one consumer and not the other is worse + # than no floor if the page does not say which. out.append({"ok": False, "warn": False, - "text": _pg.explain()}) + "text": _pg.explain() + + ". NOTE: peak_soc_floor bounds THE PEAK " + "GUARD ONLY - it is not a pack-wide " + "reserve, and ordinary self-consumption " + "has no SoC floor, so the battery may " + "keep discharging below this level down " + "to the BMS cut-out (SAFETY-25)"}) elif _pg.blind: out.append({"ok": False, "warn": True, "text": _pg.explain()}) else: diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index b420702..73544d9 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -286,6 +286,22 @@ the one place the question is answered. Four things about the shape of it: discharge, which is the thing SAFETY-07 introduced. A loop-side SoC guard is a different lever with a different right value. + ⚠️ SAFETY-25 MEASURED HOW FAR PAST, so this is no longer a caveat that has + to be taken on trust. On the committed 2026-08-20 capture, 19:00-22:00 UTC + - three hours of house load after the sun has gone, 830 W mean, zero export + - the 13.8 kWh reference pack started one point ABOVE `peak_soc_floor` + ends the evening at 10.0 %, i.e. at `bms_soc_floor`, having delivered + 1525 Wh; 343 of 587 control cycles deliver discharge with the pack already + under this floor, and the guard files no floor at all on any of them. The + same replay at `peak_soc_floor` = 5, 11, 20 and 60 ends at the SAME 10.0 %, + which is the finding stated as sharply as it can be: THE EXPOSURE IS A + PROPERTY OF THE CONTROL LAW, NOT A VALUE ANYONE CAN CONFIGURE. And on the + same day's high-load block the two states coexist - 49 cycles reporting + PEAK GUARD FLOOR-LIMITED while the arbiter grants the loop up to 2000 W of + discharge in every one of them. test_peakguard.py part 10 is the replay; + DECISIONS/SAFETY-25-loop-soc-floor.md carries the options and the + recommendation, and the decision itself is the product owner's and is open. + 3. IT IS GRADED ONLY WHERE IT IS NOT DEGENERATE. The SoC question is asked AFTER `wanted > 0` is established - i.e. only where a forced discharge is actually being asked for. Asked earlier it would grade a quantity that @@ -373,6 +389,12 @@ CEILING_MONTH_PEAK = "the month's banked peak" # notification all have to say WHICH, and "floor-limited" alone is unactionable. FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this " "cycle, and an unreadable SoC is not a full battery") +# ⚠️ SAFETY-25. "the peak guard's SoC floor", not "the SoC floor", and the +# possessive is load-bearing: this string is what the status page, the action log +# and the operator notification all print, and `peak_soc_floor` bounds THIS GUARD +# ONLY. Widening it to "the SoC floor" would make the one sentence an operator +# reads at the moment the battery stops shaving into a claim about the pack that +# the grid-following loop is at that instant disproving. FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor" SOURCE = "peak" diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 9c82544..daf5fe2 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -186,6 +186,26 @@ options: # worst modelled guard day is 878 Wh, i.e. 6.4 % - so 10 points above the # pack's own cut-out leaves the guard ~1.6 of its worst observed days of room # and still never reaches the BMS. + # + # ⚠️⚠️ SAFETY-25 - WHAT THIS DOES NOT BOUND, AND IT IS THE HALF THE NAME HIDES. + # peak_soc_floor BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE. + # Ordinary self-consumption - the grid-following loop that holds the meter at + # zero - has no SoC floor of any kind, so on a sustained-load evening it will + # discharge STRAIGHT PAST this number and go on until the pack's own BMS + # cut-out (bms_soc_floor) stops it. Replayed on the committed 2026-08-20 + # capture, 19:00-22:00 UTC: a 13.8 kWh pack starting at 21 % ends the evening + # at 10.0 %, and 343 of 587 control cycles deliver discharge with the pack + # already below this floor. Setting this key higher does not change that by + # one tenth of a percent - the same replay at every value the schema accepts + # (5..60) ends at the same 10.0 %. + # + # The reason is structural, not an oversight: the peak guard files a `lo` + # limit (a LOWER bound on the setpoint), which is what makes it provably + # unable to cap a discharge or contradict the arbiter - and therefore also + # unable to stop one the loop itself wants. See peakguard.py's module note and + # DECISIONS/SAFETY-25-loop-soc-floor.md, which records the options for a + # loop-side reserve and is waiting on the product owner. Until that is + # answered, the BMS is the only floor under self-consumption. peak_soc_floor: 20 # The pack's OWN cut-out percent - what the BMS enforces whatever is set # above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 15fc55f..03f5cc9 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -2,7 +2,7 @@ python3 test_peakguard.py -Ten parts. What each is for, and - where it matters - what it is deliberately +Eleven parts. What each is for, and - where it matters - what it is deliberately NOT allowed to be tested at: 1. THE CONTROL LAW, `excess_w`, graded AWAY from the two points where it is @@ -46,7 +46,16 @@ NOT allowed to be tested at: capture, against a MODELLED PACK small enough that the guard's own 878 Wh day would take it to the BMS cut-out - so the two worlds, before and after this card, are run against each other rather than reasoned about. - 10. MUTATION. Eleven one-line defeats of these two guards, each run against + 10. SAFETY-25: THE LOOP, WHICH THIS FLOOR DOES NOT BOUND. A TRIPWIRE, not a + guarantee - it records a defect and goes red when the defect is fixed. + Part 11's mutation 13 has said in words since SAFETY-24 that ordinary + self-consumption can take the pack past `peak_soc_floor`; this part + replays a sustained-load evening through the SHIPPED control law, the + SHIPPED guard and the REAL arbiter and measures how far past. It also + sweeps `peak_soc_floor` over the whole schema range to show the answer + does not move by one part in ten thousand, and pins the four + operator-facing surfaces that now say so. + 11. MUTATION. Eleven one-line defeats of these two guards, each run against the checks above to show they are not vacuous. ------------------------------------------------------------------------------ @@ -99,7 +108,7 @@ from app import peakguard from app.arbiter import ( P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve, ) -from app.control import peak_at_risk +from app.control import Tuning, compute, peak_at_risk from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w from app.main import Controller from app.p1 import ( @@ -169,12 +178,26 @@ def read_option(key: str): def read_schema_max(key: str): """The schema's upper bound for `key` - `int(lo,hi)` -> hi.""" + return _schema_bound(key, 1) + + +def read_schema_min(key: str): + """The schema's lower bound for `key` - `int(lo,hi)` -> lo. + + SAFETY-25's sweep needs BOTH ends: the argument there is that no value the + schema accepts changes the answer, and a sweep that only reached upwards + would leave "well, set it lower then" unanswered. + """ + return _schema_bound(key, 0) + + +def _schema_bound(key: str, which: int): body = (HERE / "config.yaml").read_text(encoding="utf-8") marker = body.index("\nschema:") for line in body[marker:].splitlines(): st = line.strip() if st.startswith(f"{key}:") and "(" in st and ")" in st: - return float(st[st.index("(") + 1:st.rindex(")")].split(",")[1]) + return float(st[st.index("(") + 1:st.rindex(")")].split(",")[which]) return None @@ -882,6 +905,11 @@ class _Hass: def __init__(self): self.writes = [] self.notifications = [] + # SAFETY-25. The whole payload, not only the id. AC 3 is about what the + # operator READS, so a fixture that records only that a notification + # fired cannot grade it - TEST-06's finding on SAFETY-18's NOT COVERED + # row, one file across. + self.calls = [] async def limits(self, entity): return (-LIVE_MAX_W, LIVE_MAX_W, 1.0) @@ -895,8 +923,15 @@ class _Hass: async def call(self, domain, service, data): self.notifications.append(data.get("notification_id")) + self.calls.append(dict(data)) return True + def last_message(self, notification_id: str) -> str: + """The body of the most recent notification with that id, or "".""" + bodies = [d.get("message", "") for d in self.calls + if d.get("notification_id") == notification_id] + return bodies[-1] if bodies else "" + def built(db_path, opts=None, store=None): hass = _Hass() @@ -1814,9 +1849,360 @@ check("...and it is a SEPARATE KEY from maintenance_soc_floor, whose SHIPPED " and "maintenance_soc_floor" not in PEAK_OPTION_DEFAULTS) + # ============================================================================== print() -print("10. mutation: what defeats these guards, and which check bites") +print("10. SAFETY-25: the loop discharges past this floor, and how far past") + +# ⚠️ WHAT THIS PART IS. It is a TRIPWIRE, in MAINT-04's manner, and it records a +# DEFECT rather than a guarantee. SAFETY-24's mutation 13 said in words that the +# grid-following loop can take the pack past `peak_soc_floor`; that sentence has +# now been written down four times across four tickets and never once measured. +# This part measures it on the committed capture and pins the number, so that: +# +# * the honesty half of SAFETY-25 - the config comment, DOCS.md, the operator +# notification and the status row - is anchored to a replayed figure rather +# than to a claim, and +# * THE DAY SOMEBODY BUILDS A LOOP-SIDE FLOOR, THIS GOES RED. That is the +# point. The exposure must not be able to change under a green suite; it has +# to be closed deliberately, and closing it means answering AC 2 and +# deleting or inverting these checks along with +# DECISIONS/SAFETY-25-loop-soc-floor.md's open status. +# +# ⚠️ AND IT DRIVES THE SHIPPED CODE, not a transcription of it: control.compute() +# at the shipped tuning, peakguard.decide(), peakguard.claim() and +# arbiter.resolve(), in main.py's order. Break any of them and this part moves - +# which is what makes it a check rather than a paragraph. See the memo section 7 +# for what is modelled and what is measured. + +# The shipped tuning, READ from config.yaml rather than typed - the same rule as +# every other number in this file. A tuning transcribed here would grade a +# control law nobody ships. +LOOP_TUNING = Tuning( + gain=read_option("gain"), max_w=read_option("max_w"), + slew_w=read_option("slew_w"), deadband_w=read_option("deadband_w"), + step_w=int(read_option("step_w")), saturation_w=read_option("saturation_w"), + saturation_cycles=int(read_option("saturation_cycles")), + target_grid_w=read_option("target_grid_w")) +LOOP_MAX_W = float(LOOP_TUNING.max_w) +print(f" the shipped loop tuning, out of config.yaml: {LOOP_TUNING}") + +# ⚠️ THE REFERENCE PACK, DERIVED FROM THE THREE HARDWARE NUMBERS THIS REPOSITORY +# ALREADY STATES, not from a rounded kWh figure typed here. peakguard.py's +# PEAK_OPTION_DEFAULTS comment says "300 Ah at 51.2 V with DoD 90 - 13.8 kWh +# usable"; that sentence is asserted below, so if the reference pack ever +# changes this fixture goes red instead of quietly modelling the old one. +REF_PACK_WH = 300.0 * 51.2 * 0.90 +_pg_src = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8") +check("the modelled pack is the repository's own reference pack, and the source " + "still says so - 300 Ah at 51.2 V with DoD 90. A fixture whose hardware " + "assumption drifts away from the module it grades is a fixture measuring " + "a machine nobody owns", + "300 Ah at 51.2 V with DoD 90" in _pg_src + and abs(REF_PACK_WH - 13800.0) < 100.0) + + +def evening(h0: int, h1: int): + """The rows of 2026-08-20's capture inside [h0, h1) UTC, as they were. + + `h1` may be 24, meaning "to the end of the capture" - the file runs to + 21:59:56 UTC, which is local midnight. + """ + lo = datetime(2026, 8, 20, h0, tzinfo=timezone.utc) + hi = (datetime(2026, 8, 21, tzinfo=timezone.utc) if h1 >= 24 + else datetime(2026, 8, 20, h1, tzinfo=timezone.utc)) + out = [] + for line in gzip.open(BAD, "rt", encoding="utf-8"): + row = json.loads(line) + t = datetime.fromisoformat(row["t"]) + if lo <= t < hi: + out.append((t, float(row["w"]))) + return out + + +def replay_loop(window, *, start_soc, soc_floor, usable_wh=REF_PACK_WH): + """The GRID-FOLLOWING LOOP over a window, closed-loop, with a modelled pack. + + part 6's `replay()` models the PEAK GUARD's discharge and pins the SoC above + the floor on purpose - it is grading SAFETY-07. This one models the thing + SAFETY-07 never claimed to bound: ordinary self-consumption. + + The cycle is main.py's, in main.py's order and with main.py's claims: + + peakguard.decide(...) the guard sees this cycle's SoC + control.compute(...) the loop sees the meter minus the battery + arbiter.resolve([supervised, loop@P_LOOP, peakguard.claim(...)]) + + ⚠️ THE ARBITER IS IN THE PATH AND THAT IS THE WHOLE POINT. Cutting it out + and reading `Decision.target_w` directly would measure the control law and + prove nothing about the floor: the question is whether the guard's `lo` + claim, filed at P_PEAK against a loop winning at P_LOOP, can cap the + discharge. It cannot, and it is `resolve()` that has to say so. + + The pack model is part 9's, one line for one line - SoC falls by the + DELIVERED discharge integrated over the real telegram stamps, the BMS is a + hard cut at `bms_soc_floor`, and nothing recharges inside the window. + `start_soc` is the one number the captures cannot supply (they carry meter + power only), and it is derived from the shipped option rather than picked. + """ + ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S) + store = fresh_store(f"s25-{window[0][0]:%H%M}-{soc_floor:.0f}-{start_soc:.0f}") + tracker = PeakTracker(store, cap_w=CAP_W) + batt = 0.0 # delivered discharge, W (positive) + prev_w = i_w = 0.0 + sat = 0 + soc = float(start_soc) + min_soc = soc + wh = 0.0 + prev_t = next_cycle = None + cycles = below = below_discharging = limited = limited_discharging = 0 + at_bms = guard_floors = 0 + max_granted = 0.0 + for t, w in window: + if prev_t is not None: + dwh = batt * (t - prev_t).total_seconds() / 3600.0 + wh += dwh + soc = max(0.0, soc - 100.0 * dwh / usable_wh) + min_soc = min(min_soc, soc) + prev_t = t + ing.submit(sample(t, w - batt)) + tracker.sync(ing) + if next_cycle is not None and t < next_cycle: + continue + next_cycle = t + timedelta(seconds=CYCLE_S) + cycles += 1 + d = decide(PeakTracker.project(ing.averager, t), batt_w=batt, + cap_w=CAP_W, reading=tracker.peak(t), + achievable_w=LOOP_MAX_W, max_hold_s=MAX_HOLD_S, soc=soc, + soc_floor=soc_floor, frozen=False) + dec = compute(prev_w, w - batt, batt, LOOP_TUNING, sat_count=sat, + i_w=i_w) + sat, i_w = dec.sat_count, dec.i_w + claims = [Claim.limit("supervised", P_SAFETY, -LOOP_MAX_W, LOOP_MAX_W, + "max_w"), + Claim.set("loop", P_LOOP, dec.target_w, dec.reason)] + pc = peakguard.claim(d) + if pc is not None: + claims.append(pc) + if pc.lo > 0.0: + guard_floors += 1 + r = resolve(claims) + prev_w = r.target_w + # THE BMS, MODELLED - part 9's line, for part 9's reason. The pack's own + # cut-out is a real floor and it works; the finding is that it is the + # only one. + if soc <= BMS_FLOOR: + at_bms += 1 + batt = 0.0 + else: + batt = max(0.0, r.target_w) + max_granted = max(max_granted, batt) + if soc < soc_floor: + below += 1 + below_discharging += 1 if batt > 0.0 else 0 + if d.floor_limited: + limited += 1 + limited_discharging += 1 if batt > 0.0 else 0 + return {"cycles": cycles, "wh": wh, "min_soc": min_soc, "end_soc": soc, + "below": below, "below_discharging": below_discharging, + "limited": limited, "limited_discharging": limited_discharging, + "at_bms": at_bms, "guard_floors": guard_floors, + "max_granted": max_granted} + + +# --- 10a. the sustained-load evening ---------------------------------------- +# 19:00-22:00 UTC is 21:00-24:00 local: the sun is down, the PV is off, and the +# house is simply drawing. Chosen for that property and CHECKED for it below - +# a window with export in it would let the loop recharge and the replay would be +# measuring something else. +EVE = evening(19, 24) +EVE_W = [w for _, w in EVE] +print(f" the evening of 2026-08-20, {EVE[0][0]:%H:%M}-{EVE[-1][0]:%H:%M} UTC: " + f"{len(EVE)} telegrams, mean {sum(EVE_W) / len(EVE_W):.0f} W, " + f"min {min(EVE_W):.0f} W, max {max(EVE_W):.0f} W") +check("the window really is a SUSTAINED-LOAD evening and not a quiet one - " + "every telegram in it is an IMPORT, so the pack has nowhere to recharge " + "from and the loop is discharging for the whole three hours", + min(EVE_W) > 0.0 and len(EVE) > 1500) + +# ⚠️ ONE POINT ABOVE THE FLOOR, DERIVED FROM THE SHIPPED OPTION. This is the +# state an operator who has read `peak_soc_floor: 20` believes is protected: the +# pack is above the number they set. It is the only fixture parameter the +# captures cannot supply, and it is taken from config.yaml rather than chosen. +EVE_START = SOC_FLOOR + 1.0 +EV = replay_loop(EVE, start_soc=EVE_START, soc_floor=SOC_FLOOR) +print(f" the reference pack ({REF_PACK_WH:.0f} Wh usable) starting at " + f"{EVE_START:.0f} %, one point above peak_soc_floor {SOC_FLOOR:.0f} %:") +print(f" ends at {EV['end_soc']:.1f} %, low-water {EV['min_soc']:.1f} %, " + f"{EV['wh']:.0f} Wh delivered by the LOOP over {EV['cycles']} cycles") +print(f" {EV['below']} cycles with the pack under peak_soc_floor, " + f"{EV['below_discharging']} of them still discharging; " + f"{EV['at_bms']} at the BMS cut-out; the guard filed a floor on " + f"{EV['guard_floors']}") +check(f"THE FINDING, REPLAYED. One sustained-load evening takes the pack from " + f"above peak_soc_floor to the pack's own BMS cut-out - " + f"{EVE_START:.0f} % -> {EV['end_soc']:.1f} % against a " + f"{BMS_FLOOR:.0f} % cut-out - and the EMS floor an operator set is not " + f"what stopped it", + EV["min_soc"] <= BMS_FLOOR + 0.05 and EV["at_bms"] > 0) +check("...and it is not one cycle of overshoot past the floor either: the loop " + "goes on delivering discharge for hundreds of cycles with the pack " + "already below the number the operator configured", + EV["below_discharging"] > 100 and EV["below"] > EV["below_discharging"]) +check("...and the peak guard is not involved at all. It never files a floor on " + "this evening, because 830 W of house load never projects a quarter over " + "the 3500 W ceiling - so `peak_soc_floor` is not merely too weak here, it " + "is never consulted", EV["guard_floors"] == 0 and EV["limited"] == 0) + +# --- 10b. and it is a BEHAVIOUR, not a config value ------------------------- +# ⚠️ THE SWEEP IS THE ARGUMENT. "Raise peak_soc_floor" is the first thing anyone +# reading the option name would try, so the check is not that it is insufficient +# - it is that it does NOTHING, at any value the schema will accept, to four +# decimal places. Both ends of the schema range are swept rather than a +# comfortable middle. +SOC_SCHEMA_LO = float(read_schema_min("peak_soc_floor")) +SOC_SCHEMA_HI = float(read_schema_max("peak_soc_floor")) +SWEEP_FLOORS = [SOC_SCHEMA_LO, BMS_FLOOR + 1.0, SOC_FLOOR, SOC_SCHEMA_HI] +SWEPT = [(f, replay_loop(EVE, start_soc=EVE_START, soc_floor=f)) + for f in SWEEP_FLOORS] +for f, res in SWEPT: + print(f" peak_soc_floor {f:5.0f} % -> ends at {res['end_soc']:.4f} %," + f" {res['wh']:.2f} Wh out, guard floors {res['guard_floors']}") +check(f"the exposure is a BEHAVIOUR, NOT A CONFIG VALUE - the same evening ends " + f"at the same state of charge at every peak_soc_floor the schema accepts " + f"({SOC_SCHEMA_LO:.0f}..{SOC_SCHEMA_HI:.0f} %), to four decimal places. " + f"This is AC 4's sentence, measured: the option an operator would reach " + f"for cannot move this number by any amount at all", + len({round(res["end_soc"], 4) for _, res in SWEPT}) == 1 + and len({round(res["wh"], 4) for _, res in SWEPT}) == 1) +# ⚠️ THE SWEEP DELIBERATELY OVERRUNS WHAT VALIDATE WOULD ACCEPT. Its low end is +# the SCHEMA's, and rule 8 refuses anything at or below `bms_soc_floor`, so a +# start at 5 % is not a configuration an operator could boot. Including it is the +# point: it closes "you swept only the legal band" without leaving the reader to +# wonder what happens outside it. The band that IS startable is bounded below by +# rule 8, and BMS_FLOOR + 1 is its first member, which is in the sweep too. +check("...and the sweep spans the whole schema range, rule 8's refused low end " + "included, so 'a legal value would have worked' is closed off rather " + "than left open. The first STARTABLE floor - one point above " + "bms_soc_floor - is swept as well, and lands on the same number", + SOC_SCHEMA_HI >= 60.0 and SOC_SCHEMA_LO <= BMS_FLOOR + and (BMS_FLOOR + 1.0) in SWEEP_FLOORS + and len([f for f in SWEEP_FLOORS if f > BMS_FLOOR]) >= 3) + +# --- 10c. the two states in the SAME cycle ---------------------------------- +# The evening never engages the guard, so it cannot show the sharpest form of +# the finding: the status page saying FLOOR-LIMITED while the pack empties. The +# same day's high-load block can, and it is the same capture and the same code. +HOT = evening(15, 17) +HOT_R = replay_loop(HOT, start_soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR) +print(f" the same day's high-load block " + f"{HOT[0][0]:%H:%M}-{HOT[-1][0]:%H:%M} UTC, pack started one point BELOW " + f"the floor:") +print(f" {HOT_R['limited']} cycles reporting PEAK GUARD FLOOR-LIMITED, " + f"{HOT_R['limited_discharging']} of them with the loop simultaneously " + f"granted discharge (max {HOT_R['max_granted']:.0f} W)") +check("THE ASYMMETRY, ON REAL DATA. On every cycle where the guard reports " + "FLOOR-LIMITED - 'commanding no forced discharge' on the status page and " + "in the operator's notification - the arbiter is at the same instant " + "granting the LOOP a discharge. A `lo` limit cannot cap a discharge, so " + "the guard's floor and the pack emptying are not alternatives; they " + "happen together", + HOT_R["limited"] > 10 + and HOT_R["limited_discharging"] == HOT_R["limited"] + and HOT_R["max_granted"] > 0.0) +check("...which is why SAFETY-24's floor could not be widened into this one, " + "and why the fix is not a bigger number. Recorded in " + "DECISIONS/SAFETY-25-loop-soc-floor.md; AC 2 is the product owner's and " + "is open", HOT_R["min_soc"] <= BMS_FLOOR + 0.05) + +# --- 10d. the trap in the obvious fix, checked rather than asserted --------- +# ⚠️ THE MEMO'S CENTRAL ENGINEERING CLAIM, RUN THROUGH THE REAL ARBITER. The +# first shape anyone reaches for is a `hi` limit on the loop - and filed while +# the peak guard is asking for a positive floor it is `lo > hi`, which +# arbiter.py resolves to 0 W: THE BATTERY PARKED, in the one quarter of the +# month that sets the bill. That is the same crossing-limits hazard +# shaping_suspended() already exists to avoid, and it is why the memo's Option B +# carries an ORDERING RULE rather than a number. +_RESERVE_HI = Claim.limit("reserve", P_LOOP, hi=0.0, reason="loop SoC floor") +_naive = resolve(SAFE + [Claim.set("loop", P_LOOP, 1800.0, "grid"), + peakguard.claim(d_ok), _RESERVE_HI]) +_ordered = resolve(SAFE + [Claim.set("loop", P_LOOP, 1800.0, "grid"), + lc, _RESERVE_HI]) +print(f" a naive loop reserve while the guard wants a floor: " + f"{_naive.explain()}") +print(f" the same reserve while the guard is floor-limited: " + f"{_ordered.explain()}") +check("a loop-side `hi` reserve filed while the peak guard is asking for a " + "POSITIVE floor is a CONTRADICTION and parks the battery at 0 W - so the " + "obvious shape of AC 2 has a trap in it, and this is it, run rather than " + "described", _naive.contradiction and near(_naive.target_w, 0.0)) +check("...and the ordering the memo proposes removes it: with " + "loop_soc_floor <= peak_soc_floor the guard is ALREADY floor-limited " + "wherever the reserve binds, so it files lo = 0, and lo == hi == 0 " + "resolves to a clean 0 W with no contradiction", + not _ordered.contradiction and near(_ordered.target_w, 0.0) + and near(lc.lo, 0.0) and peakguard.claim(d_ok).lo > 0.0) + +# --- 10e. the honesty half, pinned to the shipped text ---------------------- +# ⚠️ ASSERT THE STRING, NOT THE INTENT - TEST-06's lesson on SAFETY-18's NOT +# COVERED row, which was untested for exactly this reason: the one branch that +# exists to tell an operator what a guard does NOT cover is the one nothing +# grades. These four surfaces are the whole of AC 3, and each is checked where +# it actually renders. +_cfg_src = (HERE / "config.yaml").read_text(encoding="utf-8") +_docs_src = (HERE / "DOCS.md").read_text(encoding="utf-8") +_memo = HERE / "DECISIONS" / "SAFETY-25-loop-soc-floor.md" +check("config.yaml says next to the option itself that peak_soc_floor bounds " + "the peak guard only and is not a pack-wide reserve - the operator edits " + "this file, so it is the first place the scope has to be true", + "BOUNDS THE PEAK GUARD ONLY" in _cfg_src + and "NOT A PACK-WIDE RESERVE" in _cfg_src.upper() + and "bms_soc_floor) stops it" in _cfg_src) +check("...and DOCS.md says it in the options table AND in the section, with the " + "replayed evening rather than an assurance", + "not a pack-wide reserve" in _docs_src + and "IT BOUNDS THE PEAK GUARD ONLY" in _docs_src + and "SAFETY-25" in _docs_src) +check("...and the decision memo AC 1 asks for is COMMITTED, carries the options " + "and a recommendation, and is honest that the decision is not taken", + _memo.exists() + and "Status: OPEN. This is a memo, not a decision." in _memo.read_text( + encoding="utf-8") + and "Recommendation" in _memo.read_text(encoding="utf-8")) +check("...and DOCS.md sends the reader to it rather than leaving the open " + "question implicit", + "DECISIONS/SAFETY-25-loop-soc-floor.md" in _docs_src) + +# The two OPERATOR-FACING surfaces, rendered by the real Controller rather than +# grepped: the status row and the notification body are what a human reads at +# the moment the guard stops, and a scope sentence that lives only in a source +# comment is a scope sentence the operator never sees. +c25, hass25, NET25, _ = wired_over_limit( + TMP / "soc25.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0) +rows25 = [r for r in c25.checks() if "peak guard" in r["text"].lower()] +print(" status row: " + (rows25[0]["text"] if rows25 else "(none)")) +check("the STATUS PAGE row for a floor-limited guard says the floor bounds the " + "guard only and that self-consumption may keep discharging below it. " + "Without that sentence the page implies a pack-wide reserve at the exact " + "moment the replay above says the loop is still emptying the pack", + len(rows25) == 1 + and "PEAK GUARD FLOOR-LIMITED" in rows25[0]["text"] + and "BOUNDS THE PEAK GUARD ONLY" in rows25[0]["text"].upper() + and "not a pack-wide reserve" in rows25[0]["text"] + and "BMS cut-out" in rows25[0]["text"]) +_body25 = hass25.last_message("goodwe_peak_soc_floor") +print(f" notification body tail: ...{_body25[-180:]}") +check("...and so does the persistent notification, which outlives the page and " + "is the thing an operator reads on a phone at 19:00. It names what has " + "stopped - the guard buying a lower bill - and what has not", + "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body25 + and "not a" in _body25 and "pack-wide reserve" in _body25 + and "BMS cut-out" in _body25) + + +# ============================================================================== +print() +print("11. mutation: what defeats these guards, and which check bites") # Each entry re-implements ONE defeat and reports whether a check above would # have gone red. The last row is the honest one: it does NOT bite, and saying so @@ -1929,13 +2315,22 @@ MUT.append(("floor-limited reported but the floor still filed at `wanted`", # 13. THE SECOND ONE THAT DOES NOT BITE, and it is a real limitation rather than # a defect. Stated, because the alternative is a reader believing this card # bounds more than it does. +# +# ⚠️ SAFETY-25 CHANGED WHAT "NOT CAUGHT" MEANS HERE, AND ONLY THAT. Nothing +# PREVENTS it - the code is unchanged and a `lo` limit still cannot cap a +# discharge - but part 10 now MEASURES it on the committed capture, so the +# exposure can no longer change size under a green suite. That is the whole +# difference between an uncaught mutation and an unrecorded one. _loop_unbounded = near(r_disch.target_w, 1800.0, 1e-9) MUT.append(("the grid-following loop discharging past this floor", not _loop_unbounded, - "NOTHING, ON PURPOSE - this card bounds THE PEAK GUARD's forced " - "discharge, the thing SAFETY-07 introduced. SAFETY-18's finding " - "that the loop itself has no SoC guard is still open, and a `lo` " - "limit is structurally incapable of closing it")) + "NOTHING PREVENTS IT, ON PURPOSE - this card bounds THE PEAK " + "GUARD's forced discharge, the thing SAFETY-07 introduced. A `lo` " + "limit is structurally incapable of closing SAFETY-18's finding " + "that the loop has no SoC guard. SAFETY-25 part 10 now MEASURES " + "it: one sustained-load evening, " + f"{EVE_START:.0f} % -> {EV['end_soc']:.1f} % on the reference pack, " + f"identical at every peak_soc_floor the schema accepts")) for label, caught, where in MUT: print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}") @@ -1955,7 +2350,9 @@ check("...and so is the last one, which is not a defeat at all but the EDGE OF " "THIS CARD: the grid-following loop can still take the pack past this " "floor, because the floor bounds the peak guard's own forced discharge " "and a `lo` limit cannot cap a discharge. Saying so here is worth more " - "than a green line that would be untrue", not MUT[12][1]) + "than a green line that would be untrue - and part 10 now says it with a " + "number instead of a sentence, which is worth more again", + not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05) # ============================================================================== -- 2.54.0 From e498dd4c1c1f3361515b90f9ac6dc8b6360ba78b Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Tue, 1 Sep 2026 05:04:22 +0200 Subject: [PATCH 2/2] SAFETY-25: pin the claim-source set, so the tripwire is one The durability claim this PR shipped was FALSE, and the reviewer of #31 disproved it by building the fix. Part 10 said, in four committed places, that it "goes RED the day a loop-side floor is built, so the exposure cannot change size under a green suite". The reviewer implemented the memo's own recommended Option B in main.py - a `Claim.limit("reserve", P_LOOP, hi=0.0)` filed under a `loop_soc_floor` - and the ENTIRE SUITE STAYED GREEN. Part 10 green. Mutation 13 went on printing "NOTHING PREVENTS IT, ON PURPOSE" and the same 21 % -> 10.0 %, on a tree where something DID prevent it. Pushed to an absurd always-binding 95 % reserve it was caught only by SAFETY-24's pre-existing part 7 wiring checks; part 10 was still green. THE CAUSE IS STRUCTURAL, not a missing assertion. `replay_loop()` hand-builds main.py's claim list as a literal `[supervised, loop, peakguard.claim(...)]`, so it is blind to any claim main.py ADDS - which is exactly where AC 2 would be built. That is this repository's named failure mode: a check that observed something other than what it claimed to observe, shipped as a guarantee inside the document that recommends the change. THE FIX IS 10f: THE CLAIM-SOURCE SET, PINNED, two ways because the two fail differently. * STATICALLY, over `Controller.cycle()`'s own AST. Every `claims.append(...)` in it must be one the scan can read - an unreadable append FAILS rather than counting as nothing, which is how a scan like this goes quietly vacuous - and the sources it can file must be exactly {device, supervised, failsafe, safety, peak, arbitrage, maintenance, loop}. A ninth takes part 10 red on the line it is written, in every configuration, whether or not any fixture here reaches the state that files it. * DYNAMICALLY, through REAL `Controller.cycle()`s with main.py's OWN `resolve` spied - not a list this file built and hoped matched - swept over the WHOLE 0..100 % SoC range in the two states 10a-10c replay. Idle: exactly {device, supervised, loop}. Floor-limited: exactly {device, supervised, loop, peak}. A claim conditioned on a low pack, which is the shape a loop-side floor has, cannot hide from a sweep that visits 0 %. And the fold is checked rather than assumed: main.py's two P_SAFETY rating limits are both symmetric LIMITs, so replay_loop() modelling them as one `supervised` claim at the tighter bound is exact. MEASURED AGAINST THE COUNTEREXAMPLE. Reproduced first on the unfixed tree (green, as reported). On this tree the reviewer's Option B at its recommended default takes part 10 RED on 3 checks, and the 95 % variant on 6. Nine mutations were run in all, each against a freshly copied tree with no `__pycache__` present, mounted `:ro`; all nine red, no survivors, no `+0 B` delta. MUTATION.md records why a second round was needed: the first round's mutations were all aimed at DISARMING the guard, while the claim part 10 actually made was about the guard being FIXED. The four false sentences are replaced with what is now true, and each says WHICH half catches WHICH shape - 10f for an arbiter claim (Options B and C), 10a-10c for a clamp inside control.compute() or the guard (Option D), which the replay drives directly. AC 2 IS STILL OPEN AND NOTHING HERE BUILDS IT. The loop-side floor was a test mutant, reverted before commit. The memo still says "Status: OPEN. This is a memo, not a decision." Also, non-blocking, from the same review: the NOT READY banner had inherited the new scope sentence and become a ~550-character paragraph that the row three lines below repeated verbatim. A checks() row may now carry an optional short `banner` form; the floor-limited row does, and `status_payload()` prefers it. THE ROW IS UNCHANGED - shortening that would have been weakening AC 3 - and rows without the key are unaffected. Graded, and mutated: dropping the short form goes red. Merged origin/release/1.0 first (CI-02), keeping both tickets' CHANGELOG entries. Full suite green: 17/17 in goodwe-addon-ci (test_ci_gate.py needs `git`, which CI-02 added to .gitea/ci/Dockerfile but the local image lacks; run with it installed and it passes). Co-Authored-By: Claude Opus 5 (1M context) --- goodwe_controller/CHANGELOG.md | 43 ++- .../DECISIONS/SAFETY-25-loop-soc-floor.md | 24 +- goodwe_controller/MUTATION.md | 2 +- goodwe_controller/app/main.py | 20 +- goodwe_controller/test_peakguard.py | 255 +++++++++++++++++- 5 files changed, 329 insertions(+), 15 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 44ae617..a9dbfd8 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -62,12 +62,33 @@ shape — a `hi` limit on the loop — crosses the peak guard's `lo` and resolve **CONTRADICTION at 0 W**, the battery parked in the quarter that sets the bill, unless `bms_soc_floor < loop_soc_floor <= peak_soc_floor` is enforced at startup. -**`test_peakguard.py` part 10 is a tripwire, not a guarantee.** It records the -defect and goes **red** the day a loop-side floor is built, so the exposure cannot -change size under a green suite. Eight mutations were run against it, each -against a freshly copied tree with no `__pycache__` present at all, mounted -`:ro` — the same structural immunity `MUTATION.md` records for CI. All eight go -red on the named check. +**`test_peakguard.py` part 10 is a tripwire, not a guarantee, and part 10f is +what makes it one.** The replay half records the defect; on its own it could not +see the defect being FIXED, because `replay_loop()` hand-builds main.py's claim +list and is therefore blind to any claim main.py adds — which is exactly where a +loop-side floor would be built. PR #31's reviewer proved that by building the +memo's own Option B in `main.py` and watching the whole suite stay green. **10f +pins the claim-source set:** every `claims.append(...)` in `Controller.cycle()` +is read off its AST and must be one of eight named sources, and real +`Controller.cycle()`s — spied at main.py's own `resolve`, swept over the whole +0–100 % state-of-charge range in both the idle and the floor-limited state — must +hand the arbiter exactly `{device, supervised, loop}` and +`{device, supervised, loop, peak}`. A ninth source, at any state of charge, in +any configuration, takes part 10 red on the line it is written. A floor built +inside `control.compute()` or the guard instead moves 10a–10c's replayed numbers, +which the replay drives directly. So the exposure cannot change size under a +green suite. + +**Nine mutations, re-run against the fixed tree**, each against a freshly copied +tree with no `__pycache__` present at all, mounted `:ro` — the same structural +immunity `MUTATION.md` records for CI. Six are the AC 3 and behaviour mutations +already on this ticket's record (the status row's scope sentence, the +notification's, `config.yaml`'s, the memo deleted, the loop's gain zeroed, the +guard's claim given a `hi`); two are the reviewer's, and are the ones that +motivated 10f — the memo's own Option B at its recommended default, **now three +checks red where it was fully green**, and an absurd always-binding 95 % reserve, +**six red**; one is new, against the banner short form below. All nine are red, +no survivors, and no byte delta is `+0 B`. **The gate has never once graded the content a pull request would land, and a run that never started reported as a test failure (CI-02).** Both halves were @@ -134,6 +155,16 @@ carries `pending / Waiting to run` within seconds, and a concurrency-held one API. `/actions/tasks`, which is what was polled originally, lists only tasks that started, and cannot answer the question at all. +**The NOT READY banner no longer repeats the row underneath it.** +`status_payload()` renders the first hard-failing check as the page headline, so +the scope sentence above turned the loudest element on the page into a +550-character paragraph that the checks list then repeated verbatim. A row may +now carry an optional short `banner` form; the floor-limited row does, and its +headline is the guard's own line plus a 68-character scope tag. **The row itself +is unchanged** — the checks list is where an operator reads what the floor does +and does not bound, and shortening that would have been weakening AC 3. Rows +without the key are unaffected, and the page still renders with `textContent`. + **A setpoint device smaller than `max_w` silently disarmed the frozen-meter detector, and the status page reported it as armed (SAFETY-20).** SAFETY-18's conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the emitted command is diff --git a/goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md b/goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md index ba286b4..b0130e2 100644 --- a/goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md +++ b/goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md @@ -229,9 +229,27 @@ around. reading that has never been recalibrated is picking a number against an instrument we have not checked. 3. **Whichever way:** the tripwire in `test_peakguard.py` part 10 stays until the - question is answered. It **records the exposure**, so the day someone builds a - loop-side floor it goes red and has to be updated deliberately, rather than - the exposure quietly changing under a green suite. + question is answered, so the day someone builds a loop-side floor it goes red + and has to be updated deliberately, rather than the exposure quietly changing + under a green suite. It has two halves and they cover the two places the floor + can land: + + - **10f pins the claim-source set.** Every `claims.append(...)` in + `Controller.cycle()` is read off its AST and must be one of eight named + sources, and real cycles — spied at main.py's own `resolve`, swept over the + whole 0–100 % SoC range in both the idle and the floor-limited state — must + file exactly those. **Option B is a ninth source**, and so is **Option C**'s + `set` claim, so filing either takes part 10 red on the line it is written. + **Option A** files no claim at all — it is the honesty half already + shipped, and part 10 is the record it leaves behind. + - **10a–10c replay the shipped code**, so **Option D** — a clamp inside + `control.compute()` — moves the replayed numbers instead. + + ⚠️ **10f is load-bearing and was added late.** PR #31 shipped this section + claiming the tripwire without it, and the reviewer disproved the claim by + building Option B in `main.py`: the entire suite, part 10 included, stayed + green, and mutation 13 went on printing *"nothing prevents it, on purpose"* on + a tree where something did. Do not remove 10f while this question is open. ## 7. What the replay is and is not diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index 8c3c888..3a31d97 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -143,7 +143,7 @@ commit and PR bodies. | every **"all red, no survivors"** claim in the window — TARIFF-02 (24/0), TARIFF-03 (11), MAINT-04 (10), SAFETY-18 (6+4), SAFETY-03 (6), TEL-04 (10), TEL-05 (6) | **not at risk** | direction. Stale bytecode turns a red into a green survivor, never the reverse. A red is evidence that *something* changed under the check. | | **TEST-06**'s deleted assertion, "no new notification while parked at 0 W" (c34b5a2) | **not at risk / superseded** | it was recorded as undecidable and then *falsified* — `test_freeze.py` records the one-line mutation that takes the file red 9 times. The stronger later claim is a red one. | | **TARIFF-01** reviewer survivor: `record_block()` ignoring its `policy` argument (0dbd485) | **re-verified — still a survivor** | re-run here through `tools/mutation_run.sh` against the 15 suites that were on the record, all green with `billed_w(block, policy)` mutated to `billed_w(block, POLICY_NET)`. The recorded diagnosis holds: every block reaching the store is single-phase, where NET, SUM and MAX collapse. | -| **SAFETY-25** 8 mutations against `test_peakguard.py` part 10, all red | **not at risk** | each run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale - the same structural immunity recorded for CI above. Byte deltas recorded on the PR; none is `+0 B`. All eight are **reds**, which is the direction this defect cannot fabricate. | +| **SAFETY-25** 8 mutations against `test_peakguard.py` part 10, then 9 more after review, all red | **not at risk** | each run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale - the same structural immunity recorded for CI above. Byte deltas recorded on the PR; none is `+0 B`. All are **reds**, which is the direction this defect cannot fabricate. ⚠️ The second round exists because the first round's mutations were all aimed at DISARMING the guard, and the claim part 10 actually made was about the guard being FIXED - the reviewer built the fix and the suite stayed green. Bytecode was never the issue there; an unaimed battery was. | | **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. | | **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. | diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index f9d74f1..f36f476 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1453,7 +1453,19 @@ class Controller: # loop goes on discharging in EVERY cycle this row is showing. # A floor that binds one consumer and not the other is worse # than no floor if the page does not say which. + # + # ⚠️ AND IT CARRIES ITS OWN `banner`. status_payload() renders + # the FIRST not-ok/not-warn row as the page's headline, so + # without this the loudest element on the page would be a + # 550-character paragraph that the row three lines below + # repeats verbatim. The banner is the short form; the row keeps + # the whole sentence, because the row is where an operator + # reads what the floor does and does not bound. Shortening the + # ROW instead would be weakening AC 3. out.append({"ok": False, "warn": False, + "banner": _pg.explain() + + " - and peak_soc_floor bounds THE PEAK " + "GUARD ONLY (SAFETY-25, see below)", "text": _pg.explain() + ". NOTE: peak_soc_floor bounds THE PEAK " "GUARD ONLY - it is not a pack-wide " @@ -1566,7 +1578,13 @@ class Controller: level, banner = "bad", ("FAILSAFE — the command opposed the meter. " "Inverter held at 0 W.") elif bad: - level, banner = "bad", f"NOT READY — {bad[0]['text']}" + # ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may + # need to say more to an operator reading the checks list than fits + # in the one line at the top of the page - SAFETY-25's floor-limited + # row is the case that forced this - and a headline that is a full + # paragraph, repeated verbatim by the row underneath it, is a + # headline nobody reads. Rows without the key are unaffected. + level, banner = "bad", f"NOT READY — {bad[0].get('banner') or bad[0]['text']}" elif not self.auto: level, banner = "warn", "Stopped — inverter commanded to 0 W" elif self.maint.phase != IDLE: diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 03f5cc9..b522cfa 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -55,6 +55,15 @@ NOT allowed to be tested at: sweeps `peak_soc_floor` over the whole schema range to show the answer does not move by one part in ten thousand, and pins the four operator-facing surfaces that now say so. + ⚠️ THE REPLAY ALONE IS NOT THE TRIPWIRE, and believing it was is the + defect PR #31's reviewer found: `replay_loop()` hand-builds main.py's + claim list, so it is blind to any claim main.py ADDS - which is where a + loop-side floor would be built. 10f is what closes that. It pins the set + of claim sources `Controller.cycle()` can file, statically over its AST + and dynamically through real cycles swept over the whole SoC range, so a + ninth source takes this part red. A floor built inside `control.compute()` + or the guard instead moves 10a-10c's numbers directly, because the replay + drives both. 11. MUTATION. Eleven one-line defeats of these two guards, each run against the checks above to show they are not vacuous. @@ -110,6 +119,7 @@ from app.arbiter import ( ) from app.control import Tuning, compute, peak_at_risk from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w +from app import main as app_main from app.main import Controller from app.p1 import ( PLAUSIBLE_MAX_W, P1Ingest, QUARTER_S, QuarterAverager, make_sample, @@ -1869,6 +1879,15 @@ print("10. SAFETY-25: the loop discharges past this floor, and how far past") # deleting or inverting these checks along with # DECISIONS/SAFETY-25-loop-soc-floor.md's open status. # +# ⚠️ AND IT IS 10f THAT MAKES THAT SENTENCE TRUE, NOT 10a-10c. The replay +# below hand-builds main.py's claim list, so on its own it is blind to a +# floor filed as a NEW CLAIM - the memo's own Option B, and PR #31's +# reviewer proved the blindness by building it: the whole suite stayed +# green. 10f pins the claim-source set so that shape goes red too. The two +# halves cover the two places a loop-side floor can be built: 10f for an +# arbiter claim in Controller.cycle(), 10a-10c for a clamp inside +# control.compute() or peakguard, which the replay drives directly. +# # ⚠️ AND IT DRIVES THE SHIPPED CODE, not a transcription of it: control.compute() # at the shipped tuning, peakguard.decide(), peakguard.claim() and # arbiter.resolve(), in main.py's order. Break any of them and this part moves - @@ -2190,6 +2209,36 @@ check("the STATUS PAGE row for a floor-limited guard says the floor bounds the " and "BOUNDS THE PEAK GUARD ONLY" in rows25[0]["text"].upper() and "not a pack-wide reserve" in rows25[0]["text"] and "BMS cut-out" in rows25[0]["text"]) +# ⚠️ THE ROW KEEPS THE WHOLE SENTENCE; THE BANNER DOES NOT. status_payload() +# renders the first not-ok/not-warn row as the page headline, so before this the +# loudest element on the page was the same 550-character paragraph the row three +# lines below repeats verbatim. The scope wording is not weakened anywhere it +# matters - it is the ROW that an operator reads to learn what the floor bounds, +# and the row is asserted in full above. +_pay25 = c25.status_payload() +print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}") +# The guard's own line, which the banner has always carried and which is not +# this card's to shorten: `explain()` is SAFETY-24's sentence about a quarter +# that is going over the ceiling. +_expl25 = rows25[0]["text"].split(". NOTE:")[0] +check("the NOT READY banner is the guard's own line plus a SHORT scope tag - " + "under 90 characters of it - and NOT the row's whole paragraph repeated " + "at the top of the page. A headline that is a paragraph is a headline " + "nobody reads, and this one is followed three lines later by the same " + "text verbatim", + _pay25["level"] == "bad" + and _pay25["banner"].startswith("NOT READY") + and _expl25 in _pay25["banner"] + and "BOUNDS THE PEAK GUARD ONLY" in _pay25["banner"].upper() + and "not a pack-wide reserve" not in _pay25["banner"] + and len(_pay25["banner"]) - len(_expl25) < 90) +check("...and shortening the banner did NOT shorten the row: the full AC 3 " + "sentence is still what the checks list carries, which is the surface the " + "operator reads to find out what the floor does not bound", + len(rows25[0]["text"]) > 300 + and "not a pack-wide reserve" in rows25[0]["text"] + and "BMS cut-out" in rows25[0]["text"]) + _body25 = hass25.last_message("goodwe_peak_soc_floor") print(f" notification body tail: ...{_body25[-180:]}") check("...and so does the persistent notification, which outlives the page and " @@ -2200,6 +2249,198 @@ check("...and so does the persistent notification, which outlives the page and " and "BMS cut-out" in _body25) +# --- 10f. THE CLAIM-SOURCE SET, PINNED -------------------------------------- +# ⚠️ THIS IS WHAT MAKES 10a-10c A TRIPWIRE RATHER THAN A SNAPSHOT, and it is +# here because the first cut of this part did NOT have it and the tripwire +# sentence above was therefore FALSE. `replay_loop()` hand-builds main.py's +# claim list as a literal `[supervised, loop, peakguard.claim(...)]`, so it is +# structurally BLIND to any claim main.py ADDS - which is exactly where AC 2 +# would be built. PR #31's reviewer proved it by building the fix: they +# implemented the memo's own Option B in main.py - a +# `Claim.limit("reserve", P_LOOP, hi=0.0)` filed under a `loop_soc_floor` - and +# the whole suite stayed GREEN, this part included, while mutation 13 went on +# printing "NOTHING PREVENTS IT, ON PURPOSE" and the same 21 % -> 10.0 % on a +# tree where something DID prevent it. Pushed to an always-binding 95 % reserve +# it was still green here. That is this repository's named failure mode - a +# check that observed something other than what it claimed to observe - and it +# had been committed as a guarantee in four places. +# +# So the MODEL is now pinned to the MODELLED THING, two ways, because the two +# fail differently: +# +# * STATICALLY, over `Controller.cycle()`'s own AST. Every `claims.append(...)` +# in it must be one this scan can read, and the set of sources it can file +# must be exactly the eight below. A ninth - a `reserve`, whatever it ends +# up called - takes this red on the line it is written, in every +# configuration, whether or not any fixture here reaches the state that +# files it. +# * DYNAMICALLY, through REAL `Controller.cycle()`s with main.py's OWN +# `resolve` spied, swept over the WHOLE state-of-charge range in the two +# states 10a-10c replay. The static scan pins the NAMES; this pins that the +# names actually reach the arbiter and that nothing else does. A claim that +# appears only at a low SoC - precisely the shape a loop-side floor has - +# cannot hide from a sweep that visits 0 %. +# +# The third way a loop-side floor could be built is INSIDE `control.compute()` +# or the guard (the memo's Option D), and that needs nothing new here: the +# replay above drives both, so it moves the numbers in 10a-10c directly. + +# The whole inventory of claim sources main.py can file, in any configuration. +# ⚠️ ADDING TO THIS SET IS A DELIBERATE ACT. If you are here because you added a +# claim to `Controller.cycle()` and this went red, that is the tripwire working: +# read DECISIONS/SAFETY-25-loop-soc-floor.md, answer AC 2 in it, and update +# part 10 and mutation 13 rather than this line alone. +CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak", + "arbitrage", "maintenance", "loop"} +# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so +# that a THIRD such indirection reads as an unrecognised append rather than as a +# silent gap in the scan. +CLAIM_BUILDERS = {"peak_claim": "peak", "arb_claim": "arbitrage"} + +_main_tree = ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8")) +_cycle_def = next( + f + for cls in ast.walk(_main_tree) + if isinstance(cls, ast.ClassDef) and cls.name == "Controller" + for f in cls.body + if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle") + + +def claim_source(node): + """The source string an appended expression files, or None if unreadable.""" + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "Claim" + and node.func.attr in ("set", "limit") and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str)): + return node.args[0].value + if isinstance(node, ast.Name): + return CLAIM_BUILDERS.get(node.id) + return None + + +_appends = [n for n in ast.walk(_cycle_def) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) + and n.func.attr == "append" and isinstance(n.func.value, ast.Name) + and n.func.value.id == "claims" and len(n.args) == 1] +_filed = [claim_source(n.args[0]) for n in _appends] +print(f" main.py Controller.cycle() files {len(_appends)} claims, from " + f"{len(CLAIM_SOURCES)} sources: " + f"{', '.join(sorted({s for s in _filed if s}))}") +check("every `claims.append(...)` in main.py's cycle is one this scan can READ " + "- a claim filed through a shape it cannot parse has to fail here rather " + "than be counted as nothing, which is how a scan like this goes quietly " + "vacuous", len(_appends) >= 10 and None not in _filed) +check("...and the sources that cycle can file are EXACTLY these eight. THIS is " + "what makes part 10 a tripwire: replay_loop() hand-builds the claim list, " + "so it cannot see a claim main.py adds - and a loop-side SoC reserve is a " + "NINTH source. Filing one takes this line red on the line it is written", + set(_filed) == CLAIM_SOURCES) + + +def cycle_claims(run): + """The claim list main.py's OWN `resolve` was handed, from a real cycle. + + ⚠️ SPIED AT `app.main.resolve`, not reconstructed. The point of the whole + sub-part is that this file must stop believing its own transcription of + main.py's claim list, so the observation has to be taken from main.py's + call site and nowhere else. + """ + seen = [] + real = app_main.resolve + + def _spy(claims): + seen.append(list(claims)) + return real(claims) + + app_main.resolve = _spy + try: + out = run() + finally: + app_main.resolve = real + return out, (seen[-1] if seen else []) + + +def run_cycle(ctl): + asyncio.get_event_loop_policy().new_event_loop().run_until_complete( + ctl.cycle()) + return ctl + + +# The IDLE state, which is what 10a and 10b replay: a sustained house load well +# under the ceiling, so the guard files nothing and the loop is alone with the +# safety limits. One controller, cycled once per state of charge - the load is +# the evening's own mean, so this is the evening's cycle at every SoC it passes +# through and at every SoC it does not. +EVE_MEAN_W = sum(EVE_W) / len(EVE_W) +c_src, _ = built(TMP / "sources.db") +c_src.auto, c_src.batt, c_src.soc = True, 0.0, 100.0 +_blk_src = datetime.fromtimestamp( + int(datetime.now(timezone.utc).timestamp() // QUARTER_S) * QUARTER_S, + timezone.utc) +for _i in range(0, int((datetime.now(timezone.utc) + - _blk_src).total_seconds()) + 1, 5): + c_src.p1.submit(sample(_blk_src + timedelta(seconds=_i), EVE_MEAN_W)) +c_src.grid = EVE_MEAN_W + +# ⚠️ THE WHOLE RANGE, INCLUDING 0 %. A reserve conditioned on a low pack is +# invisible to a sweep that starts at a comfortable one, and "conditioned on a +# low pack" is the definition of the claim this is watching for. +SRC_SWEEP = sorted({float(s) for s in range(0, 101, 5)} + | {BMS_FLOOR, SOC_FLOOR, EVE_START, EV["end_soc"]}) +IDLE_SRC = {} +for _soc in SRC_SWEEP: + c_src.soc = _soc + _, _cl = cycle_claims(lambda: run_cycle(c_src)) + IDLE_SRC[_soc] = frozenset(c.source for c in _cl) +IDLE_SETS = set(IDLE_SRC.values()) +print(f" a real idle cycle at {len(SRC_SWEEP)} states of charge " + f"({min(SRC_SWEEP):.0f}..{max(SRC_SWEEP):.0f} %) files: " + + " | ".join(sorted(", ".join(sorted(s)) for s in IDLE_SETS))) +check("a REAL Controller.cycle() in the state 10a and 10b replay - auto, P1 " + "healthy, the guard idle under an 830 W load - hands the arbiter exactly " + "{device, supervised, loop}, at EVERY state of charge from 0 to 100 %. " + "Not one source appears at a low pack that does not appear at a full one, " + "which is the claim a loop-side reserve would break", + IDLE_SETS == {frozenset({"device", "supervised", "loop"})} + and len(SRC_SWEEP) >= 21 and min(SRC_SWEEP) == 0.0) + +# The ENGAGED state, which is what 10c replays: the guard over its ceiling and +# floor-limited on an empty pack. Two states of charge, both under the floor, +# because that is the band a loop-side reserve would be conditioned on. +HOT_SRC = set() +for _soc in (SOC_FLOOR - 1.0, 0.0): + (c_h, _h, _n, _r), _cl = cycle_claims( + lambda: wired_over_limit(TMP / f"sources-hot-{_soc:.0f}.db", + LIVE_MAX_W * 0.3, soc=_soc)) + HOT_SRC.add(frozenset(c.source for c in _cl)) + _hot_limited = c_h.peak_guard is not None and c_h.peak_guard.floor_limited +print(" a real FLOOR-LIMITED cycle files: " + + " | ".join(sorted(", ".join(sorted(s)) for s in HOT_SRC))) +check("...and with the guard actually FLOOR-LIMITED - 10c's state, on an empty " + "pack - the set gains the guard's own claim and NOTHING ELSE. So the " + "three sources replay_loop() hand-builds are the whole cycle in both of " + "the states it replays, rather than a subset of it that happened to " + "match", _hot_limited + and HOT_SRC == {frozenset({"device", "supervised", "loop", "peak"})}) + +# THE FOLD, stated rather than assumed. replay_loop() files ONE `supervised` +# limit where main.py files two - `device`, from the inverter's own reported +# range, and `supervised`, from `max_w`. Both are symmetric P_SAFETY limits, so +# the tighter one binds and modelling them as one is exact, not an +# approximation. Checked on the claims the real cycle actually filed. +_safety_lims = [c for c in _cl if c.source in ("device", "supervised")] +check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into " + "its one `supervised` claim is exact rather than convenient: both are " + "symmetric LIMIT claims at P_SAFETY, so the tighter of the pair binds and " + "one claim at the tighter bound is the same constraint", + len(_safety_lims) == 2 + and all(c.kind == "limit" and c.priority == P_SAFETY + and near(c.lo, -c.hi) and c.hi > 0.0 for c in _safety_lims) + and near(LOOP_MAX_W, read_option("max_w"))) + + # ============================================================================== print() print("11. mutation: what defeats these guards, and which check bites") @@ -2318,9 +2559,13 @@ MUT.append(("floor-limited reported but the floor still filed at `wanted`", # # ⚠️ SAFETY-25 CHANGED WHAT "NOT CAUGHT" MEANS HERE, AND ONLY THAT. Nothing # PREVENTS it - the code is unchanged and a `lo` limit still cannot cap a -# discharge - but part 10 now MEASURES it on the committed capture, so the -# exposure can no longer change size under a green suite. That is the whole -# difference between an uncaught mutation and an unrecorded one. +# discharge - but part 10 now MEASURES it on the committed capture and 10f +# PINS THE CLAIM-SOURCE SET around it, so the exposure can no longer change +# size under a green suite: file the reserve and 10f goes red on the line +# it is written. That is the whole difference between an uncaught mutation +# and an unrecorded one. ⚠️ THE PIN IS LOAD-BEARING FOR THIS ROW. Without +# it this rationale was FALSE - PR #31's reviewer built the memo's Option B +# and this row went on printing "NOTHING PREVENTS IT" under a green suite. _loop_unbounded = near(r_disch.target_w, 1800.0, 1e-9) MUT.append(("the grid-following loop discharging past this floor", not _loop_unbounded, @@ -2330,7 +2575,9 @@ MUT.append(("the grid-following loop discharging past this floor", "that the loop has no SoC guard. SAFETY-25 part 10 now MEASURES " "it: one sustained-load evening, " f"{EVE_START:.0f} % -> {EV['end_soc']:.1f} % on the reference pack, " - f"identical at every peak_soc_floor the schema accepts")) + f"identical at every peak_soc_floor the schema accepts - and 10f " + f"pins the claim-source set, so building the floor takes this " + f"part RED instead of leaving this row quietly stale")) for label, caught, where in MUT: print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}") -- 2.54.0