diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index e804246..43b3c86 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,6 +2,64 @@ ## Unreleased +**SAFETY-25's open question is ANSWERED: the BMS cut-out is the DELIBERATE and +only backstop for demand-driven discharge, and a loop-side reserve floor is +DEFERRED to `ALLOC-01` rather than rejected (SAFETY-25).** Decided by the +product owner on 2026-09-02. **Option A is the shipped behaviour, so `app/` +changed only where a comment claimed the question was still open** - no +setpoint, no claim, no option, no default moved, and the 17-suite run is green +on the same numbers as before. + +* **Why deferring is the safe direction, and it is a property of the shape + rather than a preference.** Option B's proof - `bms_soc_floor < + loop_soc_floor <= peak_soc_floor`, verified through the real arbiter in + `test_peakguard.py` part 10d - has an unstated premise: the reserve predicate + and `discharge_permitted()` read the **same** state of charge. With two packs + they read different numbers. The reserve binds on the emptier pack and files + `hi = 0` while the peak guard, reading the other pack or an average, files + `lo = excess > 0`; `lo > hi` is arbiter clause 3 and the answer is **0 W, the + battery parked in the billed quarter** - the failure the memo itself marks + fatal. **No config-ordering validator can catch it**, because it is a runtime + divergence between two readings and not a config ordering, so CFG-01's shape + of check would pass on the exact tree that parks the battery. Option A's cost + is energy; Option B's cost built too early is a parked battery in the one + quarter of the month that sets the bill. +* **The revisit trigger is recorded explicitly, and it is `ALLOC-01`.** Under + `ARCH-01`'s host-side allocator the two claims stop sharing a list: the loop + floor becomes a **per-inverter `hi` consumed as an interval at stage A**, + while the peak floor stays a **site `lo` at stage B**. Different claim lists, + different stages, **structurally unable to cross** - which is a stronger + argument than the ordering rule, because it makes the crossing unreachable + rather than harmless. That is the shape in which Option B becomes safe to + build, and the memo says so at the point a future reader will look. +* **What will have to be RE-EARNED at N=2 is stated plainly rather than left + for someone to discover.** `SAFETY-27` now names **five floors across two + packs**, so "the SoC" stops being a scalar the config can bound. And + `test_peakguard.py` **part 10f - the pin that made SAFETY-25 mergeable** - + **cannot express two packs**: its dynamic half is a scalar 0-100 % SoC sweep + against a single-series capture, and a two-pack exposure lives in the + *difference* between two readings, which that sweep visits at no resolution. + Its static half (the AST scan pinning the eight claim sources) still bites at + N=2; the dynamic half goes silent. Merging a two-pack reserve on 10f's + current green would be merging on a check that cannot see the failure. +* **The options analysis is kept intact on purpose.** A memo that recorded the + verdict and deleted the reasoning is the failure mode this `DECISIONS/` + directory exists to prevent, so §1-§7 stand exactly as written - including + §5's *"(recommended)"* on Option B and §6's recommendation - with the new §0 + saying what was decided and §6 marked as answered. Part 10e's check moved + with the memo: it used to pin the honest `Status: OPEN` line and now pins + that the memo names the option, the date and the trigger **and still carries + both options and the recommendation**. +* **The tripwire stays, and "deferred" is why it stays.** Part 10 and mutation + 13 are unchanged in substance: a ninth claim source in `Controller.cycle()` + still takes 10f red on the line it is written. Deferred, not rejected, is + precisely the state in which someone builds Option B by accident. + +Operator-facing surfaces follow the decision and nothing else changed on them: +`DOCS.md`'s SAFETY-25 section now says a choice was made and points at the +trigger, and `app/freeze.py`'s finding and `app/peakguard.py`'s point 2 say +"decided, on purpose" where they said "open". + **`PeakStore.record_block()` now has its POLICY ARGUMENT graded, and the SAFETY-20 import check now looks at all three ways of writing an import (TEST-13).** Two survivors from TARIFF-01's review, both closed in diff --git a/goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md b/goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md index b0130e2..94051c3 100644 --- a/goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md +++ b/goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md @@ -1,20 +1,156 @@ # 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. +**Status: DECIDED 2026-09-02 — Option A.** The product owner has answered §1: +**the BMS cut-out stays the only backstop for demand-driven discharge on the +grid-following loop.** **Option B — a loop-side reserve floor — is DEFERRED**, +not rejected, with an explicit revisit trigger recorded in §0c: **`ALLOC-01`**. -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. +Option A **is the shipped behaviour**, so recording this decision changed no +code. The honesty half of SAFETY-25 — `peak_soc_floor`'s scope made true in the +config, in `DOCS.md` and on the status page — had already shipped and was +required under every option. What was missing was the answer, and the answer is +now here rather than in a Trello card that scrolls away. + +**Everything from §1 down is the memo exactly as it was written before the +decision, and is deliberately left intact.** It is the record of *why*, and a +future reader arriving at the revisit trigger needs the options analysis, the +costs, the measured replay and the trap in Option B's obvious shape — not a +one-line verdict. §5 still labels Option B *"(recommended)"* and §6 still says +"Option B is what I would build"; those were the engineering recommendation put +to the owner, they are preserved as written, and **§0 is what was decided.** + +Filed with SAFETY-25. Card 6a94b2b98b73123d520e69f2. 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 SAFETY-25's own PR** and did not wait on this, +because it was required whichever way the answer went. *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.* +too, and §0d says what N=2 does to it.* + +--- + +## 0. The decision + +### 0a. What was decided, and the owner's reasoning + +**Option A. The BMS remains the only backstop for demand-driven discharge on the +grid-following loop. Option B is deferred until `ALLOC-01` exists.** + +The reasoning is not "Option B is wrong" — §5 and §6 still stand as engineering. +It is that **deferring is the safe direction, and building now is not.** + +Option B's safety proof is the ordering constraint in §5: + + bms_soc_floor < loop_soc_floor <= peak_soc_floor + +That proof has an unstated premise: **the reserve predicate and +`discharge_permitted()` must read the SAME state of charge.** At N=1 they do, +which is why the proof reads as airtight, and why part 10d can run it through +the real arbiter and watch the contradiction disappear. + +**With two packs they read different numbers.** The reserve binds on whichever +pack is emptier and files `hi = 0`; the peak guard, reading the other pack or an +average of the two, is *not* floor-limited and files `lo = excess > 0`. Then +`lo > hi`, arbiter clause 3 fires, and the result is **0 W — the battery parked, +in the billed quarter.** That is precisely the failure §5 marks *"⚠️ fatal if +missed"*, arriving by a route the ordering rule cannot close, because +`loop_soc_floor <= peak_soc_floor` is still perfectly satisfied while it happens. + +**And no config-ordering validator can catch it.** The rule §5 proposes — refuse +the bad ordering at startup, in CFG-01's manner — grades *configuration*. This is +a **runtime divergence between two readings**, not a config ordering. The startup +check would pass, every time, on the exact tree that parks the battery. + +So the shape of the risk decides the direction: **Option A's cost is energy +(§5, "every watt below the reserve becomes billed import" — inverted: the pack +may sit on its own cut-out). Option B's cost, built too early, is a parked +battery in the one quarter of the month that sets the bill.** One of those is a +tariff line; the other is the failure this memo already calls fatal. Take the +one that cannot park the battery, and build Option B when its proof holds again. + +### 0b. What this decision does NOT say + +- It does **not** say a loop-side reserve is a bad idea. §5's protection + argument — keeping the pack off its own cut-out, which is the `MAINT-01` drift + condition — is unchanged and still wanted. +- It does **not** close §4's fifth-key question. `SAFETY-27` still owns the + naming of the floors, and any future `loop_soc_floor` still lands after it. +- It does **not** weaken §6 item 3: **the tripwire in `test_peakguard.py` + part 10 stays.** A deferred option needs its tripwire more than an open one + does, because "deferred" is exactly the state in which someone builds it by + accident. Part 10f still takes red on the line a ninth claim source is + written. Do not remove it. + +### 0c. The revisit trigger: `ALLOC-01` + +**`ALLOC-01` is the trigger, and it is a trigger because it changes the SHAPE of +the two claims, not because it changes anyone's mind.** + +`ARCH-01` decided that the site grid target is **allocated host-side, above the +driver seam**: one integrator on P1, a sum-invariant split into per-inverter +shares, and each inverter told a function of **its own share** only. It also +decided that **per-inverter constraints are consumed by the allocator as +INTERVALS, never filed as site-level claims** — which is the structural fix for +the cross-inverter `lo > hi -> 0 W` contradiction. + +Under the allocator, the two claims that collide in §0a stop sharing a claim +list at all: + +| | today (N=1, no allocator) | under `ALLOC-01` | +|---|---|---| +| the loop reserve | a site `hi` at `P_LOOP` | a **per-inverter `hi`**, consumed at **stage A** as that unit's interval | +| the peak floor | a site `lo` at `P_PEAK` | still a **site `lo`**, filed at **stage B** against the site target | + +**Different claim lists, evaluated at different stages, structurally unable to +cross.** The reserve bounds one inverter's share; the peak floor bounds the site +sum. There is no arbiter resolution in which one is the other's `lo` and `hi`, +so §0a's failure cannot be constructed — not "is guarded against", *cannot be +constructed*. That is the same class of argument as §5's ordering rule but a +stronger one: §5 makes the crossing harmless, `ALLOC-01` makes it unreachable. + +**That is the shape in which Option B becomes safe to build.** When `ALLOC-01` +exists, reopen this memo at §5 Option B, restate the reserve as a stage-A +per-inverter interval, and the ordering rule becomes an implementation +convenience rather than the load-bearing proof. + +### 0d. What has to be RE-EARNED at N=2, stated plainly + +Two things this decision leans on do not survive a second pack, and saying so is +part of the decision rather than a footnote to it. + +**1. `SAFETY-27` now names FIVE floors across TWO packs.** §4's table already +carries four keys plus "the loop"; two of them ship the same default of 20 from +different cards. At N=2 each of those is a floor *per pack*, and "the SoC" stops +being a scalar the config can bound — which is the same divergence that makes +§0a's contradiction possible. `SAFETY-27` inherits a harder question than the +one it was filed with. + +**2. `test_peakguard.py` part 10f — the guarantee that made SAFETY-25 mergeable — +CANNOT EXPRESS TWO PACKS, and will not cover the two-pack case.** Say it flatly, +because it is the load-bearing pin (§6 item 3: PR #31 shipped this section +claiming a tripwire it did not have, and the reviewer disproved the claim by +building Option B under a green suite): + +- 10f's dynamic half is a **scalar 0–100 % SoC sweep** — one number swept over + one range, in the idle and floor-limited states — against a **single-series + capture** (§7: the fixtures carry signed net power at the meter and nothing + else; the pack is modelled, and one pack is modelled). +- A two-pack exposure lives in the **difference** between two SoC readings. A + sweep over one scalar visits no point of that space, at any resolution. It + cannot fail on the divergence because it cannot represent it. +- 10f's **static** half — the AST scan pinning the eight claim sources in + `Controller.cycle()` — survives, and is the half that keeps working: a ninth + source still goes red on the line it is written, at N=2 as at N=1. It is the + *dynamic* half, the one that proves the named sources actually reach the + arbiter across the whole state space, that goes silent. + +So: **10f remains a valid tripwire against building Option B today, and is not a +guarantee about N=2.** Whoever reaches the `ALLOC-01` revisit inherits the job of +re-earning it — a two-pack fixture with two SoC series, or a claim-shape argument +at stage A that does not need one. Merging a two-pack reserve on 10f's current +green is merging on a check that cannot see the failure. --- @@ -216,6 +352,11 @@ around. ## 6. Recommendation +⚠️ **This section is the recommendation that was PUT to the owner, kept as +written. It was answered on 2026-09-02 — see §0. The owner took Option A and +deferred Option B to `ALLOC-01`; item 3 below (keep the part 10 tripwire) +was taken as written and still stands.** + **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 diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index a14385a..850a93f 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -619,10 +619,16 @@ 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. +**This is now a recorded decision, not an open question.** On **2026-09-02** the +product owner took **Option A**: the BMS cut-out is **deliberately** the only +backstop for demand-driven discharge, and a loop-side reserve floor is +**deferred** rather than rejected. The options, their costs, the measured replay +and the reasoning are in +[`DECISIONS/SAFETY-25-loop-soc-floor.md`](DECISIONS/SAFETY-25-loop-soc-floor.md), +together with the revisit trigger (`ALLOC-01`, the host-side share allocator) +under which a reserve becomes safe to build. **Nothing about the behaviour above +changed** — Option A is what has always shipped. What changed is that this +section now describes a choice somebody made, and you can read why. ### The winding-loop alarm (the output clamp) diff --git a/goodwe_controller/app/freeze.py b/goodwe_controller/app/freeze.py index 32f7a78..3e1c46d 100644 --- a/goodwe_controller/app/freeze.py +++ b/goodwe_controller/app/freeze.py @@ -23,8 +23,11 @@ THE FAILURE NEEDS NO FAULT ANYWHERE ELSE. A frozen-but-talking meter is "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 options in DECISIONS/SAFETY-25-loop-soc-floor.md. DECIDED 2026-09-02, + Option A: the BMS is the sole energy backstop for demand-driven discharge + ON PURPOSE. A loop-side reserve is DEFERRED, not rejected - its revisit + trigger is ALLOC-01. So the sentence above is a recorded decision, not an + oversight and no longer an open question. 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/peakguard.py b/goodwe_controller/app/peakguard.py index 73544d9..2c48108 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -300,7 +300,10 @@ the one place the question is answered. Four things about the shape of it: 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. + recommendation, and the product owner DECIDED it on 2026-09-02: Option A, + the BMS stays the only backstop for demand-driven discharge, with a + loop-side reserve DEFERRED to ALLOC-01. This floor's scope is therefore + settled rather than provisional - it bounds the peak guard, on purpose. 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 diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 0b5f512..3ce702a 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -1877,7 +1877,8 @@ print("10. SAFETY-25: the loop discharges past this floor, and how far past") # 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. +# DECISIONS/SAFETY-25-loop-soc-floor.md's status - OPEN when this was +# written, DECIDED (Option A, deferred B) since 2026-09-02. # # ⚠️ 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 @@ -2131,8 +2132,11 @@ check("THE ASYMMETRY, ON REAL DATA. On every cycle where the guard reports " 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) + "DECISIONS/SAFETY-25-loop-soc-floor.md, where AC 2 was DECIDED on " + "2026-09-02: Option A, the BMS stays the only backstop, and the " + "loop-side reserve is DEFERRED to ALLOC-01 - so this replay is not a " + "pending exposure but the RECORDED behaviour, and part 10 stays as its " + "tripwire", 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 @@ -2182,12 +2186,30 @@ check("...and DOCS.md says it in the options table AND in the section, with the "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", +# ⚠️ THE DECISION LANDED (2026-09-02, Option A) AND THIS CHECK MOVED WITH IT. +# It used to pin "Status: OPEN. This is a memo, not a decision." - the memo being +# HONEST that AC 2 was unanswered. AC 2 is answered now, so the same check pins +# the same property in its new form: the memo states WHICH option, WHEN, and its +# REVISIT TRIGGER, and it still carries the options and the recommendation +# UNCHANGED. A memo that recorded the verdict and deleted the reasoning would be +# the failure this is guarding against, which is why "Recommendation" and both +# rejected options are still asserted here rather than only the status line. +_memo_txt = _memo.read_text(encoding="utf-8") +check("...and the decision memo AC 1 asks for is COMMITTED, records AC 2 as " + "DECIDED with a date, an option and a revisit trigger, and STILL carries " + "the options analysis and the recommendation that were put to the owner", _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")) + and "Status: DECIDED 2026-09-02 — Option A." in _memo_txt + and "ALLOC-01" in _memo_txt + and "Recommendation" in _memo_txt + and "### Option A" in _memo_txt and "### Option B" in _memo_txt) +check("...and it says plainly that part 10f - the pin that made SAFETY-25 " + "mergeable - is a SCALAR SoC sweep against a single-series capture and " + "so cannot express two packs. The tripwire's own limits belong in the " + "memo that leans on it, or the next reader merges a two-pack reserve on " + "a green check that cannot see the failure", + "10f" in _memo_txt and "SAFETY-27" in _memo_txt + and "cannot express two packs" in _memo_txt.lower()) 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) @@ -2288,8 +2310,12 @@ check("...and so does the persistent notification, which outlives the page and " # 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. +# read DECISIONS/SAFETY-25-loop-soc-floor.md and update part 10 and mutation 13 +# rather than this line alone. ⚠️ AC 2 IS DECIDED (2026-09-02, Option A) AND +# THAT DOES NOT RETIRE THIS SET - Option B is DEFERRED, not rejected, and +# "deferred" is exactly the state in which a ninth source gets built by +# accident. Its revisit trigger is ALLOC-01, under which the reserve becomes a +# PER-INVERTER interval at the allocator rather than a ninth claim here. 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