Compare commits
8
Commits
CFG-04
..
52380c0952
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52380c0952 | ||
|
|
8501e055f2 | ||
|
|
d9388c81b0 | ||
|
|
2d025eebf7 | ||
|
|
ae21b71824 | ||
|
|
18f052a071 | ||
|
|
d5c1577212 | ||
|
|
f000547518 |
@@ -2,6 +2,217 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
**`bms_soc_floor` describes a BATTERY, so at two packs it stops being one number
|
||||
(SAFETY-32).** The peak guard's energy backstop rests on two figures: the EMS
|
||||
floor `peak_soc_floor` (the depth a money-saving discharge may never reach) and
|
||||
`bms_soc_floor`, the percent **that pack's own BMS** cuts out at — register
|
||||
`0x0560`, read as 10 on the reference GW5000S-BP. Rule 8 refuses an EMS floor at
|
||||
or below the cut-out, because such a floor can never bind: the BMS wins and the
|
||||
status page reports a backstop that does not exist. With ONE cut-out for two
|
||||
packs of different models that rule graded the EMS floor against whichever
|
||||
battery happened to be written into the flat key, and a site with cut-outs of
|
||||
10 % and 30 % under `peak_soc_floor: 20` **started** — the guard would then
|
||||
command a forced discharge that pack B's BMS, not the operator's floor, is the
|
||||
thing that stops. Rule 8's own failure mode, reached through rule 8.
|
||||
|
||||
* **`bms_soc_floor` becomes a per-entry roster key, and it is REQUIRED there.**
|
||||
Nothing is inherited: an entry that omits it has no cut-out, does not borrow
|
||||
the flat one, and is refused by name at startup. A per-entry *default* was
|
||||
considered and declined — a default is one number for every pack, which is the
|
||||
defect this card removes; `DECISIONS/SAFETY-32-per-pack-bms-floor.md` records
|
||||
the decision and its migration cost. The flat key is unchanged and is now
|
||||
documented as the single-inverter shape's own cut-out.
|
||||
* **Rule 8 grades EVERY unit and NAMES the one it refuses**, through a new
|
||||
`unit_bms_floors_indexed()` that carries the **roster** index the value came
|
||||
off. CFG-04 had just fixed a rule 5/6 bug of exactly this shape — a caps-list
|
||||
index fed to a function that indexes the roster, so the message named the wrong
|
||||
inverter — and this reader skips nothing, so there is no second list for an
|
||||
index to desynchronise from. Graded on a roster whose offender is the SECOND
|
||||
entry *and* on the same two numbers in the other order.
|
||||
* **The permission is answered PER PACK.** `Controller.pack_floor_soc` — the
|
||||
emptiest measured pack — is untouched and is still what `decide()` grades;
|
||||
what changed is the floor. `peakguard.pack_permissions()` answers one
|
||||
`(name, reason)` per pack against that pack's own floor, and the floor-limited
|
||||
notification now names the pack that stopped the site rather than "the
|
||||
battery". At a roster of one the alarm is byte-for-byte what SAFETY-25 shipped.
|
||||
* **An unstated cut-out withholds rather than defaults.** `None`, not `0.0` —
|
||||
`discharge_permitted()` already treats an unknown floor as no permission, so
|
||||
the absence costs a shave and never a pack, including on `sim/inject.py`'s path
|
||||
which does not run the startup gate.
|
||||
* ⚠️ **Stated rather than hidden:** under any config rule 8 accepts, the per-pack
|
||||
*floor* reduces to `peak_soc_floor` for every pack — rule 8 makes it so. The
|
||||
reduction is written for `unit_caps_indexed()`'s reason ("unreachable" is a
|
||||
claim about rule ordering, not a property of a function) and is graded on both
|
||||
sides: 1775 rule-8-valid `(peak_soc_floor, bms_soc_floor)` pairs answer the
|
||||
option, and the pair rule 8 refuses answers the cut-out. `peak_soc_floor`
|
||||
itself is still one number for the site, so the "deeper pack needlessly
|
||||
withheld" half is filed as an open question, not closed.
|
||||
* **`tools/supervisor_shape_probe.py` re-run and repaired.** MUTATION.md habit 8:
|
||||
a committed transcript is a check and it decays. The required key broke §C and
|
||||
left §D printing PASS on a refusal about a *different* key. §D is now an
|
||||
isolated counterfactual over an accepted, merged baseline, one key varied per
|
||||
case, asserting the key **name** in the refusal — `all cases passed` on
|
||||
`amd64-hassio-supervisor:2026.08.0`.
|
||||
* ⚠️ **SECOND FIX ROUND — the round-2 fix carried the defect it was written to
|
||||
close, in its prose.** Two more rewrites survived a green 19-file suite on
|
||||
`8501e05`: `binding_soc_floor()`'s `max(floors)` → `max(floors[-2:])` (+5
|
||||
bytes) and the call site's `self.packs` → `self.packs[:2]` (+4 bytes). Both are
|
||||
checks whose TEXT claimed more than their FIXTURE could reach — 12c2's only
|
||||
counterfactual varied the **last** pack, which every window ending at the
|
||||
roster's end follows, and all three 12g runs drove a **two**-pack roster, where
|
||||
`[:2]` *is* the roster. **The general law, now stated in the file: a fixture of
|
||||
N packs cannot separate two reductions that agree on N packs**, and the regress
|
||||
does not terminate in a fixture — `[:1]` needed two packs, `[:2]` three, `[:3]`
|
||||
four. So the reduction is now graded by a **length sweep** (78 simulated
|
||||
rosters, lengths 1–12, deepest cut-out at every position, closing head and tail
|
||||
windows of width 1–11 at once) plus a **computed family table** of fourteen
|
||||
rewrites that goes red naming any member no roster kills; the call site is
|
||||
graded by **three-pack runs** with the deep cut-out at index 0, 1 and 2, plus
|
||||
an **exact structural pin** (the roster argument must unparse to `self.packs`)
|
||||
for the slice widths no bounded roster can reach. Round 3's own battery found
|
||||
`self.packs[:3]` / `[-3:]` surviving all six behavioural runs — the structural
|
||||
pin is what closes them — and one row is recorded **NOT CAUGHT**: a reduction
|
||||
over a window twelve packs wide, which needs a 13-pack roster to observe.
|
||||
No shipped behaviour changed in this round; only what observes it.
|
||||
* **Twelve source mutations, eleven red, one argued survivor**, through
|
||||
`tools/mutation_run.sh` (bytecode purged). The battery also found a real
|
||||
weakness of its own: `_flat_bms_floor()` had the default in two places and each
|
||||
covered for the other, so deleting either survived — masking, in three lines of
|
||||
option reading. One defaulting line now.
|
||||
* ⚠️ **FIX ROUND — that battery was INCOMPLETE, and the review found what it
|
||||
missed.** Neither of the two mutations below appears in the twelve rows above:
|
||||
the ledger graded "the reduction dropped" and "the bare option handed over"
|
||||
and never asked what happens when the reduction is simply the *wrong* one, or
|
||||
when the roster handed to it is *sliced*. No shipped behaviour changed here;
|
||||
two *checks* whose data could not reach the failure they named did. `max(floors)` → `min(floors)` in
|
||||
`binding_soc_floor()` (zero byte delta) and `self.packs` → `self.packs[:1]` at
|
||||
the `decide()` call site (+4 bytes) were each green across all 19 files. Both
|
||||
survived for the same reason: **under any rule-8-valid config every pack's
|
||||
`pack_floor_pct()` equals `peak_soc_floor`**, so `floors` is a list of
|
||||
identical numbers and `max`, `min`, first and last are four spellings of one
|
||||
answer — and the one check using a cut-out above the EMS floor ran on a roster
|
||||
of ONE, where every reduction agrees. The second also walked through part 12g,
|
||||
which asserted `"self.packs" in <the unparsed call site>`: a slice still
|
||||
contains that substring, so the pin passed on the mutation that grades the
|
||||
whole site against pack A. MUTATION.md habit 4 — a source-text test is a check
|
||||
on how code is spelled, not on what it does.
|
||||
* **New part 12c2** evaluates `binding_soc_floor()` on a roster whose per-pack
|
||||
floors **all differ** — four simulated cut-outs of 30/25/40/35 %, every one
|
||||
above `peak_soc_floor`, chosen so that `max` (40), `min` (25), first (30),
|
||||
last (35) and the mean (32.5) are five different numbers and the two extremes
|
||||
sit at *interior* roster positions. Two packs cannot separate those four;
|
||||
four is the smallest roster that can. The precondition is its own check
|
||||
(habit 1) and one pack — the last, neither deepest nor shallowest — is then
|
||||
raised past the old maximum and the answer follows it (habit 7).
|
||||
* **Part 12g is now behaviour, not source text.** Real `Controller.cycle()`s on
|
||||
a two-pack roster, both packs at the same SoC, with the cut-outs as the only
|
||||
thing varied: move the **non-first** pack's cut-out and the floor reaching
|
||||
`decide()` moves 20 % → 35 %, with the site's verdict flipping from a sized
|
||||
discharge to FLOOR-LIMITED. `self.packs[:1]` answers 20 % both times. Which
|
||||
option key the floor comes from stays pinned on the source where it already
|
||||
was, because that claim genuinely is about the code's shape.
|
||||
* ⚠️ **Two MORE survivors, found by this round's own battery and recorded
|
||||
rather than quietly repaired.** (a) The first cut of part 12g varied only
|
||||
pack B's cut-out, so `self.packs[-1:]` — grade the site against the **last**
|
||||
pack — agreed with the full roster on both runs and stayed green across all
|
||||
19 files (+5 bytes). A two-run fixture pins one slice and not the other.
|
||||
Part 12g now runs the **same pair of cut-outs in both orders**, so the
|
||||
deeper one is on the second pack in one run and on the first in another and
|
||||
the site floor is 35 % in both; no single-position slice passes all three
|
||||
runs. (b) `binding_soc_floor()`'s empty-roster branch rewritten to
|
||||
`return None` (−43 bytes) was also green — a defensive branch nothing in the
|
||||
suite reached. Both directions of it are pinned now, with the note that it
|
||||
cannot permit anything either way because `pack_floor_soc` is `None` at an
|
||||
empty roster.
|
||||
|
||||
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
||||
exist yet. Every second-pack figure — in fixtures, in the probe, in
|
||||
config.yaml's commented example — is **simulated and labelled**; only inverter
|
||||
A's 10 % is read off hardware.
|
||||
|
||||
**At two inverters the sign watchdog stops comparing one unit against the whole
|
||||
site meter, and becomes two checks instead of one (SAFETY-30).**
|
||||
`disagrees(commanded_w, grid_w, ...)` compared the command **this controller put
|
||||
on ONE actuator** against **what the whole site** is exchanging with the network.
|
||||
At a roster of one those are two views of the same thing - §6.2's identity - and
|
||||
that is what makes the check sound and what earned it the best record in the
|
||||
safety table. At two they are not, and it fails **in both directions**: inverter
|
||||
A commanded −1500 W while the meter reads +1500 W is evidence of nothing when
|
||||
inverter B is discharging 3000 W exactly as told, and - worse - a unit genuinely
|
||||
driving the wrong way is **MASKED** by a sibling that covers for it, because the
|
||||
site figure nets. No threshold recovers that; the summation destroys the
|
||||
evidence.
|
||||
|
||||
* **PER UNIT: each inverter's own share against its OWN measured power**
|
||||
(`Inverter.batt`, holding register 0x0518, per-instance since DEV-02).
|
||||
Conclusion: *the actuator I am driving is not doing what I told it* - evidence
|
||||
about one device, so the remedy stays the per-unit `failsafe` SET that
|
||||
`arbiter.CLAIM_SCOPE` has classified `SCOPE_UNIT` since it was written. That
|
||||
entry read *"the evidence is site-wide and the remedy is not"*; after this card
|
||||
the first half is simply true. Verified by making one unit reverse while its
|
||||
sibling covers it at the meter: the shipped comparison is false on **0 of 14**
|
||||
cycles while the new one convicts that unit on every one, and only that unit is
|
||||
parked.
|
||||
* **SITE: the SUM of the standing commands against the meter**, as a new
|
||||
`sign_site` SET at `P_SAFETY` on the site actuator, latched under flat
|
||||
`site_sign_tripped` / `site_sign_reason`. This is the fault the 2026-08-20
|
||||
capture contains and **no per-unit check can see it**, because every inverter
|
||||
is faithfully executing its share - what is wrong is the number they were
|
||||
given. It stops every inverter and the banner names the site rather than an
|
||||
inverter, so nobody is sent to swap the wrong box.
|
||||
* **⚠️ Different questions from SAFETY-31, and the PR and the decision record
|
||||
both say which.** `circulation.py` compares the units **against each other**
|
||||
and catches two inverters pushing *through* each other, which nets to zero at
|
||||
the meter and which no single command is evidence about. This compares each
|
||||
unit against **its own command**. They overlap in exactly one scenario - a unit
|
||||
reversing while its sibling pulls the other way - and `test_watchdog.py`
|
||||
asserts on that fixture that both fire and what each concludes. It is also not
|
||||
the saturation freeze: that is a magnitude test (`|command − measured| >
|
||||
500 W`) that shapes the loop and fires on a merely saturated unit; this is a
|
||||
direction test that latches and parks one.
|
||||
* **⚠️ All four thresholds RE-DERIVED at two inverters - one carried with a
|
||||
justification, one refused.** 1890 closed-loop runs in which nothing is faulty,
|
||||
over the whole range `config.yaml` accepts for `gain` and `slew_w`, across load
|
||||
reversals with write staggers and flapping loads, **run at both rosters so the
|
||||
N=1 arm is the control.** `unit_sign_disagree_w: 250` is `saturation_w / 2`, so
|
||||
the smallest divergence this can see is exactly the gap at which the control
|
||||
law itself already says the inverter is not following; `unit_sign_disagree_s:
|
||||
85` is 8.5× the worst legitimate opposition those runs produce (10.0 s), 2.8×
|
||||
the firmware's own 30 s link veto and under half the site hold.
|
||||
`site_sign_disagree_w: 300` is **carried** - its argument is the meter's noise
|
||||
floor and the deadband, and the roster touches neither. **`site_sign_disagree_s`
|
||||
is NOT: 180 s would have shipped a false trip.** The worst legitimate crossing
|
||||
at 300 W is 110 s with one inverter and **215 s with two**, because the command
|
||||
must travel a rail that is the roster sum at a `slew_w` that is not - reachable
|
||||
at `gain: 0.05`, `slew_w: 50` and a 2.2 kW load reversal, all schema-legal.
|
||||
325 s is 1.51× that, inside the 900 s billing quarter and inside every episode
|
||||
of the fault day. See `DECISIONS/SAFETY-30-per-unit-sign.md`.
|
||||
* **⚠️ At ONE inverter this changes nothing, and "nothing" is asserted rather
|
||||
than assumed.** The two questions are the same sentence at a roster of one, so
|
||||
only the detector that shipped is armed: same comparison against the meter,
|
||||
same 300 W / 180 s, same reason string byte for byte, same namespaced latch and
|
||||
flat rollback mirror, same `failsafe` claim, same banner, same single status
|
||||
row - and the site detector's window is never so much as opened.
|
||||
`unit_sign_disagree_*` and `site_sign_disagree_*` do nothing on a
|
||||
one-inverter install.
|
||||
* **The threshold WIRING is pinned, not just the threshold values.** Review
|
||||
found that `arm(self.sign_cfg if one else self.unit_sign_cfg, ...)` could be
|
||||
collapsed to `arm(self.sign_cfg, ...)` — arming every unit with the
|
||||
meter-facing pair instead of the re-derived per-unit one — with all 19 test
|
||||
files still green, because nothing at a roster of two read `sign_wd.cfg` and
|
||||
the trips in the fixture happen under either pair. `test_watchdog.py` part 3
|
||||
now asserts the pair and the instrument on **every** detector against
|
||||
literals, and the fixture's three threshold pairs were made pairwise distinct
|
||||
in both coordinates so that a hold delivered from the wrong pair cannot
|
||||
compare equal to the right one. M14–M16 in
|
||||
`DECISIONS/SAFETY-30-per-unit-sign.md` §5.
|
||||
* **Known and recorded rather than fixed:** the site comparison is defeated by a
|
||||
square-wave load whose half period is about one control cycle, which holds it
|
||||
opposed for ~1000 s at *both* rosters. The N=1 control arm is what says this is
|
||||
a property of comparing a lagged command against a meter and has nothing to do
|
||||
with the second inverter, so it is not acted on here - it wants an alternation
|
||||
test, not a number, and a card of its own.
|
||||
|
||||
**An operator can declare a second inverter, and the shape was measured against
|
||||
the Supervisor rather than assumed (CFG-04).** DEV-02 made rating, SoC,
|
||||
watchdogs, heartbeat and store keys per-instance, so the code no longer assumed
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
# SAFETY-30 — what the sign watchdog compares once there is more than one inverter, and the numbers it compares with
|
||||
|
||||
**Status: DECIDED 2026-09-04.** The one comparison becomes **two**, asked of the
|
||||
evidence that can answer each: **each unit's standing command against that
|
||||
unit's own measured power** (register 0x0518, per-instance since [[DEV-02]]),
|
||||
and **the sum of the standing commands against the meter**. At a roster of one
|
||||
nothing changes at all — the two questions are the same sentence there, and the
|
||||
detector that ships is the only one armed.
|
||||
|
||||
This document records the argument for that split, the campaign the four numbers
|
||||
were read off, **a number that was carried and a number that refused to be**, and
|
||||
the mutation ledger. Filed with SAFETY-30, card `6a9a1828e844f22fabf7fe9c`.
|
||||
|
||||
---
|
||||
|
||||
## 0. The half that was already built, and the half this card is
|
||||
|
||||
[[DEV-02]] shipped the **per-instance** half and nothing here re-tests it: each
|
||||
`Inverter` owns its own `SignWatchdog`, the latch is namespaced under
|
||||
`unit/<name>/sign_tripped`, the flat pre-DEV-02 pair is maintained as a rollback
|
||||
view, and a trip on B no longer latches A. `test_inverter.py` part 3 grades that.
|
||||
|
||||
What was left is **the comparison itself**, and it was one line:
|
||||
|
||||
```python
|
||||
inv.sign_wd.evaluate(stood, self.grid, self.following, now) # every unit
|
||||
```
|
||||
|
||||
`stood` is the command **this controller put on THIS actuator**. `self.grid` is
|
||||
what the **whole site** is exchanging with the network. At one inverter those are
|
||||
two views of one thing — §6.2's identity — which is what makes the check sound
|
||||
and what earned its record. At two they are not, and it fails in **both**
|
||||
directions:
|
||||
|
||||
| | what happens | why no threshold fixes it |
|
||||
|---|---|---|
|
||||
| **false trip** | A commanded −1500 W while the meter reads +1500 W, because B is discharging 3000 W exactly as told | the meter is answering for a unit A's command says nothing about |
|
||||
| **missed fault** | B commanded +1500 W and **measured** at −1500 W, beside A at +1500 W — the meter sits where a healthy site leaves it | the summation **destroys** the evidence |
|
||||
|
||||
NOTES.md calls SAFETY-02 the highest-value check in the safety table: 93 %
|
||||
agreement while healthy against 4 % during runaway, four observed episodes, zero
|
||||
false positives. **That record was earned at N=1 on the site comparison.** The
|
||||
first row above spends it; the second row is the guard being absent when it
|
||||
matters.
|
||||
|
||||
## 1. The two questions, and the two remedies
|
||||
|
||||
> **PER UNIT** — this unit's standing command against **its own** measured power.
|
||||
> Conclusion: *the actuator I am driving is not doing what I told it.* Evidence
|
||||
> about one device, so the remedy is that device: the `failsafe` SET, which
|
||||
> `arbiter.CLAIM_SCOPE` has classified `SCOPE_UNIT` since it was written.
|
||||
>
|
||||
> **SITE** — the **sum** of the standing commands against the meter. Conclusion:
|
||||
> *the site as a whole is driving the wrong way* — an inverted gain, an
|
||||
> integrator wound past the rail, a strategy on the wrong side of the meter.
|
||||
> That is the 2026-08-20 fault day, it is a property of the **connection**, and
|
||||
> the remedy is a new SITE SET, `sign_site`, that stops every inverter.
|
||||
|
||||
### Why `failsafe` stays per-unit and `sign_site` is new
|
||||
|
||||
`CLAIM_SCOPE`'s entry for `failsafe` used to read *"the evidence is site-wide and
|
||||
the remedy is not"* — a site measurement attributed to one actuator on the
|
||||
strength of the N=1 identity. After this card the first half of that sentence is
|
||||
simply true: the evidence **is** per-unit. The site-wide half did not disappear;
|
||||
it moved to a claim that is site-scoped for the reason `circulation` is, one
|
||||
entry above it: **the offending number is the site target, which no single
|
||||
inverter owns.** Parking one unit on it would leave the rest of the roster
|
||||
pushing the same way with the guard satisfied.
|
||||
|
||||
### At N=1, exactly one detector is armed
|
||||
|
||||
`Controller._arm_sign_watchdogs()` is the only place the roster decides anything
|
||||
here, and it is a pure function of `len(self.inverters)`:
|
||||
|
||||
* **one inverter** — the unit detector is armed against **the meter**, at the
|
||||
shipped `sign_disagree_w` / `sign_disagree_s`. The site detector is
|
||||
**constructed and never evaluated**. Same comparison, same thresholds, same
|
||||
reason string byte for byte, same namespaced keys and flat mirror, same
|
||||
`failsafe` claim, same banner, same single status row. `test_watchdog.py`
|
||||
part 7 asserts each of those, including that the site detector's window was
|
||||
never so much as opened.
|
||||
* **two or more** — each unit against its own 0x0518 power under the per-unit
|
||||
pair, and the site detector against the meter under the site pair.
|
||||
|
||||
Arming both at N=1 would be two latches and two banners for one fault, and a
|
||||
false-positive record measured on **one** comparison spent twice.
|
||||
|
||||
## 2. What this is NOT — the two neighbours, named
|
||||
|
||||
Two guards that overlap without saying so is how a false-alarm generator gets
|
||||
built, so both are written down.
|
||||
|
||||
### It is not [[SAFETY-31]]
|
||||
|
||||
| | **SAFETY-31 — `circulation.py`** | **SAFETY-30 — the per-unit half** |
|
||||
|---|---|---|
|
||||
| compares | the units **against each other**: `Σ\|Bᵢ\| − \|ΣBᵢ\|` over measured power | each unit against **its own command** |
|
||||
| catches | two inverters pushing **through** each other; nets to zero at the meter | a unit **not doing what it was told**; that unit is faulty or wedged |
|
||||
| attributes | nothing — the quantity is defined over the roster and is identically zero for one unit | **the unit**, by name, and parks it |
|
||||
| scope | SITE claim, flat latch | UNIT claim, namespaced latch |
|
||||
|
||||
They genuinely overlap in **one** scenario — a unit reversing while its sibling
|
||||
pulls the other way — and `test_watchdog.py` part 4 asserts exactly that, on the
|
||||
same fixture, with both readings printed: circulating power is over its threshold
|
||||
**and** SAFETY-30 names B. Neither is the other with a different threshold. Take
|
||||
two units faithfully executing an allocation with two signs: circulating power is
|
||||
the whole loop and every per-unit sign check reads perfect agreement. Take one
|
||||
unit doing the opposite of its share while its sibling covers: this reads the
|
||||
whole disagreement and circulating power can be zero.
|
||||
|
||||
### It is not the saturation freeze
|
||||
|
||||
`compute()`'s anti-windup test is `|prev_w − actual_w| > saturation_w` (500 W). It
|
||||
concludes *the inverter cannot follow*, it fires on a merely **saturated** unit as
|
||||
readily as on a reversed one, it **stops the integrator winding** — an ordinary
|
||||
control action taken every day — and it neither latches nor parks anything.
|
||||
|
||||
This asks for **opposition**: a sign, on both sides of a threshold, held
|
||||
continuously. A saturated-but-obedient unit cannot produce it. And the two meet
|
||||
at a number rather than overlapping in a band nobody drew — see §3.
|
||||
|
||||
## 3. The numbers, and where they come from
|
||||
|
||||
Read off `admin/goodwe`'s `sim/` two-master rig (TEST-19), used **read-only** and
|
||||
run container-free. Real and imported, never transcribed: the control law
|
||||
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
|
||||
`allocator.plan`), the sign predicate (`watchdog.disagrees`), the wire
|
||||
(`frame_check.setpoint_atomic`, the firmware's own encoder), the plant
|
||||
(`plantsim.Plant`), and the per-unit reading, taken from **holding register
|
||||
0x0518** — the register the live `batt_entity` binds.
|
||||
|
||||
**1890 closed-loop runs in which NOTHING is faulty** — every unit executes the
|
||||
share the allocator gave it — over **6 gains × 5 slews**, the whole range
|
||||
`config.yaml` accepts for `gain` `float(0.05,1.0)` and `slew_w` `int(50,5000)`,
|
||||
across both families of stimulus that can produce legitimate opposition:
|
||||
|
||||
* **1050 load reversals** — 7 sizes (±300 W … ±4000 W) × 5 write staggers, the
|
||||
serialised write path landing unit B's number up to four cycles after A's;
|
||||
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes.
|
||||
|
||||
Every run is executed **twice**: once at a roster of two and once at a roster of
|
||||
one on the same stimulus. **The N=1 arm is the control**, and it is the reason
|
||||
this document can separate *"a property of the check that shipped"* from *"a
|
||||
property of the second inverter"* instead of asserting one and hoping.
|
||||
|
||||
Supervised caps are the shipped `max_w` (2000 W per unit), so the site rail is
|
||||
2000 W at N=1 and 4000 W at N=2 — `Controller.site_max_w`, the roster sum.
|
||||
|
||||
### Reproducing it
|
||||
|
||||
The harness is **not committed here**, for the reason SAFETY-31's was not: it
|
||||
imports `plantsim` and `frame_check`, which live in the other repository and are
|
||||
not present in CI. What it does is small enough to restate: build two
|
||||
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`, close the loop with the
|
||||
add-on's own `compute()` and `arbiter.allocate_record()`, put each share on its
|
||||
own wire through `Inverter.command()`, advance `Site.settle()` one control cycle,
|
||||
and record `(standing command, inv.reg(0x0518))` per unit and
|
||||
`(Σ standing, site.grid)` for the site. Then, for each candidate threshold, the
|
||||
**longest run of consecutive cycles** on which the shipped `disagrees()` is true —
|
||||
not a count of true samples, because the watchdog's window is cleared by any
|
||||
disagreeing sample. Point it at a checkout with `GOODWE_ADDON_REPO`.
|
||||
⚠️ MUTATION.md's note applies: those harnesses write `__pycache__` into **both**
|
||||
trees.
|
||||
|
||||
### A. PER-UNIT — a unit's standing command vs its own 0x0518 power, N=2
|
||||
|
||||
| threshold | worst continuous, reversals | worst continuous, flapping | runs crossing (of 1890) |
|
||||
|---:|---:|---:|---:|
|
||||
| 50 W | 25.0 s | 20.0 s | 962 |
|
||||
| 100 W | 20.0 s | 15.0 s | 511 |
|
||||
| 150 W | 15.0 s | 15.0 s | 379 |
|
||||
| 200 W | 10.0 s | 15.0 s | 258 |
|
||||
| **250 W** | **10.0 s** | **10.0 s** | **194** |
|
||||
| 300 W | 10.0 s | 10.0 s | 124 |
|
||||
| 500 W | 5.0 s | 5.0 s | 37 |
|
||||
| 700 W | 5.0 s | 5.0 s | 16 |
|
||||
| 1000 W | 0.0 s | 0.0 s | 0 |
|
||||
|
||||
Largest opposed magnitude anywhere in the 1890 runs: **875 W**.
|
||||
|
||||
**The curve is flat, and that is the finding that decided the shape of this
|
||||
pair.** Unlike the site comparison — where the meter is driven by the LOAD and a
|
||||
flapping load can hold it opposed for minutes — a unit's own power **chases its
|
||||
own command** through a bounded ramp and a first-order lag, so the opposition can
|
||||
only last as long as the crossing does. Dropping the threshold from 300 W to
|
||||
50 W buys 15 s of extra transient and nothing else. **So the work is done by the
|
||||
window, and the threshold is put where the control law already draws its line.**
|
||||
|
||||
#### `unit_sign_disagree_w: 250` — schema `int(50,5000)`
|
||||
|
||||
`saturation_w / 2`. The check gates on **both** sides being over `min_w`, and the
|
||||
two are **opposed**, so the smallest command-versus-power gap it can ever see is
|
||||
`2 × min_w`. At 250 W that is exactly the 500 W at which `compute()`'s own
|
||||
anti-windup test already concludes the inverter is not following. **This watchdog
|
||||
therefore cannot fire on a divergence the shipped control law itself calls
|
||||
ordinary tracking**, and the two guards meet at a number.
|
||||
|
||||
Rejected: a threshold above the observed 875 W maximum, which would gate nothing
|
||||
legitimate in — and would also be blind to a unit reversing a 900 W share.
|
||||
|
||||
#### `unit_sign_disagree_s: 85` — schema `int(30,1800)`
|
||||
|
||||
Three bounds, and the value clears all three.
|
||||
|
||||
* **lower, from the rig — 10.0 s.** Two control cycles, the worst continuous
|
||||
legitimate opposition above 250 W across all 1890 runs. It is a reversing site
|
||||
whose second write lands a cycle after the first, both units then slewing
|
||||
through zero behind a 10 s lag. Nothing is wrong.
|
||||
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
|
||||
executing its share for less than the firmware's own veto window is a *link*
|
||||
fault the add-on already answers by dropping the unit from the split. Firing
|
||||
inside that window would raise a second, louder alarm about a state already
|
||||
being handled — the SAFETY-15/SAFETY-19 failure exactly.
|
||||
* **upper, from SAFETY-02 itself — 180 s, the site hold.** A unit that is not
|
||||
doing what it was told must not be parked *later* than the site would be
|
||||
parked for the same watts, and this conclusion needs nothing to net out: it is
|
||||
complete on the first cycle it appears.
|
||||
|
||||
**85 s** is seventeen control cycles: **8.5× the worst legitimate episode**,
|
||||
**2.8× the firmware veto**, and **under half** the site hold. Deliberately not
|
||||
60, 75, 90, 120 or 180.
|
||||
|
||||
### B / C. SITE — Σ standing commands vs the meter, at BOTH rosters
|
||||
|
||||
**Load-reversal family** (1050 runs each), worst continuous legitimate opposition:
|
||||
|
||||
| threshold | **N=2** | **N=1 (control)** | ratio |
|
||||
|---:|---:|---:|---:|
|
||||
| 100 W | 335.0 s | 175.0 s | 1.91× |
|
||||
| 200 W | 260.0 s | 120.0 s | 2.17× |
|
||||
| **300 W** | **215.0 s** | **110.0 s** | **1.95×** |
|
||||
| 500 W | 155.0 s | 90.0 s | 1.72× |
|
||||
| 700 W | 130.0 s | 70.0 s | 1.86× |
|
||||
| 1000 W | 85.0 s | 35.0 s | 2.43× |
|
||||
|
||||
**The second inverter roughly DOUBLES the worst legitimate crossing, and at the
|
||||
shipped 300 W threshold it takes it from 110 s — comfortably inside the 180 s
|
||||
hold — to 215 s, which is outside it.**
|
||||
|
||||
The mechanism is not mysterious and is worth stating because it predicts the
|
||||
ratio: the transient the 180 s hold exists to reject is the loop crossing zero
|
||||
after a load reversal, and the command has to travel the whole **site rail** at
|
||||
`slew_w` per cycle. At N≥2 that rail is `Controller.site_max_w`, the roster sum,
|
||||
while `slew_w` is unchanged. The worst case is the corner of the config space
|
||||
where that takes longest — `gain: 0.05`, `slew_w: 50`, both **schema floors** —
|
||||
against a ±2200 W load reversal, which is an oven and a heat pump.
|
||||
|
||||
#### `site_sign_disagree_w: 300` — CARRIED, and here is the justification
|
||||
|
||||
The argument behind 300 W is about **noise**: *"near zero the sign is noise, and
|
||||
near zero is where a healthy loop LIVES"* — the deadband holds the command still
|
||||
while the meter wanders either side of `target_grid_w`. `deadband_w` is 15 W and
|
||||
there is one P1 whatever is behind it. **Nothing in that argument depends on the
|
||||
roster**, and the campaign agrees: the fraction of samples gated in at 300 W is
|
||||
within a few percent at both rosters. Carried, with a measurement behind the
|
||||
carry rather than an assumption.
|
||||
|
||||
#### `site_sign_disagree_s: 325` — NOT carried, and that is the point of the card
|
||||
|
||||
180 s is **inside** the 215 s the two-inverter campaign measures, at a
|
||||
configuration the schema accepts. Carrying it would have shipped a false trip on
|
||||
the check with the best record in the safety table — which is the exact failure
|
||||
this card was opened to prevent, arriving from the other direction.
|
||||
|
||||
**325 s** is 1.51× the measured worst. It is well inside the **900 s** quarter
|
||||
that bills the capacity tariff, and it is inside **every** episode the fault day
|
||||
contains: 2026-08-20's four clusters run 16, 30, 77 and 77 minutes, and its
|
||||
opposed runs reach 1750 s. What it costs is stated rather than hidden — about two
|
||||
and a half extra minutes of wrong-direction **site** power before the stop —
|
||||
and it is bought to remove a false trip a legal configuration produces.
|
||||
Deliberately not 300 or 360.
|
||||
|
||||
**Rejected alternative:** keep 180 s and raise the site threshold to 1000 W,
|
||||
where the worst is 85 s. It gives a better margin and a faster stop, and it is
|
||||
wrong: it would be raising a **noise** gate to reject a **transient**, which is
|
||||
what the window is for, and it would leave the site check blind to a 900 W
|
||||
whole-site reversal at a 4 kW site.
|
||||
|
||||
### A limitation this campaign found, which SAFETY-30 does not introduce and does not fix
|
||||
|
||||
The **flapping-load** family behaves completely differently for the SITE
|
||||
comparison, and the control arm is what makes it interpretable:
|
||||
|
||||
| threshold | **N=2** | **N=1 (control)** |
|
||||
|---:|---:|---:|
|
||||
| 300 W | 980.0 s | 1000.0 s |
|
||||
| 700 W | 975.0 s | 1000.0 s |
|
||||
| 1000 W | 950.0 s | 1000.0 s |
|
||||
|
||||
A square wave whose half period is about one control cycle opposes the command on
|
||||
**every** cycle: the command chases the previous half-cycle's meter while the
|
||||
meter has already reversed. **No window inside the billing quarter survives it and
|
||||
no threshold up to 1000 W does either — at EITHER roster.** The N=1 column is
|
||||
this document's whole reason for existing: this is a property of comparing a
|
||||
lagged command against a meter, and it has nothing to do with the second
|
||||
inverter.
|
||||
|
||||
It is not acted on here, in order of weight: it is **identical at the roster this
|
||||
add-on ships**, so acting on it would be changing a live safety path on evidence
|
||||
that has nothing to do with this card; a ~6 kW square wave at 0.1 Hz sustained
|
||||
for a quarter of an hour is not a house (the captured healthy day's longest
|
||||
opposed run is **30 s**); and the fix is not a number — it wants the detector to
|
||||
notice that the disagreement is **alternating**, which is a different check.
|
||||
**It is a card, and this section is its evidence.**
|
||||
|
||||
### D. The fault arm — because a survey of healthy data is not a derivation
|
||||
|
||||
210 runs with inverter B executing the **negation** of its share throughout,
|
||||
sibling faithful, meter left to net:
|
||||
|
||||
| threshold | longest run the per-unit check detects | runs the SITE check also sees |
|
||||
|---:|---:|---:|
|
||||
| 250 W | 890.0 s | 210 / 210 |
|
||||
| 300 W | 890.0 s | 180 / 210 |
|
||||
| 700 W | 885.0 s | 112 / 210 |
|
||||
| 1000 W | 885.0 s | 60 / 210 |
|
||||
| 1500 W | 880.0 s | 45 / 210 |
|
||||
| 2000 W | 0.0 s | 39 / 210 |
|
||||
|
||||
At the shipped 250 W the fault is detected continuously for the whole run in
|
||||
every one of the 210 — an 85 s hold has ten times the evidence it needs. The
|
||||
right-hand column is the honest half: on a *reversing* stimulus the site
|
||||
comparison often does see something too, because the reversal is large enough to
|
||||
move the meter. **The case it cannot see is the steady one**, and that is the
|
||||
fixture `test_watchdog.py` part 4 runs: the shipped comparison is false on
|
||||
**0 of 14** cycles while the per-unit check convicts B on every one.
|
||||
|
||||
## 4. Inverter B does not exist
|
||||
|
||||
Every two-inverter figure in §3 was produced with **inverter A's ramp and lag
|
||||
copied onto B** (`plantsim.INVERTER_B`, `measured=False`); its 3000 W rating,
|
||||
where this repository's fixtures use one, is a **hypothesis marked as one**.
|
||||
[[SPIKE-04]] measures the real ones and is the revisit trigger for all four
|
||||
numbers — not a reason to move any of them now.
|
||||
|
||||
Which figures depend on it, stated so nobody has to work it out:
|
||||
|
||||
* **`unit_sign_disagree_s: 85`** — most. Its lower bound is the 10.0 s crossing,
|
||||
which is a direct function of B's ramp and lag. A slower second unit lengthens
|
||||
it; the 8.5× is what buys headroom for that.
|
||||
* **`site_sign_disagree_s: 325`** — yes, but less directly. Its 215 s is
|
||||
dominated by the **rail** and by `slew_w`, which are configuration, not
|
||||
hardware; B's dynamics move it at the margin.
|
||||
* **`unit_sign_disagree_w: 250`** — **no.** It is `saturation_w / 2`, read off
|
||||
the add-on's own control law. The rig only confirms that nothing legitimate
|
||||
sits over it for long.
|
||||
* **`site_sign_disagree_w: 300`** — **no.** It is the meter's noise floor and the
|
||||
deadband, neither of which is a property of any inverter.
|
||||
|
||||
## 5. Mutation ledger
|
||||
|
||||
Every run on a **freshly copied tree of its own, no `__pycache__`, mounted
|
||||
`:ro`** into the CI image — the structural immunity MUTATION.md records. ⚠️ The
|
||||
first battery used ONE shared directory rebuilt between mutations and reported a
|
||||
**RED baseline**; a container mounted it mid-copy and read a missing
|
||||
`config.yaml` as a failing check. That whole run was discarded: MUTATION.md's
|
||||
rule is that a run which could have been racing is not a result, and a battery
|
||||
whose baseline is not green establishes nothing. The harness now copies a
|
||||
directory per mutation and **refuses to report anything if the baseline is not
|
||||
green**.
|
||||
|
||||
Byte deltas measured, not estimated; the `+0 B` ones are marked, since a
|
||||
size-preserving edit is what TEST-15 turned into a false survivor once already.
|
||||
|
||||
| # | file | mutation | Δ | result |
|
||||
|---|---|---|---|---|
|
||||
| M1 | `main.py` | the per-unit comparison put back to `self.grid` at every roster | −30 B | **RED** — `test_watchdog.py`. This is the card |
|
||||
| M2 | `main.py` | the per-unit comparison forced to `inv.batt` at N=1 too | −31 B | **RED** — AC 8 |
|
||||
| M3 | `main.py` | the site half never evaluated | −6 B | **RED** — AC 3 |
|
||||
| **M4** | `main.py` | **the site half armed at N=1 as well** | **+0 B** | **SURVIVOR on the first battery — GAP CLOSED**, then RED. See below |
|
||||
| M5 | `main.py` | the `sign_site` claim never filed | −6 B | **RED** — `test_watchdog.py`. ⚠️ `test_peakguard.py` stayed **GREEN**, correctly: 10f's AST scan pins which sources CAN be filed and the append is still written; whether it is REACHED is a dynamic question no fixture there gets to |
|
||||
| M6 | `main.py` | the site latch not persisted to the store | −58 B | **RED** — the flat-keys and restart checks |
|
||||
| M7 | `main.py` | the operator clear leaves the site latch set | −42 B | **RED** — the clear check |
|
||||
| M8 | `watchdog.py` | `arm()` clears `disagree_since` | +35 B | **RED** — "re-armed on every one of 38 cycles still trips" |
|
||||
| M9 | `watchdog.py` | the reason hard-codes "a meter reading" again | +1 B | **RED** — `test_watchdog.py`. `test_inverter.py` stayed **GREEN**; its part-3 row reads the noun off `sign_wd.against` rather than out of the reason string, so the two checks bite in different places |
|
||||
| M10 | `watchdog.py` | `unit_sign_disagree_w` 250 → 300 | **+0 B** | **RED** — the `saturation_w / 2` pin and the CFG-03 pin |
|
||||
| M11 | `watchdog.py` | `site_sign_disagree_s` 325 → 180, i.e. **the number carried** | **+0 B** | **RED** — the measured-215 s check and the CFG-03 pin |
|
||||
| M12 | `arbiter.py` | `sign_site` scoped `SCOPE_UNIT` | **+0 B** | **RED** — `test_arbiter.py` **and** `test_peakguard.py` |
|
||||
| M13 | `validate.py` | the `unit_sign_disagree_w` rule removed | −126 B | **RED** — `test_validate.py` |
|
||||
| **M14** | `main.py` | **`arm()` handed `self.sign_cfg` at EVERY roster** — the WIRING, not the values | −31 B | **SURVIVOR at review — GAP CLOSED**, then RED. See below |
|
||||
| M15 | `main.py` | per-unit `arm()` given the right `min_w` and the SHIPPED hold — a HOLD-ONLY mis-wire | +78 B | **RED** — the same wiring pin |
|
||||
| M16 | `main.py` | per-unit `arm()` given the right `min_w` and the SITE hold — hold-only again, and behaviour-preserving | +83 B | **RED** — the wiring pin, and the ONLY red in `test_watchdog.py` |
|
||||
|
||||
### M4 is recorded as a survivor because it was one
|
||||
|
||||
The check it should have gone red on read:
|
||||
|
||||
```python
|
||||
not _c1s.site_sign_wd.tripped and _c1s.site_sign_wd.disagree_since is None
|
||||
```
|
||||
|
||||
Arming the site detector at a roster of one left that **green**, and the reason
|
||||
is worth reading because the check looked airtight. In the N=1 fixture the unit
|
||||
detector trips first at its 1 s hold and parks the inverter; the site's standing
|
||||
command is then **0 W**, so the site predicate is false, its window is never
|
||||
opened, and `disagree_since is None` is true *whether or not the detector was
|
||||
evaluated at all*. **The assertion ran, the sentence was true, and the data
|
||||
could not reach the failure it named** — MUTATION.md's third failure mode,
|
||||
inside the check written to prevent it.
|
||||
|
||||
Replaced with a **call counter installed on the object** before the run: twelve
|
||||
cycles, zero calls, printed. A counter can only be zero for one reason. The
|
||||
mutation was re-run against it and is red; the run that found it was discarded
|
||||
along with its tree, so this is a result and not an artefact.
|
||||
|
||||
### M14: the VALUES were pinned and the WIRING was not
|
||||
|
||||
M10 and M11 mutate the numbers in `watchdog.SIGN_OPTION_DEFAULTS` and both are
|
||||
red through the config pins. Neither of them touches the line that DELIVERS
|
||||
those numbers to a detector:
|
||||
|
||||
```python
|
||||
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
|
||||
AGAINST_METER if one else AGAINST_OWN_POWER)
|
||||
```
|
||||
|
||||
Drop the conditional — `arm(self.sign_cfg, ...)` — and every unit is armed with
|
||||
SAFETY-02's meter-facing pair instead of the re-derived per-unit one. At review
|
||||
that left **all 19 files green**. `grep -rn "sign_wd.cfg" test_*.py` found
|
||||
exactly one threshold assertion, and it is §7's N=1 arm — `(300.0, 1.0)` —
|
||||
which the mutation PRESERVES. Nothing anywhere read `inv.sign_wd.cfg` at a
|
||||
roster of two.
|
||||
|
||||
The behavioural checks in part 3 cannot close it and it is worth saying why: B
|
||||
is reversed at ~2000 W and every hold in that fixture is short, so B trips under
|
||||
either pair. **A fault big enough and long enough to trip under both thresholds
|
||||
is evidence about neither.**
|
||||
|
||||
What the survivor costs in the field is the whole product of §3: at
|
||||
`config.yaml`'s own defaults the per-unit check would run at `sign_disagree_s`
|
||||
**180 s instead of 85 s** — 2.1× slower to park a reversed inverter — and its
|
||||
gate would move from 250 W to 300 W, so a unit reversing a share between the two
|
||||
becomes invisible, while `SIGN_OPTION_DEFAULTS`, `config.yaml` and §3 all go on
|
||||
describing a pair the running code does not use. With CI green.
|
||||
|
||||
**Closed by a wiring pin in `test_watchdog.py` part 3**, on the real
|
||||
two-inverter Controller: every `inv.sign_wd.cfg` equals the per-unit pair,
|
||||
`site_sign_wd.cfg` equals the site pair, and each detector's `against` is its
|
||||
own instrument — all graded **against literals**, since `== self.unit_sign_cfg`
|
||||
cannot tell *the per-unit pair* from *whatever this Controller was handed*.
|
||||
|
||||
⚠️ **And the fixture had to be changed for that pin to mean anything.**
|
||||
`_sign_site` set `unit_sign_disagree_s: 1` **and** `site_sign_disagree_s: 1`
|
||||
**and** `sign_disagree_s: 1`, with two of the three magnitude gates at 300 W. A
|
||||
bare equality there discriminates on `min_w` alone: a mis-wiring that delivered
|
||||
the right magnitude with the wrong hold would compare EQUAL to the pair it was
|
||||
supposed to have. That is this document's own §5 lesson and MUTATION.md's third
|
||||
failure mode — a check comparing two things that happen to be equal cannot see
|
||||
the difference it names — reappearing inside the fix for it. The three pairs are
|
||||
now pairwise distinct **in both coordinates** (250 W / 1 s, 320 W / 2 s,
|
||||
300 W / 3 s), a PRECONDITION check asserts that distinctness so a later edit
|
||||
collapsing them goes red rather than quietly vacuous, and both pairs are
|
||||
**printed**. M15 and M16 exist to show the hold half actually bites: right
|
||||
magnitude, wrong hold, red both times — and M16, whose wrong hold still trips
|
||||
inside the drive, is red on the wiring pin and on **nothing else** in that file.
|
||||
|
||||
## 6. What this card does NOT close
|
||||
|
||||
* **The flapping-load blind spot in the SITE comparison.** §3, at length. Present
|
||||
identically at the shipped roster of one; wants an alternation test, not a
|
||||
number; a card of its own.
|
||||
* **A unit misbehaving while it is NOT loop-driven.** Gate 1 (`following`) is
|
||||
kept for the per-unit comparison, deliberately: the whole no-false-positive
|
||||
argument is built on it, and the saturation freeze already grades that window.
|
||||
So between the start of a maintenance cycle and its end, a unit reversing
|
||||
against its own command is the freeze's to notice. Widening it is a card with a
|
||||
rig campaign behind it, not an edit.
|
||||
* **A sign inversion downstream of this add-on** — in the firmware, on the wire,
|
||||
or in a future driver. Unchanged: our view stays self-consistent and both
|
||||
comparisons stay silent. That is the witness's problem and it always was.
|
||||
* **A false-alarm interaction with SAFETY-31 that nobody has to think about.**
|
||||
They overlap in one scenario and both fire in it. That is correct — they
|
||||
conclude different things — but an operator will see two alarms for one event.
|
||||
Whether the banner should say so in one sentence is a UI card.
|
||||
@@ -0,0 +1,427 @@
|
||||
# SAFETY-32 — `bms_soc_floor` per pack, and the two judgement calls it left open
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-32`.
|
||||
**Card:** SAFETY-32 — *"One `bms_soc_floor` for two packs grades the shallower
|
||||
pack against the deeper one's cut-out"*, scoped by the card's own PM PROOFREAD
|
||||
to its **third** bullet only. Bullets 1 (aggregate anti-windup) and 2 (per-instance
|
||||
`achievable_w`) were verified as already shipped by DEV-02 and CFG-04 and were
|
||||
**not** touched — `main.py`'s `actual_w=self.site_batt_w` comment is the record
|
||||
of why the first of those must stay site-level, and it still reads as written.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, stated in the terms the code uses
|
||||
|
||||
`bms_soc_floor` **describes a battery**: it is the percent that pack's own BMS
|
||||
cuts out at, register `0x0560` on the reference GW5000S-BP, read as 10. It was a
|
||||
flat option because there was one pack.
|
||||
|
||||
`peak_soc_floor` **describes the site's policy**: the depth a money-saving
|
||||
discharge may never reach. Rule 8 refuses `peak_soc_floor <= bms_soc_floor`,
|
||||
because such a floor can never bind — the BMS wins and the status page reports a
|
||||
backstop that does not exist.
|
||||
|
||||
At two packs of different models the two cut-outs differ, and one number for both
|
||||
is wrong **in the direction that empties a pack**: with cut-outs of 10 % and 30 %
|
||||
and `peak_soc_floor: 20`, the add-on **started**, and the guard would command a
|
||||
forced discharge that pack B's *BMS* — not the operator's floor — is the thing
|
||||
that stops. That is rule 8's own failure mode, reached through rule 8.
|
||||
|
||||
---
|
||||
|
||||
## 2. DECISION 1 — required per entry, not defaulted from `PEAK_OPTION_DEFAULTS`
|
||||
|
||||
The card left this open and asked for the reason to be recorded.
|
||||
|
||||
**Decided: REQUIRED of every roster entry**, in `config.yaml`'s schema
|
||||
(`bms_soc_floor: int(0,50)`, no trailing `?`) and in `validate.py` rule 8, which
|
||||
refuses a missing one **by name**.
|
||||
|
||||
The reason is that a default here is not a convenience, it is **this card's own
|
||||
defect wearing a different hat**. A default is one number applied to every pack,
|
||||
which is exactly what the flat key was. An add-on that answers "10 %" for a pack
|
||||
nobody has told it about has invented a property of somebody's battery, and the
|
||||
failure mode of the invention is the one this card exists to remove — the
|
||||
shallower pack protected by a floor that belongs to the deeper one. CFG-03's
|
||||
rule ("defaults live in exactly one place") points the same way: the one mirror
|
||||
`PEAK_OPTION_DEFAULTS["bms_soc_floor"]` is the default for the **flat** key, i.e.
|
||||
for the single-inverter shape, and reusing it per entry would make one dict the
|
||||
default in two shapes with two meanings.
|
||||
|
||||
It is also the treatment `max_w` already gets, for the same reason and by the
|
||||
same argument (`inverter.py`'s `build_roster()` docstring): the per-unit numbers
|
||||
that describe hardware are required, and the per-unit *sensors* are optional
|
||||
because an absent sensor is a real state.
|
||||
|
||||
**The cost is stated rather than hidden.** This is a schema change: a roster
|
||||
config written before this branch is refused by the Supervisor until each entry
|
||||
gains the key, and every roster fixture in the suite needed it. Nothing in
|
||||
production carries a roster today — `inverters:` ships `[]` and the second
|
||||
inverter is ~8 days out — so the migration is paid by fixtures and by whoever
|
||||
writes the first real roster, at a startup refusal that names the entry.
|
||||
|
||||
**What is NOT inherited.** An entry that omits the key does not borrow the flat
|
||||
one. `Inverter.bms_soc_floor` is then `None`, `peakguard.pack_floor_pct()`
|
||||
answers `None`, and `discharge_permitted()` already treats an unknown floor as
|
||||
**no permission** — so even in the window before rule 8 runs (e.g. `sim/inject.py`,
|
||||
which does not run the startup gate) the absence costs a shave and never a pack.
|
||||
`0.0` was rejected explicitly: it is the most permissive value the key has.
|
||||
|
||||
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
|
||||
|
||||
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
|
||||
**is unchanged**, exactly as the card instructed. What changed is the floor it is
|
||||
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
|
||||
floors, each of them `max(peak_soc_floor, that pack's own cut-out)`, with a
|
||||
`max()`. The two reductions pair: a minimum SoC against a maximum floor is the
|
||||
conservative combination, and both move the verdict towards refusing a shave and
|
||||
away from spending a pack. `min(soc) <= max(floor)` is implied by "some pack is
|
||||
at or below its own floor", so the site verdict is never more permissive than
|
||||
grading each pack separately would be. The converse is not true and is not
|
||||
claimed — a site can be refused while every pack individually has headroom, which
|
||||
costs a shave.
|
||||
|
||||
### 3a. ⚠️ FIX ROUND — the reduction was written and argued, and NOT GRADED
|
||||
|
||||
PR #48 was rejected because the argument in §4 below justified *writing* the
|
||||
reduction and was then not carried into a check. Neither of the two mutations
|
||||
below appears anywhere in §6's twelve-row battery: it graded *"the reduction
|
||||
dropped"* and *"the bare option handed over"*, and never asked what happens when
|
||||
the reduction is the **wrong one**, or when the roster handed to it is
|
||||
**sliced**. Both were applied to `app/` by the reviewer and **all 19 files
|
||||
stayed green**:
|
||||
|
||||
| mutation | Δ | why it survived |
|
||||
|---|---|---|
|
||||
| `binding_soc_floor()`: `max(floors)` → `min(floors)` | **0 bytes** | every roster in the suite was rule-8-valid or one pack long, so `floors` held **identical numbers** and `max`/`min`/first/last were four spellings of one answer |
|
||||
| the `decide()` call site: `self.packs` → `self.packs[:1]` | **+4 bytes** | part 12g asserted `"self.packs" in <the unparsed expression>` — and `self.packs[:1]` **contains that substring** |
|
||||
|
||||
The zero-byte one was proved to have actually run rather than assumed:
|
||||
`dis.get_instructions` showed the `LOAD_GLOBAL`s as `[float, pack_floor_pct,
|
||||
any, min]`, and `binding_soc_floor([Pack("a",50,10), Pack("b",50,35)], 20.0)`
|
||||
returned `20.0` where the shipped code returns `35.0`.
|
||||
|
||||
**The common cause is this repository's recurring defect** — a check whose DATA
|
||||
cannot reach the failure it names. The one check that used a cut-out **above**
|
||||
the EMS floor (12c's `_n1_deep`) ran on a roster of **ONE**, where every possible
|
||||
reduction agrees. So no check in the suite ever evaluated
|
||||
`binding_soc_floor()` on two packs whose floors **differ**.
|
||||
|
||||
**And the rule-8 degeneracy is not a defence, by §4's own words.** If
|
||||
"unreachable is a claim about today's rule ordering, not a property of this
|
||||
function, and `validate_startup_config()` is also `sim/inject.py`'s entry point"
|
||||
is enough to *write* the reduction, it is enough to *grade* it. Through
|
||||
`sim/inject.py`, which does not run rule 8 at all, `min(floors)` lets the guard
|
||||
command a forced discharge to 20 % on a pack whose BMS cuts out at 35 %.
|
||||
|
||||
**What was added — no shipped behaviour changed, only what observes it:**
|
||||
|
||||
* **`test_peakguard.py` part 12c2** evaluates `binding_soc_floor()` on a roster
|
||||
whose per-pack floors **all differ**. Four **simulated** cut-outs —
|
||||
`30, 25, 40, 35` %, every one strictly above `peak_soc_floor: 20`, so
|
||||
`pack_floor_pct()` selects the cut-out for each and `floors` *is* that tuple in
|
||||
that order. The four candidate reductions are then four different numbers:
|
||||
|
||||
max = 40 <- the shipped answer
|
||||
min = 25
|
||||
first = 30 <- "grade the site against pack A", inside the function
|
||||
last = 35
|
||||
mean = 32.5
|
||||
|
||||
The extremes sit at **interior** roster positions (index 2 is the max, index 1
|
||||
the min). A two-pack roster structurally cannot separate these — there `max`
|
||||
and `last` coincide and so do `min` and `first` — and neither can three. Four
|
||||
is the smallest roster that separates all four. The precondition is its own
|
||||
check (habit 1), one pack is then raised past the old maximum (habit 7: the
|
||||
**last** pack, which was neither extreme, so the line can only move if the
|
||||
reduction read it), and `peak_soc_floor` is lifted above every cut-out to grade
|
||||
the other side of the conjunction.
|
||||
|
||||
* **Part 12g is behaviour now, not source text** (MUTATION.md habit 4). Real
|
||||
`Controller.cycle()`s on a two-pack roster, both packs held at the same SoC and
|
||||
everything else identical, with the packs' own cut-outs as the only thing
|
||||
varied:
|
||||
|
||||
cut-outs A 10 / B 18 % -> decide() got soc_floor=20 %, floor_limited=False
|
||||
cut-outs A 10 / B 35 % -> decide() got soc_floor=35 %, floor_limited=True
|
||||
|
||||
The observable is `PeakDecision.soc_floor` — the argument, echoed back by the
|
||||
shipped `decide()` — so the whole path from `Controller.inverters` to the
|
||||
guard's argument is under test, and the site's **verdict flips** as well as the
|
||||
number. `self.packs[:1]` answers 20 % both times. **Which** option key the floor
|
||||
comes from stays pinned on the source in part 4, because that claim genuinely
|
||||
is about the code's shape; that the option still governs is now graded
|
||||
behaviourally too.
|
||||
|
||||
* ⚠️ **A THIRD SURVIVOR, FOUND WHILE FIXING THE FIRST TWO, AND RECORDED RATHER
|
||||
THAN QUIETLY REPAIRED.** The first cut of part 12g varied only **pack B's**
|
||||
cut-out — so `self.packs[-1:]`, grade the site against the **last** pack,
|
||||
agreed with the full roster on both runs and stayed green across all 19 files
|
||||
(+5 bytes). A two-run fixture that only ever moves the second pack pins `[:1]`
|
||||
and not `[-1:]`; it is the same defect one step along, and it was in the fix
|
||||
for this defect. Part 12g now runs the **same pair of cut-outs in both
|
||||
orders**:
|
||||
|
||||
cut-outs A 10 / B 18 % -> soc_floor=20 %, floor_limited=False
|
||||
cut-outs A 10 / B 35 % -> soc_floor=35 %, floor_limited=True
|
||||
...the SAME PAIR SWAPPED, A 35 / B 18 % -> soc_floor=35 %, floor_limited=True
|
||||
|
||||
`[:1]` answers 20 % on the second run, `[-1:]` answers 20 % on the third, and
|
||||
the roster answers 35 % on both. No single-position slice passes all three.
|
||||
This is the mirror `12h` already ran on the SoC axis, which is where the shape
|
||||
was borrowed from.
|
||||
|
||||
* ⚠️ **AND A FOURTH, from the same battery: the EMPTY-roster branch.** Rewriting
|
||||
`binding_soc_floor([])`'s `float(soc_floor)` to `return None` (−43 bytes) was
|
||||
green across all 19 files. It is defensive code — `Controller.inverters` is
|
||||
never empty on a started controller, and `inputs_ok` refuses the cycle before
|
||||
the guard is reached if it were — so nothing in the suite went near it. It is
|
||||
**pinned rather than argued**, in both directions (the option when there is
|
||||
one, `None` when there is not), because a branch with no check is a branch
|
||||
that can be rewritten under a green suite. The check also records why it
|
||||
cannot permit anything either way: `pack_floor_soc` is `None` at an empty
|
||||
roster, and `discharge_permitted()` refuses on a `None` SoC whatever the floor
|
||||
is.
|
||||
|
||||
* **`wired_over_limit()` gained a `prepare(ctl)` hook**, inside its retry loop so
|
||||
every attempt grades the same site, which is the only window in which a second
|
||||
inverter can join the roster before the telegrams are fed.
|
||||
|
||||
* **In-process mutation rows 22, 22b and 23** record all four survivors, so a
|
||||
later edit that re-degenerates any of these fixtures goes red on a row rather
|
||||
than on nothing.
|
||||
|
||||
### 3b. ⚠️ SECOND FIX ROUND — the fix carried the defect it was written to close
|
||||
|
||||
Round 2 closed both named survivors, and both of round 3's blockers are the
|
||||
**same defect sitting inside that fix**: a check whose TEXT claims more than its
|
||||
FIXTURE can observe. Applied to `app/` on `8501e05`, **all 19 files stayed
|
||||
green** for both:
|
||||
|
||||
| mutation | Δ | the sentence that was too broad | why the fixture could not see it |
|
||||
|---|---|---|---|
|
||||
| `binding_soc_floor()`: `max(floors)` → `max(floors[-2:])` | **+5 bytes** | *"So the reduction reads **every entry**"* | 12c2's only counterfactual raised the **last** pack (`_deeper[-1]`), and **every window that ends at the roster's end follows the last pack**. On `(30,25,40,35)` both answer 40; on `_deeper` both answer 45. |
|
||||
| the `decide()` call site: `self.packs` → `self.packs[:2]` | **+4 bytes** | *"what is reduced is the **ROSTER** and not a POSITION in it"* | all three `_floor_reaching_decide` runs drove a **two-pack** roster, where `self.packs[:2]` **is** `self.packs` — by exactly the argument 12c2 itself makes about two packs being unable to separate max/min/first/last. |
|
||||
|
||||
Round 2's *report* stated the narrow, true claim ("no **single-position** slice
|
||||
passes all three runs"). The *check in the file* stated the broad one. That gap
|
||||
is the whole of round 3.
|
||||
|
||||
**THE GENERAL LAW, stated once so the next card can use it:**
|
||||
|
||||
> **A fixture of N packs cannot separate two reductions that agree on N packs.**
|
||||
> Two cannot separate max from min from first from last. Three cannot separate
|
||||
> max from max-of-the-last-two. A single counterfactual that varies one position
|
||||
> cannot separate the reduction from any window containing that position.
|
||||
|
||||
Its corollary is the reason this section is long: **the regress does not
|
||||
terminate in a fixture.** Closing `[:1]` took two packs; `[:2]` took three;
|
||||
`[:3]` takes four — and a four-pack run hands `[:4]` the same free pass. Round 3
|
||||
therefore does two different things rather than adding one more pack.
|
||||
|
||||
**What was added — no shipped behaviour changed, only what observes it:**
|
||||
|
||||
* **Part 12c2 gained a ROSTER SET and a computed FAMILY TABLE.** The same four
|
||||
simulated cut-outs with the **deepest moved to each of the four positions**,
|
||||
and fourteen enumerated rewrites of `max(floors)` each checked against every
|
||||
roster. The table is **computed and printed**, and a member with an empty kill
|
||||
list takes the check **red naming it** — so a family member can no longer be
|
||||
added without a fixture that reaches it. `max(floors[-2:])` is killed only by
|
||||
the rosters whose deepest sits at index 0 or 1, which is precisely the data the
|
||||
single last-pack counterfactual could not supply.
|
||||
|
||||
* **Part 12c2 gained a LENGTH SWEEP.** `binding_soc_floor()` is a pure function
|
||||
over a list, which is the one place a sweep is cheap: **78 simulated rosters,
|
||||
lengths 1 through 12, the deepest cut-out at every position of each.** That
|
||||
closes *every* head window `max(floors[:w])` and *every* tail window
|
||||
`max(floors[-w:])` of width **1 to 11** in one property rather than one
|
||||
mutation at a time — and the check asserts that width **12 is NOT reached**, so
|
||||
the limit is a red line rather than a silence.
|
||||
|
||||
* **Part 12g drives THREE-pack rosters**, with the deep cut-out at index 0, 1
|
||||
**and** 2. Every positional slice of a three-pack roster is a proper subset of
|
||||
`{0,1,2}` and so drops a position; the run whose deep pack sits there answers
|
||||
the option, 20 %, where the roster answers 35 %. `self.packs[:2]` goes red on
|
||||
the index-2 run. `_floor_reaching_decide()` now takes `*floors`, and the extra
|
||||
units join through the same `second_inverter()` hook the two-pack runs use.
|
||||
|
||||
* **Part 12g gained a POSITIONAL-SLICE TABLE**, computed against the **real**
|
||||
`Controller.packs` each of the six cycles actually ran and the **real**
|
||||
`PeakDecision.soc_floor` it produced — so it measures the shipped call site's
|
||||
own answer, not a re-derivation, and a survivor takes the check red.
|
||||
|
||||
* ⚠️ **AND A FIFTH AND SIXTH SURVIVOR, FOUND BY ROUND 3'S OWN BATTERY AND
|
||||
CLOSED STRUCTURALLY RATHER THAN BY ONE MORE PACK.** `self.packs[:3]` (+4) and
|
||||
`self.packs[-3:]` (+5) survive **all six** behavioural runs, because the
|
||||
longest roster those runs drive is three packs long. Part 12g therefore also
|
||||
pins the roster **argument's identity** on the AST: it must unparse to exactly
|
||||
`self.packs`, which is false of every slice at every length. ⚠️ This is **not**
|
||||
the substring test round 2 removed — `"self.packs" in expr` is *true* of every
|
||||
slice; `ast.unparse(arg) == "self.packs"` is *false* of all of them. The two
|
||||
halves are separated and each says what it reaches: the **runs** grade the
|
||||
VALUE and reach every slice up to N=3; the **pin** grades the ARGUMENT'S
|
||||
IDENTITY at any N and is blind to two things the runs are not — a rewrite of
|
||||
what `Controller.packs` *returns* (the runs' `len(ctl.packs) == 3` catches
|
||||
that) and the reduction *inside* `binding_soc_floor()` (12c2's).
|
||||
|
||||
* ⚠️ **AND ONE ROW IS RECORDED AS NOT CAUGHT.** A reduction over a window
|
||||
**twelve packs wide** is indistinguishable from `max(floors)` on every roster
|
||||
in the file, because the sweep stops at twelve. Unlike the call site there is
|
||||
no identity claim to pin — the claim is semantic, "the maximum", and a shape
|
||||
pin would refuse honest refactors of the expression. It is reported **with its
|
||||
size** (it needs a 13-pack roster, on a site whose roster is ONE and whose
|
||||
second unit is ~8 days out) rather than hidden.
|
||||
|
||||
* **Two rewrites are EQUIVALENT, not uncaught.** `max(set(floors))` and
|
||||
`list(reversed(self.packs))` cannot change any answer — `max` is order- and
|
||||
duplicate-invariant. The second is nonetheless red on the structural pin.
|
||||
|
||||
* **In-process mutation rows 24 – 27** record all four of round 3's findings,
|
||||
including the not-caught one, so a later edit that re-degenerates any of these
|
||||
fixtures goes red on a row rather than on nothing.
|
||||
|
||||
## 4. ⚠️ THE HONEST LIMITATION, and it is the reason this record exists
|
||||
|
||||
**Under any configuration rule 8 accepts, `binding_soc_floor()` answers
|
||||
`peak_soc_floor` for every pack.** Rule 8 refuses `peak_soc_floor <= bms_soc_floor`
|
||||
on *every* unit, so `max(peak_soc_floor, bms_i) == peak_soc_floor` everywhere the
|
||||
add-on will actually start. The per-pack **floor** is therefore non-degenerate
|
||||
only against a config rule 8 refuses.
|
||||
|
||||
This is not a defect and it is not hidden. It is the same argument
|
||||
`validate.unit_caps_indexed()` makes about itself, in that function's own words:
|
||||
|
||||
> *"Rule 9(d) already refuses such a config so the two lists cannot desynchronise
|
||||
> in shipped use — but 'unreachable' is a claim about today's rule ordering, not
|
||||
> a property of this function, and `validate_startup_config()` is also
|
||||
> `sim/inject.py`'s entry point."*
|
||||
|
||||
CFG-04's index desynchronisation is what happened the last time that reasoning
|
||||
was left implicit. So the reduction is written, and it is graded on both sides:
|
||||
`test_peakguard.py` part 12c sweeps **1775** rule-8-valid `(peak_soc_floor,
|
||||
bms_soc_floor)` pairs and asserts the answer is the option in every one of them
|
||||
(that is AC 5, measured over the whole option space rather than at the shipped
|
||||
point), and then asserts that on the pair rule 8 *refuses* the answer is the
|
||||
**cut-out** — because without that second line the sweep would be satisfied by
|
||||
`soc_floor` typed straight through, and the mutation that deletes the `max()`
|
||||
could never bite. It does bite: M2 on the PR.
|
||||
|
||||
**What IS live at a rule-8-valid roster** is the rest of the card: rule 8 now
|
||||
refuses the two-model config that used to start; the permission is answered per
|
||||
pack, so the alarm names the pack that stopped the site rather than "the
|
||||
battery"; and an entry with no stated cut-out is withheld instead of defaulted.
|
||||
|
||||
## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card
|
||||
|
||||
`peak_soc_floor` is still **one number for the site**, so the card's own
|
||||
"or the deeper pack is needlessly withheld" half is not closed and this record
|
||||
does not pretend it is. With cut-outs of 10 % and 18 %, rule 8 obliges the
|
||||
operator to set the EMS floor above 18, and the 10 % pack is then held above its
|
||||
own need. Closing that means `peak_soc_floor` becoming a per-entry key too, which
|
||||
is a second option migration and a product decision about what the site-wide
|
||||
number then means. The card scoped itself to `bms_soc_floor` and explicitly told
|
||||
this developer not to widen it, so it is filed here as the open question it is
|
||||
rather than smuggled in.
|
||||
|
||||
**Also still not in this card, and named by the shipped code already:** the
|
||||
charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction
|
||||
for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling`
|
||||
takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's
|
||||
per-pack bound. Nothing here touched it.
|
||||
|
||||
## 6. What was changed, and one thing that was found on the way
|
||||
|
||||
* `config.yaml` — `bms_soc_floor: int(0,50)` required per `inverters:` entry;
|
||||
the flat key and its default are untouched and are documented as the
|
||||
single-inverter shape's own cut-out.
|
||||
* `app/inverter.py` — `Inverter.bms_soc_floor`; `_entry_bms_floor()` (`None`, not
|
||||
a borrowed number and not `0.0`); `_flat_bms_floor()`.
|
||||
* `app/peakguard.py` — `Pack`, `pack_floor_pct()`, `pack_permissions()`,
|
||||
`binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the
|
||||
one place the question is answered.
|
||||
* `app/validate.py` — `unit_bms_floors_indexed()`; rule 8 grades every unit and
|
||||
names it via the **roster** index the value came off.
|
||||
* `app/main.py` — `Controller.packs`; the `decide(soc_floor=…)` reduction; the
|
||||
floor-limited notification names the pack(s) at or below their own floor, at a
|
||||
roster of two or more only.
|
||||
* `tools/supervisor_shape_probe.py` — **re-run, and repaired.** MUTATION.md
|
||||
habit 8: a committed transcript is a check and it decays. Making the key
|
||||
required broke §C outright and, worse, left §D printing **PASS** on a refusal
|
||||
about the *wrong key* — its "no `max_w`" case was answered `Missing option
|
||||
'bms_soc_floor'`. §D is now an isolated counterfactual over a **merged,
|
||||
accepted** baseline with exactly one key varied per case, asserting the key's
|
||||
**name** in the refusal, and it carries a `bms_soc_floor` case in both
|
||||
directions. Re-run against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`:
|
||||
`all cases passed`.
|
||||
* `app/inverter.py`, found by the mutation battery — `_flat_bms_floor()` was
|
||||
written with the default in **two** places (the `.get()` fallback *and* the
|
||||
blank test). Deleting either one survived, because each covered for the other:
|
||||
MUTATION.md's masking section in three lines of option reading. It has one
|
||||
defaulting line now, and M12 takes six checks red.
|
||||
|
||||
* **Round 3 changed no `app/` file at all.** `test_peakguard.py` only:
|
||||
parts 12c2 and 12g. Its battery was **46 mutations across
|
||||
`binding_soc_floor()`'s reduction, the `decide()` call site, `Controller.packs`
|
||||
and `pack_floor_pct()`'s conjunction** — bytecode purged and counted before and
|
||||
after each (23 → 0). **43 red, 3 survivors, all three recorded above and in the
|
||||
in-process rows**: `max(floors[:12])` and `max(floors[-12:])`, which need a
|
||||
13-pack roster to observe and are reported with that size rather than hidden;
|
||||
and `max(set(floors))`, which is **equivalent** — `max` is duplicate-invariant,
|
||||
so it cannot change an answer. `list(reversed(self.packs))` is equivalent too
|
||||
(`max` is order-invariant) and is nonetheless red on the structural pin.
|
||||
|
||||
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
||||
physically exist. Every second-pack number in a fixture, in the probe and in
|
||||
config.yaml's commented example is **simulated and labelled as such**; only the
|
||||
10 % on inverter A is read off hardware. The three-pack runs added in round 3 are simulated
|
||||
twice over: there is no inverter C at all, and the shallow slots carry inverter
|
||||
A's measured 10 % while the 35 % cut-out that moves along the roster is invented.
|
||||
|
||||
---
|
||||
|
||||
## 7. The probe transcript, re-run 2026-09-04 on 2026.08.0
|
||||
|
||||
Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring):
|
||||
|
||||
```
|
||||
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" \
|
||||
--entrypoint python3 \
|
||||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||||
/probe/supervisor_shape_probe.py
|
||||
```
|
||||
|
||||
It reads `config.yaml` and runs Supervisor's own `AppOptions` validator in a
|
||||
throwaway container. It touches no live Supervisor, POSTs nothing and installs
|
||||
nothing.
|
||||
|
||||
```
|
||||
== C. AC2: a two-inverter blob an operator would POST ==
|
||||
PASS two inverters
|
||||
[{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000, "bms_soc_floor": 10}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_so
|
||||
|
||||
== D. what the schema itself refuses, per entry ==
|
||||
PASS the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere else to come from
|
||||
[{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 3000, "bms_soc_floor": 10}]
|
||||
PASS an entry with no name is refused, and the refusal NAMES it
|
||||
Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with no setpoint_entity is refused, and the refusal NAMES it
|
||||
Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with no max_w is refused, and the refusal NAMES it
|
||||
Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with no bms_soc_floor (SAFETY-32) is refused, and the refusal NAMES it
|
||||
Missing option 'bms_soc_floor' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with max_w over the range is refused, and the refusal NAMES it
|
||||
value must be at most 5000.0
|
||||
PASS an entry with bms_soc_floor over the range (SAFETY-32) is refused, and the refusal NAMES it
|
||||
value must be at most 50.0
|
||||
PASS a JSON STRING where the list belongs is refused
|
||||
Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
|
||||
== E. the ROLLBACK direction, and the operator's UI ==
|
||||
PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
|
||||
['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
|
||||
PASS Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer
|
||||
[{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer
|
||||
|
||||
== all cases passed ==
|
||||
```
|
||||
@@ -363,6 +363,10 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
||||
| `sign_disagree_w` | 300 | Both the command and the meter must exceed this before a sign difference counts. Must stay **below `max_w`** or the watchdog can never fire, and the add-on refuses to start |
|
||||
| `sign_disagree_s` | 180 | How long they must stay opposed, continuously, before the failsafe trips |
|
||||
| `unit_sign_disagree_w` | 250 | **Two or more inverters only.** Both a unit's own share and its own measured power must exceed this before their sign difference counts. It is `saturation_w / 2`, so the smallest divergence it can see is exactly the gap at which the loop already calls the inverter "not following". Must stay **below `max_w`** or it can never fire, and the add-on refuses to start |
|
||||
| `unit_sign_disagree_s` | 85 | **Two or more inverters only.** How long one unit's command and its own power must stay opposed before that unit is parked |
|
||||
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
|
||||
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
|
||||
|
||||
#### The sign-disagreement failsafe
|
||||
|
||||
@@ -374,6 +378,43 @@ there. The banner reads `FAILSAFE`, the commissioning list says which two
|
||||
numbers disagreed and for how long, and the `Controller status` entity reports
|
||||
`failsafe`.
|
||||
|
||||
#### ⚠️ At two or more inverters this is TWO checks, not one (SAFETY-30)
|
||||
|
||||
The paragraph above describes what a **one-inverter** install does, and it is
|
||||
unchanged: there is one meter, one actuator, and comparing the command against
|
||||
the meter is comparing a thing with a view of itself.
|
||||
|
||||
With a second inverter that stops being true. The meter answers for **both**
|
||||
units, so a command put on one of them can be opposed to it with nothing wrong
|
||||
(inverter A charging while inverter B discharges more), and - the worse
|
||||
direction - a unit genuinely driving the wrong way can be **hidden**, because its
|
||||
sibling's watts arrive at the meter and the sum looks healthy. So the check
|
||||
splits in two, and they are different faults:
|
||||
|
||||
- **Per inverter** — that unit's own share against **its own measured power**
|
||||
(the `batt_entity` reading, register 0x0518). This catches *"this inverter is
|
||||
not doing what it was told"*. Only that inverter is parked; the site keeps
|
||||
running on the one that works. Thresholds: `unit_sign_disagree_w` /
|
||||
`unit_sign_disagree_s`.
|
||||
- **Site-wide** — the **sum** of what every inverter has standing, against the
|
||||
meter. This catches *"the site as a whole is pushing the wrong way"*, which no
|
||||
per-inverter check can see because every unit may be faithfully executing its
|
||||
share of a number that is itself wrong. **Every** inverter is parked and the
|
||||
banner names the site rather than an inverter. Thresholds:
|
||||
`site_sign_disagree_w` / `site_sign_disagree_s`, latched under flat
|
||||
`site_sign_tripped` in `/data/state.json`.
|
||||
|
||||
Both latch, both are cleared by the same *stop, then start*, and both survive a
|
||||
restart. **On a one-inverter install neither pair does anything at all** - the
|
||||
two questions are the same question there, and only the check described above is
|
||||
armed.
|
||||
|
||||
This is **not** the circulating-power alarm further down: that one compares the
|
||||
inverters *against each other* and catches them pushing power through each other,
|
||||
which nets to zero at the meter and which no command is evidence about. It is
|
||||
also not the saturation freeze, which is a *magnitude* test that shapes the loop
|
||||
rather than a *direction* test that parks an inverter.
|
||||
|
||||
**It does not clear itself, and restarting the add-on does not clear it either.**
|
||||
Pressing *Start / stop control* twice — stop, then start — is the only thing that
|
||||
does. That is deliberate on both counts:
|
||||
@@ -513,7 +554,7 @@ help - see "Why the tuning is what it is".
|
||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||||
| `peak_cap_w` | The site's capacity-tariff target |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
|
||||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start |
|
||||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start. ⚠️ **At a declared `inverters:` roster this key moves INTO each entry and is REQUIRED there** (SAFETY-32): it describes a battery, and two packs of different models have different cut-outs, so one number for both protects the shallower pack with the deeper pack's BMS. Nothing is inherited — an entry that omits it is refused **by name** at startup, and the flat key below is then not read at all |
|
||||
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
||||
|
||||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||||
|
||||
@@ -151,6 +151,30 @@ CLAIM_SCOPE = {
|
||||
# other went on feeding the loop. The evidence is site-level and so is the
|
||||
# remedy: stop commanding, everywhere, and say why.
|
||||
"circulation": SCOPE_SITE,
|
||||
# main.py: `Claim.set("sign_site", P_SAFETY, 0.0, "site sign disagreement")`.
|
||||
# SAFETY-30, and it is the twin of `failsafe` below rather than a duplicate
|
||||
# of it - the two exist BECAUSE the same words describe two different
|
||||
# measurements once there is more than one inverter.
|
||||
#
|
||||
# `failsafe` grades ONE unit's standing command against THAT UNIT'S OWN
|
||||
# measured power (register 0x0518). Its conclusion is "the actuator I am
|
||||
# driving is not doing what I told it", which is a fact about one device,
|
||||
# so it parks one device.
|
||||
#
|
||||
# This one grades the SUM of the standing commands against P1. Its
|
||||
# conclusion is "the site as a whole is driving the wrong way" - an inverted
|
||||
# gain, an integrator wound past the rail, a strategy on the wrong side of
|
||||
# the meter - and there is nothing per-unit about it: the quantity is a sum
|
||||
# over the roster and the offending number is the site target, which no
|
||||
# single inverter owns. Parking one unit on that evidence would leave the
|
||||
# rest of the site pushing the same way with the guard satisfied.
|
||||
#
|
||||
# ⚠️ AT N=1 IT IS NEVER FILED, and that is the identity rather than a
|
||||
# special case: with one inverter the sum of the commands IS that unit's
|
||||
# command and `failsafe` already carries exactly this trip, under the keys,
|
||||
# the banner and the claim an install has had since SAFETY-02 shipped.
|
||||
# `Controller.cycle()` does not even arm the site detector there.
|
||||
"sign_site": SCOPE_SITE,
|
||||
|
||||
# --- UNIT: about one pack, one inverter, one device -------------------
|
||||
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
|
||||
@@ -164,13 +188,22 @@ CLAIM_SCOPE = {
|
||||
# same reason, so it is scoped like `device`.
|
||||
"supervised": SCOPE_UNIT,
|
||||
# main.py: `Claim.set("failsafe", P_SAFETY, 0.0, "sign disagreement")`.
|
||||
# ⚠️ THE EVIDENCE IS SITE-WIDE AND THE REMEDY IS NOT, and that is why this
|
||||
# entry needs its reasoning written down. SAFETY-02 compares P1 - a site
|
||||
# measurement - against `standing`, THE COMMAND THIS CONTROLLER PUT ON THIS
|
||||
# ACTUATOR. What it concludes is "the unit I am driving is pushing power the
|
||||
# wrong way", and what it does is park that unit. At N>=2 the same evidence
|
||||
# cannot be attributed to a sibling nobody here commands, so a site-wide
|
||||
# remedy would be this watchdog stopping an inverter it never graded.
|
||||
# ⚠️ THE EVIDENCE IS PER-UNIT AND SO IS THE REMEDY - AND SINCE SAFETY-30 THE
|
||||
# FIRST HALF OF THAT SENTENCE IS TRUE. It used to read "the evidence is
|
||||
# site-wide and the remedy is not", because the watchdog compared THIS
|
||||
# unit's standing command against P1: a site measurement, attributed to one
|
||||
# actuator on the strength of §6.2's N=1 identity. That identity does not
|
||||
# survive a second inverter - at N>=2 the meter answers for a sibling this
|
||||
# unit's command says nothing about, in both directions (a false trip when
|
||||
# the sibling covers the meter, and a missed one when the sibling MASKS a
|
||||
# unit that really is reversed).
|
||||
#
|
||||
# So at N>=2 the comparison behind this claim is THIS unit's command against
|
||||
# THIS unit's own measured power - register 0x0518, per-instance since
|
||||
# DEV-02 - and the scope that was already right for the wrong reason is now
|
||||
# right for the right one. The site-wide half moved to `sign_site` above.
|
||||
# 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.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
|
||||
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
|
||||
|
||||
@@ -15,6 +15,11 @@ The scalars, and what each one was silently asserting:
|
||||
SAFETY-24's pack floor and arbitrage's bounds. An
|
||||
average lets the emptier pack be driven below its own
|
||||
floor; a maximum parks the fuller one.
|
||||
`bms_soc_floor` SAFETY-32. ONE cut-out percent for every pack, and it
|
||||
describes A BATTERY. Two packs of different models
|
||||
have different cut-outs, so the flat key graded the
|
||||
shallower pack against the deeper one's BMS - wrong
|
||||
in the direction that empties a pack.
|
||||
`batt` ONE battery power into the anti-windup saturation
|
||||
detector - see the SITE/UNIT note at the bottom of
|
||||
this docstring, because this one goes the OTHER WAY.
|
||||
@@ -117,6 +122,12 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
||||
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
||||
# shipped default and a literal here would be the fourth defaults site that
|
||||
# ticket exists to prevent. peakguard imports arbiter/control/tariff and none
|
||||
# of those imports this module, so the direction is one-way.
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .store import unit_key
|
||||
from .watchdog import SignWatchdog, SignWatchdogConfig
|
||||
|
||||
@@ -170,7 +181,7 @@ class Inverter:
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
store=None, sign_cfg=None):
|
||||
bms_soc_floor=None, store=None, sign_cfg=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
@@ -179,6 +190,19 @@ class Inverter:
|
||||
# The supervised cap on what may be written to THIS setpoint entity.
|
||||
# See the module docstring on why this is a number and not a `Tuning`.
|
||||
self.max_w = float(max_w)
|
||||
# SAFETY-32. THIS PACK'S OWN BMS CUT-OUT, in percent, or None when the
|
||||
# roster entry did not state one.
|
||||
#
|
||||
# ⚠️ NOT DEFAULTED HERE, AND None RATHER THAN A NUMBER. A cut-out is a
|
||||
# property of the battery in the cupboard; inventing one for a pack that
|
||||
# did not state it is precisely this card's defect - a pack graded
|
||||
# against a floor that belongs to a different battery - rebuilt in the
|
||||
# constructor. `peakguard.pack_floor_pct()` treats None as an unknown
|
||||
# floor and `discharge_permitted()` treats an unknown floor as NO
|
||||
# permission, so the absence costs a shave; validate.py rule 8 refuses
|
||||
# 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))
|
||||
|
||||
# --- the device's own reported range, read from its own entity -------
|
||||
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
|
||||
@@ -339,8 +363,9 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
|
||||
`inverters:` empty?
|
||||
|
||||
EMPTY the five flat keys - `setpoint_entity`, `soc_entity`,
|
||||
`batt_entity`, `batt_invert`, `max_w` - describe the ONE inverter,
|
||||
EMPTY the six flat keys - `setpoint_entity`, `soc_entity`,
|
||||
`batt_entity`, `batt_invert`, `max_w`, `bms_soc_floor` (SAFETY-32)
|
||||
- describe the ONE inverter,
|
||||
exactly as the shipped scalars did. This is what every install
|
||||
that upgrades into this version reads, without an operator
|
||||
touching anything, and it is byte-identical to what DEV-02
|
||||
@@ -353,8 +378,8 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
`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
|
||||
rebuild it in three lines. `name`, `setpoint_entity` and `max_w`
|
||||
are required of every entry - the schema enforces it on the
|
||||
rebuild it in three lines. `name`, `setpoint_entity`, `max_w` and
|
||||
`bms_soc_floor` (SAFETY-32) are required of every entry - the schema enforces it on the
|
||||
Supervisor path, `validate.py` rule 9 enforces it for sim/inject.py
|
||||
and the checks, which reach this function with any dict at all.
|
||||
|
||||
@@ -380,6 +405,12 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
batt_entity=opts.get("batt_entity", ""),
|
||||
batt_invert=bool(opts.get("batt_invert")),
|
||||
max_w=float(opts.get("max_w", 2000)),
|
||||
# SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape
|
||||
# - the one pack the flat options describe is the one pack whose
|
||||
# cut-out `bms_soc_floor` states. Defaulted from CFG-03's single
|
||||
# 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),
|
||||
store=store, sign_cfg=sign_cfg)]
|
||||
|
||||
roster = []
|
||||
@@ -401,10 +432,54 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
# ⚠️ NOT `opts["max_w"]`. See the docstring: a rating an entry did
|
||||
# not state is not the flat one, it is a rating rule 9 refuses.
|
||||
max_w=_entry_max_w(entry),
|
||||
# ⚠️ NOT `opts["bms_soc_floor"]`, FOR `max_w`'S REASON AND THIS
|
||||
# CARD'S. An entry that omits its cut-out has NOT got the flat one;
|
||||
# it has none, and rule 8 refuses it by name. Borrowing here would be
|
||||
# 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),
|
||||
store=store, sign_cfg=sign_cfg))
|
||||
return roster
|
||||
|
||||
|
||||
def _flat_bms_floor(opts: dict):
|
||||
"""The single-inverter shape's own cut-out, or None if it is unparseable.
|
||||
|
||||
Defaulted from `PEAK_OPTION_DEFAULTS` when the key is absent or blank, which
|
||||
is `validate._number()`'s behaviour on the same key - the two have to agree
|
||||
or the validator would grade a number the roster does not build.
|
||||
"""
|
||||
# ⚠️ ONE defaulting line, not two. The first cut wrote the fallback into the
|
||||
# `.get()` AND into the blank test below it, and a mutation that deleted
|
||||
# either one survived - each was covering for the other, which is
|
||||
# MUTATION.md's masking section in three lines of configuration reading.
|
||||
raw = opts.get("bms_soc_floor")
|
||||
if raw is None or raw == "":
|
||||
return float(PEAK_OPTION_DEFAULTS["bms_soc_floor"])
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _entry_bms_floor(entry: dict):
|
||||
"""One entry's OWN cut-out, or None when it did not state a usable one.
|
||||
|
||||
None rather than a borrowed number and rather than 0.0 - `_entry_max_w()`
|
||||
can answer 0.0 because a zero rating commands nothing, but a zero FLOOR is
|
||||
the most permissive value the key has and would read as "this pack may be
|
||||
emptied". Rule 8 refuses the missing key by name; until it does, None
|
||||
withholds.
|
||||
"""
|
||||
raw = entry.get("bms_soc_floor")
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _entry_max_w(entry: dict) -> float:
|
||||
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
|
||||
|
||||
|
||||
+368
-17
@@ -64,7 +64,10 @@ from .tariff import PeakStore, PeakTracker
|
||||
from .store import migrate_unit_keys
|
||||
from .validate import (ConfigRefused, refuse, validate_startup_config,
|
||||
validate_unit_ranges)
|
||||
from .watchdog import SignWatchdogConfig, loop_is_following
|
||||
from .watchdog import (
|
||||
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
|
||||
SignWatchdog, SignWatchdogConfig, loop_is_following,
|
||||
)
|
||||
from . import web
|
||||
|
||||
OPTIONS_PATH = "/data/options.json"
|
||||
@@ -173,10 +176,42 @@ class Controller:
|
||||
# first or every existing tripped install comes up ARMED, into the
|
||||
# fault, with a green banner - which is the exact failure the latch was
|
||||
# persisted to prevent, rebuilt by a key rename.
|
||||
sign_cfg = SignWatchdogConfig(
|
||||
# SAFETY-02's pair, and since SAFETY-30 it is THE SITE COMPARISON's
|
||||
# pair - which at the shipped roster of one is the same thing under a
|
||||
# more careful name. It is what `Inverter.sign_wd` is armed with at N=1,
|
||||
# byte for byte, and what the site detector is armed with at N>=2.
|
||||
self.sign_cfg = sign_cfg = SignWatchdogConfig(
|
||||
min_w=float(opts.get("sign_disagree_w", 300)),
|
||||
hold_s=float(opts.get("sign_disagree_s", 180)),
|
||||
)
|
||||
# SAFETY-30. THE PER-UNIT COMPARISON'S OWN PAIR, RE-DERIVED AT TWO
|
||||
# INVERTERS AND NOT CARRIED ACROSS. `sign_disagree_w` / `sign_disagree_s`
|
||||
# were measured on two SINGLE-INVERTER bus captures against a command
|
||||
# opposed to THE METER; this comparison is a command against ONE UNIT'S
|
||||
# OWN POWER, where the magnitudes, the transient and the worst
|
||||
# legitimate episode are all different quantities. watchdog.py's
|
||||
# SIGN_OPTION_DEFAULTS carries the campaign that produced these, and
|
||||
# DECISIONS/SAFETY-30-per-unit-sign.md carries the tables.
|
||||
self.unit_sign_cfg = SignWatchdogConfig(
|
||||
min_w=float(opts.get("unit_sign_disagree_w",
|
||||
SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])),
|
||||
hold_s=float(opts.get("unit_sign_disagree_s",
|
||||
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"])),
|
||||
)
|
||||
# SAFETY-30. THE SITE COMPARISON'S OWN PAIR, USED ONLY AT N>=2. The
|
||||
# threshold is the shipped 300 W - carried, because the argument behind
|
||||
# it is about the meter's noise floor and the deadband, neither of which
|
||||
# the roster touches - and the WINDOW is not, because the transient it
|
||||
# rejects is the loop crossing zero at `slew_w` per cycle across a rail
|
||||
# that IS the roster sum. Measured at both rosters on the same stimuli:
|
||||
# 110 s at one inverter, 215 s at two, against a 180 s hold. See
|
||||
# watchdog.SIGN_OPTION_DEFAULTS.
|
||||
self.site_sign_cfg = SignWatchdogConfig(
|
||||
min_w=float(opts.get("site_sign_disagree_w",
|
||||
SIGN_OPTION_DEFAULTS["site_sign_disagree_w"])),
|
||||
hold_s=float(opts.get("site_sign_disagree_s",
|
||||
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])),
|
||||
)
|
||||
self.store_migration = migrate_unit_keys(store, UNIT_A)
|
||||
self.inverters = build_roster(opts, store, sign_cfg)
|
||||
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
|
||||
@@ -261,6 +296,45 @@ class Controller:
|
||||
"opposed the meter.", inv.name,
|
||||
inv.sign_wd.reason or "(reason not recorded)")
|
||||
|
||||
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
||||
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
|
||||
# The per-unit watchdogs above ask "is THIS actuator doing what I told
|
||||
# it", graded on that unit's own measured power. This one asks "is the
|
||||
# SITE driving the wrong way", graded on the SUM of the standing
|
||||
# commands against P1 - the comparison SAFETY-02 shipped, re-scoped to
|
||||
# the quantity it was always actually about.
|
||||
#
|
||||
# ⚠️ AT A ROSTER OF ONE IT IS CONSTRUCTED AND NEVER EVALUATED, and that
|
||||
# is the whole compatibility promise of this card rather than an
|
||||
# oversight. With one inverter the sum of the commands IS that
|
||||
# inverter's command, so arming this would be arming a duplicate of
|
||||
# `inverters[0].sign_wd`: two latches for one fault, two banners, and a
|
||||
# false-positive record measured on ONE comparison being spent twice.
|
||||
# `cycle()` is where that is enforced and says so at the line.
|
||||
#
|
||||
# Restored from the store like SAFETY-02's and SAFETY-31's latches, for
|
||||
# the reason store.py gives: "restart the add-on" is the first thing an
|
||||
# operator looking at a FAILSAFE banner tries, and a latch held only in
|
||||
# memory brings the controller back armed, into the fault, unattended.
|
||||
# FLAT keys, not namespaced - store.py carries that argument too.
|
||||
self.site_sign_wd = SignWatchdog(
|
||||
self.site_sign_cfg,
|
||||
tripped=bool(store.data.get("site_sign_tripped", False)),
|
||||
reason=str(store.data.get("site_sign_reason", "") or ""))
|
||||
if self.site_sign_wd.tripped:
|
||||
_LOG.error(
|
||||
"SAFETY-30 (site) was TRIPPED before this restart and stays "
|
||||
"tripped: %s. Every inverter is held at 0 W. Restarting does "
|
||||
"not clear this - start control from the UI once you know why "
|
||||
"the site's total command opposed the meter.",
|
||||
self.site_sign_wd.reason or "(reason not recorded)")
|
||||
# And the per-unit detectors are told which question they are asking,
|
||||
# here as well as in `cycle()`. `checks()` and the status page read
|
||||
# `inv.sign_wd.cfg` and can be called before the first cycle has run, so
|
||||
# a roster of two would otherwise advertise the site thresholds on a
|
||||
# detector that is about to be graded against a unit's own power.
|
||||
self._arm_sign_watchdogs()
|
||||
|
||||
# SAFETY-18. The frozen-but-fresh meter detector - see freeze.py for why
|
||||
# it is a conjunction and not an `unchanged_s` threshold, and for the
|
||||
# measurements behind the default.
|
||||
@@ -712,6 +786,45 @@ class Controller:
|
||||
def sign_wd(self):
|
||||
return self._only().sign_wd
|
||||
|
||||
# -- SAFETY-30: which comparison each sign detector is making -------------
|
||||
def _arm_sign_watchdogs(self) -> None:
|
||||
"""Tell every sign detector which of SAFETY-30's two questions it asks.
|
||||
|
||||
⚠️ A PURE FUNCTION OF `len(self.inverters)`, AND THE ONLY PLACE THE
|
||||
ROSTER DECIDES ANYTHING ABOUT THIS CHECK. Written as one method rather
|
||||
than as a conditional at the two call sites for `loop_is_following()`'s
|
||||
reason: the arrangement that does not keep two things in step is the one
|
||||
where they are written down twice.
|
||||
|
||||
AT ONE INVERTER - §6.2's identity, and the whole of AC 8. The single
|
||||
unit's detector is armed against THE METER with the shipped
|
||||
`sign_disagree_w` / `sign_disagree_s`, which is what it has always been
|
||||
armed against, and the site detector is not armed at all. A one-inverter
|
||||
install therefore makes the same comparison, at the same thresholds,
|
||||
writes the same reason string, latches under the same keys and files the
|
||||
same `failsafe` claim as it did before this card.
|
||||
|
||||
AT TWO OR MORE - the identity is gone and the meter stops being a view
|
||||
of any one actuator. Each unit's detector is armed against ITS OWN
|
||||
MEASURED POWER (`Inverter.batt`, register 0x0518) under the re-derived
|
||||
per-unit pair, and the site detector is armed against the meter under
|
||||
the site pair. Two questions, two remedies, two latches.
|
||||
|
||||
⚠️ CALLED AGAIN AT THE TOP OF EVERY `cycle()`, not only from
|
||||
`__init__`. The roster is re-read every cycle for liveness and for the
|
||||
allocation, and this repository's two-inverter fixtures append an
|
||||
`Inverter` AFTER the Controller is constructed - a detector frozen at
|
||||
construction would then be graded against the meter at a roster of two,
|
||||
which is the defect this card exists to remove, wearing a fixture's
|
||||
name. `arm()` touches no timer and no latch, so calling it every cycle
|
||||
cannot disturb an episode in progress.
|
||||
"""
|
||||
one = len(self.inverters) <= 1
|
||||
for inv in self.inverters:
|
||||
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
|
||||
AGAINST_METER if one else AGAINST_OWN_POWER)
|
||||
self.site_sign_wd.arm(self.site_sign_cfg, AGAINST_METER)
|
||||
|
||||
# -- the site aggregates, each one named for the question it answers ------
|
||||
# ⚠️ THESE ARE NOT "THE SCALARS WITH A SUM IN FRONT". Each one picks a
|
||||
# DIFFERENT reduction, because the questions are different, and getting the
|
||||
@@ -827,6 +940,26 @@ class Controller:
|
||||
return None
|
||||
return float(min(float(v) for v in vals))
|
||||
|
||||
@property
|
||||
def packs(self) -> list:
|
||||
"""SAFETY-32. Each pack as its own THREE numbers, in roster order.
|
||||
|
||||
`pack_floor_soc` above is the site's ONE verdict and stays exactly what
|
||||
DEV-02 made it - the emptiest measured pack. This is the thing that
|
||||
reduction cannot produce: by the time `min()` has answered, WHICH pack
|
||||
the number came from and how far that pack was from ITS OWN cut-out have
|
||||
both been thrown away, and two packs at 15 % against cut-outs of 10 % and
|
||||
20 % have opposite answers that are indistinguishable afterwards.
|
||||
|
||||
⚠️ EVERY unit, live or not. Same reason as `inputs_ok`: a pack whose SoC
|
||||
did not answer is a pack nothing can grade, and dropping it from this
|
||||
list would let the site spend the packs that DID answer while one sat
|
||||
unmeasured. `peakguard.discharge_permitted()` treats a None SoC as no
|
||||
permission, which is where that must be decided.
|
||||
"""
|
||||
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
|
||||
for inv in self.inverters]
|
||||
|
||||
@property
|
||||
def inputs_ok(self) -> bool:
|
||||
"""The site meter, and EVERY pack's own two readings.
|
||||
@@ -1239,17 +1372,44 @@ class Controller:
|
||||
# case where the last write was REJECTED - the inverter is then still
|
||||
# latched on `last_write`, which is exactly what `last_write` holds).
|
||||
# ⚠️ ONE WATCHDOG PER INVERTER, EVALUATED ON THAT INVERTER'S OWN
|
||||
# STANDING COMMAND. The meter is site-wide - there is one P1 - but what
|
||||
# the watchdog concludes is "THE UNIT I AM DRIVING is pushing power the
|
||||
# wrong way", and that evidence cannot be pinned on a sibling this
|
||||
# controller commanded differently. `inv.target` is that unit's share
|
||||
# from the last resolution, which at N=1 is the site target itself.
|
||||
# STANDING COMMAND. What the watchdog concludes is "THE UNIT I AM
|
||||
# DRIVING is pushing power the wrong way", and that evidence cannot be
|
||||
# pinned on a sibling this controller commanded differently.
|
||||
# `inv.target` is that unit's share from the last resolution, which at
|
||||
# N=1 is the site target itself.
|
||||
#
|
||||
# ⚠️ SAFETY-30: AND WHAT IT IS COMPARED AGAINST IS DECIDED BY THE ROSTER.
|
||||
# This block used to hand every unit's detector `self.grid`. There is
|
||||
# ONE P1, so at N>=2 that compares a number this controller put on ONE
|
||||
# actuator against a measurement that answers for ALL of them, and it is
|
||||
# wrong in both directions:
|
||||
#
|
||||
# FALSE TRIP inverter A commanded -1500 W while the meter reads
|
||||
# +1500 W is evidence of nothing when inverter B is
|
||||
# discharging 3000 W exactly as instructed. Nothing is
|
||||
# faulty and the highest-value check in the safety table
|
||||
# fires - on a record of zero false positives.
|
||||
# MISSED the converse, and it is worse. A unit commanded +1500 W
|
||||
# and MEASURED at -1500 W, beside a sibling at +1500 W,
|
||||
# leaves P1 exactly where a healthy site leaves it. The
|
||||
# sum destroys the evidence; no threshold recovers it.
|
||||
#
|
||||
# So at N>=2 each unit is graded against ITS OWN measured power -
|
||||
# `inv.batt`, register 0x0518, per-instance since DEV-02 - which is the
|
||||
# only instrument that can answer "is THIS actuator doing what I told
|
||||
# it". At N=1 §6.2's identity holds, the meter IS the view of that one
|
||||
# unit, and the comparison stays the meter, byte for byte. The site's
|
||||
# own question has not gone away; it is asked below, once, of the sum.
|
||||
# `_arm_sign_watchdogs()` states the choice and its docstring argues it.
|
||||
self._arm_sign_watchdogs()
|
||||
n_units = len(self.inverters)
|
||||
standing = {}
|
||||
for inv in self.inverters:
|
||||
was_tripped = inv.sign_wd.tripped
|
||||
stood = inv.last_write if inv.last_write is not None else inv.target
|
||||
standing[inv.name] = stood
|
||||
if inv.sign_wd.evaluate(stood, self.grid, self.following, now) \
|
||||
against = self.grid if n_units <= 1 else inv.batt
|
||||
if inv.sign_wd.evaluate(stood, against, self.following, now) \
|
||||
and not was_tripped:
|
||||
# ⚠️ REASON FIRST, FLAG SECOND, and each `set` writes the file.
|
||||
# The ordering is the whole atomicity story: a crash between the
|
||||
@@ -1290,6 +1450,50 @@ class Controller:
|
||||
# share of it. At the shipped roster of one the two are the same number.
|
||||
site_standing = float(sum(standing.values()))
|
||||
|
||||
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
||||
# The comparison SAFETY-02 shipped, asked of the quantity it was always
|
||||
# about: the TOTAL this controller has standing on the wire, against the
|
||||
# meter that total is answerable for. What it catches is a fault no
|
||||
# per-unit check can see, because every unit is faithfully executing its
|
||||
# share of a site target that is itself pointing the wrong way - an
|
||||
# inverted gain, an integrator wound past the rail, a strategy on the
|
||||
# wrong side of the meter. That is the 2026-08-20 fault day, and it is
|
||||
# the reason the site question survives rather than being replaced.
|
||||
#
|
||||
# ⚠️ ONLY AT N>=2, AND THIS BRANCH IS AC 8. At one inverter the sum of
|
||||
# the commands IS that inverter's command and the loop above has already
|
||||
# made exactly this comparison, at exactly these thresholds, on the
|
||||
# detector whose latch, store keys, claim and banner an install has had
|
||||
# since SAFETY-02 shipped. Arming this as well would be two latches for
|
||||
# one fault, two banners, and the same false-positive record spent
|
||||
# twice. A shipped install does not reach this block at all.
|
||||
if n_units > 1:
|
||||
was_site = self.site_sign_wd.tripped
|
||||
if self.site_sign_wd.evaluate(site_standing, self.grid,
|
||||
self.following, now) and not was_site:
|
||||
# REASON FIRST, FLAG SECOND - the same atomicity ordering the
|
||||
# per-unit trip uses above and SAFETY-31 uses below: a crash
|
||||
# between the two must not persist a latch with no explanation
|
||||
# for the operator who has to decide about clearing it.
|
||||
#
|
||||
# ⚠️ AND THE KEYS ARE FLAT, NOT NAMESPACED, AND NOT THE
|
||||
# PRE-DEV-02 PAIR EITHER. `sign_tripped` / `sign_reason` are the
|
||||
# ROLLBACK VIEW of a UNIT latch (store.py rule 2) and mirroring
|
||||
# a site trip onto them would hand a downgraded add-on - which
|
||||
# drives exactly one actuator - a latch taken on evidence about
|
||||
# a roster it cannot have. `site_sign_*` is its own pair, for
|
||||
# the same reason `circ_*` is.
|
||||
self.store.set("site_sign_reason", self.site_sign_wd.reason)
|
||||
self.store.set("site_sign_tripped", True)
|
||||
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason)
|
||||
_LOG.error("SAFETY-30 TRIPPED at the SITE - %s. That is the "
|
||||
"SUM of every standing command opposing the meter, "
|
||||
"which no single inverter is answerable for. "
|
||||
"Commanding 0 W on every inverter and holding it "
|
||||
"until control is started again. This survives an "
|
||||
"add-on restart; restarting will not clear it.",
|
||||
self.site_sign_wd.reason)
|
||||
|
||||
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
|
||||
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
|
||||
# not yet stale" hold below. A run of held cycles would otherwise leave
|
||||
@@ -1329,8 +1533,14 @@ class Controller:
|
||||
# list below, because its `failsafe` SET is what parks it. One tripped
|
||||
# unit therefore takes the whole roster past the hold, which is the
|
||||
# conservative direction: every other unit's claims are re-filed too.
|
||||
# ⚠️ AND SAFETY-30's SITE LATCH TAKES THE ROSTER PAST THE HOLD TOO, for
|
||||
# the same reason: its `sign_site` SET is what parks every inverter, and
|
||||
# a latched site that returned early here would leave each unit's last
|
||||
# share standing for the heartbeat to re-emit - i.e. the trip would stop
|
||||
# acting the moment a poll was missed.
|
||||
if not self.inputs_ok and not any(i.sign_wd.tripped
|
||||
for i in self.inverters):
|
||||
for i in self.inverters) \
|
||||
and not (n_units > 1 and self.site_sign_wd.tripped):
|
||||
bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0
|
||||
if bad_for < stale_after:
|
||||
# A single missed poll is not a fault. Hold, keep the heartbeat
|
||||
@@ -1384,6 +1594,30 @@ class Controller:
|
||||
"sign disagreement",
|
||||
actuator=self.actuator_of("failsafe", inv)))
|
||||
|
||||
# --- SAFETY-30: THE SITE SIGN TRIP, AND IT IS A SITE SET ------------
|
||||
# ⚠️ THE TWIN OF THE LOOP ABOVE, NOT A DUPLICATE OF IT. That loop files
|
||||
# one `failsafe` per unit on the evidence of THAT unit's own measured
|
||||
# power, and parks that unit. This files ONE claim on the SITE, because
|
||||
# what tripped is the SUM of every standing command against the meter -
|
||||
# a fact about the grid connection, whose offending number is the site
|
||||
# target and which no single inverter owns. Parking one unit on it would
|
||||
# leave the rest of the roster pushing the same way with the guard
|
||||
# satisfied. See arbiter.CLAIM_SCOPE, which classifies `sign_site` and
|
||||
# `failsafe` side by side and says why they differ.
|
||||
#
|
||||
# ⚠️ AND THE `n_units > 1` GUARD IS NOT REDUNDANT, EVEN THOUGH `cycle()`
|
||||
# never arms this detector at a roster of one. The latch is RESTORED
|
||||
# FROM THE STORE, so an install that ran with two inverters, tripped,
|
||||
# and was then reconfigured back to one would come up with
|
||||
# `site_sign_tripped: true` and would otherwise file a claim on evidence
|
||||
# about a roster it no longer has. Guarded, that install comes up
|
||||
# driving - which is right: the fault it latched on is not a statement
|
||||
# about the site it is now.
|
||||
if n_units > 1 and self.site_sign_wd.tripped:
|
||||
claims.append(Claim.set("sign_site", P_SAFETY, 0.0,
|
||||
"site sign disagreement",
|
||||
actuator=self.actuator_of("sign_site")))
|
||||
|
||||
# --- SAFETY-31: WHAT "ACTED ON" MEANS, AND WHAT IT HONESTLY CANNOT --
|
||||
# ⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. Read the next
|
||||
# paragraph before changing it, because the obvious objection is right
|
||||
@@ -1520,9 +1754,21 @@ class Controller:
|
||||
# protect. None stays None: `decide()` treats it as floor-limited,
|
||||
# which costs a shave rather than a pack.
|
||||
soc=self.pack_floor_soc,
|
||||
soc_floor=float(self.o.get(
|
||||
# ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN
|
||||
# CUT-OUTS. `peak_soc_floor` is still one site-wide EMS floor and is
|
||||
# still read from that one key with CFG-03's one mirror behind it;
|
||||
# what `binding_soc_floor()` adds is the conjunction with each pack's
|
||||
# OWN `bms_soc_floor`, so the floor this site is graded against can
|
||||
# never be below the deepest cut-out on the roster. MAXIMUM here
|
||||
# pairs with the MINIMUM in `pack_floor_soc`: both reductions move
|
||||
# the verdict towards refusing a shave and away from spending a pack.
|
||||
# Under any config rule 8 accepts it IS the option, for every pack -
|
||||
# asserted over the whole schema range in test_peakguard.py part 12
|
||||
# rather than assumed, because "another rule refuses that" is a claim
|
||||
# about rule ordering and sim/inject.py does not run rule 8 at all.
|
||||
soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get(
|
||||
"peak_soc_floor",
|
||||
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])),
|
||||
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))),
|
||||
max_hold_s=self.p1.averager.max_hold_s,
|
||||
# ⚠️ SAFETY-20. The device range goes in TOO, and this call site is
|
||||
# the one that needed it most: `standing` is `last_write`, i.e. the
|
||||
@@ -2209,6 +2455,22 @@ class Controller:
|
||||
# notification that matters into a traceback.
|
||||
_at_floor = ("at the floor" if g.soc_floor is None
|
||||
else f"at {float(g.soc_floor):.0f} %")
|
||||
# ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor
|
||||
# the decision was actually taken with, so this re-grades nothing and
|
||||
# cannot disagree with the alarm it is printed inside. At a roster of one
|
||||
# the sentence is omitted entirely and this notification is byte-for-byte
|
||||
# what SAFETY-25 shipped - there is exactly one pack and naming it would
|
||||
# be noise on every existing install.
|
||||
_blocked = [name for name, why
|
||||
in peakguard.pack_permissions(self.packs, g.soc_floor)
|
||||
if why is not None]
|
||||
_which = ("" if len(self.inverters) < 2 else
|
||||
f"\n\nSAFETY-32 - WHICH PACK: this is graded per pack against "
|
||||
f"that pack's own BMS cut-out (bms_soc_floor), and the "
|
||||
f"pack(s) at or below their own floor right now are "
|
||||
f"{', '.join(_blocked) if _blocked else '(none - see the reason above)'}"
|
||||
f". The other packs on the roster are not the reason the guard "
|
||||
f"stopped and raising their cut-out will not change this.")
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_peak_soc_floor",
|
||||
"title": "GoodWe: the capacity-peak guard has run out of battery",
|
||||
@@ -2228,7 +2490,7 @@ class Controller:
|
||||
f"{_at_floor} - ordinary self-consumption has no SoC "
|
||||
f"floor at all and will go on discharging past it, down to the "
|
||||
f"pack's own BMS cut-out. What has stopped is the guard buying "
|
||||
f"you a lower bill, not the pack being spent."),
|
||||
f"you a lower bill, not the pack being spent." + _which),
|
||||
})
|
||||
|
||||
async def check_circulating(self) -> None:
|
||||
@@ -2608,8 +2870,15 @@ class Controller:
|
||||
# here would be a surface to migrate later. This sensor is already
|
||||
# discovered, already on every dashboard, and "failsafe" is
|
||||
# additive to its vocabulary rather than a change to it.
|
||||
# SAFETY-30 adds the site sign latch to the same vocabulary, for
|
||||
# the reason the note above gives: "failsafe" is additive to a
|
||||
# sensor that already exists and is already on every dashboard, and
|
||||
# a P_SAFETY stop that did not reach it would be a site at 0 W
|
||||
# reporting "running".
|
||||
"status": ("failsafe" if any(i.sign_wd.tripped
|
||||
for i in self.inverters)
|
||||
or (self.site_sign_wd.tripped
|
||||
and len(self.inverters) > 1)
|
||||
or self.circ_wd.tripped
|
||||
else "running" if self.auto else "stopped"),
|
||||
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
|
||||
@@ -2936,24 +3205,70 @@ class Controller:
|
||||
# and a trip on B leaves A commanding. A single row would have to pick
|
||||
# one of them to describe, and the honest answer at a roster of two is
|
||||
# both. At a roster of one the wording is unchanged.
|
||||
# ⚠️ AND SINCE SAFETY-30 THE ROW SAYS WHAT THE UNIT WAS COMPARED
|
||||
# AGAINST, because at a roster of two that is no longer the meter and a
|
||||
# row that still said "the command opposed the meter" would send an
|
||||
# operator to look at P1 for a fault that is visible only on one
|
||||
# inverter's own power sensor. `sign_wd.against` is the same string the
|
||||
# trip sentence uses, so the two cannot drift apart.
|
||||
for inv in self.inverters:
|
||||
tag = "" if len(self.inverters) == 1 else f" [{inv.name}]"
|
||||
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the
|
||||
# shipped roster of one the two sentences below are the ones that
|
||||
# have always been on the page, word for word - AC 8 is about what
|
||||
# an operator sees as much as about what the detector computes, and
|
||||
# "opposed a meter reading" would be a gratuitous change to a line
|
||||
# somebody has learned to recognise. At N>=2 the noun is load-
|
||||
# bearing: the row would otherwise send them to look at P1 for a
|
||||
# fault that is visible only on one inverter's own power sensor.
|
||||
vs = ("" if len(self.inverters) == 1
|
||||
else f" against {inv.sign_wd.against}")
|
||||
if inv.sign_wd.tripped:
|
||||
what = ("the meter" if len(self.inverters) == 1
|
||||
else inv.sign_wd.against)
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": f"SIGN-DISAGREEMENT FAILSAFE{tag} - "
|
||||
+ inv.sign_wd.reason
|
||||
+ ". The inverter is held at 0 W. Find out why the "
|
||||
"command opposed the meter before starting control "
|
||||
+ f". The inverter is held at 0 W. Find out why the "
|
||||
f"command opposed {what} before starting control "
|
||||
"again; starting clears this. Restarting the "
|
||||
"add-on does NOT clear it."})
|
||||
else:
|
||||
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"sign watchdog{tag}: armed (trips after "
|
||||
"text": f"sign watchdog{tag}: armed{vs} (trips after "
|
||||
f"{inv.sign_wd.cfg.hold_s:.0f} s opposed, both over "
|
||||
f"{inv.sign_wd.cfg.min_w:.0f} W)"
|
||||
+ (f" - opposed for {held:.0f} s now" if held else "")})
|
||||
|
||||
# --- SAFETY-30: the SITE row -----------------------------------------
|
||||
# ⚠️ ONLY AT N>=2, and the absence at N=1 is the honest report rather
|
||||
# than a missing row. With one inverter the site comparison and the row
|
||||
# above are the same sentence about the same actuator; printing it twice
|
||||
# would advertise two guards where there is one, which is how an
|
||||
# operator learns to skim the list.
|
||||
if len(self.inverters) > 1:
|
||||
if self.site_sign_wd.tripped:
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": "SITE SIGN-DISAGREEMENT FAILSAFE - "
|
||||
+ self.site_sign_wd.reason
|
||||
+ ". EVERY inverter is held at 0 W. This is the "
|
||||
"SUM of the commands against the meter, so it is "
|
||||
"not one inverter's fault to find - look at the "
|
||||
"loop, the integrator and whatever won the "
|
||||
"arbitration. Starting control clears this; "
|
||||
"restarting the add-on does NOT."})
|
||||
else:
|
||||
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"site sign watchdog: armed against the sum of "
|
||||
f"the commands (trips after "
|
||||
f"{self.site_sign_wd.cfg.hold_s:.0f} s opposed, "
|
||||
f"both over "
|
||||
f"{self.site_sign_wd.cfg.min_w:.0f} W)"
|
||||
+ (f" - opposed for {_sheld:.0f} s now"
|
||||
if _sheld else "")})
|
||||
|
||||
# --- SAFETY-31 -----------------------------------------------------
|
||||
# ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and the row says
|
||||
# so. There is one circulating figure for the site; naming an inverter
|
||||
@@ -3010,10 +3325,28 @@ class Controller:
|
||||
level, banner = "bad", ("CIRCULATING POWER — the inverters were "
|
||||
"pushing against each other. Site held at "
|
||||
"0 W.")
|
||||
elif self.site_sign_wd.tripped and len(self.inverters) > 1:
|
||||
# SAFETY-30's site half, ABOVE the per-unit rows below and below
|
||||
# circulating power above, and the ordering is the same argument
|
||||
# both times: the banner carries one sentence, so it carries the one
|
||||
# that says the widest true thing. A site sign trip means EVERY
|
||||
# inverter is parked and the fault is in what the site was asking
|
||||
# for, not in one device - naming a unit here would send an operator
|
||||
# to swap the wrong box.
|
||||
level, banner = "bad", ("FAILSAFE — the site's total command "
|
||||
"opposed the meter. Every inverter held at "
|
||||
"0 W.")
|
||||
elif any(i.sign_wd.tripped for i in self.inverters):
|
||||
# Outranks "NOT READY": nothing is missing, something went wrong.
|
||||
level, banner = "bad", ("FAILSAFE — the command opposed the meter. "
|
||||
"Inverter held at 0 W.")
|
||||
# ⚠️ THE SENTENCE IS UNCHANGED AT A ROSTER OF ONE, where "the meter"
|
||||
# is what the unit was graded against. At N>=2 it is that unit's own
|
||||
# power, so the banner says so - see checks() on why the noun is
|
||||
# load-bearing rather than cosmetic.
|
||||
level, banner = "bad", (
|
||||
"FAILSAFE — the command opposed the meter. "
|
||||
"Inverter held at 0 W." if len(self.inverters) == 1 else
|
||||
"FAILSAFE — an inverter's command opposed its own measured "
|
||||
"power. That inverter held at 0 W.")
|
||||
elif bad:
|
||||
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may
|
||||
# need to say more to an operator reading the checks list than fits
|
||||
@@ -3118,6 +3451,24 @@ class Controller:
|
||||
# unit in particular.
|
||||
self.store.set("sign_tripped", False)
|
||||
self.store.set("sign_reason", "")
|
||||
# SAFETY-30's SITE latch, cleared by the SAME press and for the
|
||||
# same reason the per-unit ones are: the condition always goes
|
||||
# away on its own, because the trip commands 0 W everywhere and
|
||||
# a site commanding zero cannot oppose anything. Only a human
|
||||
# who has read the banner can say the site is clear.
|
||||
#
|
||||
# ⚠️ UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON
|
||||
# THE ROSTER. An install that ran with two inverters, tripped,
|
||||
# and was then reconfigured back to one would otherwise carry
|
||||
# `site_sign_tripped: true` in its file forever, with no roster
|
||||
# that can reach the code which clears it - and would re-arm the
|
||||
# moment a second inverter was configured again.
|
||||
if self.site_sign_wd.tripped:
|
||||
self.log_event("site sign-disagreement failsafe cleared "
|
||||
"by operator")
|
||||
self.site_sign_wd.reset()
|
||||
self.store.set("site_sign_tripped", False)
|
||||
self.store.set("site_sign_reason", "")
|
||||
# SAFETY-31, cleared by the SAME press and for the same reason:
|
||||
# the condition always goes away on its own, because the trip
|
||||
# commands 0 W everywhere and two inverters at zero cannot
|
||||
|
||||
@@ -360,6 +360,29 @@ distinction in DOCS.md and in this comment. Revisit if a THIRD floor appears -
|
||||
a loop-side guard would be the third - at which point the migration buys enough
|
||||
to be worth its cost.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SAFETY-32: ONE `bms_soc_floor` FOR TWO PACKS GRADES ONE AGAINST THE OTHER'S BMS
|
||||
|
||||
`bms_soc_floor` above describes A PACK, not a site, and it was a flat option
|
||||
because there was one pack. At two, two batteries of different models have
|
||||
different cut-outs, and one number for both is wrong in the direction that
|
||||
empties a battery: the SHALLOWER pack (the higher cut-out) is protected by a
|
||||
floor that belongs to the DEEPER one. So it became a roster key - `Pack` carries
|
||||
each unit's own value, `pack_floor_pct()` conjoins it with the site's EMS floor,
|
||||
and validate.py rule 8 grades EVERY unit and NAMES the one it refuses.
|
||||
|
||||
⚠️ `peak_soc_floor` DID NOT MOVE, AND THE RESIDUAL IS RECORDED RATHER THAN
|
||||
HIDDEN. It is one number for the site, so with cut-outs of 10 % and 18 % the
|
||||
operator must set it above 18 and the deeper pack is then withheld above its own
|
||||
need. That half - a per-pack EMS floor - is NOT this card and must not be
|
||||
smuggled in with it; DECISIONS/SAFETY-32-per-pack-bms-floor.md states it as the
|
||||
open question it is.
|
||||
|
||||
⚠️ AND THE MIN() OVER MEASURED SoC IS STILL RIGHT. `Controller.pack_floor_soc`
|
||||
reduces to the emptiest pack and this card does not touch it. What changed is
|
||||
the FLOOR it is graded against, and the addition of a per-pack answer beside the
|
||||
site's one verdict - see `pack_permissions()`.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
|
||||
|
||||
@@ -572,6 +595,110 @@ def discharge_permitted(soc, soc_floor) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pack:
|
||||
"""SAFETY-32. ONE pack's three numbers for the floor question, NAMED.
|
||||
|
||||
`name` the roster name, because a refusal that does not say WHICH pack
|
||||
stopped the site sends an operator to the wrong device - the
|
||||
same defect CFG-04's `unit_caps_indexed()` was written for.
|
||||
`soc` THIS pack's measured state of charge. None when its sensor did
|
||||
not answer, and None is not a full battery.
|
||||
`bms_floor` THIS pack's OWN cut-out percent (`bms_soc_floor`), which is a
|
||||
property of the hardware in the cupboard and NOT of the site.
|
||||
None when the roster entry did not state one - see
|
||||
`pack_floor_pct()` for why that is withheld and not 0 %.
|
||||
|
||||
⚠️ IT CARRIES THE CUT-OUT, NOT THE EMS FLOOR. `peak_soc_floor` is one number
|
||||
for the whole site (the depth a money-saving discharge may never reach) and
|
||||
stays a flat option; `bms_soc_floor` describes a battery and became a roster
|
||||
key here, because two packs of different models have different cut-outs and
|
||||
grading both against one of them is wrong in the direction that empties the
|
||||
shallower one.
|
||||
"""
|
||||
|
||||
name: str
|
||||
soc: float | None
|
||||
bms_floor: float | None
|
||||
|
||||
|
||||
def pack_floor_pct(pack, soc_floor):
|
||||
"""The percent THIS pack may not be spent below, or None if that is unknown.
|
||||
|
||||
`max(the site's EMS floor, this pack's own cut-out)` - the CONJUNCTION, and
|
||||
the direction matters: the EMS floor is what the operator asked for and the
|
||||
cut-out is what the hardware will do anyway, so a pack is protected by
|
||||
whichever is higher. Taking the site floor alone is the shipped defect this
|
||||
card removes (the shallower pack graded against the deeper one's cut-out);
|
||||
taking the cut-out alone would throw SAFETY-24's margin away.
|
||||
|
||||
⚠️ AN UNSTATED CUT-OUT IS None, NOT ZERO, AND NOT THE FLAT OPTION. 0 % would
|
||||
be a floor that permits everything - the most permissive value there is,
|
||||
invented from an absence - and borrowing the flat `bms_soc_floor` is CFG-04's
|
||||
"nothing is inherited" broken in one line, which is this card's whole subject
|
||||
restated. `discharge_permitted()` already treats a None floor as NO
|
||||
permission, so an entry that forgot the key costs a shave rather than a pack.
|
||||
|
||||
⚠️ AND UNDER A rule-8-VALID CONFIG THIS IS `soc_floor`, FOR EVERY PACK.
|
||||
validate.py rule 8 refuses `peak_soc_floor <= bms_soc_floor` on EVERY unit,
|
||||
so `max()` selects the EMS floor everywhere the add-on will actually start -
|
||||
which is exactly why the reduction is written here rather than assumed at the
|
||||
call site. "Another rule refuses that config" is a claim about today's rule
|
||||
ordering, not a property of this function, and `validate_startup_config()` is
|
||||
also `sim/inject.py`'s entry point. `unit_caps_indexed()`'s docstring records
|
||||
the same argument, and CFG-04's index desynchronisation is what happened the
|
||||
last time it was left implicit.
|
||||
"""
|
||||
if soc_floor is None or pack.bms_floor is None:
|
||||
return None
|
||||
return max(float(soc_floor), float(pack.bms_floor))
|
||||
|
||||
|
||||
def pack_permissions(packs, soc_floor) -> list[tuple[str, str | None]]:
|
||||
"""SAFETY-32. Per pack: None if it may be spent, else the reason it may not.
|
||||
|
||||
ONE `(name, reason)` pair per pack, in roster order, from the SAME
|
||||
`discharge_permitted()` the site verdict uses - a second transcription of
|
||||
"may this pack be spent" is a second answer to it, which is the argument
|
||||
that made that predicate public in the first place.
|
||||
|
||||
⚠️ THIS IS THE OBSERVABLE THE MIN() CANNOT PRODUCE. `Controller.pack_floor_soc`
|
||||
reduces the measured SoC to the emptiest pack BEFORE anything is graded, and
|
||||
that reduction is right for the site's one verdict - but it has thrown away
|
||||
which pack it came from and how far that pack was from ITS OWN floor by the
|
||||
time the answer exists. Two packs at 15 % against floors of 10 % and 20 %
|
||||
have opposite answers and are indistinguishable after the `min()`.
|
||||
"""
|
||||
return [(p.name, discharge_permitted(p.soc, pack_floor_pct(p, soc_floor)))
|
||||
for p in packs]
|
||||
|
||||
|
||||
def binding_soc_floor(packs, soc_floor):
|
||||
"""The ONE floor `decide()` may grade `pack_floor_soc` against, at a roster.
|
||||
|
||||
MAXIMUM over the packs' own floors, and it is the conservative reduction that
|
||||
pairs with the MINIMUM `pack_floor_soc` reduction: `min(soc) <= max(floor)`
|
||||
is implied by "some pack is at or below its own floor", so this verdict is
|
||||
never more permissive than grading each pack separately would be. The other
|
||||
direction is not true and is not claimed - a site can be refused here while
|
||||
every pack individually has headroom, which costs a shave and never a pack.
|
||||
|
||||
None when ANY pack's floor is unknown, for `Controller.site_dev_bound()`'s
|
||||
reason and in the same direction: `discharge_permitted()` treats a None floor
|
||||
as no permission, so an unknown floor withholds rather than defaults.
|
||||
|
||||
⚠️ AT N=1 THIS IS `soc_floor` ITSELF under any config rule 8 accepts, and
|
||||
test_peakguard.py part 12 asserts that over the whole schema range rather
|
||||
than assuming it.
|
||||
"""
|
||||
if not packs:
|
||||
return None if soc_floor is None else float(soc_floor)
|
||||
floors = [pack_floor_pct(p, soc_floor) for p in packs]
|
||||
if any(f is None for f in floors):
|
||||
return None
|
||||
return max(floors)
|
||||
|
||||
|
||||
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||
|
||||
@@ -71,6 +71,24 @@ _LOG = logging.getLogger("goodwe.store")
|
||||
# the card is gone while the fault runs on - exactly how the 2026-09-02 outage
|
||||
# went unseen for 6 h 25 m with the detector correct throughout. main.py
|
||||
# re-raises the notification from this key on every start.
|
||||
#
|
||||
# ⚠️ AND SAFETY-30's PAIR IS FLAT FOR THE SAME REASON SAFETY-31's IS, WHICH IS
|
||||
# NOT THE SAME REASON THE ONES ABOVE ARE NAMESPACED. `site_sign_tripped` /
|
||||
# `site_sign_reason` hold the SITE sign-disagreement latch: the SUM of every
|
||||
# standing command opposed the meter, continuously. That is a statement about
|
||||
# the grid connection - the offending number is the site target, which no single
|
||||
# inverter owns - so there is no unit to hang it on, and hanging it on one would
|
||||
# let an operator clear "inverter B's half" of a fault that has no halves.
|
||||
# `unit/<name>/sign_tripped` above stays what it always was and now means what
|
||||
# it says: THAT unit's own command against THAT unit's own measured power.
|
||||
#
|
||||
# ⚠️ AND THEY ARE NOT IN `LEGACY_UNIT_KEYS`, MUST NEVER BE, AND ARE NOT WRITTEN
|
||||
# AT A ROSTER OF ONE AT ALL. At N=1 the site comparison and the unit comparison
|
||||
# are the same sentence and `Controller.cycle()` arms only the per-unit
|
||||
# detector, so a one-inverter install's state file is byte-identical to what it
|
||||
# was before this card - these two keys sit at their defaults and nothing reads
|
||||
# them. A rollback therefore finds exactly the flat `sign_tripped` mirror it
|
||||
# always found.
|
||||
DEFAULTS = {
|
||||
"phase": "idle",
|
||||
"phase_started": None,
|
||||
@@ -80,6 +98,8 @@ DEFAULTS = {
|
||||
"sign_reason": "",
|
||||
"circ_tripped": False,
|
||||
"circ_reason": "",
|
||||
"site_sign_tripped": False,
|
||||
"site_sign_reason": "",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -74,7 +74,8 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
was overruled. That firmware file's own comment says a lower bound on this
|
||||
"belongs in CFG-01, not here" - so here it is.
|
||||
|
||||
5. `sign_disagree_w` at or above `max_w`. [(a)]
|
||||
5. `sign_disagree_w` - or SAFETY-30's `unit_sign_disagree_w` - at or above
|
||||
`max_w`. [(a)]
|
||||
The arbiter's `supervised` limit bounds every command to +-max_w, and
|
||||
SAFETY-02's predicate needs `abs(command) > sign_disagree_w`. Set the
|
||||
threshold at or above the ceiling and the command side of the comparison
|
||||
@@ -131,7 +132,7 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
installed base is on a fixed or static tariff. Refusing to start without a
|
||||
price feed would lock those sites out of the add-on entirely.
|
||||
|
||||
8. `peak_soc_floor` at or below `bms_soc_floor`. [(a)]
|
||||
8. `peak_soc_floor` at or below `bms_soc_floor`, ON ANY UNIT. [(a)]
|
||||
SAFETY-24, and the same shape as 1, 4, 5 and 6. The peak guard can COMMAND
|
||||
a discharge to hold a billed quarter down (SAFETY-07), and `peak_soc_floor`
|
||||
is the EMS-side bound on how far down it may take the pack. The BMS has its
|
||||
@@ -141,6 +142,12 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
of binding while its status page reports one - rule 5's "a disarmed guard
|
||||
that advertises itself as armed", on the guard whose failure mode is a pack
|
||||
that is quietly never available when it is wanted.
|
||||
⚠️ SAFETY-32: `bms_soc_floor` DESCRIBES A BATTERY, so at a roster it is a
|
||||
per-entry key and this rule grades EVERY unit and NAMES the one it refuses.
|
||||
One number for two packs of different models refuses the wrong config and
|
||||
accepts the wrong one - cut-outs of 10 % and 30 % under `peak_soc_floor: 20`
|
||||
used to start. A missing per-entry cut-out is refused here rather than
|
||||
defaulted: a cut-out invented for a pack IS this defect.
|
||||
|
||||
⚠️ `<=`, NOT `<`, and the equality case is the interesting one. At exactly
|
||||
equal, the EMS floor and the pack's cut-out act at the same percent, so the
|
||||
@@ -234,6 +241,7 @@ from .p1 import is_enabled, validate_meter_options
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
|
||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||
from .watchdog import SIGN_OPTION_DEFAULTS
|
||||
|
||||
_LOG = logging.getLogger("goodwe.config")
|
||||
|
||||
@@ -384,6 +392,49 @@ def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
|
||||
return caps
|
||||
|
||||
|
||||
def unit_bms_floors_indexed(opts: dict) -> list[tuple[int, object, object]]:
|
||||
"""SAFETY-32. Each pack's OWN `bms_soc_floor`, carrying its ROSTER INDEX.
|
||||
|
||||
`(roster index, the raw value as configured, the parsed float or None)`, one
|
||||
triple per unit. The raw value travels too because the refusal quotes what
|
||||
the operator actually wrote, and `!r` of a re-read `opts` key is the wrong
|
||||
string the moment the value came off a roster entry.
|
||||
|
||||
⚠️ THE INDEX IS THE ROSTER'S, NOT THIS LIST'S, AND THAT IS CFG-04'S BUG
|
||||
WRITTEN DOWN. Rules 5 and 6 fed a CAPS-list index into `_unit_label()`,
|
||||
which indexes the ROSTER, so on a roster whose first entry was malformed the
|
||||
refusal named the WRONG INVERTER - an operator sent to the wrong device by
|
||||
the very message that exists to stop that. `unit_caps_indexed()` exists
|
||||
because of it. This function goes one step further and SKIPS NOTHING: every
|
||||
entry produces a triple, including the ones with no usable floor, because a
|
||||
missing cut-out is itself what rule 8 has to refuse BY NAME. There is
|
||||
therefore no second list for an index to desynchronise from.
|
||||
|
||||
⚠️ AND NOTHING IS INHERITED. At a declared roster the FLAT `bms_soc_floor` is
|
||||
not read at all - CFG-04's rule, and this card's subject: a pack that borrows
|
||||
a sibling's cut-out is graded against a battery it is not. At the flat shape
|
||||
the flat key IS this one pack's cut-out, defaulted from CFG-03's single
|
||||
mirror exactly as `inverter._flat_bms_floor()` defaults it.
|
||||
"""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
return [(0, opts.get("bms_soc_floor"),
|
||||
_number(opts, "bms_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["bms_soc_floor"]))]
|
||||
out: list[tuple[int, object, object]] = []
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
# Rule 9(a)/(b) already refuses a non-dict entry by index, and it
|
||||
# runs FIRST. Naming it twice would hand the operator two sentences
|
||||
# about one line of YAML.
|
||||
continue
|
||||
raw = entry.get("bms_soc_floor")
|
||||
out.append((index, raw,
|
||||
None if raw is None or raw == ""
|
||||
else _number(entry, "bms_soc_floor", None)))
|
||||
return out
|
||||
|
||||
|
||||
def _unit_label(opts: dict, index: int) -> str:
|
||||
""""" at a single-inverter config, " (inverter-b)" at a roster.
|
||||
|
||||
@@ -741,6 +792,38 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
f"sign_disagree_w below {weakest:.0f} W (300 W is the measured "
|
||||
"default), or raise max_w.")
|
||||
|
||||
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
|
||||
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
|
||||
# unit's own SHARE against that unit's own measured power, and a share is
|
||||
# bounded by the same `supervised` claim at +-max_w - so a threshold at or
|
||||
# above the ceiling makes the command side unsatisfiable by construction,
|
||||
# exactly as above. Written as its own branch rather than folded into the
|
||||
# one above because the two produce different sentences and an operator
|
||||
# needs to be told WHICH watchdog they have disarmed.
|
||||
#
|
||||
# ⚠️ REFUSED EVEN AT A ROSTER OF ONE, WHERE THIS CHECK IS NOT ARMED. A
|
||||
# config that cannot work is refused when it is WRITTEN, not on the day a
|
||||
# second inverter is added - which is the day nobody is watching the startup
|
||||
# log. This is `meter_max_age_s`'s rule read the same way: the failure it
|
||||
# prevents is silent.
|
||||
unit_sign_w = _number(opts, "unit_sign_disagree_w",
|
||||
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
|
||||
if unit_sign_w is None:
|
||||
problems.append(f"unit_sign_disagree_w "
|
||||
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
|
||||
elif max_w is not None and max_w > 0 and unit_sign_w >= max_w:
|
||||
problems.append(
|
||||
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w is "
|
||||
f"{max_w:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
|
||||
"watchdog can never fire: no inverter is ever commanded past max_w, "
|
||||
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
|
||||
"inverters that is the guard which catches a unit doing the "
|
||||
"OPPOSITE of what it was told while its sibling covers for it at "
|
||||
"the meter - the case the site-level watchdog is structurally "
|
||||
f"blind to. Lower unit_sign_disagree_w below {max_w:.0f} W "
|
||||
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the "
|
||||
"measured default), or raise max_w.")
|
||||
|
||||
# --- 6. a maintenance charge the ceiling can actually accommodate --------
|
||||
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
|
||||
# `Claim.limit(hi=-charge_w)` against `supervised`'s `lo=-max_w`, so
|
||||
@@ -814,28 +897,39 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# in-code mirror, pinned to config.yaml by test_peakguard.py, and main.py
|
||||
# reads the same one - exactly as maintenance.MAINT_OPTION_DEFAULTS now is
|
||||
# for the `maintenance_*` family.
|
||||
#
|
||||
# ⚠️ SAFETY-32 - GRADED ONCE PER UNIT, AND THE REFUSAL NAMES THE UNIT. One
|
||||
# `bms_soc_floor` for two packs compares the EMS floor against whichever
|
||||
# battery happened to be written into the flat key: a site with cut-outs of
|
||||
# 10 % and 30 % and `peak_soc_floor: 20` STARTED, and the guard would then
|
||||
# command a forced discharge that the second pack's BMS - not the operator's
|
||||
# floor - is the thing that stops. That is the disarmed-backstop state this
|
||||
# rule exists to refuse, reached through the rule itself. So the loop is over
|
||||
# units, and `_unit_label()` is fed the ROSTER index the value came off (see
|
||||
# `unit_bms_floors_indexed()`), because CFG-04 has just fixed the version of
|
||||
# this that named the wrong inverter.
|
||||
soc_floor = _number(opts, "peak_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
|
||||
bms_floor = _number(opts, "bms_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["bms_soc_floor"])
|
||||
if soc_floor is None or bms_floor is None:
|
||||
problems.append(
|
||||
"peak_soc_floor and bms_soc_floor must both be numbers (got "
|
||||
f"{opts.get('peak_soc_floor')!r} / {opts.get('bms_soc_floor')!r})")
|
||||
elif soc_floor <= bms_floor:
|
||||
problems.append(
|
||||
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
|
||||
f"(bms_soc_floor) is {bms_floor:.0f} %, so SAFETY-07's forced "
|
||||
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
|
||||
"the pack's protection rather than yours. The guard would report a "
|
||||
"SoC floor on its status page while being incapable of ever being "
|
||||
"the thing that stops the discharge, and the symptom of that is a "
|
||||
"battery repeatedly sitting at the BMS limit with no record of why "
|
||||
f"- which looks exactly like the SoC drift the maintenance cycle "
|
||||
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
|
||||
f"(the shipped default is "
|
||||
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
|
||||
"bms_soc_floor if it does not match this pack.")
|
||||
for _ix, _raw, bms_floor in unit_bms_floors_indexed(opts):
|
||||
where = _unit_label(opts, _ix)
|
||||
if soc_floor is None or bms_floor is None:
|
||||
problems.append(
|
||||
f"peak_soc_floor and bms_soc_floor{where} must both be numbers "
|
||||
f"(got {opts.get('peak_soc_floor')!r} / {_raw!r})")
|
||||
elif soc_floor <= bms_floor:
|
||||
problems.append(
|
||||
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
|
||||
f"(bms_soc_floor{where}) is {bms_floor:.0f} %, so SAFETY-07's forced "
|
||||
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
|
||||
"the pack's protection rather than yours. The guard would report a "
|
||||
"SoC floor on its status page while being incapable of ever being "
|
||||
"the thing that stops the discharge, and the symptom of that is a "
|
||||
"battery repeatedly sitting at the BMS limit with no record of why "
|
||||
f"- which looks exactly like the SoC drift the maintenance cycle "
|
||||
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
|
||||
f"(the shipped default is "
|
||||
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
|
||||
f"bms_soc_floor{where} if it does not match this pack.")
|
||||
|
||||
# --- warnings: real, but not worth refusing over -------------------------
|
||||
integ = opts.get("integrator_max_w")
|
||||
|
||||
@@ -164,11 +164,106 @@ saturation freeze's question (|command - the battery's actual power| > 500 W,
|
||||
NOTES.md §4.7 row 4), and the two checks are complementary rather than
|
||||
overlapping. Neither substitutes for the other.
|
||||
|
||||
⚠️ THAT PARAGRAPH IS ABOUT THE COMMAND-VERSUS-METER COMPARISON, AND SINCE
|
||||
SAFETY-30 THAT IS NOT THE ONLY ONE THIS MODULE DRIVES. At a roster of two or
|
||||
more each unit is ALSO graded against its own measured power, which is exactly
|
||||
the comparison the paragraph says nothing here makes - see the SAFETY-30 section
|
||||
below, which says what that does and does not take away from the freeze. At the
|
||||
shipped roster of one the paragraph stands unchanged, word for word: the site
|
||||
comparison is the only detector armed and the freeze is still the whole answer.
|
||||
|
||||
Nor does it see a sign inversion introduced DOWNSTREAM of this add-on - in the
|
||||
firmware, on the wire, or in a future driver. Our view stays self-consistent and
|
||||
this stays silent. That is a wire-versus-intent problem and it belongs to the
|
||||
witness, which is the only vantage point that sees both.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SAFETY-30 - AT TWO INVERTERS THIS IS TWO QUESTIONS, NOT ONE
|
||||
|
||||
Everything above was written at a roster of ONE, and at N=1 it is right for a
|
||||
reason that stops being true at N=2. `commanded_w` is what THIS controller put
|
||||
on THIS actuator. `grid_w` is what the WHOLE SITE is exchanging with the
|
||||
network. At one inverter those are two views of one thing - §6.2's identity -
|
||||
which is what makes comparing them sound. At two they are not:
|
||||
|
||||
* A FALSE TRIP THE COMPARISON CANNOT AVOID. "Inverter A commanded -1500 W
|
||||
while the meter reads +1500 W" is evidence of nothing at N=2: inverter B may
|
||||
be discharging 3000 W and doing exactly what it was told. No fault anywhere,
|
||||
and the check NOTES.md calls the highest-value entry in the safety table -
|
||||
93 % agreement while healthy against 4 % during runaway, four episodes, zero
|
||||
false positives - fires.
|
||||
* THE CONVERSE, WHICH IS WORSE. A unit genuinely driving the wrong way is
|
||||
MASKED, because the site figure NETS. Commanded +1500 W, measured -1500 W,
|
||||
beside a sibling measured at +1500 W: the meter sits exactly where a healthy
|
||||
site leaves it. This is not a threshold that is set too high - the
|
||||
information is destroyed by the summation, and no tuning recovers it.
|
||||
|
||||
So the one comparison becomes two, each asked of the evidence that can answer
|
||||
it, and they are DIFFERENT FAULTS rather than one fault at two scales:
|
||||
|
||||
PER UNIT this unit's standing command against ITS OWN measured power -
|
||||
`Inverter.batt`, which on the live system is holding register
|
||||
0x0518, this inverter's own AC power, per-instance since DEV-02.
|
||||
Conclusion: "THE ACTUATOR I AM DRIVING IS NOT DOING WHAT I TOLD
|
||||
IT". The evidence is about one unit, so the remedy is that unit:
|
||||
the `failsafe` SET, which arbiter.CLAIM_SCOPE has called a UNIT
|
||||
claim from the day it was written, for this exact reason.
|
||||
SITE the SUM of the standing commands against the meter. Conclusion:
|
||||
"THE SITE AS A WHOLE IS DRIVING THE WRONG WAY" - an inverted gain,
|
||||
an integrator wound past the rail, a strategy pushing the wrong
|
||||
side of the meter. That is the fault the 2026-08-20 capture
|
||||
contains and it is a property of the CONNECTION, so the remedy is
|
||||
the connection: a SITE SET that stops every inverter.
|
||||
|
||||
⚠️ AND AT N=1 THERE IS EXACTLY ONE ARMED DETECTOR, WHICH IS THE ONE THAT
|
||||
SHIPPED. The two questions above are the SAME SENTENCE at a roster of one: the
|
||||
sum of one command is that command, and "the site" and "the unit" name one
|
||||
actuator. Arming both would be arming a duplicate - two latches, two banners and
|
||||
two chances to spend a false-positive record that was earned by ONE comparison
|
||||
on two captured bus days. `Controller.cycle()` therefore hands the single unit's
|
||||
watchdog THE METER, under the shipped `sign_disagree_w` / `sign_disagree_s`,
|
||||
exactly as before, and never arms the site one. `arm()` below is where that
|
||||
choice is written down. Nothing about a one-inverter install changes - not the
|
||||
comparison, not the thresholds, not the store keys, not the claim, not the
|
||||
banner.
|
||||
|
||||
⚠️ WHAT THE PER-UNIT COMPARISON IS NOT. Two guards that overlap without saying
|
||||
so is how a false-alarm generator gets built, so both neighbours are named:
|
||||
|
||||
* IT IS NOT SAFETY-31. `circulation.py` compares the units AGAINST EACH OTHER
|
||||
- `sum(|B_i|) - |sum(B_i)|` over MEASURED power - and catches two inverters
|
||||
pushing THROUGH each other, which nets to zero at the meter and about which
|
||||
no single command is evidence at all. This compares each unit against ITS
|
||||
OWN COMMAND. Take two units faithfully executing an allocation that ALLOC-01
|
||||
would refuse: circulating power is the whole loop, and every per-unit sign
|
||||
check here reads perfect agreement. Take one unit doing the exact opposite
|
||||
of its share while its sibling covers for it: this reads the whole
|
||||
disagreement, and circulating power can be zero, because the two measured
|
||||
powers may well agree in sign. Neither is the other with a different
|
||||
threshold.
|
||||
* IT IS NOT THE SATURATION FREEZE, AND THE OVERLAP IS REAL AND BOUNDED. The
|
||||
freeze asks `|command - measured| > saturation_w` (500 W) and concludes "the
|
||||
inverter cannot follow"; it then stops the integrator winding, which is a
|
||||
control action taken every day, on a unit that is merely SATURATED as
|
||||
readily as on one that is reversed, and it neither latches nor parks
|
||||
anything. This asks for OPPOSITION - a sign, on both sides of a threshold,
|
||||
held continuously - which a saturated-but-obedient unit cannot produce at
|
||||
all, and it LATCHES and commands that unit to zero. The freeze is a
|
||||
magnitude test that shapes the loop; this is a direction test that stops an
|
||||
actuator.
|
||||
|
||||
⚠️ GATE 1 STILL APPLIES TO BOTH, AND THAT IS A DELIBERATE CHOICE WITH A COST.
|
||||
The per-unit comparison could have been armed whenever ANY non-zero command is
|
||||
standing - a unit should do what it is told whoever told it - and that would
|
||||
catch a wedged inverter during a maintenance charge. It is not, because the
|
||||
whole no-false-positive argument above is built on `following`, because the
|
||||
freeze already grades that window, and because this card's own warning is that
|
||||
SAFETY-02's record was earned at N=1 and must not be spent on a check that fires
|
||||
when nothing is wrong. What it costs is written here rather than discovered:
|
||||
between the start of a maintenance cycle and its end, a unit reversing against
|
||||
its own command is the freeze's to notice, not this module's. Widening it is a
|
||||
card, with a rig campaign behind it, not an edit.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
STATE, TRIP AND LATCH
|
||||
|
||||
@@ -198,6 +293,178 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# SAFETY-30. CFG-03: THE ONE IN-CODE COPY OF THE FOUR MULTI-INVERTER DEFAULTS.
|
||||
# `config.yaml`'s `options:` block is the source of truth an operator edits and
|
||||
# the Supervisor reads, and `schema:` holds the bounds. This dict exists for the
|
||||
# reason `circulation.CIRC_OPTION_DEFAULTS` and `maintenance.MAINT_OPTION_DEFAULTS`
|
||||
# do - the Dockerfile does not put config.yaml in the image, so the running
|
||||
# add-on cannot read it - and `test_watchdog.py` pins the two together by READING
|
||||
# config.yaml, so a drift goes red in CI rather than shipping.
|
||||
#
|
||||
# ⚠️ EVERY KEY HERE IS DEAD AT A ROSTER OF ONE, and that is the point rather
|
||||
# than a caveat. `Controller._arm_sign_watchdogs()` arms the single unit's
|
||||
# detector against the meter with the SHIPPED `sign_disagree_w` /
|
||||
# `sign_disagree_s`, which are read in main.py exactly where they always were
|
||||
# and are not in this dict. Nothing below is consulted until a second inverter
|
||||
# exists.
|
||||
SIGN_OPTION_DEFAULTS = {
|
||||
# --- THE MAGNITUDE GATE, AND IT IS NOT SAFETY-02's 300 W BY ACCIDENT ---
|
||||
# ⚠️ DERIVED FROM `control.Tuning.saturation_w`, WHICH IS 500 W, AND THE
|
||||
# DERIVATION IS THE POINT. This check gates on BOTH sides being over
|
||||
# `min_w`, so the smallest divergence it can ever see between a command and
|
||||
# the power that command produced is `2 * min_w` - the two are OPPOSED, so
|
||||
# the gap is the sum of the magnitudes. At 250 W that is exactly 500 W: the
|
||||
# gap at which `compute()`'s own anti-windup test already concludes THE
|
||||
# INVERTER IS NOT FOLLOWING and freezes the integrator. So this watchdog
|
||||
# cannot fire on a divergence the shipped control law itself calls ordinary
|
||||
# tracking, and the two guards meet at a number rather than overlapping in a
|
||||
# band nobody has drawn.
|
||||
#
|
||||
# MEASURED, on 1890 closed-loop two-inverter runs in which NOTHING is faulty
|
||||
# (every unit executes the share the allocator gave it), swept over the
|
||||
# WHOLE range config.yaml accepts for `gain` float(0.05,1.0) and `slew_w`
|
||||
# int(50,5000), across both families of stimulus that can produce legitimate
|
||||
# opposition. Worst CONTINUOUS opposition between a unit's standing command
|
||||
# and its own 0x0518 power, as a function of this threshold:
|
||||
#
|
||||
# 50 W -> 25.0 s 250 W -> 10.0 s 700 W -> 5.0 s
|
||||
# 150 W -> 15.0 s 300 W -> 10.0 s 1000 W -> 0.0 s
|
||||
# 200 W -> 15.0 s 500 W -> 5.0 s
|
||||
#
|
||||
# ⚠️ AND THE CURVE IS FLAT, WHICH IS THE FINDING THAT DECIDED THE SHAPE OF
|
||||
# THIS PAIR. Unlike the site comparison - where the meter is driven by the
|
||||
# LOAD and a flapping load holds it opposed for minutes - a unit's own power
|
||||
# CHASES ITS OWN COMMAND through a bounded ramp and a first-order lag, so the
|
||||
# opposition can only last as long as the crossing does. Dropping the
|
||||
# threshold from 300 W to 50 W buys 15 s of extra transient and nothing else.
|
||||
# The work is therefore done by the WINDOW below, and this number is set
|
||||
# where the control law already draws its line rather than being tuned.
|
||||
#
|
||||
# The largest opposed magnitude seen anywhere in those 1890 runs is 875 W;
|
||||
# a threshold above that would gate NOTHING legitimate in at all, and is
|
||||
# rejected because it would also be blind to a unit reversing a 900 W share.
|
||||
"unit_sign_disagree_w": 250,
|
||||
|
||||
# --- THE WINDOW -------------------------------------------------------
|
||||
# Three bounds, and the value clears all three.
|
||||
#
|
||||
# LOWER, from the rig: 10.0 s - two control cycles, the worst CONTINUOUS
|
||||
# legitimate opposition above 250 W across all 1890 runs. It is a
|
||||
# reversing site whose second write lands a cycle after the first, both
|
||||
# units then slewing through zero behind a 10 s lag; nothing is wrong.
|
||||
#
|
||||
# LOWER, from the firmware: `inverter.LINK_STALE_S`, 30 s. A unit not
|
||||
# executing its share for less than the firmware's own veto window is a
|
||||
# LINK fault, and the add-on already answers it - liveness drops the unit
|
||||
# and the survivors carry its share, with the record saying so. Firing
|
||||
# inside that window would raise a second, louder alarm about a state
|
||||
# already being handled, which is the SAFETY-15/SAFETY-19 failure exactly.
|
||||
#
|
||||
# UPPER, from SAFETY-02 itself: 180 s, the SITE hold. A unit that is not
|
||||
# doing what it was told must not be parked LATER than the site would be
|
||||
# parked for the same watts, and unlike the site comparison this one needs
|
||||
# nothing to net out before it can conclude - the evidence is complete on
|
||||
# the first cycle it appears.
|
||||
#
|
||||
# 85 s: seventeen control cycles - 8.5x the worst legitimate episode this
|
||||
# rig can produce, 2.8x the firmware's own veto window, and under HALF the
|
||||
# site hold. ⚠️ IT IS DELIBERATELY NOT 60, 75, 90, 120 OR 180. A round
|
||||
# number here would be a guess wearing a measurement's clothes, and the
|
||||
# three bounds above are what the number has to answer to.
|
||||
#
|
||||
# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST. Its ramp and lag in the rig
|
||||
# are inverter A's, COPIED, carrying `measured=False` - SPIKE-04 measures
|
||||
# the real ones. A slower second unit lengthens every crossing above, which
|
||||
# is what the 8.5x buys headroom for. SPIKE-04 is the revisit trigger for
|
||||
# both numbers here, not a reason to move them now.
|
||||
"unit_sign_disagree_s": 85,
|
||||
|
||||
# --- THE SITE COMPARISON AT N>=2, AND IT IS NOT THE SHIPPED PAIR -------
|
||||
# ⚠️ THE THRESHOLD IS CARRIED AND THE WINDOW IS NOT, WHICH IS A FINDING
|
||||
# RATHER THAN A COMPROMISE. Only one of the two arguments behind 300 W /
|
||||
# 180 s depends on how many inverters the site has:
|
||||
#
|
||||
# THE THRESHOLD is about NOISE. "Near zero the sign is noise, and near
|
||||
# zero is where a healthy loop LIVES" - the deadband holds the command
|
||||
# still while the meter wanders either side of `target_grid_w`. The
|
||||
# deadband is 15 W and P1 is one meter whatever the roster, so nothing
|
||||
# about that argument moves. 300 W, carried - and the two-inverter
|
||||
# campaign says the carry is safe rather than merely convenient: in the
|
||||
# load-reversal family the site figure ever goes opposed AND over 300 W in
|
||||
# 994 of 1050 runs at two inverters and 955 of 1050 at one. The gate opens
|
||||
# about as often either way; what changed is how LONG it stays open, which
|
||||
# is the window's problem and not the threshold's.
|
||||
#
|
||||
# THE WINDOW is about a TRANSIENT, and the transient scales with the site.
|
||||
# The measured worst legitimate case is a load reversal that the loop
|
||||
# crosses zero slowly: the command has to travel the whole rail at
|
||||
# `slew_w` per cycle, and at N>=2 the rail is `Controller.site_max_w` - the
|
||||
# ROSTER SUM - while `slew_w` is unchanged. So the crossing takes about
|
||||
# twice as long at two inverters as at one, and that is exactly what the
|
||||
# rig measures.
|
||||
#
|
||||
# 1890 closed-loop runs in which nothing is faulty, the SAME campaign the
|
||||
# per-unit pair above is read off, run at BOTH rosters as its own control.
|
||||
# Worst CONTINUOUS opposition between the standing command(s) and the meter,
|
||||
# LOAD-REVERSAL family, at a 300 W threshold:
|
||||
#
|
||||
# N=1 (the shipped comparison) 110.0 s - inside 180 s, 1.6x
|
||||
# N=2 (the sum, same stimuli) 215.0 s - OVER 180 s
|
||||
#
|
||||
# ⚠️ SO CARRYING 180 s WOULD HAVE SHIPPED A FALSE TRIP, and it is a config a
|
||||
# schema-legal install can reach: `gain` 0.05 and `slew_w` 50 are both the
|
||||
# floor of what config.yaml accepts, and a +-2200 W load reversal is an oven
|
||||
# and a heat pump. That is the number this card exists to not assume.
|
||||
#
|
||||
# 325 s: 1.51x the measured worst, well inside the 900 s quarter that bills
|
||||
# the capacity tariff, and still inside every episode the fault day
|
||||
# contains - 2026-08-20's four clusters run 16, 30, 77 and 77 minutes and
|
||||
# its opposed runs reach 1750 s, so a 325 s window fires in all of them.
|
||||
# What it costs is stated rather than hidden: about two and a half extra
|
||||
# minutes of wrong-direction SITE power before the stop, bought to remove a
|
||||
# false trip that a legal configuration produces. ⚠️ DELIBERATELY NOT 300 OR
|
||||
# 360; the measurement is what it answers to.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT THE PER-UNIT WINDOW EITHER, IN THE OTHER DIRECTION. The
|
||||
# per-unit comparison is 85 s because a unit's own power CHASES ITS OWN
|
||||
# COMMAND and the transient is two cycles; the site comparison is 325 s
|
||||
# because the meter is driven by the LOAD and the transient is the loop
|
||||
# crossing zero. Same predicate, two instruments, two windows, both measured.
|
||||
"site_sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
}
|
||||
|
||||
# ⚠️ A LIMIT OF THE SITE COMPARISON THAT SAFETY-30 DID NOT INTRODUCE AND DOES
|
||||
# NOT FIX, RECORDED HERE BECAUSE THE CAMPAIGN FOUND IT AND SILENCE WOULD BE
|
||||
# WORSE. The rig's other stimulus family is a FLAPPING LOAD, and a square wave
|
||||
# whose half period is about one control cycle opposes the command on EVERY
|
||||
# cycle: the command chases the previous half-cycle's meter while the meter has
|
||||
# already reversed. Across the 840 flapping runs the worst continuous opposition
|
||||
# at a 300 W threshold is 980 s at two inverters - and 1000 s at ONE, which is
|
||||
# the whole point. The N=1 arm is the control, and it says this is a property of
|
||||
# comparing a lagged command against a meter, not a property of the second
|
||||
# inverter. No window inside the 900 s billing quarter survives it, and no
|
||||
# threshold up to 1000 W does either.
|
||||
#
|
||||
# It is not acted on here for three reasons, in order of weight: it is identical
|
||||
# at the roster this add-on actually ships, so acting on it would be changing a
|
||||
# live safety path on evidence that has nothing to do with this card; a
|
||||
# ~6 kW square wave at 0.1 Hz sustained for a quarter of an hour is not a house
|
||||
# (the captured healthy day's longest opposed run is 30 s); and the fix is not a
|
||||
# number - it wants the detector to notice that the disagreement is ALTERNATING,
|
||||
# which is a different check. It is a card, and this comment is its evidence.
|
||||
|
||||
# SAFETY-30. THE TWO NOUNS THE TRIP SENTENCE CAN NAME, WRITTEN ONCE. They are
|
||||
# constants rather than literals at the call sites because `main.py` arms the
|
||||
# detectors and `test_watchdog.py` grades what they say, and a string typed in
|
||||
# two places is a string that drifts in one of them - the same argument
|
||||
# `store.unit_key()` makes about a key name. `AGAINST_METER` is the wording that
|
||||
# has always shipped and must not change: it is what a one-inverter install's
|
||||
# `reason` reads, in the store, in the action log and on the status page.
|
||||
AGAINST_METER = "a meter reading"
|
||||
AGAINST_OWN_POWER = "its own measured power"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignWatchdogConfig:
|
||||
"""Defaults, not constants - config.yaml carries both (SAFETY-02 AC 5)."""
|
||||
@@ -206,7 +473,7 @@ class SignWatchdogConfig:
|
||||
hold_s: float = 180.0
|
||||
|
||||
|
||||
def disagrees(commanded_w, grid_w, following: bool,
|
||||
def disagrees(commanded_w, against_w, following: bool,
|
||||
cfg: SignWatchdogConfig) -> bool:
|
||||
"""The predicate, and the ONLY place it is written down.
|
||||
|
||||
@@ -215,12 +482,23 @@ def disagrees(commanded_w, grid_w, following: bool,
|
||||
inline. Two copies of a safety predicate is one copy plus a future
|
||||
disagreement about what the check means, and the copy that drifts is always
|
||||
the one in the harness that grades it.
|
||||
|
||||
⚠️ `against_w` WAS CALLED `grid_w`, AND THE RENAME IS THE WHOLE OF SAFETY-30
|
||||
IN ONE PARAMETER. The arithmetic is unchanged - opposed signs, neither side
|
||||
near zero, and the loop actually driving - but WHAT the command is opposed
|
||||
TO is now a property of the caller rather than a fact about this function.
|
||||
The site comparison hands it the meter; each unit's own comparison hands it
|
||||
that unit's measured power (register 0x0518). One predicate, two callers,
|
||||
for `loop_is_following()`'s reason: a safety predicate written down twice is
|
||||
one copy plus a future disagreement about what the check means. The
|
||||
parameter is positional at every call site in both repositories, so the
|
||||
rename moves no caller.
|
||||
"""
|
||||
return (following
|
||||
and commanded_w is not None and grid_w is not None
|
||||
and commanded_w * grid_w < 0
|
||||
and commanded_w is not None and against_w is not None
|
||||
and commanded_w * against_w < 0
|
||||
and abs(commanded_w) > cfg.min_w
|
||||
and abs(grid_w) > cfg.min_w)
|
||||
and abs(against_w) > cfg.min_w)
|
||||
|
||||
|
||||
def loop_is_following(resolution, loop_asked) -> bool:
|
||||
@@ -272,7 +550,17 @@ def loop_is_following(resolution, loop_asked) -> bool:
|
||||
|
||||
|
||||
class SignWatchdog:
|
||||
"""One instance per actuator, evaluated once per control cycle.
|
||||
"""One instance per COMPARISON, evaluated once per control cycle.
|
||||
|
||||
⚠️ SINCE SAFETY-30 THAT IS NOT THE SAME SENTENCE AS "one per actuator",
|
||||
which is what this docstring used to say. There is one instance per
|
||||
inverter - `Inverter.sign_wd`, latched under that unit's own store keys -
|
||||
AND one for the site, `Controller.site_sign_wd`, latched under flat ones.
|
||||
The two ask different questions of different evidence and reach different
|
||||
remedies; `arm()` below is where an instance is told which of them it is.
|
||||
At a roster of one they would be the same question, so only the per-unit
|
||||
instance is ever armed there - see the SAFETY-30 section of the module
|
||||
docstring.
|
||||
|
||||
⚠️ NOT SHARED ACROSS CADENCES. `disagree_since` is a wall-clock start time,
|
||||
so driving one instance from two loops at two rates would have each one
|
||||
@@ -282,8 +570,14 @@ class SignWatchdog:
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: SignWatchdogConfig | None = None, *,
|
||||
tripped: bool = False, reason: str = ""):
|
||||
tripped: bool = False, reason: str = "",
|
||||
against: str = AGAINST_METER):
|
||||
self.cfg = cfg or SignWatchdogConfig()
|
||||
# SAFETY-30. WHAT THE COMMAND IS BEING COMPARED AGAINST, as the operator
|
||||
# reads it in the trip sentence. The default is the meter, which is what
|
||||
# every trip this add-on has ever taken was compared against and is what
|
||||
# keeps a one-inverter install's `reason` string byte-identical.
|
||||
self.against = str(against)
|
||||
self.disagree_since: datetime | None = None
|
||||
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED. A latch that lives only in
|
||||
# memory is not a latch: a container restart clears it, and the add-on
|
||||
@@ -292,8 +586,31 @@ class SignWatchdog:
|
||||
self.tripped = bool(tripped)
|
||||
self.reason = reason if self.tripped else ""
|
||||
|
||||
# -- which of SAFETY-30's two questions this instance is asking ----------
|
||||
def arm(self, cfg: SignWatchdogConfig, against: str) -> None:
|
||||
"""Set the thresholds and the subject of the comparison. Nothing else.
|
||||
|
||||
⚠️ IT IS CONFIGURATION, NOT STATE, AND IT DELIBERATELY DOES NOT TOUCH
|
||||
`disagree_since`, `tripped` OR `reason`. A detector that cleared its own
|
||||
window when it was told what it was comparing against would lose the
|
||||
episode it is in the middle of, every cycle, and would never accumulate
|
||||
a hold at all - the check would be permanently green and nothing would
|
||||
say so.
|
||||
|
||||
⚠️ AND IT IS RE-STATED EVERY CYCLE RATHER THAN FROZEN AT CONSTRUCTION.
|
||||
The answer is a property of the ROSTER and of nothing else, and
|
||||
`Controller.cycle()` already re-reads the roster every cycle for
|
||||
liveness and for the allocation. Freezing it in `__init__` would also
|
||||
make it wrong for any caller that appends an inverter afterwards, which
|
||||
is exactly how a two-inverter fixture is built in this repository - and
|
||||
a fixture that silently got the one-inverter comparison would grade
|
||||
nothing while looking green.
|
||||
"""
|
||||
self.cfg = cfg
|
||||
self.against = str(against)
|
||||
|
||||
# -- the whole check, and it is a pure function of its arguments ----------
|
||||
def evaluate(self, commanded_w, grid_w, following: bool,
|
||||
def evaluate(self, commanded_w, against_w, following: bool,
|
||||
now: datetime) -> bool:
|
||||
"""Advance the detector one cycle. Returns True while tripped.
|
||||
|
||||
@@ -301,15 +618,23 @@ class SignWatchdog:
|
||||
after deadband, slew, quantisation and the arbiter. Not
|
||||
the optimizer's raw wish: the wire carries the emitted
|
||||
value, so anything else can disagree with the meter for a
|
||||
reason that is not a fault.
|
||||
`grid_w` net P1, + = importing. None when the meter is stale or
|
||||
missing, which CLEARS the timer rather than accumulating
|
||||
- a frozen figure held against a moving command would
|
||||
otherwise manufacture a disagreement in about three
|
||||
minutes, on top of SAFETY-01 having already commanded
|
||||
zero for the same cause.
|
||||
reason that is not a fault. For a per-unit instance it is
|
||||
THAT UNIT'S share of it; for the site instance it is the
|
||||
roster sum.
|
||||
`against_w` what the command is graded against, and SAFETY-30 made
|
||||
this a decision rather than a fact: net P1 for the SITE
|
||||
comparison (+ = importing), and this unit's own measured
|
||||
power for the PER-UNIT one (+ = discharging, register
|
||||
0x0518). Both carry the same sign convention as the
|
||||
command, which is why one predicate serves both. None
|
||||
when the reading is stale or missing, which CLEARS the
|
||||
timer rather than accumulating - a frozen figure held
|
||||
against a moving command would otherwise manufacture a
|
||||
disagreement in about three minutes, on top of SAFETY-01
|
||||
having already commanded zero for the same cause.
|
||||
`following` did the grid-following loop actually win this cycle? See
|
||||
gate 1 in the module docstring.
|
||||
gate 1 in the module docstring, and the SAFETY-30 section
|
||||
on why the per-unit comparison keeps it.
|
||||
`now` the caller's clock, so this stays testable and replayable.
|
||||
"""
|
||||
if self.tripped:
|
||||
@@ -317,7 +642,7 @@ class SignWatchdog:
|
||||
return True
|
||||
|
||||
cfg = self.cfg
|
||||
if not disagrees(commanded_w, grid_w, following, cfg):
|
||||
if not disagrees(commanded_w, against_w, following, cfg):
|
||||
self.disagree_since = None
|
||||
return False
|
||||
|
||||
@@ -330,9 +655,17 @@ class SignWatchdog:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
# ⚠️ `self.against` IS THE ONLY THING SAFETY-30 CHANGED IN THIS STRING,
|
||||
# and it defaults to the wording that shipped - "a meter reading" - so a
|
||||
# one-inverter install's `reason` is byte-identical to what an operator
|
||||
# (and test_watchdog.py) has always read. A per-unit instance names what
|
||||
# it actually compared instead, because "commanded +1500 W against a
|
||||
# meter reading -1500 W" would be a false statement about which
|
||||
# instrument produced the evidence, on the one line somebody woken by
|
||||
# this alarm reads first.
|
||||
self.reason = (
|
||||
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against a meter "
|
||||
f"reading {grid_w:+.0f} W for {held:.0f} s continuously "
|
||||
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against "
|
||||
f"{self.against} {against_w:+.0f} W for {held:.0f} s continuously "
|
||||
f"(limits: both over {cfg.min_w:.0f} W, held over {cfg.hold_s:.0f} s)"
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -56,23 +56,41 @@ options:
|
||||
# soc_entity: sensor.goodwe_a_soc
|
||||
# batt_entity: sensor.goodwe_a_battery_power
|
||||
# max_w: 5000
|
||||
# bms_soc_floor: 10
|
||||
# - 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: 3000
|
||||
# bms_soc_floor: 10
|
||||
#
|
||||
# ⚠️ THE SECOND ENTRY ABOVE IS AN EXAMPLE, NOT A MEASUREMENT. No second
|
||||
# inverter exists on this site yet; its 3000 W rating and its 10 % cut-out are
|
||||
# placeholders in a comment, and the cut-out in particular must be read off the
|
||||
# pack that is actually installed (register 0x0560 on the reference unit)
|
||||
# before that pack is commanded.
|
||||
#
|
||||
# ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and
|
||||
# that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the
|
||||
# rating became per-instance precisely so that no inverter is ever graded
|
||||
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
|
||||
# rebuild that defect in YAML. `name`, `setpoint_entity` and `max_w` are
|
||||
# therefore REQUIRED of every entry (the schema below enforces it, and
|
||||
# rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and
|
||||
# `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and
|
||||
# validate.py rule 9 enforces it again for the callers the Supervisor never
|
||||
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
|
||||
# SENSOR - `inputs_ok` already stops the site for that - and never means
|
||||
# "use the other one's".
|
||||
#
|
||||
# ⚠️ SAFETY-32: `bms_soc_floor` IS PER ENTRY AND IS REQUIRED, NOT DEFAULTED.
|
||||
# It is the percent THAT pack's own BMS cuts out at, and two batteries of
|
||||
# different models do not share one. A default would be this add-on inventing
|
||||
# a cut-out for a battery it has not been told about, which is the same defect
|
||||
# as inheriting one: the shallower pack gets protected by a floor that belongs
|
||||
# to the deeper pack, and validate.py rule 8 - which refuses a peak_soc_floor
|
||||
# the BMS would win against - would be grading the wrong number. Rule 8 grades
|
||||
# EVERY entry and names the one it refuses. The flat key below is unchanged and
|
||||
# is the single-inverter shape's own cut-out.
|
||||
#
|
||||
# ⚠️ 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
|
||||
@@ -167,6 +185,62 @@ options:
|
||||
sign_disagree_w: 300
|
||||
sign_disagree_s: 180
|
||||
|
||||
# --- SAFETY-30 per-inverter sign-disagreement watchdog ---------------------
|
||||
# ⚠️ A DIFFERENT COMPARISON, NOT A SECOND COPY OF THE ONE ABOVE, AND IT IS
|
||||
# ARMED ONLY WHEN THIS SITE HAS MORE THAN ONE INVERTER. The pair above grades
|
||||
# the SITE's total command against P1 - one meter, one grid connection. This
|
||||
# pair grades EACH inverter's own share against THAT inverter's own measured
|
||||
# power (holding register 0x0518), and it catches the fault the meter cannot
|
||||
# answer for once there are two units: a unit doing the opposite of what it
|
||||
# was told, while its sibling covers for it and P1 reads exactly what a
|
||||
# healthy site reads.
|
||||
#
|
||||
# At ONE inverter these two keys do nothing at all. §6.2's identity makes the
|
||||
# site's total command that inverter's command, so the pair above is already
|
||||
# asking this question of the same actuator, and arming a second detector
|
||||
# would be two latches and two banners for one fault.
|
||||
#
|
||||
# Both defaults are measured on admin/goodwe's two-master rig, over 1890
|
||||
# closed-loop runs in which nothing is faulty, swept across the whole range
|
||||
# this file accepts for `gain` and `slew_w`. 250 W is `saturation_w / 2`, so
|
||||
# the smallest divergence this can see is exactly the gap at which the control
|
||||
# law itself already says the inverter is not following; 85 s is 8.5x the
|
||||
# worst legitimate opposition those runs produce (10.0 s), 2.8x the firmware's
|
||||
# own 30 s link veto, and under half the 180 s the site check takes. See
|
||||
# app/watchdog.py's SIGN_OPTION_DEFAULTS and
|
||||
# DECISIONS/SAFETY-30-per-unit-sign.md.
|
||||
#
|
||||
# ⚠️ unit_sign_disagree_w must stay BELOW max_w, for sign_disagree_w's reason:
|
||||
# a share is bounded to +-max_w, so a threshold at or above it can never fire.
|
||||
# validate.py refuses that config.
|
||||
unit_sign_disagree_w: 250
|
||||
unit_sign_disagree_s: 85
|
||||
|
||||
# --- SAFETY-30 SITE sign-disagreement watchdog -----------------------------
|
||||
# ⚠️ THE SAME COMPARISON `sign_disagree_*` MAKES, AT THE ROSTER WHERE IT STOPS
|
||||
# BEING A STATEMENT ABOUT ONE INVERTER. At two or more units the SUM of the
|
||||
# standing commands is graded against P1, and the trip stops the whole site -
|
||||
# what it catches is the site being asked for the wrong thing, which every
|
||||
# per-inverter check is blind to because each unit is faithfully executing its
|
||||
# share. At ONE inverter these two keys do nothing: `sign_disagree_*` above is
|
||||
# already asking this of the same actuator.
|
||||
#
|
||||
# The threshold is the shipped 300 W, CARRIED, and the reason is that the
|
||||
# argument behind it does not depend on the roster: near zero the sign is
|
||||
# noise, the deadband is 15 W, and there is one P1 whatever is behind it.
|
||||
#
|
||||
# ⚠️ THE WINDOW IS NOT CARRIED, AND THAT IS A MEASUREMENT. The transient 180 s
|
||||
# exists to reject is the loop crossing zero after a load reversal, and the
|
||||
# command has to travel the whole SITE rail at `slew_w` per cycle - a rail
|
||||
# that is the roster sum while `slew_w` is not. On 1890 two-inverter runs in
|
||||
# which nothing is faulty, the worst continuous legitimate opposition at 300 W
|
||||
# is 110 s at one inverter and 215 s at two: carrying 180 s across would have
|
||||
# shipped a false trip reachable at `gain: 0.05`, `slew_w: 50` and an ordinary
|
||||
# 2.2 kW load reversal. 325 s is 1.51x that, inside the 900 s billing quarter,
|
||||
# and still inside every episode of the 2026-08-20 fault day.
|
||||
site_sign_disagree_w: 300
|
||||
site_sign_disagree_s: 325
|
||||
|
||||
# --- SAFETY-18 frozen-but-fresh meter detector -----------------------------
|
||||
# Seconds the meter may keep answering with an UNCHANGED measurement while the
|
||||
# command stands at the rail before the add-on raises an alarm. Both halves
|
||||
@@ -299,6 +373,13 @@ options:
|
||||
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
|
||||
# it: such a floor can never bind, the BMS wins, and the status page would
|
||||
# report an energy backstop that does not exist.
|
||||
#
|
||||
# ⚠️ SAFETY-32 - THIS KEY DESCRIBES ONE PACK, so it is the SINGLE-INVERTER
|
||||
# shape's number and nothing more. Declare an `inverters:` roster and each
|
||||
# entry states its own `bms_soc_floor`; this one is not read at all and is
|
||||
# not inherited by any entry. Rule 8 then grades every unit separately, which
|
||||
# is the whole of that card: one cut-out for two packs of different models
|
||||
# protects the shallower pack with the deeper pack's number.
|
||||
bms_soc_floor: 10
|
||||
|
||||
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
||||
@@ -444,6 +525,7 @@ schema:
|
||||
- name: str
|
||||
setpoint_entity: str
|
||||
max_w: int(100,5000)
|
||||
bms_soc_floor: int(0,50)
|
||||
soc_entity: str?
|
||||
batt_entity: str?
|
||||
batt_invert: bool?
|
||||
@@ -487,6 +569,33 @@ schema:
|
||||
sign_disagree_w: int(50,5000)
|
||||
sign_disagree_s: int(30,1800)
|
||||
|
||||
# SAFETY-30. No "off" value, for SAFETY-02's reason.
|
||||
#
|
||||
# ⚠️ THE THRESHOLD'S RANGE IS THE SAME AS THE SITE CHECK'S ON PURPOSE, AND THE
|
||||
# WINDOW'S FLOOR IS NOT. On the two-inverter rig the per-unit comparison's
|
||||
# worst continuous legitimate opposition is nearly FLAT in the threshold -
|
||||
# 25.0 s at 50 W, 15.0 s at 150 W, 10.0 s at 250 W, 5.0 s at 500 W - because a
|
||||
# unit's own power chases its own command rather than the load. So no value in
|
||||
# this range puts the guard outside the regime it can be answered for, and the
|
||||
# floor does not have to be a knee the way `circulation_w`'s does.
|
||||
unit_sign_disagree_w: int(50,5000)
|
||||
# The floor of 30 is `inverter.LINK_STALE_S`, the firmware's own veto window:
|
||||
# below it, a unit not executing its share is a LINK fault the add-on already
|
||||
# answers by dropping the unit from the split, and firing inside that window
|
||||
# would be a second, louder alarm about a state that is already being handled.
|
||||
unit_sign_disagree_s: int(30,1800)
|
||||
|
||||
# SAFETY-30's SITE pair. Same ranges as `sign_disagree_*`, because it is the
|
||||
# same comparison asked of the site total - and no "off" value, for the same
|
||||
# reason. ⚠️ THERE IS DELIBERATELY NO validate.py RULE PAIRING
|
||||
# site_sign_disagree_w WITH max_w, unlike the other two thresholds: the bound
|
||||
# on the SITE command is `Controller.site_max_w`, the ROSTER SUM, and
|
||||
# `validate_startup_config()` is handed options rather than a roster. A rule
|
||||
# written against max_w would refuse a perfectly reachable two-inverter
|
||||
# configuration the day CFG-04 lands. The schema range is the bound here.
|
||||
site_sign_disagree_w: int(50,5000)
|
||||
site_sign_disagree_s: int(30,1800)
|
||||
|
||||
# No "off" value, for SAFETY-02's reason: disarming a safety alarm is not a
|
||||
# supported configuration. ⚠️ THE FLOOR OF 60 IS NOT A RECOMMENDATION. At 300 s
|
||||
# the slowest a HEALTHY meter's 1 Wh register can tick and still look unchanged
|
||||
|
||||
@@ -165,21 +165,29 @@ SITE_SOURCES = {
|
||||
"identically zero for any one unit - there is no fact of "
|
||||
"the matter about which of two opposing inverters is the "
|
||||
"offender, so there is nothing to file it per-unit on",
|
||||
# SAFETY-30, and it is the entry most easily confused with `failsafe`
|
||||
# below, because the same three words describe both.
|
||||
"sign_site": "the SUM of every standing command against the meter. The "
|
||||
"offending number is the SITE target, which no single "
|
||||
"inverter owns - parking one of them would leave the rest "
|
||||
"pushing the same way with the guard satisfied",
|
||||
}
|
||||
UNIT_SOURCES = {
|
||||
"device": "one inverter's own reported min/max rating",
|
||||
"supervised": "max_w caps what may be WRITTEN to one setpoint entity",
|
||||
"failsafe": "site-wide evidence, per-unit remedy: SAFETY-02 grades the "
|
||||
"command THIS controller put on THIS actuator",
|
||||
"failsafe": "per-unit evidence, per-unit remedy: since SAFETY-30 it "
|
||||
"grades THIS unit's command against THIS unit's OWN "
|
||||
"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",
|
||||
}
|
||||
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 NINE claim sources are classified, and only those nine",
|
||||
check("all TEN claim sources are classified, and only those ten",
|
||||
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
|
||||
check("the five that are about the GRID CONNECTION are site claims",
|
||||
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",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
|
||||
@@ -200,7 +208,7 @@ check("with one inverter every source routes to the site, per-inverter ones "
|
||||
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 "
|
||||
"per-inverter sources route to the unit this controller drives, 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}
|
||||
== {**{s: "inverter-a" for s in UNIT_SOURCES},
|
||||
|
||||
@@ -196,6 +196,16 @@ A_ENT = {"setpoint_entity": "number.sp_a", "soc_entity": "sensor.soc_a",
|
||||
# chosen only because it DIFFERS from A's 5000 W, which is what lets a check
|
||||
# tell B's number from A's. SPIKE-04 measures the real one.
|
||||
B_RATING_HYPOTHESIS = 3000.0
|
||||
# SAFETY-32. Each pack's OWN BMS cut-out percent, now a REQUIRED roster key.
|
||||
# ⚠️ 10 IS MEASURED (register 0x0560 on the reference GW5000S-BP and the shipped
|
||||
# `bms_soc_floor` default); 15 IS SIMULATED - there is no second pack. They
|
||||
# DIFFER on purpose: a fixture whose two per-unit numbers are equal cannot show
|
||||
# a check that read the wrong unit's, which is MUTATION.md habit 1 and the defect
|
||||
# that has now bitten four consecutive cards. Both sit below the shipped
|
||||
# `peak_soc_floor` of 20, so rule 8 accepts this roster.
|
||||
A_BMS_FLOOR = 10
|
||||
B_BMS_FLOOR_SIM = 15
|
||||
|
||||
A_RATING = 5000.0
|
||||
|
||||
BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid",
|
||||
@@ -609,28 +619,40 @@ print("3. AC 3: a trip on B does not latch A, and it survives a restart")
|
||||
# inverters get a share of ONE site target, so an ordinary wrong-way loop trips
|
||||
# BOTH - correctly, because both really are pushing the wrong way. The state
|
||||
# this part is about is the one the flat store keys made unreachable: ONE
|
||||
# inverter standing on a command that opposes the meter while the other does
|
||||
# not. It is reached the way the field would reach it - inverter B's writes are
|
||||
# being REJECTED, so `inv.last_write` stays where it was while A's tracks the
|
||||
# loop - and `standing` is what SAFETY-02 grades, per unit.
|
||||
# inverter standing on a command that opposes what it is graded against while
|
||||
# the other does not. It is reached the way the field would reach it - inverter
|
||||
# B's writes are being REJECTED, so `inv.last_write` stays where it was while
|
||||
# A's tracks the loop - and `standing` is what SAFETY-02 grades, per unit.
|
||||
#
|
||||
# ⚠️ SAFETY-30 CHANGED WHAT "OPPOSES" MEANS HERE, AND THE FIXTURE HAD TO MOVE
|
||||
# WITH IT - WHICH IS THIS CHECK DOING ITS JOB. It used to stand B on a -1500 W
|
||||
# charge against a +2000 W METER and let the site figure convict it. At a roster
|
||||
# of two that comparison is exactly the defect SAFETY-30 removes: the meter
|
||||
# answers for BOTH inverters, so it is evidence about neither. B is now
|
||||
# convicted by its OWN power sensor - standing on a -1500 W charge while
|
||||
# `sensor.batt_b` measures it DISCHARGING at +1500 W, which is one inverter
|
||||
# doing the opposite of what it was told and is a fact about that inverter
|
||||
# alone. Inverter A's own reading stays at 0 W, so A is never gated in.
|
||||
#
|
||||
# ⚠️ AND THE REJECTION WINDOW IS WHY THIS RUNS IN THREE SECONDS AND NOT THIRTY.
|
||||
# `sign_disagree_s` is 1 s here; `LINK_STALE_S` is 30 s, so B is still LIVE for
|
||||
# the whole run and the trip is graded before liveness has anything to say.
|
||||
# `unit_sign_disagree_s` is 1 s here; `LINK_STALE_S` is 30 s, so B is still LIVE
|
||||
# for the whole run and the trip is graded before liveness has anything to say.
|
||||
def _tripping_site():
|
||||
hass = Hass(
|
||||
{"sensor.grid": 2000.0, "sensor.soc_a": 50.0, "sensor.batt_a": 0.0,
|
||||
"sensor.soc_b": 50.0, "sensor.batt_b": 0.0},
|
||||
"sensor.soc_b": 50.0, "sensor.batt_b": 1500.0},
|
||||
{"number.sp_a": (-A_RATING, A_RATING, 1.0),
|
||||
"number.sp_b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS, 1.0)},
|
||||
reject={"number.sp_b"})
|
||||
store = MemStore()
|
||||
c, _ = built({"sign_disagree_w": 300, "sign_disagree_s": 1,
|
||||
"unit_sign_disagree_w": 300, "unit_sign_disagree_s": 1,
|
||||
"auto_start": True}, store=store, hass=hass)
|
||||
c.inverters.append(inverter_b(store, sign_cfg=c.inverters[0].sign_wd.cfg))
|
||||
c.auto = True
|
||||
# B is standing on a charge command while the house imports 2000 W. Its
|
||||
# writes are rejected, so this is what it goes on standing at.
|
||||
# B is standing on a charge command while its own sensor says it is
|
||||
# discharging. Its writes are rejected, so this is what it goes on
|
||||
# standing at.
|
||||
c.inverters[1].last_write = -1500.0
|
||||
return c, hass, store
|
||||
|
||||
@@ -681,10 +703,12 @@ check("⚠️ ...AND THE TRIP IS MIRRORED ONTO THE PRE-DEV-02 FLAT PAIR, WHICH I
|
||||
|
||||
# THE RESTART, which is the whole reason the latch is persisted at all.
|
||||
_h3b = Hass({"sensor.grid": 2000.0, "sensor.soc_a": 50.0,
|
||||
"sensor.batt_a": 0.0, "sensor.soc_b": 50.0, "sensor.batt_b": 0.0},
|
||||
"sensor.batt_a": 0.0, "sensor.soc_b": 50.0,
|
||||
"sensor.batt_b": 1500.0},
|
||||
{"number.sp_a": (-A_RATING, A_RATING, 1.0),
|
||||
"number.sp_b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS, 1.0)})
|
||||
_c3r, _ = built({"sign_disagree_w": 300, "sign_disagree_s": 1,
|
||||
"unit_sign_disagree_w": 300, "unit_sign_disagree_s": 1,
|
||||
"auto_start": True}, store=_s3, hass=_h3b)
|
||||
_c3r.inverters.append(inverter_b(_s3,
|
||||
sign_cfg=_c3r.inverters[0].sign_wd.cfg))
|
||||
@@ -703,12 +727,32 @@ check("a brand new Controller against the same store comes back with B "
|
||||
check("...and A goes on carrying the site target across the restart too",
|
||||
_c3r.allocation.shares.get("inverter-a") == _c3r.allocation.sum_w
|
||||
and _c3r.allocation.sum_w != 0)
|
||||
check("...and the status page shows BOTH rows - one latched, one armed. A "
|
||||
"single row would have to pick one inverter to describe, and 'the sign "
|
||||
"watchdog is armed' printed over a latched sibling is the page lying",
|
||||
len([r for r in _c3r.checks() if "sign watchdog" in r["text"]]) == 1
|
||||
check("...and the status page shows BOTH per-inverter rows - one latched, one "
|
||||
"armed. A single row would have to pick one inverter to describe, and "
|
||||
"'the sign watchdog is armed' printed over a latched sibling is the page "
|
||||
"lying",
|
||||
len([r for r in _c3r.checks()
|
||||
if r["text"].startswith("sign watchdog [")]) == 1
|
||||
and len([r for r in _c3r.checks()
|
||||
if "SIGN-DISAGREEMENT FAILSAFE" in r["text"]]) == 1)
|
||||
if "SIGN-DISAGREEMENT FAILSAFE [" in r["text"]]) == 1)
|
||||
check("⚠️ AND SAFETY-30's SITE ROW IS THERE BESIDE THEM, EXACTLY ONCE. At two "
|
||||
"inverters there are three sign questions on this page and they are three "
|
||||
"rows: is A doing what it was told, is B, and is the SITE'S TOTAL "
|
||||
"pointing the right way. The last one is not derivable from the other "
|
||||
"two - both units can execute their shares perfectly while the number "
|
||||
"they were given is itself wrong - so a page that showed only the "
|
||||
"per-unit rows would be advertising two guards where the install has "
|
||||
"three",
|
||||
len([r for r in _c3r.checks()
|
||||
if r["text"].startswith("site sign watchdog:")]) == 1)
|
||||
check("...and the per-inverter rows now say WHAT the unit was compared "
|
||||
"against, because at a roster of two it is no longer the meter. A row "
|
||||
"still reading 'the command opposed the meter' would send an operator to "
|
||||
"look at P1 for a fault that is visible only on one inverter's own power "
|
||||
"sensor",
|
||||
all("its own measured power" in r["text"] for r in _c3r.checks()
|
||||
if r["text"].startswith(("sign watchdog [",
|
||||
"SIGN-DISAGREEMENT FAILSAFE ["))))
|
||||
|
||||
# The operator's clear, which is one button and has to reach every latch.
|
||||
run(_c3r.handle_action("auto_toggle")) # -> stopped
|
||||
@@ -728,8 +772,9 @@ check("...and the same press cleared the flat rollback view, so a downgrade "
|
||||
"- and neither does the next start, which now reads that key on EVERY "
|
||||
"boot rather than only on the one that migrates",
|
||||
_s3.data["sign_tripped"] is False and _s3.data["sign_reason"] == "")
|
||||
_c3r2, _ = built({"sign_disagree_w": 300, "sign_disagree_s": 1}, store=_s3,
|
||||
hass=_h3b)
|
||||
_c3r2, _ = built({"sign_disagree_w": 300, "sign_disagree_s": 1,
|
||||
"unit_sign_disagree_w": 300, "unit_sign_disagree_s": 1},
|
||||
store=_s3, hass=_h3b)
|
||||
check("⚠️ AND THE RESTART AFTER THAT CLEAR COMES UP UNLATCHED. This is the "
|
||||
"'re-arm forever' hazard the every-start guard would have if the clear "
|
||||
"did not reach both spellings, driven through the real button and a real "
|
||||
@@ -1324,13 +1369,16 @@ _TWO_OPTS.pop("batt_entity")
|
||||
_TWO_OPTS["inverters"] = [
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
||||
max_w=A_RATING),
|
||||
max_w=A_RATING, bms_soc_floor=A_BMS_FLOOR),
|
||||
# ⚠️ 3000 W IS A HYPOTHESIS AND IS MARKED AS ONE WHEREVER IT APPEARS. It is
|
||||
# here because it DIFFERS from A's 5000 W, which is the only property any
|
||||
# check below uses. SPIKE-04 measures the real device.
|
||||
# ⚠️ AND SO IS ITS 15 % CUT-OUT (SAFETY-32). There is no second pack to read
|
||||
# register 0x0560 off; the value is SIMULATED, and it differs from A's 10 %
|
||||
# for MUTATION.md habit 1's reason and no other.
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=B_RATING_HYPOTHESIS),
|
||||
max_w=B_RATING_HYPOTHESIS, bms_soc_floor=B_BMS_FLOOR_SIM),
|
||||
]
|
||||
_c9, _hass9 = built(_TWO_OPTS)
|
||||
print(f" the roster built from `inverters:` -> {[repr(i) for i in _c9.inverters]}")
|
||||
@@ -1406,14 +1454,19 @@ _decl9 = dict(ln.strip().lstrip("- ").split(": ", 1)
|
||||
for ln in _entry9.splitlines()[1:] if ": " in ln)
|
||||
print(f" the shipped per-entry schema: {_decl9}")
|
||||
check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
|
||||
"`setpoint_entity` and `max_w` are REQUIRED - no trailing `?` - so the "
|
||||
"Supervisor refuses a half-written entry before the add-on sees it, and "
|
||||
"an entry can never fall back on a sibling's rating because there is no "
|
||||
"entry without one",
|
||||
set(_decl9) == {"name", "setpoint_entity", "max_w", "soc_entity",
|
||||
"batt_entity", "batt_invert"}
|
||||
"`setpoint_entity`, `max_w` and `bms_soc_floor` are REQUIRED - no "
|
||||
"trailing `?` - so the Supervisor refuses a half-written entry before the "
|
||||
"add-on sees it, and an entry can never fall back on a sibling's rating "
|
||||
"or on a sibling's BMS cut-out because there is no entry without one. "
|
||||
"⚠️ 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",
|
||||
set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
|
||||
"soc_entity", "batt_entity", "batt_invert"}
|
||||
and not any(_decl9[k].endswith("?")
|
||||
for k in ("name", "setpoint_entity", "max_w"))
|
||||
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")))
|
||||
|
||||
@@ -1495,9 +1548,11 @@ check("...and two inverters on two DIFFERENT entities are accepted, because "
|
||||
"that is the configuration this card exists for and a validator that "
|
||||
"fires on the legitimate case gets switched off",
|
||||
validate_startup_config(_opts(setpoint_entity="", inverters=[
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING),
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING,
|
||||
bms_soc_floor=A_BMS_FLOOR),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
max_w=B_RATING_HYPOTHESIS)])) == [])
|
||||
max_w=B_RATING_HYPOTHESIS,
|
||||
bms_soc_floor=B_BMS_FLOOR_SIM)])) == [])
|
||||
|
||||
# --- the rest of rule 9 -------------------------------------------------------
|
||||
_p_flat = validate_startup_config(_opts(inverters=[
|
||||
|
||||
+1298
-29
File diff suppressed because it is too large
Load Diff
@@ -204,12 +204,17 @@ check("...and the refusal names the actuator and every writer aimed at it, so "
|
||||
_PER_INVERTER_OK = {k: v for k, v in base().items() if k != "setpoint_entity"}
|
||||
_PER_INVERTER_OK["inverters"] = [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.goodwe_setpoint",
|
||||
"max_w": 2000},
|
||||
"max_w": 2000, "bms_soc_floor": 10},
|
||||
# ⚠️ INVERTER B DOES NOT EXIST. 2000 W here is the SHIPPED default repeated,
|
||||
# not a rating measured off a second device - SPIKE-04 measures that when
|
||||
# there is one to measure. Nothing in this file depends on the number.
|
||||
# ⚠️ SAFETY-32 made `bms_soc_floor` a REQUIRED per-entry key, and the two
|
||||
# values here DIFFER (10 / 15) rather than being copied. 15 % is SIMULATED -
|
||||
# there is no second pack to read a cut-out off - and the reason it is not
|
||||
# simply 10 is MUTATION.md habit 1: a fixture whose two per-unit numbers are
|
||||
# equal cannot show a check reading the wrong unit's.
|
||||
{"name": "inverter-b", "setpoint_entity": "number.goodwe_setpoint_b",
|
||||
"max_w": 2000}]
|
||||
"max_w": 2000, "bms_soc_floor": 15}]
|
||||
check("two inverters with their own entities are ACCEPTED - one writer each, "
|
||||
"no shared actuator, and that is CFG-04's whole purpose",
|
||||
validate_startup_config(_PER_INVERTER_OK, -5000, 5000) == [])
|
||||
@@ -523,9 +528,26 @@ check("...and the message explains it would read as a trip, not a mistake",
|
||||
# check below.
|
||||
check("the smallest sane max_w still starts",
|
||||
validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
|
||||
unit_sign_disagree_w=0.5,
|
||||
maintenance_charge_w=1), -5000, 5000) == [])
|
||||
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
|
||||
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
|
||||
# ⚠️ SAFETY-30 ADDED A SECOND WAY TO DISARM A SIGN WATCHDOG BY CONFIGURATION,
|
||||
# and the fixture above now has to name BOTH thresholds - which is the check
|
||||
# doing its job rather than a bookkeeping edit. `unit_sign_disagree_w` bounds a
|
||||
# unit's own SHARE, which the same `supervised` claim holds at +-max_w, so at or
|
||||
# above the ceiling it is unsatisfiable by construction exactly as
|
||||
# `sign_disagree_w` is. Refused even at the shipped roster of one, where this
|
||||
# watchdog is not armed: a config that cannot work is refused when it is
|
||||
# written, not on the day somebody adds a second inverter.
|
||||
check("...and the per-inverter threshold has the same rule, named separately "
|
||||
"so an operator is told WHICH watchdog they disarmed",
|
||||
why(validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
|
||||
maintenance_charge_w=1), -5000, 5000),
|
||||
"unit_sign_disagree_w"))
|
||||
check("...and one watt under that ceiling is reachable, so accepted",
|
||||
validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("what must NOT be refused - a validator that fires on trivia gets deleted")
|
||||
|
||||
@@ -401,10 +401,19 @@ print("controller and the first thing an operator will try")
|
||||
|
||||
|
||||
class _MemStore:
|
||||
"""A Store that keeps its dict across "restarts", like the real file does."""
|
||||
"""A Store that keeps its dict across "restarts", like the real file does.
|
||||
|
||||
⚠️ SEEDED FROM `store.DEFAULTS`, NOT FROM A LITERAL. `Store.load()` merges
|
||||
that dict over whatever is on disk, so a fake that hand-listed three keys
|
||||
answered `None` where the real store answers a default - and a check reading
|
||||
"this latch was never written" then could not tell an absent key from a
|
||||
False one. It is also a place to drift: SAFETY-31 and SAFETY-30 both added
|
||||
flat pairs, and neither would have appeared here.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.data = {"phase": "idle", "sign_tripped": False, "sign_reason": ""}
|
||||
from app.store import DEFAULTS as _SDEF
|
||||
self.data = dict(_SDEF)
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
@@ -1105,6 +1114,701 @@ check("⚠️ AND THE 'HAS A HUMAN SEEN THIS' FLAG IS CLEARED WITH IT. Left "
|
||||
_cc._circ_announced is False)
|
||||
|
||||
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("SAFETY-30: at two inverters the sign check is TWO questions, not one")
|
||||
print("=" * 72)
|
||||
# ⚠️ READ THIS BEFORE ADDING A CHECK HERE. Half of this card was already built
|
||||
# and it is NOT the half below. DEV-02 shipped the PER-INSTANCE half: each
|
||||
# `Inverter` owns its own `SignWatchdog`, latched under its own namespaced store
|
||||
# keys, and a trip on B no longer latches A. test_inverter.py part 3 grades
|
||||
# that; nothing here re-tests it.
|
||||
#
|
||||
# What is graded here is THE COMPARISON ITSELF. Before this card every unit's
|
||||
# detector was handed `self.grid`. There is ONE P1, so at N>=2 that is a number
|
||||
# this controller put on ONE actuator, graded against a measurement that answers
|
||||
# for ALL of them - wrong in both directions: a FALSE trip when a sibling
|
||||
# accounts for the meter, and a MISSED one when a sibling MASKS a unit that
|
||||
# really is reversed. The fix is two comparisons, and they are two different
|
||||
# faults: each unit against ITS OWN measured power, and the SUM of the standing
|
||||
# commands against the meter.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT SAFETY-31, WHICH IS THE SECTION ABOVE. `circulation.py`
|
||||
# compares the units AGAINST EACH OTHER and catches two inverters pushing
|
||||
# THROUGH each other, which nets to zero at the meter. This compares each unit
|
||||
# against ITS OWN COMMAND and catches a unit not doing what it was told. They
|
||||
# overlap in exactly one scenario - a unit reversing while its sibling pulls the
|
||||
# other way - and part 4 below asserts what each one concludes there, because
|
||||
# two guards that overlap without saying so is how a false-alarm generator gets
|
||||
# built.
|
||||
|
||||
from app.control import Tuning as _Tuning # noqa: E402
|
||||
from app.watchdog import ( # noqa: E402
|
||||
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS, disagrees,
|
||||
)
|
||||
|
||||
print()
|
||||
print("1. ONE predicate, TWO second terms - and the trip sentence says which")
|
||||
# ⚠️ THE ARITHMETIC IS UNCHANGED AND THAT IS THE POINT: `disagrees()` was not
|
||||
# split in two. What moved is a PARAMETER NAME - `grid_w` became `against_w` -
|
||||
# because what the command is opposed TO is now the caller's decision. A second
|
||||
# copy of a safety predicate is one copy plus a future disagreement about what
|
||||
# the check means (see `loop_is_following()`), so there is still exactly one.
|
||||
_scfg = SignWatchdogConfig(min_w=300.0, hold_s=180.0)
|
||||
_ucfg = SignWatchdogConfig(
|
||||
min_w=float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]),
|
||||
hold_s=float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"]))
|
||||
check("the same call that means 'commanded -1000 W into a +1500 W import' "
|
||||
"means 'commanded -1000 W while measuring +1500 W of discharge' when the "
|
||||
"caller hands it a unit's own power - one function, two questions",
|
||||
disagrees(-1000.0, 1500.0, True, _scfg) is True)
|
||||
check("...and every gate still binds on the second term whatever it is: a "
|
||||
"250 W reading does not qualify against a 300 W gate",
|
||||
disagrees(-1000.0, 250.0, True, _scfg) is False)
|
||||
check("...nor does a unit sitting at zero, which is a REFUSED command and not "
|
||||
"a reversed one - the distinction the module docstring makes about a "
|
||||
"battery declining a discharge, now read of the unit's own sensor",
|
||||
disagrees(1500.0, 0.0, True, _ucfg) is False)
|
||||
|
||||
_wd_site = SignWatchdog(_scfg)
|
||||
_wd_unit = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
|
||||
for _t in range(0, 400, 5):
|
||||
_wd_site.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
|
||||
_wd_unit.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
|
||||
print(f" site: {_wd_site.reason!r}")
|
||||
print(f" unit: {_wd_unit.reason!r}")
|
||||
check("⚠️ THE SITE INSTANCE'S SENTENCE IS BYTE-IDENTICAL TO THE ONE THAT "
|
||||
"SHIPPED. It is what an operator reads on the status page, what goes in "
|
||||
"the action log and what is persisted to /data/state.json, so it is "
|
||||
"asserted against the literal rather than against a format string",
|
||||
_wd_site.reason == (
|
||||
"SIGN_DISAGREEMENT: commanded -1000 W against a meter reading "
|
||||
"+1500 W for 180 s continuously (limits: both over 300 W, held over "
|
||||
"180 s)"))
|
||||
check("...and the per-unit instance names what it ACTUALLY compared instead. "
|
||||
"'against a meter reading' there would be a false statement about which "
|
||||
"instrument produced the evidence, on the one line somebody woken by "
|
||||
"this alarm reads first",
|
||||
AGAINST_OWN_POWER in _wd_unit.reason
|
||||
and AGAINST_METER not in _wd_unit.reason
|
||||
and "+1500 W" in _wd_unit.reason)
|
||||
# ⚠️ `arm()` IS CONFIGURATION AND MUST NOT BE STATE. `Controller.cycle()` calls
|
||||
# it on EVERY cycle, so a version that cleared `disagree_since` there would
|
||||
# restart the window every cycle, never accumulate a hold, and leave the check
|
||||
# permanently green with nothing saying so. Driven rather than argued: open a
|
||||
# window, re-arm mid-episode, and see the window still standing.
|
||||
_rearm = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
|
||||
_rearm.evaluate(-1000.0, 1500.0, True, T0)
|
||||
_rearm.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=5))
|
||||
_rearm.arm(_ucfg, AGAINST_OWN_POWER)
|
||||
check("re-arming mid-episode leaves the window exactly where it was",
|
||||
_rearm.disagree_since == T0)
|
||||
for _t in range(10, 200, 5):
|
||||
_rearm.arm(_ucfg, AGAINST_OWN_POWER)
|
||||
_rearm.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
|
||||
check("...so a detector re-armed on every one of 38 cycles still trips at its "
|
||||
"85 s hold, which is what says `arm()` did not quietly disarm it",
|
||||
_rearm.tripped)
|
||||
|
||||
print()
|
||||
print("2. CFG-03: config.yaml is the source of truth and there is ONE copy")
|
||||
_sign_from_yaml = {k: int(v) for k, v in
|
||||
_re.findall(r"^ ((?:unit|site)_sign_disagree_\w+):\s*(\d+)\s*$",
|
||||
_cfg_opts, _re.M)}
|
||||
print(f" config.yaml options: {_sign_from_yaml}")
|
||||
print(f" SIGN_OPTION_DEFAULTS: {SIGN_OPTION_DEFAULTS}")
|
||||
check("all four new options are declared in config.yaml's options: block and "
|
||||
"the in-code mirror agrees key for key and value for value - the "
|
||||
"Dockerfile does not put config.yaml in the image, so the running add-on "
|
||||
"cannot read it and this is where the two can drift",
|
||||
_sign_from_yaml == SIGN_OPTION_DEFAULTS and len(_sign_from_yaml) == 4)
|
||||
_sign_bounds = {m[0]: (int(m[1]), int(m[2])) for m in
|
||||
_re.findall(r"^ ((?:unit|site)_sign_disagree_\w+):"
|
||||
r"\s*int\((\d+),(\d+)\)\s*$",
|
||||
_cfg_schema, _re.M)}
|
||||
print(f" schema bounds: {_sign_bounds}")
|
||||
check("...and ALL FOUR have a schema range, so the Supervisor refuses an "
|
||||
"out-of-range value rather than the add-on discovering it at runtime",
|
||||
set(_sign_bounds) == set(SIGN_OPTION_DEFAULTS))
|
||||
check("the shipped defaults sit inside their own schema ranges",
|
||||
all(_sign_bounds[k][0] <= v <= _sign_bounds[k][1]
|
||||
for k, v in SIGN_OPTION_DEFAULTS.items()))
|
||||
check("⚠️ THE THRESHOLD IS `saturation_w / 2`, WHICH IS WHERE THE CONTROL LAW "
|
||||
"ITSELF ALREADY DRAWS THE LINE. This check gates on BOTH sides, and the "
|
||||
"two are OPPOSED, so the smallest command-versus-power gap it can ever "
|
||||
"see is 2 x min_w. At 250 W that is exactly the 500 W at which "
|
||||
"`compute()`'s own anti-windup test concludes the inverter is not "
|
||||
"following and freezes the integrator - so this watchdog cannot fire on "
|
||||
"a divergence the shipped loop calls ordinary tracking, and the two "
|
||||
"guards meet at a number instead of overlapping in a band nobody drew",
|
||||
SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"] * 2
|
||||
== _Tuning().saturation_w)
|
||||
check("...and the window clears its three bounds: above the firmware's own "
|
||||
"30 s link veto (`inverter.LINK_STALE_S` - inside it, a unit not "
|
||||
"executing its share is a LINK fault the add-on already answers by "
|
||||
"dropping it from the split), and UNDER HALF the 180 s the site check "
|
||||
"takes, because the per-unit conclusion needs nothing to net out before "
|
||||
"it is complete",
|
||||
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"] > LINK_STALE_S
|
||||
and SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"] < 180 / 2)
|
||||
check("⚠️ AND NEITHER IS A ROUND NUMBER. 250 W is read off `saturation_w`; "
|
||||
"85 s is 8.5x the worst legitimate opposition 1890 two-inverter runs "
|
||||
"produce (10.0 s) and 2.8x the firmware veto. 60, 75, 90, 100, 120, 180 "
|
||||
"and 300 are all values a guess would have landed on",
|
||||
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"]
|
||||
not in (30, 60, 75, 90, 100, 120, 180, 300))
|
||||
|
||||
# ⚠️ AND THE SITE PAIR IS TWO DECISIONS, NOT ONE, WHICH IS WHY BOTH ARE PINNED.
|
||||
# The THRESHOLD is carried from the shipped pair because its argument is about
|
||||
# the meter's noise floor and the deadband - a property of ONE P1 and of
|
||||
# `Tuning.deadband_w`, neither of which the roster touches. The WINDOW is NOT
|
||||
# carried, because the transient it rejects is the loop crossing zero across a
|
||||
# rail that IS the roster sum: measured at 110 s with one inverter and 215 s
|
||||
# with two, on the same stimuli, against a 180 s hold. That is a carried number
|
||||
# justified and a carried number refused, in the same pair.
|
||||
check("the SITE threshold is the shipped one, carried - the noise argument "
|
||||
"behind 300 W is about the meter and the deadband, and a second inverter "
|
||||
"changes neither",
|
||||
SIGN_OPTION_DEFAULTS["site_sign_disagree_w"] == 300)
|
||||
check("⚠️ ...AND THE SITE WINDOW IS LONGER THAN THE SHIPPED 180 s, WHICH IS THE "
|
||||
"MEASUREMENT REFUSING TO BE CARRIED. At two inverters the worst "
|
||||
"legitimate opposition the rig produces is 215.0 s - reachable at "
|
||||
"`gain: 0.05`, `slew_w: 50` and a 2.2 kW load reversal, all inside what "
|
||||
"config.yaml accepts - so 180 s would have shipped a false trip on the "
|
||||
"check with the best record in the safety table. 325 s is 1.51x that",
|
||||
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] > 215.0
|
||||
and SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] < 215.0 * 2)
|
||||
check("...and it is still inside the 900 s quarter that bills the capacity "
|
||||
"tariff, and inside every episode the 2026-08-20 fault day contains - "
|
||||
"its four clusters run 16, 30, 77 and 77 minutes",
|
||||
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] < 900
|
||||
and SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] < 16 * 60)
|
||||
check("...and it is deliberately not 300 or 360, the two round numbers next to "
|
||||
"it",
|
||||
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] not in (300, 360, 600, 900))
|
||||
|
||||
print()
|
||||
print("3. AC 1: ONE unit drives the wrong way, and ONLY that unit trips")
|
||||
# ⚠️ THE PLANT IS MODELLED AND SAYS SO. `_SignHass` is two inverters that obey
|
||||
# INSTANTLY - no ramp, no lag - and one meter that is `load - sum(measured)`.
|
||||
# That is deliberately not a claim about transients: the worst legitimate
|
||||
# opposition a real ramp and lag can produce is measured on admin/goodwe's
|
||||
# two-master rig, over 1890 closed-loop runs, and recorded in
|
||||
# DECISIONS/SAFETY-30-per-unit-sign.md. What this fixture is for is the
|
||||
# ATTRIBUTION - which detector concludes what, from which instrument - and for
|
||||
# that an instant plant is the honest model, because it removes the one
|
||||
# mechanism that could make a unit look reversed without being reversed.
|
||||
|
||||
|
||||
class _SignHass(_Hass):
|
||||
"""Two inverters, one meter, and a chosen subset that does the OPPOSITE.
|
||||
|
||||
`reversed_units` are roster indices whose measured power is the NEGATION of
|
||||
what this controller told them - HW-01's wedged power stage, an inverted
|
||||
driver, a unit executing a stale share backwards. Everything else obeys.
|
||||
"""
|
||||
|
||||
def __init__(self, load_w=2000.0, reversed_units=()):
|
||||
super().__init__()
|
||||
self.load_w = float(load_w)
|
||||
self.reversed_units = set(reversed_units)
|
||||
self.ctrl = None
|
||||
|
||||
def measured(self):
|
||||
if self.ctrl is None:
|
||||
return [0.0, 0.0]
|
||||
out = []
|
||||
for i, inv in enumerate(self.ctrl.inverters):
|
||||
w = inv.last_write if inv.last_write is not None else inv.target
|
||||
w = float(w or 0.0)
|
||||
out.append(-w if i in self.reversed_units else w)
|
||||
return out
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
m = self.measured() + [0.0, 0.0]
|
||||
return {"sensor.grid": self.load_w - sum(m[:2]),
|
||||
"sensor.soc": 50.0, "sensor.soc_b": 50.0,
|
||||
"sensor.batt": m[0], "sensor.batt_b": m[1]}.get(entity)
|
||||
|
||||
|
||||
def _sign_site(hass, store=None, **over):
|
||||
"""A two-inverter Controller, with SAFETY-31 deliberately given a long window.
|
||||
|
||||
⚠️ THE LONG `circulation_hold_s` IS ISOLATION, NOT A CLAIM THAT SAFETY-31 IS
|
||||
SILENT HERE. A unit reversing while its sibling pulls the other way IS
|
||||
circulating power and SAFETY-31 sees it - part 4 asserts exactly that, and
|
||||
what each guard concludes about it. What must not happen is the SITE being
|
||||
stopped by circulation while this section is trying to grade a PER-UNIT
|
||||
attribution, because then every "only B is parked" check below would be
|
||||
measuring the wrong guard.
|
||||
|
||||
⚠️ THE THREE THRESHOLD PAIRS ARE PAIRWISE DISTINCT IN BOTH COORDINATES,
|
||||
AND THAT IS LOAD-BEARING RATHER THAN COSMETIC. This fixture is the only
|
||||
place in the suite where `_arm_sign_watchdogs()` runs at a roster of two, so
|
||||
it is the only place that can see WHICH pair reaches WHICH detector. The
|
||||
first version of it set every hold to 1 s and two of the three magnitude
|
||||
gates to 300 W; a detector armed with the shipped pair, or with the site
|
||||
pair, or with the right magnitude and the wrong hold, then compared EQUAL
|
||||
to the pair it was supposed to have, and the wiring check below would have
|
||||
passed on three of those four arrangements. That is MUTATION.md's third
|
||||
failure mode written into a fixture - a check comparing two things that
|
||||
happen to be equal cannot see the difference it names - and the
|
||||
precondition beside the check is what stops a later edit collapsing them
|
||||
again without a red line.
|
||||
|
||||
Anyone changing a hold here: the PER-UNIT hold is what part 3's trip timing
|
||||
is read against, and the SITE hold must still expire inside `_drive()`'s
|
||||
window (14 cycles x 0.2 s), which part 5 depends on.
|
||||
"""
|
||||
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": 0.6,
|
||||
"sign_disagree_w": 300, "sign_disagree_s": 3,
|
||||
"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 1,
|
||||
"site_sign_disagree_w": 320, "site_sign_disagree_s": 2,
|
||||
"circulation_hold_s": 3600, "auto_start": True}
|
||||
opts.update(over)
|
||||
c = Controller(opts, hass, store if store is not None else _MemStore(),
|
||||
_P(), stop=asyncio.Event())
|
||||
b = Inverter("inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=B_RATING_HYPOTHESIS, store=c.store)
|
||||
b.dev_min, b.dev_max = -B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS
|
||||
c.inverters.append(b)
|
||||
c.auto = True
|
||||
for inv in c.inverters:
|
||||
if inv.dev_min is None:
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
hass.ctrl = c
|
||||
return c
|
||||
|
||||
|
||||
async def _drive(c, n=14, dt=0.2, watch=None):
|
||||
seen = []
|
||||
for _ in range(n):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
if watch is not None:
|
||||
seen.append(watch(c))
|
||||
await asyncio.sleep(dt)
|
||||
return seen
|
||||
|
||||
|
||||
async def _one_reversed():
|
||||
hass = _SignHass(load_w=2000.0, reversed_units={1})
|
||||
c = _sign_site(hass)
|
||||
seen = await _drive(c, watch=lambda c: dict(
|
||||
grid=c.grid,
|
||||
stood=[i.last_write if i.last_write is not None else i.target
|
||||
for i in c.inverters],
|
||||
meas=[i.batt for i in c.inverters],
|
||||
a=c.inverters[0].sign_wd.tripped, b=c.inverters[1].sign_wd.tripped,
|
||||
site=c.site_sign_wd.tripped))
|
||||
return c, hass, seen
|
||||
|
||||
|
||||
_c30, _h30, _s30 = asyncio.run(_one_reversed())
|
||||
_a30, _b30 = _c30.inverters
|
||||
|
||||
# ⚠️ THE WIRING PIN FOR AC 5, AND IT IS A DIFFERENT CLAIM FROM EVERY
|
||||
# BEHAVIOURAL CHECK IN THIS SECTION. The rest of part 3 grades what the
|
||||
# detectors CONCLUDED; this grades WHICH THRESHOLDS THEY WERE HANDED, and the
|
||||
# gap between the two was live. Replacing
|
||||
# `arm(self.sign_cfg if one else self.unit_sign_cfg, ...)` with
|
||||
# `arm(self.sign_cfg, ...)` in `Controller._arm_sign_watchdogs()` throws the
|
||||
# re-derived per-unit pair away and arms every unit with SAFETY-02's
|
||||
# meter-facing one - and left ALL 19 FILES GREEN. Nothing at a roster of two
|
||||
# read `inv.sign_wd.cfg` at all; the one place that does is part 7's N=1 arm,
|
||||
# which that edit PRESERVES.
|
||||
#
|
||||
# WHY THE TRIPS BELOW CANNOT CATCH IT, said here because "the suite is green"
|
||||
# and "nothing was wrong" are the two readings this comment exists to separate.
|
||||
# B is reversed at ~2000 W - far over 250 W and far over 300 W - and every hold
|
||||
# in this fixture is short enough to expire inside `_drive()`. A fault that is
|
||||
# big enough and long enough to trip under BOTH pairs is evidence about
|
||||
# neither. Only an equality on the pair itself can tell them apart.
|
||||
#
|
||||
# WHAT THE SURVIVOR COSTS IN THE FIELD, at config.yaml's own defaults: the
|
||||
# per-unit check runs at `sign_disagree_s` 180 s instead of the re-derived 85 s
|
||||
# - 2.1x slower to park a reversed inverter - and its gate moves from 250 W to
|
||||
# 300 W, so a unit reversing a share between the two becomes invisible, while
|
||||
# watchdog.SIGN_OPTION_DEFAULTS, config.yaml and DECISIONS section 3 all go on
|
||||
# describing a pair the running code does not use.
|
||||
_UNIT_PAIR = (250.0, 1.0) # `unit_sign_disagree_w` / `_s` - one per unit
|
||||
_SITE_PAIR = (320.0, 2.0) # `site_sign_disagree_w` / `_s` - one per site
|
||||
_SHIPPED_PAIR = (300.0, 3.0) # `sign_disagree_w` / `_s` - N=1 only
|
||||
_armed = {inv.name: (inv.sign_wd.cfg.min_w, inv.sign_wd.cfg.hold_s)
|
||||
for inv in _c30.inverters}
|
||||
_armed_site = (_c30.site_sign_wd.cfg.min_w, _c30.site_sign_wd.cfg.hold_s)
|
||||
print(f" armed per unit: {_armed}")
|
||||
print(f" armed site: {_armed_site} "
|
||||
f"shipped pair, wanted by NOBODY here: {_SHIPPED_PAIR}")
|
||||
check("PRECONDITION: the three pairs are pairwise distinct IN BOTH "
|
||||
"COORDINATES, so an equality below can fail on the magnitude alone, on "
|
||||
"the hold alone, or on both. This fixture originally gave all three the "
|
||||
"same 1 s hold and two of them the same 300 W gate, and a mis-wiring "
|
||||
"that delivered the right magnitude with the wrong hold would then have "
|
||||
"compared EQUAL to the pair it was supposed to have",
|
||||
len({_UNIT_PAIR, _SITE_PAIR, _SHIPPED_PAIR}) == 3
|
||||
and len({p[0] for p in (_UNIT_PAIR, _SITE_PAIR, _SHIPPED_PAIR)}) == 3
|
||||
and len({p[1] for p in (_UNIT_PAIR, _SITE_PAIR, _SHIPPED_PAIR)}) == 3)
|
||||
check("PRECONDITION: and all three are pairs this Controller actually holds, "
|
||||
"so the literals below are the fixture's own numbers and not three "
|
||||
"constants that agree with nothing. In particular the shipped pair is "
|
||||
"still constructed and still a reachable mis-wiring target",
|
||||
(_c30.unit_sign_cfg.min_w, _c30.unit_sign_cfg.hold_s) == _UNIT_PAIR
|
||||
and (_c30.site_sign_cfg.min_w, _c30.site_sign_cfg.hold_s) == _SITE_PAIR
|
||||
and (_c30.sign_cfg.min_w, _c30.sign_cfg.hold_s) == _SHIPPED_PAIR)
|
||||
check("⚠️ AC 5 WIRING. EVERY per-unit detector on this roster of two is armed "
|
||||
"with the PER-UNIT pair - 250 W / 1 s here, `unit_sign_disagree_w` and "
|
||||
"`unit_sign_disagree_s` in the field. Graded against LITERALS rather "
|
||||
"than against `_c30.unit_sign_cfg`, which cannot tell 'the per-unit "
|
||||
"pair' from 'whatever this Controller was handed'. This is the line that "
|
||||
"goes red when `_arm_sign_watchdogs()` stops choosing between the two "
|
||||
"pairs and hands every unit the site-facing one",
|
||||
len(_armed) == 2 and all(p == _UNIT_PAIR for p in _armed.values()))
|
||||
check("...and the SITE detector is armed with the SITE pair - 320 W / 2 s "
|
||||
"here - which differs from the per-unit pair AND from the shipped pair "
|
||||
"in both coordinates, so this equality is not carried by a number the "
|
||||
"fixture happens to hold twice",
|
||||
_armed_site == _SITE_PAIR)
|
||||
check("...and each detector is pointed at its own INSTRUMENT as well as at its "
|
||||
"own thresholds: the units at their own 0x0518 power, the site at the "
|
||||
"meter. They are two arguments of one call and a pin on one is not a pin "
|
||||
"on the other",
|
||||
all(inv.sign_wd.against == AGAINST_OWN_POWER for inv in _c30.inverters)
|
||||
and _c30.site_sign_wd.against == AGAINST_METER)
|
||||
|
||||
_mid = _s30[3]
|
||||
print(f" cycle 3: stood {[round(x) for x in _mid['stood']]} "
|
||||
f"measured {[round(x) for x in _mid['meas']]} "
|
||||
f"meter {_mid['grid']:+.0f} W")
|
||||
check("PRECONDITION: the stimulus is not vacuous - inverter B really is doing "
|
||||
"the OPPOSITE of what it was told, and inverter A really is obeying. "
|
||||
"Without this line every claim below could be true of a fixture in which "
|
||||
"nothing was reversed",
|
||||
_mid["stood"][1] * _mid["meas"][1] < 0
|
||||
and abs(_mid["meas"][1]) > 250
|
||||
and _mid["stood"][0] * _mid["meas"][0] > 0)
|
||||
check("⚠️ AC 1. THE REVERSED UNIT TRIPS AND ITS SIBLING DOES NOT. The evidence "
|
||||
"is B's own 0x0518 power against B's own share, so it is a fact about B "
|
||||
"and nothing about A - which is what makes parking one of them the right "
|
||||
"remedy rather than an attribution nobody measured",
|
||||
_b30.sign_wd.tripped and not _a30.sign_wd.tripped)
|
||||
check("...and the reason names the instrument, so the operator is sent to the "
|
||||
"right one", AGAINST_OWN_POWER in _b30.sign_wd.reason)
|
||||
print(f" {_b30.sign_wd.reason!r}")
|
||||
# ⚠️ READ OFF THE ALLOCATION RECORD, NOT OFF `Controller.reason`. At a roster
|
||||
# of two `reason` is the SITE resolution's line - "loop -> 2000 W" - because
|
||||
# B's failsafe is a UNIT claim resolved on B's own actuator and never competes
|
||||
# for the site winner. That is the DEV-02 split working, and a check that
|
||||
# grepped the site line for "failsafe" would go green only while the roster was
|
||||
# one. The per-unit record is where the pin is stated, and it names its source.
|
||||
_bplan = [pl for pl in (_c30.allocation.plans if _c30.allocation else ())
|
||||
if pl.unit == "inverter-b"]
|
||||
print(f" allocation: {_c30.allocation.explain()}")
|
||||
print(f" inverter-b plan: {_bplan[0].why if _bplan else '(none)'}")
|
||||
check("...B is commanded 0 W by its OWN `failsafe` claim, named in the "
|
||||
"allocation record, rather than by some other guard happening to stop it",
|
||||
_b30.target == 0.0 and _bplan and "failsafe" in _bplan[0].why)
|
||||
check("...and A IS STILL COMMANDING. One inverter in trouble stops that "
|
||||
"inverter; the site goes on running on the one that works, which is what "
|
||||
"a UNIT-scoped claim buys and what a site-wide stop would have thrown "
|
||||
"away", _a30.target != 0.0)
|
||||
check("...the latch is under B's OWN namespaced key, and A's is untouched",
|
||||
_c30.store.data.get(unit_key("inverter-b", "sign_tripped")) is True
|
||||
and _c30.store.data.get(TRIPPED_KEY) is False)
|
||||
|
||||
print()
|
||||
print("4. AC 2: and it is caught EVEN THOUGH THE METER SAYS EVERYTHING IS FINE")
|
||||
# ⚠️ THIS IS THE CHECK THE WHOLE CARD IS FOR, so it is built the way MUTATION.md
|
||||
# says to build one: the claim is "the new comparison sees what the old one
|
||||
# structurally cannot", and the OLD comparison is RUN, on the same samples, and
|
||||
# its blindness is asserted as its own precondition and PRINTED - not argued.
|
||||
_old_view = [disagrees(s["stood"][1], s["grid"], True, _scfg) for s in _s30]
|
||||
_site_view = [disagrees(sum(s["stood"]), s["grid"], True, _scfg) for s in _s30]
|
||||
print(f" the SHIPPED comparison (B's command vs the meter) was true on "
|
||||
f"{sum(_old_view)} of {len(_old_view)} cycles")
|
||||
print(f" the SITE comparison (sum of commands vs the meter) on "
|
||||
f"{sum(_site_view)} of {len(_site_view)}")
|
||||
check("⚠️ THE COMPARISON THAT SHIPPED IS FALSE ON EVERY SINGLE CYCLE HERE. B is "
|
||||
"commanded to discharge and the house is importing, so B's command and "
|
||||
"the meter AGREE all the way through - the meter cannot tell that the "
|
||||
"watts arriving are inverter A's while B does the opposite. This is not "
|
||||
"a threshold set too high: the summation destroys the evidence and no "
|
||||
"tuning recovers it",
|
||||
not any(_old_view))
|
||||
check("...and the SITE comparison is blind for the same reason, which is why "
|
||||
"it is kept as a SEPARATE question rather than offered as the fix",
|
||||
not any(_site_view))
|
||||
check("...while the shipped SITE watchdog stayed correctly silent, so nothing "
|
||||
"here is a site-level trip wearing a per-unit label",
|
||||
not _c30.site_sign_wd.tripped
|
||||
and _c30.store.data.get("site_sign_tripped") is not True)
|
||||
check("⚠️ AND SAFETY-31 SEES SOMETHING TOO, WHICH IS NOT A DUPLICATE AND IS "
|
||||
"SAID HERE RATHER THAN LEFT FOR SOMEBODY TO DISCOVER. A unit reversing "
|
||||
"while its sibling pulls the other way IS power going round a loop, so "
|
||||
"`circulating_w()` is over its threshold on this fixture - it is only "
|
||||
"the long window this fixture gives it that keeps it from stopping the "
|
||||
"site. The two guards CONCLUDE different things: circulating power is "
|
||||
"defined over the roster and names no offender, while this one names B "
|
||||
"and parks B. Neither is the other with a different threshold",
|
||||
max(circulating_w(s["meas"]) for s in _s30) > _c30.circ_wd.cfg.min_w
|
||||
and not _c30.circ_wd.tripped
|
||||
and _c30.site_circulating_w == 0.0)
|
||||
|
||||
print(f" circulating power over the episode: "
|
||||
f"{[round(circulating_w(s['meas'])) for s in _s30]}")
|
||||
check("...and it falls back to ZERO the moment B is parked, which is the "
|
||||
"remedy working rather than the sensor going quiet: a unit held at 0 W "
|
||||
"cannot be half of a loop. It is asserted on the EPISODE rather than on "
|
||||
"the end state for exactly that reason",
|
||||
_s30[-1]["meas"][1] == 0.0)
|
||||
|
||||
print()
|
||||
print("5. AC 3: the SITE question survives, separately, and catches whole-site")
|
||||
print(" reversal - which no per-unit check can see")
|
||||
# ⚠️ THE FAULT HERE IS THE ONE THE 2026-08-20 CAPTURE CONTAINS, and it is
|
||||
# INVISIBLE to every per-unit comparison by construction: both inverters are
|
||||
# doing EXACTLY what they were told. What is wrong is the number they were told.
|
||||
# A negative gain is the minimal faithful model of it - an inverted polarity, a
|
||||
# wound-up integrator, a strategy on the wrong side of the meter - and it is not
|
||||
# a reachable configuration (`gain` is float(0.05,1.0)), which is the point: it
|
||||
# stands in for a code regression, which is the case a check can hold the line on.
|
||||
|
||||
|
||||
async def _site_reversed():
|
||||
hass = _SignHass(load_w=2000.0, reversed_units=())
|
||||
c = _sign_site(hass, gain=-0.6)
|
||||
seen = await _drive(c, watch=lambda c: dict(
|
||||
grid=c.grid, site=c.site_sign_wd.tripped,
|
||||
stood=[i.last_write if i.last_write is not None else i.target
|
||||
for i in c.inverters],
|
||||
meas=[i.batt for i in c.inverters]))
|
||||
return c, hass, seen
|
||||
|
||||
|
||||
_c31, _h31, _s31 = asyncio.run(_site_reversed())
|
||||
# ⚠️ THE LAST SAMPLE BEFORE THE TRIP, NOT A FIXED INDEX. After the trip every
|
||||
# command is 0 W by design, so a fixed index reads the REMEDY rather than the
|
||||
# fault and the precondition below would be asserting that a stopped site is
|
||||
# stopped. This is `Run.p2p_between`'s lesson from the rig, in a unit test: name
|
||||
# the window rather than inferring it.
|
||||
_pre31 = [x for x in _s31 if not x["site"]]
|
||||
_mid31 = _pre31[-1]
|
||||
print(f" last cycle before the trip: stood "
|
||||
f"{[round(x) for x in _mid31['stood']]} "
|
||||
f"measured {[round(x) for x in _mid31['meas']]} "
|
||||
f"meter {_mid31['grid']:+.0f} W")
|
||||
check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
|
||||
"agrees in sign with its own share - and the site is still charging into "
|
||||
"an importing house. Without this the site trip below could be a "
|
||||
"per-unit fault in disguise",
|
||||
all(s * m > 0 for s, m in zip(_mid31["stood"], _mid31["meas"]))
|
||||
and sum(_mid31["stood"]) * _mid31["grid"] < 0)
|
||||
check("⚠️ AC 3. THE SITE WATCHDOG TRIPS AND NOT ONE PER-UNIT WATCHDOG DOES. "
|
||||
"That asymmetry is the card: the fault is in the number the site was "
|
||||
"asking for, which no single inverter owns, and every unit is innocent "
|
||||
"of it",
|
||||
_c31.site_sign_wd.tripped
|
||||
and not any(i.sign_wd.tripped for i in _c31.inverters))
|
||||
print(f" {_c31.site_sign_wd.reason!r}")
|
||||
check("...and it stops the WHOLE site, through the `sign_site` claim by name "
|
||||
"rather than by some other guard happening to stop it",
|
||||
_c31.target == 0.0 and "sign_site" in _c31.reason
|
||||
and all(i.target == 0.0 for i in _c31.inverters))
|
||||
check("...the latch is FLAT - `site_sign_tripped`, beside SAFETY-31's pair - "
|
||||
"and NOT mirrored onto the pre-DEV-02 `sign_tripped`, which is the "
|
||||
"rollback view of a UNIT latch. A downgraded add-on drives exactly one "
|
||||
"actuator; handing it a latch taken on evidence about a roster it cannot "
|
||||
"have would be a trip it can never explain",
|
||||
_c31.store.data.get("site_sign_tripped") is True
|
||||
and "SIGN_DISAGREEMENT" in str(_c31.store.data.get("site_sign_reason"))
|
||||
and _c31.store.data.get("sign_tripped") is not True)
|
||||
check("the status page carries a SITE row of its own, and the banner says the "
|
||||
"site's TOTAL command opposed the meter rather than naming an inverter - "
|
||||
"naming one would send an operator to swap the wrong box",
|
||||
any("SITE SIGN-DISAGREEMENT FAILSAFE" in ck["text"]
|
||||
for ck in _c31.checks())
|
||||
and "total command" in _c31.status_payload()["banner"])
|
||||
_pub30 = {}
|
||||
_c31.mqtt = type("P", (), {"enabled": True,
|
||||
"publish": lambda _s, v: _pub30.update(v),
|
||||
"close": lambda _s: None})()
|
||||
_c31.publish()
|
||||
check("...and the controller-status sensor reports the failsafe state, so a "
|
||||
"site parked at 0 W cannot publish 'running'",
|
||||
_pub30.get("status") == "failsafe")
|
||||
|
||||
# The latch, and the one way out of it.
|
||||
_was31 = _c31.site_sign_wd.tripped
|
||||
asyncio.run(_c31.handle_action("auto_toggle")) # stop
|
||||
check("pressing STOP clears nothing - the condition always goes away on its "
|
||||
"own, because the trip commands 0 W and a site at zero cannot oppose "
|
||||
"anything", _c31.site_sign_wd.tripped and _was31)
|
||||
asyncio.run(_c31.handle_action("auto_toggle")) # the deliberate start
|
||||
check("pressing START clears the latch in memory and on disk together, which "
|
||||
"is the same deliberate human act that clears every other latch here",
|
||||
not _c31.site_sign_wd.tripped
|
||||
and _c31.store.data.get("site_sign_tripped") is False
|
||||
and _c31.store.data.get("site_sign_reason") == "")
|
||||
|
||||
|
||||
async def _site_restart(store):
|
||||
hass = _SignHass(load_w=2000.0)
|
||||
c = _sign_site(hass, store=store, gain=-0.6)
|
||||
await _drive(c, n=3, dt=0.05)
|
||||
return c
|
||||
|
||||
|
||||
_store31 = _MemStore()
|
||||
_store31.data["site_sign_tripped"] = True
|
||||
_store31.data["site_sign_reason"] = "SIGN_DISAGREEMENT: (from before the restart)"
|
||||
_c31r = asyncio.run(_site_restart(_store31))
|
||||
check("⚠️ AND IT SURVIVES A RESTART, which is the only thing that makes it a "
|
||||
"latch rather than a pause. 'Restart the add-on' is the first thing an "
|
||||
"operator staring at a FAILSAFE banner tries, and a site that came back "
|
||||
"armed would drive straight into the fault it had just stopped for",
|
||||
_c31r.site_sign_wd.tripped and _c31r.target == 0.0
|
||||
and "sign_site" in _c31r.reason)
|
||||
|
||||
print()
|
||||
print("6. AC 4: two inverters commanded OPPOSITE SIGNS by a legitimate")
|
||||
print(" allocation, and nothing trips")
|
||||
# ⚠️ THIS IS THE FALSE TRIP THE CARD OPENS WITH, AND IT IS DRIVEN THROUGH THE
|
||||
# SHIPPED PREDICATE RATHER THAN DESCRIBED. Inverter A charging 1500 W while
|
||||
# inverter B discharges 1500 W into a 1500 W import is an ordinary two-inverter
|
||||
# arrangement - a per-unit maintenance charge on A while the loop drives B, or
|
||||
# SAFETY-25's floor pinning one unit. Nothing is faulty. Before this card the
|
||||
# meter convicted A on it.
|
||||
_A_CMD, _B_CMD, _METER = -1500.0, 1500.0, 1500.0
|
||||
_wd_a = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
|
||||
_wd_b = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
|
||||
_sitecfg = SignWatchdogConfig(
|
||||
min_w=float(SIGN_OPTION_DEFAULTS["site_sign_disagree_w"]),
|
||||
hold_s=float(SIGN_OPTION_DEFAULTS["site_sign_disagree_s"]))
|
||||
_wd_s = SignWatchdog(_sitecfg)
|
||||
for _t in range(0, 1200, 5):
|
||||
_wd_a.evaluate(_A_CMD, _A_CMD, True, T0 + timedelta(seconds=_t))
|
||||
_wd_b.evaluate(_B_CMD, _B_CMD, True, T0 + timedelta(seconds=_t))
|
||||
_wd_s.evaluate(_A_CMD + _B_CMD, _METER, True, T0 + timedelta(seconds=_t))
|
||||
print(f" A commanded {_A_CMD:+.0f} W and measured {_A_CMD:+.0f} W; "
|
||||
f"B {_B_CMD:+.0f} / {_B_CMD:+.0f}; meter {_METER:+.0f} W")
|
||||
check("PRECONDITION: THE COMPARISON THAT SHIPPED CONVICTS INVERTER A HERE, on "
|
||||
"every one of these samples. A is commanded to CHARGE while the meter "
|
||||
"reads IMPORT - opposed, both far over 300 W, and it would have held for "
|
||||
"20 minutes. That is the false trip, and it is asserted rather than "
|
||||
"asserted about",
|
||||
disagrees(_A_CMD, _METER, True, _scfg) is True)
|
||||
check("⚠️ AC 4. NEITHER UNIT TRIPS UNDER THE NEW COMPARISON, over 20 minutes at "
|
||||
"an 85 s hold. Each one is doing exactly what it was told, and its own "
|
||||
"power sensor is the instrument that can say so",
|
||||
not _wd_a.tripped and not _wd_b.tripped)
|
||||
check("...and the SITE watchdog does not trip either, because the shares SUM "
|
||||
"to zero and a 0 W total is not a wrong-direction command - the "
|
||||
"magnitude gate that has always been there, now applied to the quantity "
|
||||
"it belongs on", not _wd_s.tripped and _wd_s.disagree_since is None)
|
||||
# ⚠️ AND THE COUNTERFACTUAL, BECAUSE "nothing tripped" IS ALSO WHAT A DISARMED
|
||||
# GUARD REPORTS. Same site, same meter, but the shares no longer cancel: a NET
|
||||
# -1600 W of charge into a +1500 W import is the site genuinely driving the
|
||||
# wrong way, and it must trip. Without this line the check above passes
|
||||
# identically against a site watchdog that was never wired to anything.
|
||||
_wd_s_net = SignWatchdog(_sitecfg)
|
||||
for _t in range(0, 600, 5):
|
||||
_wd_s_net.evaluate(-1600.0, _METER, True, T0 + timedelta(seconds=_t))
|
||||
check("...and the site question is not thereby disarmed: the same site, "
|
||||
"commanded a NET -1600 W into the same import, DOES trip",
|
||||
_wd_s_net.tripped)
|
||||
|
||||
print()
|
||||
print("7. AC 8: at ONE inverter this card changes NOTHING, and 'nothing' is")
|
||||
print(" asserted rather than assumed")
|
||||
|
||||
|
||||
async def _n1_sign():
|
||||
hass = _SignHass(load_w=2000.0)
|
||||
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": -0.6,
|
||||
"sign_disagree_w": 300, "sign_disagree_s": 1,
|
||||
"auto_start": True}
|
||||
c = Controller(opts, hass, _MemStore(), _P(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -5000.0, 5000.0
|
||||
hass.ctrl = c
|
||||
# ⚠️ A CALL COUNTER, NOT AN OBSERVATION OF THE DETECTOR'S STATE, AND A
|
||||
# MUTATION IS WHY. The first version of the check below read
|
||||
# `site_sign_wd.disagree_since is None` - and arming the site detector at
|
||||
# N=1 as well left this suite GREEN, because the unit detector trips first
|
||||
# at its 1 s hold, parks the inverter, and the site's standing command is
|
||||
# then 0 W: the site predicate is false, so its window is never opened and
|
||||
# "never evaluated" reads identically to "evaluated and found nothing". That
|
||||
# is MUTATION.md's third failure mode - the assertion ran, the sentence was
|
||||
# true, and the data could not reach the failure it names. A counter can
|
||||
# only be zero for the one reason.
|
||||
_n1_calls = []
|
||||
_orig_eval = c.site_sign_wd.evaluate
|
||||
|
||||
def _spy(*a, **kw):
|
||||
_n1_calls.append(a)
|
||||
return _orig_eval(*a, **kw)
|
||||
|
||||
c.site_sign_wd.evaluate = _spy
|
||||
await _drive(c, n=12, dt=0.2)
|
||||
return c, _n1_calls
|
||||
|
||||
|
||||
_c1s, _n1_site_calls = asyncio.run(_n1_sign())
|
||||
_inv1 = _c1s.inverters[0]
|
||||
print(f" roster={_c1s.units} armed against {_inv1.sign_wd.against!r} "
|
||||
f"at {_inv1.sign_wd.cfg.min_w:.0f} W / {_inv1.sign_wd.cfg.hold_s:.0f} s")
|
||||
check("the single unit is graded against THE METER, at the SHIPPED thresholds "
|
||||
"- §6.2's identity, and the whole compatibility promise of this card. "
|
||||
"The unit's own power is not consulted, and `unit_sign_disagree_*` does "
|
||||
"nothing on a one-inverter install",
|
||||
_inv1.sign_wd.against == AGAINST_METER
|
||||
and (_inv1.sign_wd.cfg.min_w, _inv1.sign_wd.cfg.hold_s) == (300.0, 1.0))
|
||||
check("...it trips, on the same evidence and through the same UNIT `failsafe` "
|
||||
"claim as before, commanding 0 W",
|
||||
_inv1.sign_wd.tripped and _c1s.target == 0.0
|
||||
and "failsafe" in _c1s.reason and "sign_site" not in _c1s.reason)
|
||||
check("...the reason sentence still says 'a meter reading', word for word - it "
|
||||
"is what an operator reads, what the action log carries and what is "
|
||||
"persisted, so it is graded on the literal",
|
||||
"against a meter reading" in _inv1.sign_wd.reason)
|
||||
check("...the latch is under the same namespaced key with the same flat "
|
||||
"rollback mirror, and SAFETY-30's site pair is untouched at its default. "
|
||||
"A one-inverter install's state file is byte-identical to what it was",
|
||||
_c1s.store.data.get(TRIPPED_KEY) is True
|
||||
and _c1s.store.data.get("sign_tripped") is True
|
||||
and _c1s.store.data.get("site_sign_tripped") is False
|
||||
and _c1s.store.data.get("site_sign_reason") == "")
|
||||
print(f" site detector evaluated {len(_n1_site_calls)} times over "
|
||||
f"12 cycles")
|
||||
check("⚠️ ...AND THE SITE DETECTOR IS NEVER ARMED. Not 'did not trip', and not "
|
||||
"'its window stayed closed' - NEVER CALLED, counted on the object over "
|
||||
"twelve cycles. That is what says the second detector is ABSENT at a "
|
||||
"roster of one rather than merely quiet, and arming it would be two "
|
||||
"latches and two banners for one fault with a false-positive record "
|
||||
"measured on ONE comparison spent twice",
|
||||
_n1_site_calls == []
|
||||
and not _c1s.site_sign_wd.tripped
|
||||
and _c1s.site_sign_wd.disagree_since is None)
|
||||
check("...the banner is the sentence that shipped",
|
||||
_c1s.status_payload()["banner"]
|
||||
== "FAILSAFE — the command opposed the meter. Inverter held at 0 W.")
|
||||
check("...the status page carries ONE sign row, with no unit tag and no site "
|
||||
"row - an extra row for an identically-duplicated question is a page "
|
||||
"advertising two guards where the install has one",
|
||||
len([ck for ck in _c1s.checks()
|
||||
if "sign watchdog" in ck["text"] or "SIGN-DISAGREEMENT" in ck["text"]
|
||||
]) == 1
|
||||
and not any("site sign" in ck["text"].lower() for ck in _c1s.checks()))
|
||||
check("...and no `sign_site` claim can be filed at a roster of one, so the "
|
||||
"arbitration is byte-identical to what shipped",
|
||||
"sign_site" not in _c1s.reason)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -40,8 +40,15 @@ DECISIONS/CFG-04-inverter-roster.md:
|
||||
reconstruction happens to lack FIRST, which is an artefact of the
|
||||
reconstruction and NOT evidence about `inverters`. B is that evidence.
|
||||
C. A two-inverter blob validates, per-entry types enforced.
|
||||
D. An entry missing `name`, `setpoint_entity` or `max_w` is refused BY NAME,
|
||||
and a JSON string where the list belongs is refused outright.
|
||||
D. An entry missing `name`, `setpoint_entity`, `max_w` or `bms_soc_floor` is
|
||||
refused BY NAME - one key varied from an ACCEPTED baseline, and the key's
|
||||
own name asserted in the refusal - and a JSON string where the list belongs
|
||||
is refused outright.
|
||||
|
||||
⚠️ RE-RUN 2026-09-04 FOR SAFETY-32, which made `bms_soc_floor` a REQUIRED
|
||||
per-entry key. That change broke §C and, worse, left §D printing PASS on a
|
||||
refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C
|
||||
now carries the key and §D is an isolated counterfactual asserting the key name.
|
||||
"""
|
||||
import sys, json, copy
|
||||
sys.path.insert(0, "/usr/src/supervisor")
|
||||
@@ -145,13 +152,22 @@ show("the same blob MERGED with these defaults - what App.options actually is",
|
||||
f"max_w={out.get('max_w')!r}" if ok else out)
|
||||
|
||||
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
|
||||
# ⚠️ SAFETY-32 ADDED `bms_soc_floor` AS A REQUIRED PER-ENTRY KEY, and this blob
|
||||
# is what proves the operator can still POST a two-inverter config. The TWO
|
||||
# VALUES DIFFER ON PURPOSE (10 and 15): equal values would not exercise a
|
||||
# per-entry number at all, and this file's own §B is the record of what an
|
||||
# instrument that cannot distinguish its two cases is worth.
|
||||
# ⚠️ NEITHER IS A MEASUREMENT OF INVERTER B. Only inverter A's 10 % is read off
|
||||
# hardware (register 0x0560 on the reference GW5000S-BP); there is no second
|
||||
# pack on this site, so B's 3000 W and its 15 % are BOTH placeholders in a
|
||||
# schema-shape probe and must not be quoted as anything else.
|
||||
TWO = dict(LIVE, setpoint_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
|
||||
"max_w": 5000},
|
||||
"max_w": 5000, "bms_soc_floor": 10},
|
||||
{"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": 3000}])
|
||||
"max_w": 3000, "bms_soc_floor": 15}])
|
||||
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
||||
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
|
||||
# The validated blob, for anyone who wants to POST it. Optional: the probe's
|
||||
@@ -162,14 +178,38 @@ except OSError:
|
||||
pass
|
||||
|
||||
print("\n== D. what the schema itself refuses, per entry ==")
|
||||
for title, entry in (
|
||||
("no name", {"setpoint_entity": "number.b", "max_w": 3000}),
|
||||
("no setpoint_entity", {"name": "inverter-b", "max_w": 3000}),
|
||||
("no max_w", {"name": "inverter-b", "setpoint_entity": "number.b"}),
|
||||
("max_w over the range", {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 9999})):
|
||||
ok, out = validate(dict(TWO, inverters=[entry]))
|
||||
show(f"an entry with {title} is refused", not ok, out)
|
||||
ok, out = validate(dict(TWO, inverters="[]"))
|
||||
# ⚠️ ONE KEY VARIED FROM AN ACCEPTED BASELINE, AND THE REFUSAL IS ASSERTED BY
|
||||
# NAME - §B's two lessons, applied here because SAFETY-32 is what broke this
|
||||
# section. These entries used to omit several keys at once and assert a bare
|
||||
# `not ok`; the moment `bms_soc_floor` became required, the "no max_w" case went
|
||||
# on printing PASS while the refusal it printed said `Missing option
|
||||
# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of
|
||||
# anything, so the baseline below is COMPLETE and each case deletes exactly one
|
||||
# key - or, for the range case, moves exactly one value out of range.
|
||||
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 3000,
|
||||
"bms_soc_floor": 10}
|
||||
# MERGED with the shipped defaults, exactly as §C is and as App.options is: the
|
||||
# raw LIVE blob is missing root keys unrelated to this section (`maintenance_
|
||||
# start_hour` on the run that caught it), and a baseline refused for one of THOSE
|
||||
# is the provenance problem §B2 is kept as a warning about.
|
||||
D_BASE = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO))
|
||||
ok, out = validate(dict(D_BASE, inverters=[D_FULL]))
|
||||
show("the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere "
|
||||
"else to come from", ok, json.dumps(out.get("inverters")) if ok else out)
|
||||
for title, entry, expect in (
|
||||
("no name", {k: v for k, v in D_FULL.items() if k != "name"}, "name"),
|
||||
("no setpoint_entity",
|
||||
{k: v for k, v in D_FULL.items() if k != "setpoint_entity"}, "setpoint_entity"),
|
||||
("no max_w", {k: v for k, v in D_FULL.items() if k != "max_w"}, "max_w"),
|
||||
("no bms_soc_floor (SAFETY-32)",
|
||||
{k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"),
|
||||
("max_w over the range", dict(D_FULL, max_w=9999), "at most 5000"),
|
||||
("bms_soc_floor over the range (SAFETY-32)",
|
||||
dict(D_FULL, bms_soc_floor=99), "at most 50")):
|
||||
ok, out = validate(dict(D_BASE, inverters=[entry]))
|
||||
show(f"an entry with {title} is refused, and the refusal NAMES it",
|
||||
(not ok) and expect in str(out), out)
|
||||
ok, out = validate(dict(D_BASE, inverters="[]"))
|
||||
show("a JSON STRING where the list belongs is refused", not ok, out)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user