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) # ==============================================================================