diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 7aa05a9..cfc18e2 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -206,6 +206,67 @@ the bound from 10 to 13. That ceiling change also removes a degenerate setting: the release bound, so **no accepted configuration can stop the block engaging**. At the old bound of 9 a setting of 9 or 10 deleted it silently. +⚠️ **THE SITE TARGET IS NO LONGER SPLIT 50/50 BETWEEN TWO PACKS ([[OPT-09]]) - +AND THIS ADDS A PER-ENTRY OPTION, SO IT IS A SCHEMA CHANGE.** +`capacity_kwh: float(0.1,200)?` joins the `inverters` roster as an **optional** +per-entry key. Optional, where SAFETY-32 made `bms_soc_floor` required, and for +that card's own reason: a missing cut-out would have to be *defaulted* and one +default for two batteries protects the shallower pack with the deeper pack's +number, whereas a missing capacity has an honest answer - no opinion about the +split, every unit weighs 1.0, which is exactly what shipped. It is **all or +nothing across the roster** and rule 9 refuses a partial one. There is no flat +`capacity_kwh`: at one inverter there is no split to weight. + +The allocator has had a `weights` argument since [[ALLOC-01]] and **nothing had +ever passed one** - `main.py` called `resolve()` without it and `arbiter.py` +defaulted it to 1.0 per unit - so the split was fixed 50/50 regardless of what +either pack could do. Two things now feed it, and they are not the same kind of +thing: + +- **an ENVELOPE, which is a permission.** A new `pack` UNIT claim at `P_SAFETY` + closes a unit's `hi` at 0 W when its own SoC is at or under its own + `bms_soc_floor`, and its `lo` at 0 W at 100 %. The allocator obeys it + absolutely, so an empty pack is not asked to discharge and its share moves to + the sibling. ⚠️ **Not filed at all at a roster of one** - every source routes + to the site there, so it would bound the SITE TARGET, and [[SAFETY-25]]'s + Option A (no loop-side SoC floor) stands untouched on the shipped install. +- **WEIGHTS, which are a preference.** Between those limits each pack is asked + for the fraction matching its **energy** headroom in kWh - not its power + rating. The two packs are asymmetric in opposite directions (5000 W / + 15.36 kWh against 4600 W / 20.48 kWh **from datasheet**), so the two ways of + dividing hand the work to different packs. + +⚠️ **AND WEIGHTS ARE A COMFORT CHOICE FOR STABILITY, NOT FOR DELIVERY.** +[[ARCH-01]]'s convex combination guarantees a bad split cannot destabilise the +site; it says nothing about whether the site can regulate. Measured (two 2000 W +units, one at its floor, modelled plant, real `compute()`): at a 3000 W load the +weighted arm and the shipped arm command the **identical** +2000 W into the empty +pack and deliver the **identical** 2000 W, because `_fill()`'s last-resort pass +hands the remainder to a zero-weighted unit rather than leave watts unplaced. +Closing the envelope does not recover those watts either - it reports them: +`unmet_w` becomes +2000 W and the action log says so, where before the record +read impeccable. A unit that cannot deliver still parks the site short by its +share, and that is now visible instead of silent. + +Also: `arbitrage.decide()` is sized per pack rather than at the site rating on +both sides, so a two-pack site cannot ask to buy 9600 W with one pack able to +absorb it; and a 0 W sizing is refused by name, because a 0 W arbitrage claim is +a park order that outranks the loop. ⚠️ The sell side is a **no-op on every +roster today** - the sell gate reads the emptiest pack, so it refuses outright in +exactly the states where the sum could be smaller - and moving that gate is +deliberately not this card's; `DECISIONS/OPT-09-soc-aware-weights.md` §6 says +why. + +⚠️ **[[SAFETY-42]]'s end-to-end fixture moved, and its finding did not.** That +card's evidence was that a wedged unit and a pack at its floor are byte-identical +in the power domain. After this card the cold pack is no longer *asked*, so the +shares differ - but the two quantities SAFETY-42's rejected discriminator reads +(saturation persistence and |grid error|) are still inseparable on both arms, so +the conclusion is unchanged and is now stated on the predicate's own inputs. +`test_delivery.py` part 4 carries the whole argument. The two cards meet at one +hazard from opposite sides: **OPT-09 stops the site ASKING a pack whose SoC says +it cannot deliver; SAFETY-42 alarms when a pack that said it could does not.** + ⚠️ **AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME ([[SAFETY-42]]) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.** `delivery_hold_s: 185` joins `options:` and `schema:` (`int(30,900)`). The diff --git a/goodwe_controller/DECISIONS/OPT-09-soc-aware-weights.md b/goodwe_controller/DECISIONS/OPT-09-soc-aware-weights.md new file mode 100644 index 0000000..e409462 --- /dev/null +++ b/goodwe_controller/DECISIONS/OPT-09-soc-aware-weights.md @@ -0,0 +1,249 @@ +# OPT-09 - the weights parameter, and what it is honestly for + +**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `464d398`. + +## 0. What was actually wrong + +`allocator.plan()` has taken a `weights` argument since ALLOC-01, `_fill()` +consumes it in two passes, and **nothing had ever passed one**. Confirmed on the +base commit: + + arbiter.py:535 def resolve(claims, *, units=None, weights=None, live=None, ...) + main.py:2272 resolve(claims, units=self.units, live=self.live_units(), ...) + +So `weights` was None on every tick of every install, `plan()` turned that into +1.0 per unit, and the site target was split **50/50 regardless of what either +pack could do**. The card was filed as "per-pack arbitrage sizing"; the architect +pass found the sharper content, and it is the sharper content that this branch is +mostly about. + +## 1. Two instruments, and they are not interchangeable + +`allocator.py` states the rule this card is built on, and it is mechanical rather +than advisory: + +> WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is +> "please do not use this unit", and it is honoured while any other unit has +> headroom - but it is NOT a constraint. + +`_fill()` runs a deliberate **last-resort pass** over the zero-weight units when +the preferred ones saturate, so that a preference can never manufacture a +standing error at the meter. A pack at its own cut-out that is only *weighted* +to zero is therefore still commanded to discharge the moment its sibling fills +up. So this branch ships both: + +| | question | mechanism | +|---|---|---| +| `packshare.envelope()` | MAY this pack be asked, at all, in this direction? | a `pack` UNIT claim at `P_SAFETY`, `hi = 0` at the cut-out, `lo = 0` at 100 % | +| `packshare.weights()` | GIVEN that both may, which should carry more? | energy headroom in kWh, handed to `resolve()` | + +## 2. ⚠️ What the weights CANNOT do + +ARCH-01's convex-combination argument - the gain at the meter is +`sum(a_i * g_i)`, so it lies in `[min g_i, max g_i]` for **any** split - +guarantees that a bad split cannot **destabilise** the site. It says nothing +about whether the site can **regulate**. A unit at its energy limit has +`g_i = 0`; the combination is still inside the interval and the load it was +asked to carry simply does not move. + +Measured, `test_packshare.py` part 8, two units rated 2000 W each, B sitting on +its own floor and therefore answering 0 W to any discharge command. The plant is +modelled in that file (`lag_s = 0`, obeys within one cycle, bounded by its +rating) and the loop is the real `compute()`: + +| load | arm | site target | shares | grid | unmet | +|---|---|---|---|---|---| +| 1500 W | shipped | +3000 W | A +1500, B +1500 | 0 W | 0 W | +| 1500 W | weights | +1500 W | A +1500, B +0 | 0 W | 0 W | +| 1500 W | envelope | +1500 W | A +1500, B +0 | 0 W | 0 W | +| 3000 W | shipped | +4000 W | A +2000, B +2000 | +1000 W | 0 W | +| 3000 W | weights | +4000 W | A +2000, B +2000 | +1000 W | 0 W | +| 3000 W | envelope | +4000 W | A +2000, B **+0** | +1000 W | **+2000 W** | + +Three things are worth reading off that table, and the second and third are the +uncomfortable ones: + +1. At a load the healthy pack can cover, the weights alone deliver the whole + improvement. The shipped arm reaches the same meter reading only by winding + the site target to **twice the load** and standing a +1500 W command on a + battery that answers nothing, permanently saturation-frozen. That is not a + working site, it is a site with no headroom left for the next load step. +2. At a load it cannot cover, **the weights stop working entirely**. The + weighted arm and the shipped arm command the identical +2000 W into the empty + pack, because `_fill()`'s last-resort pass hands the remainder to the + zero-weighted unit. Same shares, same watts, same error. +3. **And the envelope does not rescue the delivery either - it names the + shortfall.** Same 2000 W delivered, same 1000 W standing at the meter. What + changes is that the empty pack is commanded 0 W instead of 2000 W, and 2000 W + of site target is *reported* as unplaceable instead of appearing to have been + placed. + +> **Weights are a comfort choice for STABILITY and NOT for DELIVERY.** Nothing in +> this add-on shifts a unit's load to its sibling once the sibling is at its +> rating, and weighting a failing unit down is not a remedy for it. + +## 2b. ⚠️ The card's own premise is only half true + +The card opens with *"a unit that stops delivering its share (empty, full, +curtailed, wedged) parks the site with a standing grid error and no alarm."* +After this branch: the **ask** stops, and while the sibling has headroom the site +regulates to zero error instead of winding to twice the load. The **standing grid +error does not stop** once the sibling is at its rating - §2's table measures +1000 W standing in all three arms - and there is **still no alarm** for it, only +a report. SAFETY-42 will not alarm on a pack at its own floor; that is one of its +acceptance criteria and its evidence is that the benign and the wedged case are +indistinguishable in the power domain. + +So the two cards together mean: an empty pack limiting the site is now REPORTED +(`unmet_w`, an action-log line) and still, deliberately, NOT ALARMED. Whether it +should be alarmed is a decision - and the alarm it would need is about a site's +stored ENERGY, not about an inverter's delivery, so it is not this card's and not +SAFETY-42's. Recorded rather than quietly closed against a premise this card only +half meets. + +## 3. Which floor, and which ceiling - the two places this could have become policy + +**The floor is `bms_soc_floor` alone, never `peakguard.pack_floor_pct()`.** That +function's conjunction with `peak_soc_floor` is right for the peak guard and +wrong here: SAFETY-25 decided (2026-09-02, Option A) that `peak_soc_floor` bounds +the peak guard **only**, ordinary self-consumption discharges straight past it, +and closing an envelope at it would turn an operator preference into a hard stop +on the grid-following loop - the memo's **deferred Option B**, built by accident. +`delivery.py`'s `account()` made the same choice one step out, for the same +reason. Graded: a pack at 15 % with a 10 % cut-out is not closed, though a 20 % +EMS floor would have closed it. + +**The ceiling is 100 %, and the coarseness is the honest answer.** There is no +per-pack charge ceiling anywhere in this add-on - `delivery.py` says so at length +and calls it a card, and it is why SAFETY-42 is armed on discharge shares only. +The only other number available is `arbitrage_soc_ceiling`, which is one +site-wide **trade policy**; filing it as a `P_SAFETY` envelope on the loop would +be the mistake above in the charge direction. So the envelope closes only where +the ask is physically impossible, and everything between is left to the weights - +which is the right instrument for a graded preference and is honest about being +only a preference. + +## 4. N=1, and the tripwire this had to argue past + +`actuator_for()` routes **every** source to the site at one inverter, so a `pack` +interval filed there would bound the SITE TARGET rather than a share. `main.py` +therefore does not file it at all below a roster of two - the same guard, for the +same reason, that SAFETY-30's `sign_site` carries. AC 4 ("at N=1 allocation is +byte-identical") is that `if`. + +`test_peakguard.py` part 10f pins the claim-source set against `main.py`'s own +AST precisely so that a state-of-charge-conditioned claim cannot arrive quietly. +`pack` is state-of-charge-conditioned, so the argument is written at that set +rather than the count edited: the number is not an option, the claim is +unit-scoped so it cannot bound the site target, it is not filed at N=1, and part +10f's own note names this as the revisit trigger - *"under ALLOC-01 the reserve +becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a ninth claim +here"*. Stage 1 is how an interval reaches the allocator and stage 1's input is +claims, so a unit-scoped claim is the shape that sentence asks for. + +**What N=1 still does not get, said plainly:** nothing stops the loop discharging +into the BMS cut-out at one inverter. That is unchanged, it is SAFETY-25's +deferred option, and the BMS is what stops it. + +## 5. SAFETY-42 - the same hazard from the opposite side + +They meet and do not overlap, and the seam is **the state of charge**: + +* **OPT-09 stops the site ASKING** a pack whose own SoC says it cannot deliver. +* **SAFETY-42 alarms** when a pack that said it could does not. + +A wedged unit reads a healthy SoC, so no envelope closes and no weight moves - +OPT-09 cannot see it at all. A pack at its floor is `account()`'s explained case, +so SAFETY-42 stays silent - and after this card that ask does not arrive in the +first place, which removes the benign arm from the detector's input rather than +teaching it a new discriminator. + +⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without +connecting it.** That module's rejection of re-allocation rests on *"the only +re-allocation this add-on can express is ALL OR NOTHING [...] a partial +re-allocation is what would make acting safe, and the allocator has no way to +express it today"*. Passing weights **is** that expression. Wiring delinquency +into them is deliberately **not** done here: it would be acting on the alarm, +which SAFETY-42 refused with rig evidence (a unit derated to 50 % is 400 W worse +dropped than kept, and no instrument this add-on has can tell it from a wedged +one), and - see §2 - a weight cannot stop a unit being commanded anyway. That is +a card, and this paragraph is its input. + +## 6. Per-pack arbitrage sizing, and the half that is deliberately not moved + +`arbitrage.decide()` was handed `site_max_w` on **both** sides, so a two-pack +site would buy at the sum of the ratings whatever either pack's state of charge +was. It is now sized to the packs whose own SoC permits that direction +(`Controller.trade_power_w()`), using **arbitrage's own** bounds rather than the +BMS cut-out - sizing a trade is the trade's business; permitting a command is the +pack's. + +Two honest limits: + +* **The sell side is a no-op on every roster today.** `decide()` is handed + `pack_floor_soc` - the emptiest pack - so a sell is refused outright whenever + *any* pack is at or under the floor, i.e. in exactly the states where the sum + could be smaller than the site rating. The arithmetic is graded directly + (`test_packshare.py` part 9); it becomes reachable the day that gate is + per-pack. +* **Moving that gate is not this card's.** Sizing a sell off the *fullest* pack + would let the trade proceed while the emptiest pack was below + `arbitrage_soc_floor`, and the only per-pack instrument that could keep it out + of that pack is the weights - which are a preference, and `_fill()` will hand + it watts anyway. Doing it safely needs an envelope that depends on WHICH CLAIM + WON the tick, which the arbiter's partition does not express. §2 is why that is + a real objection and not a hedge. + +`arbitrage.decide()` also now refuses a **0 W sizing** by name (`IDLE_NO_SIZE`), +because a `Claim.set(..., 0.0)` at `P_ARBITRAGE` is a park order that outranks +the loop - the failure the note on `claim()` is already written against. The +sizing can never be zero on a tick that trades today; that is a coincidence of +the existing gate, and this makes it structural instead. + +## 7. The fixture, which is the hard part + +Two packs that differ in only ONE of SoC and capacity cannot separate a +SoC-aware weighting from a capacity-aware one; two identical packs cannot +separate ANY weighting from any other. `test_packshare.py` part 1 grades the +fixture itself before anything is asserted with it - four candidate schemes +(equal, power rating, SoC only, energy), four distinct splits of the same +1000 W: + + equal A +500, B +500 + power A +521, B +479 + soc only A +125, B +875 + energy A +97, B +903 + +and the counterfactuals: on two identical packs all four collapse to one answer; +at the same SoC, `soc only` collapses onto `equal`; at the same capacity, +`energy` collapses onto `soc only`. + +## 8. Provenance of every number + +* A: **5000 W, 15.36 kWh, 10 % floor** - measured (GW5000S-BP, register 0x0560). +* B: **4600 W, 20.48 kWh, 10 % floor** - **FROM DATASHEET AND SERIAL** + (`goodwe/NOTES.md` §1.1b, GW5048D-ES `55048ESU22AW0992`). ⚠️ **Inverter B does + not exist** (expected ~2026-09-12). +* Every two-unit run in `test_packshare.py` is arithmetic over intervals and + weights, or a plant modelled in that file. `sim/plantsim.py:216`'s `INVERTER_B` + is **inverter A copied** with `measured=False`, so any two-inverter simulation + result anywhere in this project is **two copies of A**. +* No rig was taken, no hardware was touched, and nothing here is evidence about + a second GoodWe inverter. + +## 9. Config + +`capacity_kwh` joins the `inverters` roster as an **optional** per-entry key +(`float(0.1,200)?`) - the opposite choice to SAFETY-32's required +`bms_soc_floor`, and for that card's own reason: a missing cut-out would have to +be *defaulted*, and one default for two batteries protects the shallower pack +with the deeper pack's number, whereas a missing capacity has an honest answer - +no opinion about the split, every unit weighs 1.0, which is exactly what shipped. + +It is **all or nothing across the roster** and rule 9 refuses a partial one: a +pack measured in kWh beside a sibling weighing a dimensionless 1.0 is not a +comparison, `weights()` answers "no opinion" for the whole roster in that case, +and a key that silently changes nothing is worse than one that refuses to load. + +There is deliberately **no flat `capacity_kwh`**: at one inverter there is no +split to weight, so it would be an option an operator can set and nothing reads. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 3fc3191..c12fdba 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -136,6 +136,7 @@ either direction, and the check is mutation-tested both ways. | `soc_entity` | yes | Battery state of charge — use the **ESP32's own read** | | `batt_entity` | yes | Battery power — again the ESP32's read, `+` = discharging | | `batt_invert` | | Flip if needed | +| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below | | `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` | Use the ESP32's readings rather than the inverter's cloud or dongle sensors: @@ -161,11 +162,13 @@ inverters: soc_entity: sensor.goodwe_a_soc batt_entity: sensor.goodwe_a_battery_power max_w: 5000 + capacity_kwh: 15.36 - name: inverter-b setpoint_entity: number.goodwe_b_setpoint_w soc_entity: sensor.goodwe_b_soc batt_entity: sensor.goodwe_b_battery_power max_w: 4600 + capacity_kwh: 20.48 ``` ⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a @@ -229,6 +232,51 @@ in the log rather than silent, but it is an outage until someone puts the single-inverter entity back in that field. **Before downgrading, write down the `setpoint_entity` of the unit you want to keep running.** +#### How the site target is divided between the packs (OPT-09) + +Two packs do not get half each. Since 0.7.x the split is driven by two separate +things, and it is worth knowing which is which, because only one of them is a +rule: + +* **The envelope — a permission.** A pack whose own state of charge is at or + under its own `bms_soc_floor` is **not asked to discharge**; a pack at 100 % + is **not asked to charge**. Its share goes to the sibling, inside that + sibling's own rating. This is absolute: the allocator cannot exceed it. +* **The weights — a preference.** Between those limits, each pack is asked for + the fraction of the site target that matches its **energy headroom**: the kWh + it has above its own cut-out when the site is discharging, the kWh it has + below full when the site is charging. That is what `capacity_kwh` is for. + +⚠️ **`capacity_kwh` is optional, and it is all-or-nothing.** Leave it out +everywhere and the add-on has no opinion about the split — the target is divided +evenly, exactly as it was before. Fill it in on **every** entry or on none: a +pack measured in kWh beside one that is not is not a comparison, and the add-on +refuses to start rather than ignore half of what you typed. + +⚠️ **Why energy and not the rating.** The two packs here pull in opposite +directions — A is 5000 W with 15.36 kWh, B is 4600 W with 20.48 kWh, so B has +about a third more energy and about 8 % less power. Dividing by rating hands the +work to A; dividing by energy hands it to B. The question a split is answering is +"which pack can sustain its share for the hour", so it is the energy one. Each +inverter's own rating still binds its own share, separately, through `max_w`. + +⚠️ **What the weights will NOT do for you, stated plainly.** They are a comfort +choice about which pack does the work. They are **not** a way to make the site +deliver. A unit that is at its energy limit — or that has simply stopped +responding — does not hand its load to its sibling: the loop sees the shortfall, +freezes, and the site runs short by that unit's share. Weighting a failing unit +down does not fix that, because when the healthy unit reaches its own rating the +allocator hands the remainder to the down-weighted one anyway rather than leave +watts unplaced. What you get instead is honesty: the action log reports the watts +that could not be placed (`ALLOCATOR: +2000 W of the site target could not be +placed on any inverter`), and a unit that accepts its share and does not produce +it is named by the delivery alarm (SAFETY-42, above). + +⚠️ **At one inverter none of this applies and nothing changed.** There is no +split to weight, `capacity_kwh` is not read, and no envelope is closed — +self-consumption still discharges down to the battery's own cut-out exactly as +it always has. + ### What appears in Home Assistant at two inverters **Nothing you already have changes.** The eight entities this add-on has always diff --git a/goodwe_controller/app/allocator.py b/goodwe_controller/app/allocator.py index 73913fa..2bc9b22 100644 --- a/goodwe_controller/app/allocator.py +++ b/goodwe_controller/app/allocator.py @@ -394,6 +394,14 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(), `.pinned_by` and `.contradiction` so that this module does not import the arbiter and cannot grow a dependency on claims. `weights` unit -> non-negative comfort weight. Missing units weigh 1.0. + ⚠️ A MAPPING, ALWAYS - never a function. OPT-09 lets `resolve()` + accept a callable of the site target, because an energy-headroom + weighting is direction-dependent and the direction is stage 2's + answer; `resolve()` calls it and hands the RESULT here. Keeping + that out of this module is what keeps `plan()` a pure function of + numbers and intervals: a callback invoked inside the fill would + put a caller's code, and whatever it reads, back into the module + whose whole discipline is that it reads nothing. `live` the units whose master has reported recently enough to be commanded, or None for "all of them". A unit not listed is DROPPED and the record says so; the survivors then carry the diff --git a/goodwe_controller/app/arbiter.py b/goodwe_controller/app/arbiter.py index abcbc5b..2ecfc36 100644 --- a/goodwe_controller/app/arbiter.py +++ b/goodwe_controller/app/arbiter.py @@ -205,6 +205,27 @@ CLAIM_SCOPE = { # At N=1 the two are the same comparison and this claim is the one filed, # exactly as it shipped. "failsafe": SCOPE_UNIT, + # main.py: `Claim.limit("pack", P_SAFETY, lo, hi, ...)`, OPT-09. THIS PACK'S + # OWN STATE OF CHARGE against THIS PACK'S OWN `bms_soc_floor`, filed only + # where it BINDS and only at a roster of two or more. It says "this battery + # cannot do that", which is a fact about one cupboard in the same way + # `device`'s rating is a fact about one box, so it is scoped like `device` + # and filed at the same priority. + # + # ⚠️ AND IT IS NOT SAFETY-25's DEFERRED LOOP-SIDE RESERVE, WHICH LOOKS + # EXACTLY LIKE IT AND IS THE REASON test_peakguard.py PART 10f EXISTS. That + # one was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW + # `loop_soc_floor` option - an EMS preference, chosen by an operator, + # stopping the grid-following loop at a depth somebody picked. This is the + # pack's own BMS cut-out, which is measured off register 0x0560 and required + # of every roster entry since SAFETY-32; no option is added, `peak_soc_floor` + # is deliberately NOT consulted, and the SITE target is not touched - only + # which unit carries it. Part 10f's own note names the revisit trigger: + # "under ALLOC-01 the reserve becomes a PER-INVERTER INTERVAL AT THE + # ALLOCATOR rather than a ninth claim here", and stage 1 is how an interval + # reaches the allocator, so a unit-scoped claim is the shape that sentence + # asks for rather than an evasion of it. + "pack": SCOPE_UNIT, # main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns # the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when # it only shapes. §12's monthly recalibration is a PACK procedure - drain @@ -544,7 +565,10 @@ def resolve(claims: list, *, units=None, weights=None, live=None, `weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals, - wear - and cannot destabilise the site whatever it says. `live` is the set + wear - and cannot destabilise the site whatever it says. ⚠️ OPT-09 LET IT BE + A CALLABLE as well as a mapping: called with the resolved site target once + stage 2 has run, because an energy-headroom weighting is direction-dependent + and the direction is stage 2's answer. See the call site below. `live` is the set of units whose master has reported recently enough to be commanded; a unit left out of it is DROPPED from the envelope set and the record says so, because the survivor carrying a dead unit's share silently is the failure @@ -600,6 +624,27 @@ def resolve(claims: list, *, units=None, weights=None, live=None, # left as a log line nothing grades. out.scope_errors = scope_violations(claims) out.unit_bounds = bounds + # OPT-09. THE WEIGHTS MAY ARRIVE AS A FUNCTION OF THE SITE TARGET, and this + # is the one line that makes SoC-aware weighting expressible at all. + # + # ⚠️ THE REASON IS THAT THE DIRECTION IS NOT KNOWN ANY EARLIER. A weighting + # by ENERGY HEADROOM is direction-dependent - room above the floor when the + # site is discharging, room below full when it is charging - and the two + # give opposite answers on the same roster. The site target is chosen by + # stage 2, three lines up, so a caller computing weights before `resolve()` + # would have to guess which way the site is about to go, and would be wrong + # in one of the two directions on every tick. + # + # ⚠️ IT IS CALLED ONCE, HERE, BETWEEN STAGE 2 AND STAGE 3, AND NEVER FROM + # INSIDE THE ALLOCATOR. `allocator.plan()` goes on taking a plain mapping, + # which is what keeps it a pure function of numbers and intervals: a + # callback invoked inside the fill would put a caller's code - and whatever + # it reads - back into the module whose whole discipline is that it reads + # nothing. See `packshare.weights()` on the residual this leaves (the fill's + # own direction can differ from the target's sign under a pin, in which case + # the weighting is a worse preference and still cannot be unsafe). + if callable(weights): + weights = weights(out.target_w) out.shares, out.allocated, out.allocation = allocate_record( out.target_w, bounds, weights=weights, live=live, scope_errors=out.scope_errors, site_priority=out.winner_priority, diff --git a/goodwe_controller/app/arbitrage.py b/goodwe_controller/app/arbitrage.py index e29770a..cb11a58 100644 --- a/goodwe_controller/app/arbitrage.py +++ b/goodwe_controller/app/arbitrage.py @@ -173,6 +173,17 @@ IDLE_NO_SOC = ( "state of charge is not being read, and a battery whose fill nobody knows " "cannot be told to empty or to fill") IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour" +# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE +# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating +# and are now SIZED to the packs whose state of charge permits that direction, so +# a caller can hand this function a zero. A zero must not reach `claim()`: a +# `Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the loop, +# which is the failure the note on `claim()` is written against - it would +# silently disable self-consumption for the whole hour. So the sizing is checked +# where the decision is made rather than trusted to arrive positive. +IDLE_NO_SIZE = ( + "no pack's state of charge permits this side of the trade, so it is sized " + "at 0 W - and a 0 W arbitrage claim is a park order, not a trade") NEGATIVE_PRICE = ( "§17: the sell hour is priced BELOW ZERO, so discharging into it would give " "energy away and pay for the privilege. Arbitrage must not export at a " @@ -372,6 +383,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct): f"the sell hour is here at {price:.4f} EUR/kWh but the pack is " f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor " f"arbitrage may not spend (§10.1 priority 4)", **common) + if not abs(float(discharge_w)) > 0.0: + return _idle(IDLE_NO_SIZE, **common) return ArbitrageDecision( action=DISCHARGE, power_w=abs(float(discharge_w)), reason=(f"{DISCHARGE}: this is the dearest firm hour at " @@ -388,6 +401,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct): f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at " f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is " f"nowhere to put the cheap energy", **common) + if not abs(float(charge_w)) > 0.0: + return _idle(IDLE_NO_SIZE, **common) return ArbitrageDecision( action=CHARGE, power_w=-abs(float(charge_w)), reason=(f"{CHARGE}: this is the cheapest firm hour at " diff --git a/goodwe_controller/app/inverter.py b/goodwe_controller/app/inverter.py index f16f341..eb886f7 100644 --- a/goodwe_controller/app/inverter.py +++ b/goodwe_controller/app/inverter.py @@ -192,8 +192,8 @@ class Inverter: def __init__(self, name, *, setpoint_entity="", soc_entity="", batt_entity="", batt_invert=False, max_w=2000.0, - bms_soc_floor=None, store=None, sign_cfg=None, - delivery_cfg=None): + bms_soc_floor=None, capacity_kwh=None, store=None, + sign_cfg=None, delivery_cfg=None): self.name = str(name) self.setpoint_entity = str(setpoint_entity or "") self.soc_entity = str(soc_entity or "") @@ -215,6 +215,20 @@ class Inverter: # the config by name long before that, at startup. self.bms_soc_floor = (None if bms_soc_floor is None else float(bms_soc_floor)) + # OPT-09. THIS PACK'S USABLE CAPACITY IN kWh, or None when the roster + # entry did not state one. + # + # ⚠️ OPTIONAL WHERE `bms_soc_floor` IS REQUIRED, AND THE ASYMMETRY IS + # THE POINT RATHER THAN AN INCONSISTENCY. A missing cut-out would have + # to be defaulted, and one default for two batteries protects the + # shallower pack with the deeper pack's number - SAFETY-32's whole card. + # A missing capacity has an honest answer that costs nothing: this pack + # states no energy headroom, so `packshare.weights()` has NO OPINION + # about the split and every unit weighs 1.0, which is exactly the + # behaviour that shipped. None, never 0.0: a zero-capacity pack is one + # that may never be used, and nobody decides that by omitting a key. + self.capacity_kwh = (None if capacity_kwh is None + else float(capacity_kwh)) # --- the device's own reported range, read from its own entity ------- # None until `hass.limits()` has answered. NOT guessed at: ESPHome is @@ -482,7 +496,9 @@ def build_roster(opts: dict, store, sign_cfg=None, it - see the options-block note on `inverters:`. NON-EMPTY each entry describes one inverter and the flat keys describe - none of them. ⚠️ NOTHING IS INHERITED. An entry that omits + none of them. OPT-09's `capacity_kwh` is an entry key too, and an + OPTIONAL one - see `_entry_capacity_kwh()` and `Inverter.__init__` + on why that absence is answerable where a missing cut-out is not. ⚠️ NOTHING IS INHERITED. An entry that omits `soc_entity` has no SoC sensor; it does NOT borrow the flat one, because "this unit's number came from somewhere else" is the defect DEV-02 spent a card removing and a fallback here would @@ -519,6 +535,13 @@ def build_roster(opts: dict, store, sign_cfg=None, # mirror exactly as validate.py rule 8 defaults it, so an install # that never set the key reads the same 10 % in both places. bms_soc_floor=_flat_bms_floor(opts), + # ⚠️ OPT-09 ADDS NO FLAT `capacity_kwh`, AND THE ABSENCE IS THE + # DECISION. Capacity is read for ONE purpose - weighting a split + # between two packs - and at the single-inverter shape there is no + # split: `allocator.plan()` is a pass-through at N<=1 and a weight + # is arithmetic nobody reads. A flat key would be an option an + # operator can set, that the add-on reads, and that changes nothing, + # which is the worst of the three. store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)] roster = [] @@ -546,6 +569,11 @@ def build_roster(opts: dict, store, sign_cfg=None, # SAFETY-32's own defect written in three lines - the shallower pack # graded against the deeper pack's BMS. bms_soc_floor=_entry_bms_floor(entry), + # OPT-09, and NOT inherited either, for the reason above it: a + # capacity borrowed from a sibling would weight a 15.36 kWh pack as + # though it were a 20.48 kWh one, which is the split this card + # exists to stop being wrong. + capacity_kwh=_entry_capacity_kwh(entry), store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)) return roster @@ -617,6 +645,30 @@ def _flat_max_w(opts: dict) -> float: except (TypeError, ValueError): return 0.0 +def _entry_capacity_kwh(entry: dict): + """One entry's OWN usable capacity in kWh, or None when it stated none. + + None rather than 0.0 and rather than a borrowed number: `_entry_max_w()` can + answer 0.0 because a zero rating commands nothing and rule 9 refuses it by + name, but a zero CAPACITY is a pack with no energy headroom in either + direction, i.e. a pack the weighting would silently stop preferring. "Not + stated" and "empty" must not be the same answer - `packshare.weights()` + turns the first into "no opinion at all" and the second into a weight of + zero, and those are different splits. + + A NON-POSITIVE or unparseable value also answers None. Rule 9 refuses it at + startup with a message; until it does, withholding is the direction that + cannot invent a preference. + """ + raw = entry.get("capacity_kwh") + if raw is None or raw == "": + return None + try: + val = float(raw) + except (TypeError, ValueError): + return None + return val if val > 0.0 else None + def _entry_max_w(entry: dict) -> float: """One entry's supervised cap, or 0.0 when it did not state a usable one. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index efec4b4..7dba693 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -65,6 +65,7 @@ from .maintenance import ( from .mqtt import (MqttPublisher, announced_units, unit_slugs, unit_state_key) from . import peakguard +from . import packshare from .p1 import P1Ingest, build_source, is_enabled from .price import Uncertainty, price_option from .static_tariff import resolve_tariff, static_status @@ -145,6 +146,23 @@ def load_options() -> dict: return {} + +def pack_energy_of(inv): + """OPT-09. ONE roster entry as the view `packshare` consumes. + + ⚠️ ONE BUILDER, TWO CALLERS, AND THAT IS THE POINT. `Controller.pack_energy` + needs the whole roster (for the weights and the trade sizing) and + `Controller.cycle()` needs one entry at a time (for that unit's `pack` + claim). Written out twice, the two would drift - and the direction they + would drift in is a unit whose envelope is computed from a different set of + numbers than its weight, which is precisely the class of defect DEV-02 spent + a card removing. It is a module function rather than a method on `Inverter` + so that `inverter.py` does not have to import `packshare`. + """ + return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor, + inv.capacity_kwh, inv.max_w) + + class Controller: def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop, peak_db_path: str = "/data/tariff.db"): @@ -628,6 +646,12 @@ class Controller: # edge-triggered. Starts as the empty tuple - nothing has been dropped # yet and nothing has been said about it. self._dropped_last = () + # OPT-09. The last cycle's `unmet_w`, so the line that reports it is + # edge-triggered for `_dropped_last`'s reason: a site that cannot place + # every watt stays that way for as long as the pack is empty, and a line + # a second would flush the 40-row action log of the context explaining + # it. 0 is "every watt was placed", which is the state at boot. + self._unmet_last = 0 # ⚠️ THE SITE MEASUREMENT, AND THE ONLY ONE LEFT ON THIS OBJECT. `soc` # and `batt` were per-device scalars and now live on each `Inverter`; # `grid` is P1, one meter, one connection, and belongs here. @@ -1015,6 +1039,55 @@ class Controller: """ return float(sum(inv.max_w for inv in self.inverters)) + def trade_power_w(self, floor_pct, ceiling_pct) -> tuple: + """OPT-09. `(charge_w, discharge_w)` - how hard a trade may be SIZED. + + The site rating is what the site can push; this is what the site can + push AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` used to be + handed `site_max_w` on both sides, so on a roster of two the trade was + sized at the sum of the ratings whatever either pack's state of charge + was - the site would buy at 9600 W with only one pack able to absorb it, + and the watts that could not be placed would come back as `unmet_w` for + the site integrator to chase for the rest of the hour. + + ⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` / + `arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, because SIZING + A TRADE IS THE TRADE'S BUSINESS. This method answers "how much may this + strategy ask for"; the ENVELOPE - `packshare.envelope()`, filed as a + `pack` claim - answers "what may this pack be commanded at all", and + that one is the pack's own physical cut-out for the reason SAFETY-25 + gives. Sizing with the physical floor would let the strategy ask for + watts its own reserve forbids; bounding the envelope with the trade's + reserve would let a trade preference stop the grid-following loop. + THEY ARE DIFFERENT QUESTIONS AND THEY GET DIFFERENT NUMBERS. + + ⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack ends up + carrying the trade is the allocator's answer, and the only per-pack + instrument it has for a TRADE reserve is the weights - which are a + preference, not a permission (`allocator._fill()` will hand watts to a + zero-weighted unit rather than leave them unmet). So a pack between its + arbitrage floor and its BMS cut-out can still take part of a sell. That + is stated rather than fixed: fixing it needs a per-pack envelope that + depends on WHICH claim won the tick, which the arbiter's partition does + not express, and is a card of its own. + + A pack whose SoC did not read is counted on NEITHER side. It is not + reachable with control running - `inputs_ok` stops the site when any + pack's SoC is missing - and withholding is the direction that cannot + over-size. + + ⚠️ NEITHER SIDE CAN BE ZERO ON A TICK THAT ACTUALLY TRADES, and it is + not left to that coincidence: `arbitrage.decide()` refuses a 0 W sizing + by name (`IDLE_NO_SIZE`), because a 0 W arbitrage claim is a park order + that outranks the loop. + """ + # ⚠️ THE REDUCTION ITSELF LIVES IN `packshare`, NOT HERE, and this + # method is the roster view it is applied to. A transcription of the + # arithmetic into a check is one copy plus a future disagreement about + # what it means, and the copy that drifts is always the grading one - + # so `test_packshare.py` part 9 calls the SAME function this does. + return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct) + @property def site_rating_w(self) -> float: """The largest magnitude the SITE can actually be commanded, in W. @@ -1132,6 +1205,44 @@ class Controller: return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor) for inv in self.inverters] + @property + def pack_energy(self) -> list: + """OPT-09. Each pack as the FOUR numbers a split needs, in roster order. + + The fourth is `capacity_kwh`, and it is why this is not `packs` above. + That list answers the FLOOR question - may this pack be spent - and its + consumers (`pack_permissions`, `binding_soc_floor`) have no use for a + capacity and would have to default one. This list answers the SPLIT + question - which pack should carry more of the site target - and cannot + be asked without one. + + ⚠️ EVERY unit, live or not, for `packs`'s reason. The allocator drops + what `live_units()` does not list and `plan()` ignores a weight for a + unit it dropped, so filtering here would only move the same decision to + a worse place - and `packshare.weights()` needs to see a pack that did + not answer in order to withhold its opinion about the whole roster. + """ + return [pack_energy_of(inv) for inv in self.inverters] + + def pack_weights(self, target_w): + """OPT-09. The comfort weights for a site target of `target_w`. + + ⚠️ IT IS A METHOD HANDED TO `resolve()`, NOT A DICT COMPUTED BEFORE IT, + and that is not indirection for its own sake. An energy-headroom + weighting is direction-dependent - room above the floor when the site is + discharging, room below full when it is charging - and the two give + opposite answers on the same roster. The direction is the SIGN OF THE + SITE TARGET, which is stage 2's output, so nothing here can know it + until `resolve()` has run stage 2. `arbiter.resolve()` calls this once, + between stage 2 and stage 3, with the number it just chose. + + ⚠️ AND IT IS READ FRESH ON EVERY CALL, NEVER CACHED, for the reason the + allocator refuses to cache an envelope: a pack's SoC moves for reasons + that have nothing to do with the allocator, and a weighting held from + construction prefers a pack that has since emptied. + """ + return packshare.weights(self.pack_energy, target_w) + @property def inputs_ok(self) -> bool: """The site meter, and EVERY pack's own two readings. @@ -1930,6 +2041,39 @@ class Controller: # setpoint entity (§11.3), not a bound on the grid connection. claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w", actuator=self.actuator_of("supervised", inv))) + # --- OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS ------------- + # ⚠️ THE POINT OF THE CARD, AND IT IS AN ENVELOPE RATHER THAN A + # WEIGHT. Weighting an empty pack down does NOT stop it being + # commanded: `allocator._fill()` runs a deliberate last-resort pass + # over the zero-weight units when the preferred ones saturate, so + # that a preference can never manufacture a standing error at the + # meter. Only a closed envelope stops the ask, and `cap` is obeyed + # absolutely. So a pack at or under its own BMS cut-out gets + # `hi = 0` and a pack at 100 % gets `lo = 0`, and the site target + # goes to the sibling instead of being asked of a battery that + # cannot answer it. + # + # ⚠️ AT A ROSTER OF ONE THIS IS NEVER FILED, AND THAT IS THE SAME + # IDENTITY `sign_site` IS GUARDED BY RATHER THAN A SPECIAL CASE. + # `actuator_for()` routes every source to the SITE at N=1 - with one + # inverter the fabricated grid reading and the battery setpoint are + # the same number - so this interval would bound THE SITE TARGET + # there, not a share. That is a behaviour change on the live + # single-inverter install and it is the shape SAFETY-25 decided + # against on 2026-09-02 (Option A): ordinary self-consumption is not + # given an SoC floor by this card. There is also nothing for it to + # do at N=1 - no sibling to move the share to - so the whole of AC 4 + # ("at N=1 allocation is byte-identical to today") is this `if`. + # + # WHAT IT COSTS, SAID PLAINLY: at N=1 nothing here stops the loop + # discharging into the BMS cut-out. That is unchanged, it is + # SAFETY-25's deferred Option B, and the BMS itself is what stops it. + if n_units > 1: + _pe = packshare.envelope(pack_energy_of(inv)) + if _pe.binding: + claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi, + _pe.why, + actuator=self.actuator_of("pack", inv))) # The SITE bound, for the site-level consumers below. It is the roster # sum: what this site can deliver, not what one of its inverters can. max_w = self.site_max_w @@ -2202,6 +2346,7 @@ class Controller: self.arb = None if arbitrage.enabled(self.o): floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) + charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct) self.arb = arbitrage.decide( self.fresh_tariff_plan(now), now, peak_guard_active=arbitrage.guard_state(self.peak_guard), @@ -2214,7 +2359,22 @@ class Controller: soc=self.pack_floor_soc, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), uncertainty=frozenset({Uncertainty.NO_WEATHER}), - charge_w=max_w, discharge_w=max_w, + # OPT-09. PER-PACK SIZING. Not `max_w` - the site rating - on + # both sides any more: the trade is sized to the packs whose own + # state of charge permits that direction, so it can never ask + # for more than the roster can take. `trade_power_w()` carries + # the argument, including which of the two SoC bounds belongs + # here and which belongs on the envelope. ⚠️ AT N=1 BOTH SIDES + # ARE `max_w` EXACTLY, whenever the trade is permitted at all: + # the one pack that passed the gate is the whole roster. + # ⚠️ AND ON THE SELL SIDE THIS IS A NO-OP TODAY on any roster, + # because `soc=` below is the EMPTIEST pack, so a sell is + # refused outright whenever any pack is at or under the floor - + # i.e. whenever this sum could be smaller than the site rating. + # It becomes reachable the day that gate becomes per-pack; + # `test_packshare.py` part 9 grades it directly rather than + # letting an unreachable branch go ungraded. + charge_w=charge_w, discharge_w=discharge_w, soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct) # SITE, and arbitrage.claim() says so on the object it builds. It # buys and sells at the grid against a day-ahead price: the price is @@ -2419,8 +2579,24 @@ class Controller: # stale from this line onwards. Incremented BEFORE resolve() so that the # record resolve() builds is stamped with the tick it is for. self.alloc_tick += 1 + # ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. ARCH-01 left the + # allocator one free parameter and ALLOC-01 built the machinery that + # consumes it (`allocator._fill`); this line is the first thing that has + # ever passed one. Without it every unit weighed 1.0 and the split was + # a fixed 50/50 regardless of what either pack could do. + # + # ⚠️ A METHOD, NOT A DICT. The weighting is by ENERGY HEADROOM and is + # therefore direction-dependent, and the direction is the sign of the + # site target - which stage 2 chooses inside `resolve()`. See + # `pack_weights()` and `packshare.weights()`; the arbiter calls it once, + # between stage 2 and stage 3. + # + # ⚠️ AND IT IS A PREFERENCE, NOT A PERMISSION. Nothing about the site's + # ability to DELIVER rests on this argument - a weight cannot stop a + # unit being commanded and cannot move a failing unit's load to its + # sibling. The `pack` claim filed above is the half that constrains. resolution = resolve(claims, units=self.units, live=self.live_units(), - tick=self.alloc_tick) + weights=self.pack_weights, tick=self.alloc_tick) self.allocation = resolution.allocation # --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL -------------- # Both do the same thing - REPLACE the resolution with a 0 W failsafe @@ -2514,6 +2690,25 @@ class Controller: if self._dropped_last and not _dropped: self.log_event("ALLOCATOR: every inverter is back in the split") self._dropped_last = _dropped + # 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER. + # `unmet_w` is the site target the envelopes could not absorb. It is + # NOT a broken allocation - `Allocation`'s own docstring says why + # fusing it with `ok` would let one saturated inverter park the whole + # site - but it IS the site failing to deliver what the loop asked + # for, and before this card nothing anywhere reported it. It is the + # state a closed envelope produces when BOTH packs are at their + # limits: the ask has nowhere left to go, and an operator watching a + # standing grid error needs the reason rather than the symptom. + # Edge-triggered on the VALUE, for `_dropped` reason. + _unmet = int(self.allocation.unmet_w if self.allocation else 0) + if _unmet != self._unmet_last: + if _unmet: + self.log_event( + f"ALLOCATOR: {_unmet:+d} W of the site target could not be " + f"placed on any inverter - {self.allocation.explain()}") + else: + self.log_event("ALLOCATOR: the whole site target fits again") + self._unmet_last = _unmet if resolution.scope_errors: # A claim reached the arbiter on an actuator its source's scope does # not permit. It is a BUG in the filing site, not a state to survive: diff --git a/goodwe_controller/app/packshare.py b/goodwe_controller/app/packshare.py new file mode 100644 index 0000000..eed8b32 --- /dev/null +++ b/goodwe_controller/app/packshare.py @@ -0,0 +1,418 @@ +"""OPT-09 - what each pack can still do, in the two forms stage 3 accepts. + +ARCH-01 left the allocator ONE free parameter - `weights` - and ALLOC-01 built +the machinery that consumes it (`allocator._fill`). Nothing ever passed one. +`main.py` called `resolve()` without `weights=`, `arbiter.resolve()` defaulted +it to None and `allocator.plan()` turned that into 1.0 for every unit, so the +split was a fixed 50/50 REGARDLESS OF WHAT EITHER PACK COULD DO. This module is +what fills that argument, and it fills the other one too - the per-unit +interval - because on their own the weights cannot do the job. Read the two +sections below in order; the second is the important one. + +⚠️ THE TWO ARE NOT INTERCHANGEABLE, AND THE ALLOCATOR SAYS SO FIRST +=================================================================== +`allocator.py`'s docstring states the rule this module is built on: + + WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is + "please do not use this unit", and it is honoured while any other unit has + headroom - but it is NOT a constraint [...] To make a unit genuinely + unusable, close its envelope or leave it out of `live`. + +That is mechanical, not advisory: `_fill()` runs a SECOND, last-resort pass over +the zero-weight units when the preferred ones fill up, deliberately, so that a +preference can never manufacture a standing error at the meter. So a pack at its +own cut-out that is only WEIGHTED to zero is still commanded to discharge the +moment its sibling saturates. Closing its `hi` is what actually stops the ask. + +Hence this module produces two things and they answer two different questions: + + envelope(pack) MAY this pack be asked, at all, in this direction? + -> a `hi`/`lo` that main.py files as a UNIT claim, and the + allocator obeys absolutely (`cap` is never overridden). + weights(packs,S) GIVEN that both may, which should carry more of it? + -> a comfort choice, in the sense ARCH-01 means: the gain + seen at the meter is a CONVEX COMBINATION of the + per-unit gains for ANY split, so this cannot destabilise + the site whatever it says. + +⚠️ AND WHAT THE WEIGHTS CANNOT DO, BECAUSE THE CONVEX-COMBINATION ARGUMENT IS +EASY TO OVER-READ +============================================================================ +ARCH-01 guarantees that a bad split cannot DESTABILISE the site. It says nothing +whatever about whether the site can REGULATE. A unit sitting at its energy limit +has g_i = 0 - it answers no watts to any command - and the convex combination +then contains a zero: `gbar = sum(a_i * g_i)` is still inside [min g_i, max g_i] +and the site is still stable, while the load it was asked to carry simply does +not move. `compute()` sees the resulting standing error, trips its saturation +freeze and holds the site target where it is, so the share of a unit that cannot +deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on. + + ⚠️ SO: WEIGHTS ARE A COMFORT CHOICE FOR STABILITY AND NOT FOR DELIVERY. + Weighting a failing unit down is not a remedy for it. The remedies this + add-on has are the ENVELOPE (here, when the reason is the pack's own state + of charge - a fact this controller reads) and `live_units()` (when the unit + cannot be commanded at all). SAFETY-42 measured what the third case costs: + with one unit wedged and equal weights, the site settles 700 W short and + never recovers, because the freeze pins the target and the sibling's share + never rises. That module DETECTS it; this one cannot fix it, and saying so + is worth more than a weighting scheme that looks like it might. + +WHY ENERGY AND NOT POWER +======================== +The two packs at this site are asymmetric IN OPPOSITE DIRECTIONS. From the +datasheet and the serials (NOTES.md 1.1b): A is 5000 W / 15.36 kWh, B is 4600 W +/ 20.48 kWh - B has about 33 % more ENERGY and about 8 % less POWER. A split by +power rating hands the trade to A; a split by energy headroom hands it to B. The +question the split is answering is "which pack can sustain its share for the +hour", so it is the energy one, and the ratings are already applied - as the +`supervised` and `device` claims, per unit, which is where a POWER bound belongs. + +⚠️ INVERTER B DOES NOT EXIST YET (expected ~2026-09-12) AND NOTHING HERE IS +MEASURED ON IT. `sim/plantsim.py`'s INVERTER_B is inverter A copied, with +`measured=False` on it. Every figure above is FROM DATASHEET AND SERIAL, and +every two-unit result this card reports is TWO COPIES OF A driven with different +numbers, which is a statement about the arithmetic and not about the hardware. + +WHICH FLOOR, AND WHICH CEILING - THE TWO CHOICES THAT ARE POLICY IF YOU GET +THEM WRONG +========================================================================== +⚠️ THE FLOOR IS `bms_soc_floor` ALONE, NOT `peakguard.pack_floor_pct()`'s +conjunction with `peak_soc_floor`. This is the same choice `delivery.py`'s +`account()` had to make and it is made for the same reason, one direction +further out: `peak_soc_floor` bounds the PEAK GUARD ONLY (SAFETY-25), ordinary +self-consumption discharges straight past it, and closing a unit's envelope at +it would silently turn an EMS preference into a hard stop on the grid-following +loop - which is the memo's DEFERRED Option B, decided against on 2026-09-02, and +not this card's to build. `bms_soc_floor` is a property of the battery in the +cupboard: below it the pack's own BMS stops, so a discharge command sent there +is a command that cannot be executed by anything. + +⚠️ AND THE CEILING IS 100 %, WHICH IS COARSE, AND THE COARSENESS IS THE HONEST +ANSWER RATHER THAN A GAP TO BE PAPERED OVER. There is no per-pack charge ceiling +anywhere in this add-on - `delivery.py` says so at length and calls it a card, +and it is why SAFETY-42 is armed on discharge shares only. The numbers that +exist are `arbitrage_soc_ceiling` (one site-wide number, and a TRADE policy) and +nothing else. Filing a trade policy as a P_SAFETY envelope on the loop would be +exactly the mistake the paragraph above refuses, in the charge direction. So the +envelope closes only where the ask is physically impossible - a pack reporting +100 % being told to take more - and everything between is left to the WEIGHTS, +which is the right instrument for a graded preference and is honest about being +only a preference. + +CAPACITY IS OPTIONAL AND "UNKNOWN" MEANS "NO OPINION" +===================================================== +`capacity_kwh` is a new, OPTIONAL per-entry key. Optional, where SAFETY-32 made +`bms_soc_floor` required, because the two absences mean different things: a +missing cut-out would have to be DEFAULTED and a default protects the shallower +pack with the deeper pack's number, whereas a missing capacity can be answered +with "then this module has no opinion about the split" - which is exactly +today's behaviour, 1.0 for every unit. `weights()` therefore answers None unless +EVERY pack in the roster states both a capacity and a state of charge: a +half-known roster would weight the packs that answered against a sibling +weighing 1.0, which is a comparison between a kWh and a dimensionless one. +""" + +from dataclasses import dataclass +from math import inf, isfinite + +from .peakguard import discharge_permitted + +# The only charge ceiling that is a property of a PACK rather than of a policy. +# See the module docstring: `arbitrage_soc_ceiling` is a trade preference and +# must not become a P_SAFETY bound on the grid-following loop. +FULL_PCT = 100.0 + +# The claim source name main.py files these intervals under. +# +# ⚠️ MAIN.PY STILL WRITES THE STRING LITERALLY AND MUST GO ON DOING SO, so this +# constant is a NAME FOR THE CHECKS rather than something to import at the +# filing site. test_peakguard.py part 10f reads `Claim.limit(...)`'s FIRST +# POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set, and part +# 10g reads `actuator_of('')` the same way; both go blind on a name +# they would have to resolve. So the two spellings are tied by a check instead - +# `CLAIM_SCOPE[packshare.SOURCE] == SCOPE_UNIT` in test_packshare.py part 10 - +# which is the one direction that cannot make the scanners vacuous. +SOURCE = "pack" + + +@dataclass(frozen=True) +class PackEnergy: + """ONE pack's four numbers for the two questions this module answers. + + `name` the roster name. An envelope that does not say WHICH pack it + closed sends an operator to the wrong cupboard - the defect + `peakguard.Pack` and CFG-04's `unit_caps_indexed()` were + both written for. + `soc` THIS pack's measured state of charge, in percent. None when + its sensor did not answer, and ⚠️ None IS NOT A FULL + BATTERY - `discharge_permitted()` says why at length. + `bms_floor` THIS pack's OWN `bms_soc_floor`, the percent at which its + BMS cuts out. Required of every roster entry since + SAFETY-32, so at a declared roster this is never None. + `capacity_kwh` THIS pack's usable capacity. None when the entry did not + state one, which means "no opinion" and never 0 - a zero + capacity is a pack that may never be used, which is a + decision nobody made by omitting a key. + `max_w` THIS unit's supervised rating. ⚠️ IT IS NOT AN INPUT TO THE + SPLIT AND MUST NOT BECOME ONE - the whole of deliverable 2 + is that the split is by ENERGY and the rating is applied + separately, per unit, as the `supervised` and `device` + claims. It is here for `trade_power_w()` alone, which + answers a different question: how many watts the packs that + are ALLOWED to take a trade can take between them. + + ⚠️ IT IS A SEPARATE TYPE FROM `peakguard.Pack` RATHER THAN AN EXTENSION OF + IT, AND THAT IS DELIBERATE. `Pack` carries the three numbers the FLOOR + question needs and is consumed by `pack_permissions()` and + `binding_soc_floor()`, whose answers are site verdicts about spending a pack + for money. This one carries a fourth number that is meaningless to those + reductions and would have to be defaulted into them. Two small types with + one purpose each beat one type with a field half its callers must ignore. + """ + + name: str + soc: float | None + bms_floor: float | None + capacity_kwh: float | None + max_w: float = 0.0 + + +@dataclass(frozen=True) +class PackEnvelope: + """The interval OPT-09 asks for on one pack, and the sentence that says why. + + `(-inf, +inf)` is "no opinion" and is the common answer; `binding` is how a + caller asks whether there is anything to file. A claim filed for every pack + on every cycle regardless would put an open limit into the arbiter's + considered list on every tick, which costs nothing arithmetically and buries + the one cycle where it means something. + """ + + lo: float = -inf + hi: float = inf + why: str = "" + + @property + def binding(self) -> bool: + return self.lo != -inf or self.hi != inf + + +def usable_kwh(pack) -> float | None: + """Energy this pack can still DISCHARGE before its own BMS cuts out. + + None when it cannot be computed - no capacity, no reading, or no floor - and + never 0.0 for those, because "no opinion" and "nothing left" are the two + answers a weighting must not confuse: the first weighs 1.0 and the second + weighs nothing at all. + + Clamped at 0.0 from below. A pack measured BELOW its own cut-out is at + -0.4 kWh of usable energy by arithmetic and at zero in fact. + """ + if (pack.capacity_kwh is None or pack.soc is None + or pack.bms_floor is None): + return None + return max(0.0, float(pack.capacity_kwh) + * (float(pack.soc) - float(pack.bms_floor)) / 100.0) + + +def spare_kwh(pack) -> float | None: + """Energy this pack can still ACCEPT before it is full. + + `FULL_PCT`, not `arbitrage_soc_ceiling`: the module docstring carries the + argument, and it is the same one that keeps `peak_soc_floor` out of + `usable_kwh()`. A pack's own fullness is a fact; a trade's reserve is not. + + ⚠️ AND IT IS A WEIGHTING INPUT, NOT A PERMISSION. It says nothing about the + constant-voltage taper, in which a pack legitimately accepts far less than + it is told for tens of minutes - there is no configured ceiling in this + add-on that could describe that, which is `delivery.py`'s stated reason for + being armed on discharge shares only. + """ + if pack.capacity_kwh is None or pack.soc is None: + return None + return max(0.0, float(pack.capacity_kwh) + * (FULL_PCT - float(pack.soc)) / 100.0) + + +def envelope(pack) -> PackEnvelope: + """The interval this pack may be commanded in, from its state of charge. + + Positive is DISCHARGE throughout this add-on, so: + + * a pack at or below its own cut-out gets `hi = 0` - it may still be + charged, it may not be asked to discharge; + * a pack at 100 % gets `lo = 0` - it may still be discharged, it may not + be asked to charge. + + ⚠️ THE DISCHARGE HALF ASKS `peakguard.discharge_permitted()`, THE PREDICATE + SAFETY-32 MADE PUBLIC, rather than writing `soc <= floor` a third time. That + function is already the one place this question is answered - for the peak + guard's own verdict and for `delivery.account()`'s discriminator - and a + safety predicate transcribed into a third copy is two future disagreements + about what it means. What is NOT reused is its wording, which is peak-guard + flavoured; the floor handed in is the pack's own cut-out, exactly as + `delivery.account()` hands it. + + ⚠️ AND A MISSING READING OR A MISSING FLOOR WITHHOLDS THE CLOSURE, NOT THE + DISCHARGE - WHICH IS THE OPPOSITE DIRECTION FROM `discharge_permitted()`'s + OWN, ON PURPOSE. That predicate answers "may this site SPEND this pack for + money", where an unread SoC must not authorise the spend and a full battery + is exactly the value that would disarm the floor. The question here is + different: whether to take a unit's share away from it and hand it to a + sibling. An absent measurement is not evidence that a battery is flat, and + closing on it would park a healthy unit's discharge on every tick for as + long as its sensor was missing - moving load onto its sibling on the + strength of a reading nobody took. + + Both absences already have a stronger answer arriving first, which is why + this one costs nothing: a missing SoC stops the WHOLE SITE through + `inputs_ok` (a site-level P_SAFETY set of 0 W), and a roster entry with no + `bms_soc_floor` is refused at startup by validate.py rule 8 and by + config.yaml's own schema, which requires the key of every entry. So the + conjunction below is "positive evidence only": both numbers present, and the + predicate says the pack is at or under its own cut-out. + + ⚠️ `discharge_permitted()` IS STILL THE PREDICATE, and it is still the only + place the comparison is written - what this module decides for itself is the + two None cases, which it decides differently and says why. The BOUNDARY + (`<=`, so a pack sitting exactly on its floor is closed) comes from there + and is graded against it over the whole percent range in test_packshare.py + part 2, so a future change to that boundary moves both. + + ⚠️ AT P_SAFETY IT BINDS THE MAINTENANCE DRAIN TOO, AND THAT IS RIGHT RATHER + THAN AN OVERSIGHT. main.py files this as a `P_SAFETY` limit, so clause 2 of + the arbiter's rule applies it to the §12 recalibration drain (a SET at + P_MAINTENANCE): a drain into a pack already at its BMS cut-out is clamped to + 0 W. On the shipped numbers it cannot even bite - `maintenance_soc_floor` + defaults to 11 % against a 10 % cut-out, so the drain finishes above this + interval. On a config that sets the drain target BELOW the cut-out + (`int(5,30)` against `int(0,50)`, so the schema permits it) the drain stalls + and the FSM's 12 h DRAIN timeout eventually reports "drain stuck" - which is + what ALREADY happens without this card, because the pack's own BMS refuses + the command whatever the allocator sends. The failure is the config's, the + remedy is the same, and this envelope does not make it worse; it only makes + the refusal visible one layer earlier, in the allocation record. + + ⚠️ BOTH ENDS CANNOT CLOSE AT ONCE ON ANY CONFIGURATION THE SCHEMA ACCEPTS. + It would take a pack reporting at least 100 % while sitting at or under its + own cut-out, and `config.yaml` bounds `bms_soc_floor` at `int(0,50)` on both + the flat key and every roster entry - so the two conditions cannot both hold. + If they somehow did, the result is `[0, 0]`, which is an interval containing + exactly one integer rather than a contradiction, and the allocator commands + that unit 0 W. Nothing is special-cased for it here: inventing a second + opinion about an unreachable state is how unreachable states get opinions + that are wrong. + """ + hi, lo, why = inf, -inf, "" + if (pack.soc is not None and pack.bms_floor is not None + and discharge_permitted(pack.soc, pack.bms_floor) is not None): + hi = 0.0 + why = (f"{pack.name} is at {float(pack.soc):g} %, at or under its own " + f"{float(pack.bms_floor):g} % BMS cut-out - it is not asked to " + f"discharge") + if pack.soc is not None and float(pack.soc) >= FULL_PCT: + lo = 0.0 + why = (f"{pack.name} is at {float(pack.soc):g} % and cannot take a " + f"charge - it is not asked to") + return PackEnvelope(lo=lo, hi=hi, why=why) + + +def weights(packs, target_w) -> dict | None: + """Per-unit comfort weights for THIS tick's site target, or None for none. + + The weight of a pack is the energy it has left IN THE DIRECTION THE SITE IS + ABOUT TO TRAVEL - usable energy above its own cut-out when the site is + discharging, room below full when it is charging. `allocator._fill()` uses + only the RATIOS, so the unit is free and kWh is the honest one to leave it + in: it is what a reader of the action log can check against the datasheet. + + ⚠️ THE DIRECTION IS AN ARGUMENT BECAUSE IT IS NOT KNOWABLE ANY EARLIER. The + two directions give OPPOSITE answers on the same roster - A at 20 % of + 15.36 kWh and B at 80 % of 20.48 kWh weigh 1.5 : 14.3 to discharge and + 12.3 : 4.1 to charge - so a direction-free weighting is not a compromise + between them, it is wrong in one of the two on every tick. The site target + is chosen by `arbiter.site_resolve()`, i.e. by stage 2, so the earliest + honest moment to ask this is between stage 2 and stage 3. That is why + `resolve()` accepts a CALLABLE here and calls it with the resolved target, + and why main.py hands it a method rather than a dict. + + ⚠️ `target_w >= 0` IS THE DISCHARGE SIDE, AND IT IS THE SITE TARGET'S SIGN + RATHER THAN THE FILL'S. `allocator.plan()` decides its own direction from + `need = remainder - placed`, which differs from `sign(target_w)` only when a + PIN or an envelope that excludes zero has already moved the base - a + maintenance drain, a failsafe park. On those ticks the weighting is computed + for the other direction and is therefore merely a worse preference; it + cannot be unsafe, for ARCH-01's reason, and it cannot break the sum, which + is integer arithmetic in the allocator. Chasing the exact direction would + mean handing this function into `plan()` and calling it from inside the + fill, which would make the allocator's purity depend on a caller's callback. + + None means "no opinion, weigh everything 1.0", and there are three ways to + get it, all of which are today's behaviour rather than a new one: + + * fewer than two packs. At N<=1 `plan()` is a pass-through and a weighting + would be arithmetic nobody reads; + * any pack that cannot state its headroom - see the module docstring on + why a half-known roster is worse than an unweighted one; + * every pack at zero headroom in this direction. All-zero weights would + put the whole roster into `_fill()`'s LAST-RESORT pass, where the shares + come out equal anyway and every `UnitPlan.why` reads "last resort: the + preferred units were full" - a true sentence about the wrong thing. + """ + packs = list(packs) + if len(packs) < 2: + return None + room = (usable_kwh if float(target_w) >= 0.0 else spare_kwh) + out = {} + for p in packs: + kwh = room(p) + if kwh is None or not isfinite(kwh): + return None + out[p.name] = float(kwh) + if not any(v > 0.0 for v in out.values()): + return None + return out + + +def trade_power_w(packs, floor_pct, ceiling_pct) -> tuple: + """`(charge_w, discharge_w)` - how hard an arbitrage trade may be SIZED. + + The site rating is what the site can push; this is what the site can push + AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` was handed + `site_max_w` on BOTH sides, so a two-pack site would ask to buy at the sum of + the ratings whatever either pack's state of charge was - and the watts that + could not be placed would come back as `unmet_w` for the site integrator to + chase for the rest of the hour. + + ⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` / + `arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, BECAUSE SIZING A + TRADE IS THE TRADE'S BUSINESS. This answers "how much may this strategy ask + for"; `envelope()` above answers "what may this pack be commanded at all", + and that one is the pack's own physical cut-out for SAFETY-25's reason. + Sizing with the physical floor would let the strategy ask for watts its own + reserve forbids; bounding the envelope with the trade's reserve would let a + trade preference stop the grid-following loop. TWO QUESTIONS, TWO NUMBERS. + + ⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack carries the trade + is the allocator's answer, and the only per-pack instrument it has for a + TRADE reserve is the weights - which are a preference, not a permission. So + a pack between its arbitrage floor and its BMS cut-out can still take part + of a sell. That is stated rather than fixed: fixing it needs an envelope + that depends on WHICH CLAIM WON the tick, which the arbiter's partition does + not express, and it is a card of its own. + + A pack whose SoC did not read is counted on NEITHER side: withholding is the + direction that cannot over-size, and `inputs_ok` has stopped the site by + then anyway. + + ⚠️ NEITHER SIDE MAY REACH `arbitrage.claim()` AS A ZERO - a 0 W arbitrage + claim is a PARK ORDER that outranks the loop. It cannot be zero on a tick + that trades today, because the sell gate refuses on the emptiest pack and + the buy gate refuses when every pack is at the ceiling; `arbitrage.decide()` + refuses a 0 W sizing by name anyway, so that is structural rather than a + coincidence of the gates. + """ + return (float(sum(p.max_w for p in packs + if p.soc is not None and float(p.soc) < ceiling_pct)), + float(sum(p.max_w for p in packs + if p.soc is not None and float(p.soc) > floor_pct))) diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index 8a95794..93dfdf8 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -570,7 +570,7 @@ def _roster_problems(opts: dict) -> list[str]: rules up. A rule the Supervisor duplicates costs nothing and covers the callers it does not. - Five things are refused, and each is a config that would otherwise RUN: + Seven things are refused, and each is a config that would otherwise RUN: a. `inverters` is not a list. `roster_entries()` wraps a scalar so this can name it; without the rule the roster would be one inverter built @@ -593,6 +593,18 @@ def _roster_problems(opts: dict) -> list[str]: config. Refused rather than ignored, for §1.5's reason and for this card's: a config that silently becomes a different config is worse than one that refuses to load. + f. OPT-09. An entry whose `capacity_kwh` is stated but is not a usable + positive number of kWh. It is read to weight the SPLIT by energy + headroom; a zero, a negative or a word is a pack the split would + quietly stop preferring while the operator can see a value in the box. + g. OPT-09. `capacity_kwh` stated on SOME entries and not others. The + weighting is a COMPARISON between packs, so a pack measured in kWh + against a sibling weighing a dimensionless 1.0 is not one - + `packshare.weights()` answers "no opinion" for the WHOLE roster in that + case, which means every key the operator filled in does nothing at all + and nothing says so. All or none, and this is clause (e)'s argument + restated: a config that silently becomes a different config is worse + than one that refuses to load. """ problems: list[str] = [] raw = opts.get("inverters") @@ -651,6 +663,42 @@ def _roster_problems(opts: dict) -> list[str]: "sibling is exactly what DEV-02 removed. State this unit's own " "rating in watts.") + # --- (f) and (g): OPT-09's capacity, which is optional but not partial --- + stated, blank = [], [] + for i, entry in enumerate(entries): + if not isinstance(entry, dict): + continue # already refused above, by name + raw = entry.get("capacity_kwh") + nm = str(entry.get("name") or "").strip() + who = f"inverters[{i}] ({nm})" if nm else f"inverters[{i}]" + if raw is None or raw == "": + blank.append(who) + continue + try: + val = float(raw) + except (TypeError, ValueError): + val = None + if val is None or not (val > 0.0): + problems.append( + f"{who} has capacity_kwh {raw!r}, which is not a usable number " + "of kilowatt-hours. It is THIS pack's usable capacity and it is " + "read to weight the split of the site target by ENERGY headroom " + "rather than by power rating; a zero or an unparseable value is " + "a pack the split would quietly stop preferring while the box " + "still shows a value. State it in kWh, or leave it empty on " + "every entry.") + else: + stated.append(who) + if stated and blank: + problems.append( + f"capacity_kwh is stated on {', '.join(stated)} and left empty on " + f"{', '.join(blank)}. It is OPTIONAL but it is not PARTIAL: the " + "split weights one pack's energy headroom against another's, so a " + "pack measured in kWh beside a sibling that weighs a dimensionless " + "1.0 is not a comparison - the add-on answers 'no opinion' for the " + "whole roster and the value you filled in changes nothing. State a " + "capacity for every inverter, or for none of them.") + flat_sp = str(opts.get("setpoint_entity") or "").strip() if entries and flat_sp: problems.append( diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index ec8568a..3d6a6b1 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -108,6 +108,25 @@ options: # EVERY entry and names the one it refuses. The flat key below is unchanged and # is the single-inverter shape's own cut-out. # + # ⚠️ OPT-09: `capacity_kwh` IS PER ENTRY AND IS OPTIONAL, WHICH IS THE + # OPPOSITE CHOICE TO `bms_soc_floor` ABOVE AND IS MADE FOR THE SAME REASON. + # It is THAT pack's usable capacity in kWh, and it is read for exactly one + # purpose: weighting the SPLIT of the site target between packs by ENERGY + # headroom rather than by power rating. The two packs here are asymmetric in + # OPPOSITE directions - 5000 W / 15.36 kWh against 4600 W / 20.48 kWh, from + # the datasheet and the serials - so a split by rating and a split by energy + # hand the work to DIFFERENT packs, and the energy one is the question ("which + # pack can sustain its share for the hour"). A missing cut-out would have to be + # DEFAULTED, and one default for two batteries protects the shallower pack with + # the deeper pack's number; a missing capacity has an honest answer instead - + # this add-on then has NO OPINION about the split and every unit weighs 1.0, + # which is exactly what shipped before this card. ⚠️ IT IS ALL OR NOTHING + # ACROSS THE ROSTER: state it on every entry or on none, because a pack + # weighted in kWh against a sibling weighing a dimensionless 1.0 is not a + # comparison. There is deliberately NO flat `capacity_kwh` - at one inverter + # there is no split to weight, so the key would be one an operator can set and + # nothing reads. + # # ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a # roster is declared is two config sites aimed at actuators, one of which # nothing writes; if it names an entity an entry also names, SAFETY-28's @@ -621,6 +640,12 @@ schema: # enforced per entry, a missing required key names the key, a scalar where the # list belongs is refused, and the UI renders it as a repeatable section. # + # ⚠️ AND OPT-09's `capacity_kwh` IS DECLARED OPTIONAL (`?`), UNLIKE + # `bms_soc_floor`, WHICH IS THE ARGUMENT IN THE OPTIONS BLOCK ABOVE: an + # optional cut-out is a defaulted one and a default is one number for two + # batteries, whereas an absent capacity has an honest answer - no opinion + # about the split, every unit weighs 1.0, which is what shipped. + # # ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with # no `name` has no Store namespace and would share another unit's SAFETY-02 # latch; one with no `setpoint_entity` is a unit nothing can command; one with @@ -642,6 +667,7 @@ schema: soc_entity: str? batt_entity: str? batt_invert: bool? + capacity_kwh: float(0.1,200)? estop_fitted: bool diff --git a/goodwe_controller/test_arbiter.py b/goodwe_controller/test_arbiter.py index eff6ccb..76327d2 100644 --- a/goodwe_controller/test_arbiter.py +++ b/goodwe_controller/test_arbiter.py @@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what " Claim.limit("device", P_SAFETY, -1, 1).source == "device") print() -print("ARB-03 2. the split: eight sources, each classified where it is BUILT") +print("ARB-03 2. the split: eleven sources, each classified where it is BUILT") # The classification is graded against what each claim is ABOUT, restated here # so that the table in arbiter.py and the expectation in a test are not the same # sentence read twice. test_peakguard.py part 10f pins this set against main.py's @@ -180,16 +180,25 @@ UNIT_SOURCES = { "measured power (0x0518), so what it learns is about one " "device. The site-wide half moved to `sign_site`", "maintenance": "the monthly recalibration is a pack procedure", + # OPT-09, and it is the entry a reader will want to check against + # SAFETY-25's deferred loop-side reserve, because it has the same shape. + "pack": "THIS pack's own state of charge against THIS pack's own " + "`bms_soc_floor` - a fact about one battery in one cupboard, " + "exactly as `device`'s rating is a fact about one box. It is " + "not SAFETY-25's deferred `loop_soc_floor` reserve: no option " + "is added, `peak_soc_floor` is not consulted, the SITE target " + "is untouched, and main.py does not file it at all at a " + "roster of one", } for _src, _why in sorted(SITE_SOURCES.items()): print(f" site {_src:<11} {_why}") for _src, _why in sorted(UNIT_SOURCES.items()): print(f" unit {_src:<11} {_why}") -check("all TEN claim sources are classified, and only those ten", +check("all ELEVEN claim sources are classified, and only those eleven", set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES)) check("the six that are about the GRID CONNECTION are site claims", {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES)) -check("the four that are about a PACK, a DEVICE or one write are per-inverter", +check("the five that are about a PACK, a DEVICE or one write are per-inverter", {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES)) check("a source nobody classified cannot be routed at all - actuator_for() " "raises rather than defaulting to the site, because defaulting to the " @@ -207,7 +216,7 @@ check("with one inverter every source routes to the site, per-inverter ones " all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE)) check("...and with none configured at all, likewise", all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE)) -check("with TWO the identity breaks and the split becomes real: the four " +check("with TWO the identity breaks and the split becomes real: the five " "per-inverter sources route to the unit this controller drives, the six " "site sources stay on the grid connection", {s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE} diff --git a/goodwe_controller/test_delivery.py b/goodwe_controller/test_delivery.py index 9c91805..e05f79f 100644 --- a/goodwe_controller/test_delivery.py +++ b/goodwe_controller/test_delivery.py @@ -475,13 +475,42 @@ def _power_domain(rows): _pw, _pf = _power_domain(_sw), _power_domain(_sf) -check(f"THE TWO ARMS ARE BYTE-IDENTICAL IN THE POWER DOMAIN on all " - f"{len(_pw)} cycles - grid, site target, every share, every measured " - f"watt. No threshold on any of those numbers, at any N and any X, can " - f"separate a wedged unit from a pack at its floor, and the card's own " - f"proposed discriminator reads only those numbers", - len(_pw) == len(_pf) and _pw == _pf) -check("...and the ONLY thing that differs is the state of charge", +# ⚠️ OPT-09 CHANGED WHAT THIS PAIR OF ARMS LOOKS LIKE, AND THE CARD'S FINDING +# SURVIVES IT INTACT. UNTIL OPT-09 the two traces were byte-identical in every +# power number - the cold pack was ASKED for the same +2000 W the wedged one was +# asked for, and both produced 0 W - and that identity was this file's headline. +# OPT-09 closes a pack's `hi` at 0 W once its own state of charge is at or under +# its own `bms_soc_floor`, so the cold arm's B is now commanded 0 W and the two +# arms' SHARES differ. ⚠️ THAT IS A CHANGE IN WHAT THE SITE ASKS FOR, NOT IN +# WHAT ANY DETECTOR CAN SEE, and the card's argument is about the latter: the +# proposed discriminator reads SATURATION-PERSISTENCE and |GRID ERROR|, and on +# BOTH arms the loop is saturation-frozen on every cycle while ~1000 W stands at +# the meter. No (N, X) separates them, exactly as measured on the rig - so the +# claim is now stated on the two quantities the predicate actually reads rather +# than on the whole trace, which is the stronger form of the same sentence. +_ERR_W = 900.0 +_frozen = [[r["reason"] for r in rows] for rows in (_sw, _sf)] +_settled = [[r for r in rows if abs(r["grid"]) > _ERR_W] for rows in (_sw, _sf)] +print(f" the two quantities the card's predicate reads, last 8 cycles: " + f"wedged grid {[round(r['grid']) for r in _sw[-8:]]}, " + f"cold grid {[round(r['grid']) for r in _sf[-8:]]}") +check(f"THE TWO ARMS ARE INSEPARABLE IN THE TWO QUANTITIES THE CARD'S PROPOSED " + f"DISCRIMINATOR READS. On both, the loop reports the SAME reason on the " + f"last {len(_sw[-8:])} cycles and |grid error| stands over " + f"{_ERR_W:.0f} W on every one of them. No threshold on saturation " + f"persistence and no threshold on grid error, at any N and any X, can " + f"separate a wedged unit from a pack at its floor", + [r["reason"] for r in _sw[-8:]] == [r["reason"] for r in _sf[-8:]] + and len(_settled[0]) >= 8 and len(_settled[1]) >= 8) +check("...and the SHARES now differ, which is OPT-09 and not a weakening of the " + "line above: the cold pack is commanded 0 W because its own SoC closed " + "its own envelope, while the wedged pack - whose SoC is healthy - is " + "still asked for its full share and still delivers nothing. The two " + "cards meet here from opposite sides: OPT-09 stops the ASK, this file " + "grades the ALARM, and neither can do the other's half", + _pw != _pf + and _sf[-1]["shares"][1] == 0.0 and _sw[-1]["shares"][1] == 2000.0) +check("...and the state of charge is still what tells them apart", [r["soc"] for r in _sw] != [r["soc"] for r in _sf]) print(f" both arms, last cycle: grid {_sw[-1]['grid']:.0f} W, shares " f"{tuple(round(v) for v in _sw[-1]['shares'])}, measured " @@ -490,27 +519,48 @@ print(f" SoC: wedged arm {_sw[-1]['soc']} cold arm {_sf[-1]['soc']}") check("AC 1: the WEDGED unit is NAMED, and only it", _sw[-1]["named"] == ("inverter-b",)) -check("AC 2: the COLD PACK AT ITS FLOOR IS NOT - on the identical trace", +check("AC 2: the COLD PACK AT ITS FLOOR IS NOT", _sf[-1]["named"] == ()) print(f" {_cw.inverters[1].delivery_wd.reason}") # ⚠️ THE VACUOUS-PASS GUARD, AND IT IS NOT DECORATION. MUTATION.md's third # failure mode is a check whose data cannot reach the failure it names: "the # cold arm named nobody" reads exactly the same against a fixture where nothing -# was short, nothing was asked, or the loop never ran. So the cold arm is -# asserted to have REACHED the branch and been turned away by the account, not -# to have missed it. +# was short, nothing was asked, or the loop never ran. +# +# ⚠️ AND OPT-09 MOVED WHICH OF THOSE IS TRUE, WHICH IS WORTH MORE THAN THE +# CHECK IT REPLACES. Until OPT-09 the cold pack WAS asked for its full share, +# was short of it, and was turned away by `account()` alone - the account was the +# only thing standing between an empty battery and an alarm. It is not any more: +# the pack's own envelope is closed, so it is never asked, `short_by()` answers +# None on a zero share, and AC 2 is delivered STRUCTURALLY - by the site not +# asking - rather than by a discriminator applied after the fact. +# +# BOTH HALVES ARE ASSERTED, because the second is the one that survives OPT-09 +# being reverted or a pack whose numbers are absent: `account()` STILL turns this +# pack away when the ask IS present, and that is asked of it directly here on +# this arm's own measured SoC and configured cut-out. Parts 2 and 3 grade the +# predicate over its whole input space; this line pins it to THIS fixture's +# numbers so the end-to-end arm cannot pass for a reason unrelated to the pack. _cold_b = _cf.inverters[1] -check("...and the cold arm REACHED that decision rather than falling short of " - "it: B was asked for a discharge share, was short of it by more than " - "saturation_w, and was turned away by `account()` alone", - float(_cold_b.standing_w()) > 0 - and short_by(_cold_b.standing_w(), _cold_b.batt) > _cf.tuning.saturation_w - and account(live=True, parked=False, soc=_cold_b.soc, - bms_floor=_cold_b.bms_soc_floor) is not None) -check("...and the WEDGED arm's B differs from it in exactly that one place", +check("...and the cold arm reaches AC 2 the way OPT-09 makes it reach it: B's " + "own state of charge closed B's own envelope, so B is commanded 0 W and " + "`short_by()` has no discharge share to be short of. The ask never " + "arrives, which is a better answer than an alarm suppressed after it does", + float(_cold_b.standing_w()) == 0.0 + and short_by(_cold_b.standing_w(), _cold_b.batt) is None) +check("...and `account()` STILL turns this pack away if the ask does arrive - " + "asked directly, on this arm's own measured SoC and its own configured " + "cut-out. That is the half that must not depend on OPT-09: an unreadable " + "SoC or an unstated floor is a case OPT-09 deliberately does NOT close on, " + "and the account is what keeps those quiet", + account(live=True, parked=False, soc=_cold_b.soc, + bms_floor=_cold_b.bms_soc_floor) is not None) +check("...and the WEDGED arm's B differs from it in exactly that one place - " + "which is why it is still asked for its share, and still named", account(live=True, parked=False, soc=_cw.inverters[1].soc, - bms_floor=_cw.inverters[1].bms_soc_floor) is None) + bms_floor=_cw.inverters[1].bms_soc_floor) is None + and float(_cw.inverters[1].standing_w()) > 0) # ⚠️ WHICH FLOOR, AND THIS IS A WIRING CHECK THAT THE PURE ONES ABOVE CANNOT # MAKE. `peakguard.pack_floor_pct()` conjoins a pack's own cut-out with the @@ -694,11 +744,17 @@ check("...and each arm is a config validate.py rule 8 accepts: its " "peak_soc_floor is above EVERY unit's own cut-out", all(float(c.o.get("peak_soc_floor")) > float(i.bms_soc_floor) for c in (_c_deep, _c_shal) for i in c.inverters)) -check("...and the two arms are IDENTICAL IN THE POWER DOMAIN on every cycle - " - "grid, site target, every share, every measured watt. The configured " - "cut-out is the only thing that differs, which is part 4's claim made " - "again at two cut-outs that are not the shipped one", - _power_domain(_r_deep) == _power_domain(_r_shal)) +check("...and the two arms are INSEPARABLE IN THE QUANTITIES THE CARD'S " + "PROPOSED DISCRIMINATOR READS - the same loop reason and over 900 W of " + "grid error on every one of the last eight cycles - which is part 4's " + "claim made again at two cut-outs that are not the shipped one. ⚠️ THEIR " + "SHARES DIFFER, because OPT-09 closes the DEEP arm's envelope (15 % " + "against a 25 % cut-out) and leaves the SHALLOW arm's open (8 % against " + "5 %); that is the site declining to ask, not a detector telling them " + "apart, and it is why the pin below still needs the pack's OWN number", + [r["reason"] for r in _r_deep[-8:]] == [r["reason"] for r in _r_shal[-8:]] + and all(abs(r["grid"]) > 900.0 + for rows in (_r_deep, _r_shal) for r in rows[-8:])) check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is " f"cold, and is NOT named - even though it is far above the " @@ -706,11 +762,14 @@ check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is " f"against, which would have called an empty pack a fault", _r_deep[-1]["named"] == () and float(_bd.bms_soc_floor) == _FLOOR_DEEP and _bd.soc == _DEEP_SOC) -check("...and it REACHED that decision: it was asked for a discharge share, " - "was short of it by more than saturation_w, and was turned away by " - "`account()` alone", - float(_bd.standing_w()) > 0 - and short_by(_bd.standing_w(), _bd.batt) > _c_deep.tuning.saturation_w +check("...and it reaches that decision the way OPT-09 makes it reach it - the " + "pack's own 25 % cut-out closed its own envelope, so it is commanded 0 W " + "and has no discharge share to be short of - while `account()`, asked " + "directly on the same two numbers, STILL turns it away. Both halves, for " + "the reason part 4 gives: the second is what survives OPT-09 being " + "reverted, and it is what covers the absences OPT-09 will not close on", + float(_bd.standing_w()) == 0.0 + and short_by(_bd.standing_w(), _bd.batt) is None and account(live=True, parked=False, soc=_bd.soc, bms_floor=_bd.bms_soc_floor) is not None) check(f"a pack at {_SHAL_SOC:.0f} % against its OWN {_FLOOR_SHALLOW} % cut-out " @@ -801,12 +860,21 @@ check("the saturation freeze: FIRES - and that is the point. It is the one " print(f" saturation counter {_cw.sat_count} (freezes at " f"{_cw.tuning.saturation_cycles}); site commanded " f"{_cw.target:.0f} W against {_cw.site_batt_w:.0f} W measured") -check("...and it fires IDENTICALLY on the benign arm, on the identical trace, " - "at the same counter. A saturation-shaped alarm cannot tell these two " - "apart at any threshold and any duration", - _cf.sat_count == _cw.sat_count - and _cf.site_batt_w == _cw.site_batt_w - and _cf.target == _cw.target) +print(f" ...and on the benign arm: counter {_cf.sat_count}, site " + f"commanded {_cf.target:.0f} W against {_cf.site_batt_w:.0f} W measured") +check("...and it fires ON THE BENIGN ARM TOO, from the same site numbers. The " + "site is commanded the same watts against the same measured watts and is " + "frozen on both, many times over the configured cycle count. ⚠️ THE " + "COUNTERS DIFFER BY ONE CYCLE SINCE OPT-09, and that rescues nothing: the " + "cold arm reaches its steady state one cycle sooner because its healthy " + "sibling gets the whole share immediately instead of half of it. A " + "duration threshold that both arms exceed by an order of magnitude " + "cannot be set between them, which is the claim - and the two settled " + "states are identical to the watt", + _cf.site_batt_w == _cw.site_batt_w + and _cf.target == _cw.target + and min(_cf.sat_count, _cw.sat_count) >= _cw.tuning.saturation_cycles + and abs(_cf.sat_count - _cw.sat_count) <= 1) print() print(" ...and a unit the ALLOCATOR HAS ALREADY DROPPED is not reported twice") diff --git a/goodwe_controller/test_inverter.py b/goodwe_controller/test_inverter.py index 051d012..d173759 100644 --- a/goodwe_controller/test_inverter.py +++ b/goodwe_controller/test_inverter.py @@ -1529,14 +1529,23 @@ check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, " "⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS " "`int(0,50)?`, and the difference is the whole of that card: an optional " "per-pack cut-out is a DEFAULTED one, and a default is one number for " - "two batteries - the shallower pack protected by the deeper pack's BMS", + "two batteries - the shallower pack protected by the deeper pack's BMS. " + "⚠️ OPT-09 ADDED `capacity_kwh` AND MADE THE OPPOSITE CHOICE ON PURPOSE: " + "it is OPTIONAL (`float(0.1,200)?`) because an absent capacity has an " + "honest answer - the add-on then has no opinion about the SPLIT and every " + "unit weighs 1.0, which is exactly what shipped - where an absent cut-out " + "would have to be invented. If you are here because you added a REQUIRED " + "key, read `Inverter.__init__` before changing this line: a required key " + "refuses every options blob written before it existed", set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor", - "soc_entity", "batt_entity", "batt_invert"} + "soc_entity", "batt_entity", "batt_invert", + "capacity_kwh"} and not any(_decl9[k].endswith("?") for k in ("name", "setpoint_entity", "max_w", "bms_soc_floor")) and all(_decl9[k].endswith("?") - for k in ("soc_entity", "batt_entity", "batt_invert"))) + for k in ("soc_entity", "batt_entity", "batt_invert", + "capacity_kwh"))) # ⚠️ NOTHING IS INHERITED, AND THIS IS DEV-02 RESTATED AS A CONFIG RULE. # ⚠️ THE FLAT KEYS ARE SET TO REAL ENTITIES HERE, AND THAT IS THE WHOLE CHECK. diff --git a/goodwe_controller/test_packshare.py b/goodwe_controller/test_packshare.py new file mode 100644 index 0000000..6ad4818 --- /dev/null +++ b/goodwe_controller/test_packshare.py @@ -0,0 +1,875 @@ +"""Runnable check for OPT-09. `python3 test_packshare.py` + +THE CARD IN ONE SENTENCE: the architecture's one free parameter - the allocator's +`weights` - was never passed by anything, so the site target was split 50/50 +regardless of what either pack could do, and a pack that could not deliver its +share parked the site with a standing grid error and no alarm. + +⚠️ INVERTER B DOES NOT EXIST (expected ~2026-09-12), AND EVERY TWO-UNIT NUMBER +IN THIS FILE IS THEREFORE ARITHMETIC AND NOT HARDWARE. B's figures - 4600 W, +20.48 kWh, 10 % floor - are FROM THE DATASHEET AND THE SERIAL (NOTES.md 1.1b); +A's 5000 W / 15.36 kWh / 10 % are measured. `sim/plantsim.py`'s INVERTER_B is +inverter A copied with `measured=False` on it, so anything driven through a +modelled plant here - part 8 - is TWO COPIES OF A given different numbers. What +this file grades is the SPLIT, which is arithmetic over intervals and weights +and needs no plant at all; where a plant appears it is modelled IN THIS FILE +(`lag_s=0`, obeys within one cycle, bounded by its rating) exactly as +test_alloc.py part 8's is, and the claim is about the control law rather than +about a GW5000S-BP. + +⚠️ THE FIXTURE IS THE HARD PART, AND PART 1 IS WHERE IT IS EARNED. Two packs +that differ in ONLY ONE of SoC and capacity cannot tell a SoC-aware weighting +from a capacity-aware one, and two identical packs cannot tell ANY weighting +from any other - every scheme agrees on equal packs, so a weighting bug is +invisible and the checks stay green. That is this repository's recurring defect +(MUTATION.md: "a check whose data cannot reach the failure it names") and part 1 +grades the fixture itself against it before anything else is asserted. + +⚠️ MUTATING THE MODULES THIS FILE GRADES? PURGE `__pycache__` FIRST - or run +`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you. +""" + +import ast +import sys +from datetime import datetime, timedelta, timezone +from math import inf +from pathlib import Path + +from app import arbitrage, packshare +from app.allocator import plan +from app.arbiter import ( + ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve, +) +from app.control import Tuning, compute +from app.peakguard import discharge_permitted + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +# --- the two packs, and every number's provenance ----------------------------- +A, Bee = "inverter-a", "inverter-b" +A_W, B_W = 5000.0, 4600.0 # A measured; B FROM DATASHEET +A_KWH, B_KWH = 15.36, 20.48 # A measured; B FROM DATASHEET +FLOOR = 10.0 # both, register 0x0560 / datasheet +A_SOC, B_SOC = 20.0, 80.0 # the fixture's own choice - see part 1 + + +def PE(name, soc, kwh, floor=FLOOR, max_w=0.0): + return packshare.PackEnergy(name, soc, floor, kwh, max_w) + + +PACKS = [PE(A, A_SOC, A_KWH), PE(Bee, B_SOC, B_KWH)] + + +def envelopes(packs): + """`{unit: (lo, hi)}` for a roster, the way main.py files them.""" + return {p.name: packshare.envelope(p) for p in packs} + + +def site_claims(packs, target_w, ratings=(A_W, B_W), envelope=True): + """The claim list main.py builds for one cycle, at a roster of two. + + `envelope=False` is WHAT SHIPPED: the `pack` claim is the half of this card + that constrains, so a baseline that kept it would be comparing OPT-09 + against itself. + """ + out = [] + for p, rating in zip(packs, ratings): + out.append(Claim.limit("supervised", P_SAFETY, -rating, rating, "max_w", + actuator=p.name)) + env = packshare.envelope(p) + if envelope and env.binding: + out.append(Claim.limit("pack", P_SAFETY, env.lo, env.hi, env.why, + actuator=p.name)) + out.append(Claim.set("loop", P_LOOP, target_w, "grid", + actuator=ACTUATOR_SITE)) + return out + + +def cycle(packs, target_w, weights="soc", ratings=(A_W, B_W), envelope=True): + """One `resolve()`, the way main.py calls it. `weights` is the variable. + + `weights="shipped"` reverts BOTH halves - no weighting and no envelope - + which is the baseline every "before this card" line below is measured + against. + """ + if weights == "soc": + w = lambda t: packshare.weights(packs, t) # noqa: E731 - OPT-09 + elif weights in ("equal", "shipped"): + w = None + envelope = envelope and weights != "shipped" + else: + w = weights + return resolve(site_claims(packs, target_w, ratings, envelope), + units=tuple(p.name for p in packs), weights=w, tick=1) + + +# ============================================================================== +print("1. THE FIXTURE, GRADED BEFORE ANYTHING IS ASSERTED WITH IT") + +# Four weighting schemes anybody might write. On the RIGHT fixture they give +# four different splits; on a wrong one they collapse into each other and every +# check downstream is green whatever the code does. +def split(weights, target=1000.0, packs=None): + r = plan(target, {p.name: _B(-A_W, A_W) for p in (packs or PACKS)}, + weights=weights) + assert r.ok, r.refused + return r.shares + + +class _B: + """One unit's interval, the way ARB-03 stage 1 hands it over.""" + def __init__(self, lo=-inf, hi=inf): + self.lo, self.hi = lo, hi + self.gate = self.pinned_w = self.pinned_by = None + self.contradiction = False + + +def schemes(packs, ratings=(A_W, B_W)): + """The four candidate weightings, as `{name: weights dict}`.""" + return { + "equal": {p.name: 1.0 for p in packs}, + "power": {p.name: r for p, r in zip(packs, ratings)}, + "soc only": {p.name: max(0.0, p.soc - p.bms_floor) for p in packs}, + "energy": packshare.weights(packs, 1000.0), + } + + +# ⚠️ THE PRECONDITION, WRITTEN BEFORE THE FIXTURE IS USED FOR ANYTHING. A +# `weights()` that answered None on this roster would make every scheme below +# collapse to `equal` - or, as an earlier draft found, take this file down with a +# TypeError instead of a FAIL. A mutation that crashes is a weaker gate than one +# that fails a named check, so the named check is written first. +check("PRECONDITION: `packshare.weights()` HAS AN OPINION about this roster - " + "both packs state a capacity and a reading, so the answer is a mapping " + "over both units and not the None that means 'weigh everything 1.0'", + isinstance(packshare.weights(PACKS, 1000.0), dict) + and set(packshare.weights(PACKS, 1000.0)) == {A, Bee} + and set(packshare.weights(PACKS, -1000.0)) == {A, Bee}) + +_sch = schemes(PACKS) +_splits = {k: split(v) for k, v in _sch.items()} +for _k, _v in _splits.items(): + print(f" {_k:<9} weights " + + ", ".join(f"{u}={_sch[_k][u]:g}" for u in sorted(_sch[_k])) + + " -> " + ", ".join(f"{u}{s:+d}" for u, s in sorted(_v.items()))) +check("⚠️ THE FIXTURE SEPARATES ALL FOUR SCHEMES. Two packs at 20 %/15.36 kWh " + "and 80 %/20.48 kWh - DIFFERENT IN BOTH SoC AND CAPACITY - give four " + "distinct splits of the same 1000 W, so a weighting bug has somewhere to " + "show. This is AC 2, and it is the check the other parts rest on", + len({tuple(sorted(v.items())) for v in _splits.values()}) == 4) + +_ident = [PE(A, 50.0, A_KWH), PE(Bee, 50.0, A_KWH)] +_ident_splits = {k: split(v, packs=_ident) + for k, v in schemes(_ident, (A_W, A_W)).items()} +print(" the SAME four schemes on two IDENTICAL packs (50 %, 15.36 kWh, " + "5000 W each): " + + "; ".join(f"{k}: " + ", ".join(f"{u}{s:+d}" for u, s in sorted(v.items())) + for k, v in _ident_splits.items())) +check("...and the counterfactual that says why the fixture had to be built this " + "way: on two IDENTICAL packs every one of the four schemes gives the SAME " + "split, so a fixture of equal packs cannot distinguish the shipped " + "50/50 from any weighting whatever. That is the defect this repository " + "has found eleven times, and it is why the two packs here differ in both " + "dimensions rather than in one", + len({tuple(sorted(v.items())) for v in _ident_splits.values()}) == 1) + +_same_soc = [PE(A, 50.0, A_KWH), PE(Bee, 50.0, B_KWH)] +_same_cap = [PE(A, A_SOC, A_KWH), PE(Bee, B_SOC, A_KWH)] +check("...and ONE dimension is not enough either, in EITHER direction, which " + "is the sharper half. Two packs at the SAME SoC cannot tell an " + "energy-aware weighting from a SoC-only one being wrong about capacity - " + "`soc only` collapses onto `equal` while `energy` follows the capacity " + "alone. Two packs of the SAME CAPACITY cannot tell `energy` from " + "`soc only` AT ALL: with capacity constant the energy headroom IS the SoC " + "headroom scaled, so the two give the identical split and a capacity bug " + "is invisible. Only differing in BOTH separates them", + split(schemes(_same_soc)["soc only"], packs=_same_soc) + == split(schemes(_same_soc)["equal"], packs=_same_soc) + != split(schemes(_same_soc)["energy"], packs=_same_soc) + and split(schemes(_same_cap)["energy"], packs=_same_cap) + == split(schemes(_same_cap)["soc only"], packs=_same_cap) + and split(schemes(PACKS)["energy"]) != split(schemes(PACKS)["soc only"])) + + +# ============================================================================== +print() +print("2. THE ENVELOPE: what a pack's own state of charge FORBIDS") + +_empty = PE(A, FLOOR, A_KWH) # exactly ON its own cut-out +_below = PE(A, 5.0, A_KWH) +_full = PE(Bee, 100.0, B_KWH) +_mid = PE(Bee, 55.0, B_KWH) +for _p in (_empty, _below, _full, _mid): + _e = packshare.envelope(_p) + print(f" {_p.name} at {_p.soc:g} % -> " + f"[{_e.lo:g}, {_e.hi:g}] {_e.why or '(no opinion)'}") +check("a pack AT its own BMS cut-out is not asked to discharge: `hi` closes at " + "0 W. ⚠️ AT, not below - `discharge_permitted()`'s boundary is `<=`, " + "because a pack sitting exactly on the floor has already spent everything " + "there was, and permitting one more cycle there makes the floor mean 'one " + "cycle below the floor'", + packshare.envelope(_empty).hi == 0.0 + and packshare.envelope(_below).hi == 0.0), +check("...and it may still be CHARGED - `lo` is untouched. A closed envelope is " + "a direction, not a park: parking the pack would be a claim about what it " + "may do in the direction that would fix it", + packshare.envelope(_empty).lo == -inf) +check("a pack at 100 % is not asked to charge: `lo` closes at 0 W, and it may " + "still discharge", + packshare.envelope(_full).lo == 0.0 + and packshare.envelope(_full).hi == inf) +check("a pack with room in both directions gets NO OPINION - `(-inf, +inf)`, " + "`binding` False - and main.py files no claim for it. A limit filed on " + "every unit every tick would put an open interval in the arbiter's " + "considered list forever and bury the one cycle that means something", + packshare.envelope(_mid) == packshare.PackEnvelope() + and not packshare.envelope(_mid).binding + and packshare.envelope(_empty).binding) +check("⚠️ AND THE DISCHARGE HALF ASKS `peakguard.discharge_permitted()` RATHER " + "THAN WRITING `soc <= floor` A THIRD TIME. It is the predicate SAFETY-32 " + "made public and SAFETY-42's `account()` already reuses; a third " + "transcription is two future disagreements about what it means - and in " + "particular the BOUNDARY (`<=`) is that function's, so a change to it " + "moves both. Graded by agreement over the whole percent range, on two " + "different floors, at every readable input", + all((packshare.envelope(PE(A, s, A_KWH, f)).hi == 0.0) + == (discharge_permitted(s, f) is not None) + for f in (10.0, 25.0) for s in [x / 2.0 for x in range(0, 201)])) +check("⚠️ AND A MISSING READING OR A MISSING FLOOR WITHHOLDS THE CLOSURE, NOT " + "THE DISCHARGE - THE OPPOSITE DIRECTION FROM `discharge_permitted()`'s " + "OWN, AND DELIBERATELY SO. That predicate decides whether to SPEND a pack " + "for money, where a full battery is exactly the value that would disarm " + "the floor. This decides whether to take a unit's share AWAY and hand it " + "to a sibling, and an absent measurement is not evidence that a battery " + "is flat: closing on it would park a healthy unit's discharge on every " + "tick for as long as its sensor was missing. Both absences have a " + "stronger answer arriving first - `inputs_ok` stops the whole site on a " + "missing SoC, and rule 8 plus the schema refuse an entry with no " + "`bms_soc_floor` at startup", + not packshare.envelope(PE(A, None, A_KWH)).binding + and not packshare.envelope(PE(A, 5.0, A_KWH, None)).binding + and packshare.envelope(PE(A, 5.0, A_KWH, 10.0)).hi == 0.0) +check("⚠️ AND THE FLOOR IS THE PACK'S OWN `bms_soc_floor`, NOT " + "`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`. The " + "EMS floor bounds the PEAK GUARD ONLY (SAFETY-25, decided Option A on " + "2026-09-02): ordinary self-consumption discharges straight past it, and " + "closing an envelope at it would turn an operator preference into a hard " + "stop on the grid-following loop - the memo's DEFERRED Option B, built by " + "accident. A pack at 15 % with a 10 % cut-out is NOT closed here, though " + "a 20 % `peak_soc_floor` would have closed it", + not packshare.envelope(PE(A, 15.0, A_KWH, 10.0)).binding + and discharge_permitted(15.0, 20.0) is not None) + + +# ============================================================================== +print() +print("3. THE WEIGHTS: energy headroom, and the direction it is measured in") + +_dis = packshare.weights(PACKS, +1000.0) +_chg = packshare.weights(PACKS, -1000.0) +print(f" discharging: " + ", ".join(f"{u}={v:.3f} kWh" for u, v in sorted(_dis.items()))) +print(f" charging: " + ", ".join(f"{u}={v:.3f} kWh" for u, v in sorted(_chg.items()))) +check("discharging, the weight is the energy ABOVE the pack's own cut-out: " + "15.36 kWh x (20-10) % = 1.536 kWh for A, 20.48 kWh x (80-10) % = " + "14.336 kWh for B", + abs(_dis[A] - 1.536) < 1e-9 and abs(_dis[Bee] - 14.336) < 1e-9) +check("charging, it is the room BELOW full: 15.36 x 80 % = 12.288 kWh for A, " + "20.48 x 20 % = 4.096 kWh for B", + abs(_chg[A] - 12.288) < 1e-9 and abs(_chg[Bee] - 4.096) < 1e-9) +check("⚠️ SO THE TWO DIRECTIONS DISAGREE ABOUT WHICH PACK SHOULD CARRY MORE - " + "B by 9:1 on the way out, A by 3:1 on the way in - which is why the " + "weighting cannot be computed before the site target is known and why " + "`resolve()` takes a CALLABLE. A direction-free weighting is not a " + "compromise between these two, it is wrong in one of them on every tick", + (_dis[Bee] > _dis[A]) and (_chg[A] > _chg[Bee])) +check("⚠️ AND IT IS ENERGY, NOT POWER, WHICH IS THE WHOLE OF DELIVERABLE 2. The " + "packs are asymmetric in OPPOSITE directions - B has ~33 % more energy " + "and ~8 % less power - so a power-rating weighting hands the discharge to " + "A (5000 > 4600) and an energy weighting hands it to B. The question the " + "split answers is 'which pack can sustain its share for the hour'", + A_W > B_W and B_KWH > A_KWH + and split(_sch["power"])[A] > split(_sch["power"])[Bee] + and split(_dis)[Bee] > split(_dis)[A]) +check("a roster where ANY pack cannot state its headroom gets NO OPINION for " + "the WHOLE roster - None, i.e. 1.0 each, which is what shipped. A pack " + "weighed in kWh against a sibling weighing a dimensionless 1.0 is not a " + "comparison, and half a weighting is worse than none", + packshare.weights([PE(A, A_SOC, None), PE(Bee, B_SOC, B_KWH)], 1000.0) is None + and packshare.weights([PE(A, None, A_KWH), PE(Bee, B_SOC, B_KWH)], 1000.0) is None + and packshare.weights([PE(A, A_SOC, A_KWH, None), PE(Bee, B_SOC, B_KWH)], + 1000.0) is None) +check("...and so does a roster on which NOBODY has headroom in this direction. " + "All-zero weights would drop the whole roster into `_fill()`'s LAST-RESORT " + "pass, where the shares come out equal anyway and every plan reads 'last " + "resort: the preferred units were full' - a true sentence about the wrong " + "thing", + packshare.weights([PE(A, FLOOR, A_KWH), PE(Bee, FLOOR, B_KWH)], + +1000.0) is None + and packshare.weights([PE(A, 100.0, A_KWH), PE(Bee, 100.0, B_KWH)], + -1000.0) is None) +check("...and at a roster of one there is no opinion to have: `plan()` is a " + "pass-through at N<=1 and a weight there is arithmetic nobody reads", + packshare.weights([PE(A, A_SOC, A_KWH)], 1000.0) is None + and packshare.weights([], 1000.0) is None) +check("every weight is non-negative and finite, which `allocator.plan()` " + "REFUSES to accept otherwise - a pack below its own floor is at zero " + "usable energy, not at a negative preference", + all(v >= 0.0 for v in packshare.weights( + [PE(A, 2.0, A_KWH), PE(Bee, B_SOC, B_KWH)], 1000.0).values())) + + +# ============================================================================== +print() +print("4. AC 1, END TO END: a pack at its floor is not allocated discharge") + +_at_floor = [PE(A, FLOOR, A_KWH), PE(Bee, B_SOC, B_KWH)] +_r_now = cycle(_at_floor, 1500.0, weights="shipped") +_r_opt = cycle(_at_floor, 1500.0) +print(f" shipped (no weights, no envelope): {_r_now.allocation.explain()}") +print(f" OPT-09: {_r_opt.allocation.explain()}") +check("⚠️ AC 1. A pack sitting on its own BMS cut-out is allocated NO " + "discharge, and its share moves to the sibling WITHIN THAT SIBLING'S " + "ENVELOPE. Before this card the same cycle handed it half the site " + "target - 750 W into a battery that answers no watts to any command", + _r_opt.shares[A] == 0 and _r_opt.shares[Bee] == 1500 + and _r_opt.allocation.unmet_w == 0) +check("...and the shipped behaviour really was the other one, so the line " + "above is not vacuous: an equal split of 1500 W is 750/750, and 750 W is " + "what a pack at its cut-out was being told to produce", + _r_now.shares == {A: 750, Bee: 750}) +_plan_a = next(p for p in _r_opt.allocation.plans if p.unit == A) +print(f" {_plan_a.explain()}") +check("...and the RECORD says why, in the line an operator is already reading. " + "`UnitPlan.within_envelope` is asked rather than assumed, and SAFETY-28's " + "write gate refuses a record that contradicts itself", + _plan_a.within_envelope and _plan_a.share_w == 0 + and _plan_a.hi == 0.0 and _r_opt.allocated) +check("a pack at 100 % is not allocated a CHARGE either, and the sibling takes " + "the whole of it - the same sentence in the other direction", + cycle([PE(A, 100.0, A_KWH), PE(Bee, B_SOC, B_KWH)], -1200.0).shares + == {A: 0, Bee: -1200}) +check("⚠️ AND THE SIGN-COHERENCE REFUSAL IS NOT TRIPPED BY ANY OF THIS. One " + "envelope closed at 0 and the other open cannot produce two shares of " + "opposite sign, because the fill moves every share in ONE direction from " + "a base that is still 0 for the closed unit. Circulating power stays " + "inexpressible", + all(cycle([PE(A, s, A_KWH), PE(Bee, 100.0 - s, B_KWH)], t).allocated + for s in (0.0, FLOOR, 50.0, 100.0) for t in (-2000.0, 0.0, 2000.0))) +check("a unit whose envelope is closed is still IN the split - it is not " + "dropped. `dropped` means the master is stale and the record says so; a " + "full pack is present, commandable, and told 0 W", + not cycle(_at_floor, 1500.0).allocation.dropped + and A in cycle(_at_floor, 1500.0).shares) + + +# ============================================================================== +print() +print("5. AC 3: unmet_w when NEITHER unit can take the remainder") + +_both = [PE(A, FLOOR, A_KWH), PE(Bee, 8.0, B_KWH)] +_r_both = cycle(_both, 1500.0) +print(f" both packs at or under their cut-out: {_r_both.allocation.explain()}") +check("⚠️ AC 3. With every pack at or under its own cut-out the discharge has " + "nowhere to go: both shares are 0 and the whole 1500 W is reported as " + "`unmet_w`. Not refused - `Allocation` is explicit that fusing `unmet_w` " + "into `ok` would let one saturated inverter park the site - and not " + "silently absorbed either", + _r_both.allocated and _r_both.shares == {A: 0, Bee: 0} + and _r_both.allocation.unmet_w == 1500) +check("...and the identity still holds over integers: shares + unmet == sum_w, " + "which is what makes `unmet_w` a measurement rather than an estimate", + sum(_r_both.shares.values()) + _r_both.allocation.unmet_w + == _r_both.allocation.sum_w) +check("...and it is VISIBLE: `explain()` prints it in the line the action log " + "carries, and main.py edge-logs the transition (`_unmet_last`). Before " + "this card nothing anywhere reported a watt that could not be placed", + "UNMET" in _r_both.allocation.explain()) +_partial = [PE(A, FLOOR, A_KWH), PE(Bee, B_SOC, B_KWH)] +check("...and a partial shortfall is reported partially rather than all-or- " + "nothing: one pack closed and the sibling's own rating short of the ask " + "places what it can and names the rest", + cycle(_partial, 6000.0).allocation.unmet_w == 6000 - int(B_W)) + + +# ============================================================================== +print() +print("6. AC 4: at N=1 the allocation is BYTE-IDENTICAL to what shipped") + +# ⚠️ THE WHOLE OF AC 4 IS THAT `main.py` DOES NOT FILE THE `pack` CLAIM AT N=1, +# and the reason is `actuator_for()`: at one inverter every source routes to the +# SITE, so a unit interval filed there would bound THE SITE TARGET rather than a +# share. That is a behaviour change on the live single-inverter install and it is +# SAFETY-25's decided Option A. What this part grades is the arithmetic on both +# sides of that `if`. +_one = [PE(A, FLOOR, A_KWH)] +_n1_claims = [Claim.limit("supervised", P_SAFETY, -A_W, A_W, "max_w"), + Claim.set("loop", P_LOOP, 1234.5, "grid")] +_n1_now = resolve(_n1_claims, units=(A,), tick=1) +_n1_opt = resolve(_n1_claims, units=(A,), + weights=lambda t: packshare.weights(_one, t), tick=1) +print(f" N=1 shipped: {_n1_now.shares} OPT-09: {_n1_opt.shares}") +check("the pass-through is untouched: the share IS the site target, as a float, " + "unrounded and unclamped, with or without the weights argument", + _n1_now.shares == _n1_opt.shares == {A: 1234.5} + and _n1_opt.allocation.passthrough) +check("...and that holds across the whole range, sign for sign, because " + "`weights()` answers None at a roster of one and `plan()` never reaches " + "its weighted path there anyway", + all(resolve([Claim.limit("supervised", P_SAFETY, -A_W, A_W, "max_w"), + Claim.set("loop", P_LOOP, t, "grid")], units=(A,), + weights=lambda x: packshare.weights(_one, x), + tick=1).shares + == resolve([Claim.limit("supervised", P_SAFETY, -A_W, A_W, "max_w"), + Claim.set("loop", P_LOOP, t, "grid")], units=(A,), + tick=1).shares + for t in (-5000.0, -1999.5, -1.0, 0.0, 1.0, 999.25, 5000.0))) +check("⚠️ AND A CALLABLE THAT ANSWERS None IS THE SAME AS NO ARGUMENT AT ALL, " + "at every roster size. That is what makes the new parameter inert on any " + "install that has not stated its capacities", + resolve(site_claims(PACKS, 1000.0), units=(A, Bee), + weights=lambda t: None, tick=1).shares + == resolve(site_claims(PACKS, 1000.0), units=(A, Bee), tick=1).shares) + + +# ============================================================================== +print() +print("7. AC 5: verified BY BREAKING IT - equal weights on unequal packs") + +# ⚠️ THE CARD ASKS FOR THIS EXPLICITLY: "force equal weights on unequal packs and +# observe red". The point is that the checks above are not passing for a reason +# unrelated to the weighting - so here the SAME cycles are run with the weighting +# reverted to what shipped, and what they produce is printed beside what OPT-09 +# produces. +_cases = [ + ("1500 W out, A on its cut-out", _at_floor, 1500.0), + ("1200 W in, A full", [PE(A, 100.0, A_KWH), PE(Bee, B_SOC, B_KWH)], -1200.0), + ("1000 W out, both with room", PACKS, 1000.0), +] +_broken = [] +for _why, _pk, _t in _cases: + _o, _e = cycle(_pk, _t), cycle(_pk, _t, weights="shipped") + _broken.append((_why, _o.shares, _e.shares)) + print(f" {_why:<32} OPT-09 " + + ", ".join(f"{u}{s:+d}" for u, s in sorted(_o.shares.items())) + + " equal-weights " + + ", ".join(f"{u}{s:+d}" for u, s in sorted(_e.shares.items()))) +check("every one of these cycles gives a DIFFERENT answer with BOTH halves " + "reverted to what shipped - equal weights, no envelope - so each check " + "above is separated from its own mutation rather than passing for a " + "reason unrelated to the weighting", + all(o != e for _w, o, e in _broken)) +_env_only = cycle(_at_floor, 1500.0, weights="equal") +print(f" and with the ENVELOPE alone (equal weights, `pack` claim filed): " + + ", ".join(f"{u}{s:+d}" for u, s in sorted(_env_only.shares.items()))) +check("...and the two halves are separated from EACH OTHER as well: on a pack " + "sitting at its cut-out the envelope alone already gives the whole ask to " + "the sibling, because a closed `cap` is obeyed absolutely, so this case " + "grades the ENVELOPE. Case three - both packs with room, nothing closed - " + "is the one that can only be the WEIGHTS", + _env_only.shares == {A: 0, Bee: 1500} + and _broken[2][1] != _broken[2][2]) +check("...and the third case is the one that isolates the WEIGHTS from the " + "ENVELOPE: both packs have room, no interval is closed, nothing is " + "refused - and the split still moves from 500/500 to 97/903 because the " + "energy headroom says so", + _broken[2][1] == {A: 97, Bee: 903} and _broken[2][2] == {A: 500, Bee: 500}) + + +# ============================================================================== +print() +print("8. ⚠️ WHAT THE WEIGHTS CANNOT DO, WHICH IS THE HALF THAT IS EASY TO " + "OVER-READ") + +# ARCH-01: the gain seen at the meter is `sum(a_i * g_i)`, a CONVEX COMBINATION +# of the per-unit gains, so a bad split cannot DESTABILISE the site. That says +# nothing about whether the site can REGULATE. A unit at its energy limit has +# g_i = 0; the combination is still inside [min g, max g] and the load it was +# asked to carry simply does not move. +RATING = 2000.0 # per unit, so the SITE rail is 4000 W + + +def close_loop(load_w, mode, cycles=200, rating=RATING): + """One integrator on the meter, two units, each told its OWN share. + + `mode` is what varies. "shipped" is equal weights and no envelope; "weights" + is OPT-09's preference with the envelope left open; "envelope" is OPT-09 + entire. Unit B is AT ITS FLOOR throughout and therefore answers 0 W to any + discharge command - the benign case `delivery.py` measures as + indistinguishable, IN THE POWER DOMAIN, from a wedged box. + + ⚠️ `max_w` IS THE SITE RAIL - THE SUM OF THE UNIT RATINGS - because that is + what main.py builds `Tuning` with (`max_w=self.site_max_w`). Handing the + loop one unit's rating would model an add-on that does not exist and would + hide the wind-up this part is about: the first draft of this check did that + and reported a 500 W standing error where the shipped law reaches zero. + """ + tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=2.0 * rating) + prev, i_w, sat = 0.0, None, 0 + power = [0.0, 0.0] + grid = load_w + packs = [PE(A, 50.0, A_KWH), PE(Bee, FLOOR, B_KWH)] + trace = [] + for _c in range(cycles): + d = compute(prev, grid, sum(power), tuning, sat, i_w) + prev, i_w, sat = d.target_w, d.i_w, d.sat_count + bounds = {p.name: _B(-rating, rating) for p in packs} + if mode == "envelope": + for p in packs: + e = packshare.envelope(p) + bounds[p.name] = _B(max(-rating, e.lo), min(rating, e.hi)) + w = None if mode == "shipped" else packshare.weights(packs, d.target_w) + r = plan(d.target_w, bounds, weights=w) + assert r.ok, r.refused + # THE MODELLED PLANT. A obeys within one cycle inside its rating; B is at + # its floor and produces NOTHING in the discharge direction whatever it + # is told - which is `plantsim.Plant.limit()`'s own answer for a pack at + # its floor, and is why SAFETY-42 cannot read this case from power alone. + power = [max(-rating, min(rating, float(r.shares[A]))), + min(0.0, float(r.shares[Bee]))] + grid = load_w - sum(power) + trace.append((round(d.target_w, 1), dict(r.shares), round(grid, 1), + r.unmet_w, d.reason)) + return trace + + +_MODES = ("shipped", "weights", "envelope") +_row = {} +for _load in (1500.0, 3000.0): + for _m in _MODES: + _t = close_loop(_load, _m)[-1] + _row[(_load, _m)] = _t + print(f" load {_load:.0f} W, B at its floor, {_m:<9} " + f"site target {_t[0]:+.0f} W, shares " + + ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items())) + + f", grid {_t[2]:+.0f} W, unmet {_t[3]:+d} W, {_t[4]}") + +check("AT A LOAD THE HEALTHY PACK CAN COVER, ALL THREE ARMS REGULATE TO ZERO " + "GRID ERROR - and the shipped one is NOT thereby fine. It gets there by " + "winding the SITE TARGET to 3000 W for a 1500 W load and standing a " + "+1500 W command on a battery sitting on its own cut-out, permanently " + "saturation-frozen; OPT-09 reaches the same meter reading with a 1500 W " + "target, nothing standing on the empty pack, and the loop in its " + "deadband. The improvement here is not watts, it is that the site has " + "stopped lying to itself - a loop wound to twice the load has no headroom " + "left for the next one", + abs(_row[(1500.0, "shipped")][2]) < 1.0 + and abs(_row[(1500.0, "weights")][2]) < 1.0 + and abs(_row[(1500.0, "envelope")][2]) < 1.0 + and _row[(1500.0, "shipped")][0] == 3000.0 + and _row[(1500.0, "shipped")][1][Bee] == 1500 + and _row[(1500.0, "shipped")][4] == "saturated-freeze" + and _row[(1500.0, "envelope")][0] == 1500.0 + and _row[(1500.0, "envelope")][1][Bee] == 0) +check("⚠️ AND AT A LOAD IT CANNOT COVER, THE WEIGHTS STOP WORKING ALTOGETHER. " + "The weighted arm and the shipped arm command the SAME +2000 W into the " + "empty pack and deliver the SAME 2000 W, because once the preferred unit " + "is at its cap `_fill()`'s LAST-RESORT PASS hands the remainder to the " + "zero-weighted one - deliberately, so that a preference can never " + "manufacture a standing error at the meter. A weight of 0 is 'please do " + "not', never 'you may not'", + _row[(3000.0, "weights")][1] == _row[(3000.0, "shipped")][1] + == {A: 2000, Bee: 2000} + and _row[(3000.0, "weights")][2] == _row[(3000.0, "shipped")][2]) +check("⚠️ AND THE ENVELOPE DOES NOT RESCUE THE DELIVERY EITHER - IT NAMES THE " + "SHORTFALL. Same 2000 W delivered, same 1000 W standing at the meter, " + "because the sibling is already at its rating and there is nowhere for " + "the rest to go; what changes is that the empty pack is commanded 0 W " + "instead of 2000 W and 2000 W of site target is REPORTED as unplaceable " + "instead of appearing to have been placed. THIS IS THE WHOLE OF " + "DELIVERABLE 3: ARCH-01's convex combination guarantees a bad split " + "cannot DESTABILISE the site and says nothing about whether the site can " + "REGULATE. A unit at its energy limit has g_i = 0, `compute()` freezes on " + "the resulting error, and the load DOES NOT SHIFT to the sibling by any " + "mechanism in this add-on", + _row[(3000.0, "envelope")][2] == _row[(3000.0, "weights")][2] + and _row[(3000.0, "envelope")][1] == {A: 2000, Bee: 0} + and _row[(3000.0, "envelope")][3] == 2000 + and _row[(3000.0, "weights")][3] == 0) +check("...so: WEIGHTS ARE A COMFORT CHOICE FOR STABILITY AND NOT FOR DELIVERY, " + "and the same sentence stated as arithmetic on one plan rather than as a " + "200-cycle run", + plan(3000.0, {A: _B(-RATING, RATING), Bee: _B(-RATING, RATING)}, + weights={A: 1.0, Bee: 0.0}).shares == {A: 2000, Bee: 1000}) +check("...and SAFETY-42 is where the case this card CANNOT reach is handled. A " + "WEDGED unit - one that accepts its share and does not produce it - has a " + "healthy state of charge, so no envelope closes and no weight moves; that " + "module detects it, latches and notifies, and deliberately does not act. " + "OPT-09 stops the site ASKING a pack whose own SoC says it cannot " + "deliver; SAFETY-42 alarms when a pack that said it could does not. Same " + "hazard, opposite sides, and the seam is the state of charge", + not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding) + + +# ============================================================================== +print() +print("9. PER-PACK ARBITRAGE SIZING") + +# ⚠️ THE FUNCTION UNDER TEST IS THE SHIPPED ONE, NOT A TRANSCRIPTION OF IT. +# `Controller.trade_power_w()` is one line - the roster view handed to +# `packshare.trade_power_w()` - and this calls that same function. An earlier +# draft of this part re-implemented the two sums here, which is the defect this +# repository keeps finding: the copy that drifts is always the grading one. +trade_power_w = packshare.trade_power_w + +_near_full = [PE(A, 50.0, A_KWH, max_w=A_W), PE(Bee, 95.0, B_KWH, max_w=B_W)] +_c, _d = trade_power_w(_near_full, 20.0, 90.0) +print(f" A 50 %, B 95 %, arbitrage bounds 20/90 %: charge_w {_c:.0f} W, " + f"discharge_w {_d:.0f} W (site rating {A_W + B_W:.0f} W)") +check("⚠️ THE BUY SIDE IS THE ONE THAT BITES. With B over the arbitrage ceiling " + "the trade is sized at A's rating alone, where before this card it was " + "the sum of the ratings - the site would buy at 9600 W with one pack able " + "to absorb it, and the watts that could not be placed would come back as " + "`unmet_w` for the site integrator to chase for the rest of the hour", + _c == A_W and _c < A_W + B_W) +check("...and the sell side is unchanged here, because B is well above the " + "floor. Sizing is a REDUCTION of the ask and never an increase", + _d == A_W + B_W) +check("⚠️ AND THE SELL SIDE IS A NO-OP ON EVERY ROSTER TODAY, WHICH IS SAID " + "RATHER THAN LEFT FOR A REVIEWER TO FIND. `arbitrage.decide()` is handed " + "`pack_floor_soc` - the EMPTIEST pack - so a sell is refused outright " + "whenever ANY pack is at or under the floor, i.e. in exactly the states " + "where this sum could be smaller than the site rating. The arithmetic is " + "graded here; it becomes reachable the day that gate becomes per-pack, " + "and moving it is NOT this card's - it would let a sell be sized off the " + "fullest pack while the weights, which are only a preference, decided " + "where the watts went", + trade_power_w([PE(A, 15.0, A_KWH, max_w=A_W), + PE(Bee, B_SOC, B_KWH, max_w=B_W)], 20.0, 90.0)[1] == B_W + and min(15.0, B_SOC) <= 20.0) +check("a pack whose SoC did not read counts on NEITHER side - withholding is " + "the direction that cannot over-size, and `inputs_ok` has already stopped " + "the site by then", + trade_power_w([PE(A, None, A_KWH, max_w=A_W), + PE(Bee, B_SOC, B_KWH, max_w=B_W)], 20.0, 90.0) + == (B_W, B_W)) + + +# ⚠️ AND THE SIZING MUST NEVER REACH `claim()` AS A ZERO, which is a hazard the +# sizing itself created: `arbitrage.claim()`'s own note says a `Claim.set(..., 0)` +# at P_ARBITRAGE is a PARK ORDER that outranks the loop, i.e. it would silently +# disable self-consumption for the whole hour. Graded on `_act()` directly - the +# branch that builds the decision - because a fixture that reached it through a +# real `TariffPlan` would be grading the plan. +_NOW = datetime(2026, 9, 6, 12, 0, tzinfo=timezone.utc) + + +class _Pt: + def __init__(self, start, price=0.30): + self.start, self.eur_per_kwh, self.provisional = start, price, False + + +class _Plan: + """The smallest object `_point_at()` accepts: `plan.read.match(series=...)`.""" + def __init__(self, pt): + self.read = self + self._pt = pt + + def match(self, series, no_data): + return series(self) + + def at(self, when): + return self._pt + + +class _V: + def __init__(self, cheapest, dearest): + self.spread_eur_kwh, self.required_spread_eur_kwh = 0.20, 0.05 + self.cheapest, self.dearest = cheapest, dearest + + +_here = _Pt(_NOW) +_other = _Pt(_NOW - timedelta(hours=3)) +_sell = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0, + 0.0, 0.0, 20.0, 90.0) +_sell_ok = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0, + 0.0, 4600.0, 20.0, 90.0) +_buy = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0, + 0.0, 0.0, 20.0, 90.0) +_buy_ok = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0, 5000.0, + 0.0, 20.0, 90.0) +print(f" sized at 0 W: sell -> {_sell.action}, buy -> {_buy.action}; " + f"sized at a rating: sell -> {_sell_ok.action}, buy -> {_buy_ok.action}") +check("⚠️ A TRADE NOTHING CAN TAKE IS NOT A TRADE. A 0 W sizing IDLES on both " + "sides rather than becoming a 0 W claim, because a `Claim.set(..., 0.0)` " + "at P_ARBITRAGE is a PARK ORDER that outranks the loop - the failure " + "`arbitrage.claim()`'s own note is written against. The sizing above can " + "never be zero on a tick that trades TODAY, and this makes that " + "structural instead of a coincidence of the sell gate", + not _sell.trading and not _buy.trading + and arbitrage.claim(_sell) is None and arbitrage.claim(_buy) is None + and arbitrage.IDLE_NO_SIZE in _sell.reason + and arbitrage.IDLE_NO_SIZE in _buy.reason) +check("...and the SAME two calls with a real sizing DO trade, so the check " + "above is separated from a fixture that simply never reaches the branch", + _sell_ok.action == arbitrage.DISCHARGE + and _buy_ok.action == arbitrage.CHARGE + and _sell_ok.power_w == 4600.0 and _buy_ok.power_w == -5000.0) + + +# ============================================================================== +print() +print("10. THE CLAIM, THE SCOPE, AND WHAT THIS FILE CANNOT SEE") + +check("the `pack` source is classified in `CLAIM_SCOPE` as a UNIT claim, so " + "`scope_violations()` complains if it is ever filed on the site while the " + "site has named units. It is about one battery in one cupboard, exactly " + "as `device`'s rating is about one box", + CLAIM_SCOPE[packshare.SOURCE] == SCOPE_UNIT) +_sc = resolve(site_claims(_at_floor, 1500.0), units=(A, Bee), + weights=lambda t: packshare.weights(_at_floor, t), tick=1) +check("...and a real cycle files it without a scope complaint", + _sc.scope_errors == []) +_mis = resolve([Claim.limit("pack", P_SAFETY, -inf, 0.0, "misfiled", + actuator=ACTUATOR_SITE), + Claim.limit("supervised", P_SAFETY, -A_W, A_W, "max_w", + actuator=A), + Claim.set("loop", P_LOOP, 1000.0, "grid")], + units=(A, Bee), tick=1) +check("...and misfiling it ON THE SITE is caught and REFUSED, not consumed. A " + "per-pack interval in the site list is the cross-inverter `lo > hi` " + "ARB-03's partition exists to make inexpressible", + _mis.scope_errors and not _mis.allocated) + +# ⚠️ AND THE ARGUMENT IS ACTUALLY PASSED, WHICH IS THE WHOLE CARD AND WHICH +# NOTHING BEHAVIOURAL IN THIS FILE CAN SEE. Every cycle above builds its own +# claim list and calls `resolve()` itself, so deleting `weights=` from main.py's +# ONE call site would leave this entire file green - which is exactly the state +# release/1.0 was in, for the whole life of the parameter. It is pinned on +# main.py's own AST, the way test_peakguard.py part 12i pins the arguments of +# `arbitrage.decide()`, and for the same reason: a call site that stopped +# passing it is invisible to every fixture. +_main_src = (Path(__file__).resolve().parent / "app" / "main.py").read_text( + encoding="utf-8") +_resolve_calls = [n for n in ast.walk(ast.parse(_main_src)) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) + and n.func.id == "resolve"] +_w_arg = [ast.unparse(k.value) for c in _resolve_calls for k in c.keywords + if k.arg == "weights"] +print(f" main.py has {len(_resolve_calls)} `resolve(` call site(s); " + f"weights= is {_w_arg}") +check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHER " + "THAN A DICT. THIS IS THE CARD: the parameter has existed since ALLOC-01 " + "and nothing ever filled it, so the split was 50/50 whatever either pack " + "could do - and no fixture anywhere could see that, because every check " + "calls `resolve()` for itself. A method, not a dict, because the " + "weighting is direction-dependent and the direction is stage 2's output", + len(_resolve_calls) == 1 and _w_arg == ["self.pack_weights"]) + +# ⚠️ AC 3 SAYS `unmet_w` MUST BE **VISIBLE**, AND `Allocation.explain()` IS ONLY +# HALF OF THAT. The other half is main.py putting it in the action log, which no +# check in this file can reach without building a whole Controller - so it is +# pinned STATICALLY, on `Controller.cycle()`'s own AST, the way test_peakguard.py +# part 10f pins the claim sources. What this grades is that the branch EXISTS, +# reads the allocation's own `unmet_w`, is EDGE-TRIGGERED against the previous +# cycle's value, and logs. What it does NOT grade is the sentence it logs or +# that a real cycle reaches it; that is stated here rather than implied. +_cycle_src = next( + f for cls in ast.walk(ast.parse( + (Path(__file__).resolve().parent / "app" / "main.py") + .read_text(encoding="utf-8"))) + 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") +_unmet_ifs = [n for n in ast.walk(_cycle_src) if isinstance(n, ast.If) + and ast.unparse(n.test) == "_unmet != self._unmet_last"] +_unmet_src = [ast.unparse(n) for n in ast.walk(_cycle_src) + if isinstance(n, ast.Assign) and len(n.targets) == 1 + and ast.unparse(n.targets[0]) == "_unmet"] +print(f" main.py computes `_unmet` as {_unmet_src} and branches on it " + f"{len(_unmet_ifs)} time(s)") +check("...and main.py REPORTS an unplaceable watt rather than only recording " + "it: `Controller.cycle()` reads `self.allocation.unmet_w`, compares it " + "against the previous cycle's value - edge-triggered, because an empty " + "pack stays empty and a line a second would flush the 40-row action log " + "of the context that explains it - and calls `log_event` on the change. " + "⚠️ THIS IS A SHAPE PIN: it says the branch exists and what it reads, " + "never what it says or that a live cycle reaches it", + len(_unmet_ifs) == 1 + and len(_unmet_src) == 1 and "unmet_w" in _unmet_src[0] + and any(isinstance(n, ast.Call) + and ast.unparse(n.func) == "self.log_event" + for n in ast.walk(_unmet_ifs[0]))) + +print() +print(" ⚠️ WHAT THIS FILE CANNOT SEE, said plainly:") +print(" * NOTHING HERE IS EVIDENCE ABOUT INVERTER B. It does not exist;") +print(" every B figure is datasheet, and part 8's plant is modelled in") +print(" this file - two copies of A given different numbers.") +print(" * IT DOES NOT GRADE THE TAPER. `spare_kwh()` says a pack at 95 %") +print(" has room; a real pack in constant-voltage taper takes far less") +print(" than it is told for tens of minutes, and this add-on has no") +print(" per-pack charge ceiling to describe that with. `delivery.py`") +print(" says the same thing and calls it a card - it is why SAFETY-42") +print(" is armed on discharge shares only.") +print(" * THE CHARGE ENVELOPE CLOSES AT 100 % AND NOWHERE ELSE. That is") +print(" the only ceiling that is a property of a PACK rather than of a") +print(" policy; `arbitrage_soc_ceiling` is a trade preference and using") +print(" it here would be SAFETY-25's deferred option in the charge") +print(" direction. Everything between is left to the weights, which are") +print(" a preference and are honest about being one.") +print(" * A WEDGED UNIT IS NOT REACHABLE FROM HERE AT ALL. Its SoC reads") +print(" healthy, so no envelope closes. SAFETY-42 owns it.") + +# ============================================================================== +print() +print("11. MUTATION: what defeats this card, and which check bites") + +# ⚠️ EVERY ROW BELOW WAS RUN, on a tree rsynced from this branch with +# `__pycache__` purged and `python3 -B`, not reasoned about. MUTATION.md is the +# method. The two columns that matter are WHICH FILE went red and WHICH CHECK - +# a mutation caught only by a crash is recorded as such, because a traceback is +# a weaker gate than a named failure and the distinction is the point of the +# table. +MUT = [ + ("M1 envelope(): never close `hi` (the discharge end)", + "test_packshare 2 and 4, and test_delivery 4 - SAFETY-42's cold arm goes " + "back to being ASKED for a share it cannot deliver"), + ("M2 envelope(): never close `lo` (the charge end)", + "test_packshare 2, and test_peakguard 10g - the two-inverter cycle's " + "resolved envelope for a full pack reverts to +/-rating"), + ("M3 usable_kwh(): drop the capacity, weigh by SoC headroom alone", + "test_packshare 1 (the four schemes stop separating) and 3"), + ("M4 weights(): ignore the direction, always use usable_kwh", + "test_packshare 3 - the charge side reports the discharge headroom"), + ("M5 envelope(): `soc < floor` instead of discharge_permitted()'s `<=`", + "test_packshare 2 - the boundary and the agreement sweep"), + ("M6 main.py: stop passing `weights=` to resolve() <- THE CARD ITSELF", + "test_packshare 10, the AST pin. ⚠️ NOTHING BEHAVIOURAL CATCHES THIS, in " + "this file or any other, because every fixture calls resolve() for " + "itself - which is exactly the state release/1.0 was in"), + ("M7 main.py: file the `pack` claim at N=1 too", + "test_peakguard 10f's SoC sweep - and NOT test_packshare, which is " + "recorded rather than fixed: this file grades the arithmetic, and 'which " + "claims a real one-inverter cycle files' is that file's instrument"), + ("M8 trade_power_w(): drop the SoC filter, size at the site rating", + "test_packshare 9"), + ("M9 main.py: delete the unmet_w action-log branch", + "test_packshare 10, the AST pin - a shape check, and it says so"), + ("M10 weights(): answer None for every roster", + "test_packshare 1's PRECONDITION. ⚠️ IT WAS A TRACEBACK BEFORE THAT LINE " + "EXISTED - the first draft of this file had no precondition and the " + "mutation took it down with a TypeError three lines into part 1"), +] +for _n, _w in MUT: + print(f" caught {_n}") + print(f" {_w}") +check("every mutation in the table above was RUN and every one of them is " + "caught by a named check on this branch. ⚠️ AND THE ROW WORTH READING IS " + "M6: the whole defect this card fixes - a parameter nothing passes - is " + "invisible to every behavioural fixture in this repository, because they " + "all build their own claim list and call `resolve()` themselves. That is " + "why the call site is pinned statically as well", + len(MUT) == 10) + +print() +if fails: + print(f"{len(fails)} FAILED: {', '.join(fails)}") + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index ee083e9..25e6f48 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -2576,9 +2576,42 @@ check("...and so does the persistent notification, which outlives the page and " # observe exactly {device, supervised, loop} at every SoC, because the shipped # roster is one inverter and this claim cannot be filed there at all. See # DECISIONS/SAFETY-30-per-unit-sign.md. +# ⚠️ OPT-09 ADDED THE ELEVENTH, AND IT IS THE ONE THIS TRIPWIRE WAS BUILT FOR +# THE SHAPE OF, SO IT NEEDS THE LONGEST PARAGRAPH. `pack` is a +# `Claim.limit(... P_SAFETY ...)` on ONE INVERTER'S actuator, filed only where it +# BINDS and only at a roster of TWO OR MORE, carrying that pack's own state of +# charge against that pack's own `bms_soc_floor`: `hi = 0` for a battery at or +# under its cut-out, `lo = 0` for one at 100 %. +# +# IT IS CONDITIONED ON A STATE OF CHARGE, WHICH IS EXACTLY WHAT THIS SET WATCHES +# FOR, so the argument has to be made rather than asserted. SAFETY-25's deferred +# Option B was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW +# `loop_soc_floor` option: an EMS PREFERENCE, chosen by an operator, stopping the +# grid-following loop at a depth somebody picked, ON THE SITE. Four things +# separate this from it, and all four are graded elsewhere in this repository: +# +# 1. THE NUMBER IS NOT AN OPTION. It is `bms_soc_floor` - measured off +# register 0x0560 and required of every roster entry since SAFETY-32. No +# new option is added and `peak_soc_floor` is deliberately NOT consulted +# (test_packshare.py part 2 grades that a pack at 15 % with a 10 % cut-out +# is NOT closed, though a 20 % EMS floor would have closed it). +# 2. IT IS A UNIT CLAIM, SO IT CANNOT BOUND THE SITE TARGET. It changes WHICH +# INVERTER carries the target, never how big the target is. +# 3. IT IS NOT FILED AT ALL AT A ROSTER OF ONE, which is why the sweeps below +# still observe exactly {device, supervised, loop} at every state of charge +# from 0 to 100 % on the shipped install. Option A stands untouched there: +# ordinary self-consumption still discharges to the BMS cut-out. +# 4. AND THE MEMO ITSELF NAMES THIS AS THE REVISIT: "under ALLOC-01 the +# reserve becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a +# ninth claim here". Stage 1 is how an interval reaches the allocator, and +# stage 1's input is claims - so a unit-scoped claim is the shape that +# sentence asks for. +# +# If you are here because you added a TWELFTH, that argument is what this line +# wants from you, not an edit. CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak", "arbitrage", "maintenance", "loop", "circulation", - "sign_site"} + "sign_site", "pack"} # 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. @@ -2619,10 +2652,11 @@ 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 nine. 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", +check("...and the sources that cycle can file are EXACTLY these ELEVEN. 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 new source. Filing one takes this line red on the line it " + "is written, and OPT-09's `pack` had to argue its way past it above", set(_filed) == CLAIM_SOURCES) @@ -2749,8 +2783,8 @@ check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into " # structurally impossible. The structure is `CLAIM_SCOPE`, so `CLAIM_SCOPE` is # pinned to the AST-scanned set rather than trusted to agree with it. check("every source main.py can file is CLASSIFIED site or per-inverter, and " - "nothing else is. A TENTH source now goes red twice - once here for " - "having no classification, once above for being a tenth - and it takes an " + "nothing else is. A TWELFTH source now goes red twice - once here for " + "having no classification, once above for being a twelfth - and it takes an " "argument about what the claim is ABOUT to make either line green", set(CLAIM_SCOPE) == CLAIM_SOURCES) print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() @@ -2758,8 +2792,8 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() + " | per-inverter: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT))) check("...and the split is the one ARB-03 argued, with SAFETY-31's and " - "SAFETY-30's additions: the six about the GRID CONNECTION are site " - "claims and the four about a pack, a device or one write are " + "SAFETY-30's and OPT-09's additions: the six about the GRID CONNECTION " + "are site claims and the five about a pack, a device or one write are " "per-inverter. `circulation` is a site claim because its quantity is " "defined over the roster and is identically zero for any one unit; " "`sign_site` is one because the number that tripped it is the SITE " @@ -2769,7 +2803,7 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and " {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"} and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} - == {"device", "supervised", "failsafe", "maintenance"}) + == {"device", "supervised", "failsafe", "maintenance", "pack"}) # The shipped roster is ONE inverter, so §6.2's identity holds and every claim a # real cycle files is a site claim. Observed on the claims main.py's own resolve @@ -2853,8 +2887,9 @@ _restore_b() print(" with a second inverter on the roster, main.py routes: " + ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items()))) check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second " - "inverter and its OWN routing method splits the ten: `device`, " - "`supervised`, `failsafe` and `maintenance` bind the unit this controller " + "inverter and its OWN routing method splits the eleven: `device`, " + "`supervised`, `failsafe`, `maintenance` and OPT-09's `pack` bind the " + "unit this controller " "drives, `loop`, `peak`, `arbitrage`, `safety`, SAFETY-31's `circulation` " "and SAFETY-30's `sign_site` stay on the grid connection. Swap any one of " "them - route `peak` to the unit and the site keeps no floor; route " @@ -2865,6 +2900,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second " "rest of the roster goes on pushing the same way - and this line goes red", _routed == {"device": "inverter-a", "supervised": "inverter-a", "failsafe": "inverter-a", "maintenance": "inverter-a", + "pack": "inverter-a", "loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE, "arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE, "circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE}) @@ -2948,16 +2984,16 @@ _unit_sources_unrouted = [ if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]] print(f" {len(_direct)} direct appends, each read at its own line: " + ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing)) -check("all TEN of `Controller.cycle()`'s DIRECT appends route through " +check("all ELEVEN of `Controller.cycle()`'s DIRECT appends route through " "`self.actuator_of()`, so this scan can read the routing " "of EVERY one of them. An append that computed its actuator, or omitted " "the argument, or hand-wrote a string, fails HERE rather than being " "silently skipped - which is how a scan like this goes quietly vacuous, " "and 10f is in this file because that already happened once", - len(_direct) == 10 + len(_direct) == 11 and all(a is not None and a.startswith("self.actuator_of('") for _s, a, _ln in _routing)) -check("⚠️ AND AT EVERY ONE OF THE TEN THE LITERAL IT ROUTES THROUGH IS THE " +check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE " "SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed " "`actuator_of(...)` at a call site goes red on - all nine of them, on " "the line the typo is written, at every roster, INCLUDING the six " @@ -2971,14 +3007,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. " "site-scoped sources and is the whole defect for a per-inverter one: a " "`device` limit built inside the roster loop and routed without `inv` " "files inverter B's rating on inverter A, where it binds the wrong " - "share. Five of the ten appends are SCOPE_UNIT - `device`, " - "`supervised`, `failsafe` and maintenance's two - and all five pass one. " + "share. Six of the eleven appends are SCOPE_UNIT - `device`, " + "`supervised`, `failsafe`, maintenance's two and OPT-09's `pack` - and " + "all six pass one. " "SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on " "the site, beside `circulation`, because the number that tripped it is " "the site target", _unit_sources_unrouted == [] and len([r for r in _routing - if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 5) + if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 6) check("...and the only appends this pin does NOT reach are the two that file a " "claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no " "`actuator=` at the call site to mis-type. Their routing is pinned where " @@ -2987,7 +3024,7 @@ check("...and the only appends this pin does NOT reach are the two that file a " "arbitrage.claim() by test_arbitrage.py", sorted(_indirect) == sorted(CLAIM_BUILDERS) and len(_direct) + len(_indirect) == len(_appends)) -check("...and between them the eleven appends account for exactly the nine " +check("...and between them the twelve appends account for exactly the eleven " "sources 10f pins, so the routing pin and the source set are one scan " "and neither can drift from the other", {s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values()) @@ -3027,10 +3064,19 @@ check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN " "connection", sorted((c.source, c.actuator) for c in _cl2) == [("device", "inverter-a"), ("device", "inverter-b"), - ("loop", ACTUATOR_SITE), ("supervised", "inverter-a"), + ("loop", ACTUATOR_SITE), + # ⚠️ OPT-09, AND IT IS OBSERVED HERE RATHER THAN ONLY PINNED + # STATICALLY. This fixture leaves both packs at 100 % - the last value + # of the SoC sweep above - so each one's `pack` claim closes its `lo` + # at 0 W: a full battery is not asked to charge. It is on ITS OWN + # actuator, which is the half a `pack` limit filed on the site would + # fail, and it appears ONLY here: the one-inverter sweep above files + # no `pack` claim at any state of charge, which is AC 4. + ("pack", "inverter-a"), ("pack", "inverter-b"), + ("supervised", "inverter-a"), ("supervised", "inverter-b")]) check("...and naming those three is not an accident of this fixture but the " - "reason the static pin exists - SEVEN of the ten sources need a state " + "reason the static pin exists - SEVEN of the eleven sources need a state " "no check in this file reaches. SAFETY-31's `circulation` is one of the " "seven: it is filed only while the site is MEASURED to be circulating, " "which no fixture here is. SAFETY-30's `sign_site` is another, and it " @@ -3079,16 +3125,21 @@ check("...and so is each unit's `device` bound, which is the other number the " "inverter's reported range bound nothing at all", _dev2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W), "inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)}) +_n2_plans = {p.unit: (p.lo, p.hi) for p in (_n2_alloc.plans if _n2_alloc else ())} +print(f" the envelopes the ARBITER RESOLVED: {_n2_plans}") check("...and the bounds the ARBITER RESOLVED for inverter B - not just the " "claims filed at it - are B's own, because a claim filed correctly and " "then resolved against the wrong envelope would be the same defect one " - "layer down", + "layer down. ⚠️ THE `hi` IS B'S OWN RATING AND THE `lo` IS 0 W, and the " + "second half is OPT-09 rather than a defect: both packs in this fixture " + "sit at 100 % - the last value of the SoC sweep above - so each one's " + "`pack` claim closes its charge end. The two units' envelopes are still " + "DIFFERENT NUMBERS, which is what this check exists to observe: a bound " + "copied from the first unit would read +5000 on B", _n2_alloc is not None - and next((p for p in _n2_alloc.plans if p.unit == "inverter-b"), None) - is not None - and (lambda p: p.lo == -B_RATING_HYPOTHESIS - and p.hi == B_RATING_HYPOTHESIS)( - next(p for p in _n2_alloc.plans if p.unit == "inverter-b"))) + and _n2_plans.get("inverter-b") == (0.0, B_RATING_HYPOTHESIS) + and _n2_plans.get("inverter-a") == (0.0, LIVE_MAX_W) + and _n2_plans["inverter-a"] != _n2_plans["inverter-b"]) # --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER --- diff --git a/goodwe_controller/test_translations.py b/goodwe_controller/test_translations.py index 50bd1c9..72aaf0c 100644 --- a/goodwe_controller/test_translations.py +++ b/goodwe_controller/test_translations.py @@ -74,7 +74,7 @@ check(f"the option surface was actually read - {len(ORDER)} options, not zero", check("...and the repeatable `inverters:` section's per-entry keys were read " "too, which is the half a top-level-only reader would miss", NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"] - and len(NESTED["inverters"]) == 7) + and len(NESTED["inverters"]) == 8) print("\n2. AC: every option and every per-entry key is translated, in BOTH " "languages") @@ -248,10 +248,17 @@ for what, text in BAD: check(f"every shape outside the accepted subset RAISES rather than being " f"skipped. Silently accepted: {_accepted}", not _accepted) check("...and the accepted subset really does round-trip the shipped files - " - "71 options and 7 per-entry keys came back out of each, which is what " - "says the strictness above is not just refusing everything", + # ⚠️ COUNTED, NOT TYPED. UX-02 corrected this sentence's option count + # 70 -> 71 while OPT-09 independently added an eighth per-entry key + # (`capacity_kwh`), and the two edits collided here on the rebase - each + # restating a number the other had moved. The counts now come from the + # same parse the assertion uses, so the sentence cannot drift from it + # again. PROC-01. + f"{len(ORDER)} options and {sum(len(v) for v in NESTED.values())} " + f"per-entry keys came back out of each, which is what says the " + f"strictness above is not just refusing everything", all(len(TR[lg]["configuration"]) == len(ORDER) - and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7 + and len(TR[lg]["configuration"]["inverters"]["fields"]) == 8 for lg in LANGS)) print(" ⚠️ the licence for this hand parser is NOT this file. " "tools/supervisor_shape_probe.py section F loads the same two files with " diff --git a/goodwe_controller/test_validate.py b/goodwe_controller/test_validate.py index d1e2975..8dde80c 100644 --- a/goodwe_controller/test_validate.py +++ b/goodwe_controller/test_validate.py @@ -264,6 +264,53 @@ check("...but the ENTRY is refused all the same, by rule 9 and not by the " "setpoint_entity": " "}]), -5000, 5000), "has no setpoint_entity")) +# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL --------------- +# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE +# RUN, which is the bar every clause of this rule is held to. A capacity that is +# not a usable number is a pack the split would quietly stop preferring while +# the operator can see a value in the box; a capacity on SOME entries is worse, +# because `packshare.weights()` answers "no opinion" for the WHOLE roster in +# that case - so every key the operator filled in does exactly nothing and +# nothing says so. +def _with_cap(*caps): + o = {k: v for k, v in _PER_INVERTER_OK.items()} + o["inverters"] = [dict(e) for e in _PER_INVERTER_OK["inverters"]] + for entry, cap in zip(o["inverters"], caps): + if cap is not None: + entry["capacity_kwh"] = cap + return o + + +check("a roster stating a capacity on EVERY entry is accepted - and the two " + "differ (15.36 / 20.48, FROM DATASHEET for B), because a fixture whose " + "two per-unit numbers are equal cannot show a check reading the wrong " + "unit's", + validate_startup_config(_with_cap(15.36, 20.48), -5000, 5000) == []) +check("...and a roster stating NONE is accepted too, which is what OPTIONAL " + "means: the add-on has no opinion about the split and every unit weighs " + "1.0, exactly as it did before this card", + validate_startup_config(_with_cap(None, None), -5000, 5000) == []) +check("a capacity that is not a usable number of kWh is REFUSED, and the " + "refusal names the entry - zero, negative and a word alike", + all(why(validate_startup_config(_with_cap(15.36, bad), -5000, 5000), + "capacity_kwh") for bad in (0, -3, "twenty", 0.0))) +check("⚠️ AND A PARTIAL ROSTER IS REFUSED - stated on one entry and left empty " + "on the other. It is the clause that costs an operator something, and it " + "is the one worth having: a pack measured in kWh beside a sibling " + "weighing a dimensionless 1.0 is not a comparison, so the key would " + "silently change nothing at all", + why(validate_startup_config(_with_cap(15.36, None), -5000, 5000), + "not PARTIAL") + and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000), + "not PARTIAL")) +check("...and the refusal names WHICH entry is which, because an operator with " + "three inverters told 'one of them is wrong' is sent to the wrong " + "cupboard", + why(validate_startup_config(_with_cap(15.36, None), -5000, 5000), + "inverter-a") + and why(validate_startup_config(_with_cap(15.36, None), -5000, 5000), + "inverter-b")) + # --------------------------------------------------------------------------- # print("3. a meter that can never read") # ⚠️ These predicates belong to p1.validate_meter_options(), which build_source() diff --git a/goodwe_controller/translations/en.yaml b/goodwe_controller/translations/en.yaml index 205e409..512c879 100644 --- a/goodwe_controller/translations/en.yaml +++ b/goodwe_controller/translations/en.yaml @@ -78,6 +78,9 @@ configuration: batt_invert: name: "Battery power sign is reversed (optional)" description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number." + capacity_kwh: + name: "Usable battery capacity (kWh) (optional)" + description: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or its label. It is read for one purpose: dividing the site's target between the inverters by how much ENERGY each pack has left rather than by how many watts it is rated for. The two packs at the reference site are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so those two ways of dividing the work hand it to different batteries, and the energy one is the question worth asking. Optional: leave it empty and the add-on has no opinion, dividing the target evenly exactly as it did before. State it on EVERY inverter or on none of them - a pack measured in kWh next to one that is not is not a comparison, and the add-on refuses to start rather than ignore half of what you filled in." estop_fitted: name: "Hardware · RS485 emergency stop is fitted" description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour." diff --git a/goodwe_controller/translations/nl.yaml b/goodwe_controller/translations/nl.yaml index 108bb96..d3c32cc 100644 --- a/goodwe_controller/translations/nl.yaml +++ b/goodwe_controller/translations/nl.yaml @@ -58,6 +58,9 @@ configuration: batt_invert: name: "Teken van het batterijvermogen omkeren (optioneel)" description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft." + capacity_kwh: + name: "Bruikbare capaciteit van de batterij (kWh) (optioneel)" + description: "De bruikbare capaciteit van DEZE batterij in kilowattuur, van het datablad of het typeplaatje. Ze wordt voor één ding gelezen: het verdelen van de doelwaarde van de installatie over de omvormers volgens hoeveel ENERGIE elke batterij nog heeft, in plaats van volgens het vermogen waarvoor ze gebouwd is. De twee batterijen op de referentiesite zijn 5000 W met 15,36 kWh en 4600 W met 20,48 kWh, dus die twee manieren van verdelen wijzen het werk aan verschillende batterijen toe, en de energieverdeling is de juiste vraag. Optioneel: laat leeg en de add-on heeft geen voorkeur en verdeelt gelijk, precies zoals voordien. Vul ze in bij ELKE omvormer of bij geen enkele - een batterij in kWh naast een batterij zonder waarde is geen vergelijking, en de add-on start liever niet op dan de helft van wat u invulde te negeren." estop_fitted: name: "Hardware · RS485-noodstop is geplaatst" description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."