Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
834e7ca44b | ||
|
|
5d7e275df4 | ||
|
|
24c3d87d60 | ||
|
|
d5b9317d57 | ||
|
|
e2efc765af | ||
|
|
357cb4f07f | ||
|
|
564ba11c11 | ||
|
|
3033420dd7 | ||
|
|
a8bd2b9330 | ||
|
|
9e42cecc24 | ||
|
|
9b270a66a5 | ||
|
|
bbc903b2b4 | ||
|
|
d8e02ad3f8 | ||
|
|
4482cd8201 | ||
|
|
3527c8a04c | ||
|
|
8790c4d38f | ||
|
|
1d393ced22 | ||
|
|
3516947b5e | ||
|
|
86eaef2517 | ||
|
|
26c5ee8adc | ||
|
|
f72c48dbfd | ||
|
|
fef6ef20a4 | ||
|
|
3993f9f32b | ||
|
|
19decf433f | ||
|
|
8beba0a3e7 | ||
|
|
a4c95f83ce | ||
|
|
ffbabea8ce | ||
|
|
f9e7fbf86d | ||
|
|
67e649ad6b | ||
|
|
a9993aeda2 | ||
|
|
3934ad7ea5 | ||
|
|
905b6fd2b3 | ||
|
|
65cddf35f1 | ||
|
|
1aef19e995 | ||
|
|
2d025eebf7 | ||
|
|
ae21b71824 | ||
|
|
18f052a071 | ||
|
|
d5c1577212 | ||
|
|
f000547518 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
# MAINT-05 — one recalibration per pack, one at a time, and what the card left open
|
||||
|
||||
**Status: DECIDED 2026-09-04.** There is one `Maintenance` per roster entry,
|
||||
each with its own schedule, phase and history. The site holds exactly **one
|
||||
maintenance slot**: at most one pack may be in a power-moving phase, the slot is
|
||||
derived from the packs' own stored phases rather than persisted separately, and
|
||||
when several packs are due in the same hour it goes to the one that has gone
|
||||
**longest without a completed cycle**. The schedule is **not** staggered by
|
||||
construction — the interlock defers, and the stagger emerges from it.
|
||||
|
||||
**Fix round 2026-09-04.** The store migration is now graded against the
|
||||
**roster** rather than against the unit name it was handed — see §2's
|
||||
*"The read-back postcondition is graded against the ROSTER"* — and the same
|
||||
hole in SAFETY-02's latch migration is recorded in §7.
|
||||
|
||||
Filed with MAINT-05. Card `6a9a9e8503872905f298d80a`.
|
||||
|
||||
---
|
||||
|
||||
## 0. What the card asked to be decided, and the answers
|
||||
|
||||
| the card's open question | the decision |
|
||||
|---|---|
|
||||
| §2 the arbitration | **one pack at a time; the other stays under the loop.** Nothing preempts a cycle in flight. |
|
||||
| §2 who wins when several are due | **the longest-waiting pack**, `last_completed` oldest first, `inf` for never-recalibrated. Roster order breaks a numeric tie and nothing else. |
|
||||
| §4 staggering | **not by construction.** The interlock defers the second pack; the stagger is what comes out of it. §3 below. |
|
||||
| AC 7 rule 6's `maint_unit` comment | **no, the first entry's cap is no longer right.** It grades against the weakest cap on the roster now, like rule 5. §4 below. |
|
||||
| the store layout at N=1 | **flat, unchanged.** The namespace appears the day the roster grows. §2 below. |
|
||||
|
||||
## 1. Why the slot is derived and not persisted
|
||||
|
||||
The holder is *"whichever pack's stored phase is not `idle`"* — a fact already on
|
||||
disk, written by the packs themselves in the per-pack history this card
|
||||
introduces. A `maint_slot_holder` key would be a **second opinion about the same
|
||||
thing**, and the two would disagree the first time the add-on was killed between
|
||||
writing the phase and writing the holder. Derived, a controller that comes back
|
||||
up mid-drain reads the drain and re-derives the holder from it; there is no
|
||||
recovery path to get wrong because there is no second fact.
|
||||
|
||||
`CHARGE` is in `POWER_MOVING` even though the control **loop** owns the setpoint
|
||||
in that phase, and that is the decision most likely to be "simplified" later.
|
||||
Pack A charging from the grid at 2000 W while pack B drains 2000 W to export is
|
||||
4 kW going round in a circle with the meter reading zero and both packs
|
||||
believing they are behaving — the circulating power [[SAFETY-31]] exists to
|
||||
detect, arriving as something this module can simply not do. `HOLD` is in the
|
||||
tuple for the other half of the sentence: it moves nothing, and it **owns the
|
||||
actuator**, so a second cycle starting under it would still be two
|
||||
recalibrations at once.
|
||||
|
||||
## 2. Why the store layout at a roster of one does not change
|
||||
|
||||
The maintenance keys are flat — `phase`, `phase_started`, `last_completed`,
|
||||
`last_start_attempt` — on every install since the add-on shipped. At one pack
|
||||
there is nothing for a namespace to distinguish that pack **from**: §6.2's
|
||||
identity, the same one `arbiter.actuator_for()` leans on when it routes every
|
||||
claim to the site actuator at N=1. So `Maintenance` is constructed with
|
||||
`unit=None` there, reads and writes the flat keys directly, and
|
||||
`migrate_maint_keys()` **returns without writing anything**. A one-inverter state
|
||||
file comes out of this card byte-identical, which is AC 6 and which
|
||||
`test_maintenance.py` part 10 asserts by driving a whole cycle and comparing the
|
||||
key set.
|
||||
|
||||
### The migration has its own marker, and that is not tidiness
|
||||
|
||||
`SCHEMA_VERSION` / `schema_version` is DEV-02's, and it is **monotone**: every
|
||||
install runs that migration exactly once. This one runs **only where the roster
|
||||
has more than one pack**, so a single counter could not express *"migrated on
|
||||
this site, deliberately not migrated on that one"* without lying to the next
|
||||
version that reads it — a one-inverter store would have to be pinned at the old
|
||||
number forever, and a genuine future v2 migration could then never be told apart
|
||||
from a site that simply never grew. `maint_schema_version` answers one question
|
||||
and answers it honestly.
|
||||
|
||||
The **three rules are shared in code**, not restated: `_move_flat_keys()` is
|
||||
rule 1 (never overwrite an existing namespaced value) and rule 2 (the flat keys
|
||||
stay in the file), `_verify_moves()` is the read-back postcondition, and the
|
||||
marker is written last. A second transcription of a migration is a second
|
||||
opinion about what "it landed" means.
|
||||
|
||||
### The read-back postcondition is graded against the ROSTER (fix round)
|
||||
|
||||
**A postcondition that reads back the namespace it was handed cannot detect
|
||||
being handed the wrong one.** That sentence is the whole finding, and this card
|
||||
shipped it broken.
|
||||
|
||||
`_verify_moves()` iterated `rep.moved` — the keys it had just written — and
|
||||
asked whether each read back equal. On a store where the write lands, it always
|
||||
does. So a migration handed a `unit` that is on no roster wrote four keys, read
|
||||
four keys back, set `verified = True`, and the caller stamped
|
||||
`maint_schema_version`. Every symptom of a clean migration, and an install's
|
||||
whole maintenance history parked under a name nothing will ever look up. And
|
||||
because the marker was stamped, **the next start skipped the migration too** —
|
||||
which is precisely the state `store.py`'s header says must never happen: a
|
||||
failed migration a later version treats as done.
|
||||
|
||||
Reproduced on the reviewer's roster, a house with a garage pack and a shed pack,
|
||||
carrying a flat `last_completed` of 2026-08-07:
|
||||
|
||||
| | `unit` the migration was handed | key written | pack 0 reads back | due now? |
|
||||
|---|---|---|---|---|
|
||||
| correct | `self.inverters[0].name` = `garage` | `unit/garage/last_completed` | `2026-08-07` | **False** |
|
||||
| wrong | `inverter.UNIT_A` = `inverter-a` | `unit/inverter-a/last_completed` | `None` | **True** |
|
||||
|
||||
`build_roster()` names a declared roster's entries **from the config**, and its
|
||||
fallback for a nameless entry is a **positional** `inverter-1` — so `inverter-a`
|
||||
is not the name of anything on a declared roster, not even by default.
|
||||
|
||||
**The remedy, and why this one.** `migrate_maint_keys()` now takes the **roster**
|
||||
in place of an `n_units` count (the count was `len(self.inverters)` at the only
|
||||
call site, so the roster carries it, and a count passed beside the names is a
|
||||
second opinion about how many packs there are). `_verify_moves()` grades the
|
||||
namespace it wrote against those names, and a unit that is not among them is
|
||||
reported as `misdirected` — a **different** failure from `lost`:
|
||||
|
||||
* `lost` is *the store took the write and dropped it* — a storage fault, and it
|
||||
has a harmless-default case (a write of `last_completed: None` loses nothing,
|
||||
because "absent" and "never recalibrated" are the same statement).
|
||||
* `misdirected` is *the store did exactly as it was told, by a caller that was
|
||||
wrong*. There is no harmless-default version of it: a write nobody reads is
|
||||
not information, whatever its value. So anything in it fails `ok` on its own.
|
||||
|
||||
And **`ok` gates the marker**, so a misdirected migration is not stamped and the
|
||||
next start runs it again. The argument for it is asymmetric: a re-run is
|
||||
recoverable — rule 1 makes it a no-op where keys did land, and rule 2 left the
|
||||
flat keys in the file to recover *from* — while a silent skip is not recoverable
|
||||
at all, because the store now claims the migration happened.
|
||||
|
||||
### ⚠️ THE PARAGRAPH ABOVE USED TO END HERE, AND AS IT STOOD IT WAS FALSE
|
||||
|
||||
**The refusal lasted exactly one start. The second start stamped the marker.**
|
||||
That is a correction to what the first fix round wrote down, not a new
|
||||
qualification of it, and it is recorded here because a decision document that
|
||||
states a guarantee the code does not provide is worse than one that says
|
||||
nothing.
|
||||
|
||||
`_verify_moves()` graded only `rep.moved`. Measured at Controller level over
|
||||
**one persisted store**, roster `["garage", "shed"]`, flat `last_completed`
|
||||
2026-08-07, with the call site still handing `inverter-a` on **both** starts:
|
||||
|
||||
```
|
||||
construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent> (withheld)
|
||||
construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1 (STAMPED)
|
||||
history parked under unit/inverter-a/…; marker stamped after ONE restart.
|
||||
```
|
||||
|
||||
On start 2 the same wrong call site finds the four keys it wrote already
|
||||
present, so **rule 1** — never overwrite a namespaced value — routes every one
|
||||
of them to `rep.kept`. `rep.moved` is empty, the roster-grading loop iterates
|
||||
over nothing, `misdirected` is empty, `ok` is True, the marker is stamped, and
|
||||
start 3 takes the `from_version >= MAINT_SCHEMA_VERSION` early return **forever
|
||||
— including after the call site is fixed**. Loud-and-recoverable became
|
||||
silent-and-permanent after one restart: exactly the state `store.py`'s header
|
||||
says must never happen, reached by the fix written to prevent it.
|
||||
|
||||
**The remedy is one line**, and it is sound by construction rather than by
|
||||
argument: grade `set(rep.moved) | set(rep.kept)`. Every entry in *both* lists is
|
||||
a `unit_key(unit, legacy)` built from the **same** `unit` argument, so grading
|
||||
`kept` grades the same namespace decision `moved` does. A correct call site's
|
||||
`kept` entries are under a roster name and pass; a wrong one cannot launder its
|
||||
namespace by having already written it. `migrate_unit_keys()` passes
|
||||
`roster=None` and returns before the loop, so it is unaffected.
|
||||
|
||||
**Why the check missed it, which is the part worth keeping.** Part 9b's recovery
|
||||
check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` — the **corrected** name.
|
||||
It measures recovery along the one path where the operator has *already fixed
|
||||
the bug*. The real failure is a call site that is still wrong on the next start,
|
||||
and nothing in the 19 files called `migrate_maint_keys()` twice with the **same
|
||||
wrong** `unit`. The check was true, passed, and could not reach the failure its
|
||||
sentence named — MUTATION.md's third defect, this time sitting inside the fix
|
||||
written to close it. Part **9c** now drives two starts with the same wrong unit
|
||||
over one store, asserts the marker is still withheld on the second, and asserts
|
||||
the *shape* of the second report (`moved` empty, `kept` full) so it cannot
|
||||
quietly revert to 9b's path.
|
||||
|
||||
### The marker gate is `rep.ok` and nothing weaker — both guards, each pinned
|
||||
|
||||
`if rep.ok:` is `not lost and not misdirected`, and until this round only the
|
||||
`misdirected` half was reachable by a check that also had a non-empty `kept`.
|
||||
The mutation
|
||||
|
||||
```
|
||||
if rep.ok: -> if rep.ok or rep.kept:
|
||||
```
|
||||
|
||||
stamps the marker on a migration that **lost a month of history** — defeating
|
||||
the older, settled `lost` guard as well as the new one — and **all 19 suites
|
||||
were green on it**. Part **9d** is the fixture that separates them: two keys
|
||||
already namespaced (`kept` non-empty, so the mutant's `or` fires) and two keys
|
||||
whose write the store drops (`lost` non-empty, so `ok` is False), with
|
||||
`misdirected` empty throughout so the section cannot pass on the other guard by
|
||||
accident. Measured differential on it: shipped → marker absent; mutant → marker
|
||||
`1`, both reporting `ok=False lost=2 kept=2`.
|
||||
|
||||
**Why the fixtures had to change, and this is the part that got the card
|
||||
rejected.** Nothing in the repository could see whether any of the above was
|
||||
true, because `inverter.UNIT_A` **is** the string `"inverter-a"` and every
|
||||
two-pack fixture in `test_maintenance.py` named its first roster entry
|
||||
`inverter-a`. `self.inverters[0].name` and `UNIT_A` were therefore the same
|
||||
value in every check that existed, and swapping one for the other at the call
|
||||
site left **all 19 suites green**. MUTATION.md's third defect exactly — a check
|
||||
whose *data* cannot reach the failure it names — and the fifth consecutive card
|
||||
to carry it. Parts 9 and 11 now run on `["garage", "shed"]`, each behind a
|
||||
**precondition check** asserting that the first name is not `UNIT_A`, so an edit
|
||||
that makes them equal again goes red instead of making the claims vacuous.
|
||||
|
||||
### And the flat keys are kept LIVE, which is the half DEV-02 had to add later
|
||||
|
||||
Rule 2 originally said the flat keys are left in place and go stale. DEV-02
|
||||
amended it, for the sign latch, to *"left in place is not enough"* — and the
|
||||
same sentence holds for history. A fossil dated the day the second inverter was
|
||||
declared would hand a rolled-back single-FSM add-on a `last_completed` months
|
||||
old (it recalibrates a pack that does not need it) or a stale `phase` with a
|
||||
`phase_started` old enough to abort on the 12 h timeout the moment it comes up.
|
||||
So `UnitHistory` **mirrors the first pack's writes back onto the flat keys** for
|
||||
as long as the roster has more than one entry. Only the first pack: the flat
|
||||
spelling means `inverters[0]` on every install that has one, and a mirror from
|
||||
pack B would overwrite pack A's rollback view with pack B's phase.
|
||||
|
||||
## 3. Staggering: the interlock defers, and the stagger is the residue
|
||||
|
||||
Two packs on one monthly schedule are due in the same hour every month. The
|
||||
options were a stagger **by construction** — offset each pack's first cycle by
|
||||
`interval_days / N` — or **deferral**. Deferral, for three reasons:
|
||||
|
||||
1. **A constructed stagger cannot remove the need for the interlock.** A forced
|
||||
start, a commissioning cycle, an abort-and-retry, or a pack added mid-month
|
||||
desynchronises any static offset immediately, so the interlock has to be
|
||||
there anyway — and then the stagger is a second mechanism that must also be
|
||||
correct, for a case the first one already handles.
|
||||
2. **A deferral is observable and a stagger is not.** The deferred pack says, in
|
||||
the action log and on the status page, that it is waiting, which pack it is
|
||||
waiting for, and that it will run at the next start hour. A silent offset
|
||||
moves an operator's configured start day and explains nothing.
|
||||
3. **The stagger comes out of the deferral for free and then persists.** Pack A
|
||||
completes on day 1 and is next due on day 29; pack B, deferred, completes on
|
||||
day 2 and is next due on day 30. One contested month separates them by one
|
||||
cycle length and they stay separated.
|
||||
|
||||
**Starvation was the risk and it is closed at two points.** A deferral does
|
||||
**not** stamp `last_start_attempt` — that key is the post-abort cooldown, and at
|
||||
90 minutes inside a 60-minute start hour, stamping it would lock the deferred
|
||||
pack out of exactly the hour it is waiting for. And the winner is the
|
||||
longest-waiting pack rather than the first in the roster, so a pack that keeps
|
||||
aborting cannot monopolise the slot month after month while its sibling drifts —
|
||||
which is this card's own defect wearing a different costume.
|
||||
|
||||
**The deferral must not read as a fault.** §12's 12 h and 36 h phase timeouts
|
||||
mean *"the battery is not following commands"*. A deferred pack stays `IDLE`
|
||||
with `phase_started` untouched, so no timeout is reachable by waiting, and the
|
||||
line it logs says WAITING, names the pack it waits for and says it will run
|
||||
next — none of which any failure path in `maintenance.py` says.
|
||||
|
||||
## 4. Rule 6 (the card calls it rule 8): the first entry's cap is no longer right
|
||||
|
||||
`validate.py`'s rule 6 comment said, in as many words, that
|
||||
`maintenance_charge_w` was graded against `inverters[0]`'s cap because there was
|
||||
exactly one `Maintenance` and it could only ever recalibrate the first pack —
|
||||
and that taking `min()` would be an over-refusal, because it would refuse *"a
|
||||
5000 W pack being recalibrated beside a 2000 W sibling the cycle never
|
||||
touches"*. It closed: **"when `maint_unit` stops being `inverters[0]`, this line
|
||||
moves with it."**
|
||||
|
||||
It has stopped. Every pack is recalibrated on its own schedule, so **the sibling
|
||||
the cycle never touches does not exist** and the over-refusal argument has no
|
||||
subject. The shaping claim is filed on whichever pack is charging, so the cap
|
||||
that can contradict it is any cap on the roster and the one that contradicts it
|
||||
first is the smallest.
|
||||
|
||||
**This refuses configurations that used to start**, and that is the point rather
|
||||
than a side effect: `max_w` 5000 on pack A beside 2000 on pack B with
|
||||
`maintenance_charge_w` 3000 was correct while only pack A was ever recalibrated,
|
||||
and is now a cheap-window charge asking the arbiter for `hi = -3000` against a
|
||||
supervised `lo = -2000` — `lo > hi`, arbiter clause 3, 0 W, on the pack nobody
|
||||
was watching. Refused when it is **written**, which is rule 6's whole job. At a
|
||||
roster of one `min()` over one cap is that cap: same comparison, same message,
|
||||
same boundary.
|
||||
|
||||
## 5. The claim stays per-unit, and nothing here files a site claim
|
||||
|
||||
`arbiter.CLAIM_SCOPE` has classified `maintenance` as `SCOPE_UNIT` since it was
|
||||
written. Two FSMs mean two claims on two actuators, which the allocator consumes
|
||||
as per-unit intervals. [[ARCH-01]] is explicit that this must never become a
|
||||
site claim, because per-inverter constraints filed site-wide are the
|
||||
cross-inverter `lo > hi` contradiction at `arbiter.py:114`. The only line this
|
||||
card changed there is **which** roster entry the claim is filed on: the one whose
|
||||
state machine produced the result, rather than `inverters[0]`.
|
||||
|
||||
## 6. FOUND AND NOT FIXED: the site loop still contradicts a drain at N>=2
|
||||
|
||||
Measured while wiring this card, and **pre-existing on `release/1.0` at
|
||||
`2d025ee`** — the same drain filed on `inverters[0]` produces the identical
|
||||
refusal with the two unit names swapped.
|
||||
|
||||
The maintenance SET **pins** its pack. The loop's target is a **site** claim.
|
||||
When the site target is *below* the pin — a 2000 W drain while the house imports
|
||||
less than that, which is most of a night — the only split that sums to the site
|
||||
target asks the sibling to **charge** while the maintained pack **discharges**,
|
||||
and `allocator.plan()` refuses it by name as circulating power. The refusal is
|
||||
correct and fails safe: the whole site goes to 0 W with an `ARBITER: REFUSED`
|
||||
line in the action log.
|
||||
|
||||
**The consequence an operator would see is a misdiagnosis.** The pack is held at
|
||||
0 W while its state machine believes it is draining, so after 12 h the DRAIN
|
||||
timeout fires and reports *"drain stuck"* — §12's words for a battery that is not
|
||||
following commands. The battery is fine; the site refused to allocate.
|
||||
|
||||
**Why it is not fixed here.** The fix is to change what the site loop's target
|
||||
*means* while a unit is pinned — regulate the meter using only the unpinned
|
||||
packs — which is loop and allocator territory and changes the integrator's
|
||||
subject. MAINT-05 moves which pack is pinned and nothing else about it. It is
|
||||
pinned as a running check (`test_maintenance.py` part 12) rather than left as a
|
||||
comment, so it cannot be forgotten or quietly tuned away, and it wants a card of
|
||||
its own.
|
||||
|
||||
## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH
|
||||
|
||||
Found while fixing §2's instance. It is the same defect one module over and it
|
||||
costs a **disarmed safety guard** rather than a lost history.
|
||||
|
||||
`Controller.__init__` calls `migrate_unit_keys(store, UNIT_A)` — a hardcoded
|
||||
constant where the maintenance migration now passes the roster's own first name.
|
||||
It **has** to run before `build_roster()`, because each `Inverter` restores its
|
||||
sign latch in its constructor, so there is no roster to name yet and
|
||||
`_verify_moves()` is passed `roster=None`. On a **declared** roster a pre-DEV-02
|
||||
`sign_tripped: true` therefore lands under `unit/inverter-a/sign_tripped`, which
|
||||
no unit on that site reads.
|
||||
|
||||
**And the guard that exists for exactly this is defeated by the key the defect
|
||||
wrote.** `_grade_store_migration()` re-arms the latch when the flat key says
|
||||
tripped and *nothing namespaced is latched*. The misdirected write **is**
|
||||
namespaced and **is** latched, so `namespaced_latched` is `True`, the guard
|
||||
returns, and no inverter on the roster comes up tripped — on an install whose
|
||||
`/data/state.json` still reads `sign_tripped: true`. That is DEV-02's own
|
||||
sentence for the worst case: the state file says protected, the banner is green,
|
||||
and the controller resumes commanding into the fault that tripped it.
|
||||
|
||||
Measured, on the same `["garage", "shed"]` roster:
|
||||
|
||||
```
|
||||
pre-DEV-02 flat latch + declared roster ['garage', 'shed'] ->
|
||||
migrated 2 flat key(s): unit/inverter-a/sign_reason, unit/inverter-a/sign_tripped
|
||||
latch keys in the store: ['sign_tripped', 'site_sign_tripped', 'unit/inverter-a/sign_tripped']
|
||||
roster came up tripped: [('garage', False), ('shed', False)]
|
||||
the same store on the FLAT config -> [('inverter-a', True)]
|
||||
```
|
||||
|
||||
The last line is the counterfactual: on the flat shape, where `UNIT_A` really is
|
||||
the roster's only name, the same store migrates and comes up latched. So this is
|
||||
a defect of the **declared roster**, not of the migration as such.
|
||||
|
||||
**Why it is not fixed here.** The remedy is to name the migration's target from
|
||||
the config before any `Inverter` exists, which means factoring `build_roster()`'s
|
||||
naming rule out of `build_roster()` so there is still only **one** spelling of
|
||||
it — `inverter.py` and SAFETY-02's latch semantics, with its own mutation battery
|
||||
over `test_inverter.py` and `test_watchdog.py`. MAINT-05 moves the maintenance
|
||||
history and nothing about the sign latch, and a silent expansion into a safety
|
||||
latch is how a rejected PR becomes a broken one.
|
||||
|
||||
It is pinned as a running check (`test_maintenance.py` part 13) rather than left
|
||||
as a comment. ⚠️ **Its assertions state the WRONG behaviour on purpose** — read
|
||||
them as the specification of a defect; the card that fixes it should expect them
|
||||
to go red and should replace them.
|
||||
|
||||
### And its MARKER GATE had the round-3 hole too — found here, closed here
|
||||
|
||||
Separate from the namespace hole above, and **fixed** rather than recorded,
|
||||
because closing it cost one test fixture and no source change.
|
||||
`migrate_unit_keys()` gates `schema_version` on `rep.ok` exactly as
|
||||
`migrate_maint_keys()` gates its own marker, and every check that reached that
|
||||
gate ran on a store where **nothing** was namespaced yet — so `rep.kept` was
|
||||
empty in all of them and
|
||||
|
||||
```
|
||||
if rep.ok: -> if rep.ok or rep.kept:
|
||||
```
|
||||
|
||||
**survived all 19 files**. What it does is stamp the schema marker on a
|
||||
migration that DROPPED THE LATCH: the guard is off, the state file still reads
|
||||
`sign_tripped: true`, and because the store now says the migration finished, the
|
||||
next start skips the one thing that would have re-armed it. DEV-02's own worst
|
||||
case, filed under *done*.
|
||||
|
||||
`test_inverter.py` part 10 now drops the `sign_tripped` write **beside an
|
||||
already-namespaced `sign_reason`** — `lost` non-empty *and* `kept` non-empty,
|
||||
which is the one shape none of the existing fixtures had — and asserts the
|
||||
marker is withheld, with the accepted-baseline counterfactual beside it (habit
|
||||
7). The shipped gate was correct throughout; what changed is that a check can
|
||||
now see it.
|
||||
@@ -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,816 @@
|
||||
# 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.
|
||||
|
||||
## 4b. ⚠️ ROUND 4 — the sibling argument, and SPIKE-04 making this the live axis
|
||||
|
||||
Rounds 2 and 3 hardened the **floor** axis: the `max` inside
|
||||
`binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
|
||||
`pack_floor_pct()`'s conjunction. Round 4's blocker is the same family at the
|
||||
**sibling argument of the same call**:
|
||||
|
||||
```python
|
||||
self.peak_guard = peakguard.decide(
|
||||
...,
|
||||
soc=self.pack_floor_soc, # MINIMUM <- round 4
|
||||
soc_floor=peakguard.binding_soc_floor(self.packs, ...), # MAXIMUM <- rounds 2-3
|
||||
```
|
||||
|
||||
`peakguard.py`'s own docstring names them as a pair — *"MAXIMUM here pairs with
|
||||
the MINIMUM in `pack_floor_soc`"* — and a conjunction is exactly as strong as
|
||||
its weaker half. On `52380c0`, with all 19 files green and verified again after
|
||||
the rebase onto `3934ad7`:
|
||||
|
||||
| mutation in `Controller.pack_floor_soc` | bytes | before round 4 | now |
|
||||
|---|---|---|---|
|
||||
| `min(float(v) for v in vals[:2])` | +4 | **GREEN** | RED |
|
||||
| `min(float(v) for v in vals[-2:])` | +5 | **GREEN** | RED |
|
||||
| `min(float(v) for v in vals[:3])` | +4 | **GREEN** | RED |
|
||||
| `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)`, the mean | | RED | RED |
|
||||
|
||||
The suite's separating power on the SoC axis stopped at exactly **N = 2** —
|
||||
precisely the state the floor axis was in before round 3, and which round 3
|
||||
itself named as its blocker 2. A fixture of N packs cannot separate reductions
|
||||
that agree on N packs.
|
||||
|
||||
**It changes a real answer.** A three-pack roster at 50 / 60 / 11 %, emptiest
|
||||
pack **third**: the shipped property answers 11 %, `vals[:2]` answers 50 %. The
|
||||
site is then graded for discharge *permission* against a pack 39 points fuller
|
||||
than the emptiest, and spends a pack its own BMS stops — through
|
||||
`sim/inject.py`, which runs no validator rule at all. The verdict flips with it:
|
||||
11 % is below the 20 % EMS floor, so the shipped site is floor-limited and sizes
|
||||
no forced discharge, where the mutant is permitted and sizes a positive one.
|
||||
|
||||
### ⚠️ SPIKE-04 makes the differing-floor discipline the entire exposure
|
||||
|
||||
SPIKE-04 reads pack B as **4 × Dyness DL5.0C, DoD 90 → floor 10 %** — **FROM THE
|
||||
DATASHEET AND THE SERIAL. Inverter B does not physically exist (~8 days out) and
|
||||
nothing about it has been measured.** Pack A is DoD 90 → 10 % as well, and *that
|
||||
one is measured*, off register `0x0560` on the reference GW5000S-BP.
|
||||
|
||||
So **production is degenerate on the floor axis**: both packs floor at the same
|
||||
value, `max(10, 10) == 10`, and a differing floor exists **only in the
|
||||
fixtures**. That is not an argument for simplifying the fixtures to matching
|
||||
values — it is the argument for keeping them. `binding_soc_floor()`'s reduction
|
||||
is observable *nowhere else*, and the moment a third pack, a replacement pack or
|
||||
a different chemistry arrives it is observable in the field. **Anyone who
|
||||
"simplifies" `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to one number deletes the only
|
||||
data this repository has that can see the reduction at all**; part 12c2's
|
||||
precondition check goes red if they do, and this paragraph is why.
|
||||
|
||||
The **SoC axis has no such degeneracy**. Two batteries at the same state of
|
||||
charge is a coincidence, not a configuration, so `pack_floor_soc` is the half of
|
||||
the pair that will be non-degenerate in the field from the day inverter B is
|
||||
wired up.
|
||||
|
||||
### The treatment, transferred from round 3
|
||||
|
||||
* **the roster SET** — six real `Controller.cycle()` runs, the emptiest pack at
|
||||
every position of a three-pack roster and both positions of a two-pack one,
|
||||
the observable read off `PeakDecision.soc` (the argument, echoed) rather than
|
||||
off a spy or a grep. Cut-outs held flat across all of them, so the floor axis
|
||||
cannot be what moved the answers.
|
||||
* **the positional-slice table and the non-slice table** — computed against the
|
||||
real `Controller.inverters` each cycle carried and the real decision it
|
||||
produced, printed, with a member that nothing kills taking the check red.
|
||||
* **the length sweep** — the real property on real rosters of 1 to 12 inverters,
|
||||
the empty pack at every position. Closes head and tail windows of width 1-11
|
||||
at once. Width 12 and wider is **honestly not reached**, and asserted to be
|
||||
unreached so a later sweep that lengthens the rosters must rewrite the
|
||||
sentence.
|
||||
* **the exact structural pin** — three of them, because there are three
|
||||
reductions over roster length in this one property: `vals` is built by one
|
||||
comprehension over the bare `self.inverters`; `any(... for v in vals)` reduces
|
||||
the whole list; `min(... for v in vals)` reduces the whole list. Every slice
|
||||
of either is red at **every** roster length, including the `[:3]` and `[:12]`
|
||||
the runs and the sweep cannot see.
|
||||
|
||||
Unlike the floor axis, **there is no uncaught row here**: the claim at
|
||||
`pack_floor_soc` is an *identity* ("this list"), so a shape pin is the right
|
||||
instrument, where `binding_soc_floor()`'s claim is *semantic* ("the maximum")
|
||||
and a shape pin there would refuse honest refactors. That asymmetry is why one
|
||||
carries an open row and the other does not.
|
||||
|
||||
## 4c. ⚠️ THE ENUMERATION — what OTHER reductions over roster length are graded
|
||||
|
||||
This card has now been rejected three times for the same shape one step over, so
|
||||
round 4 stopped closing members and enumerated the **family**. Every reduction
|
||||
over `self.inverters` / `self.packs` in `Controller` was found from the AST and
|
||||
mutated to a slice, one at a time, against the full 19-file suite.
|
||||
|
||||
| # | site | reduction | consumer | mutation | result |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `pack_floor_soc` | `min` over SoC | `decide(soc=)`, `arbitrage.decide(soc=)` | `vals[:2]` +4 | **was GREEN → now RED** (12i) |
|
||||
| 2 | `pack_floor_soc` | `any(v is None)` | same | `vals[:1]` +4 | RED (raises in 12d; pinned in 12i) |
|
||||
| 3 | `pack_floor_soc` | the `vals` comprehension | same | `self.inverters[:2]` +4 | RED (12i sweep + pin) |
|
||||
| 4 | `binding_soc_floor` | `max` over floors | `decide(soc_floor=)` | `min`, windows | RED (12c2, round 2-3) |
|
||||
| 5 | call site | the roster argument | same | `self.packs[:1..3]` | RED (12g, round 3) |
|
||||
| 6 | `Controller.packs` | the comprehension | `binding_soc_floor`, `pack_permissions` | `self.inverters[:2]` +4 | RED (12g three-pack runs) |
|
||||
| 7 | `site_batt_w` | `sum` + `any(None)` | `decide(batt_w=)` — sizes the floor | `self.inverters[:1]` +4 | RED (test_inverter.py) |
|
||||
| 8 | `site_rating_w` | `sum` of per-unit conjunction | `decide(achievable_w=)`, freeze rail | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 9 | `site_max_w` | `sum` of `max_w` | the site loop's supervised bound | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 10 | `site_dev_bound` | `sum` + `any(None)` | `looks_frozen`, `rail_unreachable` | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 11 | `site_circulating_w` | `circulating_w` over measured power | SAFETY-31 trip | `[:1]` +4 | RED (test_watchdog.py) |
|
||||
| 12 | `inputs_ok` | `all` over both readings | gates the whole cycle (§11.1 rule 1) | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 13 | `units` | `tuple` of names | the arbiter's scope table | `[:1]` +4 | RED (5 files) |
|
||||
| 14 | `status_payload` | `min(_dues)` over `self.maints` | the operator's "maintenance due" row | `_dues[:1]` +4 | RED (test_maintenance.py) |
|
||||
| 15 | `maint_unit` | `self.inverters[self.maint_index]` | which pack the FSM drives | `[0]` −15 | RED (test_maintenance.py) |
|
||||
| 16 | store migration | the roster name list | MAINT-05's key migration | `[:1]` +4 | RED (test_maintenance.py) |
|
||||
| 17 | `validate_unit_ranges` | the roster argument | startup refusal | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 18 | `cycle()` stale-input hold | `any(sign_wd.tripped)` | **a latched unit's failsafe SET** | `[:1]` +4 | **was GREEN → now RED** (test_watchdog.py part 8) |
|
||||
| 19 | `publish()` status | `any(sign_wd.tripped)` | the MQTT status vocabulary | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||||
| 20 | `status_payload()` banner | `any(sign_wd.tripped)` | the one headline on the page | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||||
| 21 | `_grade_store_migration` | `any(sign_wd.tripped)` | SAFETY-02's re-arm guard | `[:1]` +4 | was GREEN → now RED (12j), see below |
|
||||
| 22 | `cycle()` undeliverable list | `sorted(...)` over shares | the *text* of one event line | `[:1]` +4 | **STILL GREEN — honest gap, below** |
|
||||
|
||||
**Rows 18-20 are the finding, and they are not this card's code.** All three are
|
||||
`any(i.sign_wd.tripped for i in self.inverters)`, all three are SAFETY-30's, and
|
||||
all three survived a green 19-file suite for exactly the reason every blocker on
|
||||
this card survived: *the only fixture that reached them was a roster of ONE,
|
||||
where `self.inverters[:1]` IS `self.inverters`.* Part 3 of test_watchdog.py has
|
||||
built a two-inverter roster with only inverter B latched since SAFETY-30
|
||||
shipped — the precise data all three needed — and none of them read it.
|
||||
|
||||
Row 18 is the one with teeth. A latched unit must take the whole roster past the
|
||||
stale-input hold, because that unit's `failsafe` SET is the thing parking it;
|
||||
main.py's own comment says *"the trip would stop acting the moment a poll was
|
||||
missed"*. With `[:1]`, a latched inverter B plus a one-cycle gap in inverter A's
|
||||
SoC returns early, and B's last share stands for the heartbeat to re-emit.
|
||||
test_watchdog.py part 8 now drives that with the latch at **each** position, plus
|
||||
the no-latch counterfactual that proves the hold really fires.
|
||||
|
||||
**Row 21 — graded structurally, and argued to be redundant.** The `[:1]` there
|
||||
is now red on 12j's enumeration, but I could not construct a *reachable* state in
|
||||
which it changes an answer: `_grade_store_migration()` runs at startup, before
|
||||
any cycle, so a unit is tripped in memory only if its own `unit/<name>/sign_tripped`
|
||||
key was truthy — which makes `namespaced_latched` True and short-circuits the
|
||||
disjunct. I believe it is an equivalent mutant at that call site rather than a
|
||||
live hole, and I am recording the argument instead of asserting the conclusion.
|
||||
|
||||
**Row 22 — STILL GREEN, and reported with its size.** `", ".join(sorted(u for u
|
||||
in resolution.shares if not self._can_write(u)))[:1]` truncates the **names in
|
||||
one event-log line**. It is +4 bytes and no check bites. The refusal itself —
|
||||
`elif {u for u in resolution.shares if not self._can_write(u)}` — is a different
|
||||
expression and *is* graded (test_peakguard.py part 10h-1b), so the site still
|
||||
stops; what a `[:1]` costs is an operator being told one name when two units have
|
||||
no writer. It is not a roster comprehension (it iterates `resolution.shares`), so
|
||||
12j does not cover it and does not pretend to. Closing it needs a three-inverter
|
||||
fixture with two writer-less entries, which is ALLOC-01's fixture and not this
|
||||
card's. **Filed as open, with its size and its consequence, rather than closed
|
||||
quietly or left unmentioned.**
|
||||
|
||||
### And the family closed rather than the member
|
||||
|
||||
`test_peakguard.py` part 12j enumerates the family from the AST and requires
|
||||
every member to take the roster **whole**, so a new reduction over a slice is red
|
||||
**on the line it is written**, at every roster length, with no fixture required.
|
||||
That check is what would have caught rounds 2, 3 and 4's blockers on the round
|
||||
they were written instead of one review round each; it is also what found rows
|
||||
18-21.
|
||||
|
||||
⚠️ **AND IT WAS STILL TOO NARROW — see §4d.** Round 4's version enumerated the
|
||||
26 roster comprehensions inside the `Controller` class, because that is where
|
||||
the four known sites were. Round 5's blockers were four reductions in
|
||||
`validate.py`, one module over, over a list *derived* from the roster three
|
||||
calls away from it. The pin now parses every `.py` under `app/` and finds the
|
||||
roster by transitive closure from one option key: **183 reductions across 9
|
||||
modules.**
|
||||
|
||||
## 4d. ⚠️ ROUND 5 — the same family one MODULE over, and an instrument that discovers its own scope
|
||||
|
||||
Round 4 enumerated the family and closed it **inside the `Controller` class**,
|
||||
because that is where the four known sites lived. The review found four more
|
||||
survivors immediately, in `validate.py`, on a 19/19 green suite:
|
||||
|
||||
| site | mutation | delta | suite |
|
||||
|---|---|---|---|
|
||||
| `validate.py:779` | `min(caps)` → `min(caps[:1])` | +4 B | **GREEN** |
|
||||
| `validate.py:779` | `min(caps)` → `min(caps[-1:])` | +5 B | **GREEN** |
|
||||
| `validate.py:778` | `caps = [cap for _, cap in caps_ix[:1]]` | +4 B | **GREEN** |
|
||||
| `validate.py:893` | `min(caps_ix[-1:], key=lambda p: p[1])` | +5 B | **GREEN** |
|
||||
|
||||
**None of them is an equivalent mutant — a safety refusal disappears.** With
|
||||
`max_w: 5000` on inverter-a beside `2000` on inverter-b and
|
||||
`sign_disagree_w: 3000`, the shipped validator answers one problem —
|
||||
*"sign_disagree_w is 3000 W but max_w (inverter-b) is 2000 W, so the SAFETY-02
|
||||
sign-disagreement watchdog can never fire"* — and every one of the four answers
|
||||
**zero**. The site starts, with a watchdog reporting `armed` on the status page
|
||||
that cannot trip on pack B. That is rule 5's own failure mode reached **through**
|
||||
rule 5, which is the sentence `test_inverter.py:1657` says the rule exists to
|
||||
prevent. Rule 6 loses its refusal the same way.
|
||||
|
||||
### Why round 4's pin could not see them, and why the fix is not four more checks
|
||||
|
||||
`caps` and `caps_ix` are not comprehensions over `self.inverters`; they are lists
|
||||
**derived** from the roster, three function calls away from it, in a module the
|
||||
enumeration never opened. Closing them by hand would have produced a fifth
|
||||
rejection with the same shape one step over — that is the whole record of this
|
||||
card. **A hand-written scope is the same defect as a hand-written fixture**
|
||||
(MUTATION.md habit 12): a check whose data cannot reach the failure it names.
|
||||
|
||||
### The instrument
|
||||
|
||||
`test_peakguard.py` part 12j now parses **every `.py` under `app/`** off disk —
|
||||
no module is named anywhere in the check — and finds the roster by transitive
|
||||
closure from **one seed**, the `inverters` option key:
|
||||
|
||||
* a function whose returns are roster-carrying is **roster-producing**
|
||||
(`roster_entries` → `build_roster` → `unit_caps_indexed` → `unit_caps`);
|
||||
* a name assigned one carries it, **and so does a list accumulated in a loop
|
||||
over one** — which is exactly what `caps`, `roster` and `problems` are, and
|
||||
what round 4's rule could not express;
|
||||
* an attribute or property answering one carries it (`self.inverters`,
|
||||
`Controller.packs`, `Controller.units`, `live_units()`);
|
||||
* **an argument at a call site taints that parameter in the callee**, which is
|
||||
how the pin walks into `peakguard.binding_soc_floor`, `arbiter.resolve`,
|
||||
`allocator.plan`, `circulation.circulating_w`, `maintenance.arbitrate` and
|
||||
`store.migrate_maint_keys` without being told they exist;
|
||||
* and **a slice of a roster is still a roster**, so a mutation cannot cut the
|
||||
taint and hide every reduction downstream of itself. An **index** is one unit
|
||||
and is not.
|
||||
|
||||
It then requires, at every place a roster-carrying value is reduced or iterated,
|
||||
that the whole of it arrives: no `[:1]`, `[-1:]`, `[:2]` or `[0]` between the
|
||||
roster and the reduction.
|
||||
|
||||
### What it found — and the hand-written list was wrong in BOTH directions
|
||||
|
||||
**183 reductions across 9 modules**, out of 23 `.py` files parsed:
|
||||
`main.py` 96, `allocator.py` 24, `validate.py` 19, `arbiter.py` 14,
|
||||
`circulation.py` 10, `maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3,
|
||||
`store.py` 3 — and 14 modules with no roster reduction at all.
|
||||
|
||||
The card's own suggested module list named `watchdog.py`, which reduces over no
|
||||
roster at all (SAFETY-30's sign watchdogs are strictly per-unit — there is
|
||||
nothing to slice), and did **not** name `maintenance.py` or `store.py`, which
|
||||
both do. A pin built from that list would have made a vacuous claim about one
|
||||
module and no claim at all about two others. That is the argument for computing
|
||||
the scope, made with data rather than with an assertion.
|
||||
|
||||
The pin **independently rediscovers all four blockers** — it is told one option
|
||||
key and nothing else — and finds a **fifth** beside them at
|
||||
`validate.py:783`, the `min(caps_ix, key=...)` inside rule 5's message, which no
|
||||
review round named.
|
||||
|
||||
### The counterfactual, at two depths
|
||||
|
||||
* **Per site, all of them.** Every enumerated site's own expression, in its own
|
||||
scope, mutated with each of the four cuts this card has actually been defeated
|
||||
by. **183 sites × 4 cuts = 732** mutated spellings; every one reported. The same run asserts
|
||||
the predicate **accepts** the legitimate wrapped forms the package really uses
|
||||
(`enumerate`, `zip`, `sorted`, `pack_permissions(packs, floor)`) and does not
|
||||
treat a keyed lookup (`weights[u]` in `allocator._fill`) as a slice — which is
|
||||
what four false SLICED reports cost before `shortens()` was written.
|
||||
* **End to end, on the real files.** `[:1]` written into `app/validate.py` at
|
||||
each blocker position, the **whole** analysis re-run over the mutated source,
|
||||
and a sliced site required at that exact line — where the clean package
|
||||
reports none. This is the check that would have taken PR #48 red four rounds
|
||||
ago, with no fixture reaching it.
|
||||
|
||||
### The sweep, and the number that must not be quoted alone
|
||||
|
||||
All 183 sites were mutated to `[:1]`, one at a time, +4 bytes each, and each
|
||||
mutant run against the full 19-file suite in `goodwe-addon-ci` (mounted `:ro`,
|
||||
`python3 -B`, a fresh rsync per mutant; 0 `.pyc` in the tree before and after —
|
||||
a read-only mount cannot write one, so TEST-15 is closed by construction here
|
||||
rather than by discipline). **183 of 183 RED.**
|
||||
|
||||
⚠️ **On its own that is tautological**: with the pin in the tree every slice is
|
||||
red *by the pin*. So all 51 mutants whose first red was `test_peakguard.py` were
|
||||
re-run with the pin **neutralised** (`_SLICED` blanked), leaving only the
|
||||
behavioural checks:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **145 of 183** | a behavioural fixture in this repository separates the site from a slice of itself — red with the pin turned off |
|
||||
| **38 of 183** | **nothing but this pin sees it.** Structurally closed by this card; behaviourally **UNGRADED**, and listed rather than counted as covered |
|
||||
|
||||
The 38: `main.py` 27, `allocator.py` 6, `validate.py` 2, `arbiter.py` 1,
|
||||
`circulation.py` 1, `maintenance.py` 1. Grouped by what a `[:1]` there costs:
|
||||
|
||||
* **The operator's page — 15, the largest group.** `Controller.checks`
|
||||
(3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370, 3400, 3458) and
|
||||
`status_payload` (3556, 3566, 3578). A slice reports one unit's row where two
|
||||
exist, or "1 of 2 live" reading 1 of 1. Nothing commands differently; what is
|
||||
lost is the evidence a human uses to notice that it should.
|
||||
* **The write path's own counts.** `write_setpoint` 1267/1272/1280 — three
|
||||
`len(targets)` guards. The write itself iterates `targets` at 1287 and *is*
|
||||
graded (test_inverter.py); these three are its log arithmetic and refusal
|
||||
count.
|
||||
* **The allocator's internals.** `_fill`'s zero-weight last-resort pool (365),
|
||||
`plan`'s `dropped` (526, 653), `free` (584), `frac_order` (591). ALLOC-01
|
||||
drives two and three units through the *weighted* pass; the last-resort pass
|
||||
and the largest-remainder tie-break are reached, but not with enough units for
|
||||
a slice to change the answer.
|
||||
* **Startup and FSM plumbing.** `__init__` 235/351, `_only` 831,
|
||||
`_maint_button` 805, `cycle`'s maintenance zip 1505, `send_heartbeats` 3015,
|
||||
`check_output_clamp` 2445/2452, `check_circulating` 2701,
|
||||
`maintenance.arbitrate` 547, `arbiter.site_resolve` 517,
|
||||
`circulation.opposing` 242.
|
||||
* **Two refusal-TEXT sites.** `validate._roster_problems`'s `len(entries)` (554)
|
||||
and `refuse()`'s numbering loop (1263) — both shape the words of a refusal
|
||||
that still happens.
|
||||
* **`_grade_store_migration` (698)**, already row 21 of §4c, argued there to be
|
||||
an equivalent mutant at that call site. The argument is unchanged and is still
|
||||
an argument, not a proof.
|
||||
|
||||
⚠️ **None of the 38 is closed behaviourally by this card, and saying so is the
|
||||
point.** Closing sites one at a time is what cost this card four review rounds.
|
||||
The pin closes the **family** — a slice at any of them is red on the line it is
|
||||
written — and this list is what a shape pin can never promise.
|
||||
|
||||
### And the behavioural half, because a shape pin cannot grade a meaning
|
||||
|
||||
The pin grades an **identity** ("the whole roster arrives"). `min` → `max` is a
|
||||
zero-byte edit and invisible to it, forever. So `test_validate.py` grows a
|
||||
**roster SET** for the three rules that reduce over `unit_caps_indexed()`:
|
||||
rosters of width 2, 3, 4 and 5 with the one small cap at **every position** —
|
||||
14 rosters × 3 rules = 42 runs of the real validator — plus a **computed family
|
||||
table** of twelve candidate reductions (`max`, `first`, `last`, the mean, and
|
||||
`[:1]`, `[-1:]`, `[:2]`, `[:3]`, `[1:]`, `[:-1]`, `[::2]`) each compared against
|
||||
what the shipped validator actually said on the same roster. Only `min` agrees
|
||||
everywhere; any member no roster kills is **printed as a named survivor** and
|
||||
takes the check red.
|
||||
|
||||
### ⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
|
||||
|
||||
Found by reading the three branches side by side — **not** by the pin, and said
|
||||
that way because it is the shape of what the pin cannot see. Rule 5's sibling
|
||||
branch, `unit_sign_disagree_w` (SAFETY-30's per-inverter sign watchdog), was
|
||||
graded against `_number(opts, "max_w", 2000.0)` — the **flat** option — three
|
||||
lines under a branch that had already stopped doing that. Both directions were
|
||||
measured before the fix:
|
||||
|
||||
* a roster of two 2000 W units beside a leftover `max_w: 5000` from a
|
||||
single-inverter install, with `unit_sign_disagree_w: 3000`, produced **zero
|
||||
problems**. The site started, the status page said `armed`, and the per-unit
|
||||
watchdog could not trip on **either** unit. Identical sentence to the blocker,
|
||||
reached through the branch beside it;
|
||||
* and a roster of two 5000 W units with no flat key was **refused** at 3000 W
|
||||
against a 2000 W *default the operator never wrote*, for a threshold both
|
||||
units can reach.
|
||||
|
||||
It takes the same `weakest` the branch above takes, from the same list, and
|
||||
names the same unit. At a roster of one `unit_caps()` is `[max_w]`, so the
|
||||
shipped single-inverter refusal is the same comparison, the same boundary and
|
||||
the same sentence. Both directions are asserted in `test_validate.py`.
|
||||
|
||||
### ⚠️ WHAT THE PIN CANNOT SEE — open rows, with sizes
|
||||
|
||||
1. **Semantics.** `min` vs `max` vs the mean: zero-byte edits, invisible to any
|
||||
shape pin. Only the behavioural sets above can see them, and they only see
|
||||
the rules they drive.
|
||||
2. **Direct index sites that are not reductions.** `self.inverters[0]` in
|
||||
`_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in
|
||||
`maint_unit`, `entries[index]` in `_unit_label()`. Single-unit lookups, meant
|
||||
to be, graded where they live.
|
||||
3. **A subscript by a NAME.** `weights[u]`, `cap[u]`, `add[u]` in
|
||||
`allocator._fill` are keyed lookups into a roster-derived mapping and are
|
||||
accepted deliberately, so `caps[i]` with a variable `i` would pass. Not in
|
||||
the family this card has been defeated by; buying it costs four false reports.
|
||||
4. **A slice through an intermediate call.** `sorted(roster)[:1]` subscripts a
|
||||
call result, not a roster reference. Nothing in `app/` is written that way
|
||||
today; if something is, it would not be seen.
|
||||
5. **Anything outside `app/`.** `tools/`, `sim/` and the checks themselves are
|
||||
not parsed — and `sim/inject.py` reaches `validate_startup_config()`.
|
||||
6. **The taint's own seam.** It crosses a call boundary only where the callee is
|
||||
a plain function this package defines and the argument is positional or
|
||||
keyword. A roster handed through `*args`, stored in a dict and read back, or
|
||||
passed to a method on an unresolvable object, is lost at that hop — and
|
||||
everything downstream goes **unenumerated** rather than reported unreachable.
|
||||
That is the shape of the remaining exposure: not zero, and not measurable
|
||||
from inside the check.
|
||||
7. **`cycle()`'s undeliverable-unit log line** (row 22 of §4c) is still green at
|
||||
+4 bytes, unchanged from round 4 and still filed open.
|
||||
|
||||
## 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-05 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.
|
||||
|
||||
⚠️ **RE-RUN AGAIN ON 2026-09-05**, because the rebase onto `67e649a` merged
|
||||
SPIKE-04a's `max_w: 4600` into the same §C/§D blocks this card had rewritten,
|
||||
and MUTATION.md habit 8 says a committed transcript that a document quotes is a
|
||||
check. A hand-merged transcript is not a measurement of anything. The block
|
||||
below is the output of that run, not of the 2026-09-04 one: B's rating reads
|
||||
**4600** — its datasheet figure, still not a measurement of any unit — and every
|
||||
case still passes.
|
||||
|
||||
```
|
||||
== 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": 4600, "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 ==
|
||||
```
|
||||
@@ -0,0 +1,925 @@
|
||||
# SAFETY-38 — pinning `site_standing`, the one quantity both site guards are asked about
|
||||
|
||||
**Status: DECIDED 2026-09-05.** `app/main.py`'s
|
||||
`site_standing = float(sum(standing.values()))` is **correct and is not
|
||||
changed**. What this card adds is the thing it never had: a **behavioural**
|
||||
fixture whose per-unit shares **cannot** answer for the site, so that a change
|
||||
of *meaning* in that reduction goes red instead of green.
|
||||
|
||||
Filed with SAFETY-38, card `6a9ac95f2b78646e5a76d2e9`. It is a **coverage**
|
||||
card: not one byte of `app/` moves.
|
||||
|
||||
---
|
||||
|
||||
## 0. The line, and its two consumers
|
||||
|
||||
```python
|
||||
site_standing = float(sum(standing.values())) # app/main.py:1598
|
||||
```
|
||||
|
||||
`standing` is `{unit name -> the value THIS controller last put on that unit's
|
||||
setpoint entity}` — `inv.last_write`, i.e. the number **after**
|
||||
`clamp_to_rating()` has applied `max_w` and the device's own
|
||||
`[dev_min, dev_max]`. Two safety inputs are derived from it and nothing else in
|
||||
`cycle()` is:
|
||||
|
||||
| consumer | where | what it does with it |
|
||||
|---|---|---|
|
||||
| **[[SAFETY-30]]**'s site sign detector | `self.site_sign_wd.evaluate(site_standing, self.grid, …)` (main.py:1619) | **latches and stops the whole site** when the total command opposes the meter for `site_sign_disagree_s` |
|
||||
| **[[SAFETY-18]]**'s freeze conjunction | `looks_frozen(unchanged_s, site_standing, max_w, …)` inside `peakguard.decide(frozen=…)` (main.py:1926) | takes the peak guard **blind** and files `floor_w = 0.0` — the battery may not import while the billed quarter is unmeasurable |
|
||||
|
||||
Both are P0 paths. **Neither was pinned to the reduction.**
|
||||
|
||||
## 1. What survived, and what the rescope measured
|
||||
|
||||
The card opened with three demonstrated live mutants, all green across the whole
|
||||
suite. Re-measured in round 2 against `release/1.0` @ `86eaef2`:
|
||||
|
||||
| mutation of `site_standing` | on release/1.0 @ 86eaef2 | closed by |
|
||||
|---|---|---|
|
||||
| `list(standing.values())[0]` — one unit's share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||||
| `sum(...) / max(1, len(standing))` — the mean | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||||
| `max(standing.values(), key=abs)` — the largest share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||||
|
||||
### ⚠️ ROUND 1 SAID `[0]` WAS ALREADY CLOSED BY SAFETY-32. THAT WAS FALSE.
|
||||
|
||||
Round 1 of this card recorded `[0]` as **CAUGHT by SAFETY-32's package-wide
|
||||
structural pin**, wrote that claim into four places, and told future authors
|
||||
*"DO NOT RE-COVER THAT ONE HERE"*. It is wrong, and **how** it went wrong is the
|
||||
part worth keeping.
|
||||
|
||||
**How the error was made.** The mutation was measured **once**. That single run
|
||||
happened to hit `test_peakguard.py`'s [[CI-06]] flake — a **measured ~20%**
|
||||
false-red rate — and the red was recorded as a **mutation kill**. Nothing else
|
||||
was checked, because a red is the answer you are hoping for and it did not look
|
||||
like it needed a second run.
|
||||
|
||||
**The correct measurement.** On `release/1.0` @ `86eaef2` carrying **only** the
|
||||
`[0]` edit (+4 bytes, re-read from disk, bytecode purged by
|
||||
`tools/mutation_run.sh`): the **full 21-file suite is green**, and
|
||||
`test_peakguard.py` run **five times in isolation** on that same mutated tree is
|
||||
**PASS PASS PASS PASS PASS**. The mutation **survives**. (The reviewer's own
|
||||
independent re-measurement of the same tree hit the flake once —
|
||||
PASS PASS PASS FAIL PASS — which is the same verdict plus a live demonstration
|
||||
of the mechanism that produced the original error.)
|
||||
|
||||
**Why SAFETY-32 cannot see it, read off 12j's own enumeration rather than
|
||||
argued.** Part 12j taints names *bound* to roster-carrying values and lists
|
||||
*accumulated* in a loop over one. `standing` is neither: it is a dict created
|
||||
empty at `main.py:1554` and filled by **subscript assignment**
|
||||
(`standing[inv.name] = stood`) inside the roster loop, and a subscript store is
|
||||
not a binding 12j follows. So `standing` never enters its taint set and
|
||||
`sum(standing.values())` is **not a roster site** at all. Run 12j against a tree
|
||||
carrying the `[0]` mutation and its enumeration lists `main.py:1553`, `1555` and
|
||||
`1690` — the three `self.inverters` sites bracketing this code — and **not
|
||||
`1599`**. No shape pin reaches this line; only the behavioural fixture below
|
||||
does.
|
||||
|
||||
**The rule this cost.** `test_peakguard.py` flakes at a measured ~20%. A single
|
||||
red from it proves nothing. Re-run it in isolation **at least five times** and
|
||||
**name the failing check** before believing any red from it. This card is the
|
||||
**third** measurement CI-06 has corrupted.
|
||||
|
||||
SAFETY-32's own disclosed blind spot says why the other two were always out of
|
||||
reach:
|
||||
|
||||
> *SEMANTICS. `min` → `max` → the mean are edits invisible to any shape pin,
|
||||
> permanently. Only the behavioural sets can see them.*
|
||||
|
||||
`sum` → mean and `sum` → `max(key=abs)` are **semantic**: identical shape,
|
||||
different meaning. **No shape pin will ever close them.** That disclosure was
|
||||
accurate, and is now confirmed by measurement rather than taken on trust.
|
||||
|
||||
`[0]` is the one that *looks* structural and therefore *looks* covered — and
|
||||
that appearance is exactly what produced the false attribution above. It is
|
||||
out of reach here for a different reason from the other two: not because the
|
||||
shape is invisible, but because **the value never becomes a roster** in the
|
||||
analysis's eyes. All three are closed by the same behavioural fixture, and
|
||||
**all three needed it.**
|
||||
|
||||
## 2. Why the suite could not see it — habit 4, inside the site half of the very card that names it
|
||||
|
||||
`test_watchdog.py`'s `_sign_site` is the **only** N≥2 site fixture in the
|
||||
repository. It runs at `site_sign_disagree_w: 320` with shares of roughly
|
||||
−2500 W **each**. Every one of them is individually far over the gate, so on
|
||||
those numbers
|
||||
|
||||
```
|
||||
sum = −5000 W over mean = −2500 W over max = −2500 W over
|
||||
```
|
||||
|
||||
— **all three reductions agree**, and a check whose two candidate answers happen
|
||||
to be equal cannot observe a change that swaps them. That is `MUTATION.md`'s
|
||||
third failure mode, and this is its ninth instance.
|
||||
|
||||
At a roster of **one** it is not a fixture defect but arithmetic:
|
||||
`sum == mean == max == the one share`, identically, so all three mutations are
|
||||
genuinely **equivalent** on a shipped install. No N=1 fixture can ever separate
|
||||
them; §3c asserts that rather than leaving it to the reader.
|
||||
|
||||
## 3. The fixture, and the numbers it is built on
|
||||
|
||||
Two **simulated** units rated 220 W and 280 W, both obeying exactly, inverted
|
||||
gain, house importing 2000 W. Both stand at their own rails, so `standing` is
|
||||
`{-220, -280}` in roster order and the meter reads +2500 W.
|
||||
|
||||
| reduction | value | vs the SHIPPED 300 W gate |
|
||||
|---|---|---|
|
||||
| `sum` | **−500 W** | **OVER** → trips |
|
||||
| `sum / len` (the mean) | −250 W | under → silent |
|
||||
| `max(…, key=abs)` | −280 W | under → silent |
|
||||
| `list(…)[0]` | −220 W | under → silent |
|
||||
|
||||
**Four distinct magnitudes — 500, 250, 280, 220 — of which exactly one clears
|
||||
the gate.** The asymmetry is load-bearing twice: equal shares would collapse
|
||||
mean, max and first onto one number, and putting the **smaller** unit first is
|
||||
what keeps `[0]` distinct from `max(key=abs)`. The gate is left at its shipped
|
||||
default so the fixture is graded against the magnitude an install runs; only the
|
||||
**hold** is shortened (325 s → 2 s), because the hold is a wall clock and the
|
||||
campaign behind 325 s is measured on the rig, not here.
|
||||
|
||||
⚠️ **220 W and 280 W are SIMULATED ratings chosen for that arithmetic and for
|
||||
nothing else.** They are not measurements. In particular they are **not**
|
||||
inverter B, which does not physically exist; B is a GW5048D-ES, serial
|
||||
`55048ESU22AW0992`, **4600 W from its datasheet** (`goodwe/NOTES.md` §1.1b).
|
||||
|
||||
### 3a. The site sign detector
|
||||
|
||||
Shipped tree: the site latches, commands 0 W on every inverter, and the sentence
|
||||
an operator reads is
|
||||
`SIGN_DISAGREEMENT: commanded -500 W against a meter reading +2500 W …`. No
|
||||
per-unit watchdog trips — every unit is faithfully executing its share, which is
|
||||
the asymmetry the site detector exists for.
|
||||
|
||||
Under the mean and under `max(key=abs)`: **nothing trips, ever.** The reviewer's
|
||||
tree was still commanding −500 W of charge into a +2500 W import at t = 595 s
|
||||
with `following=True`.
|
||||
|
||||
Two counterfactuals sit beside it so the trip is attributable:
|
||||
|
||||
* the **same** inverted-gain site whose shares sum to −250 W — under the gate —
|
||||
does **not** trip, so the trip above is the sum crossing 300 W and not the
|
||||
mere presence of a second inverter;
|
||||
* a site whose **device range is narrower than `max_w`** asks for −500 W while
|
||||
only −250 W stands, and does **not** trip — see §5.
|
||||
|
||||
### 3b. The second consumer, covered rather than scoped out
|
||||
|
||||
`site_standing` is also `looks_frozen()`'s `commanded_w`. The same
|
||||
discrimination applies at a different threshold: the site rail is
|
||||
`RAIL_FRAC * site_max_w` = 0.95 × 500 = **475 W**.
|
||||
|
||||
| reduction | value | vs the 475 W rail |
|
||||
|---|---|---|
|
||||
| `sum` | **500 W** | **RAILED** → `BLIND_FROZEN`, `floor_w = 0.0` |
|
||||
| the mean | 250 W | under → silent |
|
||||
| `max(…, key=abs)` | 280 W | under → silent |
|
||||
| `list(…)[0]` | 220 W | under → silent |
|
||||
|
||||
The sign is flipped from §3a on purpose: the gain is the **shipped positive**
|
||||
one and the frozen meter reads import, so the site *discharges* +500 W against a
|
||||
+2500 W import — the two agree in sign, SAFETY-30 has nothing to say, and this
|
||||
half grades SAFETY-18 alone. Reusing §3a's inverted gain would have the site
|
||||
sign latch park everything at 0 W, and the freeze conjunction could then never
|
||||
be true: a check measuring the *other* guard's remedy.
|
||||
|
||||
⚠️ **The open quarter is backfilled with the same stuck reading, and that is
|
||||
not decoration.** Without it the block is mostly uncovered, `blindness()`
|
||||
answers `BLIND_COVERAGE`, and the mutant reaches *the same action by a different
|
||||
route* — a check on the action alone could not then see the difference it names.
|
||||
Seeded, the measurement is honest:
|
||||
|
||||
* shipped: `blind = BLIND_FROZEN`, `floor_w = 0.0` — no charging while the
|
||||
billed quarter is unobservable;
|
||||
* both mutations: **not blind at all**, `floor_w = None` — the site may go on
|
||||
importing through a quarter whose billed average cannot be measured. That is
|
||||
the capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
|
||||
|
||||
The counterfactual comes from the **same run**: on the earlier cycles the
|
||||
command had not yet reached the rail and the guard did not report SAFETY-18,
|
||||
with the meter equally frozen throughout — so the verdict is produced by the
|
||||
command magnitude `site_standing` carries and not by `unchanged_s` alone. A low
|
||||
gain (0.05, the schema floor) is what buys those below-the-rail cycles.
|
||||
|
||||
### 3c. At N=1, nothing changes — asserted
|
||||
|
||||
A real one-inverter controller is driven through the same frozen-meter
|
||||
scenario. Its `standing` has a single entry, so `sum`, the mean,
|
||||
`max(key=abs)` and `[0]` are printed and asserted to be **one number**; the
|
||||
site sign detector is still absent (part 7 counts the calls on the object); and
|
||||
SAFETY-18 still reaches `BLIND_FROZEN` on that same value. The claim "the
|
||||
shipped install is unaffected" now rests on the roster rather than on the
|
||||
reader.
|
||||
|
||||
## 4. Mutation ledger
|
||||
|
||||
Every run: a **fresh full-repo copy**, `__pycache__` and `*.pyc` excluded,
|
||||
mounted `:ro`, graded by `tools/mutation_run.sh` (which purges, prints the tree
|
||||
fingerprint, and verifies no cache appears during the run). The mutated line was
|
||||
**re-read from disk** after the edit and the byte delta printed. Shipped
|
||||
`app/main.py` is 234 542 bytes at `86eaef2`.
|
||||
|
||||
| # | mutation of `site_standing` | Δ bytes | verdict |
|
||||
|---|---|---|---|
|
||||
| M1 | `sum(...) / max(1, len(standing))` — the mean | +24 | **RED** |
|
||||
| M2 | `max(standing.values(), key=abs)` — largest share | +9 | **RED** |
|
||||
| M3 | `list(standing.values())[0]` — one share | +4 | **RED** — and **survives on release/1.0**, see §1 |
|
||||
| M4 | `min(standing.values(), key=abs)` — smallest share | +9 | **RED** |
|
||||
| M5 | `sum(abs(v) for v in ...)` — magnitude, sign lost | +16 | **RED** |
|
||||
| M6 | `-sum(standing.values())` — sign flipped | +1 | **RED** |
|
||||
| M7 | `float(self.target)` — the arbiter's site target | **−11** | **was a SURVIVOR — now RED**, see §5 |
|
||||
| M8 | `sum(standing.values()) / 2.0` | +6 | **RED** |
|
||||
| M9 | `sum(standing.values()) * 2.0` | +6 | **RED** |
|
||||
| M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** |
|
||||
| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
|
||||
| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c |
|
||||
|
||||
**Every one of the ten `site_standing` entries is red in `test_watchdog.py`.** Re-measured in round 2
|
||||
on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more
|
||||
than the `f72c48d` the round-1 figures were taken on): all ten still red, at the
|
||||
byte deltas in the table, each on a fresh copy with the edit re-read from disk.
|
||||
Adding assertions cannot turn a red green, so those verdicts carry forward to
|
||||
the round-3 tree unchanged.
|
||||
|
||||
### ⚠️ ROUND 2 SAID THREE OF THESE SURVIVED. **EIGHT DID.**
|
||||
|
||||
Round 2 wrote: *"Before part 9 existed, three of these left the whole suite
|
||||
green — M1, M2 and M3 — and the remaining seven were caught by SAFETY-30's own
|
||||
N≥2 checks."* Round 1's wording was vaguer and harmless; naming three made it
|
||||
**specific and wrong**, and it is the same species of error the round was
|
||||
written to fix — **a survivor recorded as a kill**. This file contradicted it
|
||||
three times on its own pages: the ledger row calls M7 *"a SURVIVOR — now RED"*,
|
||||
§5 says M7 survived the whole suite, and `CHANGELOG.md` says so too.
|
||||
|
||||
**Re-measured in round 3, one mutation at a time, on clean `release/1.0` @
|
||||
`bbc903b`** — a fresh copy each, bytecode purged (`0` remaining, printed), the
|
||||
line re-read from disk, byte delta **and** `app/main.py` fingerprint printed,
|
||||
mounted `:ro`, and the **FULL 21-FILE SUITE** run each time rather than
|
||||
`test_watchdog.py` alone. (`release/1.0` has since moved on to `9e42cec`, which
|
||||
merged [[CI-06]]; that commit touches `test_peakguard.py` and `CHANGELOG.md`
|
||||
only, neither of which grades `site_standing`, so the ten verdicts below stand
|
||||
on the tree they were taken on and this branch is rebased onto the newer tip.)
|
||||
|
||||
| # | Δ bytes | fingerprint of `app/main.py` | suite before part 9 |
|
||||
|---|---|---|---|
|
||||
| M1 | +24 | `362374cfb6486378` | **21/21 PASS — SURVIVOR** |
|
||||
| M2 | +9 | `86573e7494f1c933` | **21/21 PASS — SURVIVOR** |
|
||||
| M3 | +4 | `59fa8815e6fda4cf` | **21/21 PASS — SURVIVOR** |
|
||||
| M4 | +9 | `43d852be3037f861` | **21/21 PASS — SURVIVOR** |
|
||||
| M5 | +16 | `58d26de82af29a1a` | **RED** — `test_watchdog.py`, 3 checks |
|
||||
| M6 | +1 | `407ddb5f375cc217` | **RED** — `test_watchdog.py`, 3 checks |
|
||||
| M7 | **−11** | `2a635a4eca0a790d` | **21/21 PASS — SURVIVOR** |
|
||||
| M8 | +6 | `b21eed89ff4c9240` | **21/21 PASS — SURVIVOR** |
|
||||
| M9 | +6 | `0973d3caf680db65` | **21/21 PASS — SURVIVOR** |
|
||||
| M10 | +7 | `0e7b7a4fed7787d0` | **21/21 PASS — SURVIVOR** |
|
||||
|
||||
**So: EIGHT of the ten left the whole suite green, and TWO were caught** — not
|
||||
three and seven, and not the four and six a reviewer proposed either. The
|
||||
correct sentence is: *before part 9 existed, eight of these ten left the whole
|
||||
21-file suite green, and the only two SAFETY-30's own N≥2 checks caught were M5
|
||||
and M6.*
|
||||
|
||||
**⚠️ AND THE TWO THAT WERE CAUGHT ARE CAUGHT FOR A REASON THAT MAKES THE CARD'S
|
||||
CASE RATHER THAN WEAKENING IT.** M5 (`sum(abs(v) …)`) and M6 (`-sum(…)`) are
|
||||
the only two entries in the table that **reverse the SIGN** of `site_standing`.
|
||||
Every other entry changes only its **magnitude** — and `_sign_site`, the suite's
|
||||
only N≥2 site fixture before this card, runs shares of about **−2500 W each**
|
||||
against a **320 W** gate. `sum`, the mean, `max(key=abs)`, `min(key=abs)`,
|
||||
`sorted(...)[-1]`, `[0]`, `self.target`, half the sum and twice the sum all land
|
||||
on **−2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W**: nine
|
||||
different numbers, all on the **same side of the same threshold**. The three
|
||||
checks that fired on M5 and M6 grade *"the site trips and no unit does"*, the
|
||||
claim it trips through, and where the latch is written — none of them grades the
|
||||
**quantity**, and the sign flip reaches them only because it stops the trip
|
||||
happening at all.
|
||||
|
||||
That is the whole thesis of this card measured rather than argued: **the
|
||||
pre-existing coverage could see a sign, and nothing else.** Part 9's fixture —
|
||||
per-unit shares each **below** the gate whose **sum** clears it — is the only
|
||||
arrangement in which the magnitude is load-bearing, and it is what takes the
|
||||
other eight red.
|
||||
|
||||
⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported
|
||||
`FAIL test_peakguard.py`. Re-run in isolation **three times** and once more
|
||||
through `tools/mutation_run.sh` — **four clean passes, zero failures**. The
|
||||
batch script had discarded the failure detail, so the failing check cannot be
|
||||
named here; what can be said is that the red did not reproduce in four attempts
|
||||
and that **no claim in this card rests on it**. `test_peakguard.py` is the file
|
||||
CI-06 records as flaky, and this is the behaviour it records. Believe no red
|
||||
from it without three isolated re-runs.
|
||||
|
||||
⚠️ **ROUND 3 CAN NAME ONE, AND IT IS THE SAME FILE.** Round 3's own batch — the
|
||||
ten full-suite runs above, plus §7's sibling swaps — produced **no** red from
|
||||
`test_peakguard.py` on M10 or on any other ledger entry. It produced exactly one
|
||||
elsewhere, on a **`static_tariff.py`-only** edit (§7b, A4), and this time the
|
||||
failing check *was* captured: *"one cycle of the intent form is exactly the
|
||||
delivered form plus the command standing on the wire"* — which is CI-06's
|
||||
**mechanism 1 by name**, the cross-time projection. `test_peakguard.py` then
|
||||
passed **four for four** in isolation on that same tree. That is the first time
|
||||
this card has been able to put a name to one of these reds, and it is the name
|
||||
CI-06 predicts.
|
||||
|
||||
⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded
|
||||
M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has
|
||||
the correction and the mechanism: `[0]` **survives** a full green 21-file suite
|
||||
on `release/1.0` @ `86eaef2`, because `standing` is a dict filled by subscript
|
||||
assignment and never enters 12j's taint set. **M3 is a real kill by this card's
|
||||
fixture and by nothing else in the repository.** It is claimed here.
|
||||
|
||||
### 4a. ⚠️ The zero-byte survivor one line below the one this card pinned
|
||||
|
||||
Round 1 pinned the **value** `site_standing` and never pinned **where it goes**.
|
||||
Swapping the detector's first two arguments at `main.py:1620` is a **zero-byte**
|
||||
edit — the same characters, reordered — and it left the round-1 tree **21/21
|
||||
green**.
|
||||
|
||||
What it costs is the banner. `SignWatchdog.evaluate()` formats
|
||||
`commanded_w` first and `against_w` second, so under the swap part 9a's own
|
||||
output reads:
|
||||
|
||||
```
|
||||
SIGN_DISAGREEMENT: commanded +2500 W against a meter reading -500 W for 2 s …
|
||||
```
|
||||
|
||||
The site is standing at **−500 W of charge into a +2500 W import** and the
|
||||
**latching, site-stopping** banner tells the operator **the exact opposite** —
|
||||
which instrument read what, inverted. `watchdog.py:658-665` names this failure
|
||||
in its own words: *"a false statement about which instrument produced the
|
||||
evidence, on the one line somebody woken by this alarm reads first."*
|
||||
|
||||
**Why round 1's check could not see it, and this is the general lesson.** It
|
||||
asserted
|
||||
|
||||
```python
|
||||
f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason
|
||||
```
|
||||
|
||||
— that `"-500 W"` appears **somewhere** in the sentence. Under the swap it still
|
||||
does: in the **meter slot**. A substring test cannot reach a failure that is
|
||||
purely **positional**, and round 1's own comment claimed the opposite ("*would
|
||||
put a different number in the store, the action log and the banner, and this is
|
||||
where that shows*"). It shows the **number**, never its **position**. This is
|
||||
MUTATION.md's *"a check whose data cannot reach the failure it names"*, in the
|
||||
card that exists to complain about exactly that.
|
||||
|
||||
**The fix is one line and touches no `app/` code** — assert the **ordered
|
||||
pair**:
|
||||
|
||||
```python
|
||||
f"commanded {sum(_share38):+.0f} W against a meter reading "
|
||||
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason
|
||||
```
|
||||
|
||||
Measured: with that line, M11 is **red in `test_watchdog.py` and green in the
|
||||
other twenty files** — one file, one check.
|
||||
|
||||
### 4b. Claim wiring, re-measured rather than assumed
|
||||
|
||||
Round 1's reviewer disclosed it had **not** mutated the arbiter claim wiring or
|
||||
[[SAFETY-31]]/circulation. Both were mutated in round 2. Both are **zero-byte**
|
||||
edits to `arbiter.CLAIM_SCOPE`:
|
||||
|
||||
| # | mutation | Δ bytes | verdict |
|
||||
|---|---|---|---|
|
||||
| W1 | `"sign_site": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py` (parts 5 **and 9a**), `test_arbiter.py`, `test_peakguard.py` |
|
||||
| W2 | `"circulation": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py`, `test_arbiter.py`, `test_peakguard.py` |
|
||||
|
||||
Part 9a's *"…and it stops the WHOLE site, through the `sign_site` claim by name"*
|
||||
is one of the two checks that kills W1, so this card contributes to that
|
||||
coverage rather than merely inheriting it. **W2 is caught entirely by
|
||||
pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not
|
||||
claim to.
|
||||
|
||||
### 4c. ⚠️ The SAME zero-byte defect one call site over — and it is the one the shipped install can see
|
||||
|
||||
Round 2 closed §4a by asserting the ordered pair on the **site** banner at
|
||||
`main.py:1620`. It did not touch `main.py:1560`, the **per-unit** call, and the
|
||||
identical swap there —
|
||||
|
||||
```python
|
||||
if inv.sign_wd.evaluate(stood, against, self.following, now) # shipped
|
||||
if inv.sign_wd.evaluate(against, stood, self.following, now) # M12
|
||||
```
|
||||
|
||||
— left the round-2 tree **21/21 green**. Measured: `app/main.py` 234 542 bytes
|
||||
before and after, **Δ 0 bytes**, fingerprint `61aa413f…` → `e57e6026…`, bytecode
|
||||
purged, tree mounted `:ro`, the line re-read from disk.
|
||||
|
||||
**Nothing behavioural can catch it, and that is structural rather than
|
||||
unlucky.** `watchdog.disagrees()` is *symmetric* in its first two arguments —
|
||||
`commanded_w * against_w < 0`, with an `abs()` gate on **both** — so the swap
|
||||
cannot change a single trip decision on any input. What it changes is the
|
||||
**sentence**. Measured by diffing the suite's own printed banners between the
|
||||
round-3 tree and the same tree carrying only M12: **9 emitted banner lines
|
||||
invert, in 3 distinct sentences.**
|
||||
|
||||
```
|
||||
shipped: SIGN_DISAGREEMENT: commanded -2000 W against a meter reading +2000 W …
|
||||
M12: SIGN_DISAGREEMENT: commanded +2000 W against a meter reading -2000 W …
|
||||
|
||||
shipped: … commanded +1000 W against its own measured power -1000 W …
|
||||
M12: … commanded -1000 W against its own measured power +1000 W …
|
||||
|
||||
shipped: … commanded -2000 W against a meter reading +4000 W … (the N=1 arm)
|
||||
M12: … commanded +4000 W against a meter reading -2000 W …
|
||||
```
|
||||
|
||||
**⚠️ AND IT IS WORSE THAN THE ONE ROUND 2 CLOSED.** Part 9c asserts the SITE
|
||||
detector is **not armed at a roster of one**, and inverter B does not physically
|
||||
exist. So on the shipped install the **per-unit banner is the only
|
||||
sign-disagreement sentence that can ever be printed** — in the action log, in
|
||||
`_LOG.error`, in `status_payload()`'s headline banner, and in `sign_reason`,
|
||||
which is persisted and read back after the restart the latch is designed to
|
||||
survive. Round 2 pinned the N≥2-only call site and left the shipping one open.
|
||||
|
||||
**Closed by the ORDERED PAIR on both per-unit banners**, with both operands read
|
||||
off the **observed cycle** — `inv.last_write` and `inv.batt` off the objects the
|
||||
plant moved, never off the arguments the detector was handed, which would be
|
||||
comparing the swap against itself:
|
||||
|
||||
* `test_watchdog.py` **part 3**, the N≥2 per-unit trip:
|
||||
`f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W"`,
|
||||
plus the persisted `unit/inverter-b/sign_reason` copy, plus a **precondition**
|
||||
that the two operands are different numbers of opposite sign — without which
|
||||
a transposition would produce the same string and the check would be
|
||||
decoration;
|
||||
* `test_watchdog.py` **part 7**, the **N=1** controller-driven trip:
|
||||
`f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W"`, plus the
|
||||
namespaced key **and** the flat rollback mirror, plus the same precondition.
|
||||
|
||||
**Measured after the fix: M12 is RED on four named checks in
|
||||
`test_watchdog.py` and GREEN in the other twenty files** — one file, four
|
||||
checks. M11 re-measured on the same tree: still red.
|
||||
|
||||
**⚠️ Neither §6a nor MUTATION.md disclosed it.** §6a's "what was NOT closed"
|
||||
enumerated `circ_wd`, the eight other `CLAIM_SCOPE` entries, `actuator_of()`'s
|
||||
body and the `n_units > 1` guard — not this. MUTATION.md's own closing rule, in
|
||||
the file round 2 edited, is *"a variant is not covered because a near-miss of it
|
||||
is."* §7 is the enumeration that would have caught both instances at once.
|
||||
|
||||
## 5. The survivor this card went looking for, and closed
|
||||
|
||||
M7 is not one of the card's three. It was found by asking the question the
|
||||
recurring defect demands — *what mutation would my new checks NOT see?* — and it
|
||||
is the answer: **−11 bytes**, and it survived every check above **and the whole
|
||||
20-file suite**, because in every fixture built so far the arbiter's site target
|
||||
and the sum of what actually stands on the wire are **the same number**.
|
||||
|
||||
They are not the same number when a setpoint device's own reported range is
|
||||
narrower than its `max_w`. The `device` P_SAFETY claim is filed **per actuator**
|
||||
(`actuator_of("device", inv)`), so it bounds each unit's **share** and never the
|
||||
site target, and `clamp_to_rating()` clips again on the way out. On two units
|
||||
rated 220/280 W whose devices report ±120/±130 W, the site asks for **−500 W**
|
||||
and **−250 W** stands.
|
||||
|
||||
Which one the guards mean is already written down, in both of them: SAFETY-02
|
||||
grades *"the command standing on the wire"*, and `looks_frozen()`'s docstring
|
||||
says `commanded_w` is *"the EMITTED setpoint — what the arbiter resolved and
|
||||
`write_setpoint()` put on the wire — not the loop's raw wish"*. [[SAFETY-20]] is
|
||||
the whole card about the difference. Those sentences are now made to bite: on
|
||||
the site target the detector fires on a site standing at −250 W against a
|
||||
+2250 W meter — **under its own 300 W gate** — which is a latching, site-wide
|
||||
false trip on an install whose inverters simply report a narrower range than the
|
||||
operator typed.
|
||||
|
||||
## 6. Honest gaps
|
||||
|
||||
* **A mutation that changes the SIGN CONVENTION of `standing` itself** — i.e. in
|
||||
`main.py`'s `stood = inv.last_write if inv.last_write is not None else
|
||||
inv.target`, not on the reduction line — is **not** in this ledger. It is a
|
||||
different line and a different card; §3a's obedience precondition would see
|
||||
some of it, but that is not a measured claim and is not made here.
|
||||
* **The freeze half is graded at ONE roster length (2) and one rail.** The
|
||||
`RAIL_FRAC` constant, `rail_ceiling_w()`'s device-bound conjunction and
|
||||
`rail_unreachable()` are `test_freeze.py`'s subject and are untouched here.
|
||||
* **The site hold is 2 s, not the shipped 325 s.** The window's derivation is a
|
||||
rig campaign (`DECISIONS/SAFETY-30-per-unit-sign.md` §3B) and this file does
|
||||
not re-grade it. The **gate** is the shipped 300 W, which is the coordinate
|
||||
this card is about.
|
||||
* **Everything about inverter B here is simulated and labelled so.** No number
|
||||
in this document or in the fixture is a measurement of a device that does not
|
||||
yet exist.
|
||||
|
||||
### 6a. Round 2: what was closed, and what was NOT
|
||||
|
||||
Round 1's reviewer disclosed that it had not mutated SAFETY-31/circulation, the
|
||||
arbiter claim wiring, or ledger entries M4/M5/M6/M8/M9/M10. Enumerated, with the
|
||||
gaps left open stated rather than rounded off:
|
||||
|
||||
**Closed in round 2 (measured, on `86eaef2`, bytecode purged, edit re-read from
|
||||
disk, byte delta printed):**
|
||||
|
||||
1. **M11**, the zero-byte argument swap — §4a. Red on one named check.
|
||||
2. **M3**, the `[0]` reduction — re-measured, **survives on release/1.0**, closed
|
||||
by part 9a and 9b. The false attribution corrected in all four places it was
|
||||
committed: this file §1 and §4, `CHANGELOG.md`, and `test_watchdog.py`'s part
|
||||
9 header (which previously *instructed future authors not to re-cover it*).
|
||||
3. **M1, M2, M4, M5, M6, M7, M8, M9, M10** — every ledger entry re-run
|
||||
independently in round 2 rather than carried forward from round 1's report.
|
||||
All ten red; M1/M2/M4 re-run in the exact wrapped form the table records so
|
||||
the byte deltas are the table's own (+24/+9/+9).
|
||||
4. **W1**, `sign_site` claim scope — §4b. Zero bytes, red, part 9a among the
|
||||
killers.
|
||||
5. **W2**, `circulation` claim scope — §4b. Zero bytes, red, but **entirely on
|
||||
pre-existing checks**.
|
||||
|
||||
**NOT closed, and not claimed:**
|
||||
|
||||
* **SAFETY-31's `circ_wd` detector itself** — its threshold, its hold, its
|
||||
`circulating_w()` reduction over the roster. Only its **claim scope** was
|
||||
mutated (W2), and that kill belongs to checks this card did not write.
|
||||
Circulation's own behavioural coverage is `test_watchdog.py` part 4's and is
|
||||
untouched here.
|
||||
* **The other eight `CLAIM_SCOPE` entries** — `loop`, `peak`, `arbitrage`,
|
||||
`safety` (site) and `device`, `supervised`, `failsafe`, `maintenance` (unit).
|
||||
`CLAIM_SCOPE` has ten entries; only the two named above were mutated. `test_peakguard.py` part 10f grades the
|
||||
classification statically; that is a different instrument and this card did
|
||||
not re-measure it.
|
||||
* **`actuator_of()`'s own body** and the `n_units > 1` guard on the `sign_site`
|
||||
append at `main.py:1764` were **not** mutated.
|
||||
* **The ten ledger mutations were re-graded against `test_watchdog.py` only.**
|
||||
The "green in the other twenty files" half of the round-1 claim was
|
||||
re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is
|
||||
otherwise carried forward from round 1's measurement on `f72c48d`. It is a
|
||||
weaker statement than round 1 made and is written down as such.
|
||||
⚠️ **Round 3 closed the other half of this**, but only in the *before*
|
||||
direction: all ten were re-run on clean `release/1.0` @ `bbc903b` under the
|
||||
**full 21-file suite** to establish which of them the pre-existing coverage
|
||||
could see — see §4's ⚠️ paragraph, and the answer was **eight survivors, not
|
||||
three**. The *after* direction — "each of the ten is red in
|
||||
`test_watchdog.py` and green in the other twenty **on the round-3 tree**" —
|
||||
is still measured end-to-end only for **M3, M11 and M12**. For the other
|
||||
seven, "red" carries forward from round 2 (which adding assertions cannot
|
||||
undo) and "green elsewhere" carries forward from round 1.
|
||||
* **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not
|
||||
this card's subject. It is *characterised* here (five isolated runs on the M3
|
||||
tree, all PASS) only far enough to overturn the false kill it produced.
|
||||
|
||||
### 6b. Round 3: what was closed, and what is still open
|
||||
|
||||
**Closed in round 3:**
|
||||
|
||||
1. **M12**, the zero-byte argument swap at the **per-unit** call site
|
||||
`main.py:1560` — §4c. It was a survivor of the round-2 tree at **21/21
|
||||
green**, and it is the **only** sign-disagreement banner a shipped
|
||||
one-inverter install can print. Red on four named checks, green in the other
|
||||
twenty files.
|
||||
2. **The ledger arithmetic in §4**, which round 2 made specific and wrong. See
|
||||
§4's own ⚠️ paragraph: re-measured mutation by mutation on clean
|
||||
`release/1.0` @ `bbc903b`, full 21-file suite each time.
|
||||
3. **The family itself is now enumerated by machine** —
|
||||
`tools/symmetric_message_scan.py`, §7 — rather than by hand for a third time.
|
||||
|
||||
**Open, named, and NOT claimed:**
|
||||
|
||||
* **A3 and B1** — the only two status-page rows in which the freeze detector
|
||||
ever names a device range: the **NOT COVERED** row and the **armed** row's
|
||||
"below max_w because …" clause. `rail_unreachable()`'s `dev_min`/`dev_max` at `main.py:3208` and the
|
||||
literal `f"only {inv.dev_min}...{inv.dev_max} W"` at `main.py:3231`. Both
|
||||
measured: **0 bytes each, 21/21 green each.** Real, open members of the
|
||||
family. They are **status-page rows rather than latching alarms**, and closing
|
||||
them wants a fixture with a device whose two bounds are **asymmetric** — every
|
||||
fixture in this repository uses `±d`, so the transposition is currently
|
||||
invisible by construction — which belongs to `test_freeze.py`'s card, not to
|
||||
this one.
|
||||
* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source
|
||||
level** — the distinguishing literal appears in no `test_*.py` — and **not**
|
||||
mutated. Weaker evidence than A1–A4 and B1, and written down as such
|
||||
— §7d says the same thing with the same list.
|
||||
* **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card
|
||||
closed for SAFETY-02/30 and is *also* persisted and re-read after a restart.
|
||||
§6a already declines to touch `circ_wd`; §7d says what that costs.
|
||||
* Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other
|
||||
`CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard.
|
||||
|
||||
### 6c. Round 4: what was closed, and what is still open
|
||||
|
||||
**The blocker round 3 shipped.** The enumeration read as complete and covered
|
||||
**one syntactic sink**. Every one of census B's 16 members was a sentence that
|
||||
is **appended, assigned, or passed as a log argument** (`problems.append`,
|
||||
`out.append({"text": …})`, `self.reason = …`, `_LOG.info`). **Not one was a
|
||||
sentence that is RETURNED.** Census A has a return bucket, but only for
|
||||
symmetric **parameters**, so a method formatting its **own attributes** and
|
||||
handing the string back was invisible to both — and `describe()` / `explain()` /
|
||||
the reason-builders are exactly where this codebase keeps its operator
|
||||
sentences. Worse than the gap: it was in **none of the five blind spots the tool
|
||||
prints about itself**. A tool that asserts a completeness it does not have is
|
||||
the recurring defect wearing a new hat.
|
||||
|
||||
**Closed in round 4:**
|
||||
|
||||
1. **`clamp.py:253` `ClampResult.describe()`**, pair `requested_w` /
|
||||
`applied_w` — §7e C6. A survivor of the round-3 tree at **21/21 green**,
|
||||
**0 bytes**. It is the §15 action-log row at `main.py:1366` **and** the
|
||||
ClampWatchdog notification reason at `main.py:2452`; **no `test_*.py`
|
||||
mentioned `describe()` at all.** Pinned on the ordered pair, on the
|
||||
contiguous literal, at **both** consumers, with a precondition that the two
|
||||
operands render differently — `test_clamp.py` part 6d. **4 checks red** under
|
||||
the transposition.
|
||||
2. **`peakguard.py:493` `PeakDecision.explain()`**, pair `projected_w` /
|
||||
`ceiling_w` — §7e C7. Same measurement: **0 bytes, 21/21 green**; the string
|
||||
`"peak guard idle"` was in no `test_*.py`. Pinned on the decision and on the
|
||||
shipped status row at `main.py:3313` — `test_peakguard.py` part 13. **3
|
||||
checks red.**
|
||||
3. **`arbiter.py:264` (B12)** — the check whose **claim was wider than its
|
||||
assertion**, carried forward from §7d. Measured this round rather than left
|
||||
at source level: **0 bytes, 21/21 green** before, **`test_arbiter.py` red**
|
||||
after. **2 checks red.**
|
||||
4. **The tool's sinks are now stated rather than implied.** It prints what it
|
||||
scans as a MESSAGE, that it now scans the RETURN sink through the returning
|
||||
method's call sites, and — the part that was missing — **an explicit list of
|
||||
what it does not scan at all**, largest hole first.
|
||||
|
||||
**⚠️ The correction round 4 owes the ticket.** The brief placed
|
||||
`PeakDecision.explain()`'s idle sentence at `main.py:3299/3302`. Those two lines
|
||||
are the **floor-limited** branch, where `explain()` returns `self.reason` before
|
||||
the idle literal is ever built. The idle sentence reaches the page at
|
||||
**`main.py:3313`** and the action log at **`main.py:1951`** (edge-logged, so it
|
||||
is the RELEASE edge that writes it). The pin is on 3313; 1951 is disclosed
|
||||
below, unpinned.
|
||||
|
||||
**Open, named, and measured — see §7f for the ledger:**
|
||||
|
||||
* **TEN further zero-byte survivors** found by the widened scan, each measured
|
||||
at 21/21 green on this tree: **two** in `allocator.py`'s `UnitPlan.explain()`
|
||||
(C1 :154, C2 :160), three in `arbiter.py`'s two `explain()`s (C4a, C4b :344,
|
||||
C5 :392), two in `tariff.py` (C8, C9), two in `arbitrage.py`'s trade reasons
|
||||
(B17, B18) and one in `main.py`'s SoC-floor notification (B19).
|
||||
None is a control decision; every one is a sentence an operator reads.
|
||||
⚠️ **CORRECTED after review, and the error is worth keeping visible.** This
|
||||
bullet first said "Nine" while listing eleven, and the CHANGELOG said eleven
|
||||
while listing eleven — and BOTH counted `allocator.py:162` (C3) among the
|
||||
survivors when §7f records it as a KILL, red in `test_alloc.py`. The
|
||||
authoritative tables in §7e and §7f were right throughout; only the summary
|
||||
sentences a reader actually relies on were wrong. That is this card's own
|
||||
recurring defect in prose rather than in code: a claim wider than the
|
||||
measurement beneath it. Arithmetic, from §7f: 14 measured = 10 surviving +
|
||||
1 already caught (C3) + 3 caught by this round (C6, C7, B12).
|
||||
* **The action-log surface of the peak-guard idle line** (`main.py:1951`) is
|
||||
not pinned. The status row (3313) is, and both come off the same `explain()`,
|
||||
so the *sentence* is closed and the *second call site* is not — which is
|
||||
precisely the distinction §4c was written about, recorded rather than assumed.
|
||||
* Everything §6a and §6b list stays open.
|
||||
|
||||
## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE
|
||||
|
||||
**Why this section exists, and it is the most useful thing in the card.** Round 2
|
||||
pinned the operand order of `SignWatchdog.evaluate()` at `main.py:1620`. Round 3
|
||||
found the **identical zero-byte defect at `main.py:1560`**, one call site over —
|
||||
and that one is worse, because `test_watchdog.py` part 9c asserts the site
|
||||
detector is **never armed at
|
||||
a roster of one**, so on the shipped install the per-unit banner is the **only**
|
||||
sign-disagreement sentence that can ever be printed. Twice in one card the
|
||||
enumeration was written by hand and twice it was short. MUTATION.md habit 12: *a
|
||||
hand-written scope is the same defect as a hand-written fixture.* So the family
|
||||
is now **computed**, by `tools/symmetric_message_scan.py`.
|
||||
|
||||
### 7a. What makes this family invisible, and why it is not a fixture problem
|
||||
|
||||
Every other defect in this document is a fixture problem: *no fixture happens to
|
||||
separate the mutant from the shipped code.* Build the right fixture and it goes
|
||||
red. This one is not that.
|
||||
|
||||
Where a callee is **symmetric** in two arguments, a swap at a call site cannot
|
||||
change **any** action on **any** input. `watchdog.disagrees()` is
|
||||
`commanded_w * against_w < 0` with an `abs()` gate on **both** sides: the
|
||||
product commutes and the two gates are the same gate. There is nothing for a
|
||||
behavioural check to observe, ever — **not "no fixture separates them" but no
|
||||
fixture *can*.** The only surface that moves is the **sentence**, and the only
|
||||
check that can see it is one asserting the **ordered pair** (MUTATION.md habit
|
||||
14) at **that** call site.
|
||||
|
||||
Two consequences the earlier rounds got wrong:
|
||||
|
||||
* **The unit of coverage is the CALL SITE, not the function.** `evaluate()`'s
|
||||
operand order was pinned and the defect still shipped, because the pin was on
|
||||
the *other* call.
|
||||
* **A behavioural suite is not weak evidence here, it is NO evidence.** "21/21
|
||||
green" is what this mutation is *guaranteed* to produce. Measured, twice: M11
|
||||
at `main.py:1620` on the round-1 tree, and M12 at `main.py:1560` on the
|
||||
round-2 tree.
|
||||
|
||||
### 7b. Census A — the callee formats its own parameters
|
||||
|
||||
`tools/symmetric_message_scan.py` renames each parameter pair, erases every
|
||||
formatted string, canonicalises the commutative operators (`*`, `+`, `and`,
|
||||
`or`, `==`, `min`/`max`, iterated tuple literals) and compares the **bodies**;
|
||||
it iterates to a **fixpoint**, because the symmetry is usually one call deep —
|
||||
`evaluate()` is symmetric only *through* `disagrees()`, and a single pass
|
||||
reports an empty family on a tree whose answer is already known. It then
|
||||
classifies each sentence by **where it goes**.
|
||||
|
||||
Fixpoint on this tree: **3 rounds, 17 interchangeable positional and 5 keyword
|
||||
pairs across 14 functions**. 45 parameter pairs reach a sentence; **8** are
|
||||
symmetric — **5** whose sentence goes only to an operator, and **3** whose
|
||||
sentence is the **return value** and therefore has to be classified by hand,
|
||||
because a returned string is sometimes a KEY and sometimes a SENTENCE.
|
||||
|
||||
| # | scan row | pair | called at | swap costs | reaches an operator via | pinned? |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **A1** | `watchdog.py:613` `SignWatchdog.evaluate()` | `(commanded_w, against_w)` | **`main.py:1560`** — per-unit, **and the N=1 detector** | **0 B** | `sign_reason` (persisted), the action log, `_LOG.error`, `status_payload()`'s headline banner | **YES — round 3.** `test_watchdog.py` part 3 (`its own measured power`) and part 7 (`a meter reading`), on the contiguous literal with both operands read off the observed cycle. **Measured: 0 B, red on 4 named checks, green in the other 20 files.** |
|
||||
| **A2** | same row, second call site | `(commanded_w, against_w)` | **`main.py:1620`** — site | **0 B** | `site_sign_reason`, the action log, the status page | **YES — round 2**, part 9a. Re-measured on the round-3 tree: still red. |
|
||||
| **A3** | `freeze.py:405` `FreezeWatchdog.evaluate()` and `freeze.py:311` `rail_unreachable()` — both symmetric through `rail_ceiling_w()`'s `for bound in (dev_min, dev_max)` | `(dev_min, dev_max)` | **`main.py:3208`** (and 1928, 2755, 3196, 3219) | **0 B** | the status page's `freeze detector: NOT COVERED — the setpoint device reports a range of …` row | **NO.** Measured: swapped at `main.py:3208-3209`, **0 bytes**, `main.py` fingerprint `61aa413f…` → `2305b560…`, **21/21 green**. Disclosed in §7d, not closed. ⚠️ `rail_unreachable()` is the row the scan puts in its **RETURN** bucket: its returned string is a **sentence**, not a key, so it is a family member after all — which is exactly why that bucket says *read by hand*. |
|
||||
| **A4** | `static_tariff.py:522` `_raise_gap()` | `(start, end)` | `static_tariff.py:509`, `:513` | **0 B** | the `ProfileInvalid` refusal an operator reads at startup | **YES — pre-existing.** Measured: both call sites swapped, **0 bytes**, `static_tariff.py` fingerprint `caef7fde…` → `50e9a768…`, **red in `test_static_tariff.py` on two named checks** (*"…and names the uncovered range so the operator can fix it"*, *"…and the refusal names the uncovered range"*), because `_hhmm(start)}-{_hhmm(end)` is asserted as the contiguous literal `"22:00-23:00"`. ⚠️ That batch run also reported `FAIL test_peakguard.py` on a `static_tariff.py`-only edit; the failing check is *"one cycle of the intent form is exactly the delivered form plus the command standing on the wire"*, which is [[CI-06]]'s **mechanism 1 by name**, and `test_peakguard.py` passes **four for four** in isolation on that same tree. No claim here rests on it. |
|
||||
| A5–A6 | `static_tariff.py:522` `_raise_gap()` | `(start, weekday)`, `(end, weekday)` | — | — | — | **not real — the scan's one over-report.** It erases the sentence, so it cannot see that `DAY_NAMES[start]` with `start = 1320` raises `IndexError`. A mixed-type pair fails loudly, which is the opposite of this family. |
|
||||
| A7–A8 | `store.py:177` `unit_key()`, `mqtt.py:222` `unit_state_key()` | `(unit, key)`, `(slug, key)` | many | 0 B | — | **not this family.** The formatted string is the **return value and it is a KEY**: a swap renames a store key and an MQTT topic, which is behaviour every ordinary check can see. MAINT-05 is the card about it. |
|
||||
|
||||
### 7c. Census B — the caller builds the sentence
|
||||
|
||||
Census A only sees a callee formatting its **own** parameters, which was gap 2
|
||||
of its own honest-gap list. The other half never crosses a function boundary:
|
||||
an operator sentence built where it is printed, interpolating two values that
|
||||
are **interchangeable by shape**. No predicate is involved at all, so census A's
|
||||
symmetry test has nothing to test. The scan pairs interpolated expressions that
|
||||
are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` /
|
||||
`cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only
|
||||
sentences whose sink is a **message**: an f-string that becomes a URL or a store
|
||||
key is data, and a swap there is an ordinary behavioural change. **16 candidates** in round 3, every one of them a **zero-byte** edit — and **22** after round 4 widened the sink classifier (B17–B22 below):
|
||||
|
||||
| # | site | pair | reaches an operator as | pinned? |
|
||||
|---|---|---|---|---|
|
||||
| **B1** | `main.py:3231` | `inv.dev_min` / `inv.dev_max` | status page, `… accepts only −120…130 W` | **NO — MEASURED.** Transposed in place, **0 bytes**, `main.py` fingerprint `61aa413f…` → `ac36a97b…`, purged, `:ro`, **21/21 green**. The row then tells the operator the device accepts `130…−120 W`. |
|
||||
| **B2** | `main.py:3362` | `inv.dev_min` / `inv.dev_max` | status page, `setpoint … (range −120…130 W)` | **NO** — the literal `setpoint … (range` is in no `test_*.py`. |
|
||||
| **B3** | `main.py:3435` | `sign_wd.cfg.hold_s` / `.cfg.min_w` | status page, `sign watchdog: armed (trips after 180 s opposed, both over 300 W)` | **NO** — `s opposed` is in no `test_*.py`. |
|
||||
| **B4** | `main.py:3460` | `site_sign_wd.cfg.hold_s` / `.min_w` | status page, the site sign row | **NO** — `armed against the sum` is in no `test_*.py`. |
|
||||
| **B5** | `main.py:3491` | `circ_wd.cfg.hold_s` / `.min_w` | status page, the circulation row | **NO** — `circulation watchdog: armed` is in no `test_*.py`. |
|
||||
| **B6** | `circulation.py:317` | `cfg.min_w` / `cfg.hold_s` | **`circ_reason` — persisted, and re-read after a restart** | **NO** — `test_watchdog.py` asserts only `"CIRCULATING_POWER" in reason`. **This is SAFETY-31's twin of the exact defect this card just closed for SAFETY-02/30**, on a string with the same persistence. |
|
||||
| **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. |
|
||||
| **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. |
|
||||
| **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. |
|
||||
| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **ROUND 4: MEASURED, THEN CLOSED.** Round 3 left it at source level; the transposition is **0 bytes**, `arbiter.py` `fdb4ed0520eeb721` → `9111f627ef639ecc`, and it was **21/21 green**. Now red in `test_arbiter.py` on 2 checks. Round 3's reasoning was right: `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. |
|
||||
| **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. |
|
||||
| **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. |
|
||||
| **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. |
|
||||
| B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. |
|
||||
| **B17** | `arbitrage.py:377` | `v.spread_eur_kwh` / `v.required_spread_eur_kwh` | the DISCHARGE decision's `reason` | **ROUND 4, NEW, and MEASURED: 0 bytes, 21/21 green.** Not visible to round 3 at all — the sentence is handed to a constructor as `reason=`, and the sink walker scored only *assignments*. The row then reads *"0.0400 above the cheapest and clear of the 0.1200 this cycle had to cover"* with the two swapped: a trade justified by a spread it did not clear. |
|
||||
| **B18** | `arbitrage.py:393` | same pair, CHARGE branch | the CHARGE decision's `reason` | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** |
|
||||
| **B19** | `main.py:2652` | `g.reason` / `g.soc_floor_reason` | the `goodwe_peak_soc_floor` notification body | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** The alarm then opens with the *reason the floor bound* and closes with the *guard's own conclusion*, i.e. both halves of a two-part explanation in the wrong order, in the one message SAFETY-24/25 exist to send. |
|
||||
| B20–B22 | `main.py:2558`, `main.py:2652` (two more pairs) | `g.reason` / `g.ceiling_w`, `g.ceiling_w` / `g.soc_floor_reason` | the same two notifications | **new in round 4, NOT measured** — mixed types (`str` beside `:.0f`), the same over-report class as B16. |
|
||||
|
||||
**⚠️ Six of these twenty-two were invisible to round 3, and the reason is one
|
||||
line of the sink classifier.** It scored a sentence a MESSAGE when it was
|
||||
appended, logged, raised, or assigned to a `reason`-shaped **name**. This
|
||||
codebase mostly builds operator sentences as `Decision(…, reason=f"…")` and
|
||||
`out.append({"text": f"…"})` — a **keyword argument** and a **dict key**, which
|
||||
that walker stepped straight past. Both are message sinks now.
|
||||
|
||||
### 7d. What this enumeration does NOT close, with sizes
|
||||
|
||||
* **A3 is a measured, open survivor.** `dev_min`/`dev_max` transposed in
|
||||
`rail_unreachable()`'s NOT COVERED row: **0 bytes, 21/21 green.** It is
|
||||
disclosed here rather than fixed, because closing it wants a `test_freeze.py`
|
||||
or status-page fixture with a device whose two bounds are **asymmetric** —
|
||||
every fixture in the repository uses `±d` — and that is a different file's
|
||||
card. It is a **status-page row**, not a latching alarm.
|
||||
* **B1 is a second measured, open survivor.** `inv.dev_min` and `inv.dev_max`
|
||||
transposed in `main.py:3231`'s status row: **0 bytes**, fingerprint
|
||||
`61aa413f…` → `ac36a97b…`, **21/21 green**. The page then tells the operator
|
||||
the device accepts `130…−120 W`. Same disposition as A3: **A3 is the row the
|
||||
freeze detector prints when the rail is UNREACHABLE, B1 the clause the ARMED
|
||||
row prints when the device is what set the rail** — the two rows in which that
|
||||
detector ever names a device range, and neither one's operand order is
|
||||
asserted anywhere.
|
||||
* **Thirteen further census-B sites are unpinned and UNMEASURED.** B2–B14 above
|
||||
are determined at the **source level** — the distinguishing literal appears in
|
||||
no `test_*.py`, or the check that exists asserts a substring wider than its
|
||||
own claim — and were **not** mutated. That is a weaker statement than A1–A4
|
||||
and B1, and is written down as one.
|
||||
* **B12 is a check whose CLAIM is wider than its ASSERTION**, which is habit
|
||||
14's own shape one module over. `test_arbiter.py:452` says
|
||||
`scope_violations` *"names the source and the actuator it does not belong
|
||||
on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Swap `c.source` and
|
||||
`c.actuator` in `arbiter.py:264` and the sentence becomes
|
||||
*"inverter-b: a SITE claim filed on unit 'peak'"* — and the substring is still
|
||||
found. Source-level, not mutated, and not fixed here.
|
||||
* **B6 is the closest thing to a repeat.** SAFETY-31's `circ_reason` is
|
||||
persisted and re-read after a restart, exactly like `sign_reason`, and its
|
||||
two limits are graded by a bare `"CIRCULATING_POWER" in reason`. This card
|
||||
does not fix it; §6a already declines to touch `circ_wd`, and this is the
|
||||
precise shape of what that declining costs.
|
||||
* **The instrument's own blind spots** are printed by the instrument, every
|
||||
run: semantic symmetry it cannot spell (it under-reports), census B pairs
|
||||
that are the same *kind* but not the same *shape*, `%s`-style logging and
|
||||
non-f-string payloads, anything outside `app/`, and the one way it
|
||||
**over-reports** — it erases the sentence, so a swap that would make the
|
||||
formatting itself raise is scored as changing no decision (A5–A6 above).
|
||||
|
||||
### 7e. Census C — the sentence an object returns about itself (round 4)
|
||||
|
||||
**Why there is a third census.** A and B between them covered **one syntactic
|
||||
sink**. Everything either of them reported was a sentence that is appended,
|
||||
assigned, or logged; **none was returned**. Census A's RETURN bucket only ever
|
||||
held a callee formatting its own **parameters**, so a method formatting its own
|
||||
**attributes** and handing the string back fell between them — and that is the
|
||||
whole `describe()` / `explain()` / reason-builder family, which is where the
|
||||
operator sentences in this codebase actually live.
|
||||
|
||||
**The test** is census B's — one f-string, two interpolations **one leaf apart**
|
||||
— run over the RETURN sink, plus one thing B never needed: **a returned string
|
||||
is not yet a message.** `store.unit_key()` and `mqtt.unit_state_key()` return
|
||||
f-strings too, and swapping there renames a store key and an MQTT topic, which
|
||||
every ordinary check can see (MAINT-05). So the sink of a returned sentence is
|
||||
**resolved through its call sites**: it is in this family only when some caller
|
||||
in `app/` hands it to an operator. That resolution matches on the **bare method
|
||||
name**, which over-collects — at least four unrelated classes have an
|
||||
`explain()` — so every verdict is printed **with the sites that produced it**,
|
||||
and a MESSAGE verdict is a prompt to read them, not a proof.
|
||||
|
||||
The scan reports **53 returned pairs, 39 reaching an operator, 16 of those
|
||||
sharing a format spec** (two values rendered alike, so a transposition is silent
|
||||
rather than visibly broken). It **rediscovered both members this round was sent
|
||||
to fix without being told where to look**, which is the only reason to trust the
|
||||
rest of the list.
|
||||
|
||||
| # | site | pair | reaches an operator as | Δ bytes | verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **C6** | `clamp.py:253` `ClampResult.describe()` | `requested_w` / `applied_w` | §15 action log (`main.py:1366`) **and** the ClampWatchdog notification reason (`main.py:2452`) | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_clamp.py` part 6d, 4 checks |
|
||||
| **C7** | `peakguard.py:493` `PeakDecision.explain()` | `projected_w` / `ceiling_w` | status row `main.py:3313`; action log `main.py:1951` on the release edge | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_peakguard.py` part 13, 3 checks |
|
||||
| C1 | `allocator.py:154` `UnitPlan.explain()` | `self.span` / `self.why` | the arbiter/allocator log line | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C2 | `allocator.py:160` | `self.span` / `self.why` | the ENVELOPE VIOLATED line | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C3 | `allocator.py:162` | `self.span` / `self.why` | the ordinary in-envelope line | **0** | **RED** — `test_alloc.py` already asserts that whole literal. The one member of this census the suite could already see, and it says the habit works. |
|
||||
| C4a | `arbiter.py:344` `UnitBounds.explain()` | `self.lo` / `self.hi` | `CONTRADICTION within the unit (device lo=… > peak hi=…)` | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C4b | `arbiter.py:344` | `self.lo_src` / `self.hi_src` | the same sentence, the two SOURCE names | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C5 | `arbiter.py:392` `Resolution.explain()` | `self.winner` / `self.bound_by` | `main.py:2236`'s `ARBITER:` action-log row and `self.reason` at `main.py:2329` | **0** | **SURVIVOR, 21/21 green** — the winner and the limiter swap names, on the line §10.1 exists to make legible |
|
||||
| C8 | `tariff.py:279` `PeakReading.explain()` | `self.month` / `self.detail` | the PEAK UNREADABLE row | **0** | **SURVIVOR, 21/21 green** |
|
||||
| C9 | `tariff.py:528` `Projection.explain()` | `self.projected_w` / `self.elapsed_s` | the projection line | **0** | **SURVIVOR, 21/21 green** — watts printed as seconds and seconds as watts, both `:.0f` |
|
||||
|
||||
**Not in the family, and the scan says why rather than dropping them silently:**
|
||||
`inverter.py:338` `__repr__()` and `static_tariff.py:378` `words` are reported
|
||||
**UNRESOLVED — no call site inside `app/`** (a `__repr__` is called by the
|
||||
interpreter, not by a name the scan can match); `price_forecast.py:421/428` and
|
||||
`tariff.py:214` `month_key()` resolve to **OTHER** — `month_key` is a **store
|
||||
key**, which is MAINT-05's case and a swap there is behaviour every check sees.
|
||||
|
||||
### 7f. Round 4's mutation ledger
|
||||
|
||||
Same protocol as §4: a fresh full-repo copy per mutation, `:ro`, bytecode
|
||||
purged **and the purge count printed** before the edit, the edited file
|
||||
**re-read from disk**, both sides' length and sha256 printed, and the full
|
||||
**21-file** suite via `tools/mutation_run.sh`. Every row is a pure
|
||||
transposition, so **every Δ is 0 bytes** — verified, not assumed: `len(old) ==
|
||||
len(new)` is asserted before the write and `len(after) == len(before)` after it.
|
||||
|
||||
**⚠️ And every row was also required to change the ANSWER.** A byte delta says
|
||||
the file moved; it does not say the sentence did. Each mutated tree is rendered
|
||||
through a scratch probe that evaluates the shipped f-string with **distinct
|
||||
operands** and diffs it against the same render of the clean tree. **The first
|
||||
cut of that probe numbered the operands by ORDER OF APPEARANCE** — so after a
|
||||
transposition the other leaf took the same number and all thirteen diffs came
|
||||
back **empty**, which reads exactly like "the edit is inert". Keying the value
|
||||
to the **name** fixed it. That is the same defect as everything else in this
|
||||
card, in the instrument this time: a measurement comparing the swap against
|
||||
itself.
|
||||
|
||||
| mutation | file, before → after (sha256, 16) | Δ | verdict |
|
||||
|---|---|---|---|
|
||||
| C1 `allocator.py:154` span↔why | `10c1b26ed0099654` → `ac0c174de1ec28ad` | 0 | **SURVIVED 21/21** |
|
||||
| C2 `allocator.py:160` span↔why | `10c1b26ed0099654` → `9b10730f4659befd` | 0 | **SURVIVED 21/21** |
|
||||
| C3 `allocator.py:162` span↔why | `10c1b26ed0099654` → `07097bf5da31f0c6` | 0 | **RED** — `test_alloc.py` |
|
||||
| C4a `arbiter.py:344` lo↔hi | `fdb4ed0520eeb721` → `009820de7ef0eff0` | 0 | **SURVIVED 21/21** |
|
||||
| C4b `arbiter.py:344` lo_src↔hi_src | `fdb4ed0520eeb721` → `04cb8cf69dfc7432` | 0 | **SURVIVED 21/21** |
|
||||
| C5 `arbiter.py:392` winner↔bound_by | `fdb4ed0520eeb721` → `9238c3db920da8ad` | 0 | **SURVIVED 21/21** |
|
||||
| **C6** `clamp.py:253` requested↔applied | `0153133741497fa5` → `47e4e371043e3201` | 0 | **SURVIVED 21/21 before — RED after**, `test_clamp.py`, 4 checks |
|
||||
| **C7** `peakguard.py:493` projected↔ceiling | `d95df61a51716c26` → `2d2ee3d0bcd51251` | 0 | **SURVIVED 21/21 before — RED after**, `test_peakguard.py`, 3 checks |
|
||||
| C8 `tariff.py:279` month↔detail | `82790523dd5227c9` → `4b2147ca17a28425` | 0 | **SURVIVED 21/21** |
|
||||
| C9 `tariff.py:528` projected↔elapsed | `82790523dd5227c9` → `517e9148d26a243a` | 0 | **SURVIVED 21/21** |
|
||||
| B17 `arbitrage.py:377` spread↔required | `67fbf949b2938349` → `cddd8045bdeb5e29` | 0 | **SURVIVED 21/21** |
|
||||
| B18 `arbitrage.py:393` spread↔required | `67fbf949b2938349` → `04a4f3ad3d255de1` | 0 | **SURVIVED 21/21** |
|
||||
| B19 `main.py:2652` reason↔soc_floor_reason | `61aa413fb890433e` → `bbcbb28f476a98d0` | 0 | **SURVIVED 21/21** |
|
||||
| **B12** `arbiter.py:264` source↔actuator | `fdb4ed0520eeb721` → `9111f627ef639ecc` | 0 | **SURVIVED 21/21 before — RED after**, `test_arbiter.py`, 2 checks |
|
||||
|
||||
**14 measured, 12 survived the full suite before this round's checks, 1 was
|
||||
already caught, 3 are caught now.** The tree these were taken on is
|
||||
`SAFETY-38` @ `357cb4f` rebased on `release/1.0` @ `9e42cec`; `app/main.py` is
|
||||
234 542 bytes.
|
||||
|
||||
### 7g. What round 4 does NOT close, with sizes
|
||||
|
||||
* **Eleven measured zero-byte survivors are left open** — C1, C2, C4a, C4b, C5,
|
||||
C8, C9, B17, B18, B19 and the three already open from round 3 (A3, B1, and the
|
||||
B2–B11/B13–B14 source-level set). Each is a sentence an operator reads and
|
||||
none is a control decision. They are disclosed with their measurement rather
|
||||
than pinned because pinning each one wants a fixture in the file that owns the
|
||||
module — `test_alloc.py`, `test_arbiter.py`, `test_tariff.py`,
|
||||
`test_arbitrage.py` — and this card has now spent four rounds inside
|
||||
`test_watchdog.py`'s subject. **An enumerated list with honest gaps beats a
|
||||
fifth round finding a fifth instance**; what would NOT be honest is a fifth
|
||||
round finding one that is **not on this list**.
|
||||
* **`main.py:1951`, the action-log surface of the peak-guard idle line,** is
|
||||
unpinned. The status row off the same `explain()` is pinned; the second call
|
||||
site is not, and §4c is the card about exactly that costing a round.
|
||||
* **The largest remaining hole is not in any census: `%s`-style lazy logging.**
|
||||
`_LOG.error("… %s … %s", a, b)` puts the two operands in *arguments*, not in
|
||||
an f-string, so nothing here looks at them and the transposition is the same
|
||||
zero-byte edit. The tool now says so first in its NOT-SCANNED list, **with
|
||||
the size measured rather than asserted: 31 such calls across 8 modules in
|
||||
`app/`, each one listed**.
|
||||
* **The tool's own blind-spot list was the round-4 blocker and is now two
|
||||
lists.** "What this scans, and what it does not" enumerates its message sinks
|
||||
from the constants themselves and then names, in order, what it never looks at:
|
||||
`%s`-style logging, `.format()`/`%`/`+`/`.join()` sentences, a sentence that
|
||||
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
|
||||
sentences, attributes outside the `reason`-shaped set, and everything outside
|
||||
`app/`. "What it can see but gets wrong" keeps the five gaps round 3 printed,
|
||||
with the over-report gap now backed by a **printed format-spec column** so a
|
||||
reader can tell a silent same-typed transposition from a noisy mixed-type one.
|
||||
@@ -0,0 +1,438 @@
|
||||
# SAFETY-39 — `slew_w` is a ratio to the rail, and `gain` is the constant that is still wrong
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-39`.
|
||||
**Card:** SAFETY-39 — *"`slew_w` was derived at a 1500 W clamp and runs at 5000,
|
||||
so the loop outruns the plant"*.
|
||||
**Nothing here reached the inverter.** The live install still runs the interim
|
||||
`slew_w: 400` applied by hand on 2026-09-05 12:00, which this branch's new rule
|
||||
accepts unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 0. The three things a reader should take away
|
||||
|
||||
1. **The overshoot is reproduced in simulation, and one half of it is not
|
||||
reproducible at all.** The setpoint stepping to the rail and the meter
|
||||
crossing from export into import are both reproduced, on two independent
|
||||
plant models. The **−5154 W battery figure is not reproducible in either**,
|
||||
because both clamp the plant at its rating. An acceptance criterion written
|
||||
on it would pass on the broken configuration too, so none is. §2.
|
||||
2. **The deliverable is the relationship, not the number.** `slew_w: 0` now
|
||||
means *follow the rail* and resolves to `SLEW_RAIL_FRAC * max_w`;
|
||||
`validate.py` rule 10 refuses an explicit value faster than that against the
|
||||
**site** rail. Verified by breaking it. §4, §5.
|
||||
3. ⚠️ **`slew_w` does not own the over-absorption. `gain` does, and `gain` was
|
||||
not changed.** Re-deriving the slew removes the ≥ 900 W step — the symptom
|
||||
that escalated 2 → 1 → 13 → 43 per 30 min on the morning of the excursion —
|
||||
and removes nothing else. Anyone reading this card as "the overshoot is
|
||||
fixed" is reading more than was delivered. §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, stated in the terms the code uses
|
||||
|
||||
`slew_w: 1000` was derived on 2026-08-22 against a **clamp of 1500 W**
|
||||
(goodwe/`NOTES.md`, *"Simulated 2 kW load step (gain 0.6, clamp 1500)"*). Its
|
||||
stated justification:
|
||||
|
||||
> `slew: 1000` is the default because it puts most of the correction in the first
|
||||
> cycle **while still capping how far the loop can jump ahead of the inverter**.
|
||||
|
||||
At 1500 W that sentence is true, and the reason is the rail rather than the
|
||||
number: 1000 W/cycle reaches the rail in two cycles, so the excursion is bounded
|
||||
by 1500 W whatever the loop does.
|
||||
|
||||
The live install then went to `max_w: 5000` and the constant did not move.
|
||||
`DRIVER-07` ("raise actuator power cap to full inverter rating") is still in To
|
||||
Do; the cap was raised ahead of it and `slew_w` was never revisited. **Nothing
|
||||
in the code is broken.** The rail moved and the derivation did not.
|
||||
|
||||
| | derived at | shipped default | live install |
|
||||
|---|---|---|---|
|
||||
| `max_w` | **1500** | 2000 | **5000** |
|
||||
| `slew_w` | 1000 | 1000 | **1000** |
|
||||
|
||||
Measured on the owner's system, 2026-09-05 11:43–11:46:
|
||||
|
||||
```
|
||||
11:43:55 grid -3385 W a real 3.4 kW export
|
||||
11:43:56 SP 380 → 1380 → 2380 → 3380 → 4380 → 5000 +1000 per 2 s tick
|
||||
11:44:47 batt -5154 W ABOVE the 5000 VA rating
|
||||
11:45:55 grid +2831 W flipped to 2.8 kW IMPORT
|
||||
SP 5000 → 2930 → 3840 → 3750 hunting
|
||||
```
|
||||
|
||||
Large (≥ 900 W) setpoint steps per ~30 min that morning: `2 → 1 → 13 → 43`.
|
||||
|
||||
---
|
||||
|
||||
## 2. ⭐ Reproduced first — and what could not be
|
||||
|
||||
`tools/slew_sim.py --repro`, against admin/goodwe's `plantsim.Plant`, the real
|
||||
`compute()`, and the firmware's own encoder on the wire. Gain 0.6, `max_w` 5000,
|
||||
`slew_w` 1000, 2 s tick, one 3385 W export step at t = 10 s:
|
||||
|
||||
```
|
||||
t grid in setpoint battery grid out reason
|
||||
10 -3739 -280 290 -3675 slew-limited
|
||||
12 -3675 -1280 57 -3442 slew-limited
|
||||
14 -3442 -2280 -316 -3069 slew-limited
|
||||
...
|
||||
34 -1012 -5000 -2823 -562 i-clamped ← the rail
|
||||
38 -167 -5000 -3541 156 saturated-freeze ← EXPORT → IMPORT
|
||||
44 562 -4310 -4018 633 saturated-freeze
|
||||
50 544 -3210 -3804 419 tracking
|
||||
```
|
||||
|
||||
Reproduced: the setpoint stepping a full 1000 W per tick to the rail, the loop
|
||||
absorbing ~640 W more than the site needed, and the meter crossing from **3.7 kW
|
||||
of export to +633 W of import** — the loop manufacturing the error it then
|
||||
chases. Over a family of 51 export steps (1.0–4.2 kW × 3 phases), **48 of 51**
|
||||
runs contain a ≥ 900 W setpoint step.
|
||||
|
||||
### ⚠️ What is NOT reproducible, and why no criterion is written on it
|
||||
|
||||
`plantsim.Plant.limit()` clamps the plant at `p_max`. The modelled battery
|
||||
**cannot exceed its rating by one watt, at any tuning, on any step, ever.**
|
||||
Neither can `test_control.py`'s independent model. So:
|
||||
|
||||
* the **−5154 W excursion is not reproducible in simulation at all**, and this
|
||||
is a property of the models rather than of the tuning;
|
||||
* the card's AC *"battery power no longer exceeds `max_w` on a step of the size
|
||||
observed"* is **unfalsifiable in simulation** — it passes identically on the
|
||||
configuration that produced the excursion. It is the one acceptance criterion
|
||||
this card had to **rewrite rather than meet**, per the board's own rule that a
|
||||
third review round means the AC is wrong.
|
||||
|
||||
What replaced it, and what is graded in CI: **the ≥ 900 W step is gone from
|
||||
every member of the family**, and **the meter excursion and the over-absorption
|
||||
are measured and reported rather than claimed to be fixed.**
|
||||
|
||||
### The two models agree
|
||||
|
||||
`test_control.py`'s in-repo first-order lag and admin/goodwe's ramp-plus-lag
|
||||
`plantsim.Plant` are independent, and they agree on every sign and rank:
|
||||
|
||||
| | in-repo model | `plantsim.Plant` |
|
||||
|---|---|---|
|
||||
| ≥ 900 W steps at `slew_w` 1000 | 48/51 | 48/51 |
|
||||
| ≥ 900 W steps at the derived value | 0/51 | 0/51 |
|
||||
| over-absorption, slew 1000 → 250 | 1005 → 1330 W | 958 → 1081 W |
|
||||
| over-absorption, gain 0.6 → 0.2 | 1109 → 393 W | 987 → 435 W |
|
||||
|
||||
---
|
||||
|
||||
## 3. The derivation
|
||||
|
||||
The constraint, in words, and it is the 2026-08-22 sentence made checkable:
|
||||
|
||||
> **The loop must not traverse its full output range faster than the plant takes
|
||||
> to settle.**
|
||||
|
||||
Three measured quantities, and each is named where it came from:
|
||||
|
||||
| | value | where it was measured |
|
||||
|---|---|---|
|
||||
| plant settling time | **18 s** | goodwe/`NOTES.md` §5.10, 2026-08-22 — a 1.78 kW kettle step driven from HA off a plug that meters its own draw. ~18 s up to the deadband, ~23 s down. **18 is taken because it is the shorter and therefore the looser bound**; this is a refusal boundary, and rules 5 and 6 refuse the impossible case only, never "close to the rail". |
|
||||
| control tick | **2 s** | the live install, measured 2026-09-05 — the setpoint stepped +1000 W every 2 s. `run_control()` runs a cycle on a changed meter value or on `cycle_floor_s`, whichever comes first, so the tick is the *meter's* rate. |
|
||||
| output range | `max_w` | `main.py` builds `Tuning(max_w=self.site_max_w)`, the **roster sum**. |
|
||||
|
||||
```
|
||||
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 0.1111 (9 ticks)
|
||||
|
||||
slew_w ≤ SLEW_RAIL_FRAC × max_w
|
||||
```
|
||||
|
||||
| rail | derived `slew_w` | ticks to cross | seconds to cross |
|
||||
|---|---|---|---|
|
||||
| 1500 (where the original was derived) | 167 W | 9 | 18 s |
|
||||
| 2000 (shipped default) | 222 W | 9 | 18 s |
|
||||
| **5000 (the live install)** | **556 W** | 9 | 18 s |
|
||||
| 9600 (with inverter B — **datasheet, not measured**) | 1067 W | 9 | 18 s |
|
||||
|
||||
And the configuration that was measured failing: `slew_w` 1000 at `max_w` 5000
|
||||
crosses the rail in **5.0 ticks = 10 s**, against a plant that takes 18 s.
|
||||
|
||||
### ⚠️ The tick is the weak term and it is not wrong in the safe direction
|
||||
|
||||
A **slower** meter makes this bound conservative — it refuses configurations that
|
||||
would have been fine, which is visible immediately and costs one edit. A meter
|
||||
**faster** than 2 s makes it loose by exactly that ratio and nothing in this
|
||||
add-on would say so. `run_control()`'s `await asyncio.sleep(1)` bounds the tick
|
||||
below at 1 s, so the worst case is a factor of two — but if a faster meter is
|
||||
ever fitted, `LOOP_TICK_S` has to be **re-derived, not merely re-read**. Compare
|
||||
`validate.FIRMWARE_INGEST_VETO_S`, which carries the same kind of copy and can
|
||||
only ever be wrong the other way; that difference is why both numbers are
|
||||
written out in `control.py` rather than their quotient.
|
||||
|
||||
### ⚠️ The rig cannot pick 556 out of the table, and is not asked to
|
||||
|
||||
`tools/slew_sim.py --slew`, worst case over 51 export steps:
|
||||
|
||||
```
|
||||
slew_w 1000 ← 2026-09-05 live pair step 1000 W 48/51 ≥900 W over-absorbed 958 W
|
||||
slew_w 800 step 800 W 0/51 ≥900 W over-absorbed 1107 W
|
||||
slew_w 700 step 700 W 0/51 ≥900 W over-absorbed 1133 W
|
||||
slew_w 600 step 600 W 0/51 ≥900 W over-absorbed 1058 W
|
||||
slew_w 555 ← the derived value step 560 W 0/51 ≥900 W over-absorbed 987 W
|
||||
slew_w 500 step 500 W 0/51 ≥900 W over-absorbed 1029 W
|
||||
slew_w 400 ← the live interim step 400 W 0/51 ≥900 W over-absorbed 1150 W
|
||||
slew_w 300 step 300 W 0/51 ≥900 W over-absorbed 1143 W
|
||||
slew_w 250 step 250 W 0/51 ≥900 W over-absorbed 1081 W
|
||||
```
|
||||
|
||||
The ≥ 900 W step disappears at 800 and stays gone. Below that the table is
|
||||
**flat and slightly non-monotone**, and 800 is *better* than 556 on
|
||||
over-absorption. So the rig does not choose the number — **the constraint
|
||||
chooses it, and the rig says what the constraint does and does not buy.** That
|
||||
is the same relationship the 2026-08-22 table had to its own three columns.
|
||||
|
||||
⚠️ **A single step is not evidence here.** The deadband, the 10 W quantisation
|
||||
and the phase of the step against the tick move the result by hundreds of watts,
|
||||
non-monotonically. The first cut of this derivation was a single step and ranked
|
||||
gain 0.3 as *worse* than 0.6, which the family shows is backwards. Every number
|
||||
above is a worst case over a family, which is how admin/goodwe grades the same
|
||||
kind of quantity (`config.yaml`'s "1890 closed-loop runs", "924 runs").
|
||||
|
||||
---
|
||||
|
||||
## 4. What ships — the relationship, not the number
|
||||
|
||||
**`slew_w: 0` means FOLLOW THE RAIL.** `control.slew_limit_w()` resolves it to
|
||||
`SLEW_RAIL_FRAC * max_w`. It is the shipped default and it is the only value
|
||||
that cannot be left behind when the rail moves. This is the idiom
|
||||
`integrator_max_w: 0` has carried in the same options block since it was added —
|
||||
not a new concept, the same one, applied to the key that needed it.
|
||||
|
||||
An explicit number is still accepted **and is never silently reduced**: the live
|
||||
install runs 400, and silently reducing an operator's stated bound is case (a)
|
||||
in `validate.py`'s own docstring — the defect that whole module exists to
|
||||
prevent.
|
||||
|
||||
**`validate.py` rule 10** refuses an explicit `slew_w` above
|
||||
`SLEW_RAIL_FRAC × site rail`. Graded against **`sum(unit_caps(opts))`**, the
|
||||
roster sum — the same number `Controller.site_max_w` builds — and *not* against
|
||||
the flat `max_w` and *not* against the weakest unit. Rules 5, 6 and 8 take
|
||||
`min()` because each grades one watchdog, one shaping claim, one pack. `slew_w`
|
||||
is not a per-unit quantity at all: `compute()` emits the **site** target and the
|
||||
allocator divides it afterwards.
|
||||
|
||||
⚠️ `config.yaml`'s note on `site_sign_disagree_w` says there is deliberately no
|
||||
rule pairing a site quantity with `max_w`, "because … `validate_startup_config()`
|
||||
is handed options rather than a roster". That objection is to grading a site
|
||||
quantity against the **flat key**, and it is answered rather than ignored:
|
||||
`unit_caps()` already reads the roster the add-on will build, so the roster sum
|
||||
*is* available at startup, and `site_rail_w()` is that number.
|
||||
|
||||
---
|
||||
|
||||
## 5. ⭐ Verified by breaking it
|
||||
|
||||
`test_validate.py`, section 10 — the acceptance criterion written as a test:
|
||||
|
||||
```
|
||||
restoring slew_w 1000 at max_w 5000 -> 1 problem(s)
|
||||
slew_w is 1000 W against a site rail of 5000 W, so the loop crosses its
|
||||
whole output range in 5.0 cycles - 10 s at the 2 s tick this site was
|
||||
measured running - while the plant takes 18 s to settle …
|
||||
```
|
||||
|
||||
and the four things the refusal has to say, each graded separately: which rail
|
||||
it used, how long the crossing takes, which measurement it is graded against,
|
||||
and what to do about it (including `0`, which cannot go stale). Plus:
|
||||
|
||||
* **equality starts** — at exactly the derived value the crossing takes exactly
|
||||
the settling time, which is the constraint met, not violated (rule 6's case,
|
||||
not rule 5's; getting it backwards would refuse the recommended value);
|
||||
* **one watt of slack starts** — a boundary that is not meaningful to one watt
|
||||
must not refuse on one watt;
|
||||
* **the live interim `slew_w: 400` starts**, and so does the schema floor —
|
||||
there is no such thing as too slow here, and refusing it would refuse the
|
||||
mitigation that is holding;
|
||||
* **`slew_w: 0` starts at every rating from 100 W to 15 kW.**
|
||||
|
||||
`test_control.py` grades the same boundary from the law's side, so the rule can
|
||||
only ever refuse what the ratio says is too fast, and the two cannot drift apart.
|
||||
|
||||
---
|
||||
|
||||
## 5b. What the change found in this repository's own tests
|
||||
|
||||
Five files moved, and **none of them moved because a number was rounded
|
||||
differently**. Each is worth reading as a finding.
|
||||
|
||||
**Two fixtures encoded the defect.** `test_inverter.py`'s `BASE_OPTS` paired
|
||||
`slew_w: 1000` with `max_w: 5000` — the 2026-09-05 configuration exactly — and
|
||||
`test_loop.py`'s `BASE` paired 1000 with 2000. Both were typed when those rails
|
||||
were new; nothing in either file had noticed. Rule 10 refused them on its first
|
||||
run, so those fixtures **would not start**. That is what a rule about a
|
||||
*relationship* is for, and it is the clearest evidence the rule is not a
|
||||
formality.
|
||||
|
||||
**Two reproductions depended on a default they never named.**
|
||||
`test_alloc.py`'s `close_loop` and `test_freeze.py`'s "make both terms
|
||||
reachable" case reached `slew_w: 1000` through `Tuning`'s default. ARCH-01's
|
||||
4000 W broadcast limit cycle and SAFETY-18's proof-of-capability alarm would
|
||||
both have gone quiet — reporting a measured finding as *absent* because a
|
||||
default moved underneath them. Both now write the number beside the measurement
|
||||
it belongs to.
|
||||
|
||||
**One harness had a latent off-by-one that only a ramping loop could expose.**
|
||||
`test_watchdog.py` graded SAFETY-02's banner by pairing `c.grid` with
|
||||
`last_write` sampled **after** `cycle()`, while the detector is evaluated
|
||||
**inside** `cycle()` against the *standing* command from the previous one. At
|
||||
`slew_w: 1000` the loop reached its rail in two cycles and consecutive commands
|
||||
were identical, so the wrong pair and the right pair were the same numbers. At a
|
||||
rail-following slew they differ by one step and the check went red. `_drive()`
|
||||
now has a `pre` hook that reads the operands the detector will actually use, and
|
||||
the check reads the **latching** cycle rather than the one before it. The
|
||||
assertion is strictly stronger than it was.
|
||||
|
||||
**One tripwire had two assertions that were exact by coincidence.**
|
||||
`test_peakguard.py` part 10c read `limited_discharging == limited` (49/49) and
|
||||
`min_soc <= bms_soc_floor + 0.05`. At the slower slew the loop drains that pack
|
||||
~82 Wh less over the same two hours and no longer touches the cut-out *inside
|
||||
that window*: 48/49, low-water 10.57 % against a 10 % cut-out, 0 cycles at the
|
||||
BMS limit instead of 190. **The finding is unchanged and is sharper on the
|
||||
measure that carries it** — 162 cycles of granted discharge below the EMS floor,
|
||||
up from 138. The two exact equalities were artefacts of one tuning, the old and
|
||||
new numbers are tabulated in the file beside the checks, and the assertions now
|
||||
grade the substance. ⚠️ *Loosening an assertion to get a branch green is the
|
||||
first thing a reviewer should suspect here; the table is there so the suspicion
|
||||
can be settled rather than argued.*
|
||||
|
||||
---
|
||||
|
||||
## 6. ⚠️ `gain: 0.6` — measured, and deliberately NOT changed
|
||||
|
||||
The card asks whether `gain` still holds at this clamp. **It does not, and here
|
||||
is the measurement.**
|
||||
|
||||
`tools/slew_sim.py --gain`, `slew_w` at the derived 556 W, same 51-step family:
|
||||
|
||||
```
|
||||
gain 0.6 ← shipped over-absorbed 987 W meter driven to +987 W
|
||||
gain 0.5 over-absorbed 941 W
|
||||
gain 0.4 over-absorbed 977 W
|
||||
gain 0.3 over-absorbed 876 W
|
||||
gain 0.2 over-absorbed 435 W
|
||||
gain 0.1 over-absorbed 66 W
|
||||
```
|
||||
|
||||
Against the slew column in §3, which is flat. The mechanism is visible in
|
||||
`control.py` and is structural rather than empirical:
|
||||
|
||||
* **`slew_w` bounds the OUTPUT.**
|
||||
* **The integrator accumulates `gain × error` per cycle** and is bounded only by
|
||||
`integrator_max_w`. At gain 0.6 into a 3.4 kW error it moves **~2040 W per
|
||||
tick** and reaches a 5000 W rail in **under three ticks, whatever `slew_w`
|
||||
is**.
|
||||
|
||||
So the 2026-08-22 derivation's own sentence — *"the gain, not the slew, is the
|
||||
limit by then"* — has become literally true, and `gain` is the term that owns
|
||||
the over-absorption at this rail. It is written about a 1500 W rail, where
|
||||
`gain × 1500 = 900 W` and the clamp caught it in two cycles; at 5000 there is
|
||||
3.3× more room to wind before the clamp binds.
|
||||
|
||||
**Why nothing was changed:**
|
||||
|
||||
* `gain` is a live control-law constant on a running house. Two of them moving
|
||||
in one release makes the next measurement uninterpretable — and the interim
|
||||
`slew_w: 400` has been in service since 2026-09-05 12:00, so there is a clean
|
||||
baseline to measure the next change against and it would be destroyed.
|
||||
* A validator rule pairing `gain` with a rail would be **inventing a
|
||||
relationship nobody has measured.** The bound would have to be
|
||||
`gain × error_max ≤ SLEW_RAIL_FRAC × max_w`, and `error_max` is the
|
||||
disturbance, which is not config and is not bounded.
|
||||
* `integrator_max_w` is **not** the lever it looks like: it bounds the output as
|
||||
well as the accumulator (`target = clamp(i_w, ±max_w)`), which is why setting
|
||||
it to 2000 on the live system on 2026-09-05 capped the inverter to 2000 W of
|
||||
its 5000 and was reverted the same day.
|
||||
|
||||
This is named in `validate.py`'s *"deliberately not refused"* list so that it is
|
||||
a known open item rather than an omission, and it is asserted in
|
||||
`test_control.py` so that nobody can quietly read this card as having fixed it.
|
||||
**It wants its own ticket.**
|
||||
|
||||
---
|
||||
|
||||
## 7. N = 1 → N = 2: the rail is what moved
|
||||
|
||||
⚠️ **Inverter B does not exist on site** (SPIKE-04, ≈ 2026-09-12). Its figures
|
||||
are from its **datasheet and serial**, not a measurement: GW5048D-ES, serial
|
||||
`55048ESU22AW0992`, 4600 W, pack 4 × Dyness DL5.0C = 20.48 kWh, floor 10 %
|
||||
(goodwe/`NOTES.md` §1.1b) — and the 4600 W is printed under *battery input data*,
|
||||
so it may be DC, in which case the AC ceiling is nearer 4324 W.
|
||||
|
||||
⚠️ **And `sim/plantsim.py`'s `INVERTER_B` is inverter A copied**, carrying
|
||||
`measured=False`. **Every two-inverter row below is a result about two copies of
|
||||
A on a 10000 W rail, not about the ~9600 W site B implies.**
|
||||
|
||||
**The site rail becomes `sum(max_w)` ≈ 9600 W while `slew_w` is per-site.** The
|
||||
derived value there is `0.1111 × 9600 = 1067 W` per tick, which at an even split
|
||||
is 533 W per unit against each unit's own 5000/4600 W rating — ARCH-01's
|
||||
split-invariance holding, as its `two_master_gain_sweep` already shows.
|
||||
|
||||
**Does the enforced relationship produce it automatically? Yes.** Rule 10 reads
|
||||
`sum(unit_caps(opts))` and `slew_limit_w()` reads `Tuning.max_w`, which
|
||||
`main.py` sets from `Controller.site_max_w`. Declaring inverter B moves both,
|
||||
with no operator action and no second derivation. That is the property that
|
||||
makes this a fix rather than a re-tune, and `test_validate.py` asserts the
|
||||
9600 W arithmetic directly.
|
||||
|
||||
⚠️ **But the direction of the rule is not the one the ticket assumed, and it
|
||||
must be said.** A rail that **grows loosens** this bound: `slew_w: 1000`
|
||||
crosses 9600 W in 19.2 s and **passes**, while the same 1000 at 5000 W is the
|
||||
configuration measured failing. That is what the constraint says and it is
|
||||
correct — the bound is on how long the crossing *takes*. What the rule protects
|
||||
against is the constant being left behind when the rail moves, in either
|
||||
direction. **It is not a bound on how much power the site may command** (that is
|
||||
each unit's own `max_w` through the arbiter's `supervised` claim) **and it does
|
||||
not bound the over-absorption** (§6).
|
||||
|
||||
⚠️ **The ≥ 900 W symptom goes stale at N = 2 the same way the constant did.**
|
||||
On the two-copies-of-A rows, 48/51 runs contain a ≥ 900 W step *at the derived
|
||||
value*, because at a 10000 W rail the derived step **is** 1111 W. A check
|
||||
written on the absolute 900 W would report the correct tuning as broken.
|
||||
Anything grading this symptom at N ≥ 2 has to grade it as a **fraction of the
|
||||
rail** too — which is this whole card, one layer out.
|
||||
|
||||
---
|
||||
|
||||
## 8. `max_w: 5000` — a recommendation, not a change
|
||||
|
||||
The battery reached **5154 W against a 5000 VA inverter**, so the configured cap
|
||||
left no headroom for the overshoot that the loop was going to produce anyway.
|
||||
Three things are worth writing down and none of them is changed here:
|
||||
|
||||
1. **The cap is the rating, so the overshoot has nowhere to go but past it.**
|
||||
`compute()` bounds the *command* to `±max_w`; nothing bounds the plant's
|
||||
response to a command at the rail, and §6 shows the loop asks for the rail on
|
||||
any large step at gain 0.6. Recommend **`max_w` at 4500–4750 W**, i.e. 90–95 %
|
||||
of the 5000 VA nameplate, leaving 250–500 W for the overshoot the loop still
|
||||
has. This is `freeze.py`'s `RAIL_FRAC` argument applied to the cap itself.
|
||||
2. **The −5154 W may not be an AC figure at all.** `batt_entity` is the pack
|
||||
sensor and `max_w` is graded against `0x0518`, the inverter's **AC** power —
|
||||
`config.yaml`'s roster comment already records exactly this ambiguity for
|
||||
inverter B. If the battery reading is DC, 5154 W DC is ~4900 W AC and the
|
||||
inverter was never over its nameplate. **Nobody has measured which**, and the
|
||||
recommendation above is worth making either way.
|
||||
3. **It is the owner's number.** `translations/en.yaml` marks `max_w` "THIS IS
|
||||
YOUR NUMBER" precisely so it is not confused with a derived constant, and
|
||||
changing it unilaterally in a pull request would undo that distinction.
|
||||
`SAFETY-20`'s `min(max_w, dev_max)` settles the device side once ESPHome has
|
||||
answered; the *headroom* question is a policy choice and belongs to the owner.
|
||||
|
||||
---
|
||||
|
||||
## 9. What was deliberately not done
|
||||
|
||||
* **`saturation_w` and the deadband were not touched.** The card forbids it and
|
||||
it is right to: widening either hides the symptom. The loop is following its
|
||||
command correctly; the command is wrong.
|
||||
* **`gain` was not changed.** §6.
|
||||
* **`max_w` was not changed.** §8.
|
||||
* **`control.py`'s slew *limit* was not clamped to the bound.** An explicit
|
||||
value above it is refused at startup, not quietly reduced — silently reducing
|
||||
an operator's stated bound is case (a) in `validate.py`'s docstring.
|
||||
* **Nothing in admin/goodwe was modified**, and nothing reached the inverter.
|
||||
@@ -0,0 +1,115 @@
|
||||
# TEL-07 — why inverter A does not get a device of its own, and B does
|
||||
|
||||
**Status: DECIDED 2026-09-05.** MQTT discovery announces **two** devices at a
|
||||
roster of two: the existing one, unchanged, plus one for the second inverter.
|
||||
It does **not** announce one per inverter. This document exists because the
|
||||
symmetrical design is the obvious one, is tidier, and would destroy the live
|
||||
system's recorded history — so the next person to read `app/mqtt.py` needs the
|
||||
reason written down where they will find it before they "fix" it.
|
||||
|
||||
Filed with TEL-07. Card `6a9bfa75f0a3550275ba4888`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The mechanism, in one paragraph
|
||||
|
||||
`app/mqtt.py`'s own note records the finding, tested on HA 2026.8: Home
|
||||
Assistant composes an entity id from the **device name plus the entity name**,
|
||||
and `object_id` in the discovery payload does **not** override it. So the device
|
||||
name is half of every entity id.
|
||||
|
||||
The second half of the mechanism is the half that makes it silent. Home
|
||||
Assistant keeps the entity id it composed at **first** discovery. Re-scoping the
|
||||
device therefore does not rename an entity in place; it creates a **new** entity
|
||||
beside the old one. The recorded history stays on the old id, the old id stops
|
||||
updating, and nothing errors. Every dashboard card, template and automation that
|
||||
names it — including `goodwe_master_watchdog_held_*` and
|
||||
`goodwe_master_parked_at_0_w_*`, which are live — keeps pointing at a sensor
|
||||
that has quietly gone flat.
|
||||
|
||||
`sensor.goodwe_p1_sample_age` is the sharp end of it: the ESP32's stale-input
|
||||
watchdog subscribes to that composed name, and its own comment calls this the
|
||||
failure that "FAILS SILENTLY AND LOOKS CORRECT".
|
||||
|
||||
## 2. The two designs
|
||||
|
||||
| | **one device per inverter** | **what shipped** |
|
||||
|---|---|---|
|
||||
| A's device | new: `inverter-a` | **the one it already has** |
|
||||
| B's device | new: `inverter-b` | new: `goodwe_rs485_controller_inverter_b` |
|
||||
| A's existing entity ids | **all renamed** | **byte-identical** |
|
||||
| A's recorded history | **stranded on dead ids** | kept |
|
||||
| watchdog automations | **silently broken** | untouched |
|
||||
| composed per-unit ids | `sensor.goodwe_inverter_a_*` / `..._b_*` | **the same** |
|
||||
| reads as | symmetrical and tidy | asymmetrical, and needs this file |
|
||||
|
||||
The last two rows are the whole argument. The tidy design's only real benefit is
|
||||
a pair of symmetric, predictable entity ids — and the shipped design produces
|
||||
**exactly the same ids** by putting the unit's name in the *entity* name for the
|
||||
first roster entry and in the *device* name for the others. There is nothing
|
||||
left to buy, and the price was the live system's history.
|
||||
|
||||
## 3. What is per-unit and what is site
|
||||
|
||||
**Per-unit:** setpoint (this unit's share of the site target, the number its own
|
||||
heartbeat re-emits), battery power (`Inverter.batt`, register `0x0518`, this
|
||||
unit's own reading), SoC (`Inverter.soc` — **this** pack; the site row is the
|
||||
*emptiest* pack, which at N≥2 may be the other one), and the SAFETY-02 sign
|
||||
latch, which is one instance per actuator by construction.
|
||||
|
||||
**Site:** `grid` and `p1_sample_age_s`. There is one P1 meter and one grid
|
||||
connection, so a per-inverter copy invents two of a thing there is one of — and
|
||||
`p1_sample_age_s` is a **watchdog input**, so a second copy is a second answer to
|
||||
"is the meter fresh?", one of which is always the one nobody is looking at.
|
||||
Circulating power is site because it *is* a property of the set
|
||||
(`sum(|Bᵢ|) − |sum(Bᵢ)|`); so are the controller status, the maintenance phase
|
||||
and the capacity-tariff peak.
|
||||
|
||||
**Named as absent rather than left looking finished:** the card also lists
|
||||
"inverter AC power" and per-unit heartbeats as per-unit. Neither has a source in
|
||||
the add-on today — nothing reads an AC-power register per unit, and
|
||||
`Inverter.last_beat` is a monotonic loop clock with no wall-clock age published
|
||||
anywhere. Announcing them would create entities nothing ever feeds, which is the
|
||||
hazard the `omit` note in `mqtt.py` exists to refuse. They are gaps.
|
||||
|
||||
## 4. Why a roster of one announces nothing
|
||||
|
||||
Every install running today is a roster of one, and at N=1 the site rows **are**
|
||||
that unit's rows: `battery` is its power, `soc` is its pack. Four extra entities
|
||||
would be four new rows carrying numbers already on the dashboard, on a live
|
||||
system, for no information. `announced_units()` answers `[]` below
|
||||
`PER_UNIT_MIN_ROSTER`, and `Controller.publish()` reads **that same list** —
|
||||
the empty list is the gate on both halves, rather than a predicate written twice
|
||||
that could later disagree.
|
||||
|
||||
## 5. How it was verified, and what that does not cover
|
||||
|
||||
The claim is a **diff of the announced payloads** against `release/1.0`,
|
||||
generated by running that commit's module rather than transcribed from it:
|
||||
`9 topics, 0 missing, 0 changed, 0 added` at a roster of one, and
|
||||
`0 missing, 0 changed, 8 added` at a roster of two. `test_mqtt.py` part 2 shows
|
||||
`_announce()` publishes exactly what is diffed, so the comparison is against the
|
||||
bytes paho is handed.
|
||||
|
||||
Sixteen mutations were run. Twelve are in the file as a running table, each
|
||||
named with the property it breaks. Three properties in that table exist only
|
||||
because the table found their absence — dropping the unit from the state topic,
|
||||
moving `grid` into the per-unit list, and `units=()` in main()'s publisher call
|
||||
were all green on the first run, as was a roster fallback that named two
|
||||
nameless entries `inverter-a`.
|
||||
|
||||
**What it does not cover is listed in `test_mqtt.py` part 9, with sizes.** The
|
||||
largest item: Home Assistant itself is not in the loop. The entity-id
|
||||
composition is a **model**, anchored on exactly one measured id
|
||||
(`sensor.goodwe_p1_sample_age`, verified against the live system 2026-08-26).
|
||||
Closing that needs a broker, an HA and the owner present, and this card is not
|
||||
allowed near the live install.
|
||||
|
||||
## 6. ⚠️ Inverter B does not exist
|
||||
|
||||
Not until ~2026-09-12. Every roster of two in this repository's checks is
|
||||
**simulated**; B's `max_w: 4600` is the datasheet's figure for a GW5048D-ES
|
||||
(serial `55048ESU22AW0992`), recorded in `goodwe/NOTES.md` §1.1b, and nothing
|
||||
about B has ever been measured. No entity described here has been seen on a
|
||||
broker. When the second inverter is installed, the first thing to check is that
|
||||
the eight ids in §1 are still updating — not that the new ones appeared.
|
||||
+126
-3
@@ -40,6 +40,30 @@ hardware holds it there.
|
||||
|
||||
## Configuration
|
||||
|
||||
### What the Configuration tab actually shows you (UX-01)
|
||||
|
||||
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
|
||||
so Home Assistant's *Configuration* tab renders human labels and a description
|
||||
for every option instead of the raw option keys. What that does and does not buy
|
||||
you is worth stating plainly, because it was **measured against the Supervisor
|
||||
image this site runs** (`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`)
|
||||
rather than assumed — `tools/supervisor_shape_probe.py` section F prints the
|
||||
whole rendered list, in both languages.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **All 70 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
|
||||
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
|
||||
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
|
||||
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
|
||||
| **The description says whether to touch it** | A number that was **derived** names the campaign it came from and says not to change it without re-deriving; a number that is genuinely yours (`max_w`, `peak_cap_w`) says **"THIS IS YOUR NUMBER"**; and `cycle_cost_threshold_eur`, which is neither, says it is an open product question |
|
||||
| **Dutch falls back per key** | `translations[your language]?.name \|\| translations["en"]?.name \|\| the raw key`. A key missing from `nl.yaml` degrades to English; a key present but wrong does not degrade at all |
|
||||
| **⚠️ The per-entry keys inside `inverters:` still render raw** | Measured, and negative. A list-of-objects becomes an `object` selector with `multiple: true`, and `ha-selector-object` labels its fields `fields[name].label \|\| name` — the converter sets no `label` and no `translation_key`. The `fields:` translations are shipped and validated (they are the shape Supervisor's own schema defines) but on frontend 20250925.1 they do **not** reach the screen. The `inverters` label and description themselves do |
|
||||
|
||||
`test_translations.py` is what stops this rotting: a translation naming an
|
||||
option that does not exist, or an option with no translation, fails CI — in
|
||||
either direction, and the check is mutation-tested both ways.
|
||||
|
||||
### Sources
|
||||
|
||||
| option | required | meaning |
|
||||
@@ -78,9 +102,20 @@ inverters:
|
||||
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: 4600
|
||||
```
|
||||
|
||||
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
|
||||
GoodWe GW5048D-ES (serial `55048ESU22AW0992`, identified 2026-09-04) whose
|
||||
datasheet gives max charging and discharging power as 4600 W; with nothing
|
||||
connected to its PV inputs the battery path is the ceiling, not the 5000 VA AC
|
||||
nameplate. The datasheet prints that number under *Battery Input Data*, so it
|
||||
may be a DC figure, while `max_w` is graded against register `0x0518` — the
|
||||
inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
|
||||
`max_w: 4600` is still the right thing to configure, because the freeze rail is
|
||||
`min(max_w, dev_max)` and the device range read off the unit is what binds.
|
||||
`goodwe/NOTES.md` §1.1b records both readings and picks neither.
|
||||
|
||||
| per-entry key | required | meaning |
|
||||
|---|---|---|
|
||||
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
|
||||
@@ -131,6 +166,53 @@ in the log rather than silent, but it is an outage until someone puts the
|
||||
single-inverter entity back in that field. **Before downgrading, write down the
|
||||
`setpoint_entity` of the unit you want to keep running.**
|
||||
|
||||
### What appears in Home Assistant at two inverters
|
||||
|
||||
**Nothing you already have changes.** The eight entities this add-on has always
|
||||
published — `Setpoint`, `Grid power`, `Battery power`, `Battery SoC`,
|
||||
`Circulating power`, `Maintenance phase`, `Controller status`, `P1 sample age` —
|
||||
keep their device, their `unique_id`s and therefore their **entity ids and their
|
||||
whole recorded history**. At a roster of one, nothing new is announced at all:
|
||||
no second device, no renamed entity, not one added topic.
|
||||
|
||||
Declare a second inverter and **four rows per unit** appear beside them:
|
||||
|
||||
| per-unit row | what it is |
|
||||
|---|---|
|
||||
| `Setpoint` | this unit's own share of the site target — the number its heartbeat re-emits |
|
||||
| `Battery power` | this unit's own reading, `+` = discharging |
|
||||
| `Battery SoC` | **this** pack. The site `Battery SoC` row is the *emptiest* pack, which may be the other one |
|
||||
| `Sign latch` | this unit's SAFETY-02 latch: `tripped` or `clear` |
|
||||
|
||||
⚠️ **The devices are deliberately asymmetric, and it is not an oversight.** The
|
||||
first inverter's rows are added to the **existing** *GoodWe* device; every later
|
||||
inverter gets its **own** device (*GoodWe Inverter B*), shown in Home Assistant
|
||||
as connected via the controller. Home Assistant composes an entity id from the
|
||||
**device name plus the entity name**, so giving the first inverter a device of
|
||||
its own would rename every entity you already have — and because HA keeps the id
|
||||
it composed at first discovery, you would get a new entity beside a dead one:
|
||||
the history stays on the old id and every dashboard, template and automation
|
||||
naming it silently stops updating. The entity ids come out symmetric anyway —
|
||||
`sensor.goodwe_inverter_a_battery_power` beside
|
||||
`sensor.goodwe_inverter_b_battery_power`.
|
||||
|
||||
**Do not rename the device.** Not in this add-on, and not in Home Assistant's
|
||||
device page. It is half of every entity id, including
|
||||
`sensor.goodwe_p1_sample_age`, which the ESP32's stale-input watchdog subscribes
|
||||
to; a rename disarms that guard by leaving it waiting for a state that never
|
||||
arrives, and it fails silently and looks correct.
|
||||
|
||||
**`Grid power` and `P1 sample age` stay site-scoped and are announced once.**
|
||||
There is one P1 meter and one grid connection; a per-inverter copy would invent
|
||||
two of a thing there is one of, and `P1 sample age` is a safety input. Same for
|
||||
`Circulating power` (it is a property of the *set* of inverters), the controller
|
||||
status, the maintenance phase and the capacity-tariff peak.
|
||||
|
||||
⚠️ **Two roster names that differ only in punctuation or case** — `inverter-b`
|
||||
and `Inverter B` — are two names to the validator and one id once slugged. The
|
||||
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
|
||||
get then depend on roster order. Give your inverters plainly different names.
|
||||
|
||||
### P1 meter ingestion
|
||||
|
||||
`meter_entity` above expects one signed sensor, which usually means a template
|
||||
@@ -350,7 +432,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
|---|---|---|
|
||||
| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning. **Not read when `inverters` is declared** — each entry carries its own, and the site ceiling is their sum |
|
||||
| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** |
|
||||
| `slew_w` | 1000 | Maximum change per cycle |
|
||||
| `slew_w` | 0 | Largest change in one cycle. **0 means follow the rail** - `max_w / 9`, i.e. the loop takes the plant's own measured ~18 s settling time to cross its whole output range at the 2 s tick the live install runs. 222 W at `max_w: 2000`, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. A fixed number is accepted and never silently reduced, but one FASTER than the rail implies is refused at startup (SAFETY-39): `slew_w: 1000` against `max_w: 5000` crossed the rail in 10 s against an 18 s plant, and on 2026-09-05 drove the meter from 3.4 kW of export to 2.8 kW of import |
|
||||
| `deadband_w` | 15 | Ignore errors smaller than this |
|
||||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||||
| `step_w` | 10 | Quantisation |
|
||||
@@ -363,6 +445,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 +460,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 +636,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
|
||||
|
||||
@@ -135,11 +135,31 @@ manufactures them from a **fixture**: the check runs, the assertion is
|
||||
evaluated, the sentence above it is true — and the numbers it compares are
|
||||
arranged so that the failure it describes could not have changed the answer.
|
||||
|
||||
It is not hypothetical and it is not rare. Six instances in two weeks; three
|
||||
It is not hypothetical and it is not rare. ELEVEN instances in two weeks; four
|
||||
were found by review, two - CFG-04's fixture pair - by an author's own mutation
|
||||
battery, which is the cheaper place, and the sixth by review again, in the one
|
||||
battery, which is the cheaper place, one by review again, in the one
|
||||
place nobody had thought to point the rule at: not a check in the suite, but a
|
||||
COMMITTED EVIDENCE ARTIFACT four documents cite.
|
||||
COMMITTED EVIDENCE ARTIFACT four documents cite - the eighth INSIDE THE FIX
|
||||
WRITTEN TO CLOSE THE SEVENTH, the ninth inside the SITE HALF of the card
|
||||
that names the rule in its own text - and the TENTH INSIDE THE FIX WRITTEN TO
|
||||
CLOSE THE NINTH, for the second time in this list. See SAFETY-38 below: the
|
||||
check that closed it asserted a NUMBER and not the number's POSITION.
|
||||
|
||||
⚠️ AND THE ELEVENTH IS IN A MEASURING INSTRUMENT RATHER THAN IN A CHECK, which
|
||||
is where the seventh was too. SAFETY-38 round 4's render probe - the thing that
|
||||
decides whether a mutation changed the SENTENCE and not merely the bytes -
|
||||
numbered each interpolated operand BY ORDER OF APPEARANCE. After a transposition
|
||||
the other operand takes the same number, so all thirteen mutant renders came back
|
||||
BYTE-IDENTICAL to the clean tree: the probe compared the swap against itself, and
|
||||
its output read exactly like "the edit is inert". Keying the value to the
|
||||
operand's NAME fixed it. Habit 16.
|
||||
|
||||
⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance,
|
||||
MAINT-05's, is the one that says why: the defect is not carelessness about
|
||||
fixtures, it is that **a check and the thing it checks were built from the same
|
||||
constant**. Ask of every difference-claim not "is this assertion true" but
|
||||
"where did each side of this comparison come from" - and if the answer is the
|
||||
same place twice, the check is decoration.
|
||||
|
||||
* **ARB-03** shipped a call-site scan asserting that every `claims.append` in
|
||||
`cycle()` routes through `actuator_of()`. Its own parse **saw 3 of the 8
|
||||
@@ -167,6 +187,44 @@ COMMITTED EVIDENCE ARTIFACT four documents cite.
|
||||
than against its behaviour. It now drives the shipped `run_control()` on a
|
||||
two-inverter config, **under a deadline**, because the mutation's real symptom
|
||||
is that the controller enters its loop and never returns.
|
||||
* **MAINT-05** shipped `Controller.__init__` calling
|
||||
`migrate_maint_keys(store, self.inverters[0].name, ...)` under a comment
|
||||
saying *"it targets `self.inverters[0].name` and NOT `UNIT_A` ... writing
|
||||
`UNIT_A` here would deposit an install's whole maintenance history under a
|
||||
name that is not on the roster at all"*. `inverter.UNIT_A` **is** the string
|
||||
`"inverter-a"`, and every two-pack fixture in `test_maintenance.py` named its
|
||||
first roster entry `inverter-a` — so the two expressions were the same value
|
||||
in every check that existed, and swapping them left **all 19 files green**
|
||||
while losing a real install's whole maintenance history. Two-pack fixtures are
|
||||
now `["garage", "shed"]`, each behind a precondition check (habit 1) that the
|
||||
first name is not `UNIT_A`.
|
||||
|
||||
⚠️ **AND THE POSTCONDITION HAD THE SAME SHAPE AS THE FIXTURE.**
|
||||
`store._verify_moves()` read back the keys it had just written, under the
|
||||
namespace it had just been handed — so it verified clean, and the caller
|
||||
stamped the schema marker, on a migration nothing would ever read. *A check
|
||||
that reads back the namespace it was handed cannot detect being handed the
|
||||
wrong one*: it compared a thing against itself, exactly as DEV-02's fixture
|
||||
compared `(-5000, 5000)` against `(-5000, 5000)`. The habit-2 fix is the same
|
||||
one — grade against something the thing under test did not supply, here the
|
||||
ROSTER the machines read.
|
||||
|
||||
⚠️ **AND THE FIX ITSELF CARRIED THE DEFECT, WHICH IS THE SIXTH CARD IN A ROW
|
||||
AND THE FIRST TIME IT HAS APPEARED *INSIDE* A REMEDY.** The fix round's
|
||||
recovery check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` — the
|
||||
**corrected** name. It measures recovery along the one path where the operator
|
||||
has *already fixed the bug*, and nothing in the 19 files called
|
||||
`migrate_maint_keys()` twice with the **same wrong** `unit`. So the refusal
|
||||
the card was rejected twice to obtain lasted **exactly one start**: measured
|
||||
at Controller level over one persisted store, roster `["garage", "shed"]` —
|
||||
construction 1 `ok=False misdirected=4 moved=4 kept=0 marker=<absent>`,
|
||||
construction 2 `ok=True misdirected=0 moved=0 kept=4 marker=1`. On start 2
|
||||
rule 1 routes the wrongly-written keys to `rep.kept`, `rep.moved` is empty,
|
||||
the roster loop iterates over nothing, and the marker is stamped on a
|
||||
migration that reached no readable key — after which the early return skips it
|
||||
forever, *including after the call site is fixed*. The check was true and
|
||||
passed for the wrong reason. Habit **9** below is what falls out of it.
|
||||
|
||||
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
|
||||
reaching outside the suite. The probe is the committed evidence that
|
||||
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
|
||||
@@ -188,6 +246,65 @@ COMMITTED EVIDENCE ARTIFACT four documents cite.
|
||||
assertion cannot fail is exactly the thing this section is about. Two habits
|
||||
follow, and both are habit 5 and 6 below.
|
||||
|
||||
* **SAFETY-38** — `main.py`'s
|
||||
`site_standing = float(sum(standing.values()))`, the first argument of
|
||||
SAFETY-30's latching site sign detector *and* SAFETY-18's `looks_frozen()`
|
||||
input. `test_watchdog.py`'s `_sign_site` is the suite's **only** N≥2 site
|
||||
fixture, and it runs shares of about **−2500 W each** — every one
|
||||
individually over the 320 W gate it is configured with. On numbers like those
|
||||
`sum`, the mean and `max(key=abs)` **all agree**, so the reduction could be
|
||||
swapped for either of the other two and the site guard went **blind with the
|
||||
whole suite green** — still commanding −500 W of charge into a +2500 W import at
|
||||
t = 595 s with `following=True`. At a roster of **one** it is worse and it is
|
||||
not a fixture defect at all: `sum == mean == max == the one share`
|
||||
*identically*, so every such mutation is genuinely **equivalent** there and no
|
||||
N=1 fixture could ever separate them. The fixture that separates them is the
|
||||
one the detector exists for — **per-unit shares EACH BELOW the gate whose SUM
|
||||
clears it** — and it is now `test_watchdog.py` part 9, at asymmetric ratings
|
||||
so that all four candidate reductions give four **distinct** answers.
|
||||
|
||||
⚠️ **AND THE CARD'S OWN SEARCH FOR ITS NEXT SURVIVOR FOUND ONE, WHICH IS THE
|
||||
POINT OF ASKING.** Having built the fixture, the question *what mutation would
|
||||
this NOT see?* produced `float(self.target)` — the arbiter's site target
|
||||
instead of the sum of what stands on the wire — at **−11 B**, green across the
|
||||
whole suite, because every fixture in the repository was built where the two
|
||||
are the same number. They separate only when a device's reported range is
|
||||
narrower than its `max_w`, since the `device` claim is filed **per actuator**
|
||||
and never bounds the site target. Closed in the same commit, by a
|
||||
counterfactual that asks for −500 W while −250 W stands.
|
||||
|
||||
⚠️ **AND THEN THE FIX ITSELF WAS THE TENTH INSTANCE — A SUBSTRING WHERE THE
|
||||
FAILURE WAS POSITIONAL.** The check written to pin the banner asserted
|
||||
`f"{sum(...):.0f} W" in reason`: that **"−500 W" appears somewhere** in the
|
||||
sentence. Swapping the detector's first two arguments
|
||||
(`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`)
|
||||
is a **ZERO-BYTE** edit that leaves both numbers in the string and moves each
|
||||
into the other's slot — so the substring is still found, **in the meter
|
||||
slot**, and the whole suite stayed green while the latching site-stopping
|
||||
banner told the operator which instrument read what **exactly backwards**.
|
||||
The fix is to assert the **ORDERED PAIR**,
|
||||
`f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`.
|
||||
|
||||
⚠️ **AND THE ELEVENTH WAS THE SAME DEFECT ONE CALL SITE OVER — THE ONE A
|
||||
SHIPPED INSTALL CAN ACTUALLY SEE.** Round 2 pinned the operand order of
|
||||
`SignWatchdog.evaluate()` at `main.py:1620`. That call site is the **site**
|
||||
detector, and `test_watchdog.py` part 9c asserts it is **never armed at a
|
||||
roster of one** — which is every install this add-on has. The **per-unit**
|
||||
call at `main.py:1560` was left unpinned, and swapping its first two
|
||||
arguments is the same **ZERO-BYTE** edit: `disagrees()` is symmetric in them
|
||||
(`commanded_w * against_w < 0` with an `abs()` gate on both), so **no trip
|
||||
decision can change** and the full 21-file suite stays green — measured. What
|
||||
moves is five banner lines, including the one persisted to `sign_reason` and
|
||||
re-read after a restart: *"commanded −1000 W against its own measured power
|
||||
+1000 W"*, operands inverted, on the only sign-disagreement sentence a
|
||||
one-inverter install can ever print. Closed in round 3 by the ordered pair on
|
||||
**both** per-unit banners — the N≥2 `its own measured power` one and the N=1
|
||||
`a meter reading` one — red on four named checks, green in the other twenty
|
||||
files. **Habit 15 is what falls out of finding the same shape twice in the
|
||||
same card.**
|
||||
|
||||
The general habits are **14** and **15** below.
|
||||
|
||||
Note what these have in common with a masked rung: the mutation *runs*, and the
|
||||
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
|
||||
can see it, because the code under test really did behave identically on the
|
||||
@@ -241,6 +358,193 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
red. `inverters: []` was deleted from `config.yaml` and the probe re-run
|
||||
against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` for
|
||||
exactly that reason.
|
||||
9. **Exercise a refusal a SECOND time with the SAME wrong input — not the
|
||||
corrected one.** A guard that refuses once and then admits the same bad
|
||||
caller is not a guard, it is a delay, and the second call is a *different
|
||||
code path* whenever the first call left state behind. MAINT-05's roster
|
||||
grading iterated the keys it had just **written**; on the second start the
|
||||
same wrong caller found them already present, they moved to the
|
||||
`already namespaced` list, and the loop graded an empty set. Re-running with
|
||||
the **fixed** argument — which is what the check did — measures the operator's
|
||||
recovery, never the persistence of the refusal. Ask of every refusal: *what
|
||||
does this do when the caller has not been fixed and simply runs again?* And
|
||||
assert the **shape** of that second report, not only its verdict, so the
|
||||
check cannot silently revert to grading the first path.
|
||||
|
||||
10. **A fixture of N members cannot separate two reductions that agree on N
|
||||
members — so grade the FAMILY, and grade it from the SOURCE.** This is the
|
||||
only lesson SAFETY-32 cost four review rounds to learn, and it generalises
|
||||
past packs to every roster, list and window in this repository. Two packs
|
||||
cannot tell `max` from `min` from first from last. Three cannot tell `max`
|
||||
from `max(x[-2:])`. **The regress does not terminate in a fixture**: `[:1]`
|
||||
needs two members, `[:2]` three, `[:3]` four, and adding one more member
|
||||
moves the hole rather than closing it. Three things follow, and all three
|
||||
are now in `test_peakguard.py` parts 12c2, 12g, 12i and 12j:
|
||||
* the fixture is a **SET** — the extreme at every position of the roster,
|
||||
not one roster with the extreme somewhere;
|
||||
* the family table is **COMPUTED and PRINTED**, so a member no fixture kills
|
||||
shows up as a named survivor and takes the check red, instead of being
|
||||
absent from a list nobody re-derived;
|
||||
* where the claim is an **IDENTITY** ("this roster", "this list"), pin it
|
||||
**exactly on the AST** — `ast.unparse(arg) == "self.packs"` is false of
|
||||
every slice, where `"self.packs" in src` is true of all of them. Where the
|
||||
claim is **SEMANTIC** ("the maximum"), a shape pin would refuse honest
|
||||
refactors, so report the unreached width **with its size** instead.
|
||||
|
||||
11. **When a check of this shape is found, mutate its SIBLINGS before shipping
|
||||
the fix.** SAFETY-32 was rejected three times for the same defect one step
|
||||
over: the reduction, then the roster handed to it, then the reduction handed
|
||||
to the *other argument of the same call*. Enumerating the family from the
|
||||
AST and mutating each member took one afternoon and found **four more
|
||||
survivors in code the card never touched** — three of SAFETY-30's
|
||||
`any(i.sign_wd.tripped for i in self.inverters)` reductions, invisible
|
||||
because the only fixture that reached them was a roster of ONE, where
|
||||
`self.inverters[:1]` **is** `self.inverters`. Ask of every fix: *what is the
|
||||
sibling of the thing I just hardened, and does any fixture separate it from
|
||||
a slice of itself?*
|
||||
|
||||
12. **When the same shape has been found twice, STOP CLOSING SITES AND BUILD
|
||||
THE INSTRUMENT — and make it DISCOVER its own scope.** SAFETY-32 was
|
||||
rejected four times, and each round closed exactly the site it was handed:
|
||||
the reduction, then the roster handed to it, then the *sibling argument* of
|
||||
the same call, then the same family one *module* over, in validate.py.
|
||||
Round 4 did enumerate — and enumerated inside the `Controller` class,
|
||||
because that is where the four known sites lived. **A hand-written scope is
|
||||
the same defect as a hand-written fixture**: it is a check whose data cannot
|
||||
reach the failure it names, and it goes on passing while the thing it was
|
||||
supposed to cover sits in the file nobody added to the list. Round 5's pin
|
||||
parses every `.py` under `app/`, seeds itself from ONE option key, and
|
||||
propagates through returns, accumulator loops, attributes and *call
|
||||
arguments* to find the roster wherever it went — 183 reductions across 9
|
||||
modules. It rediscovered all four blockers without being told where to look,
|
||||
and it named two modules the hand-written list would not have contained
|
||||
(`maintenance.py`, `store.py`) while *omitting* one the list did contain
|
||||
(`watchdog.py`, which is strictly per-unit and reduces over no roster at
|
||||
all). **The list was wrong in both directions**, which is exactly what a
|
||||
computed scope is for.
|
||||
|
||||
13. **Say what the instrument CANNOT see, with sizes, in the same breath.** A
|
||||
structural pin grades an **identity** ("this whole roster arrives"); it can
|
||||
never grade a **meaning** (`min` vs `max` is a zero-byte edit and invisible
|
||||
to it). Ship the pin AND the behavioural set, name the gap, and give every
|
||||
unreached case its byte delta — `cycle()`'s undeliverable-unit log line
|
||||
(+4 B, still green), a subscript by a *variable* rather than a constant, a
|
||||
slice taken through an intermediate call, anything outside `app/`. **An
|
||||
enumerated list with an honest gap is worth more than a claim of
|
||||
completeness a reviewer falsifies in ten minutes**, and every round of this
|
||||
card that claimed the latter was rejected for it.
|
||||
|
||||
14. **An `in` test against a formatted sentence grades PRESENCE, never
|
||||
ARRANGEMENT — so assert the ORDERED PAIR.** SAFETY-38 pinned the banner of a
|
||||
latching, site-stopping alarm with
|
||||
`f"{sum(_share38):.0f} W" in ...reason` — that `"-500 W"` appears
|
||||
*somewhere*. Swapping the detector's first two arguments at the call site
|
||||
(`evaluate(site_standing, self.grid, …)` →
|
||||
`evaluate(self.grid, site_standing, …)`) is a **ZERO-BYTE** edit: both
|
||||
numbers are still in the string, each in the other's slot. The substring was
|
||||
still found — **in the meter slot** — the whole suite stayed green, and the
|
||||
banner told the operator which instrument read what **exactly backwards**.
|
||||
Whenever the property you mean is "**this** value went in **this** slot" — a
|
||||
reason string, a log line, a notification, an MQTT payload — assert the
|
||||
surrounding **literal text with the value embedded in its own position**
|
||||
(`f"commanded {x:+.0f} W against a meter reading {y:+.0f} W"`), or assert
|
||||
the fields **before** they are formatted. Two values that both appear in one
|
||||
sentence can always be transposed by an edit that costs **nothing**, and
|
||||
that no byte delta, no fingerprint and no shape pin can see. ⚠️ Note where
|
||||
this one was found: **inside the fix written to close the previous instance
|
||||
of the same family**, which is the second time in this list.
|
||||
|
||||
⚠️ **AND THE FIX FOR IT WAS ONE CALL SITE WIDE, WHICH IS THE HALF WORTH
|
||||
KEEPING.** The ordered pair went onto `main.py:1620`, the **site** detector
|
||||
— armed only at N≥2, and part 9c asserts it is **absent at a roster of
|
||||
one**. The identical swap at `main.py:1560`, the **per-unit** detector, was
|
||||
still **ZERO BYTES**, still changed no decision, still left the 21-file
|
||||
suite green — and it is the **only** sign-disagreement sentence a shipped
|
||||
one-inverter install can ever print: *"commanded −1000 W against its own
|
||||
measured power +1000 W"*, backwards, in the log, on the status page and in
|
||||
`sign_reason` after the restart the latch is designed to survive. Measured
|
||||
size: **main.py:1560, 0 bytes, 21/21 green.** So habit 14 is not "pin the
|
||||
banner", it is **"pin every call site of the banner, and say which one the
|
||||
shipped roster reaches"** — and habit 15 is how you find them all.
|
||||
|
||||
15. **A SYMMETRIC PREDICATE MAKES ARGUMENT ORDER A PROPERTY OF THE MESSAGE
|
||||
ALONE — so ENUMERATE THAT FAMILY BY MACHINE, and grade the CALL SITES, not
|
||||
the function.** Where a callee is symmetric in two arguments, a swap at any
|
||||
call site is invisible to every behavioural check **by construction**: not
|
||||
"no fixture happens to separate them" but *no fixture can*. There is
|
||||
nothing for a mutation battery to find, because the mutant and the shipped
|
||||
tree take identical actions on every input. The only surface that moves is
|
||||
the sentence, and the only check that can see it is one asserting the
|
||||
**ordered pair** (habit 14) at **that** call site.
|
||||
Three consequences, and all three are why this is its own habit:
|
||||
* **The unit of coverage is the CALL SITE.** SAFETY-38 pinned
|
||||
`evaluate()`'s operand order and still shipped the defect, because the
|
||||
pin was on the call two lines below the one that matters. Ask of every
|
||||
banner: *how many places call this, and which does the shipped roster
|
||||
reach?*
|
||||
* **The scope must be COMPUTED** — habit 12's argument, and SAFETY-38 is
|
||||
its proof: the family was enumerated by hand twice and was short both
|
||||
times. `tools/symmetric_message_scan.py` renames each parameter pair,
|
||||
erases every formatted string, canonicalises the commutative operators
|
||||
and iterates to a **fixpoint** over `app/` (the symmetry is usually one
|
||||
call deep — `evaluate()` is symmetric only *through* `disagrees()`), then
|
||||
classifies each sentence by where it GOES. A returned string may be a
|
||||
**key** — swapping `store.unit_key(unit, key)` renames a store key, which
|
||||
is behaviour and is MAINT-05's card — or a **sentence**, which is this
|
||||
family.
|
||||
* **Say what the instrument cannot see, with shapes** (habit 13). That scan
|
||||
under-reports semantic symmetry no canonicaliser on its finite list can
|
||||
spell; its second census, for sentences the CALLER builds, pairs only
|
||||
expressions **one leaf apart** and never sees `%s`-style logging; it
|
||||
covers only `app/`; and it **over-reports in exactly one way** — it
|
||||
erases the sentence, so a swap that would make the FORMATTING itself
|
||||
raise is scored as changing no decision. It prints all of that on every
|
||||
run. The family, every member's call sites, and which of them are pinned
|
||||
are in `DECISIONS/SAFETY-38-site-standing-pin.md` §7 — **with two
|
||||
measured, still-open survivors named there rather than rounded off**,
|
||||
both zero bytes and both 21/21 green.
|
||||
* ⚠️ **AND THE SINK IS HALF THE SCOPE, WHICH IS HOW THE FIRST THREE ROUNDS
|
||||
OF THIS SCAN MISSED THE PLACE THE CODEBASE ACTUALLY KEEPS ITS SENTENCES.**
|
||||
The scan classified a sentence as operator-facing when it was **appended,
|
||||
assigned to a `reason`-shaped name, or logged** — and every one of its 16
|
||||
reported members was one of those. **None was RETURNED.** A method
|
||||
formatting its own **attributes** and handing the string back —
|
||||
`ClampResult.describe()`, `PeakDecision.explain()`, every reason-builder
|
||||
in the tree — was outside all of it, and so were sentences handed to a
|
||||
constructor as `reason=` or placed under a `"text"` dict key. Four
|
||||
zero-byte survivors of a green 21-file suite were sitting there, two of
|
||||
them on the alarm SAFETY-40 and SAFETY-41 are about. **The general form:
|
||||
an enumeration is scoped by a SINK as much as by a PREDICATE, and a sink
|
||||
list is a hand-written list like any other (habit 12).** Ask of any such
|
||||
scan: *which syntactic forms does this call a message, and what does the
|
||||
code under it actually use?*
|
||||
|
||||
16. ⚠️ **AN INSTRUMENT THAT ASSERTS A COMPLETENESS IT DOES NOT HAVE IS WORSE
|
||||
THAN NO INSTRUMENT, AND ITS BLIND-SPOT LIST IS PART OF THE CODE UNDER
|
||||
REVIEW.** `tools/symmetric_message_scan.py` printed five honest gaps and a
|
||||
census count, and a reader — including its author, twice — took the count
|
||||
for the family. The gap that mattered, the RETURN sink, was **in none of the
|
||||
five**. A gap list is not a disclaimer; it is a claim, and it is the claim
|
||||
most likely to be believed because nothing else in the output argues with
|
||||
it. Two consequences:
|
||||
* **State what is NOT scanned, largest hole first, as a list of forms** —
|
||||
not "this under-reports". The tool now names `%s`-style lazy logging
|
||||
(`_LOG.error("… %s … %s", a, b)`, where the operands are *arguments*, so
|
||||
no f-string census can ever see them), `.format()`/`%`/`+`/`.join()`
|
||||
sentences, sentences that leave through a dict or dataclass, `yield`,
|
||||
message attributes outside its own constant set, and everything outside
|
||||
`app/`. Where the list is derived from a constant in the file, it is
|
||||
**printed from that constant**, so the two cannot drift.
|
||||
* **AND THE PROBE THAT GRADES THE MUTATIONS IS SUBJECT TO EVERY HABIT
|
||||
ABOVE.** Round 4's render probe gave each interpolated operand a value by
|
||||
**order of appearance** — so after a transposition the other operand took
|
||||
the same number and **all thirteen mutant renders came back
|
||||
byte-identical**, which reads exactly like *the edit changed nothing*.
|
||||
Keying the value to the operand's **NAME** fixed it. That is habit 4, in
|
||||
the measuring instrument: the fixture's own values were arranged so the
|
||||
failure it was built to see could not change the answer. **Eleventh
|
||||
instance of habit 4 in this file, and the second one in an instrument
|
||||
rather than in a check.**
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
@@ -297,6 +601,8 @@ commit and PR bodies.
|
||||
| **TARIFF-01** reviewer survivor: `record_block()` ignoring its `policy` argument (0dbd485) | **re-verified — still a survivor** | re-run here through `tools/mutation_run.sh` against the 15 suites that were on the record, all green with `billed_w(block, policy)` mutated to `billed_w(block, POLICY_NET)`. The recorded diagnosis holds: every block reaching the store is single-phase, where NET, SUM and MAX collapse. **Now CLOSED by TEST-13** (`test_tariff.py` section 7), which round-trips section 7's own unbalanced `(3000, 500, -200) W` block THROUGH the store under each policy - three distinct banked figures, so a hardcoded policy takes at least two of them red. Third instance of "a stimulus evaluated where the quantity it grades is degenerate", after WITNESS-02's span normalisation and TARIFF-01's own DST window. |
|
||||
| **SAFETY-25** 8 mutations against `test_peakguard.py` part 10, then 9 more after review, all red | **not at risk** | each run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale - the same structural immunity recorded for CI above. Byte deltas recorded on the PR; none is `+0 B`. All are **reds**, which is the direction this defect cannot fabricate. ⚠️ The second round exists because the first round's mutations were all aimed at DISARMING the guard, and the claim part 10 actually made was about the guard being FIXED - the reviewer built the fix and the suite stayed green. Bytecode was never the issue there; an unaimed battery was. |
|
||||
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
|
||||
| **MAINT-05 round 3**, `migrate_maint_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **was a survivor — now CLOSED** | measured against head `65cddf3` under the purge, tree mounted `:ro`, `19/19 PASS`. It stamps the schema marker on a migration that LOST state, defeating the older `lost` guard as well as the new `misdirected` one, because every check that reached the marker did so on a store whose `kept` list was empty. Closed by `test_maintenance.py` **9d**; the mutation is now red on three named checks. |
|
||||
| **MAINT-05 round 3**, `migrate_unit_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **found here — SAFETY-02's half, now CLOSED** | the SAME mutation one function up, on the LATCH migration rather than the history one. Measured a survivor against this branch under the purge, `:ro`, `19/19 PASS`, then closed by `test_inverter.py` part 10's new fixture — a store with `unit/inverter-a/sign_reason` already present (so `kept` is non-empty) whose `sign_tripped: true` write is dropped. Shipped: marker withheld. Mutant: marker stamped on a **disarmed latch**, and the next start skips the migration that would have re-armed it. |
|
||||
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
|
||||
|
||||
**Nothing here says a past result is wrong.** The two survivors re-run above
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -122,6 +122,18 @@ CIRC_OPTION_DEFAULTS = {
|
||||
# 200 W -> 445.0 s 350 W -> 10.0 s 1000 W -> 5.0 s
|
||||
# 250 W -> 40.0 s 400 W -> 5.0 s 1700 W -> 0.0 s
|
||||
#
|
||||
# ⚠️ SAFETY-39 MOVED THAT SCHEMA FLOOR FROM 50 TO 0, AND THE CAMPAIGN ABOVE
|
||||
# WAS NOT RE-RUN. It does not have to be, and here is the argument rather
|
||||
# than the assurance. What these windows are about is how long the command
|
||||
# takes to CROSS ITS RAIL - the swept worst case is the SLOWEST crossing,
|
||||
# which is `slew_w` at its floor, 5000/50 = 100 cycles. `slew_w: 0` means
|
||||
# "follow the rail" and resolves to `SLEW_RAIL_FRAC * max_w`, which is
|
||||
# exactly 9 cycles at EVERY rail by construction. So the new value can never
|
||||
# be the slowest crossing at any `max_w`, and it is strictly inside the
|
||||
# envelope this campaign measured. An explicit number still spans 50..5000
|
||||
# and is unchanged. If `SLEW_RAIL_FRAC` is ever loosened past 1/100, this
|
||||
# paragraph stops being true and the campaign has to be re-run.
|
||||
#
|
||||
# THE KNEE IS AT 400 W and it is sharp. Below ~250 W a flapping load holds
|
||||
# the sensor above the threshold for MINUTES at a time - 505 s at 100 W -
|
||||
# and no window shorter than the 900 s billing quarter could tell that from
|
||||
|
||||
@@ -145,14 +145,94 @@ from datetime import datetime
|
||||
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||
REASON_CODE = "OUTPUT_CLAMP"
|
||||
|
||||
# Re-assert the notification this often while the clamp stays engaged. 30 s is
|
||||
# the planning cadence (2.3), i.e. slow enough not to flood and fast enough
|
||||
# that a pinned loop is never quiet for a whole billing quarter.
|
||||
REASSERT_S = 30.0
|
||||
# Re-assert the notification this often while the clamp stays engaged.
|
||||
#
|
||||
# ⚠️ RAISED 30 s -> 150 s on 2026-09-05, and the reason is a REGIME CHANGE, not a
|
||||
# change of mind about the alarm. 30 s was the planning cadence (2.3), chosen
|
||||
# when a loop-side engagement was a rare event: `main.py`'s note at the action
|
||||
# log records "60 cycles of the §10.3 runaway produce 0 write-path engagements
|
||||
# and 2 loop-side ones". On the live install the counter reached 48 loop-side
|
||||
# ENGAGEMENTS in ~25 minutes of ordinary operation - roughly one cycle in six,
|
||||
# not a pinned rail, but far more often than a guard sized for 2-in-60 assumed -
|
||||
# and the owner received a persistent notification about it all day. ⚠️ NOT
|
||||
# because of the 30 s re-assert - see the block below; those notifications never
|
||||
# consulted this constant at all.
|
||||
#
|
||||
# ⚠️ BOTH FIGURES ARE ENGAGEMENT COUNTS; only the regime differs - 2-in-60 was
|
||||
# measured over a §10.3 RUNAWAY, 48-in-305 was ordinary running on the live
|
||||
# install. The 48 is read off the live log, not measured in this repo.
|
||||
#
|
||||
# ⚠️ AND RAISING THIS CONSTANT ALONE DID NOT FIX THAT FLOOD - review found it
|
||||
# and the first version of this change was wrong. At ~5 s cycles a 1-in-6
|
||||
# engagement leaves ~25 s of clean cycles between engagements, past
|
||||
# CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED between almost every one
|
||||
# and came back as a fresh rising edge. The rising edge used to fire
|
||||
# unconditionally, so those notifications never consulted this constant at all:
|
||||
# 48 notifications at 30 s, and still 48 at 150 s. The fix was to rate-limit the
|
||||
# RISING EDGE too and let `last_alarm` survive a de-assert, so this interval
|
||||
# bounds the notification rate across episodes rather than only within one.
|
||||
# Measured on that same window afterwards: 51 episodes, 11 notifications.
|
||||
#
|
||||
# WHY the loop reaches its integrator bound so much more often than the guard's
|
||||
# author saw: `max_w` on the live install is 5000 W, while the loop's slew was
|
||||
# derived against a 1500 W clamp - admin/goodwe `NOTES.md` §"Simulated 2 kW load
|
||||
# step (gain 0.6, clamp 1500)". ⚠️ THAT DERIVATION IS IN THE OTHER REPO; nothing
|
||||
# in this one corroborates it, so do not go looking here. SAFETY-39 is the ticket
|
||||
# that re-derives it, and fixing that is what would stop the railing; this
|
||||
# constant only changes how often the alarm repeats.
|
||||
#
|
||||
# ⚠️ 150 s IS THE REQUIREMENT'S EDGE WITH MARGIN, AND THE CHECK NOW ENFORCES THAT
|
||||
# EDGE. `test_clamp.py` part 2 grades the cadence in wall-clock terms - a pinned
|
||||
# loop must speak at least MIN_PER_QUARTER = 5 times inside the 900 s quarter
|
||||
# that bills the capacity tariff - which caps this constant at 900/5 = 180 s.
|
||||
# Measured on the worst sliding 900 s window: 180 s earns 5 alarms, 181 s earns
|
||||
# 4. 150 s earns 6.
|
||||
#
|
||||
# ⚠️ THAT CHECK USED TO PERMIT 224 s, AND THE FIX SHIPPED WITH THIS COMMIT. Its
|
||||
# sweep starts at i=0, where the rising edge is free, so it measured one alarm
|
||||
# more than a real quarter earns. A later editor raising this to 200 s would have
|
||||
# had a green suite and a requirement quietly unmet. `test_clamp.py` now grades
|
||||
# floor(QUARTER_S / REASSERT_S) directly.
|
||||
#
|
||||
# ⚠️ MIN_PER_QUARTER = 5 IS A TEST-LOCAL INTEGER, not a spec figure - it exists
|
||||
# only in `test_clamp.py` and the CHANGELOG entry that introduced it. The 900 s
|
||||
# quarter IS system-wide (`tariff.py`, `freeze.py`, `DOCS.md`). So the 180 s
|
||||
# ceiling rests on one number a test author chose; if that number is ever
|
||||
# revisited, this constant moves with it.
|
||||
#
|
||||
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
|
||||
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
|
||||
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
|
||||
# suppressed: every engagement is still counted, the action-log row is written on
|
||||
# every engaged cycle regardless, and the rising edge of every episode alarms
|
||||
# unconditionally, so no alarm is DROPPED - only delayed.
|
||||
REASSERT_S = 150.0
|
||||
|
||||
# Consecutive clean cycles before the alarm de-asserts (10.2 hysteresis).
|
||||
CLEAR_CYCLES = 3
|
||||
|
||||
# ⚠️ SAFETY-41. Consecutive ENGAGED cycles after which an episode speaks even if
|
||||
# the notification budget is spent. THE ESCALATION EXISTS BECAUSE THE RATE LIMIT
|
||||
# ALONE DELAYS A RUNAWAY, and review measured it: a benign one-cycle clamp
|
||||
# followed by a continuous runaway held the runaway's first notification for up
|
||||
# to 140 s - one REASSERT_S minus a cycle. On the live install, where ordinary
|
||||
# railing spends the budget essentially all the time, a runaway arriving during
|
||||
# normal operation would be delayed with near-certainty. That is the "benign
|
||||
# noise suppresses the bug signal" shape this module's docstring is written
|
||||
# against.
|
||||
#
|
||||
# THE DISCRIMINATOR IS RUN LENGTH, and the two populations genuinely differ: the
|
||||
# live flood was runs of length ONE (engage, de-assert, engage), while a §10.3
|
||||
# runaway engages on EVERY cycle. So a sustained run speaks regardless of budget,
|
||||
# and scattered chatter does not.
|
||||
#
|
||||
# Measured, shipped-without vs shipped-with - it costs nothing it was protecting:
|
||||
# live scatter flood (305 cycles, 1-in-6) 11 -> 11 notifications
|
||||
# chatter 1-engaged/3-clean over 8000 s 50 -> 50 notifications
|
||||
# pinned 900 s quarter (floor is 5) 6 -> 6 alarms
|
||||
# runaway delay after a benign episode 140 s -> 10 s
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
@@ -258,6 +338,10 @@ class ClampWatchdog:
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
|
||||
# spoken yet - the two the escalation clause needs.
|
||||
self.run_len = 0
|
||||
self.spoken = False
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
|
||||
"""Advance one cycle. True when the caller should raise the alarm.
|
||||
@@ -269,26 +353,72 @@ class ClampWatchdog:
|
||||
self.engagements += 1
|
||||
self.clean_cycles = 0
|
||||
self.reason = reason
|
||||
self.run_len += 1
|
||||
if not self.active:
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
self.spoken = False
|
||||
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
|
||||
# not used to be. It fired unconditionally, so a condition that
|
||||
# engages and clears repeatedly raised one notification PER
|
||||
# EPISODE no matter how fast the episodes came - and
|
||||
# `REASSERT_S` never entered into it, because it only governs
|
||||
# the still-engaged path below.
|
||||
#
|
||||
# That is the shape the live install actually produced: 48
|
||||
# engagements in 25 minutes, scattered roughly one cycle in six.
|
||||
# At a ~5 s cycle the gaps are ~25 s of clean cycles, which
|
||||
# exceeds CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED
|
||||
# between almost every engagement and each one came back as a
|
||||
# fresh rising edge. Measured on this class over that window:
|
||||
# 48 episodes and 48 notifications at REASSERT_S = 30 - and
|
||||
# still 48 at REASSERT_S = 150. Raising the re-assert removed
|
||||
# NONE of them, because none of them came from the re-assert.
|
||||
#
|
||||
# So the interval now bounds the NOTIFICATION RATE rather than
|
||||
# the repeat rate within one episode: `last_alarm` survives a
|
||||
# de-assert, and a new episode speaks only if the interval has
|
||||
# elapsed. ⚠️ NOTHING IS DROPPED. Every engagement is still
|
||||
# counted, every episode still increments, the action-log row is
|
||||
# still written on every engaged cycle, and a genuine runaway -
|
||||
# which by definition keeps engaging - is picked up by the
|
||||
# re-assert path within REASSERT_S. Delay, not loss.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
|
||||
# budget was spent by an EARLIER, unrelated episode speaks anyway
|
||||
# once it has engaged SUSTAINED_CYCLES times in a row. A runaway
|
||||
# engages every cycle and reaches it; the scattered one-cycle
|
||||
# railing that produced the live flood never does.
|
||||
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
# Still engaged: re-assert on the slow cadence, not every cycle.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
|
||||
self.run_len = 0
|
||||
if not self.active:
|
||||
return False
|
||||
self.clean_cycles += 1
|
||||
if self.clean_cycles >= CLEAR_CYCLES:
|
||||
self.active = False
|
||||
self.since = None
|
||||
self.last_alarm = None
|
||||
# ⚠️ `last_alarm` DELIBERATELY SURVIVES THE DE-ASSERT. Clearing it
|
||||
# here is what made the next rising edge speak immediately however
|
||||
# soon it arrived, which is the flood described at the rising edge
|
||||
# above. The episode ends; the notification budget does not reset
|
||||
# with it.
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
return False
|
||||
|
||||
@@ -18,12 +18,92 @@ exists because its absence produced a specific, observed failure.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-39. THE ONE RELATIONSHIP IN THIS FILE THAT IS NOT A NUMBER.
|
||||
#
|
||||
# ⚠️ WHAT WENT WRONG, because the shape of it is the reason this is a ratio and
|
||||
# not a constant. `slew_w: 1000` was derived on 2026-08-22 against a clamp of
|
||||
# 1500 W (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)"),
|
||||
# and its stated justification was that it "puts most of the correction in the
|
||||
# first cycle WHILE STILL CAPPING HOW FAR THE LOOP CAN JUMP AHEAD OF THE
|
||||
# INVERTER". At 1500 W that sentence was true: 1000 W/cycle reaches the rail in
|
||||
# two cycles and the rail itself bounds the excursion.
|
||||
#
|
||||
# The rail then moved to 5000 W on the live install and the constant did not.
|
||||
# 1000 W per 2 s tick is 500 W/s, so the loop traverses its WHOLE OUTPUT RANGE
|
||||
# in 10 s against a plant NOTES.md §5.10 measured at ~18 s up / ~23 s down for a
|
||||
# 1.78 kW step. It commands its entire range before it can observe its answer to
|
||||
# the first step. Measured on the owner's system 2026-09-05 11:43-11:46: a real
|
||||
# 3.4 kW export, the setpoint ramping 380 -> 5000 in five ticks, the battery at
|
||||
# -5154 W against a 5000 VA inverter, and the grid pushed from 3.4 kW of EXPORT
|
||||
# to 2.8 kW of IMPORT - the loop manufactured the error it then chased.
|
||||
#
|
||||
# ⚠️ SO THE FIX IS NOT A NEW NUMBER. A number is what went stale. What is
|
||||
# written here is the RELATIONSHIP the 2026-08-22 sentence was really asserting:
|
||||
#
|
||||
# THE LOOP MUST NOT TRAVERSE ITS FULL OUTPUT RANGE FASTER THAN THE PLANT
|
||||
# TAKES TO SETTLE.
|
||||
#
|
||||
# slew_w <= max_w * LOOP_TICK_S / PLANT_SETTLE_S
|
||||
#
|
||||
# `max_w` here is the SITE rail (main.py builds `Tuning(max_w=site_max_w)`, the
|
||||
# roster sum), so the bound moves with the roster and a second inverter cannot
|
||||
# re-open this by arriving. validate.py rule 10 refuses a configuration that
|
||||
# breaks it; `slew_w = 0` below means "follow the rail" and is the shipped
|
||||
# default, exactly as `integrator_max_w = None` already means "follow max_w".
|
||||
#
|
||||
# ⚠️ BOTH NUMBERS ARE WRITTEN OUT RATHER THAN THEIR QUOTIENT, AND THAT IS NOT
|
||||
# TIDINESS. The quotient is only meaningful in the regime the two were measured
|
||||
# in, and one of them is NOT a property of this code:
|
||||
#
|
||||
# PLANT_SETTLE_S is the inverter and the pack. goodwe/NOTES.md §5.10,
|
||||
# 2026-08-22, a 1.78 kW kettle step driven from HA with a
|
||||
# plug that meters its own draw: ~18 s from the step to the
|
||||
# deadband going up, ~23 s coming down. 18 is taken because
|
||||
# it is the SHORTER of the two and therefore the LOOSER
|
||||
# bound - this is a refusal boundary, and rules 5 and 6 in
|
||||
# validate.py refuse the impossible case only, never "close
|
||||
# to the rail".
|
||||
#
|
||||
# LOOP_TICK_S is the METER, not this loop. `run_control()` runs a cycle on
|
||||
# a changed meter value or on `cycle_floor_s`, whichever comes
|
||||
# first, so the tick is the meter's own update rate. 2.0 s is
|
||||
# what the live install measured on 2026-09-05 (the setpoint
|
||||
# stepped +1000 W every 2 s), and it is the fastest meter this
|
||||
# project has ever run.
|
||||
#
|
||||
# ⚠️ AND THIS ONE IS NOT WRONG IN THE SAFE DIRECTION, WHICH IS WHY IT IS SAID
|
||||
# HERE. A SLOWER meter makes this bound conservative - it refuses some
|
||||
# configurations that would have been fine, which is visible immediately and
|
||||
# costs one edit. A meter FASTER than 2 s makes it loose by exactly that ratio,
|
||||
# and nothing in this add-on would say so. The tick cannot go below 1 s
|
||||
# (`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), so the
|
||||
# worst case is a factor of two - but if a faster meter is ever fitted, this
|
||||
# constant has to be RE-DERIVED and not merely re-read. Compare
|
||||
# validate.FIRMWARE_INGEST_VETO_S, which carries the same kind of copy and can
|
||||
# only ever be wrong the other way.
|
||||
PLANT_SETTLE_S = 18.0
|
||||
LOOP_TICK_S = 2.0
|
||||
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S # 0.111..., i.e. 9 ticks
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
gain: float = 0.6
|
||||
max_w: float = 2000.0
|
||||
slew_w: float = 1000.0
|
||||
# SAFETY-39. How far the command may move in one cycle. ⚠️ 0 MEANS "FOLLOW
|
||||
# THE RAIL" - `SLEW_RAIL_FRAC * max_w` - and it is the default and the
|
||||
# recommended setting, for the reason written at the top of this file: a
|
||||
# fixed number here is a constant that outlives the rail it was derived
|
||||
# against, and that is the defect measured on the live system on
|
||||
# 2026-09-05. Read `slew_limit_w()`, never this field.
|
||||
#
|
||||
# It stays settable, because slower than the rail implies is a legitimate
|
||||
# and sometimes necessary choice - the live install has run an explicit
|
||||
# 400 W since 2026-09-05 12:00 - and because `slew_w` is an option an
|
||||
# operator already has. FASTER than the rail implies is what validate.py
|
||||
# rule 10 refuses.
|
||||
slew_w: float = 0.0
|
||||
deadband_w: float = 15.0
|
||||
step_w: int = 10
|
||||
saturation_w: float = 500.0
|
||||
@@ -83,6 +163,28 @@ class Decision:
|
||||
clamped: bool = False
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
"""How far the command may move this cycle, in W. Always positive.
|
||||
|
||||
⚠️ THE ONE READER OF `Tuning.slew_w`, and everything else must come through
|
||||
here. `slew_w = 0` means "follow the rail" and resolves to
|
||||
`SLEW_RAIL_FRAC * max_w`, which is the whole of SAFETY-39: the limit is a
|
||||
RATIO to the rail, so raising `max_w` moves it and cannot leave it behind.
|
||||
A caller that read the field directly would see 0 and apply no limit at all
|
||||
- the exact opposite of the setting's meaning - which is why this exists as
|
||||
a function rather than as a comment telling people to remember.
|
||||
|
||||
⚠️ AND IT IS NOT CLAMPED TO THE BOUND. An explicit `slew_w` above the rail
|
||||
fraction is refused at STARTUP by validate.py rule 10, not silently reduced
|
||||
here. Silently reducing it is the failure this whole module's docstring is
|
||||
about - an operator's stated number quietly meaning something else - and
|
||||
validate.py's own docstring calls it case (a).
|
||||
"""
|
||||
if tuning.slew_w:
|
||||
return abs(float(tuning.slew_w))
|
||||
return SLEW_RAIL_FRAC * abs(float(tuning.max_w))
|
||||
|
||||
|
||||
def compute(
|
||||
prev_w: float,
|
||||
grid_w: float,
|
||||
@@ -231,7 +333,8 @@ def compute(
|
||||
if clamped:
|
||||
reason = "clamped"
|
||||
|
||||
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
|
||||
slew = slew_limit_w(tuning)
|
||||
slewed = max(prev_w - slew, min(prev_w + slew, target))
|
||||
if slewed != target:
|
||||
reason = "slew-limited"
|
||||
target = slewed
|
||||
|
||||
@@ -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
|
||||
@@ -333,14 +357,46 @@ def roster_entries(opts: dict) -> list:
|
||||
return list(entries)
|
||||
|
||||
|
||||
def roster_names(opts: dict) -> list:
|
||||
"""The unit names, in roster order. THE one answer, for two callers.
|
||||
|
||||
⚠️ IT EXISTS BECAUSE MQTT DISCOVERY CANNOT WAIT FOR A ROSTER. TEL-07 gives
|
||||
every unit past the first its own MQTT device, and `MqttPublisher` is
|
||||
constructed in `main()` BEFORE `Controller.__init__` builds the roster -
|
||||
announcing happens on the broker's CONNACK, which can land first. Building a
|
||||
second roster there would construct a second set of `Inverter`s off the same
|
||||
Store; transcribing the naming rule there would be a third spelling of it.
|
||||
So the naming rule lives here, `build_roster()` reads it, and the two cannot
|
||||
drift.
|
||||
|
||||
⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries both
|
||||
called `inverter-a` would share one Store namespace, so one unit's SAFETY-02
|
||||
latch would be the other's - a fault on B latching A is the exact failure
|
||||
DEV-02's namespaced keys removed. Rule 9 refuses a nameless entry outright;
|
||||
this only decides what the refusal is allowed to call it in the meantime.
|
||||
|
||||
An EMPTY `inverters:` is the flat-key shape and its one unit is `UNIT_A`,
|
||||
which is what every install running today reads.
|
||||
"""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
return [UNIT_A]
|
||||
names = []
|
||||
for i, entry in enumerate(entries):
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
names.append(str(entry.get("name") or "").strip() or f"inverter-{i + 1}")
|
||||
return names
|
||||
|
||||
|
||||
def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
"""The site's inverters, in roster order.
|
||||
|
||||
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 +409,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,18 +436,24 @@ 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 = []
|
||||
for i, entry in enumerate(entries):
|
||||
names = roster_names(opts)
|
||||
# ⚠️ NAMES COME FROM `roster_names()`, NOT FROM A SECOND READING OF THE
|
||||
# ENTRIES. TEL-07 announces one MQTT device and one `unique_id` per unit off
|
||||
# these names, and it has to build them BEFORE a `Controller` exists - so a
|
||||
# second spelling of the fallback here would be an entity announced under a
|
||||
# name no unit answers to. `zip` pairs them positionally, which is what
|
||||
# `roster_names()` is defined to be.
|
||||
for entry, name in zip(entries, names):
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
# ⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries
|
||||
# both called `inverter-a` would share one Store namespace, so one
|
||||
# unit's SAFETY-02 latch would be the other's - a fault on B latching A
|
||||
# is the exact failure DEV-02's namespaced keys removed. Rule 9 refuses
|
||||
# a nameless entry outright; this only decides what the refusal is
|
||||
# allowed to call it in the meantime.
|
||||
name = str(entry.get("name") or "").strip() or f"inverter-{i + 1}"
|
||||
roster.append(Inverter(
|
||||
name,
|
||||
setpoint_entity=entry.get("setpoint_entity", ""),
|
||||
@@ -401,10 +463,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.
|
||||
|
||||
|
||||
+724
-127
File diff suppressed because it is too large
Load Diff
@@ -18,16 +18,47 @@ OWNERSHIP, not priority. Exactly one thing writes the setpoint at any moment:
|
||||
The loop is never "disabled" - it yields. The heartbeat to the inverter keeps
|
||||
running in every phase, so the hardware watchdog stays fed and a crash here
|
||||
still ends with the inverter at 0 W rather than latched.
|
||||
|
||||
ONE PACK AT A TIME (MAINT-05). There is now one `Maintenance` per roster entry,
|
||||
each with its own schedule, its own phase and its own history under its own
|
||||
Store keys - and therefore, for the first time, two of them able to move power
|
||||
on ONE grid connection at once. Read `arbitrate()` before touching any of it:
|
||||
the site holds exactly ONE maintenance slot, a pack that is not IDLE holds it,
|
||||
and every other pack is held at IDLE under the loop until it is released. That
|
||||
is not a scheduling nicety. A drain of pack A against a charge of pack B is two
|
||||
power-moving phases pulling opposite ways across the meter - the circulating
|
||||
power SAFETY-31 exists to DETECT, arriving here as something this module can
|
||||
simply not do.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .store import MAINT_UNIT_KEYS, unit_key
|
||||
|
||||
_LOG = logging.getLogger("goodwe.maint")
|
||||
|
||||
IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold"
|
||||
|
||||
# ⚠️ THE PHASES THAT MOVE POWER, WHICH IS NOT THE SAME LIST AS "owns the
|
||||
# setpoint". The table above is about WHO WRITES the number; this is about
|
||||
# whether the pack is being pushed. DRAIN and HOLD own the setpoint. CHARGE does
|
||||
# NOT - the loop owns it - and CHARGE is in here anyway, because the charge-only
|
||||
# clamp plus the cheap-window floor is what pulls a second pack full while the
|
||||
# first is being emptied. Leaving CHARGE out is precisely the interlock hole
|
||||
# this card is about: pack A charging from the grid at 2000 W while pack B
|
||||
# drains 2000 W to export is 4 kW going round in a circle with the meter at
|
||||
# zero, and both packs believing they are behaving.
|
||||
#
|
||||
# HOLD commands 0 W and moves nothing, and it is in here for the other half of
|
||||
# the sentence: it OWNS the actuator - the pack is parked at the top for the BMS
|
||||
# and is deliberately not available to the loop - so a second cycle starting
|
||||
# under it would still be two recalibrations at once, which is the thing whose
|
||||
# value is a clean uninterrupted coulomb count.
|
||||
POWER_MOVING = (DRAIN, CHARGE, HOLD)
|
||||
|
||||
# A drain of a 13.8 kWh usable pack (300 Ah x 51.2 V, DoD 90) at the shipped
|
||||
# 2000 W is ~6.9 h even with no house load helping - and less in practice, since
|
||||
# house load drains alongside the export and soc_floor stops short of empty.
|
||||
@@ -153,17 +184,102 @@ class MaintResult:
|
||||
setpoint_w: float | None = None
|
||||
charge_only: bool = False
|
||||
events: list = field(default_factory=list)
|
||||
# ⚠️ WHY A DEFERRAL IS A FIELD AND NOT JUST A LOG LINE. A pack that is due
|
||||
# and is not starting looks, from every surface an operator has, exactly
|
||||
# like a pack whose schedule has quietly stopped firing - §12's opening
|
||||
# sentence, the failure this whole module exists because of. It is carried
|
||||
# so the status page can SAY "waiting for the other pack" rather than
|
||||
# showing an idle machine and a due date in the past. Empty on every path
|
||||
# that is not deferring, so a roster of one never sees it.
|
||||
deferred: str = ""
|
||||
|
||||
|
||||
class UnitHistory:
|
||||
"""One pack's maintenance history in the Store, under one spelling.
|
||||
|
||||
⚠️ THE NAMESPACE IS A CONSTRUCTOR ARGUMENT AND `None` IS A REAL VALUE, NOT
|
||||
AN OMISSION. `unit=None` means the FLAT keys - `phase`, `phase_started`,
|
||||
`last_completed`, `last_start_attempt` - which is what every install has
|
||||
written since the add-on shipped and what a one-inverter site keeps writing
|
||||
after MAINT-05. §6.2's identity: with one pack there is nothing for a
|
||||
namespace to distinguish it FROM, and adding one would change the layout of
|
||||
a live state file to say something it already said. `arbiter.actuator_for()`
|
||||
makes the same move for the same reason and its docstring argues it.
|
||||
|
||||
⚠️ `mirror_flat` IS THE ROLLBACK VIEW AND IT IS ONLY EVER THE FIRST PACK'S.
|
||||
At N>=2 pack A's history has been migrated into its namespace and is written
|
||||
there; the flat keys are ALSO kept in step, so an operator who rolls the
|
||||
add-on back to a single-FSM version finds a CURRENT history rather than one
|
||||
dated the day the second inverter was declared. store.migrate_maint_keys()
|
||||
carries the argument, and it is DEV-02's rule 2 applied to history instead
|
||||
of to a latch: "left in the file" is not the same promise as "still true".
|
||||
Pack B has no flat spelling to mirror to - the flat keys mean pack A on
|
||||
every install that has them - so it is constructed with mirroring off, and
|
||||
a mirror from B would OVERWRITE A's rollback view with B's phase.
|
||||
"""
|
||||
|
||||
def __init__(self, store, unit: str | None = None, mirror_flat: bool = False):
|
||||
self.store = store
|
||||
self.unit = unit
|
||||
self.mirror_flat = bool(mirror_flat)
|
||||
|
||||
def key(self, name: str) -> str:
|
||||
"""The Store key this pack's `name` lives under."""
|
||||
return name if self.unit is None else unit_key(self.unit, name)
|
||||
|
||||
def get(self, name: str, default=None):
|
||||
return getattr(self.store, "data", {}).get(self.key(name), default)
|
||||
|
||||
def set(self, name: str, value) -> None:
|
||||
self.store.set(self.key(name), value)
|
||||
if self.mirror_flat:
|
||||
self.store.set(name, value)
|
||||
|
||||
def get_time(self, name: str):
|
||||
return self.store.get_time(self.key(name))
|
||||
|
||||
def set_time(self, name: str, when) -> None:
|
||||
self.store.set_time(self.key(name), when)
|
||||
if self.mirror_flat:
|
||||
self.store.set_time(name, when)
|
||||
|
||||
|
||||
class Maintenance:
|
||||
def __init__(self, cfg: MaintConfig, store):
|
||||
"""ONE pack's recalibration cycle: its schedule, its phase, its history.
|
||||
|
||||
⚠️ ONE PER ROSTER ENTRY SINCE MAINT-05, AND THE SLOT IS NOT ITS OWN. This
|
||||
object decides WHEN ITS PACK would like to start; `arbitrate()` decides
|
||||
which of them may, because the constraint is a property of the grid
|
||||
connection and no single FSM can see it. Never call `tick()` in a loop
|
||||
without passing the `blocked_by` that `arbitrate()` returned for it: that is
|
||||
the two-packs-at-once bug with the interlock present but not wired.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
|
||||
name: str = "", mirror_flat: bool = False):
|
||||
self.cfg = cfg
|
||||
self.store = store
|
||||
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
|
||||
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
|
||||
# the one place that turns it into the key this pack owns. A raw
|
||||
# `store.set("phase", ...)` anywhere below would write pack A's history
|
||||
# from pack B's state machine, which is the shared-state bug this card
|
||||
# is about, and there would be nothing to see at a roster of one.
|
||||
self.store = UnitHistory(store, unit, mirror_flat)
|
||||
self.unit = unit
|
||||
# What an event or a refusal calls this pack. At a roster of one it is
|
||||
# never rendered - a single machine cannot be deferred - so the shipped
|
||||
# one-inverter reason strings are untouched.
|
||||
self.name = str(name or unit or "the pack")
|
||||
# Edge-memo for the deferral note. The action log holds 40 lines and a
|
||||
# deferral lasts as long as the other pack's cycle - hours - so a line
|
||||
# per cycle would flush the very context that explains it. Same pattern
|
||||
# as `Controller._arb_action` and `_maint_shape_suspended`.
|
||||
self._deferred = ""
|
||||
|
||||
# -- state ---------------------------------------------------------------
|
||||
@property
|
||||
def phase(self) -> str:
|
||||
return self.store.data.get("phase", IDLE)
|
||||
return self.store.get("phase", IDLE)
|
||||
|
||||
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
|
||||
self.store.set("phase", phase)
|
||||
@@ -185,14 +301,60 @@ class Maintenance:
|
||||
last = self.store.get_time("last_completed")
|
||||
return None if last is None else last + timedelta(days=self.cfg.interval_days)
|
||||
|
||||
def waited_days(self, now: datetime) -> float:
|
||||
"""Days since this pack last COMPLETED a cycle; `inf` if it never has.
|
||||
|
||||
⚠️ THE ARBITRATION KEY, AND IT IS DELIBERATELY NOT `due()`. `due()` is a
|
||||
yes/no and two packs that are both due are indistinguishable by it -
|
||||
which is how a roster-order tie-break gets written and how the first
|
||||
entry then wins every contested month forever while the second drifts.
|
||||
This is an ORDERING: the pack whose coulomb counter has gone longest
|
||||
without a recalibration goes first. `inf` for never-recalibrated is the
|
||||
same statement `due()` makes about an absent `last_completed`, kept as
|
||||
one so a fresh pack cannot lose to one that ran last month.
|
||||
"""
|
||||
last = self.store.get_time("last_completed")
|
||||
return math.inf if last is None else (now - last).total_seconds() / 86400.0
|
||||
|
||||
def _cooled(self, now: datetime) -> bool:
|
||||
"""Is the post-abort cooldown over?"""
|
||||
last_try = self.store.get_time("last_start_attempt")
|
||||
return (last_try is None
|
||||
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN))
|
||||
|
||||
def wants_start(self, now: datetime) -> bool:
|
||||
"""Would this pack begin a cycle on this tick if the site let it?
|
||||
|
||||
⚠️ THE SAME PREDICATE `tick()` USES, NOT A COPY OF IT. `arbitrate()` has
|
||||
to know who is asking before anybody moves, and a second transcription
|
||||
of "enabled, in the start hour, due and cooled down" is a second opinion
|
||||
about who was asking - so the IDLE branch of `tick()` calls this one.
|
||||
"""
|
||||
return (self.phase == IDLE and self.cfg.enabled
|
||||
and now.hour == self.cfg.start_hour and self.due(now)
|
||||
and self._cooled(now))
|
||||
|
||||
# -- operations ----------------------------------------------------------
|
||||
def force_start(self, now: datetime) -> list:
|
||||
def force_start(self, now: datetime, blocked_by: str = "") -> list:
|
||||
"""Start a cycle regardless of the calendar.
|
||||
|
||||
Commissioning uses this: a cycle must be proven end to end before a site
|
||||
is signed off, and nobody waits a month to find out the schedule never
|
||||
fires.
|
||||
|
||||
⚠️ REGARDLESS OF THE CALENDAR, NOT REGARDLESS OF THE INTERLOCK. The
|
||||
calendar is a preference and an operator may overrule it; "one pack
|
||||
moves power at a time" is a property of the grid connection and an
|
||||
operator pressing a button does not add a second one. A forced start
|
||||
against a busy site is REFUSED, by name, and the phase machine does not
|
||||
move - the refusal is returned as an event so the operator reads it in
|
||||
the action log rather than watching nothing happen.
|
||||
"""
|
||||
if blocked_by:
|
||||
msg = (f"maintenance: forced start REFUSED on {self.name} - "
|
||||
f"{blocked_by}")
|
||||
_LOG.warning("%s", msg)
|
||||
return [msg]
|
||||
events = []
|
||||
self.store.set_time("last_start_attempt", now)
|
||||
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
|
||||
@@ -203,23 +365,80 @@ class Maintenance:
|
||||
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
|
||||
return events
|
||||
|
||||
def _defer_note(self, why: str, events: list) -> list:
|
||||
"""Say once, not every second, that this pack is waiting its turn.
|
||||
|
||||
⚠️ THE WORDING IS DELIBERATELY NOT AN ABORT AND NOT A TIMEOUT. §12's
|
||||
notes are explicit that a 12 h phase timeout means "the battery is not
|
||||
following commands - a FAULT, not a slow day", and the one thing that
|
||||
must not happen here is an operator reading a deferral as that. This
|
||||
line says the pack is WAITING, names the pack it is waiting for, and
|
||||
says it will run next - none of which any failure path in this module
|
||||
says.
|
||||
"""
|
||||
if self._deferred != why:
|
||||
self._deferred = why
|
||||
msg = (f"maintenance: {self.name} is due and is WAITING its turn - "
|
||||
f"{why}. It is idle under the control loop and will "
|
||||
f"recalibrate at the next start hour once the slot is free; "
|
||||
f"this is not a stalled phase.")
|
||||
_LOG.info("%s", msg)
|
||||
events.append(msg)
|
||||
return events
|
||||
|
||||
# -- the machine ---------------------------------------------------------
|
||||
def tick(self, now: datetime, soc: float | None) -> MaintResult:
|
||||
def tick(self, now: datetime, soc: float | None,
|
||||
blocked_by: str = "") -> MaintResult:
|
||||
"""One cycle of this pack's state machine.
|
||||
|
||||
`blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the
|
||||
site's one maintenance slot is available to it, otherwise the reason it
|
||||
is not. It gates two things and nothing else - a start, and a phase that
|
||||
is already moving power on a pack that does not hold the slot.
|
||||
"""
|
||||
events: list = []
|
||||
phase = self.phase
|
||||
|
||||
# ⚠️ THE SLOT IS CHECKED BEFORE THE PHASE, AND THIS BRANCH IS A RECOVERY
|
||||
# RATHER THAN AN EXPECTED PATH. `arbitrate()` grants the slot to the
|
||||
# pack that is already mid-cycle, so a pack that is BOTH moving power
|
||||
# and blocked means two histories on disk say "drain" - a hand-edited
|
||||
# state file, or a store carried across a version that had no interlock.
|
||||
# The fail-safe direction is unambiguous: two packs pulling against each
|
||||
# other on one connection is the fault SAFETY-31 latches the whole site
|
||||
# for. Stand this one down to 0 W and say why; the holder keeps its
|
||||
# cycle, which is the outcome that loses no coulomb count at all.
|
||||
if blocked_by and phase in POWER_MOVING:
|
||||
events += self.abort(now, f"interlock - {blocked_by}")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
|
||||
if phase == IDLE:
|
||||
if self.cfg.enabled and now.hour == self.cfg.start_hour and self.due(now):
|
||||
last_try = self.store.get_time("last_start_attempt")
|
||||
cooled = (
|
||||
last_try is None
|
||||
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN)
|
||||
)
|
||||
if cooled:
|
||||
self.store.set_time("last_start_attempt", now)
|
||||
self._enter(DRAIN, now, events,
|
||||
"maintenance: due -> drain (exports the surplus)")
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
if self.wants_start(now):
|
||||
if blocked_by:
|
||||
# ⚠️ `last_start_attempt` IS NOT STAMPED HERE, AND THAT IS
|
||||
# THE STARVATION FIX RATHER THAN AN OMISSION. That key is
|
||||
# the post-ABORT cooldown: it exists so a cycle that failed
|
||||
# does not re-trigger inside the same start hour. A deferral
|
||||
# is not an attempt - nothing was tried and nothing failed -
|
||||
# and stamping it would put this pack under a 90 minute
|
||||
# cooldown for a start hour that is 60 minutes long, so the
|
||||
# pack that lost the arbitration would still be locked out
|
||||
# at the moment the winner released the slot.
|
||||
#
|
||||
# ⚠️ AND THE PHASE DOES NOT MOVE, WHICH IS THE OTHER HALF.
|
||||
# `phase_started` is untouched, so the 12 h DRAIN and 36 h
|
||||
# CHARGE timeouts - which mean "the battery is not following
|
||||
# commands, a fault" - cannot be reached by waiting. A
|
||||
# deferred pack is IDLE under the loop, not a stuck one.
|
||||
return MaintResult(IDLE, False, None, False,
|
||||
self._defer_note(blocked_by, events),
|
||||
blocked_by)
|
||||
self._deferred = ""
|
||||
self.store.set_time("last_start_attempt", now)
|
||||
self._enter(DRAIN, now, events,
|
||||
"maintenance: due -> drain (exports the surplus)")
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
self._deferred = ""
|
||||
return MaintResult(IDLE, False, None, False, events)
|
||||
|
||||
# ⚠️ Fail toward inaction. Without a state-of-charge reading none of the
|
||||
@@ -273,3 +492,77 @@ class Maintenance:
|
||||
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
|
||||
events += self.abort(now, f"unknown phase {phase!r}")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
|
||||
|
||||
def _days(n: float) -> str:
|
||||
return "never recalibrated" if n == math.inf else f"{n:.0f} d since its last cycle"
|
||||
|
||||
|
||||
def arbitrate(machines, now: datetime) -> list:
|
||||
"""Grant the site's ONE maintenance slot. Returns a list parallel to
|
||||
`machines`: "" for the pack that may move power, otherwise the reason the
|
||||
pack may not.
|
||||
|
||||
⚠️ THE SLOT IS A PROPERTY OF THE GRID CONNECTION, WHICH IS WHY IT IS NOT ON
|
||||
`Maintenance`. Each FSM can see its own pack's schedule and nothing else; the
|
||||
quantity being rationed - "how many phases are moving power across one
|
||||
meter" - is a sum over the roster, exactly like `sum(|B_i|) - |sum(B_i)|` in
|
||||
SAFETY-31 and for the same reason. A per-pack answer to a site-level
|
||||
question is the shape of defect this repository keeps finding.
|
||||
|
||||
⚠️ IT IS DERIVED, NOT PERSISTED, AND THAT IS DELIBERATE. The holder is
|
||||
"whichever pack's stored phase is not IDLE" - a fact already on disk, in the
|
||||
per-pack history the packs themselves write. A separate `maint_slot_holder`
|
||||
key would be a second opinion about the same thing, and the two would
|
||||
disagree the first time the add-on was killed between writing the phase and
|
||||
writing the holder. Restart-safe by construction: a controller that comes
|
||||
back up mid-drain reads the drain and re-derives the holder from it.
|
||||
|
||||
TWO CLAUSES, IN ORDER:
|
||||
|
||||
1. A PACK ALREADY MOVING POWER KEEPS THE SLOT. Nothing preempts a cycle in
|
||||
flight, because a recalibration interrupted halfway is worth less than
|
||||
nothing - the coulomb counter is graded on ONE uninterrupted drain and
|
||||
refill, so a cycle stopped at 40 % has spent the energy and bought no
|
||||
calibration. If more than one pack somehow comes up moving power, the
|
||||
FIRST in roster order holds and `tick()` stands the others down; see the
|
||||
recovery branch there.
|
||||
|
||||
2. OTHERWISE THE LONGEST-WAITING CANDIDATE STARTS. `wants_start()` is asked
|
||||
of every pack, and among those that say yes the slot goes to the one with
|
||||
the largest `waited_days()`. NOT roster order: two packs on one monthly
|
||||
schedule are due in the same hour every month, and a roster-order tie
|
||||
would hand the slot to `inverters[0]` every single time while the second
|
||||
pack's coulomb counter drifted forever - which is, in a different
|
||||
costume, the exact defect this card was opened for. Roster order breaks a
|
||||
genuine numeric tie and nothing else.
|
||||
|
||||
⚠️ AT A ROSTER OF ONE THIS IS THE IDENTITY FUNCTION. One machine is never
|
||||
another machine's blocker: clause 1 finds at most itself and clause 2 has at
|
||||
most one candidate, so it returns `[""]` on every tick and no reason string
|
||||
it can build is ever rendered. That is what makes the one-inverter behaviour
|
||||
byte-identical rather than merely similar.
|
||||
"""
|
||||
blocks = [""] * len(machines)
|
||||
busy = [(i, m) for i, m in enumerate(machines) if m.phase in POWER_MOVING]
|
||||
if busy:
|
||||
held, holder = busy[0]
|
||||
for i, m in enumerate(machines):
|
||||
if i != held:
|
||||
blocks[i] = (f"{holder.name} is mid-cycle ({holder.phase}) and "
|
||||
f"holds the site's one maintenance slot")
|
||||
return blocks
|
||||
|
||||
wants = [(i, m) for i, m in enumerate(machines) if m.wants_start(now)]
|
||||
if len(wants) <= 1:
|
||||
return blocks
|
||||
# Largest waited_days wins; `-index` makes a numeric tie fall to roster
|
||||
# order rather than to whatever `max()` happens to see first.
|
||||
won, winner = max(wants, key=lambda p: (p[1].waited_days(now), -p[0]))
|
||||
for i, m in wants:
|
||||
if i != won:
|
||||
blocks[i] = (f"{winner.name} has waited longer "
|
||||
f"({_days(winner.waited_days(now))} against "
|
||||
f"{_days(m.waited_days(now))}) and takes the site's "
|
||||
f"one maintenance slot first")
|
||||
return blocks
|
||||
|
||||
+232
-24
@@ -90,14 +90,239 @@ SENSORS = [
|
||||
BASE = "goodwe_ctl"
|
||||
AVAILABILITY = f"{BASE}/availability"
|
||||
|
||||
# --- TEL-07: a SECOND device, for inverter B, and NOT one per inverter -------
|
||||
#
|
||||
# ⚠️ READ THE DEVICE-NAME NOTE ABOVE BEFORE CHANGING ANYTHING HERE. The device
|
||||
# name is half of every entity_id, so re-scoping `DEVICE` renames every entity
|
||||
# the live install already has - and HA keeps the id it composed at FIRST
|
||||
# discovery, so what a rename actually produces is a NEW entity beside a dead
|
||||
# one: the history stays behind on the old id, and every dashboard, template
|
||||
# and automation that names it (including `goodwe_master_watchdog_held_*` and
|
||||
# `goodwe_master_parked_at_0_w_*`) keeps pointing at the entity that stopped
|
||||
# updating. That is silent. So:
|
||||
#
|
||||
# `DEVICE` IS NOT TOUCHED AND ITS ENTITIES ARE NOT TOUCHED. It is the SITE
|
||||
# device - the meter's view and the controller's own state - and at the
|
||||
# shipped roster of one it is also, in effect, inverter A's.
|
||||
#
|
||||
# THE FIRST ROSTER ENTRY GETS NO NEW DEVICE. Its per-unit rows are announced
|
||||
# ON THE DEVICE IT ALREADY HAS, under new `unique_id`s and new names. Nothing
|
||||
# existing is renamed, so nothing existing loses its history.
|
||||
#
|
||||
# EVERY LATER ENTRY GETS ITS OWN DEVICE (`via_device` the site one).
|
||||
#
|
||||
# ⚠️ THE ASYMMETRY IS THE POINT, AND IT WILL LOOK LIKE AN OVERSIGHT. "One
|
||||
# device per inverter", with A as `inverter-a` and B as `inverter-b`, is tidy,
|
||||
# symmetrical, and is the change that destroys the live system's recorded
|
||||
# history. DO NOT "CLEAN THIS UP". The composed entity_ids come out symmetric
|
||||
# anyway - `sensor.goodwe_inverter_a_battery_power` and
|
||||
# `sensor.goodwe_inverter_b_battery_power` - which is the whole benefit of the
|
||||
# tidy version, without the cost.
|
||||
#
|
||||
# WHAT IS PER-UNIT AND WHAT IS SITE, and why the split is where it is:
|
||||
#
|
||||
# PER-UNIT setpoint (this unit's share of the site target, the number its
|
||||
# own heartbeat re-emits), battery power (`inv.batt`, this unit's
|
||||
# own meter reading), SoC (`inv.soc`, THIS pack - the site row is
|
||||
# the EMPTIEST pack, which at N>=2 may be the other one), and the
|
||||
# SAFETY-02 sign latch, which is per actuator by construction.
|
||||
#
|
||||
# SITE `grid` and `p1_age`. There is ONE P1 meter and ONE grid
|
||||
# connection; announcing them per inverter would invent two of a
|
||||
# thing there is one of. `p1_sample_age_s` is additionally a
|
||||
# WATCHDOG INPUT (see `omit` below and the firmware note above), so
|
||||
# a second copy of it is a second copy of a safety signal - two
|
||||
# answers to "is the meter fresh?", one of which is always the one
|
||||
# nobody is looking at. Also site: circulating power (it is
|
||||
# `sum(|Bi|) - |sum(Bi)|`, a property of the SET), the controller
|
||||
# status, the maintenance phase, and the capacity-tariff peak.
|
||||
#
|
||||
# NOT ANNOUNCED, AND SAID OUT LOUD RATHER THAN LEFT LOOKING FINISHED: the
|
||||
# card also lists "inverter AC power" and per-unit HEARTBEATS as per-unit.
|
||||
# Neither has a source in this add-on today - nothing reads an AC-power
|
||||
# register per unit, and `inv.last_beat` is a monotonic loop clock with no
|
||||
# wall-clock age published anywhere. Announcing them would create entities
|
||||
# nothing ever feeds, which is exactly what the `omit` note below refuses to
|
||||
# do for `p1_sample_age_s`. They are gaps, not omissions.
|
||||
UNIT_SENSORS = [
|
||||
("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),
|
||||
("battery", "battery_power", "Battery power", "W", "power", "measurement", None),
|
||||
("soc", "battery_soc", "Battery SoC", "%", "battery", "measurement", None),
|
||||
("sign_latch", "sign_latch", "Sign latch", None, None, None, "mdi:flash-alert"),
|
||||
]
|
||||
|
||||
# ⚠️ A ROSTER OF ONE ANNOUNCES NOTHING NEW - not a device, not an entity, not a
|
||||
# topic. Every install running today is a roster of one, and "the upgrade added
|
||||
# four entities nobody asked for" is a change to a live system's entity list
|
||||
# for no information: at N=1 the site rows ARE this unit's rows, exactly.
|
||||
PER_UNIT_MIN_ROSTER = 2
|
||||
|
||||
|
||||
def _slug(text) -> str:
|
||||
"""Home Assistant's own object_id spelling: lowercase, `_`, no runs.
|
||||
|
||||
⚠️ ASCII-ONLY ON PURPOSE. `str.isalnum()` is true for 'é' and for '٤', and
|
||||
HA transliterates those rather than keeping them, so treating them as
|
||||
alphanumeric here would produce an id this module claims and HA does not.
|
||||
They become separators, which is conservative in the right direction: two
|
||||
names that differ ONLY outside ASCII collide, and `unit_slugs()` below is
|
||||
what stops a collision becoming two entities with one `unique_id`.
|
||||
"""
|
||||
out = []
|
||||
for ch in str(text).strip().lower():
|
||||
out.append(ch if (ch.isascii() and ch.isalnum()) else "_")
|
||||
slug = "".join(out)
|
||||
while "__" in slug:
|
||||
slug = slug.replace("__", "_")
|
||||
return slug.strip("_")
|
||||
|
||||
|
||||
def _title(name) -> str:
|
||||
"""`inverter-b` -> `Inverter B`. The half of the entity_id HA composes."""
|
||||
words = str(name).replace("-", " ").replace("_", " ").split()
|
||||
return " ".join(w[:1].upper() + w[1:] for w in words) or str(name)
|
||||
|
||||
|
||||
def unit_slugs(units) -> list:
|
||||
"""One id-safe slug per roster entry, in roster order, ALL DISTINCT.
|
||||
|
||||
⚠️ THE DE-COLLISION IS NOT DECORATION. `validate.py` rule 9(c) refuses two
|
||||
entries with the SAME name; it does not refuse `inverter-b` beside
|
||||
`Inverter B`, and those two slug to one string. Two entities published with
|
||||
one `unique_id` is not an error in HA - the second one is DROPPED, so one
|
||||
inverter's telemetry silently stops existing. The suffix is the roster
|
||||
INDEX, so it is stable for a given roster and does not depend on what the
|
||||
other unit is called.
|
||||
"""
|
||||
slugs, seen = [], set()
|
||||
for i, name in enumerate(units):
|
||||
slug = _slug(name) or f"inverter_{i + 1}"
|
||||
while slug in seen:
|
||||
slug = f"{slug}_{i + 1}"
|
||||
seen.add(slug)
|
||||
slugs.append(slug)
|
||||
return slugs
|
||||
|
||||
|
||||
def announced_units(units) -> list:
|
||||
"""The roster, in order - or EMPTY below `PER_UNIT_MIN_ROSTER`.
|
||||
|
||||
⚠️ ONE ANSWER TO "WHICH UNITS GET PER-UNIT ENTITIES", read by the
|
||||
announcement here AND by `Controller.publish()` in main.py. Two spellings of
|
||||
the same predicate is how an entity gets announced that nothing publishes
|
||||
to, or published to on a topic nothing announced - and at N=1 the second
|
||||
would be an added topic, which AC 4 forbids outright.
|
||||
"""
|
||||
names = [str(u) for u in units]
|
||||
if len(names) < PER_UNIT_MIN_ROSTER:
|
||||
return []
|
||||
return names
|
||||
|
||||
|
||||
def unit_state_key(slug: str, key: str) -> str:
|
||||
"""The `publish()` key - i.e. the state topic under `BASE` - for one unit.
|
||||
|
||||
Written once, here, because main.py builds it and this module announces it;
|
||||
a second transcription is a topic announced and never fed.
|
||||
"""
|
||||
return f"unit/{slug}/{key}"
|
||||
|
||||
|
||||
def unit_device(name, slug: str) -> dict:
|
||||
"""B's OWN device. ⚠️ Never returned for the first roster entry."""
|
||||
return {
|
||||
"identifiers": [f"{DEVICE['identifiers'][0]}_{slug}"],
|
||||
"name": f"{DEVICE['name']} {_title(name)}",
|
||||
"manufacturer": DEVICE["manufacturer"],
|
||||
"model": DEVICE["model"],
|
||||
# Hangs B under the controller in HA's device tree rather than beside
|
||||
# it. Cosmetic, and the one line that says in the UI what the asymmetry
|
||||
# above means: there is one controller, and it drives both.
|
||||
"via_device": DEVICE["identifiers"][0],
|
||||
}
|
||||
|
||||
|
||||
def _payload(name, object_id, unique_id, state_topic, device,
|
||||
unit, dev_class, state_class, icon) -> dict:
|
||||
"""One discovery payload.
|
||||
|
||||
⚠️ KEY ORDER IS PART OF THE OUTPUT. `json.dumps` writes keys in insertion
|
||||
order, so this dict's construction order IS the bytes on the wire, and AC 1
|
||||
is a BYTE comparison against what `release/1.0` publishes. Do not reorder.
|
||||
"""
|
||||
cfg = {
|
||||
"name": name,
|
||||
"object_id": object_id,
|
||||
"unique_id": unique_id,
|
||||
"state_topic": state_topic,
|
||||
"availability_topic": AVAILABILITY,
|
||||
"device": device,
|
||||
}
|
||||
if unit:
|
||||
cfg["unit_of_measurement"] = unit
|
||||
if dev_class:
|
||||
cfg["device_class"] = dev_class
|
||||
if state_class:
|
||||
cfg["state_class"] = state_class
|
||||
if icon:
|
||||
cfg["icon"] = icon
|
||||
return cfg
|
||||
|
||||
|
||||
def discovery_payloads(units=(), omit=()) -> list:
|
||||
"""Every `(topic, payload)` this add-on announces, in announcement order.
|
||||
|
||||
Split out of `_announce()` so that the acceptance criterion can be graded as
|
||||
a DIFF OF THE ANNOUNCED PAYLOADS against `release/1.0`, rather than as a
|
||||
reading of the code. `_announce()` publishes exactly this list and nothing
|
||||
else, which is itself checked.
|
||||
"""
|
||||
omit = set(omit)
|
||||
out = []
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
||||
if key in omit:
|
||||
continue
|
||||
out.append((
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
|
||||
DEVICE, unit, dev_class, state_class, icon)))
|
||||
names = announced_units(units)
|
||||
slugs = unit_slugs(names)
|
||||
site_slug = _slug(DEVICE["name"])
|
||||
for i, (name, slug) in enumerate(zip(names, slugs)):
|
||||
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
|
||||
# keeps the device it already has and carries its name in the ENTITY
|
||||
# name instead; every later one gets its own device and does not.
|
||||
# Either way HA composes device-name + entity-name into the same
|
||||
# string, which is why `object_id` below can be written once for both.
|
||||
device = DEVICE if i == 0 else unit_device(name, slug)
|
||||
prefix = f"{_title(name)} " if i == 0 else ""
|
||||
for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS:
|
||||
unique_id = f"{BASE}_unit_{slug}_{key}"
|
||||
out.append((
|
||||
f"homeassistant/sensor/{unique_id}/config",
|
||||
_payload(prefix + label,
|
||||
f"{site_slug}_{slug}_{oid}",
|
||||
unique_id,
|
||||
f"{BASE}/{unit_state_key(slug, key)}",
|
||||
device, unit, dev_class, state_class, icon)))
|
||||
return out
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
def __init__(self, host, port, username=None, password=None, omit=()):
|
||||
def __init__(self, host, port, username=None, password=None, omit=(),
|
||||
units=()):
|
||||
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
|
||||
# sensor that nothing will ever publish to is not harmless here:
|
||||
# p1_sample_age_s is a watchdog input, and an entity that exists but is
|
||||
# never fed is a worse signal than one that does not exist at all.
|
||||
self.omit = set(omit)
|
||||
# TEL-07. The roster's NAMES, in roster order - `inverter.roster_names()`,
|
||||
# not a second reading of the option. Defaults to empty, which
|
||||
# `announced_units()` treats exactly as a roster of one: nothing
|
||||
# per-unit is announced, so every caller that has not been taught about
|
||||
# the roster keeps publishing what it published before.
|
||||
self.units = [str(u) for u in units]
|
||||
self.enabled = mqtt is not None and bool(host)
|
||||
self.client = None
|
||||
if not self.enabled:
|
||||
@@ -130,29 +355,12 @@ class MqttPublisher:
|
||||
self.enabled = False
|
||||
|
||||
def _announce(self) -> None:
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
||||
if key in self.omit:
|
||||
continue
|
||||
cfg = {
|
||||
"name": name,
|
||||
"object_id": object_id,
|
||||
"unique_id": f"{BASE}_{key}",
|
||||
"state_topic": f"{BASE}/{key}",
|
||||
"availability_topic": AVAILABILITY,
|
||||
"device": DEVICE,
|
||||
}
|
||||
if unit:
|
||||
cfg["unit_of_measurement"] = unit
|
||||
if dev_class:
|
||||
cfg["device_class"] = dev_class
|
||||
if state_class:
|
||||
cfg["state_class"] = state_class
|
||||
if icon:
|
||||
cfg["icon"] = icon
|
||||
self.client.publish(
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
json.dumps(cfg), retain=True,
|
||||
)
|
||||
# ⚠️ NO PAYLOAD IS BUILT HERE. `discovery_payloads()` is the whole
|
||||
# announcement, so the check that diffs it against `release/1.0` is
|
||||
# diffing what actually goes on the wire - and a payload built here
|
||||
# instead would be one this add-on publishes and no check has ever seen.
|
||||
for topic, cfg in discovery_payloads(self.units, self.omit):
|
||||
self.client.publish(topic, json.dumps(cfg), retain=True)
|
||||
self.client.publish(AVAILABILITY, "online", retain=True)
|
||||
|
||||
def publish(self, values: dict) -> None:
|
||||
|
||||
@@ -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.
|
||||
|
||||
+301
-26
@@ -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": "",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -106,16 +126,53 @@ SCHEMA_VERSION = 1
|
||||
SCHEMA_KEY = "schema_version"
|
||||
|
||||
# Which flat keys belong to ONE inverter, and therefore move into its namespace.
|
||||
# ⚠️ THE MAINTENANCE KEYS ARE NOT IN HERE AND THAT IS DELIBERATE. `phase`,
|
||||
# `phase_started`, `last_completed` and `last_start_attempt` belong to the
|
||||
# maintenance state machine, of which this add-on still holds exactly ONE - a
|
||||
# monthly recalibration is a pack procedure and per-pack SCHEDULING is a card of
|
||||
# its own, not something to infer from a key rename. Moving them here would
|
||||
# quietly re-home an install's whole maintenance history under a unit name; they
|
||||
# stay where they are, untouched, which is the other half of "migrates without
|
||||
# losing its latch OR ITS HISTORY".
|
||||
# ⚠️ THE MAINTENANCE KEYS ARE STILL NOT IN HERE, AND SINCE MAINT-05 THAT IS A
|
||||
# DIFFERENT DECISION RATHER THAN THE SAME ONE. They DO move now - one
|
||||
# `Maintenance` per roster entry needs one history per roster entry - but they
|
||||
# move on their OWN marker, `MAINT_SCHEMA_KEY`, and only on a site that actually
|
||||
# has more than one pack. Two conditions the SAFETY-02 keys do not share, so
|
||||
# folding them into this tuple would either drag them across on a
|
||||
# single-inverter install (which is the byte-identical store layout MAINT-05
|
||||
# must not change) or hold the sign latch hostage to a roster count it has
|
||||
# nothing to do with. `MAINT_UNIT_KEYS` below carries them.
|
||||
LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MAINT-05: THE SECOND MIGRATION, AND WHY IT IS A SECOND ONE
|
||||
#
|
||||
# 0 = the flat, one-history layout every install has had since the add-on
|
||||
# shipped: `phase` / `phase_started` / `last_completed` /
|
||||
# `last_start_attempt` describe THE pack, because there was one FSM.
|
||||
# 1 = one history per roster entry, under `unit/<name>/...`.
|
||||
#
|
||||
# ⚠️ ITS OWN MARKER, NOT A BUMP OF `SCHEMA_VERSION`, AND THE REASON IS THAT THE
|
||||
# TWO MIGRATIONS DO NOT HAVE THE SAME PRECONDITION. DEV-02's runs on every
|
||||
# install exactly once and is monotone in the version number. This one runs ONLY
|
||||
# WHERE THE ROSTER HAS MORE THAN ONE PACK, because at N=1 §6.2's identity holds
|
||||
# and the flat keys ARE that pack's namespace - moving them would add four keys
|
||||
# and a version marker to a store MAINT-05's own acceptance criterion requires
|
||||
# to be byte-identical. A single monotone counter cannot express "migrated on
|
||||
# this site, deliberately not migrated on that one" without lying to the next
|
||||
# version that reads it: a one-inverter store would have to be left at the old
|
||||
# number forever, and then a genuine v2 migration could never be told apart from
|
||||
# a site that simply never grew.
|
||||
#
|
||||
# ⚠️ AND IT IS TWO MARKERS, NOT TWO COPIES OF THE THREE RULES.
|
||||
# `_move_flat_keys()` and `_verify_moves()` below are shared verbatim by both
|
||||
# migrations, so rules 1 and 3 and the READ-BACK POSTCONDITION are one
|
||||
# implementation. A second transcription of a migration is a second opinion
|
||||
# about what "it landed" means.
|
||||
MAINT_SCHEMA_VERSION = 1
|
||||
MAINT_SCHEMA_KEY = "maint_schema_version"
|
||||
|
||||
# The flat maintenance-history keys, in the order they are moved.
|
||||
# ⚠️ THE VALUES ARE THE FLAT SPELLINGS AND THE MODULE THAT READS THEM INDEXES
|
||||
# THIS SAME TUPLE (`maintenance.UnitHistory`), so a key renamed here cannot
|
||||
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
|
||||
# SAFETY-02 pair.
|
||||
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
|
||||
"last_start_attempt")
|
||||
|
||||
|
||||
def unit_key(unit: str, key: str) -> str:
|
||||
"""The namespaced Store key for `key` on inverter `unit`.
|
||||
@@ -154,16 +211,35 @@ class MigrationReport:
|
||||
# to shout: an alarm that fires on the harmless case is an alarm an
|
||||
# operator learns to dismiss before the real one arrives.
|
||||
self.lost = []
|
||||
# ⚠️ A DIFFERENT FAILURE FROM `lost`, AND IT HAS TO BE, BECAUSE THE
|
||||
# READ-BACK CANNOT SEE IT. `lost` is "the store took the write and
|
||||
# dropped it". This is "the store took the write, kept it perfectly, and
|
||||
# NOTHING WILL EVER READ IT" - the migration was handed a unit name that
|
||||
# is not on the roster, so every machine looks under a different key and
|
||||
# finds nothing. There is no harmless-default version of this the way
|
||||
# there is for `lost`: a write nobody reads is not information, whatever
|
||||
# its value, so anything in here fails `ok` on its own.
|
||||
self.misdirected = []
|
||||
# Why nothing ran, when nothing ran. Empty means "already at schema".
|
||||
self.skipped = ""
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Nothing that CARRIED STATE failed to land."""
|
||||
return not self.lost
|
||||
"""Nothing that CARRIED STATE failed to land, and it landed WHERE THE
|
||||
MACHINES LOOK."""
|
||||
return not self.lost and not self.misdirected
|
||||
|
||||
def explain(self) -> str:
|
||||
if not self.ran:
|
||||
return (f"store already at schema {self.from_version}; no "
|
||||
f"per-inverter migration needed")
|
||||
# ⚠️ `skipped` RATHER THAN A SECOND SENTENCE ABOUT VERSIONS. Since
|
||||
# MAINT-05 there are two reasons a migration does not run - the
|
||||
# store is already at this schema, or the roster has one pack and
|
||||
# the flat keys ARE its namespace - and an operator reading the
|
||||
# startup log needs to be told which, because only one of them
|
||||
# changes when a second inverter is declared.
|
||||
return self.skipped or (f"store already at schema "
|
||||
f"{self.from_version}; no "
|
||||
f"per-inverter migration needed")
|
||||
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
|
||||
f"namespaces: " + ", ".join(sorted(self.moved)))
|
||||
if self.kept:
|
||||
@@ -172,6 +248,8 @@ class MigrationReport:
|
||||
out += " - PROBLEMS: " + "; ".join(self.problems)
|
||||
if self.lost:
|
||||
out += " - STATE LOST: " + "; ".join(self.lost)
|
||||
if self.misdirected:
|
||||
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
|
||||
return out
|
||||
|
||||
|
||||
@@ -223,7 +301,30 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
return rep
|
||||
|
||||
rep.ran = True
|
||||
for legacy in LEGACY_UNIT_KEYS:
|
||||
_move_flat_keys(store, unit, LEGACY_UNIT_KEYS, rep)
|
||||
_verify_moves(store, rep)
|
||||
|
||||
if rep.ok:
|
||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
||||
else:
|
||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
||||
# next start rather than be recorded as finished - the one thing worse
|
||||
# than a failed migration is a failed migration a later version skips.
|
||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
||||
"marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
def _move_flat_keys(store, unit: str, keys, rep: MigrationReport) -> None:
|
||||
"""Rule 1, once, for both migrations.
|
||||
|
||||
Copies each flat `key` to `unit/<unit>/<key>` UNLESS the namespaced spelling
|
||||
is already present - a store that has both has been written by a version
|
||||
that knew about the namespace, and that value is the live one. Rule 2 is the
|
||||
absence of a delete: the flat keys are left exactly where they were.
|
||||
"""
|
||||
data = getattr(store, "data", {})
|
||||
for legacy in keys:
|
||||
target = unit_key(unit, legacy)
|
||||
if target in data:
|
||||
rep.kept.append(target)
|
||||
@@ -232,17 +333,63 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
# tripped has no `sign_tripped` in its file at all, and the value it
|
||||
# then lands on has to be the one `Store.load()` would have supplied -
|
||||
# otherwise "absent" and "never tripped" stop being the same statement,
|
||||
# which is the distinction store.py's own header is about.
|
||||
# which is the distinction store.py's own header is about. The same
|
||||
# sentence holds for `last_completed`: absent means "never recalibrated"
|
||||
# and a literal here would be a second opinion about that.
|
||||
value = data.get(legacy, DEFAULTS.get(legacy))
|
||||
store.set(target, value)
|
||||
rep.moved[target] = value
|
||||
|
||||
# THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION. This
|
||||
# is the difference between this function and a two-line copy: the caller
|
||||
# can ask "is the latch that was in the file before now under the key the
|
||||
# watchdog reads", through the same `unit_key()` the watchdog's constructor
|
||||
# reads through, and a `False` there is a guard that is off on an install
|
||||
# whose state file says it is on.
|
||||
|
||||
def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
|
||||
"""THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION.
|
||||
|
||||
This is the difference between these functions and a two-line copy: the
|
||||
caller can ask "is the latch that was in the file before now under the key
|
||||
the watchdog reads" - and, since MAINT-05, "is the history that was in the
|
||||
file before now under the key THIS PACK's state machine reads" - through the
|
||||
same `unit_key()` those readers read through. A `False` here is a guard that
|
||||
is off, or a month of coulomb-count history gone, on an install whose state
|
||||
file says otherwise.
|
||||
|
||||
⚠️ AND `roster` IS THE OTHER HALF, WHICH THE READ-BACK ON ITS OWN CANNOT BE.
|
||||
A POSTCONDITION THAT READS BACK THE NAMESPACE IT WAS HANDED CANNOT DETECT
|
||||
BEING HANDED THE WRONG ONE. That is not a hypothetical: MAINT-05 shipped
|
||||
with `migrate_maint_keys()` taking a bare `unit` string, and a call site that
|
||||
passed `inverter.UNIT_A` instead of the first roster entry's name migrated an
|
||||
install's whole maintenance history into `unit/inverter-a/...` on a roster of
|
||||
`["garage", "shed"]`, read all four keys back under that same name, was
|
||||
SATISFIED, and stamped the schema marker - so the next start skipped the
|
||||
migration too and both packs came up believing they had never been
|
||||
recalibrated. Every symptom of a clean migration, and the history gone.
|
||||
`build_roster()` names a declared roster's entries from the config and falls
|
||||
back to a POSITIONAL `inverter-N`, so `inverter-a` is not even the default
|
||||
there; there is no roster on which that write could have been read.
|
||||
|
||||
So the namespace is graded against the one the MACHINES read - the roster -
|
||||
rather than against the argument this function was handed, which is the
|
||||
thing under test. A unit that is not on the roster is `misdirected`, which
|
||||
fails `ok`, which means the caller does NOT stamp the version marker and the
|
||||
next start runs the migration again. A re-run is recoverable, by rule 1; a
|
||||
silent skip is the state store.py's header says must never happen.
|
||||
|
||||
⚠️ AND THE GRADED SET IS `moved` UNION `kept`, WHICH IS WHAT MAKES THAT
|
||||
PARAGRAPH TRUE ON THE SECOND START AS WELL AS THE FIRST. Graded over `moved`
|
||||
alone the refusal lasted exactly one start: the wrong call site is still
|
||||
wrong next time, finds the keys it wrote already present, and rule 1 routes
|
||||
every one of them to `kept` - so `moved` is empty, this loop sees nothing,
|
||||
`ok` comes back True and the marker is stamped on a migration that has
|
||||
reached no readable key. The measurement is in the comment on the loop
|
||||
itself. `test_maintenance.py` section 9c calls this twice with the SAME
|
||||
wrong `unit` over one store, which is the shape nothing in the suite had.
|
||||
|
||||
`roster=None` means "not graded", and the one caller that passes it is
|
||||
`migrate_unit_keys()`, which runs BEFORE `build_roster()` for the reason its
|
||||
own docstring gives - the `Inverter` objects restore their latch in their
|
||||
constructors, so the keys have to be in place before there is a roster to
|
||||
check against. That gap is real and is recorded as a running check rather
|
||||
than left to a comment: see test_maintenance.py section 13.
|
||||
"""
|
||||
for target, value in rep.moved.items():
|
||||
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
||||
if got == value:
|
||||
@@ -254,14 +401,142 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
if value != DEFAULTS.get(legacy):
|
||||
rep.lost.append(note)
|
||||
|
||||
if roster is None:
|
||||
return
|
||||
names = [str(n) for n in roster]
|
||||
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
|
||||
# through the same spelling the writer used rather than re-deriving it.
|
||||
#
|
||||
# ⚠️ `moved` UNION `kept`, AND THE UNION IS THE WHOLE OF THE SECOND FIX
|
||||
# ROUND. Grading only `moved` made the refusal last EXACTLY ONE START.
|
||||
# Measured at Controller level over one persisted store, roster
|
||||
# ["garage", "shed"], flat `last_completed` 2026-08-07, with the call site
|
||||
# still handing `inverter-a` on both starts:
|
||||
#
|
||||
# construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent>
|
||||
# construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1
|
||||
#
|
||||
# On start 2 the four keys the WRONG start 1 wrote are already present, so
|
||||
# rule 1 routes every one of them to `rep.kept`, `rep.moved` is EMPTY, this
|
||||
# loop iterates over nothing, `misdirected` is empty and `ok` is True - so
|
||||
# the caller stamps the marker and start 3 takes the `from_version >=
|
||||
# SCHEMA` early return FOREVER, including after the call site is fixed.
|
||||
# Loud-and-recoverable became silent-and-permanent after one restart: the
|
||||
# exact state this module's header says must never happen, reached by the
|
||||
# fix that was written to prevent it.
|
||||
#
|
||||
# The union is sound by construction rather than by hope: every entry in
|
||||
# both `rep.moved` and `rep.kept` is a `unit_key(unit, legacy)` built from
|
||||
# the SAME `unit` argument, so grading `kept` grades the same namespace
|
||||
# decision `moved` does. A correct call site puts its `kept` entries under a
|
||||
# roster name and they pass; a wrong one cannot launder its namespace by
|
||||
# having already written it.
|
||||
for target in sorted(set(rep.moved) | set(rep.kept)):
|
||||
parts = target.split("/")
|
||||
unit = parts[1] if len(parts) > 2 else ""
|
||||
if unit in names:
|
||||
continue
|
||||
rep.verified = False
|
||||
note = (f"{target} was migrated into namespace {unit!r}, which is NOT "
|
||||
f"on the roster {names!r} - nothing reads that key")
|
||||
rep.problems.append(note)
|
||||
rep.misdirected.append(note)
|
||||
|
||||
|
||||
def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
|
||||
"""Move the flat maintenance history into the FIRST pack's namespace.
|
||||
|
||||
MAINT-05. Until this card there was one `Maintenance` and one history, and
|
||||
`store.py`'s own header argued the keys should stay flat because per-pack
|
||||
scheduling was a card of its own. This is that card: there is now one FSM
|
||||
per roster entry, so there is one history per roster entry, and the flat one
|
||||
belongs to the pack that was being recalibrated - `inverters[0]`, which is
|
||||
what `Controller.maint_unit` named and what `validate.py` rule 6 graded
|
||||
against.
|
||||
|
||||
⚠️ `len(roster) <= 1` IS A RETURN, NOT A SPECIAL CASE. At one inverter the
|
||||
flat keys ARE that pack's namespace (§6.2's identity, the same one
|
||||
`arbiter.actuator_for()` leans on), `Maintenance` is constructed to read
|
||||
them directly, and a migration would add four keys plus a marker to a store
|
||||
that must come out byte-identical. The migration happens on the day the
|
||||
roster grows, which is the day the flat spelling stops being able to answer
|
||||
"which pack".
|
||||
|
||||
⚠️ `roster` IS REQUIRED, AND IT REPLACED AN `n_units: int`. Two reasons, and
|
||||
the second is the one MAINT-05 was rejected over.
|
||||
|
||||
The small one: `n_units` was `len(self.inverters)` at the only call site, so
|
||||
the roster carries it - `len(roster)` - and a count passed beside the names
|
||||
is a second opinion about how many packs there are.
|
||||
|
||||
The load-bearing one: THE NAMES ARE THE POSTCONDITION. `unit` says which
|
||||
pack the flat keys belonged to and that is genuinely a decision - the first
|
||||
roster entry, because that is the pack the one FSM was driving - but a
|
||||
decision the caller can get WRONG, and until this signature existed nothing
|
||||
could tell. `_verify_moves()` read back the namespace it was handed, so a
|
||||
migration into a name no machine reads verified clean and stamped the
|
||||
marker. Handing the roster in means the read-back is graded against the
|
||||
keys the MACHINES read, and a `unit` that is not among these names comes
|
||||
back `misdirected`: not `ok`, marker NOT written, migration runs again next
|
||||
start - and again the start after that, because `_verify_moves()` grades
|
||||
`kept` as well as `moved`. See `_verify_moves()` for the install this was
|
||||
measured on, and for what grading `moved` alone cost on start 2.
|
||||
|
||||
THE THREE RULES ARE `migrate_unit_keys()`'s, SHARED IN CODE rather than
|
||||
restated: `_move_flat_keys()` is rule 1 (never overwrite a namespaced value)
|
||||
and rule 2 (the flat keys are left in the file), `_verify_moves()` is the
|
||||
read-back postcondition, and the marker is written LAST so a crash before it
|
||||
leaves the migration to run again - which rule 1 makes a no-op.
|
||||
|
||||
⚠️ AND RULE 2 IS KEPT LIVE HERE TOO, WHICH IS THE HALF DEV-02 HAD TO ADD
|
||||
LATER. "Left in place" alone makes the flat history a fossil dated the day
|
||||
the second inverter was declared: an operator who rolls back to a
|
||||
single-FSM add-on would find a `last_completed` months old and recalibrate a
|
||||
pack that does not need it, or - worse - a stale `phase` and a
|
||||
`phase_started` old enough to abort on the 12 h timeout the moment it comes
|
||||
up. So `maintenance.UnitHistory` MIRRORS the first pack's writes back onto
|
||||
the flat keys for as long as the roster has more than one entry. The flat
|
||||
pair is the ROLLBACK VIEW of the history, maintained, exactly as
|
||||
`sign_tripped` is the rollback view of the latch.
|
||||
"""
|
||||
rep = MigrationReport()
|
||||
data = getattr(store, "data", {})
|
||||
names = [str(n) for n in roster]
|
||||
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
|
||||
if len(names) <= 1:
|
||||
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
|
||||
"history and are read in place, so there is nothing to "
|
||||
"move and the store layout does not change")
|
||||
return rep
|
||||
if rep.from_version >= MAINT_SCHEMA_VERSION:
|
||||
rep.skipped = (f"maintenance history already at schema "
|
||||
f"{rep.from_version}; written by a multi-inverter "
|
||||
f"version and not touched")
|
||||
return rep
|
||||
|
||||
rep.ran = True
|
||||
_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)
|
||||
_verify_moves(store, rep, roster=names)
|
||||
|
||||
# ⚠️ `rep.ok` AND NOTHING ELSE - NOT `ok or kept`, NOT `not lost`, NOT
|
||||
# `not misdirected`. BOTH guards gate this line and each one is pinned by a
|
||||
# check that the other cannot satisfy: section 9c refuses a store whose
|
||||
# failure is `misdirected` with `lost` empty, and section 9d refuses one
|
||||
# whose failure is `lost` with `misdirected` empty AND `kept` non-empty -
|
||||
# the mutation `if rep.ok or rep.kept:` stamps the marker on a migration
|
||||
# that lost a month of history, and until section 9d existed all 19 suites
|
||||
# were green on it.
|
||||
if rep.ok:
|
||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
||||
store.set(MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION)
|
||||
else:
|
||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
||||
# next start rather than be recorded as finished - the one thing worse
|
||||
# than a failed migration is a failed migration a later version skips.
|
||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
||||
"marked done: %s", rep.explain())
|
||||
# ⚠️ NOT MARKED DONE, FOR EITHER FAILURE. The same sentence
|
||||
# `migrate_unit_keys()` carries: the one thing worse than a failed
|
||||
# migration is a failed migration a later version skips. Rule 1 makes
|
||||
# the re-run a no-op where the keys did land, so refusing the marker
|
||||
# costs one repeated pass and buys the difference between a migration
|
||||
# that can still be fixed and one the store says already happened.
|
||||
_LOG.error("per-pack maintenance-history migration did NOT land and "
|
||||
"has not been marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -176,6 +183,60 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
the per-unit form and main.py runs it from `run_control()`, once every
|
||||
unit's own ESPHome has replied.
|
||||
|
||||
10. `slew_w` above the fraction of the SITE RAIL the plant can follow. [(a)]
|
||||
SAFETY-39, and the first rule here whose subject is a RELATIONSHIP rather
|
||||
than a threshold. `slew_w` bounds how far the site command may move in one
|
||||
cycle; `control.SLEW_RAIL_FRAC` is `LOOP_TICK_S / PLANT_SETTLE_S`, so
|
||||
`SLEW_RAIL_FRAC * rail` is the largest step at which the loop still takes
|
||||
the plant's own measured settling time to traverse its whole output range.
|
||||
Above it the controller commands its entire range before it can observe
|
||||
its answer to the first step, and the stated limit - "the command moves at
|
||||
most `slew_w` per cycle, so it cannot jump ahead of the inverter" - is
|
||||
untrue: it jumps ahead by construction.
|
||||
|
||||
⚠️ THIS IS (a) AND NOT (b), and the distinction is what makes it a refusal
|
||||
rather than a warning. Nothing goes inert. What happens is that a bound
|
||||
the operator set means something other than what it says, and the layer
|
||||
that overrules it is the PLANT - which has no status row, publishes
|
||||
nothing, and cannot be read off any page in this add-on. Measured on the
|
||||
owner's system 2026-09-05 11:43-11:46 at `max_w: 5000`, `slew_w: 1000`:
|
||||
setpoint 380 -> 5000 in five 2 s ticks, battery -5154 W against a 5000 VA
|
||||
inverter, and the grid driven from 3.4 kW of EXPORT to 2.8 kW of IMPORT.
|
||||
Large (>= 900 W) setpoint steps per 30 min climbed 2 -> 1 -> 13 -> 43 that
|
||||
morning. Nothing in the add-on reported anything wrong, because nothing
|
||||
was: the loop followed its command exactly, and the command was wrong.
|
||||
|
||||
⚠️ IT IS GRADED AGAINST THE ROSTER SUM, NOT THE FLAT `max_w` AND NOT THE
|
||||
WEAKEST UNIT, and this is the one rule in this file for which that is the
|
||||
right rail. Rules 5, 6 and 8 are all about ONE unit - one watchdog, one
|
||||
shaping claim, one pack - so they take `min()` over `unit_caps()`.
|
||||
`slew_w` is not per unit: `main.py` builds `Tuning(max_w=self.site_max_w)`
|
||||
and `compute()` produces the SITE target, which the allocator then splits.
|
||||
So the range this bound is about is the sum, `Controller.site_max_w`, and
|
||||
`site_rail_w()` below is that same number computed from options.
|
||||
|
||||
⚠️ AND THE `site_sign_disagree_w` NOTE IN config.yaml IS NOT A PRECEDENT
|
||||
AGAINST THIS. That note says there is deliberately no rule pairing
|
||||
`site_sign_disagree_w` with `max_w`, "because 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." That objection is
|
||||
to grading a site quantity against the FLAT key. It is answered rather than
|
||||
ignored: `unit_caps()` already reads the roster the add-on will build, so
|
||||
the roster sum IS available here, and this rule takes it.
|
||||
|
||||
⚠️ THE DIRECTION OF THIS RULE, SAID PLAINLY, BECAUSE IT IS NOT THE
|
||||
DIRECTION THE TICKET ASSUMED. A rail that GROWS loosens this bound: at a
|
||||
9600 W two-inverter rail, `slew_w: 1000` traverses the rail in 19.2 s and
|
||||
passes, while at 5000 W the same 1000 W is the configuration measured
|
||||
above. That is what the constraint says and it is correct - the bound is on
|
||||
traverse TIME, and a bigger rail at the same step size takes longer to
|
||||
cross. What this rule therefore protects against is the constant being left
|
||||
behind when the rail moves EITHER way. It is NOT a bound on how much power
|
||||
the site may command, and it does NOT bound the over-absorption: see the
|
||||
note below on `gain`, which is the term that owns that and which SAFETY-39
|
||||
measured and did not change.
|
||||
|
||||
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
|
||||
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
||||
@@ -219,6 +280,34 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
is answered. Compare rule 5, which CAN be settled at startup because both
|
||||
`sign_disagree_w` and `max_w` are config and are both known at boot.
|
||||
|
||||
- `gain` at any value the schema accepts, INCLUDING against a large rail.
|
||||
SAFETY-39 measured this and deliberately left it alone, which is worth more
|
||||
than the rule would have been. On admin/goodwe's rig, over a family of 51
|
||||
export steps from 1.0 to 4.2 kW at `max_w: 5000` and a 2 s tick, the
|
||||
over-absorption - peak battery power beyond what the site actually needed -
|
||||
is FLAT in `slew_w`: 958 W at slew 1000 and 1081 W at slew 250. It falls
|
||||
in `gain`: 987 W at 0.6, 876 W at 0.3, 435 W at 0.2 - a real reduction, but
|
||||
NOT to zero, and the 0.2 figure is the one worth not rounding away. The
|
||||
reason is structural and is visible in control.py: `slew_w` bounds the
|
||||
OUTPUT, while the integrator accumulates `gain * error` per cycle and is
|
||||
bounded only by `integrator_max_w`. At gain 0.6 into a 3.4 kW error the
|
||||
integrator moves ~2040 W per tick and reaches a 5000 W rail in under three
|
||||
ticks whatever `slew_w` is.
|
||||
So the 2026-08-22 derivation's own sentence - "the gain, not the slew, is
|
||||
the limit by then" - is now literally true, and `gain` is the constant that
|
||||
needs re-deriving next. It is NOT refused and NOT changed here: it is a
|
||||
live control-law constant on a running house, changing it in the same pull
|
||||
request as `slew_w` would make the next measurement uninterpretable, and a
|
||||
validator rule pairing `gain` with a rail would be inventing a relationship
|
||||
nobody has measured. Named here so it is a known open item rather than an
|
||||
omission. See DECISIONS/SAFETY-39-slew-against-the-rail.md.
|
||||
|
||||
- `slew_w` BELOW the rail fraction, at any value the schema accepts. Slower
|
||||
than the plant is always safe and is sometimes necessary - the live install
|
||||
has run an explicit 400 W against a 5000 W rail since 2026-09-05 12:00,
|
||||
which is 12.5 ticks to traverse the rail against the 9 this rule requires.
|
||||
Refusing "too slow" would refuse the mitigation that is holding.
|
||||
|
||||
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
|
||||
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
|
||||
every ordering of the two is a legitimate site configuration. Refusing on
|
||||
@@ -228,12 +317,14 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
import logging
|
||||
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .inverter import roster_entries
|
||||
from .maintenance import MAINT_OPTION_DEFAULTS
|
||||
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 +475,70 @@ def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
|
||||
return caps
|
||||
|
||||
|
||||
def site_rail_w(opts: dict) -> float | None:
|
||||
"""The SITE rail: what the whole site may be commanded, in W. Rule 10's rail.
|
||||
|
||||
⚠️ THE SUM, NOT THE MINIMUM, AND NOT THE FLAT KEY - and it is the only place
|
||||
in this file that wants a sum. Rules 5, 6 and 8 grade a threshold against
|
||||
ONE unit (one watchdog, one shaping claim, one pack) and take `min()` over
|
||||
`unit_caps()`. `slew_w` is not a per-unit quantity at all: `main.py` builds
|
||||
`Tuning(max_w=self.site_max_w)` from the roster sum and `compute()` emits
|
||||
the SITE target, which the allocator then divides. So the output range
|
||||
`slew_w` is a rate across is `Controller.site_max_w`, and this is that same
|
||||
number read out of options rather than off a built roster.
|
||||
|
||||
At the shipped roster of one this is `unit_caps()`'s single entry, which is
|
||||
`max_w` exactly - byte for byte the number the single-inverter shape has
|
||||
always meant. None when no entry carries a usable cap, which rule 9 has
|
||||
already refused by the time rule 10 runs.
|
||||
"""
|
||||
caps = unit_caps(opts)
|
||||
return float(sum(caps)) if caps else None
|
||||
|
||||
|
||||
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 +896,65 @@ 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.
|
||||
#
|
||||
# ⚠️ AND IT READS THE WEAKEST UNIT'S CAP, NOT THE FLAT `max_w` - SAFETY-32
|
||||
# ROUND 5. This branch used to compare against `max_w = _number(opts,
|
||||
# "max_w", 2000.0)`, the FLAT option, which on a roster config is either
|
||||
# ABSENT (so it compared against a 2000 W DEFAULT describing no unit on the
|
||||
# site) or a leftover from a single-inverter install (so it compared against
|
||||
# a number no unit has). Both directions were live and both were measured:
|
||||
#
|
||||
# a roster of two 2000 W units beside a leftover `max_w: 5000` and
|
||||
# `unit_sign_disagree_w: 3000` produced ZERO problems - the site STARTED,
|
||||
# with a per-inverter watchdog reporting `armed` on the status page and
|
||||
# incapable of tripping on EITHER unit. That is the identical sentence the
|
||||
# branch above exists to refuse, reached through the branch beside it;
|
||||
#
|
||||
# and a roster of two 5000 W units with no flat key was REFUSED at
|
||||
# `unit_sign_disagree_w: 3000` against a 2000 W default the operator never
|
||||
# wrote, for a threshold both units can reach.
|
||||
#
|
||||
# `unit_caps()`'s own docstring already said why: there is no such thing as
|
||||
# a site rating on the wire, and this watchdog grades ONE UNIT's standing
|
||||
# command against THAT unit's `supervised` bound. So it takes the same
|
||||
# `weakest` the branch above takes, from the same list, and names the same
|
||||
# unit. At a roster of one `unit_caps()` is `[max_w]` and this is the
|
||||
# identical comparison it always was - which is what keeps the shipped
|
||||
# single-inverter refusal byte-for-byte what it was.
|
||||
unit_sign_w = _number(opts, "unit_sign_disagree_w",
|
||||
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
|
||||
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
||||
if caps_ix else "")
|
||||
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 weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
||||
problems.append(
|
||||
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}"
|
||||
f" is {weakest:.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 {weakest:.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
|
||||
@@ -768,24 +982,47 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# rule 8 below already makes with `PEAK_OPTION_DEFAULTS` - and
|
||||
# test_maintenance.py pins it by reading the shipped file.
|
||||
#
|
||||
# ⚠️ AND IT IS GRADED AGAINST THE FIRST UNIT'S CAP, NOT THE SMALLEST AND NOT
|
||||
# THE FLAT ONE. Rule 5 takes the weakest unit because there is a watchdog on
|
||||
# every one of them; there is exactly ONE `Maintenance` in this add-on and
|
||||
# `Controller.maint_unit` names the pack it recalibrates - the first in the
|
||||
# roster, because the cycle's history lives under un-namespaced Store keys.
|
||||
# The shaping claim is filed on THAT actuator and on no other, so the cap
|
||||
# that can contradict it is THAT unit's. Taking `min()` here would refuse a
|
||||
# perfectly workable site - a 5000 W pack being recalibrated beside a 2000 W
|
||||
# sibling the cycle never touches - which is the over-refusal this file's
|
||||
# docstring says gets a validator switched off. When `maint_unit` stops
|
||||
# being `inverters[0]`, this line moves with it.
|
||||
# ⚠️ MAINT-05: IT IS GRADED AGAINST THE WEAKEST UNIT'S CAP NOW, AND THE
|
||||
# COMMENT THIS REPLACES SAID SO WOULD HAPPEN. It read: "there is exactly ONE
|
||||
# `Maintenance` in this add-on and `Controller.maint_unit` names the pack it
|
||||
# recalibrates - the first in the roster, because the cycle's history lives
|
||||
# under un-namespaced Store keys. The shaping claim is filed on THAT
|
||||
# actuator and on no other, so the cap that can contradict it is THAT
|
||||
# unit's. Taking `min()` here would refuse a perfectly workable site - a
|
||||
# 5000 W pack being recalibrated beside a 2000 W sibling the cycle never
|
||||
# touches ... When `maint_unit` stops being `inverters[0]`, this line moves
|
||||
# with it."
|
||||
#
|
||||
# `maint_unit` has stopped being `inverters[0]`. There is one `Maintenance`
|
||||
# per roster entry, each with its own namespaced history, and EVERY pack is
|
||||
# recalibrated on its own schedule - so the sibling "the cycle never
|
||||
# touches" does not exist any more and the over-refusal argument has no
|
||||
# subject. The shaping claim is filed on whichever pack is charging, so the
|
||||
# cap that can contradict it is any cap on the roster, and the one that
|
||||
# contradicts it FIRST is the smallest. This is rule 5's conjunction for
|
||||
# rule 5's reason: a threshold that is fine against the biggest unit says
|
||||
# nothing about the smallest, and the smallest is the one that goes to 0 W.
|
||||
#
|
||||
# ⚠️ THIS REFUSES CONFIGS THAT USED TO START, AND THAT IS THE POINT RATHER
|
||||
# THAN A SIDE EFFECT. `max_w` 5000 on pack A beside 2000 on pack B with
|
||||
# `maintenance_charge_w` 3000 was accepted before this card and was correct
|
||||
# while only pack A was ever recalibrated. Pack B now recalibrates too, and
|
||||
# its cheap-window charge asks the arbiter for `hi = -3000` against a
|
||||
# supervised `lo = -2000`: `lo > hi`, arbiter clause 3, 0 W, contradiction -
|
||||
# a maintenance charge that silently never happens on that pack, which is
|
||||
# exactly what rule 6 exists to refuse at the moment it is WRITTEN. At a
|
||||
# roster of one `min()` over one cap is that cap and the comparison, the
|
||||
# message and the boundary are identical.
|
||||
charge_w = _number(opts, "maintenance_charge_w",
|
||||
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]))
|
||||
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON. `caps[0]` is the first
|
||||
# unit WITH A USABLE CAP, which is `inverters[0]` only while every earlier
|
||||
# entry has one; `_unit_label(opts, 0)` was hard-wired to `inverters[0]`
|
||||
# regardless. Both come off the one pair now.
|
||||
maint_index, maint_cap = caps_ix[0] if caps_ix else (0, None)
|
||||
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON: `unit_caps_indexed()`
|
||||
# SKIPS entries with no usable `max_w`, so a caps-list position is not a
|
||||
# roster position and `_unit_label()` indexes the roster. The index travels
|
||||
# with the number, so the unit the message names is the unit the number came
|
||||
# off - which on a roster whose first entry has a junk `max_w` is not the
|
||||
# same thing at all.
|
||||
maint_index, maint_cap = (min(caps_ix, key=lambda p: p[1]) if caps_ix
|
||||
else (0, None))
|
||||
maint_where = _unit_label(opts, maint_index)
|
||||
if charge_w is None:
|
||||
problems.append(
|
||||
@@ -802,6 +1039,62 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
f"two numbers. Lower maintenance_charge_w to {maint_cap:.0f} W or "
|
||||
f"less, or raise max_w to at least {charge_w:.0f} W.")
|
||||
|
||||
# --- 10. a slew rate the plant can actually follow -----------------------
|
||||
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
|
||||
# threshold, and the whole point of writing it as one is that the number it
|
||||
# bounds must not be able to outlive the rail again. `control.py`'s header
|
||||
# carries the derivation; the short version is that `slew_w: 1000` was
|
||||
# derived against a 1500 W clamp and the live install raised the clamp to
|
||||
# 5000 without revisiting it, so the loop traversed its whole output range
|
||||
# in 10 s against a plant measured at ~18 s.
|
||||
#
|
||||
# ⚠️ `slew_w: 0` IS NOT GRADED AND MUST NOT BE. Zero means "follow the
|
||||
# rail" - `control.slew_limit_w()` resolves it to `SLEW_RAIL_FRAC * max_w`,
|
||||
# which satisfies this rule by construction at every rail there is. It is
|
||||
# the shipped default, and a rule that refused it would refuse the fix.
|
||||
#
|
||||
# ⚠️ STRICTLY `>`, MATCHING RULE 6 RATHER THAN RULE 5. Equality is the
|
||||
# derived value itself: at `slew_w == SLEW_RAIL_FRAC * rail` the loop takes
|
||||
# exactly `PLANT_SETTLE_S` to cross its range, which is the constraint
|
||||
# satisfied, not violated. Refusing it would refuse the value this rule
|
||||
# exists to recommend - and `slew_limit_w()` produces exactly that value
|
||||
# for every default install, so `>=` would refuse the shipped default
|
||||
# through a float comparison. A tolerance is applied for the same reason:
|
||||
# `SLEW_RAIL_FRAC * 5000` is 555.55..., and an operator who writes the 556
|
||||
# a status page rounded to is one watt over a boundary that is not
|
||||
# meaningful to one watt.
|
||||
slew_w = _number(opts, "slew_w", 0.0)
|
||||
rail = site_rail_w(opts)
|
||||
if slew_w is None:
|
||||
problems.append(f"slew_w {opts.get('slew_w')!r} is not a number")
|
||||
elif slew_w < 0:
|
||||
problems.append(
|
||||
f"slew_w is {slew_w:.0f} W and must not be negative - it is a "
|
||||
"magnitude, and 0 means 'follow max_w' (the recommended setting).")
|
||||
elif slew_w and rail is not None and rail > 0:
|
||||
bound = SLEW_RAIL_FRAC * rail
|
||||
if slew_w > bound + 1.0:
|
||||
ticks = rail / slew_w
|
||||
problems.append(
|
||||
f"slew_w is {slew_w:.0f} W against a site rail of {rail:.0f} W, "
|
||||
f"so the loop crosses its whole output range in {ticks:.1f} "
|
||||
f"cycles - {ticks * LOOP_TICK_S:.0f} s at the {LOOP_TICK_S:.0f} s "
|
||||
f"tick this site was measured running - while the plant takes "
|
||||
f"{PLANT_SETTLE_S:.0f} s to settle (goodwe/NOTES.md §5.10, a "
|
||||
"1.78 kW step). The controller would command its entire range "
|
||||
"before it could observe its answer to the first step, which is "
|
||||
"not a slow loop, it is a loop that MANUFACTURES the error it "
|
||||
"then chases: measured on this system 2026-09-05, a 3.4 kW "
|
||||
"export drove the setpoint 380 -> 5000 W in five ticks, the "
|
||||
"battery to -5154 W past a 5000 VA rating, and the grid from "
|
||||
"3.4 kW of export to 2.8 kW of import. The stated limit - the "
|
||||
"command moves at most slew_w per cycle, so it cannot jump "
|
||||
"ahead of the inverter - is untrue at this rail; THE PLANT "
|
||||
f"WINS, and it has no status row. Set slew_w to 0, which means "
|
||||
f"'follow the rail' and resolves to {bound:.0f} W here and "
|
||||
"moves on its own if the rail does, or write a value at or "
|
||||
f"below {bound:.0f} W. Lower is always accepted.")
|
||||
|
||||
# --- 7. two price sources on one site ------------------------------------
|
||||
problems.extend(_price_problems(opts))
|
||||
|
||||
@@ -814,28 +1107,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,190 @@ 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
|
||||
#
|
||||
# ⚠️ SAFETY-39 MOVED THAT SCHEMA FLOOR FROM 50 TO 0, AND THE CAMPAIGN ABOVE
|
||||
# WAS NOT RE-RUN. It does not have to be, and here is the argument rather
|
||||
# than the assurance. What these windows are about is how long the command
|
||||
# takes to CROSS ITS RAIL - the swept worst case is the SLOWEST crossing,
|
||||
# which is `slew_w` at its floor, 5000/50 = 100 cycles. `slew_w: 0` means
|
||||
# "follow the rail" and resolves to `SLEW_RAIL_FRAC * max_w`, which is
|
||||
# exactly 9 cycles at EVERY rail by construction. So the new value can never
|
||||
# be the slowest crossing at any `max_w`, and it is strictly inside the
|
||||
# envelope this campaign measured. An explicit number still spans 50..5000
|
||||
# and is unchanged. If `SLEW_RAIL_FRAC` is ever loosened past 1/100, this
|
||||
# paragraph stops being true and the campaign has to be re-run.
|
||||
#
|
||||
# ⚠️ 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 +485,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 +494,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 +562,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 +582,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 +598,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 +630,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 +654,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 +667,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
|
||||
|
||||
+469
-255
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.6.0"
|
||||
version: "0.7.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -31,23 +31,23 @@ services:
|
||||
- mqtt:want
|
||||
|
||||
options:
|
||||
# --- sources (required) ---------------------------------------------------
|
||||
meter_entity: sensor.p1_meter_active_power
|
||||
meter_invert: false
|
||||
# ==========================================================================
|
||||
# HARDWARE - the inverter, its actuator and its own sensors
|
||||
# ==========================================================================
|
||||
setpoint_entity: ""
|
||||
soc_entity: ""
|
||||
batt_entity: ""
|
||||
batt_invert: false
|
||||
setpoint_entity: ""
|
||||
|
||||
# --- ARCH-01 / CFG-04: the inverter roster ---------------------------------
|
||||
# ⚠️ EMPTY IS NOT "UNCONFIGURED", IT IS THE SINGLE-INVERTER CONFIGURATION, and
|
||||
# it is what every install that upgrades into this version lands on. An empty
|
||||
# list means THE FIVE FLAT KEYS ABOVE DESCRIBE THE ONE INVERTER - same
|
||||
# entities, same rating, same behaviour, no operator action - which is the
|
||||
# only migration story this key was allowed to have. `build_roster()` reads
|
||||
# this and nothing else decides the shape.
|
||||
# list means THE SIX FLAT KEYS - `setpoint_entity`, `soc_entity`,
|
||||
# `batt_entity`, `batt_invert`, `max_w` and `bms_soc_floor` - DESCRIBE THE
|
||||
# ONE INVERTER: same entities, same rating, same behaviour, no operator
|
||||
# action, which is the only migration story this key was allowed to have.
|
||||
# `build_roster()` reads this and nothing else decides the shape.
|
||||
#
|
||||
# Declared, it REPLACES those five keys rather than extending them. Each entry
|
||||
# Declared, it REPLACES those six keys rather than extending them. Each entry
|
||||
# names its own actuator, its own rating and its own sensors:
|
||||
#
|
||||
# inverters:
|
||||
@@ -56,23 +56,58 @@ 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
|
||||
# max_w: 4600
|
||||
# bms_soc_floor: 10
|
||||
#
|
||||
# ⚠️ THE 4600 IS INVERTER B'S DATASHEET FIGURE, NOT A MEASUREMENT AND NOT A
|
||||
# PLACEHOLDER. SPIKE-04a (2026-09-04) identified B as a GoodWe GW5048D-ES,
|
||||
# serial `55048ESU22AW0992`, whose datasheet gives Max. Charging / Discharging
|
||||
# Power as 4600 W; nothing will be connected to its MPPTs, so the battery path
|
||||
# is the ceiling and the 5000 VA AC nameplate is not. The 3000 W this example
|
||||
# used to carry was a guess made before the unit was known. ⚠️ It is STILL not
|
||||
# measured: the datasheet prints 4600 W under BATTERY INPUT DATA, so it may be
|
||||
# DC, and `max_w` is graded against 0x0518 - the inverter's own AC power. If it
|
||||
# is DC the AC ceiling is nearer 4324 W. SAFETY-20's `min(max_w, dev_max)` is
|
||||
# what settles that once the device range is read; see goodwe/NOTES.md §1.1b,
|
||||
# which records both readings and picks neither.
|
||||
#
|
||||
# ⚠️ AND THE `bms_soc_floor: 10` BESIDE IT IS FROM THE SAME PLACE - THE
|
||||
# DATASHEET AND THE SERIAL, NOT A PACK ANYONE HAS READ. No second inverter
|
||||
# exists on this site (SPIKE-04, ≈ 2026-09-12 for B); the cut-out must be read
|
||||
# off the pack that is actually installed (register 0x0560 on the reference
|
||||
# unit) before that pack is commanded. It reads 10 here because pack A reads
|
||||
# 10, which makes this example DEGENERATE ON THE FLOOR AXIS: a site whose two
|
||||
# packs floor at the SAME percent cannot separate `min`, `max`, first or last
|
||||
# over the roster, and every SAFETY-32 fixture that DIFFERS the floors differs
|
||||
# it for exactly that reason. Do not "simplify" those fixtures to match this
|
||||
# example.
|
||||
#
|
||||
# ⚠️ 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
|
||||
@@ -91,9 +126,12 @@ options:
|
||||
# validator answers `Missing option 'inverters' in root` for every existing
|
||||
# options blob, and the upgrade leaves an add-on that cannot start.
|
||||
inverters: []
|
||||
estop_fitted: false
|
||||
|
||||
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
||||
# `off` keeps the original single meter_entity path above, so an existing
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
# ==========================================================================
|
||||
# `off` keeps the original single `meter_entity` path, so an existing
|
||||
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
|
||||
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below;
|
||||
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
|
||||
@@ -104,153 +142,62 @@ options:
|
||||
# nothing at all for a repeated reading - so it is the only mode a firmware
|
||||
# watchdog may threshold. See DOCS.md.
|
||||
meter_source: "off"
|
||||
meter_entity: sensor.p1_meter_active_power
|
||||
meter_invert: false
|
||||
meter_phases: 1
|
||||
meter_max_age_s: 30
|
||||
meter_mqtt_topic: ""
|
||||
|
||||
# homewizard_local only. The meter's own address, `host` or `host:port`.
|
||||
p1_host: ""
|
||||
|
||||
# homewizard_local only. Seconds between polls. Must be well under
|
||||
# meter_max_age_s - the age is never fresher than this interval - and there is
|
||||
# nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640).
|
||||
meter_poll_s: 5
|
||||
meter_max_age_s: 30
|
||||
meter_mqtt_topic: ""
|
||||
|
||||
# The two UNSIGNED Belgian registers. The EMS derives net power from them
|
||||
# (import - export); do NOT point these at a signed template sensor.
|
||||
p1_import_entity: ""
|
||||
p1_export_entity: ""
|
||||
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
|
||||
# three-phase connection; the list length must equal meter_phases.
|
||||
p1_phase_import_entities: []
|
||||
p1_phase_export_entities: []
|
||||
|
||||
# ha_signed only. ONE signed net-power sensor: positive = import from the
|
||||
# grid, negative = export to it. Do NOT split it into two template sensors -
|
||||
# the split is done in the add-on (p1.split_signed) precisely so the sign
|
||||
# convention is tested rather than living in unreviewed YAML.
|
||||
p1_net_entity: ""
|
||||
|
||||
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
|
||||
# three-phase connection; the list length must equal meter_phases.
|
||||
p1_phase_import_entities: []
|
||||
p1_phase_export_entities: []
|
||||
|
||||
# Optional, in L1..L3 order, each one signed the same way. Same role as
|
||||
# p1_phase_import_entities: the capacity-tariff peak on a three-phase
|
||||
# connection. The list length must equal meter_phases.
|
||||
p1_phase_net_entities: []
|
||||
|
||||
# --- control ---------------------------------------------------------------
|
||||
# ==========================================================================
|
||||
# RATINGS AND LIMITS - what this site is allowed to do
|
||||
# ==========================================================================
|
||||
max_w: 2000
|
||||
gain: 0.6
|
||||
slew_w: 1000
|
||||
deadband_w: 15
|
||||
target_grid_w: -10
|
||||
step_w: 10
|
||||
saturation_w: 500
|
||||
saturation_cycles: 3
|
||||
integrator_max_w: 0
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
cycle_floor_s: 15
|
||||
auto_start: false
|
||||
|
||||
# --- SAFETY-02 sign-disagreement watchdog ----------------------------------
|
||||
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
|
||||
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
|
||||
# a failsafe that latches until an operator starts control again.
|
||||
# The pack's OWN cut-out percent - what the BMS enforces whatever
|
||||
# `peak_soc_floor` is set to. Register 0x0560 reads 10 on the reference
|
||||
# GW5000S-BP, consistent with 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.
|
||||
#
|
||||
# Both defaults are measured, not chosen. On the captured healthy day
|
||||
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
|
||||
# not caught up with - is 30 s, and sign agreement over the gated samples is
|
||||
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
|
||||
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
|
||||
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
|
||||
# fault day still trips 13 times. There is no reason to move either number
|
||||
# without a capture that says so. They are options because the AC says
|
||||
# thresholds are defaults rather than constants - not because they are knobs
|
||||
# anyone is expected to turn.
|
||||
#
|
||||
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
|
||||
# arbiter bounds the command to +-max_w). validate.py refuses that config.
|
||||
sign_disagree_w: 300
|
||||
sign_disagree_s: 180
|
||||
|
||||
# --- 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
|
||||
# are required: neither one alone is safe, and app/freeze.py carries why.
|
||||
#
|
||||
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
|
||||
# the battery; a false notification costs an alert.
|
||||
#
|
||||
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
|
||||
# only transport that reads the meter's own document. On every other source
|
||||
# the status page says NOT COVERED rather than "armed".
|
||||
#
|
||||
# 300 s is measured, not chosen. Over 2399 s of the live house at the
|
||||
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
|
||||
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
|
||||
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
|
||||
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
|
||||
# still inside the 900 s quarter that bills the capacity tariff.
|
||||
meter_freeze_s: 300
|
||||
|
||||
# --- SAFETY-31: circulating power ------------------------------------------
|
||||
# ⚠️ CFG-03. THESE TWO LINES ARE THE SOURCE OF TRUTH FOR THESE DEFAULTS, and
|
||||
# `app/circulation.py`'s `CIRC_OPTION_DEFAULTS` is the ONE in-code copy - the
|
||||
# Dockerfile does not put this file in the image, so the running add-on cannot
|
||||
# read it. test_watchdog.py pins the copy by READING these lines, so a drift
|
||||
# goes red in CI rather than shipping. Add a third option in three places -
|
||||
# options:, schema:, CIRC_OPTION_DEFAULTS - and in no fourth.
|
||||
#
|
||||
# Circulating power is sum(|Bi|) - |sum(Bi)| over MEASURED per-inverter power:
|
||||
# two inverters pushing against each other, which NETS TO ZERO AT THE METER
|
||||
# and is therefore invisible to every other guard in this add-on. Both numbers
|
||||
# below were measured on the TEST-19 two-master rig over 924 closed-loop runs
|
||||
# in which nothing was faulty; circulation.py carries the curve they were read
|
||||
# off and the argument for each. They are NOT sensitivity preferences.
|
||||
#
|
||||
# Both are ignored at one inverter, where the quantity is identically zero.
|
||||
circulation_w: 700
|
||||
circulation_hold_s: 75
|
||||
|
||||
# --- maintenance -----------------------------------------------------------
|
||||
# ⚠️ CFG-03. THESE EIGHT LINES ARE THE SOURCE OF TRUTH FOR THE MAINTENANCE
|
||||
# DEFAULTS. The Supervisor reads this file and the operator edits this file,
|
||||
# so nothing else in the add-on gets an opinion about what a missing
|
||||
# `maintenance_*` key means. There is exactly ONE in-code copy -
|
||||
# `app/maintenance.py`'s `MAINT_OPTION_DEFAULTS` - because the Dockerfile does
|
||||
# not put this file in the image and the running add-on therefore cannot read
|
||||
# it; test_maintenance.py pins that copy by READING these lines, so a drift
|
||||
# goes red in CI rather than shipping. main.py and validate.py both read the
|
||||
# mirror and type no number of their own.
|
||||
#
|
||||
# Before this card the same defaults were written down three times and had
|
||||
# already disagreed: this block said 2000 while main.py and MaintConfig said
|
||||
# 2500, so an options dict with the key ABSENT passed validation at 2000 and
|
||||
# then ran at 2500 against a 2000 W ceiling - a silent 0 W arbiter
|
||||
# contradiction. Changing one of these numbers now moves every consumer.
|
||||
maintenance_enabled: false
|
||||
maintenance_interval_days: 28
|
||||
maintenance_start_hour: 10
|
||||
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
||||
# than the 2500 they shipped as. The cheap-window charge shaping asks the
|
||||
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
|
||||
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
|
||||
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
|
||||
# default install. validate.py refuses that config (rule 6), so these
|
||||
# defaults had to become self-consistent with the max_w above rather than
|
||||
# ship a configuration the validator rejects.
|
||||
#
|
||||
# The discharge side is clamped rather than contradicted - it drains at max_w
|
||||
# and the arbiter says so - so it is only warned about; it is 2000 here for
|
||||
# symmetry and to keep the shipped config warning-free.
|
||||
#
|
||||
# Raising max_w (the live reference install runs 5000) is what unlocks a
|
||||
# faster maintenance cycle. Raise max_w FIRST, then these.
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
maintenance_soc_floor: 11
|
||||
maintenance_soc_target: 99
|
||||
maintenance_hold_min: 120
|
||||
|
||||
# --- tariff / capacity tariff (all optional) ------------------------------
|
||||
peak_forecast_entity: ""
|
||||
# ⚠️ 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
|
||||
peak_cap_w: 3500
|
||||
peak_forecast_entity: ""
|
||||
|
||||
# --- SAFETY-24: the energy backstop under SAFETY-07's forced discharge -----
|
||||
# The peak guard can COMMAND a discharge to hold a billed quarter under
|
||||
# peak_cap_w. Until this card it was bounded from below by nothing but the
|
||||
# pack's own BMS cut-out - a real floor, but the PACK's protection rather than
|
||||
@@ -294,31 +241,10 @@ options:
|
||||
# loop-side reserve and is waiting on the product owner. Until that is
|
||||
# answered, the BMS is the only floor under self-consumption.
|
||||
peak_soc_floor: 20
|
||||
# The pack's OWN cut-out percent - what the BMS enforces whatever is set
|
||||
# above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with
|
||||
# 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.
|
||||
bms_soc_floor: 10
|
||||
|
||||
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
||||
# add-on delegating the whole price question to Home Assistant: main.py reads
|
||||
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
||||
# nothing except the maintenance cycle's cheap-window charge floor. That is
|
||||
# all they have ever done. They are kept, because retiring them would take
|
||||
# the maintenance cycle's only price signal away on a live install before
|
||||
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
|
||||
# are the cards that replace it, and neither has landed.
|
||||
#
|
||||
# ⚠️ They may NOT be combined with price_provider below. Two price sources on
|
||||
# one site, one of which silently wins, is exactly the "double up" the PM
|
||||
# named; validate.py rule 7 refuses that configuration at startup rather than
|
||||
# letting an operator believe the provider they configured is driving
|
||||
# something. See app/validate.py.
|
||||
price_now_entity: ""
|
||||
price_avg_entity: ""
|
||||
|
||||
# --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) --------------
|
||||
# ==========================================================================
|
||||
# TARIFF - what energy costs, and whether to trade on it
|
||||
# ==========================================================================
|
||||
# `off` is the shipped default and means the §11.4 no-price-data row applies:
|
||||
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
|
||||
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
|
||||
@@ -337,12 +263,12 @@ options:
|
||||
price_provider: "off"
|
||||
price_markup_eur_kwh: 0.03
|
||||
price_taxes_eur_kwh: 0.06
|
||||
price_csv_path: "/share/goodwe_prices.csv"
|
||||
price_feed_url: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
|
||||
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
|
||||
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
|
||||
# which by itself strongly favours self-consumption over export. A scalar, not
|
||||
# a second series: no shipped supplier publishes a dynamic injection curve.
|
||||
export_price_eur_kwh: 0.04
|
||||
|
||||
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
|
||||
# of the installed base is on [a static profile]" - this is what makes those
|
||||
# sites first-class rather than "price_provider: off, good luck".
|
||||
@@ -372,13 +298,28 @@ options:
|
||||
# to fall back to and a dead feed leaves the site with no tariff at all.
|
||||
# validate.py warns about exactly that.
|
||||
static_profile: ""
|
||||
price_csv_path: "/share/goodwe_prices.csv"
|
||||
price_feed_url: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
|
||||
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
|
||||
# which by itself strongly favours self-consumption over export. A scalar, not
|
||||
# a second series: no shipped supplier publishes a dynamic injection curve.
|
||||
export_price_eur_kwh: 0.04
|
||||
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
||||
# add-on delegating the whole price question to Home Assistant: main.py reads
|
||||
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
||||
# nothing except the maintenance cycle's cheap-window charge floor. That is
|
||||
# all they have ever done. They are kept, because retiring them would take
|
||||
# the maintenance cycle's only price signal away on a live install before
|
||||
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
|
||||
# are the cards that replace it, and neither has landed.
|
||||
#
|
||||
# ⚠️ They may NOT be combined with `price_provider`. Two price sources on
|
||||
# one site, one of which silently wins, is exactly the "double up" the PM
|
||||
# named; validate.py rule 7 refuses that configuration at startup rather than
|
||||
# letting an operator believe the provider they configured is driving
|
||||
# something. See app/validate.py.
|
||||
price_now_entity: ""
|
||||
price_avg_entity: ""
|
||||
|
||||
# --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) ---------------
|
||||
# Charge in the cheapest published hour, discharge in the dearest, and ONLY
|
||||
# when the spread covers what the round trip costs. Off in the shipped
|
||||
# configuration: this is the first strategy in the add-on that moves energy
|
||||
@@ -399,10 +340,12 @@ options:
|
||||
# owner; the code is correct at any value and no number was picked to make a
|
||||
# trade appear.
|
||||
arbitrage_enabled: false
|
||||
|
||||
# §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh
|
||||
# - the wear on the pack plus the conversion losses. A spread below this is
|
||||
# a cycle that loses money and pack life, so it is not taken.
|
||||
cycle_cost_threshold_eur: 0.10
|
||||
|
||||
# The reserve arbitrage may not spend, and the headroom it may not overfill.
|
||||
# Deliberately NOT the maintenance_soc_* keys: those describe a monthly
|
||||
# balancing cycle, and pointing a trading strategy at them would couple two
|
||||
@@ -410,17 +353,222 @@ options:
|
||||
arbitrage_soc_floor: 20
|
||||
arbitrage_soc_ceiling: 95
|
||||
|
||||
# --- site ------------------------------------------------------------------
|
||||
estop_fitted: false
|
||||
# ==========================================================================
|
||||
# MAINTENANCE - the monthly battery recalibration cycle
|
||||
# ==========================================================================
|
||||
# ⚠️ CFG-03. THESE EIGHT LINES ARE THE SOURCE OF TRUTH FOR THE MAINTENANCE
|
||||
# DEFAULTS. The Supervisor reads this file and the operator edits this file,
|
||||
# so nothing else in the add-on gets an opinion about what a missing
|
||||
# `maintenance_*` key means. There is exactly ONE in-code copy -
|
||||
# `app/maintenance.py`'s `MAINT_OPTION_DEFAULTS` - because the Dockerfile does
|
||||
# not put this file in the image and the running add-on therefore cannot read
|
||||
# it; test_maintenance.py pins that copy by READING these lines, so a drift
|
||||
# goes red in CI rather than shipping. main.py and validate.py both read the
|
||||
# mirror and type no number of their own.
|
||||
#
|
||||
# Before this card the same defaults were written down three times and had
|
||||
# already disagreed: this block said 2000 while main.py and MaintConfig said
|
||||
# 2500, so an options dict with the key ABSENT passed validation at 2000 and
|
||||
# then ran at 2500 against a 2000 W ceiling - a silent 0 W arbiter
|
||||
# contradiction. Changing one of these numbers now moves every consumer.
|
||||
maintenance_enabled: false
|
||||
maintenance_interval_days: 28
|
||||
maintenance_start_hour: 10
|
||||
|
||||
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
||||
# than the 2500 they shipped as. The cheap-window charge shaping asks the
|
||||
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
|
||||
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
|
||||
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
|
||||
# default install. validate.py refuses that config (rule 6), so these
|
||||
# defaults had to become self-consistent with the max_w above rather than
|
||||
# ship a configuration the validator rejects.
|
||||
#
|
||||
# The discharge side is clamped rather than contradicted - it drains at max_w
|
||||
# and the arbiter says so - so it is only warned about; it is 2000 here for
|
||||
# symmetry and to keep the shipped config warning-free.
|
||||
#
|
||||
# Raising max_w (the live reference install runs 5000) is what unlocks a
|
||||
# faster maintenance cycle. Raise max_w FIRST, then these.
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
maintenance_soc_floor: 11
|
||||
maintenance_soc_target: 99
|
||||
maintenance_hold_min: 120
|
||||
|
||||
# ==========================================================================
|
||||
# SAFETY - DERIVED CONSTANTS. Every number below was measured. Do not change
|
||||
# one without re-deriving it
|
||||
# ==========================================================================
|
||||
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
|
||||
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
|
||||
# a failsafe that latches until an operator starts control again.
|
||||
#
|
||||
# Both defaults are measured, not chosen. On the captured healthy day
|
||||
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
|
||||
# not caught up with - is 30 s, and sign agreement over the gated samples is
|
||||
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
|
||||
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
|
||||
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
|
||||
# fault day still trips 13 times. There is no reason to move either number
|
||||
# without a capture that says so. They are options because the AC says
|
||||
# thresholds are defaults rather than constants - not because they are knobs
|
||||
# anyone is expected to turn.
|
||||
#
|
||||
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
|
||||
# arbiter bounds the command to +-max_w). validate.py refuses that config.
|
||||
sign_disagree_w: 300
|
||||
sign_disagree_s: 180
|
||||
|
||||
# ⚠️ 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
|
||||
|
||||
# ⚠️ 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
|
||||
|
||||
# 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
|
||||
# are required: neither one alone is safe, and app/freeze.py carries why.
|
||||
#
|
||||
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
|
||||
# the battery; a false notification costs an alert.
|
||||
#
|
||||
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
|
||||
# only transport that reads the meter's own document. On every other source
|
||||
# the status page says NOT COVERED rather than "armed".
|
||||
#
|
||||
# 300 s is measured, not chosen. Over 2399 s of the live house at the
|
||||
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
|
||||
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
|
||||
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
|
||||
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
|
||||
# still inside the 900 s quarter that bills the capacity tariff.
|
||||
meter_freeze_s: 300
|
||||
|
||||
# ⚠️ CFG-03. THESE TWO LINES ARE THE SOURCE OF TRUTH FOR THESE DEFAULTS, and
|
||||
# `app/circulation.py`'s `CIRC_OPTION_DEFAULTS` is the ONE in-code copy - the
|
||||
# Dockerfile does not put this file in the image, so the running add-on cannot
|
||||
# read it. test_watchdog.py pins the copy by READING these lines, so a drift
|
||||
# goes red in CI rather than shipping. Add a third option in three places -
|
||||
# options:, schema:, CIRC_OPTION_DEFAULTS - and in no fourth.
|
||||
#
|
||||
# Circulating power is sum(|Bi|) - |sum(Bi)| over MEASURED per-inverter power:
|
||||
# two inverters pushing against each other, which NETS TO ZERO AT THE METER
|
||||
# and is therefore invisible to every other guard in this add-on. Both numbers
|
||||
# below were measured on the TEST-19 two-master rig over 924 closed-loop runs
|
||||
# in which nothing was faulty; circulation.py carries the curve they were read
|
||||
# off and the argument for each. They are NOT sensitivity preferences.
|
||||
#
|
||||
# Both are ignored at one inverter, where the quantity is identically zero.
|
||||
circulation_w: 700
|
||||
circulation_hold_s: 75
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
auto_start: false
|
||||
gain: 0.6
|
||||
|
||||
# ⚠️ SAFETY-39. 0 IS NOT "UNSET" AND IT IS NOT "NO LIMIT" - IT IS "FOLLOW THE
|
||||
# RAIL", and it is the shipped value. `control.slew_limit_w()` resolves it to
|
||||
# `SLEW_RAIL_FRAC * max_w`, which is 222 W against the 2000 W default here,
|
||||
# 556 W against the live install's 5000 W, and 1067 W against the ~9600 W a
|
||||
# second inverter makes of the site. `integrator_max_w: 0` two lines down has
|
||||
# meant exactly this since it was added; this key now means it too.
|
||||
#
|
||||
# ⚠️ WHY IT IS A RATIO AND NOT A NUMBER, WHICH IS THE WHOLE OF THIS CARD. It
|
||||
# WAS a number: 1000, derived 2026-08-22 against a clamp of 1500 W
|
||||
# (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)") on the
|
||||
# grounds that it caps "how far the loop can jump ahead of the inverter". The
|
||||
# rail then went to 5000 on the live install and the constant did not move, so
|
||||
# 1000 W per 2 s tick crossed the whole output range in 10 s against a plant
|
||||
# NOTES.md §5.10 measured at ~18 s up / ~23 s down. Measured on the owner's
|
||||
# system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000
|
||||
# in five ticks, the battery to -5154 W past a 5000 VA rating, and the grid
|
||||
# from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Large (>= 900 W) steps per 30 min
|
||||
# went 2 -> 1 -> 13 -> 43 that morning. Nothing was broken; the rail moved.
|
||||
#
|
||||
# An explicit number is still accepted and is not clamped - the live install
|
||||
# has run 400 since 2026-09-05 12:00, which is slower than the rail implies
|
||||
# and is fine. What validate.py rule 10 refuses is a number FASTER than the
|
||||
# rail implies, because at that setting the stated limit is not the limit and
|
||||
# the layer that overrules it is the plant, which has no status row.
|
||||
#
|
||||
# ⚠️ AND THE COST IS REAL AND IS NOT HIDDEN: following the rail is slower to
|
||||
# converge than 1000 was. Measured over 51 export steps on the in-repo model
|
||||
# (test_control.py) the largest tick step falls from 1000 W to 556 W and the
|
||||
# >= 900 W step disappears from every run - but the OVER-ABSORPTION does not
|
||||
# move, because that is a `gain` term and not a slew term. See
|
||||
# DECISIONS/SAFETY-39-slew-against-the-rail.md, which says so at length rather
|
||||
# than letting this card be read as more than it is.
|
||||
slew_w: 0
|
||||
deadband_w: 15
|
||||
target_grid_w: -10
|
||||
step_w: 10
|
||||
saturation_w: 500
|
||||
saturation_cycles: 3
|
||||
integrator_max_w: 0
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
cycle_floor_s: 15
|
||||
log_level: info
|
||||
|
||||
schema:
|
||||
meter_entity: str
|
||||
meter_invert: bool
|
||||
# ==========================================================================
|
||||
# HARDWARE - the inverter, its actuator and its own sensors
|
||||
# ==========================================================================
|
||||
setpoint_entity: str
|
||||
soc_entity: str
|
||||
batt_entity: str
|
||||
batt_invert: bool
|
||||
setpoint_entity: str
|
||||
|
||||
# CFG-04. THE ONE LIST-OF-OBJECTS IN THIS FILE, and see the options block for
|
||||
# the measurement that says Supervisor 2026.08.0 accepts it: nested types are
|
||||
@@ -444,49 +592,154 @@ 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?
|
||||
|
||||
estop_fitted: bool
|
||||
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
# ==========================================================================
|
||||
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
|
||||
meter_entity: str
|
||||
meter_invert: bool
|
||||
|
||||
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
|
||||
# telegram whose phase count disagrees is rejected at ingest and logged, so a
|
||||
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
|
||||
# quietly wrong number.
|
||||
meter_phases: int(1,3)
|
||||
meter_max_age_s: int(5,300)
|
||||
meter_poll_s: int(1,60)
|
||||
meter_mqtt_topic: str?
|
||||
p1_host: str?
|
||||
meter_poll_s: int(1,60)
|
||||
meter_max_age_s: int(5,300)
|
||||
meter_mqtt_topic: str?
|
||||
p1_import_entity: str?
|
||||
p1_export_entity: str?
|
||||
p1_net_entity: str?
|
||||
p1_phase_import_entities:
|
||||
- str
|
||||
|
||||
p1_phase_export_entities:
|
||||
- str
|
||||
p1_net_entity: str?
|
||||
|
||||
p1_phase_net_entities:
|
||||
- str
|
||||
|
||||
# ==========================================================================
|
||||
# RATINGS AND LIMITS - what this site is allowed to do
|
||||
# ==========================================================================
|
||||
max_w: int(100,5000)
|
||||
gain: float(0.05,1.0)
|
||||
slew_w: int(50,5000)
|
||||
deadband_w: int(0,500)
|
||||
target_grid_w: float(-200,200)
|
||||
step_w: int(1,100)
|
||||
saturation_w: int(100,2000)
|
||||
saturation_cycles: int(1,10)
|
||||
integrator_max_w: int(0,15000)
|
||||
heartbeat_s: int(2,25)
|
||||
stale_input_s: int(5,120)
|
||||
cycle_floor_s: int(5,60)
|
||||
auto_start: bool
|
||||
bms_soc_floor: int(0,50)
|
||||
peak_cap_w: int(500,15000)
|
||||
peak_forecast_entity: str?
|
||||
|
||||
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
|
||||
# a supported way to have no energy bound at all. The ranges OVERLAP on
|
||||
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
|
||||
# rule 8 is what refuses the pair, with a message naming both numbers.
|
||||
peak_soc_floor: int(5,60)
|
||||
|
||||
# ==========================================================================
|
||||
# TARIFF - what energy costs, and whether to trade on it
|
||||
# ==========================================================================
|
||||
# ⚠️ The provider list is written out rather than left as `str`, so the
|
||||
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
|
||||
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
|
||||
# checks reach validate_startup_config() with a dict the Supervisor never saw.
|
||||
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
|
||||
# else, which is AC2.
|
||||
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_markup_eur_kwh: float(0,1)
|
||||
price_taxes_eur_kwh: float(0,1)
|
||||
|
||||
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
|
||||
# gone below zero (you pay to export at midday in summer), and a schema that
|
||||
# bottoms out at 0 would make that site inexpressible.
|
||||
export_price_eur_kwh: float(-1,1)
|
||||
|
||||
# A JSON string rather than a nested schema, deliberately: it is ONE option
|
||||
# key (§14 has one), it survives config.yaml's flat options block, and
|
||||
# parse_static_profile() accepts an already-parsed dict too, so a future
|
||||
# nested schema is a config.yaml change and no code change at all.
|
||||
static_profile: str?
|
||||
price_csv_path: str?
|
||||
price_feed_url: str?
|
||||
price_horizon_h: int(1,72)
|
||||
price_timeout_s: int(2,60)
|
||||
price_now_entity: str?
|
||||
price_avg_entity: str?
|
||||
|
||||
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
|
||||
# and a negative one is a threshold every spread clears - arbitrage with the
|
||||
# brake removed. app/arbitrage.py refuses it in code too, because
|
||||
# validate_startup_config() is also sim/inject.py's entry point and can be
|
||||
# handed any dict at all.
|
||||
arbitrage_enabled: bool
|
||||
cycle_cost_threshold_eur: float(0,1)
|
||||
arbitrage_soc_floor: int(5,95)
|
||||
arbitrage_soc_ceiling: int(10,100)
|
||||
|
||||
# ==========================================================================
|
||||
# MAINTENANCE - the monthly battery recalibration cycle
|
||||
# ==========================================================================
|
||||
# ⚠️ CFG-03 - THIS IS WHERE A RANGE LIVES, AND THE ONLY PLACE IT LIVES. The
|
||||
# `options:` block above holds the DEFAULT; this block holds the BOUNDS, and
|
||||
# `MAINT_OPTION_DEFAULTS` deliberately mirrors the former and not the latter -
|
||||
# the Supervisor is what enforces a range and the Supervisor reads only this
|
||||
# file, so a second copy of `int(500,5000)` in Python would be a place to
|
||||
# disagree and nothing else. What is NOT expressible here is a relation
|
||||
# BETWEEN keys: `maintenance_charge_w` must also stay at or below `max_w`, the
|
||||
# schema grammar has no way to say so, and that is why validate.py's rule 6 is
|
||||
# written in code instead. Add a ninth maintenance option in three places -
|
||||
# options:, schema:, MAINT_OPTION_DEFAULTS - and in no fourth.
|
||||
maintenance_enabled: bool
|
||||
maintenance_interval_days: int(1,90)
|
||||
maintenance_start_hour: int(0,23)
|
||||
maintenance_discharge_w: int(500,5000)
|
||||
maintenance_charge_w: int(500,5000)
|
||||
maintenance_soc_floor: int(5,30)
|
||||
maintenance_soc_target: int(50,100)
|
||||
maintenance_hold_min: int(5,480)
|
||||
|
||||
# ==========================================================================
|
||||
# SAFETY - DERIVED CONSTANTS. Every number below was measured. Do not change
|
||||
# one without re-deriving it
|
||||
# ==========================================================================
|
||||
# No "off" value, deliberately: this is a P0 safety check and the schema is
|
||||
# not the place to make disarming it a supported configuration.
|
||||
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
|
||||
@@ -510,6 +763,7 @@ schema:
|
||||
# fault. A configurable value under 400 W would be a false-alarm generator
|
||||
# that this project's own doctrine (SAFETY-15, SAFETY-19) forbids shipping.
|
||||
circulation_w: int(400,5000)
|
||||
|
||||
# The floor of 30 is `inverter.LINK_STALE_S`: below the firmware's own veto
|
||||
# window a unit still executing a stale share is a LINK fault the add-on
|
||||
# already handles by dropping it from the split, and firing inside that window
|
||||
@@ -518,68 +772,28 @@ schema:
|
||||
# loop must be caught and acted on well inside one.
|
||||
circulation_hold_s: int(30,900)
|
||||
|
||||
# ⚠️ CFG-03 - THIS IS WHERE A RANGE LIVES, AND THE ONLY PLACE IT LIVES. The
|
||||
# `options:` block above holds the DEFAULT; this block holds the BOUNDS, and
|
||||
# `MAINT_OPTION_DEFAULTS` deliberately mirrors the former and not the latter -
|
||||
# the Supervisor is what enforces a range and the Supervisor reads only this
|
||||
# file, so a second copy of `int(500,5000)` in Python would be a place to
|
||||
# disagree and nothing else. What is NOT expressible here is a relation
|
||||
# BETWEEN keys: `maintenance_charge_w` must also stay at or below `max_w`, the
|
||||
# schema grammar has no way to say so, and that is why validate.py's rule 6 is
|
||||
# written in code instead. Add a ninth maintenance option in three places -
|
||||
# options:, schema:, MAINT_OPTION_DEFAULTS - and in no fourth.
|
||||
maintenance_enabled: bool
|
||||
maintenance_interval_days: int(1,90)
|
||||
maintenance_start_hour: int(0,23)
|
||||
maintenance_discharge_w: int(500,5000)
|
||||
maintenance_charge_w: int(500,5000)
|
||||
maintenance_soc_floor: int(5,30)
|
||||
maintenance_soc_target: int(50,100)
|
||||
maintenance_hold_min: int(5,480)
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
auto_start: bool
|
||||
gain: float(0.05,1.0)
|
||||
|
||||
peak_forecast_entity: str?
|
||||
peak_cap_w: int(500,15000)
|
||||
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
|
||||
# a supported way to have no energy bound at all. The ranges OVERLAP on
|
||||
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
|
||||
# rule 8 is what refuses the pair, with a message naming both numbers.
|
||||
peak_soc_floor: int(5,60)
|
||||
bms_soc_floor: int(0,50)
|
||||
price_now_entity: str?
|
||||
price_avg_entity: str?
|
||||
|
||||
# ⚠️ The provider list is written out rather than left as `str`, so the
|
||||
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
|
||||
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
|
||||
# checks reach validate_startup_config() with a dict the Supervisor never saw.
|
||||
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
|
||||
# else, which is AC2.
|
||||
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_markup_eur_kwh: float(0,1)
|
||||
price_taxes_eur_kwh: float(0,1)
|
||||
price_csv_path: str?
|
||||
price_feed_url: str?
|
||||
price_horizon_h: int(1,72)
|
||||
price_timeout_s: int(2,60)
|
||||
# A JSON string rather than a nested schema, deliberately: it is ONE option
|
||||
# key (§14 has one), it survives config.yaml's flat options block, and
|
||||
# parse_static_profile() accepts an already-parsed dict too, so a future
|
||||
# nested schema is a config.yaml change and no code change at all.
|
||||
static_profile: str?
|
||||
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
|
||||
# gone below zero (you pay to export at midday in summer), and a schema that
|
||||
# bottoms out at 0 would make that site inexpressible.
|
||||
export_price_eur_kwh: float(-1,1)
|
||||
|
||||
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
|
||||
# and a negative one is a threshold every spread clears - arbitrage with the
|
||||
# brake removed. app/arbitrage.py refuses it in code too, because
|
||||
# validate_startup_config() is also sim/inject.py's entry point and can be
|
||||
# handed any dict at all.
|
||||
arbitrage_enabled: bool
|
||||
cycle_cost_threshold_eur: float(0,1)
|
||||
arbitrage_soc_floor: int(5,95)
|
||||
arbitrage_soc_ceiling: int(10,100)
|
||||
|
||||
estop_fitted: bool
|
||||
# ⚠️ THE FLOOR IS 0, NOT 50, AND 0 IS THE SHIPPED VALUE - see the options block
|
||||
# above. It means "follow max_w", so the range starts at the setting that
|
||||
# cannot go stale rather than at the smallest number somebody could type.
|
||||
# ⚠️ THE CEILING IS STILL 5000 AND IS STILL NOT THE BOUND THAT MATTERS. The
|
||||
# binding one is `SLEW_RAIL_FRAC * max_w`, a relation BETWEEN keys, and this
|
||||
# grammar cannot express a relation - which is the same reason
|
||||
# `maintenance_charge_w`'s ceiling is not its real bound either (see the note
|
||||
# above it). validate.py rule 10 is where it is written.
|
||||
slew_w: int(0,5000)
|
||||
deadband_w: int(0,500)
|
||||
target_grid_w: float(-200,200)
|
||||
step_w: int(1,100)
|
||||
saturation_w: int(100,2000)
|
||||
saturation_cycles: int(1,10)
|
||||
integrator_max_w: int(0,15000)
|
||||
heartbeat_s: int(2,25)
|
||||
stale_input_s: int(5,120)
|
||||
cycle_floor_s: int(5,60)
|
||||
log_level: list(trace|debug|info|warning|error)
|
||||
|
||||
@@ -580,7 +580,16 @@ def close_loop(gain, split, cycles=200, load_w=2000.0, mode="alloc",
|
||||
below. "site" is the sum of the units' power; "unit" is inverter A's alone,
|
||||
which is what the shipped add-on's single `inverter_power` sensor gives it.
|
||||
"""
|
||||
tuning = Tuning(gain=gain)
|
||||
# ⚠️ `slew_w` IS PINNED HERE AND WAS NOT BEFORE, AND THAT IS SAFETY-39.
|
||||
# ARCH-01's 4000 W peak-to-peak broadcast limit cycle was measured at the
|
||||
# shipped pair of the day - `max_w: 2000` with `slew_w: 1000` - and this
|
||||
# function reached that 1000 through `Tuning`'s DEFAULT. SAFETY-39 made that
|
||||
# default 0, meaning "follow the rail" (`SLEW_RAIL_FRAC * max_w`, 222 W at
|
||||
# this rail), so the same call would now sweep a DIFFERENT law and report
|
||||
# ARCH-01's measurement as absent. A reproduction that silently re-tunes
|
||||
# itself when a default moves is not a reproduction. The number is written
|
||||
# here, next to the measurement it belongs to.
|
||||
tuning = Tuning(gain=gain, slew_w=1000.0)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
power = [0.0, 0.0]
|
||||
grid = load_w
|
||||
|
||||
@@ -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},
|
||||
@@ -441,9 +449,32 @@ mis_peak = SITE_SIDE[:1] + [Claim.limit("peak", P_PEAK, lo=1500.0,
|
||||
r_mis = resolve(mis_peak)
|
||||
print(f" peak floor misfiled on inverter-b: {r_mis.explain()} "
|
||||
f"scope_errors={r_mis.scope_errors}")
|
||||
check("the mis-routing is CAUGHT: scope_violations names the source and the "
|
||||
"actuator it does not belong on", len(r_mis.scope_errors) == 1
|
||||
and "peak" in r_mis.scope_errors[0])
|
||||
# ⚠️ THE ORDERED PAIR, AND THE CHECK THAT USED TO BE HERE WAS WIDER THAN ITS
|
||||
# OWN CLAIM. It said `scope_violations` "names the source and the actuator it
|
||||
# does not belong on" and then asserted `"peak" in r_mis.scope_errors[0]`.
|
||||
# `arbiter.py:264` builds that sentence as
|
||||
# `f"{c.source}: a SITE claim filed on unit {c.actuator!r} - …"`; transpose the
|
||||
# two - zero bytes, no decision changes, SAFETY-38 census B row B12 - and it
|
||||
# reads "inverter-b: a SITE claim filed on unit 'peak'", naming the misfiled
|
||||
# claim as the actuator and the actuator as the claim. `"peak" in …` is STILL
|
||||
# TRUE under that, so the old line graded neither half of what it claimed.
|
||||
# The operands are read off the CLAIM this test built, not off the message.
|
||||
_mis_c = mis_peak[-1]
|
||||
check("PRECONDITION: the misfiled claim's source and actuator are DIFFERENT "
|
||||
"STRINGS, so a transposition genuinely produces a different sentence",
|
||||
_mis_c.source != _mis_c.actuator and _mis_c.source == "peak"
|
||||
and _mis_c.actuator == "inverter-b")
|
||||
check("the mis-routing is CAUGHT, and scope_violations names the source FIRST "
|
||||
"and the actuator it does not belong on SECOND - the ORDERED PAIR on the "
|
||||
"contiguous literal, because a substring test finds either name under a "
|
||||
"transposition that swaps their roles",
|
||||
len(r_mis.scope_errors) == 1
|
||||
and r_mis.scope_errors[0].startswith(
|
||||
f"{_mis_c.source}: a SITE claim filed on unit {_mis_c.actuator!r}"))
|
||||
check("...and the TRANSPOSED sentence is not what it says, so the check above "
|
||||
"discriminates rather than decorates",
|
||||
f"{_mis_c.actuator}: a SITE claim filed on unit {_mis_c.source!r}"
|
||||
not in r_mis.scope_errors[0])
|
||||
check("...and the reason it must be caught, measured rather than argued: with "
|
||||
"the floor on a unit the SITE has no floor, so the loop charges 1500 W "
|
||||
"into the quarter the guard was trying to shave",
|
||||
|
||||
@@ -78,7 +78,8 @@ import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, ClampWatchdog, clamp_to_rating,
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
|
||||
clamp_to_rating,
|
||||
is_commandable,
|
||||
)
|
||||
from app.allocator import Allocation, UnitPlan, plan
|
||||
@@ -239,11 +240,112 @@ print(f" pinned on the rail for {QUARTER_S:.0f} s at 1 s cycles: "
|
||||
check(f"a loop pinned for a whole billing quarter speaks at least "
|
||||
f"{MIN_PER_QUARTER} times (it spoke {alarms})",
|
||||
alarms >= MIN_PER_QUARTER)
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it is the SAME DEFECT as the one recorded
|
||||
# twenty lines above, one notch weaker. The sweep above runs i = 0..899 and the
|
||||
# rising edge at i=0 is FREE, so it measures `1 + floor(899 / REASSERT_S)` - one
|
||||
# alarm more than a real quarter earns. Measured on this very check:
|
||||
# REASSERT_S = 180 -> 5 alarms, green (and 180 is the true edge: on the worst
|
||||
# REASSERT_S = 224 -> 5 alarms, GREEN sliding 900 s window 180 gives 5 and
|
||||
# REASSERT_S = 225 -> 4 alarms, red 181 gives 4)
|
||||
# So the check as written permits 224 s while the REQUIREMENT permits 180 s, and
|
||||
# anyone raising the constant to 200 s would get a green suite and conclude the
|
||||
# floor was met when a real quarter would earn 4 alarms. Grade the cadence
|
||||
# DIRECTLY, so the check enforces the ceiling its own comment claims for it.
|
||||
check(f"...and the CADENCE itself honours the floor, independent of where the "
|
||||
f"sweep happens to start: floor({QUARTER_S:.0f}/{REASSERT_S:.0f}) = "
|
||||
f"{int(QUARTER_S // REASSERT_S)} >= {MIN_PER_QUARTER}",
|
||||
QUARTER_S // REASSERT_S >= MIN_PER_QUARTER)
|
||||
check(f"...and on no more than {MAX_FRACTION:.0%} of cycles (it was "
|
||||
f"{alarms / QUARTER_S:.1%})", alarms <= MAX_FRACTION * QUARTER_S)
|
||||
check("...while every single cycle is still counted",
|
||||
pinned.engagements == int(QUARTER_S))
|
||||
|
||||
# --- THE SCATTERED REGIME, WHICH IS THE ONE THE OWNER ACTUALLY HIT -----------
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it falsified the first version of this card.
|
||||
# The rising edge used to fire UNCONDITIONALLY, so REASSERT_S governed only the
|
||||
# still-engaged path. A condition that engages and clears repeatedly therefore
|
||||
# raised one notification PER EPISODE at any value of the constant.
|
||||
#
|
||||
# That is what the live install produced: 48 engagements in ~25 minutes,
|
||||
# scattered about one cycle in six. At the measured ~5 s cycle the gaps are ~25 s
|
||||
# of clean cycles, past CLEAR_CYCLES x 5 s = 15 s, so the alarm de-asserted
|
||||
# between almost every engagement. Raising REASSERT_S 30 -> 150 removed NONE of
|
||||
# them. The interval now bounds the notification RATE across episodes.
|
||||
#
|
||||
# The stimulus is written in wall-clock seconds and in the shape the field
|
||||
# actually produced, so it does not move when the constant does.
|
||||
SCATTER_CYCLE_S = 5.0
|
||||
SCATTER_ONE_IN = 6
|
||||
SCATTER_CYCLES = 305
|
||||
|
||||
scat = ClampWatchdog()
|
||||
scat_alarms = sum(1 for i in range(SCATTER_CYCLES)
|
||||
if scat.note(i % SCATTER_ONE_IN == 0, "scattered",
|
||||
T0 + timedelta(seconds=SCATTER_CYCLE_S * i)))
|
||||
_scat_span = SCATTER_CYCLE_S * SCATTER_CYCLES
|
||||
print(f" scattered 1-in-{SCATTER_ONE_IN} over {_scat_span:.0f} s at "
|
||||
f"{SCATTER_CYCLE_S:.0f} s cycles: {scat.episodes} episodes, "
|
||||
f"{scat.engagements} engagements, {scat_alarms} alarm(s)")
|
||||
check(f"a condition that de-asserts between engagements is rate-limited ACROSS "
|
||||
f"episodes, not once per episode ({scat_alarms} alarms for "
|
||||
f"{scat.episodes} episodes)",
|
||||
scat_alarms < scat.episodes)
|
||||
check(f"...and the rate honours the same interval the pinned case does: at most "
|
||||
f"ceil({_scat_span:.0f}/{REASSERT_S:.0f}) + 1 = "
|
||||
f"{int(_scat_span // REASSERT_S) + 1}",
|
||||
scat_alarms <= int(_scat_span // REASSERT_S) + 1)
|
||||
check("...while EVERY episode and EVERY engagement is still counted - the "
|
||||
"budget is on notifications, never on evidence",
|
||||
scat.episodes == scat.engagements == SCATTER_CYCLES // SCATTER_ONE_IN + 1)
|
||||
# ⚠️ THE COUNTERFACTUAL, AND THE FIRST VERSION OF IT COULD NOT FAIL FOR THE
|
||||
# DEFECT IT NAMED. It read `ClampWatchdog().note(True, ...) is True` - a FRESH
|
||||
# object, so it only ever exercised the `last_alarm is None` branch, which is the
|
||||
# trivially-true one. A fresh watchdog has no earlier episode to be held back BY.
|
||||
# That is the third instance in this file of the family recorded at line 216 and
|
||||
# at the 224 s sweep, and it was guarding the most safety-critical property in
|
||||
# the change. Review found it.
|
||||
#
|
||||
# The stimulus must therefore SPEND THE BUDGET FIRST: a benign one-cycle clamp,
|
||||
# a gap long enough to de-assert, then a continuous runaway. Without the
|
||||
# escalation clause the runaway's first notification is held for up to
|
||||
# REASSERT_S - measured at 140 s before it was added.
|
||||
_esc = ClampWatchdog()
|
||||
_esc.note(True, "benign", T0) # spends the budget
|
||||
for _i in range(1, 1 + CLEAR_CYCLES + 1): # de-assert
|
||||
_esc.note(False, "", T0 + timedelta(seconds=5.0 * _i))
|
||||
_RUNAWAY_AT = 5.0 * (CLEAR_CYCLES + 2)
|
||||
_esc_fires = [5.0 * (CLEAR_CYCLES + 2 + _j)
|
||||
for _j in range(200)
|
||||
if _esc.note(True, "runaway",
|
||||
T0 + timedelta(seconds=5.0 * (CLEAR_CYCLES + 2 + _j)))]
|
||||
_esc_delay = _esc_fires[0] - _RUNAWAY_AT if _esc_fires else None
|
||||
print(f" budget spent by a benign episode, then a continuous runaway from "
|
||||
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={_esc_fires[0]:.0f}s "
|
||||
f"(delay {_esc_delay:.0f}s)")
|
||||
check(f"⚠️ a SUSTAINED runaway speaks even when an earlier, unrelated episode "
|
||||
f"spent the budget - it is not held for REASSERT_S ({_esc_delay:.0f}s "
|
||||
f"delay, escalation is {SUSTAINED_CYCLES} cycles)",
|
||||
_esc_delay is not None and _esc_delay < REASSERT_S / 2)
|
||||
check("...and it speaks at the escalation boundary rather than immediately, "
|
||||
"which is what keeps the scattered flood suppressed",
|
||||
_esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
|
||||
|
||||
# ⚠️ `last_alarm` SURVIVING THE DE-ASSERT IS NOW LOAD-BEARING, AND NOTHING
|
||||
# PINNED IT. Review built the mutation: if the de-assert path ALSO refreshed
|
||||
# `last_alarm = now`, the chatter case collapses from 50 notifications to 1 - the
|
||||
# alarm never fires again. That is the "alarm that never fires" class, which is a
|
||||
# different bug from one that fires later, and the suite was green on it.
|
||||
_starve = ClampWatchdog()
|
||||
_starve_fires = sum(1 for _i in range(1600)
|
||||
if _starve.note(_i % 4 == 0, "chatter",
|
||||
T0 + timedelta(seconds=5.0 * _i)))
|
||||
print(f" chatter 1-engaged/{CLEAR_CYCLES}-clean over 8000 s: "
|
||||
f"{_starve.episodes} episodes, {_starve_fires} notification(s)")
|
||||
check(f"a de-assert does not RESET the notification budget, so repeated chatter "
|
||||
f"cannot starve the alarm to silence ({_starve_fires} notifications over "
|
||||
f"8000 s, not 1)",
|
||||
_starve_fires > 10)
|
||||
|
||||
# --- THE HYSTERESIS, PINNED AGAINST THE CHATTER DIRECTION --------------------
|
||||
# ⚠️ REVIEW-FOUND. This used to issue `range(CLEAR_CYCLES - 1)` clean cycles,
|
||||
# which at CLEAR_CYCLES = 1 issues ZERO of them and passes while printing
|
||||
@@ -898,6 +1000,105 @@ check("...and main.py:856's consumer is the line that goes quietly false",
|
||||
bool(ctl_w.clamp_last and ctl_w.clamp_last.engaged))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 6d. SAFETY-38 round 4 - THE ORDERED PAIR INSIDE `ClampResult.describe()`
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THIS IS THE ALARM THE OWNER HAS BEEN COMPLAINING ABOUT ALL DAY, AND UNTIL
|
||||
# THIS BLOCK NO test_*.py MENTIONED `describe()` AT ALL. `clamp.py:253` builds
|
||||
# the whole §15 action-log row and the whole ClampWatchdog notification reason
|
||||
# out of its own attributes:
|
||||
#
|
||||
# f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W was
|
||||
# clamped to {self.applied_w:+.0f} W by the {self.bound} bound
|
||||
# (rating {self.limit_w:.0f} W) - something upstream is winding"
|
||||
#
|
||||
# Transpose `requested_w` and `applied_w` and the guard reports clamping a
|
||||
# setpoint UPWARD - here, to +9999 W by a bound whose rating the SAME SENTENCE
|
||||
# gives as 2000 W. MEASURED on this tree: a zero-byte edit (22 507 bytes before,
|
||||
# 22 507 after, sha256 0153133741497fa5 -> 47e4e371043e3201), the emitted
|
||||
# sentence changes, and the full 21-file suite stayed GREEN. Nothing decides on
|
||||
# the order, so nothing behavioural can ever see it - which is why the check
|
||||
# below has to be on the ORDERED PAIR and on the CONTIGUOUS LITERAL. A substring
|
||||
# test for either number passes under the swap; both numbers are still there.
|
||||
#
|
||||
# ⚠️ AND THE OPERANDS ARE READ OFF THE PLANT, NOT OFF `clamp_last`. `ctl.target`
|
||||
# is what the loop asked for and `hass.writes[-1]` is what the device was
|
||||
# actually sent; neither is a `ClampResult` field. Grading the sentence against
|
||||
# `result.requested_w` would compare the swap against itself - DEV-02's defect,
|
||||
# and the same one that made the first cut of this card's own render probe emit
|
||||
# thirteen empty diffs.
|
||||
|
||||
|
||||
async def _sentence(max_w, dev, wanted):
|
||||
"""One real write over `max_w`, then the guard, on a real Controller."""
|
||||
hass = _Hass(0.0)
|
||||
c = Controller({"setpoint_entity": "number.sp", "meter_source": "off",
|
||||
"max_w": max_w},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -dev, dev
|
||||
c.target = wanted
|
||||
arm(c, wanted)
|
||||
await c.write_setpoint(wanted)
|
||||
await c.check_output_clamp()
|
||||
return hass, c
|
||||
|
||||
|
||||
hass_x, ctl_x = _sentence_run = asyncio.run(_sentence(2000.0, 5000.0, 9999.0))
|
||||
notes_x = clamp_notes(hass_x)
|
||||
_asked_x = ctl_x.target # what the loop asked for
|
||||
_sent_x = hass_x.writes[-1] # what the device was actually sent
|
||||
_rating_x = ctl_x.inverters[0].max_w # the bound the sentence names
|
||||
_rows_x = [e for e in ctl_x.events if REASON_CODE in e]
|
||||
print(f" max_w {_rating_x:.0f} on a +-5000 W device, asked for "
|
||||
f"{_asked_x:+.0f} W: the device saw {_sent_x:+.0f} W")
|
||||
print(f" action log: {_rows_x[0] if _rows_x else '(none)'}")
|
||||
check("PRECONDITION: the write really clamped on the MAX_W bound, and the two "
|
||||
"operands are DIFFERENT NUMBERS that RENDER differently - equal ones "
|
||||
"would make the transposed sentence identical and every check below "
|
||||
"vacuous. The asked-for magnitude is also strictly ABOVE the rating "
|
||||
"while the sent one is not, which is what makes the swapped sentence "
|
||||
"self-contradictory rather than merely wrong",
|
||||
ctl_x.clamp_last is not None and ctl_x.clamp_last.engaged
|
||||
and ctl_x.clamp_last.bound == "max_w"
|
||||
and _asked_x != _sent_x
|
||||
and f"{_asked_x:+.0f}" != f"{_sent_x:+.0f}"
|
||||
and abs(_sent_x) <= _rating_x < abs(_asked_x))
|
||||
check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
|
||||
"contiguous literal names the setpoint the loop COMPUTED first and the "
|
||||
"one that REACHED THE WIRE second. Under the transposition this row says "
|
||||
"the loop asked for +2000 W and the guard clamped it UP to +9999 W - "
|
||||
"past the 2000 W rating printed four words later - which is the alarm "
|
||||
"reading backwards on the one line an operator is woken by",
|
||||
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in e for e in _rows_x))
|
||||
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION REASON (main.py:2452), "
|
||||
"which is a SECOND surface off the same sentence and has to be pinned "
|
||||
"separately - SAFETY-38 round 3's whole lesson was that pinning one call "
|
||||
"site leaves the other shipping",
|
||||
bool(notes_x)
|
||||
and f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in notes_x[0]["message"])
|
||||
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
|
||||
"checks above discriminate rather than decorate",
|
||||
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" in e for e in _rows_x)
|
||||
and f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" not in notes_x[0]["message"])
|
||||
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
|
||||
# unit of coverage is the call site, and `bound` is the only thing that differs
|
||||
# between the two rows an operator can receive.
|
||||
_asked_w, _sent_w = ctl_w.target, hass_w.writes[-1]
|
||||
check("...and the same ordered pair holds on the DEVICE-bound row, where the "
|
||||
"loop asked for +3000 W and the device took +2500 W. PRECONDITION: those "
|
||||
"two also render differently",
|
||||
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
|
||||
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
|
||||
and f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in notes_w[0]["message"])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("7. SAFETY-28: the write path is gated on the allocation record")
|
||||
|
||||
@@ -17,9 +17,19 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import sys
|
||||
|
||||
from app.control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from app.control import (LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC,
|
||||
Tuning, compute, slew_limit_w,
|
||||
maintenance_charge_floor, peak_at_risk)
|
||||
|
||||
T = Tuning()
|
||||
# ⚠️ SAFETY-39: `slew_w` IS EXPLICIT HERE NOW, AND IT IS release/1.0's VALUE.
|
||||
# `Tuning()`'s default became 0 - "follow the rail", `SLEW_RAIL_FRAC * max_w`,
|
||||
# 222 W against this 2000 W rail - so a bare `Tuning()` here would silently
|
||||
# re-tune every assertion below that the slew limit touches, and several of them
|
||||
# assert on a number the slew produces. The pair written here (max_w 2000,
|
||||
# slew_w 1000) is exactly what release/1.0 shipped, which is what most of this
|
||||
# file is a regression test against. The new default gets its own section at the
|
||||
# end, where it is the subject rather than a hidden parameter.
|
||||
T = Tuning(slew_w=1000.0)
|
||||
fails = []
|
||||
|
||||
|
||||
@@ -49,6 +59,13 @@ def check(name, cond):
|
||||
# A comment cannot go stale-green, because it never claims to be running.
|
||||
#
|
||||
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
|
||||
# ⚠️ THE SLEW ROWS WERE RE-MEASURED AT SAFETY-39 AND ONE OF THEM MOVED: the
|
||||
# slew limit was 4 and is now 5, because the SAFETY-39 section grades it too.
|
||||
# The new row is the RAIL DEFAULT specifically - `slew_limit_w()` reduced to
|
||||
# `return abs(float(tuning.slew_w))`, which leaves the limit intact and only
|
||||
# removes the 0-means-the-rail resolution. Three checks notice, which is over
|
||||
# the invariant's floor of two. Both figures were reproduced by deleting the
|
||||
# mechanism, running this file and counting, on a tree with __pycache__ purged.
|
||||
#
|
||||
# mechanism in compute() checks that fail when deleted
|
||||
# ------------------------------------------ -----------------------------
|
||||
@@ -56,7 +73,8 @@ def check(name, cond):
|
||||
# integrator clamp (AC 1) 6
|
||||
# integrator bound follows max_w 4
|
||||
# output clamp 3
|
||||
# slew limit 4
|
||||
# slew limit 5
|
||||
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
|
||||
# output freeze 2
|
||||
# deadband 5
|
||||
# quantisation 2
|
||||
@@ -328,7 +346,14 @@ def unwind(t):
|
||||
return prev
|
||||
|
||||
|
||||
tight, loose = unwind(Tuning(max_w=2000)), unwind(Tuning(max_w=2000, integrator_max_w=3000))
|
||||
# ⚠️ BOTH FIXTURES CARRY release/1.0's `slew_w` EXPLICITLY (SAFETY-39). The
|
||||
# subject here is `integrator_max_w`, and the figure the check names - 1000 W on
|
||||
# the first cycle after the drop - is `max_w - slew_w`, so it moves with the slew
|
||||
# limit and would stop being the number DOCS.md quotes. Pinning it keeps this a
|
||||
# test about integrator headroom instead of a test about two mechanisms at once;
|
||||
# the shipped default's own unwind is measured in the SAFETY-39 section below.
|
||||
tight = unwind(Tuning(max_w=2000, slew_w=1000.0))
|
||||
loose = unwind(Tuning(max_w=2000, slew_w=1000.0, integrator_max_w=3000))
|
||||
check(f"after saturation ends the command is {tight:.0f} W (<= 1000)", tight <= 1000)
|
||||
check(f"headroom above max_w makes that worse ({loose:.0f} W) - hence the default",
|
||||
loose > tight)
|
||||
@@ -372,7 +397,15 @@ def legacy(prev, grid, actual, t, sat_count):
|
||||
ALIAS = {"i-clamped": "clamped"}
|
||||
diffs = []
|
||||
seen = set()
|
||||
for tune in (Tuning(), Tuning(target_grid_w=-10.0), Tuning(max_w=5000, slew_w=5000)):
|
||||
# ⚠️ `slew_w` IS WRITTEN OUT IN ALL THREE (SAFETY-39). `legacy()` below is a
|
||||
# TRANSCRIPTION of release/1.0 and reads `t.slew_w` directly, as release/1.0
|
||||
# did; the new law reads `slew_limit_w(t)`, which treats 0 as 'follow the
|
||||
# rail'. The two agree for every non-zero value and differ at 0 BY DESIGN, so
|
||||
# a fixture that left the field at its new default would report the design as
|
||||
# a regression. Fixing it in `legacy()` instead would make this sweep compare
|
||||
# the new law against itself, which is the one thing it must never do.
|
||||
for tune in (Tuning(slew_w=1000.0), Tuning(slew_w=1000.0, target_grid_w=-10.0),
|
||||
Tuning(max_w=5000, slew_w=5000)):
|
||||
for prev in (-2000.0, -500.0, -100.0, 0.0, 100.0, 500.0, 2000.0):
|
||||
for grid in (-6000.0, -1000.0, -500.0, -14.0, 0.0, 14.0, 500.0, 1000.0, 6000.0):
|
||||
for actual in (-2000.0, 0.0, 600.0, 2000.0):
|
||||
@@ -443,6 +476,205 @@ check("worst billed rest point falls from 15 W to under 5 W",
|
||||
compute(500.0, 4.9, 500.0, TB).reason == "deadband"
|
||||
and compute(500.0, 5.0, 500.0, TB).reason != "deadband")
|
||||
|
||||
print("SAFETY-39: the slew limit is a RATIO to the rail, not a number")
|
||||
# ⚠️ WHAT WENT WRONG, IN ONE LINE, BECAUSE THE SHAPE IS THE POINT. `slew_w:
|
||||
# 1000` was derived on 2026-08-22 against a clamp of 1500 W and its stated
|
||||
# justification was that it caps "how far the loop can jump ahead of the
|
||||
# inverter". The live install then ran max_w 5000 with the same 1000, and on
|
||||
# 2026-09-05 11:43-11:46 a real 3.4 kW export drove the setpoint 380 -> 5000 W
|
||||
# in five 2 s ticks, the battery to -5154 W against a 5000 VA inverter, and the
|
||||
# grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken. The rail
|
||||
# moved and the constant did not.
|
||||
#
|
||||
# So what is graded below is the RELATIONSHIP and not a value: at every rail,
|
||||
# the loop takes the plant's own settling time to cross its whole output range.
|
||||
# A check that pinned "555 W" would go stale the same way the comment did.
|
||||
print(f" SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = {SLEW_RAIL_FRAC:.4f}"
|
||||
f" ({1 / SLEW_RAIL_FRAC:.0f} ticks to cross the rail)")
|
||||
check("0 means FOLLOW THE RAIL and resolves to SLEW_RAIL_FRAC * max_w",
|
||||
slew_limit_w(Tuning(max_w=5000.0, slew_w=0.0)) == SLEW_RAIL_FRAC * 5000.0)
|
||||
check("an explicit value is passed through untouched - it is the operator's "
|
||||
"number and nothing here silently reduces it",
|
||||
slew_limit_w(Tuning(max_w=5000.0, slew_w=400.0)) == 400.0)
|
||||
check("the shipped Tuning default is 0, i.e. the rail, so the law carries no "
|
||||
"second constant to go stale beside config.yaml",
|
||||
Tuning().slew_w == 0.0)
|
||||
|
||||
# ⭐ THE RELATIONSHIP ITSELF, over every rail this add-on can be built at: the
|
||||
# single-inverter schema range (100..5000), the live install (5000), and the
|
||||
# roster sums a second inverter produces - 9600 with inverter B's datasheet
|
||||
# figure, 10000 with two of A. ⚠️ 9600 IS NOT A MEASUREMENT: inverter B does not
|
||||
# exist on site yet (SPIKE-04, ~2026-09-12) and 4600 W is its datasheet and
|
||||
# serial, per goodwe/NOTES.md §1.1b. It is here because the ARITHMETIC is what
|
||||
# is being graded, and the arithmetic does not care.
|
||||
_rails = [100.0, 500.0, 1500.0, 2000.0, 5000.0, 9600.0, 10000.0]
|
||||
_ticks = {r: r / slew_limit_w(Tuning(max_w=r)) for r in _rails}
|
||||
print(" ticks to cross the rail at the default, by rail: "
|
||||
+ ", ".join(f"{r:.0f}W={t:.1f}" for r, t in _ticks.items()))
|
||||
check("⭐ AT EVERY RAIL THE DEFAULT TAKES THE SAME NUMBER OF TICKS TO CROSS IT - "
|
||||
"which is PLANT_SETTLE_S at the measured tick. This is the whole of "
|
||||
"SAFETY-39: raising max_w moves the slew limit with it, so the constant "
|
||||
"cannot be left behind by the rail again",
|
||||
all(abs(t - PLANT_SETTLE_S / LOOP_TICK_S) < 1e-9 for t in _ticks.values()))
|
||||
check("...and that is the same as saying the crossing takes the plant's own "
|
||||
f"measured settling time ({PLANT_SETTLE_S:.0f} s) at the "
|
||||
f"{LOOP_TICK_S:.0f} s tick the live install was measured running",
|
||||
all(abs(t * LOOP_TICK_S - PLANT_SETTLE_S) < 1e-9 for t in _ticks.values()))
|
||||
|
||||
# ⭐ VERIFIED BY BREAKING IT. The configuration that produced the 2026-09-05
|
||||
# excursion, put through the same arithmetic: it crosses the rail in 10 s
|
||||
# against a plant that takes 18 s. This is the number validate.py rule 10
|
||||
# refuses on, and it is asserted HERE too so that the law and the gate cannot
|
||||
# drift apart - the rule can only ever refuse what this ratio says is too fast.
|
||||
_broken = Tuning(max_w=5000.0, slew_w=1000.0)
|
||||
_broken_s = 5000.0 / slew_limit_w(_broken) * LOOP_TICK_S
|
||||
print(f" the 2026-09-05 configuration (max_w 5000, slew_w 1000) crosses its "
|
||||
f"rail in {_broken_s:.0f} s against a plant measured at {PLANT_SETTLE_S:.0f} s")
|
||||
check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by "
|
||||
"the ratio rather than by anybody's opinion - test_validate.py's rule 10 "
|
||||
"section refuses exactly this pair",
|
||||
_broken_s < PLANT_SETTLE_S
|
||||
and slew_limit_w(_broken) > SLEW_RAIL_FRAC * _broken.max_w)
|
||||
|
||||
# THE CLOSED LOOP, AND WHAT THIS PLANT IS AND IS NOT.
|
||||
#
|
||||
# ⚠️ THE PLANT BELOW IS MODELLED HERE AND IS NOT THE RIG. admin/goodwe's
|
||||
# `plantsim.Plant` is the battery model this project grades control changes
|
||||
# against, and this repository's CI cannot reach it - that repository is private
|
||||
# and this job's token is minted for this one (see .gitea/workflows/checks.yml).
|
||||
# So the rig evidence lives in `tools/slew_sim.py`, is quoted in the pull
|
||||
# request and in DECISIONS/SAFETY-39-slew-against-the-rail.md, and what runs
|
||||
# HERE is a first-order lag with the time constant that reproduces §5.10's
|
||||
# measured ~18 s to the deadband. test_alloc.py makes the same split for the
|
||||
# same reason and says so in the same words.
|
||||
#
|
||||
# ⚠️ AND IT CANNOT SHOW A BATTERY ABOVE ITS RATING, WHICH IS SAID BEFORE THE
|
||||
# RESULT RATHER THAN AFTER IT. Neither this model nor `plantsim.Plant` lets the
|
||||
# plant exceed `p_max`; both clamp. The -5154 W against a 5000 VA inverter that
|
||||
# the ticket reports is therefore NOT REPRODUCIBLE IN SIMULATION AT ALL, by
|
||||
# construction, and no acceptance criterion here is written on it. What IS
|
||||
# reproducible, and is what is graded, is the loop OVER-ABSORBING and driving
|
||||
# the meter from export into import - the error it then chases.
|
||||
_TAU_S = 6.0 # first-order lag; with the 2 s tick it reaches the
|
||||
# deadband ~18 s after a step, which is §5.10's figure
|
||||
|
||||
|
||||
def _step_run(tuning, export_w, load_w=350.0, phase=5, cycles=90,
|
||||
dt=LOOP_TICK_S):
|
||||
"""One export step onto a converged loop.
|
||||
|
||||
-> (worst import at the meter, peak battery beyond what the site NEEDED,
|
||||
largest setpoint change in one tick).
|
||||
"""
|
||||
import math
|
||||
prev, i_w, sat, actual, pv = 0.0, None, 0, 0.0, 0.0
|
||||
a = 1.0 - math.exp(-dt / _TAU_S)
|
||||
worst_import, peak, biggest, last = -1e9, 0.0, 0.0, None
|
||||
for c in range(cycles):
|
||||
if c == phase:
|
||||
pv = export_w + load_w
|
||||
grid = load_w - pv - actual
|
||||
d = compute(prev, grid, actual, tuning, sat, i_w)
|
||||
if c >= phase and last is not None:
|
||||
biggest = max(biggest, abs(d.target_w - last))
|
||||
last = d.target_w
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
# the plant: a first-order lag, clamped at the rating like the real one
|
||||
actual += (max(-tuning.max_w, min(tuning.max_w, prev)) - actual) * a
|
||||
if c >= phase:
|
||||
worst_import = max(worst_import, load_w - pv - actual)
|
||||
peak = max(peak, abs(actual))
|
||||
return worst_import, peak - export_w, biggest
|
||||
|
||||
|
||||
def _family(tuning):
|
||||
"""The worst case over 51 export steps, 1.0-4.2 kW, at three phases.
|
||||
|
||||
⚠️ A FAMILY AND NOT ONE STEP, AND THAT IS A FINDING RATHER THAN CAUTION.
|
||||
A single run of this loop is not evidence: the deadband, the 10 W
|
||||
quantisation and the phase of the step against the tick move the result by
|
||||
hundreds of watts, non-monotonically, so two adjacent slews can rank either
|
||||
way on one step and the opposite way on the next. Every number below is a
|
||||
worst case over the family, which is how admin/goodwe grades the same
|
||||
quantities (see `config.yaml`'s "1890 closed-loop runs" and "924 runs").
|
||||
"""
|
||||
worst = dict(imp=-1e9, over=-1e9, step=0.0, big=0, runs=0)
|
||||
for export_w in range(1000, 4300, 200):
|
||||
for phase in (5, 6, 7):
|
||||
imp, over, step = _step_run(tuning, float(export_w), phase=phase)
|
||||
worst["runs"] += 1
|
||||
worst["imp"] = max(worst["imp"], imp)
|
||||
worst["over"] = max(worst["over"], over)
|
||||
worst["step"] = max(worst["step"], step)
|
||||
worst["big"] += step >= 900.0
|
||||
return worst
|
||||
|
||||
|
||||
_LIVE = Tuning(max_w=5000.0, slew_w=1000.0) # what ran on 2026-09-05
|
||||
_RAIL = Tuning(max_w=5000.0) # slew_w 0 - follow the rail
|
||||
_INTERIM = Tuning(max_w=5000.0, slew_w=400.0) # applied live 2026-09-05 12:00
|
||||
_SLOWER = Tuning(max_w=5000.0, slew_w=250.0)
|
||||
_GAIN03 = Tuning(max_w=5000.0, gain=0.3)
|
||||
_GAIN02 = Tuning(max_w=5000.0, gain=0.2)
|
||||
_R = {k: _family(t) for k, t in
|
||||
(("live", _LIVE), ("rail", _RAIL), ("interim", _INTERIM),
|
||||
("slower", _SLOWER), ("gain03", _GAIN03), ("gain02", _GAIN02))}
|
||||
print(f" worst case over {_R['live']['runs']} export steps at max_w 5000, "
|
||||
f"{LOOP_TICK_S:.0f} s tick:")
|
||||
for _k, _lab in (("live", "gain 0.6 slew_w 1000 (the 2026-09-05 live pair)"),
|
||||
("rail", "gain 0.6 slew_w 0 (follow the rail)"),
|
||||
("interim", "gain 0.6 slew_w 400 (the live interim)"),
|
||||
("slower", "gain 0.6 slew_w 250"),
|
||||
("gain03", "gain 0.3 slew_w 0"),
|
||||
("gain02", "gain 0.2 slew_w 0")):
|
||||
_w = _R[_k]
|
||||
print(f" {_lab:<48} largest tick step {_w['step']:5.0f} W, "
|
||||
f"{_w['big']:2}/{_w['runs']} runs step >= 900 W, "
|
||||
f"over-absorbed {_w['over']:5.0f} W, meter driven to {_w['imp']:+6.0f} W")
|
||||
|
||||
# ⭐ THE DEFECT, REPRODUCED BEFORE ANYTHING IS CHANGED. The live pair steps the
|
||||
# setpoint by a full 1000 W on nearly every member of the family and drives the
|
||||
# meter from EXPORT into IMPORT - the loop manufacturing the error it then
|
||||
# chases. That is the 2026-09-05 observation and it is what makes this a control
|
||||
# fault rather than a slow loop.
|
||||
check("⭐ REPRODUCED: at max_w 5000 with slew_w 1000 the setpoint steps a full "
|
||||
"1000 W per tick on nearly every step in the family, and the meter is "
|
||||
"driven from export into import",
|
||||
_R["live"]["step"] >= 900.0
|
||||
and _R["live"]["big"] > 0.9 * _R["live"]["runs"]
|
||||
and _R["live"]["imp"] > 200.0)
|
||||
check("⭐ FIXED, ON THE THING THE SLEW ACTUALLY OWNS: following the rail removes "
|
||||
"the >= 900 W step from EVERY member of the family - which is the symptom "
|
||||
"that escalated 2 -> 1 -> 13 -> 43 per 30 min on the morning of the "
|
||||
"2026-09-05 excursion",
|
||||
_R["live"]["big"] > 0 and _R["rail"]["big"] == 0
|
||||
and _R["interim"]["big"] == 0)
|
||||
|
||||
# ⚠️ AND WHAT IT DOES NOT BUY, MEASURED, ASSERTED, AND SAID BEFORE ANYBODY CAN
|
||||
# READ MORE INTO THIS CARD THAN IT DELIVERED. `slew_w` bounds the OUTPUT; the
|
||||
# integrator accumulates `gain * error` per cycle and is bounded only by
|
||||
# `integrator_max_w`. At gain 0.6 into a 3.4 kW error the integrator moves
|
||||
# ~2000 W per tick and reaches a 5000 W rail in under three ticks WHATEVER
|
||||
# `slew_w` is. So the over-absorption is a GAIN term, and slowing the slew does
|
||||
# not reduce it - on this model it gets slightly WORSE, because the command
|
||||
# spends longer away from where it needs to be while the integrator keeps
|
||||
# winding. admin/goodwe's rig, a different plant model, agrees: over-absorption
|
||||
# 990 W at slew 1000 and 1086 W at slew 250, against 1010 W at gain 0.6 and
|
||||
# 863 W at gain 0.3 (tools/slew_sim.py).
|
||||
#
|
||||
# `gain` is therefore the next constant to re-derive. SAFETY-39 measured it and
|
||||
# CHANGED NOTHING: it is a live control-law constant on a running house, and two
|
||||
# of them moving in one release makes the next measurement uninterpretable.
|
||||
check("⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION, AND THIS CHECK IS HERE SO "
|
||||
"THAT NOBODY READS SAFETY-39 AS 'THE OVERSHOOT IS FIXED'. Quartering the "
|
||||
"slew from 1000 to 250 does not reduce the over-absorption at all",
|
||||
_R["slower"]["over"] >= _R["live"]["over"])
|
||||
check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at "
|
||||
"the same slew. That is the measurement behind 'gain is the next constant "
|
||||
"to re-derive', and it is a measurement rather than an opinion",
|
||||
_R["gain03"]["over"] < _R["rail"]["over"]
|
||||
and _R["gain02"]["over"] < _R["gain03"]["over"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -144,9 +144,17 @@ def check(name, cond):
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def tuning(max_w=LIVE_INSTALL_MAX_W):
|
||||
"""The shipped defaults from config.yaml, at the live install's max_w."""
|
||||
return Tuning(gain=0.6, max_w=max_w, slew_w=1000, deadband_w=15,
|
||||
def tuning(max_w=LIVE_INSTALL_MAX_W, slew_w=0):
|
||||
"""The shipped defaults from config.yaml, at the live install's max_w.
|
||||
|
||||
⚠️ `slew_w` IS 0 - "FOLLOW THE RAIL" - AND IS A PARAMETER (SAFETY-39). 0 is
|
||||
what config.yaml ships and resolves to `SLEW_RAIL_FRAC * max_w`, so it moves
|
||||
with `max_w` instead of being a number typed beside it. It has to be
|
||||
overridable because one case below picks an ABSURDLY SMALL rail on purpose,
|
||||
and at that rail a rail-following slew is 2 W per cycle - which would make
|
||||
the slew, rather than the thing under test, the reason nothing fires.
|
||||
"""
|
||||
return Tuning(gain=0.6, max_w=max_w, slew_w=slew_w, deadband_w=15,
|
||||
target_grid_w=-10, step_w=10, saturation_w=500,
|
||||
saturation_cycles=3)
|
||||
|
||||
@@ -327,10 +335,10 @@ check("the capture loaded", len(rows) > 1000)
|
||||
# covers both kWh registers, so a real `unchanged_s` on this data can only be
|
||||
# SHORTER than what is reconstructed here - this replay is the pessimistic
|
||||
# version of the converged case, not a flattering one.
|
||||
def replay(hold_s, max_w, cycle_floor_s=15.0):
|
||||
def replay(hold_s, max_w, cycle_floor_s=15.0, slew_w=0):
|
||||
"""Drive the real control law and the real detector over the capture."""
|
||||
wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s))
|
||||
t = tuning(max_w)
|
||||
t = tuning(max_w, slew_w)
|
||||
target, i_w, sat = 0.0, 0.0, 0
|
||||
prev_pwr, changed_at = None, rows[0]["t"]
|
||||
last_cycle = None
|
||||
@@ -456,7 +464,15 @@ check("...and the shipped law really does park the command there",
|
||||
# converged capture is silent" is satisfied by a detector that is wired to
|
||||
# nothing. Drop the hold to 1 s and the rail to nothing, and the SAME replay
|
||||
# must alarm - so the silence above is the data's doing, not the harness's.
|
||||
wd_hot, _, _, fired_hot = replay(1.0, 20.0)
|
||||
# ⚠️ AND THE SLEW IS PINNED FOR THIS CASE ONLY, WHICH IS THE OTHER HALF OF
|
||||
# MAKING BOTH TERMS REACHABLE (SAFETY-39). The rail here is 20 W, chosen absurdly
|
||||
# small so the rail term cannot be what holds the alarm off. At the shipped
|
||||
# `slew_w: 0` the limit follows that rail too - `SLEW_RAIL_FRAC * 20` is 2.2 W
|
||||
# per cycle - so the command could not reach 19 W inside the capture and the
|
||||
# replay would go silent for a reason that has nothing to do with the detector.
|
||||
# That is exactly the vacuous pass this case exists to rule out, arriving
|
||||
# through the case itself, so the slew is written out here and nowhere else.
|
||||
wd_hot, _, _, fired_hot = replay(1.0, 20.0, slew_w=20.0)
|
||||
check("...and the same replay DOES alarm once both terms are made reachable",
|
||||
fired_hot is not None and wd_hot.episodes > 0)
|
||||
|
||||
@@ -699,7 +715,7 @@ def _opts(**over):
|
||||
o = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": LIVE_INSTALL_MAX_W, "gain": 0.6,
|
||||
"slew_w": 1000, "deadband_w": SHIPPED_DEADBAND_W,
|
||||
"slew_w": 0, "deadband_w": SHIPPED_DEADBAND_W,
|
||||
"target_grid_w": -10, "step_w": 10, "heartbeat_s": 10,
|
||||
"stale_input_s": STALE_INPUT_S}
|
||||
o.update(over)
|
||||
|
||||
@@ -192,14 +192,40 @@ class Hass:
|
||||
|
||||
A_ENT = {"setpoint_entity": "number.sp_a", "soc_entity": "sensor.soc_a",
|
||||
"batt_entity": "sensor.batt_a"}
|
||||
# ⚠️ A HYPOTHESIS, NOT A MEASUREMENT. Inverter B does not exist; 3000 W is
|
||||
# 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.
|
||||
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
|
||||
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
|
||||
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
|
||||
# kept because the only property these checks consume is that B's number is
|
||||
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
|
||||
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
|
||||
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
|
||||
# onto the real values would cost these checks their separating power to buy
|
||||
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
|
||||
# placeholder for it either - the placeholder era ended on 2026-09-04.
|
||||
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
|
||||
|
||||
# ⚠️ `slew_w` IS 0 HERE, AND IT USED TO BE 1000 - SAFETY-39, AND THE WAY THIS
|
||||
# FIXTURE FOUND OUT IS THE ARGUMENT FOR THE RULE. This dict pairs `slew_w: 1000`
|
||||
# with `max_w: 5000` because both were typed when the flat 5000 W install was
|
||||
# new, and that pair is EXACTLY the configuration measured on the owner's system
|
||||
# on 2026-09-05 driving the meter from 3.4 kW of export to 2.8 kW of import. The
|
||||
# fixture was not wrong when it was written and nothing in this file noticed it
|
||||
# had become so; validate.py rule 10 refused it the day it existed, which is
|
||||
# what a rule about a relationship is for. 0 means "follow the rail"
|
||||
# (`control.SLEW_RAIL_FRAC * max_w`) and is what config.yaml ships.
|
||||
BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid",
|
||||
max_w=A_RATING, gain=0.6, slew_w=1000, deadband_w=15,
|
||||
max_w=A_RATING, gain=0.6, slew_w=0, deadband_w=15,
|
||||
target_grid_w=0, step_w=10, heartbeat_s=10, stale_input_s=15)
|
||||
|
||||
|
||||
@@ -572,6 +598,58 @@ check("...and a store that only fails to persist a DEFAULT is not reported as "
|
||||
"dismiss before the real one arrives",
|
||||
migrate_unit_keys(LosesTheLatch(), UNIT_A).ok is True)
|
||||
|
||||
# ⚠️ AND THE MARKER GATE, ON A STORE WHERE SOMETHING WAS ALREADY NAMESPACED.
|
||||
# FOUND BY MAINT-05's ROUND-3 BATTERY, AND IT WAS A SURVIVOR OF ALL 19 FILES.
|
||||
# `migrate_unit_keys()` gates `SCHEMA_KEY` on `rep.ok`, and every check above
|
||||
# reaches that gate on a store where NOTHING was already under `unit/...` - so
|
||||
# `rep.kept` is empty in all of them, and the mutation
|
||||
#
|
||||
# if rep.ok: -> if rep.ok or rep.kept:
|
||||
#
|
||||
# stamped the marker on a migration that DROPPED THE LATCH, with the whole suite
|
||||
# green. That is a disarmed safety guard plus a store that says the migration
|
||||
# finished, so the next start skips the one thing that would have re-armed it -
|
||||
# DEV-02's own worst case with the paper trail filed under "done".
|
||||
#
|
||||
# The fixture is the one shape that separates them: `sign_reason` ALREADY
|
||||
# namespaced (so `kept` is non-empty and the mutant's `or` fires) and the
|
||||
# `sign_tripped: true` write dropped on the floor (so `lost` is non-empty and
|
||||
# `ok` is False). `migrate_unit_keys()` is passed no roster, so `misdirected`
|
||||
# cannot be what refuses this - it is the `lost` guard or nothing.
|
||||
_kept_reason = "SIGN_DISAGREEMENT: already namespaced by a later version"
|
||||
_broken_kept = LosesTheLatch({
|
||||
"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: measured",
|
||||
unit_key(UNIT_A, "sign_reason"): _kept_reason})
|
||||
_rep_kept = migrate_unit_keys(_broken_kept, UNIT_A)
|
||||
print(f" a dropped latch write beside an already-namespaced reason: "
|
||||
f"{_rep_kept.explain()}")
|
||||
print(f" lost={len(_rep_kept.lost)} kept={len(_rep_kept.kept)} "
|
||||
f"ok={_rep_kept.ok}; marker "
|
||||
f"{_broken_kept.data.get(SCHEMA_KEY, '<<absent>>')!r}")
|
||||
check("PRECONDITION: this fixture reaches the marker gate through the `lost` "
|
||||
"guard with a NON-EMPTY `kept`, which is the combination none of the "
|
||||
"checks above has. One key dropped, one key left alone, `ok` False. If a "
|
||||
"later edit empties `kept` here the mutation goes invisible again, so the "
|
||||
"shape is asserted rather than assumed",
|
||||
_rep_kept.ran and len(_rep_kept.lost) == 1 and len(_rep_kept.kept) == 1
|
||||
and not _rep_kept.ok
|
||||
and _broken_kept.data[unit_key(UNIT_A, "sign_reason")] == _kept_reason)
|
||||
check("⚠️ AND THE STORE IS NOT MARKED MIGRATED. The key that was ALREADY there "
|
||||
"does not buy a marker for the latch that was not: stamping here would "
|
||||
"tell the next start that the migration which dropped `sign_tripped` had "
|
||||
"finished, so it would never run again and the guard would stay off on an "
|
||||
"install whose file still reads `sign_tripped: true`",
|
||||
SCHEMA_KEY not in _broken_kept.data)
|
||||
check("...and the counterfactual, one property different: the SAME store shape "
|
||||
"on a store that does not drop writes IS marked migrated. So the refusal "
|
||||
"above is about the latch that was lost, not about `kept` being non-empty",
|
||||
migrate_unit_keys(
|
||||
MemStore({"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: measured",
|
||||
unit_key(UNIT_A, "sign_reason"): _kept_reason}),
|
||||
UNIT_A).ok is True)
|
||||
|
||||
_broken2 = LosesTheLatch({"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: measured"})
|
||||
_c2d, _ = built(store=_broken2)
|
||||
@@ -609,28 +687,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 +771,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 +795,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 +840,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 +1437,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 +1522,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 +1616,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=[
|
||||
@@ -1594,11 +1717,28 @@ check("⚠️ RULE 5 NAMES THE UNIT THE NUMBER CAME OFF. The weakest cap is "
|
||||
"is inverter-B. The refusal says inverter-c",
|
||||
_why(_p_skew, "max_w (inverter-c) is 1000 W")
|
||||
and not _why(_p_skew, "max_w (inverter-b) is 1000 W"))
|
||||
check("⚠️ RULE 6 TOO. `caps[0]` is the first entry WITH a usable cap - "
|
||||
"inverter-b - while `_unit_label(opts, 0)` was hard-wired to inverter-a, "
|
||||
"so the maintenance refusal quoted B's rating under A's name",
|
||||
_why(_p_skew, "max_w (inverter-b) is 2000 W")
|
||||
and not _why(_p_skew, "max_w (inverter-a) is 2000 W"))
|
||||
# ⚠️ MAINT-05 MOVED RULE 6's SUBJECT, AND THIS CHECK MOVED WITH IT. It used to
|
||||
# assert the refusal named inverter-b, because rule 6 graded the FIRST entry
|
||||
# with a usable cap - `Controller.maint_unit` was `inverters[0]` and the shaping
|
||||
# claim could be filed nowhere else. There is now one `Maintenance` per roster
|
||||
# entry, so the shaping claim can be filed on ANY pack and the cap that
|
||||
# contradicts it first is the SMALLEST - inverter-c's 1000 W. The property this
|
||||
# check exists for is unchanged and is the reason it is still here: the index
|
||||
# has to travel with the cap, or the message quotes one unit's rating under
|
||||
# another unit's name.
|
||||
check("⚠️ RULE 6 TOO, AND SINCE MAINT-05 IT IS THE WEAKEST PACK IT NAMES. "
|
||||
"inverter-c's 1000 W sits at caps index 1, and `_unit_label(opts, 1)` is "
|
||||
"inverter-B - so a caps index leaking into a roster index would say "
|
||||
"inverter-b here. The refusal says inverter-c, with inverter-c's number",
|
||||
_why(_p_skew, "maintenance_charge_w is 3000 W but max_w (inverter-c) is "
|
||||
"1000 W")
|
||||
and not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
|
||||
"(inverter-b)"))
|
||||
check("...and it is no longer graded against the FIRST usable cap at all, which "
|
||||
"is the behaviour change MAINT-05 makes here: inverter-b's 2000 W was the "
|
||||
"pre-card answer and appears nowhere in the maintenance refusal now",
|
||||
not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
|
||||
"(inverter-b) is 2000 W"))
|
||||
check("...and the counterfactual, so this is not a check that would pass on a "
|
||||
"validator that named nothing: the OLD expressions still evaluate, on "
|
||||
"this same config, to the wrong names",
|
||||
@@ -2097,6 +2237,16 @@ MUTATIONS = [
|
||||
("validate.py allowing two roster entries with the SAME name",
|
||||
"part 10: 'two entries with the SAME name are refused' - one Store "
|
||||
"namespace for two units is DEV-02's defect rebuilt in YAML"),
|
||||
("`migrate_unit_keys()`'s marker gate `if rep.ok:` -> `if rep.ok or "
|
||||
"rep.kept:` - the schema marker stamped on a migration that DROPPED THE "
|
||||
"LATCH, because one key happened to be namespaced already",
|
||||
"part 10: 'AND THE STORE IS NOT MARKED MIGRATED'. ⚠️ THIS ONE SURVIVED "
|
||||
"ALL 19 FILES and was found by MAINT-05's round-3 battery, not by this "
|
||||
"card: every migration check here reached the gate on a store where "
|
||||
"NOTHING was namespaced yet, so `rep.kept` was empty in all of them and "
|
||||
"the `or` never fired. Stamping there disarms the latch AND tells the "
|
||||
"next start the migration finished, so it never re-arms - DEV-02's own "
|
||||
"worst case filed under 'done'"),
|
||||
]
|
||||
# ⚠️ FOUR OF THESE SURVIVED THE FIRST TIME AND THE CHECKS WERE CHANGED, NOT THE
|
||||
# TABLE - two in DEV-02, two more in CFG-04, and the CFG-04 pair are recorded in
|
||||
@@ -2115,7 +2265,7 @@ for what, caught in MUTATIONS:
|
||||
check(f"all {len(MUTATIONS)} defeats of this card are caught by a named check "
|
||||
f"above. A guard whose checks survive its own defeat is not a guard, and "
|
||||
f"this repository has three separate records of exactly that",
|
||||
len(MUTATIONS) == 34)
|
||||
len(MUTATIONS) == 35)
|
||||
|
||||
|
||||
print()
|
||||
|
||||
@@ -94,7 +94,7 @@ class _Pub:
|
||||
|
||||
BASE = {"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, "slew_w": 1000,
|
||||
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 0,
|
||||
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
|
||||
"heartbeat_s": 10, "stale_input_s": 15}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,800 @@
|
||||
"""Runnable check for TEL-07 - the SECOND MQTT device, and A's history.
|
||||
`python3 test_mqtt.py`
|
||||
|
||||
WHAT THIS FILE IS FOR, in one sentence: to prove by DIFFING THE ANNOUNCED
|
||||
PAYLOADS - not by reading the code - that giving inverter B its own MQTT device
|
||||
changes nothing about inverter A's.
|
||||
|
||||
⚠️ WHY THAT IS THE WHOLE CARD. `app/mqtt.py`'s own note records the finding:
|
||||
Home Assistant composes an entity_id from the DEVICE name plus the ENTITY name,
|
||||
and `object_id` in the discovery payload did NOT override it (tested on HA
|
||||
2026.8). So re-scoping the device renames every entity - and because HA keeps
|
||||
the id it composed at FIRST discovery, what a rename actually produces is a NEW
|
||||
entity beside a DEAD one. The recorded history stays behind on the old id, and
|
||||
every dashboard, template and automation that names it - including the three
|
||||
live watchdog automations `goodwe_master_watchdog_held_*` and
|
||||
`goodwe_master_parked_at_0_w_*` - goes on pointing at an entity that has quietly
|
||||
stopped updating. Nothing errors. That is the failure this file exists to make
|
||||
impossible to ship.
|
||||
|
||||
⚠️ AND THE TEMPTING DESIGN IS THE DESTRUCTIVE ONE. "One device per inverter" -
|
||||
A becomes `inverter-a`, B becomes `inverter-b` - is symmetrical and tidy and is
|
||||
exactly the change described above. A KEEPS THE DEVICE IT ALREADY HAS; only B
|
||||
gets a new one. Part 8 below writes that tidy version and shows it going red.
|
||||
|
||||
THE SHAPE OF THE EVIDENCE. Part 1 records what `release/1.0` actually publishes -
|
||||
generated by running THAT commit's module, not transcribed - and every later
|
||||
part is a diff against it, reported as `N topics, M missing, C changed, A added`.
|
||||
|
||||
Part 1 The recorded baseline, and the diff at a roster of one and of none.
|
||||
Part 2 `_announce()` publishes EXACTLY `discovery_payloads()` and nothing
|
||||
else, so parts 1 and 3-8 are diffing what goes on the wire.
|
||||
Part 3 A roster of two: A's nine messages byte-identical, eight added.
|
||||
Part 4 Zero overlap between A's entities and B's - topics, unique_ids,
|
||||
object_ids, state topics, and devices.
|
||||
Part 5 Site-scoped sensors stay site-scoped, at every roster length 1-6.
|
||||
Part 6 The composed entity_ids, modelled INDEPENDENTLY of `app/mqtt.py`, and
|
||||
anchored on the one composed id that was verified against the live
|
||||
system (`sensor.goodwe_p1_sample_age`, mqtt.py's own note).
|
||||
Part 7 ⚠️ THE ANNOUNCEMENT AND THE PUBLICATION AGREE, on a real `Controller`,
|
||||
with the two units carrying DIFFERENT numbers - so a swap is visible.
|
||||
Part 8 ⚠️ VERIFIED BY BREAKING IT. Twelve mutations of the shipped module,
|
||||
re-executed from mutated source, each one named with the property it
|
||||
breaks. A property nothing breaks is a property nothing is watching.
|
||||
Part 9 What these checks CANNOT see, enumerated and sized.
|
||||
|
||||
⚠️ THE FIXTURE GIVES A AND B DIFFERENT NAMES, DIFFERENT RATINGS, DIFFERENT SoC,
|
||||
DIFFERENT BATTERY POWER AND DIFFERENT LATCH STATES, ON PURPOSE. A fixture whose
|
||||
two units are equal cannot observe a bug that swaps them - MUTATION.md habit 4,
|
||||
and the defect this repository has now shipped six cards in a row.
|
||||
|
||||
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every number attributed
|
||||
to it below is SIMULATED, from the datasheet and serial recorded in NOTES.md
|
||||
§1.1b (GW5048D-ES, serial 55048ESU22AW0992, max_w 4600 W). Nothing here is
|
||||
measured, and nothing here should ever be quoted as if it were.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ. Read MUTATION.md.
|
||||
Part 8 sidesteps the whole problem by exec'ing mutated SOURCE into a fresh
|
||||
namespace, so no `.pyc` is involved at any point.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app import mqtt
|
||||
from app.inverter import build_roster, roster_names
|
||||
from app.main import Controller
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
MQTT_SRC = (HERE / "app" / "mqtt.py").read_text(encoding="utf-8")
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The recorded baseline. ⚠️ GENERATED, NOT TRANSCRIBED. Reproduce it with:
|
||||
#
|
||||
# git show release/1.0:goodwe_controller/app/mqtt.py > /tmp/mqtt_base.py
|
||||
# python3 - <<'EOF'
|
||||
# import sys; sys.path.insert(0, "/tmp"); import mqtt_base as M
|
||||
# class F:
|
||||
# def __init__(self): self.m = []
|
||||
# def publish(self, t, p=None, retain=False): self.m.append((t, p, retain))
|
||||
# p = M.MqttPublisher.__new__(M.MqttPublisher)
|
||||
# p.omit, p.client, p.enabled = set(), F(), True
|
||||
# p._announce()
|
||||
# for row in p.client.m: print(row)
|
||||
# EOF
|
||||
#
|
||||
# A transcription would be a check that grades this branch against MY MEMORY of
|
||||
# release/1.0 rather than against release/1.0.
|
||||
# ---------------------------------------------------------------------------
|
||||
_DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", '
|
||||
'"manufacturer": "GoodWe (via RS485 meter emulation)", '
|
||||
'"model": "ES/BP series"}')
|
||||
|
||||
BASELINE = [
|
||||
('homeassistant/sensor/goodwe_ctl_setpoint/config',
|
||||
'{"name": "Setpoint", "object_id": "goodwe_setpoint", "unique_id": "goodwe_ctl_setpoint", '
|
||||
'"state_topic": "goodwe_ctl/setpoint", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_grid/config',
|
||||
'{"name": "Grid power", "object_id": "goodwe_grid_power", "unique_id": "goodwe_ctl_grid", '
|
||||
'"state_topic": "goodwe_ctl/grid", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_battery/config',
|
||||
'{"name": "Battery power", "object_id": "goodwe_battery_power", "unique_id": "goodwe_ctl_battery", '
|
||||
'"state_topic": "goodwe_ctl/battery", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "W", "device_class": "power", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_soc/config',
|
||||
'{"name": "Battery SoC", "object_id": "goodwe_battery_soc", "unique_id": "goodwe_ctl_soc", '
|
||||
'"state_topic": "goodwe_ctl/soc", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "%", "device_class": "battery", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_circulation/config',
|
||||
'{"name": "Circulating power", "object_id": "goodwe_circulating_power", '
|
||||
'"unique_id": "goodwe_ctl_circulation", "state_topic": "goodwe_ctl/circulation", '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", '
|
||||
'"icon": "mdi:sync-alert"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_phase/config',
|
||||
'{"name": "Maintenance phase", "object_id": "goodwe_maintenance_phase", '
|
||||
'"unique_id": "goodwe_ctl_phase", "state_topic": "goodwe_ctl/phase", '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"icon": "mdi:battery-sync"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_status/config',
|
||||
'{"name": "Controller status", "object_id": "goodwe_controller_status", '
|
||||
'"unique_id": "goodwe_ctl_status", "state_topic": "goodwe_ctl/status", '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"icon": "mdi:heart-pulse"}', True),
|
||||
('homeassistant/sensor/goodwe_ctl_p1_age/config',
|
||||
'{"name": "P1 sample age", "object_id": "p1_sample_age_s", "unique_id": "goodwe_ctl_p1_age", '
|
||||
'"state_topic": "goodwe_ctl/p1_age", "availability_topic": "goodwe_ctl/availability", '
|
||||
'"device": ' + _DEV + ', "unit_of_measurement": "s", "device_class": "duration", '
|
||||
'"state_class": "measurement"}', True),
|
||||
('goodwe_ctl/availability', 'online', True),
|
||||
]
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Captures what `_announce()` actually hands paho."""
|
||||
|
||||
def __init__(self):
|
||||
self.msgs = []
|
||||
|
||||
def publish(self, topic, payload=None, retain=False):
|
||||
self.msgs.append((topic, payload, retain))
|
||||
|
||||
|
||||
def announce(units=(), omit=(), module=mqtt):
|
||||
"""The messages `_announce()` publishes, through the SHIPPED method.
|
||||
|
||||
⚠️ Not `discovery_payloads()` directly. Part 2 proves the two agree; every
|
||||
other part goes through this one, so what is diffed is what paho is told.
|
||||
"""
|
||||
pub = module.MqttPublisher.__new__(module.MqttPublisher)
|
||||
pub.omit = set(omit)
|
||||
pub.units = [str(u) for u in units]
|
||||
pub.enabled = True
|
||||
pub.client = FakeClient()
|
||||
pub._announce()
|
||||
return pub.client.msgs
|
||||
|
||||
|
||||
def diff(got, want=BASELINE):
|
||||
"""`(missing, changed, added)` between two announcement lists, by topic."""
|
||||
g, w = dict((t, (p, r)) for t, p, r in got), dict((t, (p, r)) for t, p, r in want)
|
||||
missing = [t for t in w if t not in g]
|
||||
changed = [t for t in w if t in g and g[t] != w[t]]
|
||||
added = [t for t in g if t not in w]
|
||||
return sorted(missing), sorted(changed), sorted(added)
|
||||
|
||||
|
||||
def report(label, got, want=BASELINE):
|
||||
m, c, a = diff(got, want)
|
||||
print(f" {label}: {len(want)} topics, {len(m)} missing, {len(c)} changed, "
|
||||
f"{len(a)} added")
|
||||
for t in m:
|
||||
print(f" MISSING {t}")
|
||||
for t in c:
|
||||
print(f" CHANGED {t}")
|
||||
for t in a:
|
||||
print(f" ADDED {t}")
|
||||
return m, c, a
|
||||
|
||||
|
||||
def cfgs(msgs):
|
||||
"""The discovery payloads, parsed, without the availability message."""
|
||||
return [json.loads(p) for t, p, _ in msgs if t.startswith("homeassistant/")]
|
||||
|
||||
|
||||
# The roster this card exists for. ⚠️ SIMULATED: inverter B does not physically
|
||||
# exist. Its rating is the datasheet's (NOTES.md §1.1b, GW5048D-ES, serial
|
||||
# 55048ESU22AW0992) and has never been measured.
|
||||
OPTS2 = {
|
||||
"meter_source": "off",
|
||||
"inverters": [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.sp_a",
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
||||
"max_w": 2000, "bms_soc_floor": 10},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
|
||||
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
|
||||
"max_w": 4600, "bms_soc_floor": 10},
|
||||
],
|
||||
}
|
||||
NAMES2 = roster_names(OPTS2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n1. the recorded baseline, and a roster of one")
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC 1 and AC 4. ⚠️ THE COMPARISON IS BYTE-FOR-BYTE ON THE PAYLOAD STRING, not
|
||||
# on a parsed dict. `json.dumps` writes keys in insertion order, so a reordered
|
||||
# payload is a different retained message on the broker even though the parsed
|
||||
# dicts compare equal - and a reordered payload is a re-announcement of every
|
||||
# entity on a live install.
|
||||
m, c, a = report("roster of one (`inverters:` empty, i.e. every install today)",
|
||||
announce(units=roster_names({})))
|
||||
check("⭐ AT THE SHIPPED ROSTER OF ONE THE ANNOUNCEMENT IS BYTE-IDENTICAL TO "
|
||||
"release/1.0: same topics, same unique_ids, same payload bytes, same "
|
||||
"retain flags, in the same order - no second device, no renamed entity, "
|
||||
"no added topic",
|
||||
(m, c, a) == ([], [], []) and announce(units=roster_names({})) == BASELINE)
|
||||
|
||||
check("...and the roster-of-one path is the one every install actually reads: "
|
||||
"an empty `inverters:` names exactly one unit",
|
||||
roster_names({}) == ["inverter-a"])
|
||||
|
||||
check("...and a publisher told NOTHING about the roster announces the same "
|
||||
"thing, so a caller that has not been taught about units cannot change "
|
||||
"what a live install sees",
|
||||
announce(units=()) == BASELINE)
|
||||
|
||||
m, c, a = report("roster of one, P1 off (`omit=('p1_age',)`)",
|
||||
announce(units=roster_names({}), omit=("p1_age",)),
|
||||
[row for row in BASELINE if "p1_age" not in row[0]])
|
||||
check("...and the `omit` path is unchanged too: with P1 ingestion off the "
|
||||
"watchdog input is still not announced, and nothing else moved",
|
||||
(m, c, a) == ([], [], []))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n2. what is diffed is what goes on the wire")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ WITHOUT THIS PART, PARTS 1 AND 3-8 GRADE A FUNCTION THE ADD-ON MIGHT NOT
|
||||
# USE. `_announce()` used to build its payloads inline; if it still did, every
|
||||
# diff here would be a diff of a second implementation that happens to sit in
|
||||
# the same file. This is the join.
|
||||
_direct = [(t, json.dumps(cfg), True)
|
||||
for t, cfg in mqtt.discovery_payloads(NAMES2)] + \
|
||||
[(mqtt.AVAILABILITY, "online", True)]
|
||||
check("`_announce()` publishes EXACTLY `discovery_payloads()`, in order, with "
|
||||
"retain set, plus the availability message - so every diff in this file "
|
||||
"is a diff of the bytes paho is handed",
|
||||
announce(units=NAMES2) == _direct)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n3. a roster of two: A untouched, B added")
|
||||
# ---------------------------------------------------------------------------
|
||||
TWO = announce(units=NAMES2)
|
||||
m, c, a = report("roster of two (inverter-a + inverter-b, SIMULATED)", TWO)
|
||||
check("⭐ AC 1: AT A ROSTER OF TWO, EVERY ONE OF A's NINE MESSAGES IS STILL "
|
||||
"BYTE-IDENTICAL - nothing missing, nothing changed. This is the criterion "
|
||||
"the live install's recorded history depends on, and it is a diff of the "
|
||||
"payloads, not a reading of the code",
|
||||
(m, c) == ([], []))
|
||||
check("...and the eight additions are all B's and A's own per-unit rows, and "
|
||||
"all of them are NEW topics", len(a) == 8)
|
||||
check("...and A's nine messages are still the FIRST nine, in the same order, "
|
||||
"so a broker replaying retained messages sees them unchanged",
|
||||
TWO[:len(BASELINE) - 1] == BASELINE[:-1])
|
||||
|
||||
site_ids = {c_["unique_id"] for c_ in cfgs(BASELINE)}
|
||||
unit_cfgs = [c_ for c_ in cfgs(TWO) if c_["unique_id"] not in site_ids]
|
||||
a_cfgs = [c_ for c_ in unit_cfgs if "inverter_a" in c_["unique_id"]]
|
||||
b_cfgs = [c_ for c_ in unit_cfgs if "inverter_b" in c_["unique_id"]]
|
||||
check("A's per-unit rows stay on THE DEVICE A ALREADY HAS - no new device is "
|
||||
"created for the first roster entry, which is the whole asymmetry",
|
||||
len(a_cfgs) == 4
|
||||
and all(c_["device"] == mqtt.DEVICE for c_ in a_cfgs))
|
||||
check("⭐ AC 2: B's rows are on B's OWN device, with its own identifiers",
|
||||
len(b_cfgs) == 4
|
||||
and all(c_["device"]["identifiers"] == ["goodwe_rs485_controller_inverter_b"]
|
||||
for c_ in b_cfgs))
|
||||
check("...and B's device hangs off the controller's via `via_device`, so HA "
|
||||
"shows one controller driving two inverters rather than two strangers",
|
||||
all(c_["device"].get("via_device") == mqtt.DEVICE["identifiers"][0]
|
||||
for c_ in b_cfgs))
|
||||
check("...and B's device carries the SAME manufacturer and model as the site "
|
||||
"device - it is the same installation, not a second product",
|
||||
all(c_["device"]["manufacturer"] == mqtt.DEVICE["manufacturer"]
|
||||
and c_["device"]["model"] == mqtt.DEVICE["model"] for c_ in b_cfgs))
|
||||
|
||||
# A regression pin on the NEW payloads. ⚠️ THIS IS NOT EVIDENCE OF CORRECTNESS -
|
||||
# it is generated from this branch, so it can only say "these bytes have not
|
||||
# changed since the PR was reviewed". It exists because B's payloads have no
|
||||
# released version to be diffed against, and a later edit that quietly changes
|
||||
# a per-unit `device_class` or state topic should have to be deliberate.
|
||||
_pin = hashlib.sha256(
|
||||
json.dumps(unit_cfgs, sort_keys=False).encode()).hexdigest()[:16]
|
||||
print(" the eight per-unit payloads, in announcement order:")
|
||||
for c_ in unit_cfgs:
|
||||
print(f" {c_['unique_id']:<40} {c_['object_id']:<34} "
|
||||
f"{c_['state_topic']}")
|
||||
print(f" regression pin (sha256 of the eight payloads): {_pin}")
|
||||
check("the per-unit payloads are the ones this PR was reviewed with "
|
||||
"(REGRESSION PIN, not a correctness claim - see the comment)",
|
||||
_pin == "eb5022b0455158d7")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n4. zero overlap between A's entities and B's")
|
||||
# ---------------------------------------------------------------------------
|
||||
def field(rows, key):
|
||||
return [r[key] for r in rows]
|
||||
|
||||
|
||||
for key in ("unique_id", "object_id", "state_topic"):
|
||||
av, bv = set(field(a_cfgs, key)), set(field(b_cfgs, key))
|
||||
sv = set(field(cfgs(BASELINE), key))
|
||||
check(f"⭐ AC 2: no `{key}` is shared between A's entities and B's, and "
|
||||
f"none of B's collides with a SITE entity that already exists",
|
||||
len(av) == 4 and len(bv) == 4 and not (av & bv) and not (bv & sv)
|
||||
and not (av & sv))
|
||||
|
||||
all_ids = field(cfgs(TWO), "unique_id")
|
||||
check("...and across the WHOLE announcement every unique_id is distinct - an "
|
||||
"HA discovery collision is not an error, it DROPS the second entity, so "
|
||||
"one inverter's telemetry would silently stop existing",
|
||||
len(all_ids) == len(set(all_ids)) == 16)
|
||||
|
||||
all_topics = [t for t, _, _ in TWO]
|
||||
check("...and so is every topic", len(all_topics) == len(set(all_topics)) == 17)
|
||||
|
||||
# ⚠️ THE SLUG COLLISION rule 9(c) DOES NOT REFUSE. `inverter-b` and `Inverter B`
|
||||
# are two different names by the validator's test and one string once slugged.
|
||||
COLLIDE = announce(units=["inverter-b", "Inverter B"])
|
||||
col_ids = field(cfgs(COLLIDE), "unique_id")
|
||||
print(" two names that slug alike ('inverter-b', 'Inverter B') ->",
|
||||
[c_["unique_id"] for c_ in cfgs(COLLIDE) if c_["unique_id"] not in site_ids])
|
||||
check("two roster names that differ only in case and punctuation - which "
|
||||
"`validate.py` rule 9(c) accepts, because it refuses only EXACT "
|
||||
"duplicates - still get distinct unique_ids",
|
||||
len(col_ids) == len(set(col_ids)))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n5. site-scoped sensors stay site-scoped")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ `p1_sample_age_s` IS A WATCHDOG INPUT (mqtt.py's `omit` note and the ESP32
|
||||
# firmware's ingest guard). A second copy of it is a second answer to "is the
|
||||
# meter fresh?", one of which is always the one nobody is looking at. `grid` is
|
||||
# the same argument without the safety edge: there is ONE grid connection.
|
||||
SITE_ONLY = ("goodwe_ctl_grid", "goodwe_ctl_p1_age", "goodwe_ctl_circulation",
|
||||
"goodwe_ctl_phase", "goodwe_ctl_status")
|
||||
rows = []
|
||||
for n in range(1, 7):
|
||||
names = [f"inverter-{chr(ord('a') + i)}" for i in range(n)]
|
||||
ids = field(cfgs(announce(units=names)), "unique_id")
|
||||
rows.append((n, [ids.count(s) for s in SITE_ONLY], len(ids)))
|
||||
print(f" roster of {n}: {len(ids)} entities, "
|
||||
+ ", ".join(f"{s}x{ids.count(s)}" for s in SITE_ONLY))
|
||||
check("⭐ AC 3: `grid` and `p1_sample_age_s` are announced EXACTLY ONCE at every "
|
||||
"roster length from 1 to 6 - announcing them per inverter would invent "
|
||||
"two of a thing there is one of, and would duplicate a safety signal",
|
||||
all(counts == [1] * len(SITE_ONLY) for _n, counts, _t in rows))
|
||||
check("...and the site set does not grow with the roster while the per-unit "
|
||||
"set does: 8 site entities always, 4 more per unit past the first",
|
||||
[t for _n, _c, t in rows] == [8, 16, 20, 24, 28, 32])
|
||||
|
||||
site_devices = {json.dumps(c_["device"], sort_keys=True)
|
||||
for c_ in cfgs(announce(units=NAMES2))
|
||||
if c_["unique_id"] in site_ids}
|
||||
check("...and every site entity is still on the SITE device, at a roster of two",
|
||||
site_devices == {json.dumps(mqtt.DEVICE, sort_keys=True)})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n6. the composed entity_ids")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ THIS SLUGIFIER IS DELIBERATELY NOT `mqtt._slug`. Importing the module's own
|
||||
# spelling would make the model and the subject move together, so a mutation in
|
||||
# the slugifier would change both sides equally and be invisible - "a check
|
||||
# whose data cannot reach the failure it names", written in one import.
|
||||
def compose(device_name, entity_name):
|
||||
"""HA's entity_id for a discovery payload, per mqtt.py's own note."""
|
||||
s = re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
|
||||
return "sensor." + s.strip("_")
|
||||
|
||||
|
||||
# The ONE anchor there is: mqtt.py records that the live entity is
|
||||
# `sensor.goodwe_p1_sample_age`, verified against the live system 2026-08-26,
|
||||
# and that the firmware subscribes to that composed name. If this model does not
|
||||
# reproduce that id, the model is wrong and every claim below it is worthless.
|
||||
p1 = [c_ for c_ in cfgs(BASELINE) if c_["unique_id"] == "goodwe_ctl_p1_age"][0]
|
||||
check("the model reproduces the ONE composed entity_id that was verified "
|
||||
"against the live system - `sensor.goodwe_p1_sample_age`, which the ESP32 "
|
||||
"ingest guard subscribes to - from the device name and the entity name",
|
||||
compose(mqtt.DEVICE["name"], p1["name"]) == "sensor.goodwe_p1_sample_age")
|
||||
check("...and that id is NOT the `object_id` in the payload "
|
||||
"(`p1_sample_age_s`), which is the whole reason the device name is "
|
||||
"load-bearing and mqtt.py says so in capitals",
|
||||
p1["object_id"] != "goodwe_p1_sample_age")
|
||||
|
||||
composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(TWO)]
|
||||
print(" every composed entity_id at a roster of two:")
|
||||
for c_, eid in zip(cfgs(TWO), composed):
|
||||
print(f" {eid:<46} <- device {c_['device']['name']!r} + "
|
||||
f"name {c_['name']!r}")
|
||||
check("⭐ every composed entity_id at a roster of two is DISTINCT: no per-unit "
|
||||
"entity can land on top of a site entity that already has history",
|
||||
len(composed) == len(set(composed)) == 16)
|
||||
|
||||
base_composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(BASELINE)]
|
||||
check("...and the eight ids that exist today are unchanged by adding B, which "
|
||||
"is AC 1 restated in the units HA actually uses",
|
||||
composed[:8] == base_composed)
|
||||
|
||||
check("...and the per-unit ids come out SYMMETRIC even though the devices are "
|
||||
"not - sensor.goodwe_inverter_a_battery_power beside "
|
||||
"sensor.goodwe_inverter_b_battery_power - which is the entire benefit of "
|
||||
"the 'one device per inverter' design, without its cost",
|
||||
"sensor.goodwe_inverter_a_battery_power" in composed
|
||||
and "sensor.goodwe_inverter_b_battery_power" in composed)
|
||||
|
||||
check("...and for every per-unit entity `object_id` equals the composed id, so "
|
||||
"the two mechanisms agree and an HA that DID honour object_id would "
|
||||
"produce the same ids as one that does not",
|
||||
all("sensor." + c_["object_id"] == compose(c_["device"]["name"], c_["name"])
|
||||
for c_ in unit_cfgs))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n7. the announcement and the publication agree")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ THE HAZARD mqtt.py's `omit` NOTE NAMES: an entity that exists and is never
|
||||
# fed is a worse signal than one that does not exist. So the set of per-unit
|
||||
# topics ANNOUNCED and the set `Controller.publish()` actually writes have to be
|
||||
# the same set - and, because a matching set of KEYS is not a matching set of
|
||||
# VALUES, the two units carry DIFFERENT numbers here so that a swap is red.
|
||||
class _Store:
|
||||
data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
|
||||
class _Pub:
|
||||
def __init__(self):
|
||||
self.last = {}
|
||||
self.units = []
|
||||
|
||||
def publish(self, values):
|
||||
self.last = dict(values)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def controlled(opts, soc, batt, tripped):
|
||||
"""A real Controller with per-unit readings planted. SIMULATED values."""
|
||||
# ⚠️ `stop` is a REQUIRED keyword (CFG-01): a Controller that cannot stop
|
||||
# itself used to be the default one. These cases only call publish(), so any
|
||||
# Event will do - but they may not skip it.
|
||||
pub = _Pub()
|
||||
ctl = Controller(dict(opts), None, _Store(), pub, stop=asyncio.Event())
|
||||
for inv, s, b, t in zip(ctl.inverters, soc, batt, tripped):
|
||||
inv.soc, inv.batt, inv.target = s, b, -1.0 * b
|
||||
inv.sign_wd.tripped = t
|
||||
ctl.publish()
|
||||
return ctl, pub.last
|
||||
|
||||
|
||||
# A 41 / 88 %, A discharging 1200 W, B charging 300 W, and B latched, not A.
|
||||
# Every one of those six numbers differs from its sibling, which is what makes
|
||||
# the assertions below able to see a swap.
|
||||
ctl2, vals2 = controlled(OPTS2, [41.0, 88.0], [1200.0, -300.0], [False, True])
|
||||
announced_unit_topics = {c_["state_topic"] for c_ in unit_cfgs}
|
||||
published_unit_topics = {f"{mqtt.BASE}/{k}" for k in vals2 if k.startswith("unit/")}
|
||||
print(f" announced per-unit topics {len(announced_unit_topics)}, "
|
||||
f"published {len(published_unit_topics)}, "
|
||||
f"announced-but-never-fed {sorted(announced_unit_topics - published_unit_topics)}, "
|
||||
f"fed-but-never-announced {sorted(published_unit_topics - announced_unit_topics)}")
|
||||
check("⭐ every per-unit entity announced is one the controller actually "
|
||||
"publishes to, and every per-unit topic published is one that was "
|
||||
"announced - no entity that nothing feeds, no topic nothing declared",
|
||||
announced_unit_topics == published_unit_topics)
|
||||
|
||||
check("⭐ AND THE VALUES LAND ON THE RIGHT UNIT: A's 41 % on A's topic and B's "
|
||||
"88 % on B's, not the other way round",
|
||||
vals2["unit/inverter_a/soc"] == 41.0
|
||||
and vals2["unit/inverter_b/soc"] == 88.0)
|
||||
check("...and the same for battery power, whose SIGNS differ between the two "
|
||||
"units here (A discharging, B charging) so a swap cannot hide in a "
|
||||
"magnitude",
|
||||
vals2["unit/inverter_a/battery"] == 1200.0
|
||||
and vals2["unit/inverter_b/battery"] == -300.0)
|
||||
check("...and the SAFETY-02 sign latch is per unit: B is latched and A is not, "
|
||||
"and the two rows say so separately",
|
||||
vals2["unit/inverter_a/sign_latch"] == "clear"
|
||||
and vals2["unit/inverter_b/sign_latch"] == "tripped")
|
||||
check("...and each unit's setpoint row is its OWN share, not the site target",
|
||||
vals2["unit/inverter_a/setpoint"] == -1200.0
|
||||
and vals2["unit/inverter_b/setpoint"] == 300.0)
|
||||
|
||||
check("...and the SITE rows still mean what they meant: `soc` is the EMPTIEST "
|
||||
"pack (41 %, not 88 and not the mean) and `battery` is the SUM (900 W), "
|
||||
"so this card added rows beside them and did not re-point them",
|
||||
vals2["soc"] == 41.0 and round(vals2["battery"]) == 900)
|
||||
|
||||
_, vals1 = controlled({"meter_source": "off", "setpoint_entity": "number.sp"},
|
||||
[55.0], [700.0], [False])
|
||||
print(f" roster of one publishes keys: {sorted(vals1)}")
|
||||
check("⭐ AC 4 on the PUBLISH side: at a roster of one not one `unit/...` topic "
|
||||
"is written either, so the live install's broker sees no new topic at all",
|
||||
not [k for k in vals1 if k.startswith("unit/")])
|
||||
|
||||
# --- 7b. the wiring, which nothing above reaches -----------------------------
|
||||
# ⚠️ FOUND BY MUTATION, NOT BY DESIGN. `units=()` in main()'s `MqttPublisher(...)`
|
||||
# call survived every check in this file: parts 1-7 build the publisher
|
||||
# themselves, so a controller publishing eight per-unit topics beside a
|
||||
# publisher announcing none of them was GREEN. That is this repository's
|
||||
# recurring defect - a check whose data cannot reach the failure it names - in
|
||||
# the one line that joins the two halves together.
|
||||
check("`roster_names()` and `build_roster()` give the same names in the same "
|
||||
"order, on every shape - the flat keys, a declared roster, a nameless "
|
||||
"entry and a non-dict entry - so the names the publisher is handed are "
|
||||
"the names the units answer to",
|
||||
all([inv.name for inv in build_roster(o, None)] == roster_names(o)
|
||||
for o in ({}, {"setpoint_entity": "number.sp"}, OPTS2,
|
||||
{"inverters": [{}, {}]},
|
||||
{"inverters": [{"name": "roof west"}, "junk"]})))
|
||||
check("...and a nameless entry gets a POSITIONAL name, not `inverter-a` twice: "
|
||||
"two units under one name share one Store namespace, so one unit's "
|
||||
"SAFETY-02 latch would be the other's",
|
||||
roster_names({"inverters": [{}, {}]}) == ["inverter-1", "inverter-2"])
|
||||
|
||||
_call = [n for n in ast.walk(ast.parse((HERE / "app" / "main.py").read_text()))
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == "MqttPublisher"]
|
||||
_kw = [k for c_ in _call for k in c_.keywords if k.arg == "units"]
|
||||
print(f" main.py builds MqttPublisher {len(_call)} time(s); "
|
||||
f"units= is " + (ast.unparse(_kw[0].value) if _kw else "ABSENT"))
|
||||
check("⚠️ A SOURCE READ, AND THE WEAKEST CHECK IN THIS FILE - said so rather "
|
||||
"than dressed up. main()'s one `MqttPublisher(...)` hands it "
|
||||
"`roster_names(opts)`; nothing else here can reach that line, because "
|
||||
"main() opens a broker, a web server and a control loop",
|
||||
len(_call) == 1 and len(_kw) == 1
|
||||
and ast.unparse(_kw[0].value) == "roster_names(opts)")
|
||||
|
||||
# --- 7c. a name that is not ASCII -------------------------------------------
|
||||
# HA transliterates before it slugs; this module cannot, so it treats anything
|
||||
# outside ASCII as a separator. Either way the id must be an id.
|
||||
_odd = announce(units=["caf\u00e9-a", "caf\u00e9-b"])
|
||||
_odd_unit = [c_ for c_ in cfgs(_odd) if c_["unique_id"] not in site_ids]
|
||||
print(" a roster named with non-ASCII ->",
|
||||
sorted({c_["unique_id"] for c_ in _odd_unit}))
|
||||
check("every unique_id, object_id and state topic this module produces is "
|
||||
"lowercase ASCII, even from a roster name that is not - an id HA would "
|
||||
"not have composed is an entity nothing can subscribe to",
|
||||
_odd_unit
|
||||
and all(re.fullmatch(r"[a-z0-9_]+", c_[k])
|
||||
for c_ in _odd_unit for k in ("unique_id", "object_id"))
|
||||
and all(re.fullmatch(r"[a-z0-9_/]+", c_["state_topic"])
|
||||
for c_ in _odd_unit))
|
||||
|
||||
# The topics the SHIPPED controller actually feeds at a roster of two. Part 8
|
||||
# grades every mutant announcement against this, which is what turns "the
|
||||
# announcement changed" into "the announcement now claims an entity nothing
|
||||
# will ever publish to".
|
||||
FED = set(published_unit_topics)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n8. verified by breaking it")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ MUTATED SOURCE, EXEC'D INTO A FRESH NAMESPACE. No file is edited and no
|
||||
# `.pyc` is consulted, so TEST-15's stale-bytecode failure cannot produce a
|
||||
# false survivor here. `app/mqtt.py` imports nothing from this package, which is
|
||||
# what makes exec'ing it standalone honest rather than a stub.
|
||||
def load(pairs):
|
||||
"""`app/mqtt.py` with `pairs` applied, executed into a fresh module.
|
||||
|
||||
⚠️ EVERY ANCHOR MUST OCCUR EXACTLY ONCE. A mutation whose anchor has drifted
|
||||
out of the file would otherwise apply to nothing and be reported as a
|
||||
survivor - a false red on the instrument rather than on the module.
|
||||
"""
|
||||
src = MQTT_SRC
|
||||
for old, new in pairs:
|
||||
if src.count(old) != 1:
|
||||
raise AssertionError(f"mutation anchor not unique: {old!r}")
|
||||
src = src.replace(old, new)
|
||||
mod = type(sys)("mqtt_mutant")
|
||||
exec(compile(src, "<mutant>", "exec"), mod.__dict__)
|
||||
return mod
|
||||
|
||||
|
||||
def properties(mod):
|
||||
"""Every claim this file makes, as booleans, over ONE module."""
|
||||
out = {}
|
||||
try:
|
||||
base = announce(units=["inverter-a"], module=mod)
|
||||
two = announce(units=["inverter-a", "inverter-b"], module=mod)
|
||||
none = announce(units=(), module=mod)
|
||||
col = announce(units=["inverter-b", "Inverter B"], module=mod)
|
||||
tc = cfgs(two)
|
||||
uc = [c_ for c_ in tc if c_["unique_id"] not in site_ids]
|
||||
bc = [c_ for c_ in uc if "inverter_b" in c_["unique_id"]]
|
||||
ac = [c_ for c_ in uc if "inverter_a" in c_["unique_id"]]
|
||||
comp = [compose(c_["device"]["name"], c_["name"]) for c_ in tc]
|
||||
out["A byte-identical at N=1"] = base == BASELINE
|
||||
out["A byte-identical at N=2"] = diff(two)[:2] == ([], [])
|
||||
out["N=1 adds no topic"] = len(base) == len(none) == len(BASELINE)
|
||||
out["unique_ids all distinct"] = (
|
||||
len(set(c_["unique_id"] for c_ in tc)) == len(tc))
|
||||
out["slug-alike names still distinct"] = (
|
||||
len(set(c_["unique_id"] for c_ in cfgs(col))) == len(cfgs(col)))
|
||||
out["no composed entity_id collision"] = len(set(comp)) == len(comp)
|
||||
out["object_id == composed id"] = all(
|
||||
"sensor." + c_["object_id"] == compose(c_["device"]["name"], c_["name"])
|
||||
for c_ in uc)
|
||||
out["A keeps the device it has"] = bool(ac) and all(
|
||||
c_["device"] == mqtt.DEVICE for c_ in ac)
|
||||
out["B has its own device"] = bool(bc) and all(
|
||||
c_["device"]["identifiers"] != mqtt.DEVICE["identifiers"] for c_ in bc)
|
||||
out["B is via_device the site"] = bool(bc) and all(
|
||||
c_["device"].get("via_device") == mqtt.DEVICE["identifiers"][0]
|
||||
for c_ in bc)
|
||||
out["site sensors announced once"] = all(
|
||||
[c_["unique_id"] for c_ in cfgs(announce(units=[f"i{j}" for j in range(n)],
|
||||
module=mod))].count(s) == 1
|
||||
for n in (1, 2, 3) for s in SITE_ONLY)
|
||||
# ⚠️ TWO PROPERTIES THAT WERE MISSING, AND THE INSTRUMENT FOUND THEIR
|
||||
# ABSENCE ITSELF: on the first run of this table, "the unit is dropped
|
||||
# from the state topic" and "`grid` is moved to the per-unit set" were
|
||||
# both GREEN. The first collapses A's and B's four topics onto one set
|
||||
# of four, so both inverters read one number; the second announces a
|
||||
# per-inverter grid sensor, which is the exact thing AC 3 forbids. The
|
||||
# ids stayed distinct through both, so every property above missed them.
|
||||
out["per-unit state topics all distinct"] = (
|
||||
len({c_["state_topic"] for c_ in uc}) == len(uc))
|
||||
# ⚠️ GRADED AGAINST THE SHIPPED `Controller.publish()`, on purpose. An
|
||||
# announcement is only honest if something feeds it - mqtt.py's `omit`
|
||||
# note - so a per-unit entity the controller will never publish to is a
|
||||
# defect however tidy its id looks. (A rename applied to BOTH halves at
|
||||
# once is invisible here and is disclosed in part 9; a rename applied to
|
||||
# one half is what actually happens, and is red.)
|
||||
out["every announced per-unit topic is fed"] = (
|
||||
{c_["state_topic"] for c_ in uc} == FED)
|
||||
except Exception as err: # a mutation that CRASHES proves nothing; say so
|
||||
out["did not raise"] = False
|
||||
out["__error__"] = repr(err)[:70]
|
||||
return out
|
||||
|
||||
|
||||
SHIPPED = properties(mqtt)
|
||||
check("the shipped module satisfies every property this instrument grades - "
|
||||
"so a red below is the mutation and not the instrument",
|
||||
all(v is True for k, v in SHIPPED.items()))
|
||||
|
||||
MUTATIONS = [
|
||||
("⭐ RE-SCOPE A's DEVICE NAME (the destructive change, AC 5)",
|
||||
[('"name": "GoodWe",', '"name": "GoodWe Inverter A",')]),
|
||||
("⭐ RE-SCOPE A's DEVICE IDENTIFIERS",
|
||||
[('"identifiers": ["goodwe_rs485_controller"],',
|
||||
'"identifiers": ["goodwe_inverter_a"],')]),
|
||||
("⭐ THE TEMPTING TIDY DESIGN: one device per inverter, A included",
|
||||
[("device = DEVICE if i == 0 else unit_device(name, slug)",
|
||||
"device = unit_device(name, slug)"),
|
||||
('prefix = f"{_title(name)} " if i == 0 else ""', 'prefix = ""')]),
|
||||
("B's rows land on A's device instead of B's own",
|
||||
[("device = DEVICE if i == 0 else unit_device(name, slug)",
|
||||
"device = DEVICE")]),
|
||||
("A's per-unit entity names lose the unit prefix",
|
||||
[('prefix = f"{_title(name)} " if i == 0 else ""', 'prefix = ""')]),
|
||||
("per-unit entities announced at a roster of ONE",
|
||||
[("PER_UNIT_MIN_ROSTER = 2", "PER_UNIT_MIN_ROSTER = 1")]),
|
||||
("the unit is dropped from the unique_id",
|
||||
[('unique_id = f"{BASE}_unit_{slug}_{key}"',
|
||||
'unique_id = f"{BASE}_unit_{key}"')]),
|
||||
("the unit is dropped from the state topic",
|
||||
[('return f"unit/{slug}/{key}"', 'return f"unit/{key}"')]),
|
||||
("the slug de-collision is removed (rule 9(c) does not cover this)",
|
||||
[("while slug in seen:", "while False and slug in seen:")]),
|
||||
("every unit takes the FIRST unit's slug",
|
||||
[("for i, (name, slug) in enumerate(zip(names, slugs)):",
|
||||
"for i, (name, slug) in enumerate(zip(names, [slugs[0]] * len(slugs))):")]),
|
||||
("`grid` is moved to the per-unit set (site sensor announced per inverter)",
|
||||
[(' ("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),\n',
|
||||
' ("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None),\n'
|
||||
' ("grid", "grid_power", "Grid power", "W", "power", "measurement", None),\n')]),
|
||||
("B's device drops `via_device`",
|
||||
[(' "via_device": DEVICE["identifiers"][0],\n', "")]),
|
||||
]
|
||||
|
||||
survivors = []
|
||||
for label, pairs in MUTATIONS:
|
||||
got = properties(load(pairs))
|
||||
broke = sorted(k for k, v in SHIPPED.items()
|
||||
if v is True and got.get(k) is not True)
|
||||
if not broke:
|
||||
survivors.append(label)
|
||||
print(f" {'RED ' if broke else 'GREEN'} {label}")
|
||||
if broke:
|
||||
print(f" breaks: {', '.join(broke)}")
|
||||
elif got.get("__error__"):
|
||||
print(f" raised: {got['__error__']}")
|
||||
check(f"⭐ ALL {len(MUTATIONS)} MUTATIONS ARE RED, each on a NAMED property: "
|
||||
"re-scoping A's device, the tidy one-device-per-inverter design, B "
|
||||
"landing on A's device, per-unit entities at a roster of one, a unit "
|
||||
"dropped from an id or a topic, two units sharing a slug, and a site "
|
||||
"sensor announced per inverter. A mutation nothing catches is a claim "
|
||||
"nothing is watching",
|
||||
not survivors)
|
||||
if survivors:
|
||||
print(" SURVIVED:", "; ".join(survivors))
|
||||
|
||||
# ⚠️ AC 6, THE FIXTURE'S OWN NON-VACUITY. If A and B were given the same name
|
||||
# the whole of part 4 would still be green and would be observing nothing.
|
||||
same = announce(units=["inverter-a", "inverter-a"])
|
||||
swapped = announce(units=["inverter-b", "inverter-a"])
|
||||
check("...and the fixture's two units are genuinely distinguishable: swapping "
|
||||
"their order changes the announcement, and a roster of two IDENTICAL "
|
||||
"names - which rule 9(c) refuses, and which this file therefore never "
|
||||
"relies on - would still not collide",
|
||||
swapped != TWO
|
||||
and len({c_["unique_id"] for c_ in cfgs(same)}) == len(cfgs(same)))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n9. what this file CANNOT see, enumerated and sized")
|
||||
# ---------------------------------------------------------------------------
|
||||
for line in [
|
||||
"1. HA ITSELF IS NOT IN THE LOOP. `compose()` in part 6 is a MODEL of how "
|
||||
"HA 2026.8 builds an entity_id, anchored on exactly ONE measured id "
|
||||
"(sensor.goodwe_p1_sample_age, mqtt.py's note, verified 2026-08-26). If HA "
|
||||
"changes that rule, or if it differs for a device with `via_device`, every "
|
||||
"id claim here moves with it and this file stays green. SIZE: the 8 "
|
||||
"per-unit ids at N=2. It cannot be closed from this repository - it needs a "
|
||||
"broker, an HA, and the owner present.",
|
||||
"2. THE TICKET AND THE MODULE DISAGREE ABOUT THE LIVE IDs. The card quotes "
|
||||
"`sensor.goodwe_rs485_controller_goodwe_battery_soc`; mqtt.py records "
|
||||
"`sensor.goodwe_p1_sample_age`. Those cannot both come from one composition "
|
||||
"rule - the first is the id an EARLIER device name composed, and HA keeps "
|
||||
"whatever it composed at first discovery. This card does not need to "
|
||||
"resolve it, because it renames no device and no entity, and the byte-diff "
|
||||
"protects both spellings equally. SIZE: unresolved, 8 live entity ids; it "
|
||||
"wants one look at the live registry, which this branch must not take.",
|
||||
"3. THE PER-UNIT PAYLOADS HAVE NO RELEASED BASELINE. A's are diffed against "
|
||||
"release/1.0; B's can only be diffed against this branch (the sha256 pin in "
|
||||
"part 3). A wrong `device_class` or unit on a per-unit sensor is therefore "
|
||||
"caught as a CHANGE from review, not as an error. SIZE: 4 sensors x 4 "
|
||||
"optional fields.",
|
||||
"4. NO CHECK HERE RUNS A BROKER. `_announce()` is graded through a fake "
|
||||
"paho client, so QoS, retain semantics on a real broker, and what HA does "
|
||||
"with a retained config for a device it has never seen are all out of "
|
||||
"scope. SIZE: the whole transport.",
|
||||
"5. THE SLUG DE-COLLISION IS ORDER-DEPENDENT. `inverter-b` before "
|
||||
"`Inverter B` gives the SECOND one the `_2` suffix; reordering the roster "
|
||||
"swaps which unit's ids move. Rule 9(c) does not refuse slug-alike names, "
|
||||
"so this is reachable from a config an operator can write. SIZE: 4 ids on "
|
||||
"one unit, and only when two names slug alike. Filed as a follow-up rather "
|
||||
"than fixed in validate.py here, because a new refusal at startup on a "
|
||||
"LIVE install is a bigger change than this card is allowed to be.",
|
||||
"7. PART 8 MUTATES `app/mqtt.py` ONLY. `Controller.publish()`'s four "
|
||||
"per-unit lines are graded by part 7 - with different SoC, different "
|
||||
"battery sign and different latch states on the two units, so a swap or a "
|
||||
"mis-paired slug is red - but no automated table mutates main.py here. "
|
||||
"Those mutations were run by hand through `tools/mutation_run.sh` and are "
|
||||
"recorded in the PR. SIZE: 4 lines. Also invisible: a rename of "
|
||||
"`unit_state_key()` applied to BOTH halves at once, which is harmless "
|
||||
"(the topics are internal) but would move every per-unit state topic "
|
||||
"silently.",
|
||||
"6. `inverter AC power` AND PER-UNIT HEARTBEAT AGE ARE NOT ANNOUNCED. The "
|
||||
"card lists both as per-unit; neither has a source in the add-on today "
|
||||
"(nothing reads an AC-power register per unit, and `inv.last_beat` is a "
|
||||
"monotonic loop clock). Announcing them would create entities nothing "
|
||||
"feeds. SIZE: 2 sensors x N units, deliberately absent, named in the PR.",
|
||||
]:
|
||||
print(" " + line)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
+2645
-38
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
||||
"""UX-01 - the Configuration tab's labels, and the check that stops them rotting.
|
||||
|
||||
RUN: python3 -B test_translations.py (no third-party imports; the add-on
|
||||
CI image is Alpine + aiohttp + paho)
|
||||
|
||||
WHAT THIS FILE IS FOR. Before UX-01 there was no `translations/` directory at
|
||||
all, so Home Assistant rendered the raw option KEYS - `unit_sign_disagree_w`,
|
||||
`saturation_cycles`, `integrator_max_w` - for all seventy options, in
|
||||
`schema:` order, with no description. The two files this grades are what turn
|
||||
that into sentences, and the highest-value half of them is not the label: it is
|
||||
the sentence that says WHETHER TO TOUCH THE VALUE, because the tab presents a
|
||||
constant derived from 1890 closed-loop test runs and the operator's own inverter
|
||||
rating as the same kind of field.
|
||||
|
||||
⚠️ WHAT THIS FILE CANNOT SEE, SAID FIRST. It grades COVERAGE and PROVENANCE
|
||||
CITATIONS - that every option has a name and a description in both languages,
|
||||
that no translation names an option that does not exist, and that each derived
|
||||
constant's English description cites the record it was derived from. It does NOT
|
||||
grade whether a sentence is true, whether the Dutch is good Dutch, or whether
|
||||
the result looks right on screen. The last of those is measured, not asserted,
|
||||
by `tools/supervisor_shape_probe.py` section F, which loads these files with
|
||||
Supervisor 2026.08.0's OWN loader out of the release image, runs its OWN
|
||||
`UiOptions`, replays the frontend's label resolution as the shipped bundle
|
||||
writes it, and prints every rendered row. That transcript is the evidence; this
|
||||
file is the gate that keeps it true.
|
||||
|
||||
⚠️ AND THE PARSER IS THE OTHER HALF OF THAT PAIR. There is no PyYAML in the CI
|
||||
image and a check that skips on ImportError is not a check, so
|
||||
`tools/translations_lint.py` parses these files by hand. A hand parser that
|
||||
quietly disagrees with the real loader is exactly the defect this repository
|
||||
keeps finding, so the probe imports THAT SAME MODULE and asserts, key by key and
|
||||
string by string, that it returns what `supervisor.store.data.
|
||||
_read_app_translations()` returns. Part 5 below re-states the arrangement so a
|
||||
reader of this file is told where the licence for the hand parser comes from.
|
||||
|
||||
MUTATION.md is the method for the ledger in part 6; `tools/mutation_run.sh` is
|
||||
the harness, and every row there was RUN rather than reasoned about.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import translations_lint as TL # noqa: E402
|
||||
|
||||
FAILED = 0
|
||||
def check(what, ok):
|
||||
global FAILED
|
||||
print((" ok " if ok else " FAIL ") + what)
|
||||
if not ok:
|
||||
FAILED += 1
|
||||
|
||||
|
||||
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
ORDER, NESTED = TL.config_surface(CFG_TEXT)
|
||||
LANGS = ("en", "nl")
|
||||
TR = {lg: TL.parse_translation_file(HERE / "translations" / f"{lg}.yaml")
|
||||
for lg in LANGS}
|
||||
|
||||
print("UX-01 - add-on option translations\n")
|
||||
print("1. the surface being described")
|
||||
print(f" config.yaml declares {len(ORDER)} top-level options under `schema:` "
|
||||
f"and {sum(len(v) for v in NESTED.values())} per-entry key(s) under "
|
||||
f"{sorted(NESTED)}")
|
||||
# ⚠️ A FLOOR, NOT AN EQUALITY. This is the "did the reader read anything at all"
|
||||
# line: `config_surface()` returning an empty list would make every coverage
|
||||
# check below vacuously true, which is the third failure mode in MUTATION.md -
|
||||
# the assertion runs, the sentence is true, and the data cannot reach the
|
||||
# failure it names.
|
||||
check(f"the option surface was actually read - {len(ORDER)} options, not zero",
|
||||
len(ORDER) >= 60 and "inverters" in ORDER and "max_w" in ORDER)
|
||||
check("...and the repeatable `inverters:` section's per-entry keys were read "
|
||||
"too, which is the half a top-level-only reader would miss",
|
||||
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
|
||||
and len(NESTED["inverters"]) == 7)
|
||||
|
||||
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
|
||||
"languages")
|
||||
for lg in LANGS:
|
||||
problems = TL.audit(CFG_TEXT, TR[lg], lg)
|
||||
print(f" {lg}: {len(TR[lg]['configuration'])} options translated, "
|
||||
f"{len(problems)} problem(s)")
|
||||
for p in problems[:10]:
|
||||
print(" - " + p)
|
||||
check(f"`{lg}` covers config.yaml exactly: no option without a translation, "
|
||||
f"no translation without an option, every one with a name AND a "
|
||||
f"description. Problems: {problems[:3]}", not problems)
|
||||
|
||||
# ⚠️ THE `nl` HALF IS NOT OPTIONAL EVEN THOUGH THE FALLBACK IS. The frontend
|
||||
# resolves `translations[lang]?.name || translations["en"]?.name || key`, PER
|
||||
# KEY - measured off the shipped bundle, see the probe - so a MISSING nl key
|
||||
# degrades to English and a WRONG one does not degrade at all. The card asks for
|
||||
# both languages complete, and complete is a thing a check can hold.
|
||||
check("...and the two files describe the SAME option set, so nothing on the "
|
||||
"owner's Dutch screen quietly falls back to English",
|
||||
set(TR["en"]["configuration"]) == set(TR["nl"]["configuration"]))
|
||||
check("...including inside the repeatable section",
|
||||
set(TR["en"]["configuration"]["inverters"]["fields"])
|
||||
== set(TR["nl"]["configuration"]["inverters"]["fields"]))
|
||||
check("...and no Dutch string was left as a copy of the English one, which is "
|
||||
"what an untranslated key looks like when it is present rather than "
|
||||
"absent",
|
||||
not [k for k in ORDER
|
||||
if TR["en"]["configuration"][k]["description"]
|
||||
== TR["nl"]["configuration"][k]["description"]])
|
||||
|
||||
print("\n3. ⭐ the description says WHETHER TO TOUCH IT")
|
||||
# ⚠️ THE HIGHEST-VALUE ITEM IN THE CARD, AND THE ONE A COVERAGE COUNT CANNOT
|
||||
# REACH. Sixty-odd descriptions that all say "the threshold, in watts" would
|
||||
# pass part 2 completely. What makes this surface safe is that a DERIVED
|
||||
# constant names the campaign it came from and says the value is not a knob,
|
||||
# while a number that really is the operator's says so too.
|
||||
#
|
||||
# ⚠️ AND THIS IS A TEXT CHECK, WHICH IS WEAK ON PURPOSE AND IS SAID TO BE. It
|
||||
# cannot know whether a citation is TRUE; it can only know that the citation is
|
||||
# there and names the right record, so that deleting the provenance - or
|
||||
# renaming the card it points at - goes red instead of shipping. The table is
|
||||
# written out rather than derived because there is nothing in config.yaml that
|
||||
# distinguishes a derived constant from an operator's number; that distinction
|
||||
# is the whole of what this card had to add.
|
||||
DERIVED = {
|
||||
"sign_disagree_w": ("SAFETY-02", "MEASURED"),
|
||||
"sign_disagree_s": ("SAFETY-02", "MEASURED"),
|
||||
"unit_sign_disagree_w": ("SAFETY-30", "1890"),
|
||||
"unit_sign_disagree_s": ("SAFETY-30", "1890"),
|
||||
"site_sign_disagree_w": ("SAFETY-30", "CARRIED"),
|
||||
"site_sign_disagree_s": ("SAFETY-30", "1890"),
|
||||
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
|
||||
"circulation_w": ("SAFETY-31", "924"),
|
||||
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
|
||||
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
|
||||
"bms_soc_floor": ("0x0560", "MEASURED"),
|
||||
"integrator_max_w": ("MEASURED", "Do not raise it"),
|
||||
"gain": ("MEASURED ON", "do not raise it"),
|
||||
# SAFETY-39. `slew_w` joins the table because it stopped being an
|
||||
# operator's convenience and became a DERIVED value the moment it was
|
||||
# tied to the rating: 0 resolves to about a ninth of `max_w`, from an
|
||||
# ~18 s measured step response. It is the one entry here whose value is
|
||||
# a RATIO, so the description has to say what it follows as well as
|
||||
# where the ratio came from - a citation alone would leave an operator
|
||||
# reading "0" on the tab with no idea it is not "off".
|
||||
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
|
||||
}
|
||||
_uncited = []
|
||||
for key, needles in sorted(DERIVED.items()):
|
||||
desc = TR["en"]["configuration"][key]["description"]
|
||||
for needle in needles:
|
||||
if needle not in desc:
|
||||
_uncited.append(f"{key} does not cite {needle!r}")
|
||||
print(f" {len(DERIVED)} derived constants must cite their campaign; "
|
||||
f"{len(_uncited)} do not")
|
||||
check(f"every derived constant's English description names WHERE THE NUMBER "
|
||||
f"CAME FROM, so an operator reading the tab can tell it apart from their "
|
||||
f"own inverter rating. Uncited: {_uncited}", not _uncited)
|
||||
# The other direction, and it matters as much: a number that IS the operator's
|
||||
# must not be dressed up as derived, or the labels stop meaning anything.
|
||||
_theirs = ["max_w", "peak_cap_w"]
|
||||
check("...and the numbers that really are the OPERATOR'S say so instead of "
|
||||
"carrying a citation - `max_w` and `peak_cap_w` are the two the card "
|
||||
"names, and both read 'THIS IS YOUR NUMBER'",
|
||||
all("THIS IS YOUR NUMBER" in TR["en"]["configuration"][k]["description"]
|
||||
for k in _theirs))
|
||||
check("...and the one threshold that is NOT derived from a measurement admits "
|
||||
"it rather than borrowing the others' authority - "
|
||||
"`cycle_cost_threshold_eur` is an open product question (OPT-08)",
|
||||
"NOT DERIVED FROM A MEASUREMENT"
|
||||
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"]
|
||||
and "OPT-08"
|
||||
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"])
|
||||
|
||||
print("\n4. task ordering, and the labels that carry it")
|
||||
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST - no section headers, no per-option advanced
|
||||
# flag, no units on the input. Grouping is approximated by ORDER plus label text
|
||||
# and by nothing else, so both halves are graded: the label prefix, and that the
|
||||
# prefixes appear in the file as CONTIGUOUS RUNS in the task order. A group
|
||||
# whose members are scattered renders as a jumble however good each label is.
|
||||
GROUPS = ["Hardware", "Meter", "Limits", "Tariff", "Maintenance", "Safety",
|
||||
"Advanced"]
|
||||
NL_GROUPS = {"Hardware": "Hardware", "Meter": "Meter", "Limits": "Grenzen",
|
||||
"Tariff": "Tarief", "Maintenance": "Onderhoud",
|
||||
"Safety": "Veiligheid", "Advanced": "Geavanceerd"}
|
||||
_prefixes = []
|
||||
for key in ORDER:
|
||||
name = TR["en"]["configuration"][key]["name"]
|
||||
_prefixes.append(name.split(" \u00b7 ")[0] if " \u00b7 " in name else f"<none:{key}>")
|
||||
_runs = [p for i, p in enumerate(_prefixes) if i == 0 or p != _prefixes[i - 1]]
|
||||
print(f" the rendered order groups into: {_runs}")
|
||||
check(f"every label carries a group prefix, so the flat list reads as groups. "
|
||||
f"Missing: {[p for p in _prefixes if p.startswith('<none:')]}",
|
||||
not [p for p in _prefixes if p.startswith("<none:")])
|
||||
check(f"...and each group is ONE CONTIGUOUS RUN in the rendered order, in the "
|
||||
f"task order the card asks for - hardware, meter, ratings and limits, "
|
||||
f"tariff, maintenance, safety, advanced. Got: {_runs}", _runs == GROUPS)
|
||||
_nl_bad = [k for k in ORDER
|
||||
if NL_GROUPS.get(TR["en"]["configuration"][k]["name"].split(" \u00b7 ")[0])
|
||||
!= TR["nl"]["configuration"][k]["name"].split(" \u00b7 ")[0]]
|
||||
check(f"...and the Dutch labels carry the SAME grouping under Dutch names, so "
|
||||
f"the two languages do not read as two different layouts. Off: {_nl_bad}",
|
||||
not _nl_bad)
|
||||
# ⚠️ THE ORDER IS THE `schema:` BLOCK'S, NOT THE `options:` BLOCK'S, AND THAT IS
|
||||
# A MEASUREMENT. `App.schema_ui` is `UiOptions(coresys)(data["schema"])`, so the
|
||||
# tab renders the schema half in its own order. Asserted here because the whole
|
||||
# of part 4 is about an ordering that would otherwise be graded on the wrong
|
||||
# half of the file - and because the two halves of config.yaml were already in
|
||||
# different orders before this card.
|
||||
# ⚠️ ANCHORED ON THE INDENTATION AND SCOPED TO THE `options:` BLOCK. The file
|
||||
# starts with the add-on manifest, whose `services:` list carries ` - mqtt:want`
|
||||
# at two spaces with a colon in it - a looser reader picks that up as an option
|
||||
# called `- mqtt` and the comparison below then fails for a reason that has
|
||||
# nothing to do with what it is watching. Measured, on the first run.
|
||||
import re as _re # noqa: E402
|
||||
_opt_half = CFG_TEXT[CFG_TEXT.index("\noptions:\n"):CFG_TEXT.index("\nschema:")]
|
||||
_opt_order = _re.findall(r"^ ([a-z_0-9]+):", _opt_half, _re.M)
|
||||
check("config.yaml's two halves now declare the same options in the same "
|
||||
"order, so the file reads the way the tab renders even though only the "
|
||||
"schema half decides it", _opt_order == ORDER)
|
||||
|
||||
print("\n5. the parser is strict, so a file it half-read cannot pass")
|
||||
# ⚠️ THE FAILURE THIS PART EXISTS FOR. A permissive parser answers "no problems"
|
||||
# for a file it silently skipped half of - the same green-and-not-watching
|
||||
# defect one layer down. So every shape outside the subset UX-01 writes must
|
||||
# RAISE, and that is graded with real malformed input rather than asserted.
|
||||
BAD = [
|
||||
("a tab-indented line", "configuration:\n\tmeter_entity:\n"),
|
||||
("three-space indent", "configuration:\n meter_entity:\n"),
|
||||
("a bare unquoted scalar", 'configuration:\n a:\n name: hello\n'),
|
||||
("a single-quoted scalar", "configuration:\n a:\n name: 'hello'\n"),
|
||||
("a list item", 'configuration:\n a:\n - "x"\n'),
|
||||
("a duplicate key", 'configuration:\n a:\n name: "x"\n name: "y"\n'),
|
||||
("an over-indented child", 'configuration:\n a:\n'),
|
||||
("a line that is not a key at all", "configuration:\n just some prose\n"),
|
||||
("an unsupported escape", 'configuration:\n a:\n name: "x\\ty"\n'),
|
||||
]
|
||||
_accepted = []
|
||||
for what, text in BAD:
|
||||
try:
|
||||
TL.parse_translation_text(text, what)
|
||||
_accepted.append(what)
|
||||
except TL.TranslationSyntaxError:
|
||||
pass
|
||||
check(f"every shape outside the accepted subset RAISES rather than being "
|
||||
f"skipped. Silently accepted: {_accepted}", not _accepted)
|
||||
check("...and the accepted subset really does round-trip the shipped files - "
|
||||
"70 options and 7 per-entry keys came back out of each, which is what "
|
||||
"says the strictness above is not just refusing everything",
|
||||
all(len(TR[lg]["configuration"]) == len(ORDER)
|
||||
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
|
||||
for lg in LANGS))
|
||||
print(" ⚠️ the licence for this hand parser is NOT this file. "
|
||||
"tools/supervisor_shape_probe.py section F loads the same two files with "
|
||||
"`supervisor.store.data._read_app_translations()` out of the 2026.08.0 "
|
||||
"release image and asserts, key by key and string by string, that this "
|
||||
"module returns the same thing. Re-run it when either changes.")
|
||||
|
||||
print("\n6. MUTATION: the check is run against broken inputs, both directions")
|
||||
# ⚠️ THIS IS THE PART THE CARD ASKS FOR IN SO MANY WORDS, AND IT IS RUN RATHER
|
||||
# THAN DESCRIBED. A coverage check is worth exactly what it catches, so each
|
||||
# mutation below is APPLIED to a copy of the real parsed translations and the
|
||||
# audit is required to NAME the key it broke - not merely to be non-empty. A
|
||||
# check that goes red for the wrong reason is a check that will go green for the
|
||||
# wrong reason later.
|
||||
import copy # noqa: E402
|
||||
|
||||
|
||||
def _mutate(what, fn, must_name):
|
||||
tr = copy.deepcopy(TR["en"])
|
||||
fn(tr)
|
||||
problems = TL.audit(CFG_TEXT, tr, "en")
|
||||
named = [p for p in problems if must_name in p]
|
||||
print(f" {what}\n -> {len(problems)} problem(s); "
|
||||
f"{(named[0][:120] + '...') if named else 'NONE NAMED ' + must_name}")
|
||||
check(f"MUTATION: {what} -> the audit REFUSES and names `{must_name}`",
|
||||
bool(named))
|
||||
|
||||
|
||||
_mutate("delete one option's whole translation (`saturation_cycles`)",
|
||||
lambda tr: tr["configuration"].pop("saturation_cycles"),
|
||||
"saturation_cycles")
|
||||
_mutate("delete one option's description but keep its name (`meter_phases`)",
|
||||
lambda tr: tr["configuration"]["meter_phases"].pop("description"),
|
||||
"meter_phases")
|
||||
_mutate("blank one option's description (`gain`) - present but empty is not "
|
||||
"covered either",
|
||||
lambda tr: tr["configuration"]["gain"].update(description=" "),
|
||||
"gain")
|
||||
_mutate("translate an option that does not exist (`saturation_cyles`, one "
|
||||
"letter out - the rename this check exists for)",
|
||||
lambda tr: tr["configuration"].update(
|
||||
saturation_cyles={"name": "x", "description": "y"}),
|
||||
"saturation_cyles")
|
||||
_mutate("delete one PER-ENTRY key of the repeatable section (`bms_soc_floor`)",
|
||||
lambda tr: tr["configuration"]["inverters"]["fields"].pop("bms_soc_floor"),
|
||||
"bms_soc_floor")
|
||||
_mutate("invent a per-entry key the schema does not declare (`max_kw`)",
|
||||
lambda tr: tr["configuration"]["inverters"]["fields"].update(
|
||||
max_kw={"name": "x", "description": "y"}),
|
||||
"max_kw")
|
||||
_mutate("drop the whole `fields:` block from the repeatable section",
|
||||
lambda tr: tr["configuration"]["inverters"].pop("fields"),
|
||||
"inverters")
|
||||
_mutate("hang a `fields:` block off an option that is not a repeatable section",
|
||||
lambda tr: tr["configuration"]["max_w"].update(
|
||||
fields={"x": {"name": "y"}}),
|
||||
"max_w")
|
||||
# ⚠️ AND THE TWO THAT COST THE WHOLE FILE RATHER THAN ONE LABEL. Supervisor
|
||||
# validates each translation file through SCHEMA_APP_TRANSLATIONS and, on
|
||||
# refusal, LOGS A WARNING AND DROPS ALL OF IT - so a shape it rejects does not
|
||||
# lose one description, it silently returns the entire tab to raw keys, and the
|
||||
# only trace is one line in a log nobody is reading. Neither of these is
|
||||
# reachable through the `configuration:` coverage above, which is exactly why
|
||||
# they are here.
|
||||
_mutate("a top-level block Supervisor does not read (`netwrok:`) - the file is "
|
||||
"DROPPED WHOLE, not partly",
|
||||
lambda tr: tr.update(netwrok={"x": "y"}), "netwrok")
|
||||
_mutate("a `network:` block whose value is a mapping rather than a string - "
|
||||
"same consequence, every label in the file gone",
|
||||
lambda tr: tr.update(network={"8099/tcp": {"x": "y"}}), "network")
|
||||
|
||||
# ⚠️ AND THE MUTATION THAT MATTERS MOST: A NEW OPTION ARRIVES. This is the rot
|
||||
# the acceptance criterion is written about - somebody adds a key to config.yaml
|
||||
# in six months and the tab renders it raw. The mutation is applied to the
|
||||
# CONFIG side rather than the translation side, because that is the direction it
|
||||
# happens in.
|
||||
_grown = CFG_TEXT.replace("\n log_level: list(", "\n new_option_s: int(1,9)\n log_level: list(")
|
||||
check("the config-side mutation really did land - a new option in the schema "
|
||||
"half that no translation mentions", _grown != CFG_TEXT
|
||||
and "new_option_s" in TL.config_surface(_grown)[0])
|
||||
_grow_problems = TL.audit(_grown, TR["en"], "en")
|
||||
print(f" a new option added to config.yaml -> {_grow_problems[:1]}")
|
||||
check("MUTATION: adding an option to config.yaml and forgetting the "
|
||||
"translation goes RED, naming the key - which is the whole of the "
|
||||
"acceptance criterion about this rotting",
|
||||
any("new_option_s" in p for p in _grow_problems))
|
||||
|
||||
# ⚠️ THE NEGATIVE CONTROL, WITHOUT WHICH THE EIGHT ROWS ABOVE PROVE NOTHING. An
|
||||
# audit that returned a problem for every input would have "passed" all of them.
|
||||
check("...and the UNMUTATED input is still clean, so the rows above are the "
|
||||
"mutations speaking and not a check that always complains",
|
||||
not TL.audit(CFG_TEXT, TR["en"], "en")
|
||||
and not TL.audit(CFG_TEXT, TR["nl"], "nl"))
|
||||
|
||||
print("\n7. no behaviour and no value moved")
|
||||
# ⚠️ UX-01 IS A PRESENTATION CARD. It reorders config.yaml and adds two files;
|
||||
# it must not move a single default. The whole options half is compared against
|
||||
# the schema half key by key here, and every other suite in this directory pins
|
||||
# the actual values by reading this same file - test_maintenance.py's eight,
|
||||
# test_watchdog.py's six, test_price.py's seven, test_freeze.py's, and
|
||||
# test_peakguard.py's. This line is the cheap one that says the reorder did not
|
||||
# lose or duplicate a key on the way.
|
||||
check("no option was lost, gained or duplicated by the reordering: the two "
|
||||
"halves of config.yaml hold the same 70 keys, once each",
|
||||
sorted(_opt_order) == sorted(ORDER) and len(set(ORDER)) == len(ORDER))
|
||||
check("...and `inverters: []` is still shipped in the options half, which is "
|
||||
"what Supervisor 2026.08.0 refuses every pre-0.6.0 options blob without "
|
||||
"(measured; see DECISIONS/CFG-04-inverter-roster.md B)",
|
||||
"\n inverters: []\n" in _opt_half)
|
||||
check("...and the schema half still declares the NESTED per-entry `max_w:` "
|
||||
"BEFORE the flat one, so test_inverter.py part 12's first-match trap "
|
||||
"stays baited rather than sorted out by this card's reordering",
|
||||
CFG_TEXT[CFG_TEXT.index("\nschema:"):].index(" max_w: int(")
|
||||
< CFG_TEXT[CFG_TEXT.index("\nschema:"):].index("\n max_w: int("))
|
||||
|
||||
print()
|
||||
if FAILED:
|
||||
print(f"{FAILED} FAILED")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -18,9 +18,10 @@ import asyncio
|
||||
import sys
|
||||
|
||||
from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
|
||||
from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC
|
||||
from app.validate import (
|
||||
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
|
||||
actuator_writers, refuse, validate_startup_config,
|
||||
actuator_writers, refuse, site_rail_w, validate_startup_config,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -204,12 +205,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) == [])
|
||||
@@ -353,6 +359,64 @@ check("...and it is refused with maintenance_enabled off, as shipped",
|
||||
base(max_w=2000, maintenance_charge_w=2500,
|
||||
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
|
||||
|
||||
# ⚠️ MAINT-05: AND AT TWO PACKS IT IS THE WEAKEST CAP, NOT THE FIRST ENTRY'S.
|
||||
# The comment this replaces in validate.py said, in as many words, that it took
|
||||
# the FIRST unit's cap because there was exactly one `Maintenance` and it could
|
||||
# only ever recalibrate `inverters[0]` - and that "when `maint_unit` stops being
|
||||
# `inverters[0]`, this line moves with it". It has. Every pack is now
|
||||
# recalibrated on its own schedule, so the shaping claim can be filed on any of
|
||||
# them and the cap that contradicts it first is the smallest.
|
||||
#
|
||||
# ⚠️ THE FIXTURE'S TWO CAPS ARE DELIBERATELY DIFFERENT AND THE RATE SITS
|
||||
# BETWEEN THEM. Two equal caps could not tell "grades the first" from "grades
|
||||
# the smallest", and a rate above both would be refused by either rule - so
|
||||
# 3000 W against caps of 5000 W and 2000 W is the one arrangement where the old
|
||||
# answer and the new one disagree. ⚠️ 2000 W for the second pack is a
|
||||
# HYPOTHESIS: inverter B does not physically exist and nothing here is measured
|
||||
# from one.
|
||||
def _roster(a_max, b_max, **over):
|
||||
o = base(**over)
|
||||
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
|
||||
o.pop(k, None)
|
||||
o["inverters"] = [
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
||||
max_w=a_max),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=b_max)]
|
||||
return o
|
||||
|
||||
|
||||
_two = _roster(5000, 2000, maintenance_charge_w=3000, maintenance_discharge_w=1000,
|
||||
sign_disagree_w=300, unit_sign_disagree_w=300)
|
||||
_p_two = [p for p in validate_startup_config(_two, -5000, 5000)
|
||||
if "maintenance_charge_w" in p]
|
||||
print(f" 5000 W and 2000 W packs, charge 3000 W -> {_p_two}")
|
||||
check("⚠️ A CHARGE RATE THE SMALLEST PACK CANNOT TAKE IS REFUSED, even though "
|
||||
"the FIRST pack could. Before MAINT-05 the second pack was never "
|
||||
"recalibrated, so its cap could not contradict anything; it is now, and "
|
||||
"its cheap-window charge would ask for hi=-3000 against lo=-2000 - the "
|
||||
"`lo > hi` contradiction, 0 W, on the pack nobody was watching",
|
||||
len(_p_two) == 1 and why(_p_two, "max_w (inverter-b) is 2000 W"))
|
||||
check("...and the message names the pack the number came off, not the first "
|
||||
"entry - an operator told to lower a number on the wrong device is being "
|
||||
"sent to the wrong device by the message that exists to stop that",
|
||||
not why(_p_two, "max_w (inverter-a)"))
|
||||
check("...while the same rate against two packs that can both take it starts, "
|
||||
"so this is a comparison and not a blanket refusal of rosters",
|
||||
[p for p in validate_startup_config(
|
||||
_roster(5000, 4000, maintenance_charge_w=3000,
|
||||
maintenance_discharge_w=1000, sign_disagree_w=300,
|
||||
unit_sign_disagree_w=300), -5000, 5000)
|
||||
if "maintenance_charge_w" in p] == [])
|
||||
check("...and at a ROSTER OF ONE `min()` over one cap is that cap: the same "
|
||||
"message, the same boundary, the same refusal the flat config always got",
|
||||
why(validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
||||
-5000, 5000), "THE CEILING WINS")
|
||||
and validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# ⚠️ THE SIBLING, CHECKED RATHER THAN ASSUMED. The shaping is NOT symmetric:
|
||||
# drain files Claim.set at P_MAINTENANCE and owns the actuator, so the ceiling
|
||||
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()
|
||||
@@ -394,6 +458,149 @@ check("arbiter: the drain SET is clamped, not contradicted",
|
||||
check("...and the clamp names itself, which is why it is not refused",
|
||||
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("10. a slew rate the plant can actually follow (SAFETY-39)")
|
||||
# ⚠️ THIS RULE GRADES A RELATIONSHIP, WHICH IS THE POINT OF IT. Every other rule
|
||||
# in this file compares a configured number against another configured number
|
||||
# and refuses one ordering. This one refuses a RATIO: `slew_w` may not exceed
|
||||
# `SLEW_RAIL_FRAC * rail`, so the same `slew_w` is fine at one rating and
|
||||
# refused at another, and raising the rating moves the bound with it instead of
|
||||
# leaving the constant behind. That is the failure the ticket is about -
|
||||
# `slew_w: 1000` was derived at a 1500 W clamp, the live install went to 5000,
|
||||
# and nothing anywhere said the derivation no longer held.
|
||||
print(f" SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = "
|
||||
f"{LOOP_TICK_S:.0f} s / {PLANT_SETTLE_S:.0f} s = {SLEW_RAIL_FRAC:.4f}")
|
||||
|
||||
_R5000 = base(max_w=5000, maintenance_charge_w=2000, maintenance_discharge_w=2000)
|
||||
check("the rail this rule grades is the SITE rail, and at the shipped roster of "
|
||||
"one it is `max_w` exactly - byte for byte the number the single-inverter "
|
||||
"shape has always meant",
|
||||
site_rail_w(_R5000) == 5000.0)
|
||||
|
||||
# ⭐ VERIFIED BY BREAKING IT. This is the acceptance criterion stated as a test:
|
||||
# put the 2026-09-05 pair back and the gate must go red. A rule that could not
|
||||
# be made to fire on the configuration it was written for is a rule nobody has
|
||||
# checked.
|
||||
_broken = validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000)
|
||||
print(f" restoring slew_w 1000 at max_w 5000 -> {len(_broken)} problem(s)")
|
||||
if _broken:
|
||||
print(f" {_broken[0][:160]}...")
|
||||
check("⭐ RESTORING THE 2026-09-05 PAIR - slew_w 1000 at max_w 5000 - IS REFUSED. "
|
||||
"It crosses the whole output range in 10 s against a plant measured at "
|
||||
"18 s, which is not a fast loop, it is a loop that commands its entire "
|
||||
"range before it can observe its answer to the first step",
|
||||
len(_broken) == 1 and why(_broken, "slew_w is 1000 W"))
|
||||
check("...and the refusal says WHICH RAIL it graded against and HOW LONG the "
|
||||
"crossing takes, because 'slew_w is too high' sends an operator to lower "
|
||||
"a number when raising max_w or setting 0 are both correct answers too",
|
||||
why(_broken, "site rail of 5000 W")
|
||||
and why(_broken, "crosses its whole output range in 5.0 cycles"))
|
||||
check("...and it names the plant measurement it is graded against, so the "
|
||||
"number is traceable to a step response rather than to a preference",
|
||||
why(_broken, "goodwe/NOTES.md §5.10"))
|
||||
check("...and it says what to DO, including the setting that cannot go stale",
|
||||
why(_broken, "Set slew_w to 0") and why(_broken, "556 W"))
|
||||
|
||||
# THE BOUNDARY. Equality is the DERIVED VALUE, not a violation - at exactly
|
||||
# `SLEW_RAIL_FRAC * rail` the loop takes precisely the plant's settling time to
|
||||
# cross its range, which is the constraint met. Rule 6 permits equality for the
|
||||
# same shape of reason and rule 5 refuses it for the opposite one; this is
|
||||
# rule 6's case, and getting it backwards would refuse the value this rule
|
||||
# exists to recommend.
|
||||
_bound = SLEW_RAIL_FRAC * 5000.0
|
||||
check(f"at exactly the derived value ({_bound:.0f} W) the config STARTS - "
|
||||
"equality is the constraint satisfied, not violated",
|
||||
validate_startup_config(dict(_R5000, slew_w=int(_bound)), -5000, 5000) == [])
|
||||
check("...and one watt of slack above it starts too, because a boundary that "
|
||||
"is not meaningful to one watt must not refuse on one watt - an operator "
|
||||
"reading 556 off a status page that rounded it is not misconfigured",
|
||||
validate_startup_config(dict(_R5000, slew_w=556), -5000, 5000) == [])
|
||||
check("...and well above it does not",
|
||||
validate_startup_config(dict(_R5000, slew_w=600), -5000, 5000) != [])
|
||||
|
||||
# SLOWER IS ALWAYS ACCEPTED, and the live install is the reason it has to be.
|
||||
check("the interim that is holding on the live system - slew_w 400 against "
|
||||
"max_w 5000, applied 2026-09-05 12:00 - STARTS. It is 12.5 ticks to cross "
|
||||
"the rail against the 9 this rule requires, and refusing 'too slow' would "
|
||||
"refuse the mitigation",
|
||||
validate_startup_config(dict(_R5000, slew_w=400), -5000, 5000) == [])
|
||||
check("...and so does the schema floor at the biggest rail, because there is no "
|
||||
"such thing as too slow here",
|
||||
validate_startup_config(dict(_R5000, slew_w=50), -5000, 5000) == [])
|
||||
|
||||
# ⚠️ 0 IS NOT GRADED AND MUST NOT BE. It means "follow the rail" and satisfies
|
||||
# this rule by construction at every rail there is; a rule that refused it would
|
||||
# refuse the fix. Checked at three ratings so that "it passes" cannot be an
|
||||
# accident of the default one.
|
||||
# ⚠️ NO SLEW PROBLEM AT ANY RATING, rather than NO PROBLEMS AT ALL. At the
|
||||
# schema floor of 100 W rules 5 and 5b fire on the shipped sign thresholds,
|
||||
# which is correct and is not this rule's business - asserting an empty list
|
||||
# would make this check fail for a reason it is not about, and somebody would
|
||||
# then "fix" it by moving a sign threshold.
|
||||
_zero_problems = {m: [p for p in validate_startup_config(
|
||||
base(max_w=m, slew_w=0,
|
||||
maintenance_charge_w=min(2000, m),
|
||||
maintenance_discharge_w=min(2000, m)),
|
||||
-abs(m), abs(m)) if "slew_w" in p]
|
||||
for m in (100, 500, 2000, 5000, 15000)}
|
||||
check("slew_w 0 - FOLLOW THE RAIL - is never refused at any rating, which it "
|
||||
"must not be, because it is the shipped value and it satisfies this rule "
|
||||
f"by construction at every rail there is: {_zero_problems}",
|
||||
not any(_zero_problems.values()))
|
||||
check("a negative slew_w is refused as nonsense rather than treated as 0 - it "
|
||||
"is a magnitude, and 0 already has a meaning here",
|
||||
why(validate_startup_config(dict(_R5000, slew_w=-400), -5000, 5000),
|
||||
"must not be negative"))
|
||||
|
||||
# ⭐ THE ROSTER, WHICH IS THE WHOLE REASON THIS GRADES A SUM. `slew_w` is not a
|
||||
# per-unit quantity: main.py builds `Tuning(max_w=self.site_max_w)` and
|
||||
# `compute()` emits the SITE target. So the rail is the roster sum, and adding
|
||||
# an inverter moves this bound with no operator action and no second derivation.
|
||||
#
|
||||
# ⚠️ INVERTER B'S 4600 W IS ITS DATASHEET AND SERIAL, NOT A MEASUREMENT, AND
|
||||
# INVERTER B IS NOT ON SITE (SPIKE-04, ~2026-09-12; goodwe/NOTES.md §1.1b). It
|
||||
# is used here because what is graded is the ARITHMETIC OF THE RULE, which does
|
||||
# not depend on the number being measured - not because 9600 W is a fact about
|
||||
# this house yet.
|
||||
def _roster(a=5000, b=4600, **over):
|
||||
o = base(setpoint_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.a", "max_w": a,
|
||||
"bms_soc_floor": 10, "soc_entity": "sensor.sa", "batt_entity": "sensor.ba"},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": b,
|
||||
"bms_soc_floor": 10, "soc_entity": "sensor.sb", "batt_entity": "sensor.bb"}])
|
||||
o.pop("max_w"); o.pop("soc_entity"); o.pop("batt_entity")
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
_site = site_rail_w(_roster())
|
||||
print(f" roster 5000 + 4600 (B FROM DATASHEET, NOT MEASURED) -> site rail "
|
||||
f"{_site:.0f} W, bound {SLEW_RAIL_FRAC * _site:.0f} W")
|
||||
check("the rail is the roster SUM and not the weakest unit, which is what "
|
||||
"separates this rule from 5, 6 and 8 - those grade one watchdog, one "
|
||||
"shaping claim and one pack, and this grades the range one SITE COMMAND "
|
||||
"is a rate across",
|
||||
_site == 9600.0)
|
||||
check("⭐ AND THE BOUND MOVES ON ITS OWN WHEN THE ROSTER DOES: the derived value "
|
||||
"at a 9600 W site is 1067 W with nobody re-deriving anything, which is "
|
||||
"the property that makes this a fix rather than a re-tune",
|
||||
abs(SLEW_RAIL_FRAC * _site - 9600.0 / 9.0) < 1e-9)
|
||||
check("...so the same explicit slew_w that is REFUSED at a 5000 W rail is "
|
||||
"ACCEPTED at 9600 - the bound is on how long the crossing TAKES, and a "
|
||||
"bigger rail at the same step size takes longer to cross. ⚠️ This is the "
|
||||
"direction of the rule stated as a test, because it is not the direction "
|
||||
"the ticket assumed: a growing rail LOOSENS this bound. What the rule "
|
||||
"protects against is the constant being left behind when the rail moves; "
|
||||
"it is not a bound on how much the site may command, and it does not "
|
||||
"bound the over-absorption, which is a `gain` term SAFETY-39 measured "
|
||||
"and deliberately did not change",
|
||||
validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000) != []
|
||||
and validate_startup_config(_roster(slew_w=1000)) == [])
|
||||
check("...and a slew that outruns even the doubled rail is still refused there, "
|
||||
"so the roster path is not simply un-graded",
|
||||
why(validate_startup_config(_roster(slew_w=2000)),
|
||||
"site rail of 9600 W"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
||||
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
|
||||
@@ -407,12 +614,21 @@ import re # noqa: E402
|
||||
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||
with open(_CFG, encoding="utf-8") as _fh:
|
||||
_txt = _fh.read()
|
||||
# ⚠️ `slew_w` JOINED THIS SET AT SAFETY-39 AND IT IS THE INTERESTING MEMBER.
|
||||
# The others are refused when they are too big for `max_w`; this one is refused
|
||||
# when it is too big for `max_w` AS A RATIO, so shipping the wrong number here
|
||||
# would be shipping the exact defect the card is about with the gate that
|
||||
# catches it standing right beside it. Reading it out of config.yaml is what
|
||||
# makes "the shipped value is 0" a fact rather than a claim.
|
||||
_shipped = {k: float(v) for k, v in re.findall(
|
||||
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
|
||||
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||
check("config.yaml really was read (all four defaults found)",
|
||||
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w"
|
||||
r"|slew_w): (-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||
check("config.yaml really was read (all five defaults found)",
|
||||
set(_shipped) == {"max_w", "maintenance_charge_w",
|
||||
"maintenance_discharge_w", "sign_disagree_w"})
|
||||
"maintenance_discharge_w", "sign_disagree_w", "slew_w"})
|
||||
check(f"...and the shipped slew_w is 0, i.e. FOLLOW THE RAIL - the one value "
|
||||
f"that cannot be left behind when max_w moves (got {_shipped.get('slew_w')})",
|
||||
_shipped.get("slew_w") == 0.0)
|
||||
check(f"the shipped defaults are accepted {_shipped}",
|
||||
validate_startup_config(base(**_shipped), -5000, 5000) == [])
|
||||
check("...and the shipped charge rate is at or under the shipped ceiling",
|
||||
@@ -523,9 +739,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")
|
||||
@@ -753,6 +986,339 @@ check("...and MaintConfig's dataclass defaults carry no numeric literal either "
|
||||
not re.search(r"^\s*(charge_w|discharge_w):\s*float\s*=\s*-?\d",
|
||||
_srcs["maintenance.py"], re.M))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("SPIKE-04a: inverter B's documented rating, and the provenance beside it")
|
||||
# ⚠️ WHY A DOCUMENTATION CARD LEFT A CHECK BEHIND. Inverter B's rating was
|
||||
# written down in THREE places - config.yaml's commented roster example,
|
||||
# DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator would
|
||||
# POST" - as 3000 W, a figure nobody had measured, read off a datasheet, or
|
||||
# sourced at all. It was wrong: SPIKE-04a (2026-09-04) identified inverter B
|
||||
# from the owner's model number and serial as a GoodWe GW5048D-ES rated 4600 W
|
||||
# on the battery path. Three transcriptions of one number drifting away from
|
||||
# the truth together is this repo's recurring defect in its plainest form, and
|
||||
# correcting them once WITHOUT PINNING THEM buys nothing: the next editor
|
||||
# touches one of the three and the other two stay wrong, silently.
|
||||
#
|
||||
# ⚠️ AND THE PROVENANCE IS GRADED, NOT ONLY THE VALUE, WHICH IS THE HALF THAT
|
||||
# MATTERS. 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT. The datasheet
|
||||
# prints it under BATTERY INPUT DATA, so it is probably DC, while `max_w` is
|
||||
# graded against 0x0518 - the inverter's own AC power - which would put the AC
|
||||
# ceiling nearer 4324 W. An editor who tidies that caveat away leaves a paper
|
||||
# number reading as an observation of hardware that does not exist yet, and
|
||||
# THAT is the failure this card exists to prevent. So the caveat is what these
|
||||
# checks reach for; a check that graded 4600 alone would watch the value and
|
||||
# miss the lie.
|
||||
#
|
||||
# ⚠️ WHAT THIS DELIBERATELY DOES NOT GRADE: the test fixtures. Several files
|
||||
# build a second inverter at 3000 W on purpose, because the property those
|
||||
# checks consume is that B's rating is UNMISTAKABLY not A's, and the real pair
|
||||
# (5000/4600) is a 400 W gap - narrower than `slew_w`. Those are illustrative,
|
||||
# they say so at the line, and they are not statements of B's rating. The three
|
||||
# sites below are the ones that ARE such statements.
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_docs_txt = open(os.path.join(_HERE, "DOCS.md"), encoding="utf-8").read()
|
||||
_probe_txt = open(os.path.join(_HERE, "tools", "supervisor_shape_probe.py"),
|
||||
encoding="utf-8").read()
|
||||
|
||||
# One regex per site, each anchored on something only THAT site has, so a match
|
||||
# cannot silently wander into a neighbouring blob. The probe's anchor is the
|
||||
# operator blob's real entity name: its section D refusal entries use
|
||||
# `number.b` and one of them carries a deliberate 9999, so an anchor loose
|
||||
# enough to reach them would grade the wrong dict and still look green.
|
||||
_b_rating = {
|
||||
"config.yaml": re.search(
|
||||
r"#\s+- name: inverter-b\b(?:[^\n]*\n)*?\s*#\s+max_w:\s*(\d+)", _txt),
|
||||
"DOCS.md": re.search(
|
||||
r"^ - name: inverter-b\b(?:[^\n]*\n)*?^ max_w:\s*(\d+)",
|
||||
_docs_txt, re.M),
|
||||
"supervisor_shape_probe.py": re.search(
|
||||
r'"name": "inverter-b",\s*"setpoint_entity": "number\.goodwe_b_setpoint_w"'
|
||||
r'(?:[^}]*?)"max_w":\s*(\d+)', _probe_txt),
|
||||
}
|
||||
check(f"all three documented rosters were FOUND "
|
||||
f"{ {k: bool(v) for k, v in _b_rating.items()} } - a regex that matches "
|
||||
"nothing is a green check that read nothing",
|
||||
all(_b_rating.values()))
|
||||
# ⚠️ THE `len(...) == 3` IS NOT DECORATION. Without it a regex that stopped
|
||||
# matching would shrink this dict, and "all the values agree" would go GREEN on
|
||||
# the two sites that were still read - a check whose data no longer reaches the
|
||||
# failure it names, which is the exact fault this whole section is about.
|
||||
_b_vals = {k: int(m.group(1)) for k, m in _b_rating.items() if m}
|
||||
check(f"...and all three state the SAME rating for inverter B {_b_vals}",
|
||||
len(_b_vals) == 3 and len(set(_b_vals.values())) == 1)
|
||||
check(f"...and it is 4600 W - the GW5048D-ES datasheet figure, not the 3000 W "
|
||||
f"hypothesis that predated the unit being identified {_b_vals}",
|
||||
len(_b_vals) == 3 and set(_b_vals.values()) == {4600})
|
||||
|
||||
# The provenance triple. Each site must still say WHERE 4600 came from, that it
|
||||
# is graded against an AC register, and what the DC-vs-AC ambiguity does to it -
|
||||
# the last carried by the 4324 W figure, which is the one marker that cannot
|
||||
# survive a careless "it's 4600 now, done" rewrite.
|
||||
#
|
||||
# ⚠️ SEARCHED IN A WINDOW AROUND THE ROSTER, NOT OVER THE WHOLE FILE, AND THE
|
||||
# FIRST VERSION OF THIS CHECK DID THE LATTER. `0x0518` already appeared once in
|
||||
# config.yaml and once in DOCS.md before this card, for unrelated reasons - so a
|
||||
# whole-file `"0x0518" in src` was GREEN on those two files no matter what
|
||||
# happened next to inverter B's rating. That is a check whose data cannot reach
|
||||
# the failure it names, in the same file that exists to complain about them.
|
||||
# The window is the text either side of the roster match, so deleting the caveat
|
||||
# beside the number goes red even where the same word survives elsewhere.
|
||||
_sites = {"config.yaml": _txt, "DOCS.md": _docs_txt,
|
||||
"supervisor_shape_probe.py": _probe_txt}
|
||||
_MARKERS = ("datasheet", "0x0518", "4324")
|
||||
_prov = {}
|
||||
for _name, _src in _sites.items():
|
||||
_m = _b_rating[_name]
|
||||
_win = _src[max(0, _m.start() - 2500):_m.end() + 2500].lower() if _m else ""
|
||||
_prov[_name] = {k: k in _win for k in _MARKERS}
|
||||
for _name, _got in sorted(_prov.items()):
|
||||
check(f"{_name} still carries B's provenance WITHIN 2500 chars of the "
|
||||
f"roster - the datasheet it came from, the 0x0518 it is graded "
|
||||
f"against, and the 4324 W the DC reading would make it {_got}",
|
||||
all(_got.values()))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("rules 5, 5b and 6 on a ROSTER SET - the smallest cap at EVERY position")
|
||||
# ⚠️ SAFETY-32 ROUND 5, AND THE REASON THIS SECTION EXISTS AT ALL. PR #48 was
|
||||
# rejected on FOUR MUTATIONS THAT SURVIVED A 19/19 GREEN SUITE, all of them in
|
||||
# the three lines above:
|
||||
#
|
||||
# validate.py:779 min(caps) -> min(caps[:1]) +4 B GREEN
|
||||
# validate.py:779 min(caps) -> min(caps[-1:]) +5 B GREEN
|
||||
# validate.py:778 caps = [cap for _, cap in caps_ix[:1]] +4 B GREEN
|
||||
# validate.py:893 min(caps_ix[-1:], key=lambda p: p[1]) +5 B GREEN
|
||||
#
|
||||
# NONE of them is an equivalent mutant: each one deletes a safety refusal.
|
||||
# `max_w: 5000` on inverter-a beside `2000` on inverter-b with
|
||||
# `sign_disagree_w: 3000` is refused by the shipped validator with "the
|
||||
# SAFETY-02 sign-disagreement watchdog can never fire" and ACCEPTED by every one
|
||||
# of the four - the site starts, with a watchdog that reports `armed` on the
|
||||
# status page and cannot trip on pack B. That is rule 5's own failure mode
|
||||
# reached THROUGH rule 5.
|
||||
#
|
||||
# WHY THE OLD FIXTURE COULD NOT SEE IT, which is the whole lesson and is now
|
||||
# MUTATION.md habit 10: the fixture was ONE roster, `[5000, 2000]`. On that
|
||||
# roster `min` and `last` and `[-1:]` and `[:2]` all answer 2000. A fixture of N
|
||||
# members cannot separate two reductions that agree on N members, and adding a
|
||||
# member moves the hole instead of closing it.
|
||||
#
|
||||
# SO THE FIXTURE IS A SET, NOT A ROSTER: the smallest cap at EVERY POSITION of
|
||||
# rosters of width 2, 3, 4 and 5. `[:1]` needs width 2 to be caught, `[:2]`
|
||||
# needs 3, `[:3]` needs 4 - the sweep is what terminates the regress that a
|
||||
# single fixture cannot. And the FAMILY TABLE below is COMPUTED AND PRINTED, so
|
||||
# a member no fixture kills shows up as a NAMED SURVIVOR rather than as an
|
||||
# absence from a list nobody re-derived.
|
||||
#
|
||||
# ⚠️ THE DIFFERING CAPS ARE A FIXTURE PROPERTY AND MUST STAY ONE. In production
|
||||
# both packs are expected to floor at 10 % and SPIKE-04a puts inverter B at
|
||||
# 4600 W against A's 5000 W - a 400 W gap, narrower than slew_w. A fixture built
|
||||
# on the real pair would be degenerate on exactly the axis these checks measure.
|
||||
# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST (SPIKE-04, ~2026-09-12). Nothing
|
||||
# in this section is measured off a second unit; the rosters are simulated and
|
||||
# are labelled so.
|
||||
_BIG, _SMALL = 5000.0, 2000.0
|
||||
_RATE = 3000.0 # strictly between them, which is the point
|
||||
|
||||
|
||||
def _roster_n(caps, **over):
|
||||
"""A roster of len(caps) units, unit-0..unit-N, each with its own cap."""
|
||||
o = base(**over)
|
||||
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
|
||||
o.pop(k, None)
|
||||
o["inverters"] = [
|
||||
dict(name=f"unit-{i}", setpoint_entity=f"number.sp{i}",
|
||||
soc_entity=f"sensor.soc{i}", batt_entity=f"sensor.batt{i}",
|
||||
max_w=c, bms_soc_floor=10)
|
||||
for i, c in enumerate(caps)]
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
# The three rules that reduce over `unit_caps_indexed()`, each as
|
||||
# (option name, the token its refusal is recognised by).
|
||||
_CAP_RULES = (
|
||||
("sign_disagree_w", "SAFETY-02 sign-disagreement"),
|
||||
("unit_sign_disagree_w", "SAFETY-30 per-inverter sign-disagreement"),
|
||||
("maintenance_charge_w", "maintenance charge in a cheap window"),
|
||||
)
|
||||
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
|
||||
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
|
||||
|
||||
# THE SET: width x position of the one small cap.
|
||||
_SET = []
|
||||
for _n in (2, 3, 4, 5):
|
||||
for _pos in range(_n):
|
||||
_caps = [_BIG] * _n
|
||||
_caps[_pos] = _SMALL
|
||||
_SET.append((_n, _pos, tuple(_caps)))
|
||||
|
||||
# habit 1: the PRECONDITION as its own check. If a later edit makes the caps
|
||||
# equal, this goes red rather than the claims below going quietly vacuous.
|
||||
check("PRECONDITION: every fixture in the set has a UNIQUE smallest cap, that "
|
||||
"cap is at the stated position, and the rate under test sits STRICTLY "
|
||||
"between it and the others - two equal caps could not tell `min` from "
|
||||
"`first` from `last` and the whole set would be measuring nothing",
|
||||
len(_SET) == 14
|
||||
and all(c[p] == _SMALL and c.count(_SMALL) == 1 and _SMALL < _RATE < _BIG
|
||||
for _n2, p, c in _SET)
|
||||
and {p for _n2, p, _c in _SET} == {0, 1, 2, 3, 4})
|
||||
|
||||
|
||||
def _verdict(opts, token):
|
||||
"""(refused?, the unit the message NAMES, the cap it QUOTES)."""
|
||||
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
|
||||
if not ps:
|
||||
return (False, None, None)
|
||||
m = re.search(r"max_w \(([^)]+)\) is (\d+) W", ps[0])
|
||||
return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
|
||||
|
||||
|
||||
# --- the shipped answer, on every member of the set -------------------------
|
||||
_rows = []
|
||||
for _opt, _token in _CAP_RULES:
|
||||
for _n, _pos, _caps in _SET:
|
||||
_o = _roster_n(list(_caps), **dict(_QUIET, **{_opt: _RATE}))
|
||||
_rows.append((_opt, _n, _pos, _caps, _verdict(_o, _token)))
|
||||
print(f" {len(_CAP_RULES)} rules x {len(_SET)} rosters = {len(_rows)} runs "
|
||||
f"of the REAL validator (rate {_RATE:.0f} W, caps {_SMALL:.0f}/"
|
||||
f"{_BIG:.0f} W, simulated rosters - inverter B does not exist):")
|
||||
for _opt in sorted({r[0] for r in _rows}):
|
||||
_got = [(r[1], r[2], r[4]) for r in _rows if r[0] == _opt]
|
||||
print(f" {_opt:<22} " + " ".join(
|
||||
f"n{n}p{p}:{'REFUSE ' + str(u) if ok else 'starts'}"
|
||||
for n, p, (ok, u, _c) in _got))
|
||||
|
||||
check("⚠️ EVERY ONE OF THE THREE CAP RULES REFUSES ON EVERY MEMBER OF THE SET, "
|
||||
"and NAMES THE UNIT THE NUMBER CAME OFF - at width 2, 3, 4 and 5, with "
|
||||
"the small cap at every position in turn. A reduction that answered "
|
||||
"`first`, `last`, `[:1]`, `[-1:]`, `[:2]` or `[:-1]` instead of `min` "
|
||||
"misses at least one member of this set, which is what four hand-run "
|
||||
"mutations against a 19/19 green suite proved the single `[5000, 2000]` "
|
||||
"fixture could not do",
|
||||
all(ok and unit == f"unit-{p}" and cap == _SMALL
|
||||
for _o2, _n2, p, _c2, (ok, unit, cap) in _rows))
|
||||
|
||||
check("...and it is a COMPARISON, not a blanket refusal of rosters: the same "
|
||||
"three rates against a roster whose caps can ALL take them start "
|
||||
"cleanly, at every width. Without this the line above is satisfied by a "
|
||||
"validator that refuses everything (habit 2 in the negative direction)",
|
||||
all(_verdict(_roster_n([_BIG] * n, **dict(_QUIET, **{opt: _RATE})),
|
||||
tok) == (False, None, None)
|
||||
for opt, tok in _CAP_RULES for n in (1, 2, 3, 4, 5)))
|
||||
|
||||
# --- the POSITIONAL-SLICE FAMILY TABLE, computed and printed ----------------
|
||||
# ⚠️ COMPUTED, NOT LISTED. Each candidate is applied to the SAME caps the
|
||||
# validator saw, and its (unit, cap) answer is compared against what the shipped
|
||||
# validator actually said on that roster. A candidate that agrees everywhere is
|
||||
# a reduction this fixture set CANNOT distinguish from the shipped one, and it
|
||||
# is printed as a SURVIVOR rather than left out of a table nobody re-derived.
|
||||
def _pick(caps, fn):
|
||||
ix = list(enumerate(caps))
|
||||
try:
|
||||
i, c = fn(ix)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
return (f"unit-{i}", c)
|
||||
|
||||
|
||||
_FAMILY = {
|
||||
"min (SHIPPED)": lambda ix: min(ix, key=lambda p: p[1]),
|
||||
"max": lambda ix: max(ix, key=lambda p: p[1]),
|
||||
"first": lambda ix: ix[0],
|
||||
"last": lambda ix: ix[-1],
|
||||
"min(caps[:1])": lambda ix: min(ix[:1], key=lambda p: p[1]),
|
||||
"min(caps[-1:])": lambda ix: min(ix[-1:], key=lambda p: p[1]),
|
||||
"min(caps[:2])": lambda ix: min(ix[:2], key=lambda p: p[1]),
|
||||
"min(caps[:3])": lambda ix: min(ix[:3], key=lambda p: p[1]),
|
||||
"min(caps[1:])": lambda ix: min(ix[1:], key=lambda p: p[1]),
|
||||
"min(caps[:-1])": lambda ix: min(ix[:-1], key=lambda p: p[1]),
|
||||
"min(caps[::2])": lambda ix: min(ix[::2], key=lambda p: p[1]),
|
||||
"mean": lambda ix: (0, sum(c for _i, c in ix) / len(ix)),
|
||||
}
|
||||
_survivors = []
|
||||
print(" the family table - each candidate against what the SHIPPED "
|
||||
"validator answered on the same roster:")
|
||||
for _name, _fn in _FAMILY.items():
|
||||
_misses = []
|
||||
for _opt, _n, _pos, _caps, (_ok, _unit, _cap) in _rows:
|
||||
_pred = _pick(list(_caps), _fn)
|
||||
_pred_refuses = _pred is not None and _pred[1] > 0 and _RATE >= _pred[1]
|
||||
if _opt == "maintenance_charge_w":
|
||||
_pred_refuses = (_pred is not None and _pred[1] > 0
|
||||
and _RATE > _pred[1])
|
||||
_said = ((True, _pred[0], _pred[1]) if _pred_refuses
|
||||
else (False, None, None))
|
||||
if _said != (_ok, _unit, _cap):
|
||||
_misses.append(f"{_opt[:4]}/n{_n}p{_pos}")
|
||||
if not _misses and _name != "min (SHIPPED)":
|
||||
_survivors.append(_name)
|
||||
print(f" {_name:<16} {'AGREES EVERYWHERE (survivor)' if not _misses else 'differs on ' + str(len(_misses)) + ' run(s), first: ' + _misses[0]}")
|
||||
check("⚠️ AND THE FAMILY TABLE HAS NO SURVIVOR BUT THE SHIPPED REDUCTION. "
|
||||
"Eleven candidates - max, first, last, the mean, and every positional "
|
||||
"slice this card has actually been defeated by - are each computed "
|
||||
"against what the real validator answered on the same roster, and every "
|
||||
"one of them differs somewhere in the set. This is the check that would "
|
||||
"have taken PR #48 red four rounds ago: a member no fixture kills is "
|
||||
"PRINTED AS A NAMED SURVIVOR here instead of being absent from a list "
|
||||
f"nobody re-derived. survivors: {_survivors}",
|
||||
_survivors == [])
|
||||
|
||||
# --- the ROSTER OF ONE, where every one of them is the same thing -----------
|
||||
_at_one = {n: _pick([_SMALL], f) for n, f in _FAMILY.items()}
|
||||
print(f" at a ROSTER OF ONE the twelve candidates answer: "
|
||||
f"{sorted({str(v) for v in _at_one.values()})}")
|
||||
check("...and at a ROSTER OF ONE every candidate that still has a member to "
|
||||
"reduce answers THE SAME THING - which is exactly why no "
|
||||
"single-inverter fixture in this repository could ever have caught any "
|
||||
"of this, and why the shipped single-inverter refusals are what they "
|
||||
"always were. The only two that do NOT coincide are `caps[1:]` and "
|
||||
"`caps[:-1]`, which EMPTY the list and would raise: at N=1 they are "
|
||||
"crashes rather than silent wrong answers, so they are not survivors "
|
||||
"there either",
|
||||
{v for v in _at_one.values() if v is not None} == {("unit-0", _SMALL)}
|
||||
and {n for n, v in _at_one.items() if v is None}
|
||||
== {"min(caps[1:])", "min(caps[:-1])"}
|
||||
and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)),
|
||||
"SAFETY-02 sign-disagreement")[0])
|
||||
|
||||
# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w --------------
|
||||
# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL
|
||||
# PIN - and said that way on purpose, because it is the shape of what the pin
|
||||
# cannot see. `unit_sign_disagree_w` is SAFETY-30's, not this card's. It was
|
||||
# graded against `_number(opts, "max_w", 2000.0)`, the FLAT option, three lines
|
||||
# under a branch that had already stopped doing that. Both directions were
|
||||
# measured before the fix and both are asserted here.
|
||||
_leftover = _roster_n([_SMALL, _SMALL],
|
||||
**dict(_QUIET, unit_sign_disagree_w=_RATE))
|
||||
_leftover["max_w"] = _BIG # what a single-inverter install leaves behind
|
||||
_lv = _verdict(_leftover, "SAFETY-30 per-inverter sign-disagreement")
|
||||
print(f" leftover flat max_w {_BIG:.0f} beside a roster of two "
|
||||
f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}")
|
||||
check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT "
|
||||
"WATCHDOG SILENTLY. Two 2000 W units beside a `max_w: 5000` left over "
|
||||
"from a single-inverter install and `unit_sign_disagree_w: 3000` used to "
|
||||
"produce ZERO problems - the site started, the status page said `armed`, "
|
||||
"and the watchdog could not trip on EITHER unit. It is refused now, "
|
||||
"against the weakest unit's own cap, and the message names that unit",
|
||||
_lv == (True, "unit-0", _SMALL))
|
||||
check("...and the over-refusal in the OTHER direction is gone too: a roster of "
|
||||
"two 5000 W units with NO flat `max_w` was refused at 3000 W against a "
|
||||
"2000 W DEFAULT the operator never wrote, for a threshold both units can "
|
||||
"reach. It starts",
|
||||
_verdict(_roster_n([_BIG, _BIG],
|
||||
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
|
||||
"SAFETY-30 per-inverter sign-disagreement")
|
||||
== (False, None, None))
|
||||
check("...and the flat single-inverter config it always refused is refused "
|
||||
"IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is "
|
||||
"the same comparison, the same boundary and the same sentence",
|
||||
why(validate_startup_config(base(max_w=2000, unit_sign_disagree_w=2000),
|
||||
-5000, 5000), "unit_sign_disagree_w")
|
||||
and validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
|
||||
-5000, 5000) == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-39's evidence, on admin/goodwe's OWN plant model.
|
||||
|
||||
python3 tools/slew_sim.py --repro # ⭐ the 2026-09-05 excursion, replayed
|
||||
python3 tools/slew_sim.py --slew # the derivation: slew against the rail
|
||||
python3 tools/slew_sim.py --gain # what the slew does NOT own
|
||||
python3 tools/slew_sim.py --roster # N=1 -> N=2, and the rail that moved
|
||||
python3 tools/slew_sim.py --all
|
||||
|
||||
WHY THIS IS A SCRIPT AND NOT A `test_*.py`
|
||||
==========================================
|
||||
Byte for byte tools/alloc_sim.py's reason, and it has not changed: this needs
|
||||
admin/goodwe checked out beside this repository for `plantsim.Plant`, and the
|
||||
add-on's CI job has no credentials for that repository - it is private and the
|
||||
job's token is minted for this one. A `test_*.py` here is globbed and RUN by
|
||||
.gitea/workflows/checks.yml, so a file that needs the sibling repo would either
|
||||
fail the gate or - worse - pass SILENTLY having checked nothing.
|
||||
|
||||
So the rig evidence lives here and is quoted in the pull request and in
|
||||
DECISIONS/SAFETY-39-slew-against-the-rail.md, and test_control.py's SAFETY-39
|
||||
section grades the same properties in CI against a plant it models itself and
|
||||
says it models. The two plants are INDEPENDENT - a first-order lag written in
|
||||
test_control.py versus `plantsim.Plant`'s ramp-plus-lag with an efficiency and
|
||||
a SoC integrator - and they agree on every sign and rank below, which is worth
|
||||
more than either alone.
|
||||
|
||||
WHAT IS REAL IN HERE
|
||||
====================
|
||||
* the control law - `compute()` and `Tuning`, imported out of THIS checkout,
|
||||
never transcribed.
|
||||
* the wire - every command goes out through `frame_check.setpoint_atomic()`,
|
||||
the firmware's own encoder, and back in through `plantsim.parse`/`decode`,
|
||||
because `Inverter.command()` is what drives the modelled unit.
|
||||
* the plant - `plantsim.Plant`, the model the rig uses.
|
||||
|
||||
MODELLED, and therefore not evidence about the thing modelled:
|
||||
* the meter. `grid = load - pv - sum(battery)` with no poll delay and no
|
||||
noise, sampled once per tick. The real P1 is a poll with its own age.
|
||||
* the tick. 2.0 s, which is what the live install was MEASURED running on
|
||||
2026-09-05; it is a parameter here (`--tick`) and not a constant, because
|
||||
the whole of this card is that the derivation depends on the regime.
|
||||
|
||||
⚠️ WHAT THIS CANNOT SHOW, SAID BEFORE THE RESULTS RATHER THAN AFTER THEM
|
||||
=======================================================================
|
||||
THE -5154 W BATTERY EXCURSION IS NOT REPRODUCIBLE HERE, AND NOT BECAUSE THE
|
||||
TUNING IS WRONG. `plantsim.Plant.limit()` clamps the plant at `p_max`, so the
|
||||
modelled battery cannot exceed its rating by one watt at any tuning, on any
|
||||
step, ever. Neither can test_control.py's model. An acceptance criterion of
|
||||
"battery power no longer exceeds max_w in simulation" would therefore be
|
||||
UNFALSIFIABLE - it passes on the broken configuration too - so no criterion in
|
||||
this card is written on it, and the ticket's own AC to that effect is the one
|
||||
that had to be rewritten rather than met.
|
||||
|
||||
What IS reproducible, and is what everything below measures:
|
||||
* the setpoint stepping a full `slew_w` per tick, over and over (the live
|
||||
symptom that escalated 2 -> 1 -> 13 -> 43 large steps per 30 min);
|
||||
* the loop OVER-ABSORBING - taking more than the site needed - and driving
|
||||
the meter from EXPORT into IMPORT, i.e. manufacturing the error it chases.
|
||||
|
||||
⚠️ INVERTER B DOES NOT EXIST. `plantsim.INVERTER_B` is inverter A's numbers
|
||||
COPIED, `measured=False`. Every N=2 row below is therefore a result about TWO
|
||||
COPIES OF A on a 10000 W rail, NOT about the ~9600 W site that B's datasheet
|
||||
and serial imply (goodwe/NOTES.md §1.1b; B is a GW5048D-ES rated 4600 W, and
|
||||
that figure may itself be DC). Each such row prints the caveat with it.
|
||||
"""
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ADDON = HERE.parent.parent # the goodwe-addon checkout root
|
||||
|
||||
|
||||
def find_sim():
|
||||
"""admin/goodwe's `sim/` directory, or None - and the caller says which.
|
||||
|
||||
$GOODWE_SIM first, the same override test_freeze.py and alloc_sim.py take,
|
||||
then the sibling checkouts that actually occur. A missing sibling is not a
|
||||
finding about the control law and must never be reported as one.
|
||||
"""
|
||||
cands = []
|
||||
if os.environ.get("GOODWE_SIM"):
|
||||
cands.append(pathlib.Path(os.environ["GOODWE_SIM"]))
|
||||
for up in (ADDON.parent, ADDON.parent.parent):
|
||||
cands += [up / "goodwe" / "sim", up / "goodwe-session" / "goodwe" / "sim"]
|
||||
for c in cands:
|
||||
if (c / "plantsim.py").exists():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def load(sim):
|
||||
sys.path[:0] = [str(sim), str(sim.parent / "master")]
|
||||
os.environ.setdefault("GOODWE_ADDON_REPO", str(ADDON))
|
||||
sys.path[:0] = [str(ADDON / "goodwe_controller")]
|
||||
import plantsim # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
return plantsim, control
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# one experiment
|
||||
|
||||
|
||||
def step_run(plantsim, control, *, gain, slew_w, rail_w, n=1, tick_s=2.0,
|
||||
export_w=3385.0, load_w=350.0, phase=5, cycles=300, dt=0.05,
|
||||
lag_s=None):
|
||||
"""One export step onto a converged loop, through the real wire.
|
||||
|
||||
`rail_w` is the SITE rail - what `main.py` hands `compute()` as `max_w`
|
||||
(`Controller.site_max_w`, the roster sum). Each of the `n` modelled units
|
||||
is rated `rail_w / n` and is commanded its even share, which is the
|
||||
arrangement ARCH-01's split-invariance says any valid split is equivalent
|
||||
to at the meter.
|
||||
"""
|
||||
over = {} if lag_s is None else dict(lag_s=lag_s)
|
||||
spec = dict(plantsim.INVERTER_A, rating_w=rail_w / n, **over)
|
||||
invs = [plantsim.Inverter.from_spec(name, spec) for name in "AB"[:n]]
|
||||
site = plantsim.Site(invs, load=load_w, pv=0.0)
|
||||
tuning = control.Tuning(gain=gain, max_w=rail_w, slew_w=slew_w,
|
||||
target_grid_w=-10.0)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
worst_import, peak, biggest, last = -1e9, 0.0, 0.0, None
|
||||
for c in range(cycles):
|
||||
if c == phase:
|
||||
site.pv = export_w + load_w
|
||||
site.grid = site.load - site.pv - site.battery
|
||||
# ⚠️ ONE UNIT'S POWER, NOT THE SITE'S SUM, at n > 1. The shipped add-on
|
||||
# binds a single `inverter_power` sensor, so a two-inverter install has
|
||||
# a loop that sees unit A only. sitesim.run_site() makes the same choice
|
||||
# and says at length why feeding the sum would be a KINDER rig than the
|
||||
# deployment.
|
||||
actual = invs[0].plant.actual
|
||||
d = control.compute(prev, site.grid, actual, tuning, sat, i_w)
|
||||
if c >= phase and last is not None:
|
||||
biggest = max(biggest, abs(d.target_w - last))
|
||||
last = d.target_w
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
for inv in invs:
|
||||
inv.command(d.target_w / n)
|
||||
site.settle(tick_s, dt=dt)
|
||||
if c >= phase:
|
||||
worst_import = max(worst_import, site.grid)
|
||||
peak = max(peak, abs(site.battery))
|
||||
return dict(imp=worst_import, over=peak - export_w, step=biggest)
|
||||
|
||||
|
||||
def family(plantsim, control, *, exports=None, phases=(5, 6, 7), **kw):
|
||||
"""The worst case over a family of step sizes and phases.
|
||||
|
||||
⚠️ A FAMILY, NOT ONE STEP, AND THAT IS ITSELF A FINDING. A single run of
|
||||
this loop is not evidence: the 15 W deadband, the 10 W quantisation and the
|
||||
phase of the step against the tick move the answer by hundreds of watts,
|
||||
NON-MONOTONICALLY, so two adjacent slews rank either way depending on which
|
||||
step you happen to run. The first version of this derivation was a single
|
||||
step and reported gain 0.3 as WORSE than gain 0.6, which the family shows is
|
||||
backwards. Everything printed below is a worst case over the family, which
|
||||
is how admin/goodwe grades the same kind of quantity (config.yaml's "1890
|
||||
closed-loop runs", "924 runs").
|
||||
"""
|
||||
exports = exports or range(1000, 4300, 200)
|
||||
worst = dict(imp=-1e9, over=-1e9, step=0.0, big=0, runs=0)
|
||||
for export_w in exports:
|
||||
for phase in phases:
|
||||
r = step_run(plantsim, control, export_w=float(export_w),
|
||||
phase=phase, **kw)
|
||||
worst["runs"] += 1
|
||||
worst["imp"] = max(worst["imp"], r["imp"])
|
||||
worst["over"] = max(worst["over"], r["over"])
|
||||
worst["step"] = max(worst["step"], r["step"])
|
||||
worst["big"] += r["step"] >= 900.0
|
||||
return worst
|
||||
|
||||
|
||||
def row(label, w):
|
||||
print(f" {label:<44} step {w['step']:6.0f} W "
|
||||
f"{w['big']:3}/{w['runs']} runs >= 900 W "
|
||||
f"over-absorbed {w['over']:6.0f} W meter to {w['imp']:+7.0f} W")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def repro(ps, ct, tick_s):
|
||||
print("⭐ THE 2026-09-05 EXCURSION, REPLAYED - gain 0.6, max_w 5000, "
|
||||
f"slew_w 1000, {tick_s:.0f} s tick")
|
||||
print(" Live, 11:43:55-11:45:55: grid -3385 W, setpoint 380 -> 5000 in five")
|
||||
print(" ticks, battery -5154 W, grid +2831 W. One 3385 W export step here:\n")
|
||||
print(f" {'t':>5} {'grid in':>9} {'setpoint':>9} {'battery':>9} {'grid out':>9} reason")
|
||||
spec = dict(ps.INVERTER_A)
|
||||
inv = ps.Inverter.from_spec("A", spec)
|
||||
site = ps.Site([inv], load=350.0, pv=0.0)
|
||||
t = ct.Tuning(gain=0.6, max_w=5000.0, slew_w=1000.0, target_grid_w=-10.0)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
for c in range(34):
|
||||
if c == 5:
|
||||
site.pv = 3385.0 + 350.0
|
||||
site.grid = site.load - site.pv - site.battery
|
||||
g = site.grid
|
||||
d = ct.compute(prev, g, inv.plant.actual, t, sat, i_w)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
inv.command(d.target_w)
|
||||
site.settle(tick_s, dt=0.05)
|
||||
print(f" {c * tick_s:5.0f} {g:9.0f} {d.target_w:9.0f} "
|
||||
f"{site.battery:9.0f} {site.grid:9.0f} {d.reason}")
|
||||
print("\n ⚠️ THE BATTERY NEVER PASSES 5000 W HERE AND CANNOT: plantsim.Plant")
|
||||
print(" clamps at its rating. The -5154 W figure is NOT reproducible in")
|
||||
print(" this model at any tuning, so nothing in this card is graded on it.")
|
||||
print(" What IS reproduced is the setpoint stepping 1000 W a tick to the")
|
||||
print(" rail, and the meter crossing from export into import.")
|
||||
|
||||
|
||||
def slew_table(ps, ct, tick_s, rail):
|
||||
frac = ct.SLEW_RAIL_FRAC
|
||||
print(f"THE DERIVATION - gain 0.6, site rail {rail:.0f} W, {tick_s:.0f} s tick, "
|
||||
f"51 export steps 1.0-4.2 kW\n")
|
||||
print(f" the constraint: the loop must not cross its whole output range "
|
||||
f"faster than the")
|
||||
print(f" plant settles. {rail:.0f} W / ({ct.PLANT_SETTLE_S:.0f} s / "
|
||||
f"{tick_s:.0f} s per tick) = {frac * rail:.0f} W per tick.\n")
|
||||
for slew in (1000, 800, 700, 600, int(frac * rail), 500, 400, 300, 250):
|
||||
w = family(ps, ct, gain=0.6, slew_w=float(slew), rail_w=rail,
|
||||
tick_s=tick_s)
|
||||
tag = " <- the derived value" if slew == int(frac * rail) else (
|
||||
" <- the 2026-09-05 live pair" if slew == 1000 else (
|
||||
" <- the live interim" if slew == 400 else ""))
|
||||
row(f"slew_w {slew}{tag}", w)
|
||||
print("\n READ IT THIS WAY. The >= 900 W step - the live symptom, which")
|
||||
print(" escalated 2 -> 1 -> 13 -> 43 per 30 min that morning - is gone from")
|
||||
print(" every run below 900 W of slew, and that is what the slew owns.")
|
||||
print(" The OVER-ABSORPTION does not move, and at the slowest slews it is")
|
||||
print(" slightly WORSE. The rig cannot pick the derived value out of that")
|
||||
print(" column, and it is not asked to: the CONSTRAINT picks it, and this")
|
||||
print(" table is what says what the constraint does and does not buy.")
|
||||
|
||||
|
||||
def gain_table(ps, ct, tick_s, rail):
|
||||
frac = ct.SLEW_RAIL_FRAC
|
||||
print(f"WHAT THE SLEW DOES NOT OWN - slew_w at the derived "
|
||||
f"{frac * rail:.0f} W, gain swept\n")
|
||||
for gain in (0.6, 0.5, 0.4, 0.3, 0.2, 0.1):
|
||||
w = family(ps, ct, gain=gain, slew_w=frac * rail, rail_w=rail,
|
||||
tick_s=tick_s)
|
||||
row(f"gain {gain}" + (" <- shipped" if gain == 0.6 else ""), w)
|
||||
print("\n `slew_w` bounds the OUTPUT. The integrator accumulates")
|
||||
print(" `gain * error` per cycle and is bounded only by `integrator_max_w`,")
|
||||
print(" so at gain 0.6 into a 3.4 kW error it moves ~2000 W per tick and")
|
||||
print(f" reaches the {rail:.0f} W rail in under three ticks WHATEVER the slew is.")
|
||||
print(" The over-absorption is therefore a GAIN term. `gain: 0.6` is the")
|
||||
print(" next constant to re-derive; SAFETY-39 measured it and changed")
|
||||
print(" nothing, because two control-law constants moving in one release on")
|
||||
print(" a running house makes the next measurement uninterpretable.")
|
||||
|
||||
|
||||
def roster_table(ps, ct, tick_s):
|
||||
frac = ct.SLEW_RAIL_FRAC
|
||||
print("N=1 -> N=2: THE RAIL IS WHAT MOVED\n")
|
||||
print(" ⚠️ INVERTER B DOES NOT EXIST (SPIKE-04, ~2026-09-12) AND")
|
||||
print(" plantsim.INVERTER_B IS INVERTER A COPIED, measured=False. The")
|
||||
print(" N=2 rows are therefore about TWO COPIES OF A on a 10000 W rail.")
|
||||
print(" The site B actually implies is ~9600 W (5000 + 4600), and 4600 is")
|
||||
print(" B's DATASHEET and SERIAL, not a measurement, and may be DC -")
|
||||
print(" goodwe/NOTES.md §1.1b. Read the rows as architecture, not as a")
|
||||
print(" prediction about this house.\n")
|
||||
for n, rail in ((1, 5000.0), (2, 10000.0)):
|
||||
for slew in (1000.0, frac * rail):
|
||||
w = family(ps, ct, gain=0.6, slew_w=slew, rail_w=rail, n=n,
|
||||
tick_s=tick_s)
|
||||
lab = (f"n={n} rail {rail:.0f} slew {slew:.0f}"
|
||||
+ (" [B = A COPIED]" if n == 2 else ""))
|
||||
row(lab, w)
|
||||
print(f"\n The derived value at the REAL 9600 W site is "
|
||||
f"{frac * 9600:.0f} W per tick, and")
|
||||
print(" validate.py reads it off `sum(unit_caps(opts))` - the same number")
|
||||
print(" `Controller.site_max_w` builds - so adding inverter B moves the")
|
||||
print(" bound with no operator action and no second derivation.")
|
||||
print("\n ⚠️ AND THE DIRECTION IS NOT THE ONE THE TICKET ASSUMED. A rail that")
|
||||
print(" GROWS loosens this bound: slew_w 1000 crosses a 9600 W rail in")
|
||||
print(" 19.2 s and PASSES, while at 5000 W the same 1000 is the")
|
||||
print(" configuration that was measured failing. That is what the")
|
||||
print(" constraint says and it is correct - the bound is on how long the")
|
||||
print(" crossing TAKES, and a bigger rail at the same step size takes")
|
||||
print(" longer to cross.")
|
||||
print("\n ⚠️ AND THE >= 900 W STEP COUNT STAYS AT 48/51 AT THE DERIVED VALUE")
|
||||
print(" ON THE N=2 ROW, WHICH IS NOT A FAILURE - IT IS THE THRESHOLD GOING")
|
||||
print(" STALE, on the same day and in the same way as the constant. 900 W")
|
||||
print(" was a meaningful step against a 5000 W rail; against 10000 W the")
|
||||
print(" derived step IS 1111 W, and a check written on the absolute 900")
|
||||
print(" would report the correct tuning as broken. Anything grading this")
|
||||
print(" symptom at N>=2 has to grade it as a FRACTION of the rail too.")
|
||||
print("\n WHAT DOES NOT GET WORSE HERE, STATED BECAUSE IT WAS EXPECTED TO.")
|
||||
print(" Over the SAME family of 1.0-4.2 kW export steps, doubling the rail")
|
||||
print(" does not worsen the over-absorption (958 -> 810 W): the extra rail")
|
||||
print(" is headroom these disturbances never ask for. That is a statement")
|
||||
print(" about this family, not about the site - a bigger site sees bigger")
|
||||
print(" steps, and the over-absorption is a `gain` term either way, which")
|
||||
print(" the enforced ratio does not bound and this card did not change.")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--repro", action="store_true")
|
||||
ap.add_argument("--slew", action="store_true")
|
||||
ap.add_argument("--gain", action="store_true")
|
||||
ap.add_argument("--roster", action="store_true")
|
||||
ap.add_argument("--all", action="store_true")
|
||||
ap.add_argument("--tick", type=float, default=None,
|
||||
help="seconds per control cycle (default: control.LOOP_TICK_S)")
|
||||
ap.add_argument("--rail", type=float, default=5000.0)
|
||||
a = ap.parse_args()
|
||||
if not any((a.repro, a.slew, a.gain, a.roster, a.all)):
|
||||
a.all = True
|
||||
sim = find_sim()
|
||||
if sim is None:
|
||||
sys.exit("slew_sim: admin/goodwe's sim/ is not beside this checkout, so "
|
||||
"plantsim.Plant is unavailable. Clone it as a sibling or set "
|
||||
"$GOODWE_SIM. This is a missing repository, NOT a result about "
|
||||
"the control law - do not report it as one.")
|
||||
ps, ct = load(sim)
|
||||
tick = a.tick if a.tick is not None else ct.LOOP_TICK_S
|
||||
print(f"slew_sim: plant from {sim}")
|
||||
print(f" control law from {ADDON / 'goodwe_controller' / 'app'}")
|
||||
print(f" {ps.Inverter.from_spec('A', ps.INVERTER_A).describe()}")
|
||||
print(f" tick {tick:.1f} s, SLEW_RAIL_FRAC {ct.SLEW_RAIL_FRAC:.4f} "
|
||||
f"= {ct.LOOP_TICK_S:.0f} s / {ct.PLANT_SETTLE_S:.0f} s\n")
|
||||
if a.repro or a.all:
|
||||
repro(ps, ct, tick); print()
|
||||
if a.slew or a.all:
|
||||
slew_table(ps, ct, tick, a.rail); print()
|
||||
if a.gain or a.all:
|
||||
gain_table(ps, ct, tick, a.rail); print()
|
||||
if a.roster or a.all:
|
||||
roster_table(ps, ct, tick)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -40,8 +40,24 @@ 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.
|
||||
|
||||
⚠️ RE-RUN AGAIN 2026-09-05, on the same 2026.08.0 image, because SAFETY-32's
|
||||
rebase onto SPIKE-04a merged two independent edits into §C and §D - the required
|
||||
`bms_soc_floor` and inverter B's datasheet `max_w: 4600`. A transcript stitched
|
||||
together by hand from two runs is not a measurement, so this one was measured:
|
||||
`all cases passed`, exit 0. B's 4600 W is STILL its DATASHEET figure and not a
|
||||
reading off any unit, and B's `bms_soc_floor: 15` in §C is illustrative - it
|
||||
differs from A's 10 so that the per-entry number is exercised at all, while
|
||||
production is expected to have BOTH packs at 10 %.
|
||||
"""
|
||||
import sys, json, copy
|
||||
sys.path.insert(0, "/usr/src/supervisor")
|
||||
@@ -145,13 +161,47 @@ 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 ==")
|
||||
# ⚠️ B's 4600 W IS FROM ITS DATASHEET, NOT A PLACEHOLDER AND NOT A MEASUREMENT.
|
||||
# SPIKE-04a (2026-09-04) identified inverter B from the owner's model number and
|
||||
# serial - a GoodWe GW5048D-ES, serial `55048ESU22AW0992` - and the datasheet's
|
||||
# Max. Charging / Discharging Power is 4600 W. The 3000 W that stood here was a
|
||||
# hypothesis from before the unit was known and was simply wrong; the 5000 VA AC
|
||||
# nameplate is wrong for this purpose too, because nothing will be connected to
|
||||
# B's MPPTs so the battery path is the ceiling. See goodwe/NOTES.md §1.1b.
|
||||
#
|
||||
# ⚠️ AND IT IS STILL NOT MEASURED. The datasheet prints 4600 W under BATTERY
|
||||
# INPUT DATA, so it is probably DC, while `max_w` is graded against 0x0518 - the
|
||||
# inverter's own AC power. If it is DC the AC ceiling is nearer 4600 x 0.94 =
|
||||
# 4324 W. NOTES.md §1.1b records both readings without picking one, and
|
||||
# SAFETY-20's `min(max_w, dev_max)` is what bounds it correctly once dev_max is
|
||||
# read off the unit. So this blob is what an operator would POST TODAY, not a
|
||||
# claim about what inverter B will turn out to do.
|
||||
#
|
||||
# ⚠️ THE TWO RATINGS ARE STILL DIFFERENT (5000 / 4600) AND MUST STAY DIFFERENT.
|
||||
# This file's own §B is the record of what an instrument that cannot distinguish
|
||||
# its two cases is worth. Note that 5000/4600 is a much NARROWER separation than
|
||||
# the 5000/3000 it replaces - fine here, where nothing computes on the numbers,
|
||||
# and NOT fine in the test fixtures, which is why those keep a wide illustrative
|
||||
# pair instead of the real one.
|
||||
#
|
||||
# ⚠️ AND `bms_soc_floor` IS A REQUIRED PER-ENTRY KEY SINCE SAFETY-32, so this
|
||||
# blob is also what proves an operator can still POST a two-inverter config
|
||||
# after that. THE TWO FLOORS DIFFER ON PURPOSE (10 and 15): equal values would
|
||||
# not exercise a per-entry number at all, for the same reason the two ratings
|
||||
# must stay different.
|
||||
# ⚠️ NEITHER OF B'S NUMBERS IS A MEASUREMENT OF A PACK. Only inverter A's 10 %
|
||||
# is read off hardware (register 0x0560 on the reference GW5000S-BP). B's 4600 W
|
||||
# is its DATASHEET figure and B's 15 % is an illustrative value chosen to differ
|
||||
# - there is no second pack on this site, and in production BOTH packs are
|
||||
# expected to floor at 10 %, which is why the differing pair lives here and in
|
||||
# the fixtures rather than in config.yaml's example.
|
||||
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": 4600, "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 +212,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": 4600,
|
||||
"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)
|
||||
|
||||
|
||||
@@ -198,6 +272,180 @@ except Exception as e: # noqa: BLE001
|
||||
show("Supervisor's UI renderer survives the shape", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
print("\n== F. UX-01: the TRANSLATED Configuration tab, rendered ==")
|
||||
# ⚠️ WHAT THIS SECTION MEASURES, AND WHY IT IS NOT AN ASSERTION ABOUT A FILE.
|
||||
# UX-01 adds translations/en.yaml and translations/nl.yaml. Whether they reach
|
||||
# an operator depends on two pieces of somebody else's software, not on us:
|
||||
#
|
||||
# 1. SUPERVISOR decides whether a file is loaded at all.
|
||||
# `supervisor/store/data.py:_read_app_translations()` globs this directory,
|
||||
# keys each file by its STEM, and runs it through `SCHEMA_APP_TRANSLATIONS`.
|
||||
# ⚠️ A FILE THAT FAILS THAT SCHEMA IS DROPPED WITH A LOG WARNING AND
|
||||
# NOTHING ELSE: `except (ConfigurationFileError, vol.Invalid): _LOGGER.
|
||||
# warning(...); continue`. The add-on then renders raw keys and the only
|
||||
# trace is one line in the Supervisor log. So the loader is RUN here, out
|
||||
# of the release image, rather than the schema being re-implemented.
|
||||
# 2. THE FRONTEND decides what it does with what Supervisor loaded. The
|
||||
# resolution below is not invented: it is transcribed from the shipped
|
||||
# bundle in THIS image, `api/panel/frontend_latest/5680.*.js`
|
||||
# (frontend 20250925.1), which reads
|
||||
#
|
||||
# _getTranslationEntry(lang,s,o){let e=this.addon.translations[lang]?.
|
||||
# configuration; if(e){if(o?.path)for(const t of o.path)
|
||||
# if(e=e[t]?.fields,!e)return; return e[s.name]}}
|
||||
# computeLabel =(s,_,o)=>this._getTranslationEntry(this.hass.language,s,o)?.name
|
||||
# ||this._getTranslationEntry("en",s,o)?.name||s.name
|
||||
# computeHelper=(s,o) =>this._getTranslationEntry(this.hass.language,s,o)?.description
|
||||
# ||this._getTranslationEntry("en",s,o)?.description||""
|
||||
# _filteredSchema=(opts,schema)=>schema.filter(e=>e.name in opts||e.required)
|
||||
#
|
||||
# i.e. the fallback is PER KEY, en is the second chance, and the raw key
|
||||
# name is the third.
|
||||
from supervisor.apps.validate import SCHEMA_APP_TRANSLATIONS # noqa: E402
|
||||
from supervisor.store.data import _read_app_translations # noqa: E402
|
||||
|
||||
TRANSLATIONS = _read_app_translations(Path("/w"))
|
||||
show("Supervisor's OWN translation loader accepts both files - a file that "
|
||||
"fails SCHEMA_APP_TRANSLATIONS is DROPPED with a log warning and the tab "
|
||||
"silently goes back to raw keys, so 'it is on disk' is not the question",
|
||||
set(TRANSLATIONS) == {"en", "nl"}, sorted(TRANSLATIONS))
|
||||
|
||||
ui = UiOptions(None)(schema)
|
||||
print(f" the Configuration tab renders {len(ui)} fields, in `schema:` order "
|
||||
"(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE "
|
||||
"SCHEMA BLOCK'S and not the options block's)")
|
||||
|
||||
|
||||
def _entry(lang, name, path=()):
|
||||
node = TRANSLATIONS.get(lang, {}).get("configuration")
|
||||
if not node:
|
||||
return None
|
||||
for step in path:
|
||||
node = (node.get(step) or {}).get("fields")
|
||||
if not node:
|
||||
return None
|
||||
return node.get(name)
|
||||
|
||||
|
||||
def _label(lang, name, path=()):
|
||||
for lg in (lang, "en"):
|
||||
e = _entry(lg, name, path)
|
||||
if e and e.get("name"):
|
||||
return e["name"], lg
|
||||
return name, "RAW KEY"
|
||||
|
||||
|
||||
def _helper(lang, name, path=()):
|
||||
for lg in (lang, "en"):
|
||||
e = _entry(lg, name, path)
|
||||
if e and e.get("description"):
|
||||
return e["description"], lg
|
||||
return "", "NONE"
|
||||
|
||||
|
||||
# ⚠️ EVERY FIELD, PRINTED. This is the transcript the card asks for: what the
|
||||
# owner's Supervisor puts on the screen, not a claim that it would look nice.
|
||||
for lang in ("en", "nl"):
|
||||
print(f"\n --- as rendered at hass.language == {lang!r} "
|
||||
f"(the owner's HA runs Europe/Brussels) ---")
|
||||
for node in ui:
|
||||
lbl, src = _label(lang, node["name"])
|
||||
hlp, hsrc = _helper(lang, node["name"])
|
||||
flag = "" if src == lang else f" [name from {src}]"
|
||||
print(f" {node['name']:28s} -> {lbl}{flag}")
|
||||
print(f" {'':28s} {hlp[:150]}{'...' if len(hlp) > 150 else ''}")
|
||||
for sub in node.get("schema", ()):
|
||||
slbl, ssrc = _label(lang, sub["name"], (node["name"],))
|
||||
print(f" · {sub['name']:24s} -> {slbl}"
|
||||
+ ("" if ssrc == lang else f" [{ssrc}]"))
|
||||
|
||||
_raw = [n["name"] for n in ui if _label("en", n["name"])[1] == "RAW KEY"]
|
||||
show("NOT ONE top-level field falls through to its raw key name in `en` - "
|
||||
f"which is what the tab rendered for all {len(ui)} of them before this "
|
||||
f"card, because there was no translations/ directory at all. Raw: {_raw}",
|
||||
not _raw)
|
||||
_nohelp = [n["name"] for n in ui if not _helper("en", n["name"])[0]]
|
||||
show("...and not one is left without a description, which is the half that "
|
||||
f"says WHETHER TO TOUCH IT. Missing: {_nohelp}", not _nohelp)
|
||||
_nl_missing = [n["name"] for n in ui if _label("nl", n["name"])[1] != "nl"]
|
||||
show("...and every one of them is translated in `nl` too, so nothing on the "
|
||||
f"owner's own screen falls back to English. Falling back: {_nl_missing}",
|
||||
not _nl_missing)
|
||||
|
||||
# ⚠️ THE HONEST HALF, AND IT IS A NEGATIVE RESULT. `inverters` is a
|
||||
# list-of-objects, so UiOptions gives it type "schema" WITH multiple: True, and
|
||||
# `_convertSchemaElement` sends exactly that case to a SELECTOR rather than to a
|
||||
# nested ha-form:
|
||||
#
|
||||
# if("schema"===t.type&&!t.multiple) return {...type:"expandable"...} <- not us
|
||||
# if("schema"===t.type){const o={};for(const a of t.schema)
|
||||
# o[a.name]={required:a.required,selector:...}; return {object:{multiple:t.multiple,fields:o}}}
|
||||
#
|
||||
# and ha-selector-object labels those fields with
|
||||
#
|
||||
# computeLabel=e=>{const t=this.selector.object?.translation_key;
|
||||
# if(this.localizeValue&&t){...}
|
||||
# return this.selector.object?.fields?.[e.name]?.label||e.name}
|
||||
#
|
||||
# `_convertSchemaElementToSelector` sets neither `label` nor `translation_key`,
|
||||
# so ON THIS FRONTEND the per-entry keys inside the repeatable section render as
|
||||
# `setpoint_entity`, `max_w`, `bms_soc_floor` - RAW - however well they are
|
||||
# translated here. The `fields:` block is still shipped and still validated,
|
||||
# because it is the shape Supervisor's own schema defines for exactly this and
|
||||
# it is what a frontend that wires `label` through would consume; but this card
|
||||
# must not claim it reaches the operator today, and the count below is asserted
|
||||
# so that the day it changes, this transcript says so.
|
||||
_multi = [n for n in ui if n.get("multiple") and n.get("type") == "schema"]
|
||||
show("`inverters` is rendered as a REPEATABLE SECTION (type schema, multiple), "
|
||||
"which is CFG-04's measurement re-confirmed",
|
||||
[n["name"] for n in _multi] == ["inverters"], json.dumps(_multi)[:200])
|
||||
show("⚠️ MEASURED AND NEGATIVE: this frontend gives that section's per-entry "
|
||||
"keys to ha-selector-object, which labels them `fields[name].label || "
|
||||
"name` - and the converter sets no `label` and no `translation_key`. So "
|
||||
"the per-entry translations below are SHIPPED AND VALIDATED but do NOT "
|
||||
"reach the screen on 20250925.1. The top-level `inverters` label and "
|
||||
"description DO.",
|
||||
bool(_multi) and all("label" not in s and "translation_key" not in s
|
||||
for n in _multi for s in n.get("schema", ())),
|
||||
"per-entry keys as rendered today: "
|
||||
+ ", ".join(s["name"] for n in _multi for s in n.get("schema", ())))
|
||||
|
||||
# ⚠️ AND THE HAND PARSER CI USES IS GRADED AGAINST THIS LOADER, KEY BY KEY.
|
||||
# test_translations.py runs in the add-on's CI image, which has no PyYAML, so it
|
||||
# parses these files with tools/translations_lint.py. A hand parser that
|
||||
# disagrees with the real loader is a check whose data cannot reach the failure
|
||||
# it names, so the two are compared here - where both are available - rather
|
||||
# than assumed to agree.
|
||||
sys.path.insert(0, "/probe")
|
||||
import translations_lint as _tl # noqa: E402
|
||||
_mismatch = []
|
||||
for lang in ("en", "nl"):
|
||||
hand = _tl.parse_translation_file(f"/w/translations/{lang}.yaml")
|
||||
real = {"configuration": TRANSLATIONS[lang].get("configuration", {})}
|
||||
# SCHEMA_APP_TRANSLATIONS fills `description: None` for an absent one; the
|
||||
# hand parser simply has no key. Normalise that ONE difference and nothing
|
||||
# else, so a real disagreement cannot hide behind the normalisation.
|
||||
def _norm(d):
|
||||
return {k: {kk: vv for kk, vv in v.items() if vv is not None
|
||||
and kk != "fields"}
|
||||
| ({"fields": _norm(v["fields"])} if v.get("fields") else {})
|
||||
for k, v in d.items()}
|
||||
if _norm(hand.get("configuration", {})) != _norm(real["configuration"]):
|
||||
a, b = _norm(hand.get("configuration", {})), _norm(real["configuration"])
|
||||
_mismatch += [f"{lang}:{k}" for k in sorted(set(a) | set(b))
|
||||
if a.get(k) != b.get(k)]
|
||||
show("the hand parser CI uses (tools/translations_lint.py) returns EXACTLY "
|
||||
"what Supervisor's own loader returns, for both files, key by key and "
|
||||
f"string by string. Disagreements: {_mismatch}", not _mismatch)
|
||||
_cfg_text = Path("/w/config.yaml").read_text(encoding="utf-8")
|
||||
_probs = []
|
||||
for lang in ("en", "nl"):
|
||||
_probs += _tl.audit(
|
||||
_cfg_text,
|
||||
{"configuration": TRANSLATIONS[lang].get("configuration", {})}, lang)
|
||||
show(f"...and the coverage audit is clean against the real loader's view too. "
|
||||
f"Problems: {_probs}", not _probs)
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
|
||||
@@ -0,0 +1,878 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-38 round 3 - the SYMMETRIC-PREDICATE FAMILY, enumerated by machine.
|
||||
|
||||
WHAT IT LOOKS FOR, in one sentence: a call whose argument ORDER is load-bearing
|
||||
for a MESSAGE and for nothing else - so swapping the arguments cannot change a
|
||||
single decision, is invisible to every behavioural check BY CONSTRUCTION, and
|
||||
still inverts what an operator reads on the line they are woken by.
|
||||
|
||||
⚠️ WHY IT IS AN INSTRUMENT AND NOT A LIST. MUTATION.md habit 12: "a hand-written
|
||||
scope is the same defect as a hand-written fixture". SAFETY-38 met this family
|
||||
twice - `SignWatchdog.evaluate()` called at `main.py:1620` (round 2) and at
|
||||
`main.py:1560` (round 3), ONE CALL SITE OVER - and both times the enumeration
|
||||
was written by hand and both times it was short. So the scope is computed.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE TEST IT APPLIES
|
||||
|
||||
For every function in `app/` and every pair (a, b) of its parameters:
|
||||
|
||||
rename a -> b and b -> a throughout the body, ERASE every string literal
|
||||
and f-string, CANONICALISE the commutative operators, and compare the two
|
||||
ASTs. If they are EQUAL, nothing the function DECIDES can depend on which
|
||||
of the two values arrived first.
|
||||
|
||||
Then, if some f-string in that function mentions BOTH names, the order is
|
||||
load-bearing for the MESSAGE ALONE, and the pair is reported.
|
||||
|
||||
⚠️ THE SYMMETRY IS USUALLY ONE CALL DEEP, WHICH IS WHY THIS IS A FIXPOINT AND
|
||||
NOT A SINGLE PASS. `SignWatchdog.evaluate()` does not compare its two arguments
|
||||
itself; it hands them to `watchdog.disagrees()`, whose body is
|
||||
`commanded_w * against_w < 0 and abs(commanded_w) > min_w and
|
||||
abs(against_w) > min_w`. An intraprocedural scan sees `disagrees(a, b, ...)`
|
||||
become `disagrees(b, a, ...)`, calls that a difference, and reports NOTHING -
|
||||
which is exactly what the first version of this file did, on a tree where the
|
||||
answer was already known. So symmetric pairs discovered in one round are used
|
||||
to canonicalise CALL ARGUMENTS in the next, and the scan iterates until the set
|
||||
stops growing.
|
||||
|
||||
CANONICALISATION, and each entry is here because something in `app/` needs it:
|
||||
* `a * b`, `a + b` - operand order sorted (`disagrees`)
|
||||
* `x and y`, `x or y` - term order sorted (`disagrees`' conjunction)
|
||||
* `a == b`, `a != b` - operand order sorted
|
||||
* `min(a, b)`, `max(a, b)` - argument order sorted (`clamp_to_rating`)
|
||||
* a tuple/list/set LITERAL being iterated - element order sorted
|
||||
(`rail_ceiling_w`'s `for bound in (dev_min, dev_max)`)
|
||||
* arguments at a KNOWN SYMMETRIC POSITION PAIR of a called function - sorted
|
||||
(the fixpoint)
|
||||
|
||||
Equal ASTs after the rename is a SUFFICIENT condition for behavioural
|
||||
invariance, not a necessary one. This scan therefore UNDER-REPORTS and says so
|
||||
- see "WHAT THIS CANNOT SEE". That is the conservative direction: everything it
|
||||
names is genuinely in the family, and the gap is stated with its shape.
|
||||
|
||||
Run: `python3 tools/symmetric_message_scan.py [--quiet]` from goodwe_controller/.
|
||||
Exit status is 0 always; it is a census, not a gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import itertools
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parent.parent / "app"
|
||||
|
||||
# {function name: {(i, j) positional index pairs that are interchangeable}}
|
||||
_SYM: dict[str, set[tuple[int, int]]] = {}
|
||||
# ⚠️ AND THE SAME THING FOR KEYWORD-ONLY PARAMETERS, WHICH THE FIRST FIXPOINT
|
||||
# DROPPED. `freeze.rail_ceiling_w(max_w, *, dev_min=None, dev_max=None)` folds
|
||||
# its two device bounds with `for bound in (dev_min, dev_max): ceiling = min(
|
||||
# ceiling, abs(bound))` - perfectly symmetric - and `rail_unreachable()` prints
|
||||
# `"a range of {dev_min}...{dev_max} W"`. A scan that only followed POSITIONAL
|
||||
# arguments called that pair asymmetric and hid a genuine member of the family
|
||||
# behind an implementation detail of its own traversal.
|
||||
_SYM_KW: dict[str, set[tuple[str, str]]] = {}
|
||||
|
||||
|
||||
class _Canon(ast.NodeTransformer):
|
||||
"""Rename one pair, erase every string, and sort what commutes."""
|
||||
|
||||
COMMUTATIVE_BINOP = (ast.Mult, ast.Add, ast.BitAnd, ast.BitOr, ast.BitXor)
|
||||
COMMUTATIVE_CMP = (ast.Eq, ast.NotEq)
|
||||
COMMUTATIVE_CALL = ("min", "max")
|
||||
|
||||
def __init__(self, a: str = "", b: str = ""):
|
||||
self.a, self.b = a, b
|
||||
|
||||
# -- the rename ---------------------------------------------------------
|
||||
def visit_Name(self, node):
|
||||
self.generic_visit(node)
|
||||
if self.a and node.id == self.a:
|
||||
node.id = self.b
|
||||
elif self.b and node.id == self.b:
|
||||
node.id = self.a
|
||||
return node
|
||||
|
||||
def visit_arg(self, node):
|
||||
if self.a and node.arg == self.a:
|
||||
node.arg = self.b
|
||||
elif self.b and node.arg == self.b:
|
||||
node.arg = self.a
|
||||
return node
|
||||
|
||||
# -- the message is exactly what is ALLOWED to differ --------------------
|
||||
def visit_JoinedStr(self, node):
|
||||
return ast.Constant(value="<msg>")
|
||||
|
||||
def visit_Constant(self, node):
|
||||
if isinstance(node.value, str):
|
||||
return ast.Constant(value="<msg>")
|
||||
return node
|
||||
|
||||
# -- canonicalisation ---------------------------------------------------
|
||||
def visit_BinOp(self, node):
|
||||
self.generic_visit(node)
|
||||
if isinstance(node.op, self.COMMUTATIVE_BINOP):
|
||||
l, r = ast.dump(node.left), ast.dump(node.right)
|
||||
if l > r:
|
||||
node.left, node.right = node.right, node.left
|
||||
return node
|
||||
|
||||
def visit_BoolOp(self, node):
|
||||
self.generic_visit(node)
|
||||
node.values = sorted(node.values, key=ast.dump)
|
||||
return node
|
||||
|
||||
def visit_Compare(self, node):
|
||||
self.generic_visit(node)
|
||||
if (len(node.ops) == 1
|
||||
and isinstance(node.ops[0], self.COMMUTATIVE_CMP)):
|
||||
l, r = ast.dump(node.left), ast.dump(node.comparators[0])
|
||||
if l > r:
|
||||
node.left, node.comparators[0] = node.comparators[0], node.left
|
||||
return node
|
||||
|
||||
def visit_For(self, node):
|
||||
self.generic_visit(node)
|
||||
if isinstance(node.iter, (ast.Tuple, ast.List, ast.Set)):
|
||||
node.iter.elts = sorted(node.iter.elts, key=ast.dump)
|
||||
return node
|
||||
|
||||
def visit_Call(self, node):
|
||||
self.generic_visit(node)
|
||||
name = (node.func.id if isinstance(node.func, ast.Name)
|
||||
else node.func.attr if isinstance(node.func, ast.Attribute)
|
||||
else "")
|
||||
if name in self.COMMUTATIVE_CALL and len(node.args) == 2 \
|
||||
and not node.keywords:
|
||||
if ast.dump(node.args[0]) > ast.dump(node.args[1]):
|
||||
node.args = [node.args[1], node.args[0]]
|
||||
return node
|
||||
# the fixpoint: sort the arguments at a known symmetric position pair
|
||||
for (i, j) in sorted(_SYM.get(name, ())):
|
||||
if j < len(node.args):
|
||||
if ast.dump(node.args[i]) > ast.dump(node.args[j]):
|
||||
node.args[i], node.args[j] = node.args[j], node.args[i]
|
||||
# ...and at a known symmetric KEYWORD pair
|
||||
for (ka, kb) in sorted(_SYM_KW.get(name, ())):
|
||||
ia = next((k for k, w in enumerate(node.keywords)
|
||||
if w.arg == ka), None)
|
||||
ib = next((k for k, w in enumerate(node.keywords)
|
||||
if w.arg == kb), None)
|
||||
if ia is not None and ib is not None:
|
||||
va, vb = node.keywords[ia].value, node.keywords[ib].value
|
||||
if ast.dump(va) > ast.dump(vb):
|
||||
node.keywords[ia].value, node.keywords[ib].value = vb, va
|
||||
return node
|
||||
|
||||
|
||||
def _canon(src: str, a: str = "", b: str = "") -> str:
|
||||
return ast.dump(_Canon(a, b).visit(ast.parse(src)))
|
||||
|
||||
|
||||
# ⚠️ THE SINK IS HALF THE CLAIM, AND THE FIRST VERSION OF THIS SCAN OMITTED IT.
|
||||
# Erasing every f-string makes a function whose ONLY job is to FORMAT one look
|
||||
# perfectly symmetric - `store.unit_key(unit, key)` and
|
||||
# `mqtt.unit_state_key(slug, key)` both came back as "the swap changes nothing"
|
||||
# when in fact the string IS the return value: swapping there renames a STORE
|
||||
# KEY and an MQTT TOPIC, which is a behaviour change every ordinary check can
|
||||
# see, and MAINT-05 is the card about exactly that. The family this file is
|
||||
# looking for is narrower - the string goes to an OPERATOR and to nobody else -
|
||||
# so each sentence is classified by where it GOES.
|
||||
_LOG_SINKS = {"debug", "info", "warning", "warn", "error", "exception",
|
||||
"critical", "log", "log_event", "notify", "persistent_notify"}
|
||||
_MSG_ATTRS = {"reason", "message", "msg", "banner", "detail", "note",
|
||||
"explanation", "why", "text"}
|
||||
|
||||
|
||||
def _sink_of(fn, target, follow=True):
|
||||
"""RETURN | MESSAGE | OTHER - where this f-string node ends up.
|
||||
|
||||
`follow` is the ONE step of data flow below, and it is off on the recursive
|
||||
call so that two locals that interpolate each other cannot loop forever.
|
||||
"""
|
||||
for stmt in ast.walk(fn):
|
||||
for child in ast.iter_child_nodes(stmt):
|
||||
pass
|
||||
# a parent map is cheaper than repeated walks
|
||||
parent = {}
|
||||
for node in ast.walk(fn):
|
||||
for child in ast.iter_child_nodes(node):
|
||||
parent[id(child)] = node
|
||||
cur = target
|
||||
while id(cur) in parent:
|
||||
up = parent[id(cur)]
|
||||
if isinstance(up, ast.Return):
|
||||
return "RETURN"
|
||||
if isinstance(up, ast.Raise):
|
||||
return "MESSAGE"
|
||||
if isinstance(up, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
||||
tgts = (up.targets if isinstance(up, ast.Assign) else [up.target])
|
||||
for t in tgts:
|
||||
if isinstance(t, ast.Attribute) and t.attr in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
if isinstance(t, ast.Name) and t.id in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
# ⚠️ ONE STEP OF DATA FLOW, AND IT IS NOT OPTIONAL. `main.py`'s
|
||||
# status page builds the device-range clause into a LOCAL - `_cap =
|
||||
# "; ".join(f"... accepts only {inv.dev_min}...{inv.dev_max} W" ...)`
|
||||
# - and only then concatenates it into the row's `text`. Stopping at
|
||||
# the assignment scores that OTHER and drops a member this card
|
||||
# MEASURED as a live survivor (0 bytes, 21/21 green). So: if the
|
||||
# name assigned here is interpolated into some other sentence in the
|
||||
# same function that IS a message, this one is a message too.
|
||||
if follow:
|
||||
for t in tgts:
|
||||
if isinstance(t, ast.Name) and _feeds_a_message(fn, t.id,
|
||||
up):
|
||||
return "MESSAGE"
|
||||
return "OTHER"
|
||||
# ⚠️ ROUND 4. A SENTENCE HANDED TO A CONSTRUCTOR AS `reason=` IS A
|
||||
# MESSAGE, and scoring only ASSIGNMENTS missed every one of them. This
|
||||
# codebase builds most of its operator sentences that way -
|
||||
# `ArbitrageDecision(action=..., reason=f"...")`,
|
||||
# `out.append({"text": f"..."})` - so the sentence never touches a name
|
||||
# this walker was watching. arbitrage.py's two DISCHARGE/CHARGE reasons
|
||||
# were scored OTHER for exactly this and are genuine members.
|
||||
if isinstance(up, ast.keyword) and up.arg in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
if isinstance(up, ast.Dict):
|
||||
for k, v in zip(up.keys, up.values):
|
||||
if (v is cur and isinstance(k, ast.Constant)
|
||||
and k.value in _MSG_ATTRS):
|
||||
return "MESSAGE"
|
||||
if isinstance(up, ast.Call):
|
||||
name = (up.func.attr if isinstance(up.func, ast.Attribute)
|
||||
else up.func.id if isinstance(up.func, ast.Name) else "")
|
||||
if name in _LOG_SINKS:
|
||||
return "MESSAGE"
|
||||
if name == "append":
|
||||
return "MESSAGE"
|
||||
cur = up
|
||||
return "OTHER"
|
||||
|
||||
|
||||
def _feeds_a_message(fn, name, origin):
|
||||
"""Is `name` interpolated into some OTHER sentence that IS a message?"""
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr) or node is origin:
|
||||
continue
|
||||
used = any(isinstance(x, ast.Name) and x.id == name
|
||||
for v in node.values if isinstance(v, ast.FormattedValue)
|
||||
for x in ast.walk(v.value))
|
||||
if used and _sink_of(fn, node, follow=False) == "MESSAGE":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _fstring_pairs(fn, params):
|
||||
"""{(a, b): [(lineno, sink), ...]} for every f-string mentioning both."""
|
||||
out: dict[tuple[str, str], list[tuple[int, str]]] = {}
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr):
|
||||
continue
|
||||
used = set()
|
||||
for v in node.values:
|
||||
if isinstance(v, ast.FormattedValue):
|
||||
for x in ast.walk(v.value):
|
||||
if isinstance(x, ast.Name) and x.id in params:
|
||||
used.add(x.id)
|
||||
if len(used) < 2:
|
||||
continue
|
||||
sink = _sink_of(fn, node)
|
||||
for a, b in itertools.combinations(sorted(used), 2):
|
||||
out.setdefault((a, b), []).append((node.lineno, sink))
|
||||
return out
|
||||
|
||||
|
||||
def _functions():
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for fn in ast.walk(tree):
|
||||
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
yield path, fn
|
||||
|
||||
|
||||
def _positional(fn):
|
||||
return [a.arg for a in fn.args.posonlyargs + fn.args.args
|
||||
if a.arg != "self"]
|
||||
|
||||
|
||||
def _all_params(fn):
|
||||
return _positional(fn) + [a.arg for a in fn.args.kwonlyargs]
|
||||
|
||||
|
||||
def _body_src(fn) -> str:
|
||||
"""The function's BODY alone.
|
||||
|
||||
⚠️ NOT `ast.unparse(fn)`. The signature lists the parameters IN ORDER, so
|
||||
renaming a pair inside it reorders `arguments.args` and every function in
|
||||
the repository compares unequal to itself - which is what the first version
|
||||
of this scan did, silently, and it reported an empty family on a tree whose
|
||||
answer was already known. The claim being tested is about what the body
|
||||
DECIDES, and the body is what is compared.
|
||||
"""
|
||||
return ast.unparse(ast.Module(body=list(fn.body), type_ignores=[]))
|
||||
|
||||
|
||||
def _symmetric(fn, a, b) -> bool:
|
||||
src = _body_src(fn)
|
||||
return _canon(src) == _canon(src, a, b)
|
||||
|
||||
|
||||
def build_fixpoint(max_rounds=6):
|
||||
"""Fill `_SYM` until it stops growing. Returns the number of rounds."""
|
||||
for round_no in range(1, max_rounds + 1):
|
||||
grew = False
|
||||
for _path, fn in _functions():
|
||||
pos = _positional(fn)
|
||||
kws = [a.arg for a in fn.args.kwonlyargs]
|
||||
if len(pos) + len(kws) < 2:
|
||||
continue
|
||||
found = set()
|
||||
for i, j in itertools.combinations(range(len(pos)), 2):
|
||||
if _symmetric(fn, pos[i], pos[j]):
|
||||
found.add((i, j))
|
||||
found_kw = set()
|
||||
for ka, kb in itertools.combinations(sorted(kws), 2):
|
||||
if _symmetric(fn, ka, kb):
|
||||
found_kw.add((ka, kb))
|
||||
if found - _SYM.get(fn.name, set()):
|
||||
_SYM.setdefault(fn.name, set()).update(found)
|
||||
grew = True
|
||||
if found_kw - _SYM_KW.get(fn.name, set()):
|
||||
_SYM_KW.setdefault(fn.name, set()).update(found_kw)
|
||||
grew = True
|
||||
if not grew:
|
||||
return round_no
|
||||
return max_rounds
|
||||
|
||||
|
||||
def scan():
|
||||
rows = []
|
||||
for path, fn in _functions():
|
||||
params = _all_params(fn)
|
||||
if len(params) < 2:
|
||||
continue
|
||||
kwonly = {a.arg for a in fn.args.kwonlyargs}
|
||||
for (a, b), hits in sorted(_fstring_pairs(fn, params).items()):
|
||||
rows.append(dict(
|
||||
module=path.name, func=fn.name, line=fn.lineno, a=a, b=b,
|
||||
msg_lines=sorted(set(hits)),
|
||||
sinks={s for _l, s in hits},
|
||||
symmetric=_symmetric(fn, a, b),
|
||||
positional=not (a in kwonly or b in kwonly)))
|
||||
return rows
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CENSUS B - THE SENTENCE THE CALLER BUILDS
|
||||
# =============================================================================
|
||||
# ⚠️ CENSUS A ABOVE ONLY SEES A CALLEE FORMATTING ITS OWN PARAMETERS, AND THAT
|
||||
# WAS GAP 2 OF ITS OWN "WHAT THIS CANNOT SEE" LIST. The other half of the family
|
||||
# never passes through a function boundary at all: an operator sentence built
|
||||
# where it is printed, interpolating two values that are INTERCHANGEABLE BY
|
||||
# SHAPE. `main.py`'s status page says
|
||||
#
|
||||
# f"{inv.setpoint_entity or inv.name} accepts only {inv.dev_min}...{inv.dev_max} W"
|
||||
#
|
||||
# and transposing those two costs ZERO BYTES, changes nothing any check can
|
||||
# observe, and tells the operator a range backwards. No predicate is involved,
|
||||
# so census A's symmetry test has nothing to test.
|
||||
#
|
||||
# THE TEST: inside one MESSAGE-sink f-string, take every pair of interpolated
|
||||
# expressions, blank every attribute NAME and every string CONSTANT, and compare.
|
||||
# If the blanked forms are EQUAL while the originals DIFFER, the two expressions
|
||||
# are the same shape with one leaf changed - `inv.dev_min` / `inv.dev_max`,
|
||||
# `site_dev_bound("dev_min")` / `site_dev_bound("dev_max")` - which is exactly
|
||||
# the pair a transposition swaps. Same direction as census A: it under-reports
|
||||
# (two values of the same KIND spelled differently are missed) rather than
|
||||
# inventing members.
|
||||
|
||||
|
||||
|
||||
class _Blank(ast.NodeTransformer):
|
||||
def visit_Attribute(self, node):
|
||||
self.generic_visit(node)
|
||||
node.attr = "<leaf>"
|
||||
return node
|
||||
|
||||
def visit_Constant(self, node):
|
||||
if isinstance(node.value, str):
|
||||
return ast.Constant(value="<leaf>")
|
||||
return node
|
||||
|
||||
|
||||
def _blanked(expr):
|
||||
return ast.dump(_Blank().visit(ast.parse(ast.unparse(expr))))
|
||||
|
||||
|
||||
def _called_name(func):
|
||||
return (func.attr if isinstance(func, ast.Attribute)
|
||||
else func.id if isinstance(func, ast.Name) else "")
|
||||
|
||||
|
||||
def _innermost(tree):
|
||||
"""{id(node): INNERMOST enclosing function}, or None at module level.
|
||||
|
||||
⚠️ NOT `for fn in ast.walk(tree): for node in ast.walk(fn)`, which is what
|
||||
the first version of census B did. That visits every sentence once per
|
||||
ENCLOSING def, so a sentence inside a nested function was classified twice -
|
||||
once against the inner scope and once against the outer - and the two
|
||||
classifications are not obliged to agree. The rows were de-duplicated
|
||||
afterwards, which hid the double visit rather than removing it.
|
||||
"""
|
||||
m = {}
|
||||
|
||||
def rec(node, fn):
|
||||
for child in ast.iter_child_nodes(node):
|
||||
f = (child if isinstance(child, (ast.FunctionDef,
|
||||
ast.AsyncFunctionDef)) else fn)
|
||||
m[id(child)] = f
|
||||
rec(child, f)
|
||||
|
||||
rec(tree, None)
|
||||
return m
|
||||
|
||||
|
||||
def _spec(fv):
|
||||
"""The FORMAT SPEC of one interpolation, as text; '' when there is none.
|
||||
|
||||
⚠️ IT IS PRINTED BESIDE EVERY PAIR BECAUSE IT IS THE ONE CHEAP TYPE SIGNAL
|
||||
THIS SCAN HAS, and gap 4 of the second list below is exactly about the pairs
|
||||
that do not share
|
||||
one. `{self.requested_w:+.0f}` beside `{self.applied_w:+.0f}` is two floats
|
||||
rendered identically - a transposition there is silent and reads as a fact.
|
||||
`{self.limit_w:.0f}` beside `{self.bound}` is a float beside a string: a
|
||||
transposition there prints `rating max_w W`, which is visibly broken rather
|
||||
than quietly wrong. Same row in this census, very different hazard, and the
|
||||
reader cannot tell them apart without this column.
|
||||
"""
|
||||
if fv.format_spec is None:
|
||||
return ""
|
||||
return "".join(str(v.value) if isinstance(v, ast.Constant) else "{...}"
|
||||
for v in fv.format_spec.values)
|
||||
|
||||
|
||||
def _same_shape_pairs(node):
|
||||
"""Pairs of interpolations in ONE f-string that are exactly one leaf apart."""
|
||||
fvs = [v for v in node.values if isinstance(v, ast.FormattedValue)]
|
||||
for x, y in itertools.combinations(fvs, 2):
|
||||
ua, ub = ast.unparse(x.value), ast.unparse(y.value)
|
||||
if ua == ub:
|
||||
continue
|
||||
if _blanked(x.value) != _blanked(y.value):
|
||||
continue
|
||||
yield ua, ub, _spec(x), _spec(y)
|
||||
|
||||
|
||||
def _sentences():
|
||||
"""(path, innermost fn, JoinedStr, sink) for every f-string in app/."""
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
inner = _innermost(tree)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.JoinedStr):
|
||||
continue
|
||||
fn = inner.get(id(node))
|
||||
if fn is None:
|
||||
continue
|
||||
yield path, fn, node, _sink_of(fn, node)
|
||||
|
||||
|
||||
def scan_caller():
|
||||
rows = []
|
||||
for path, fn, node, sink in _sentences():
|
||||
# ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS CENSUS SAID
|
||||
# "anything but a RETURN". That let in `p1.py`'s
|
||||
# `self.url = f"http://{self.host}:{self.port}…"` - an f-string that is
|
||||
# DATA, used to make a request, where a transposition is an ordinary
|
||||
# behavioural change every check can see. A formatted string is only in
|
||||
# this family when its only consumer is a person. RETURN is not OTHER
|
||||
# though, and treating it as if it were is what census C below fixes.
|
||||
if sink != "MESSAGE":
|
||||
continue
|
||||
for ua, ub, sa, sb in _same_shape_pairs(node):
|
||||
rows.append((path.name, node.lineno, fn.name, ua, ub, sa, sb))
|
||||
return sorted(set(rows), key=lambda r: (r[0], r[1], r[3]))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CENSUS C - THE SENTENCE THE OBJECT RETURNS ABOUT ITSELF
|
||||
# =============================================================================
|
||||
# ⚠️ SAFETY-38 ROUND 4. CENSUS A AND CENSUS B BETWEEN THEM COVERED ONE
|
||||
# SYNTACTIC SINK AND CALLED IT THE FAMILY. Every member either census reported
|
||||
# was a sentence APPENDED, ASSIGNED to a `reason`-shaped attribute, or passed as
|
||||
# a LOG ARGUMENT. Not one was a sentence that is RETURNED. Census A had a RETURN
|
||||
# bucket, but only for a callee formatting its own PARAMETERS - so a method
|
||||
# formatting its own ATTRIBUTES and handing the string back was invisible to
|
||||
# both, and `describe()` / `explain()` / the reason-builders are precisely where
|
||||
# this codebase keeps its operator sentences. Two live members were sitting in
|
||||
# that hole:
|
||||
#
|
||||
# clamp.py ClampResult.describe() requested_w <-> applied_w
|
||||
# peakguard.py PeakDecision.explain() projected_w <-> ceiling_w
|
||||
#
|
||||
# Worse than the gap: it was in NONE of the five blind spots this file prints
|
||||
# about itself. A tool that asserts a completeness it does not have is the
|
||||
# recurring defect in a new hat, and the list at the bottom now names its sinks.
|
||||
#
|
||||
# THE TEST is census B's - one f-string, two interpolations one leaf apart - run
|
||||
# over the RETURN sink, plus one thing census B never needed: A RETURNED STRING
|
||||
# IS NOT YET A MESSAGE. `store.unit_key()` and `mqtt.unit_state_key()` return
|
||||
# f-strings too, and swapping there renames a store key and an MQTT topic, which
|
||||
# every ordinary check can see (MAINT-05 is the card about that). So the sink of
|
||||
# a returned sentence is RESOLVED THROUGH ITS CALL SITES: the string is in this
|
||||
# family only when some caller in `app/` feeds it to an operator.
|
||||
|
||||
|
||||
def _return_consumers(func_name, depth=0, seen=frozenset()):
|
||||
"""[(site, sink, verdict)] - where the STRING RETURNED BY `func_name` goes.
|
||||
|
||||
One hop is not enough: `Resolution.explain()` is returned again by wrappers
|
||||
that are themselves logged, so a call site whose own sink is RETURN is
|
||||
resolved by recursing on the ENCLOSING function's name, bounded by `depth`
|
||||
and by `seen` so mutual recursion cannot loop.
|
||||
|
||||
⚠️ THE MATCH IS ON THE BARE METHOD NAME, exactly as `call_sites()` above,
|
||||
and `explain` is the name of at least four unrelated methods in this tree
|
||||
(`PeakDecision`, `UnitPlan`, `Resolution`, the maintenance migration
|
||||
report). So this OVER-collects sites, which pushes a verdict towards
|
||||
MESSAGE. That is the deliberate direction for a census whose measured
|
||||
failure has twice been under-reporting - but it means the verdict is a
|
||||
PROMPT TO READ THE SITES PRINTED BESIDE IT, not a proof.
|
||||
"""
|
||||
if func_name in seen or depth > 2:
|
||||
return []
|
||||
seen = seen | {func_name}
|
||||
out = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
inner = _innermost(tree)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if _called_name(node.func) != func_name:
|
||||
continue
|
||||
fn = inner.get(id(node))
|
||||
if fn is None:
|
||||
continue
|
||||
sink = _sink_of(fn, node)
|
||||
label = f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`"
|
||||
if sink == "RETURN":
|
||||
sub = _return_consumers(fn.name, depth + 1, seen)
|
||||
if any(v == "MESSAGE" for _l, _s, v in sub):
|
||||
verdict = "MESSAGE"
|
||||
elif not sub:
|
||||
verdict = "UNRESOLVED"
|
||||
else:
|
||||
verdict = "OTHER"
|
||||
sink = f"RETURN by {fn.name}() -> {verdict}"
|
||||
else:
|
||||
verdict = sink
|
||||
out.append((label, sink, verdict))
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def scan_returned():
|
||||
"""Census C rows: a returned sentence, two interpolations one leaf apart."""
|
||||
rows = []
|
||||
for path, fn, node, sink in _sentences():
|
||||
if sink != "RETURN":
|
||||
continue
|
||||
pairs = list(_same_shape_pairs(node))
|
||||
if not pairs:
|
||||
continue
|
||||
sites = _return_consumers(fn.name)
|
||||
if any(v == "MESSAGE" for _l, _s, v in sites):
|
||||
verdict = "MESSAGE"
|
||||
elif not sites:
|
||||
verdict = "UNRESOLVED - no call site inside app/"
|
||||
else:
|
||||
verdict = "OTHER"
|
||||
for ua, ub, sa, sb in pairs:
|
||||
rows.append(dict(module=path.name, line=node.lineno, func=fn.name,
|
||||
a=ua, b=ub, spec_a=sa, spec_b=sb,
|
||||
verdict=verdict, sites=sites))
|
||||
return sorted(rows, key=lambda r: (r["module"], r["line"], r["a"]))
|
||||
|
||||
|
||||
# ⚠️ THE UNIT OF COVERAGE IS THE CALL SITE, NOT THE FUNCTION - which is the
|
||||
# whole lesson of SAFETY-38 round 3. `SignWatchdog.evaluate()` had its operand
|
||||
# order pinned and the defect still shipped, because the pin was on the call two
|
||||
# lines below the one a one-inverter roster actually reaches. So a family member
|
||||
# is reported WITH EVERY PLACE IT IS CALLED.
|
||||
def call_sites(name):
|
||||
out = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if _called_name(node.func) != name:
|
||||
continue
|
||||
# ⚠️ THE RECEIVER IS PRINTED, because matching on the bare
|
||||
# attribute name conflates methods: `evaluate` is the name of
|
||||
# SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and
|
||||
# a list of four lines with no receiver tells a reader nothing
|
||||
# about which detector they are looking at. `inv.sign_wd` and
|
||||
# `self.freeze_wd` are the disambiguation, and they are the
|
||||
# caller's own words.
|
||||
out.append(f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`")
|
||||
return out
|
||||
|
||||
|
||||
def lazy_log_sites():
|
||||
"""Log calls with a %-format template and >=2 substitution ARGUMENTS.
|
||||
|
||||
⚠️ THE SIZE OF THE LARGEST HOLE, MEASURED RATHER THAN ASSERTED. The
|
||||
NOT-SCANNED list below used to end "and app/ uses that form", which is a
|
||||
claim about the tree with no number on it - the exact shape this card has
|
||||
spent four rounds arguing against. `_LOG.error("%s ... %s", a, b)` puts both
|
||||
operands in ARGUMENTS, so no f-string census here can ever reach them, and
|
||||
transposing the two costs the same zero bytes. This counts them.
|
||||
"""
|
||||
out = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if _called_name(node.func) not in _LOG_SINKS or len(node.args) < 3:
|
||||
continue
|
||||
head = node.args[0]
|
||||
tmpl = ""
|
||||
if isinstance(head, ast.Constant) and isinstance(head.value, str):
|
||||
tmpl = head.value
|
||||
elif isinstance(head, ast.BinOp) or isinstance(head, ast.JoinedStr):
|
||||
tmpl = ast.unparse(head)
|
||||
if tmpl.count("%") >= 2:
|
||||
out.append((path.name, node.lineno,
|
||||
f"{path.name}:{node.lineno} "
|
||||
f"`{ast.unparse(node.func)}` with "
|
||||
f"{len(node.args) - 1} args"))
|
||||
return [r[2] for r in sorted(out)]
|
||||
|
||||
|
||||
def _pair_line(sa, sb):
|
||||
same = sa == sb
|
||||
return (f" format spec: '{sa}' / '{sb}' -> "
|
||||
+ ("SAME - two values rendered alike, so a transposition is "
|
||||
"SILENT and reads as a fact"
|
||||
if same else
|
||||
"DIFFERENT - likely mixed types, so a transposition is more "
|
||||
"likely to look broken than to lie (gap 4 below)"))
|
||||
|
||||
|
||||
def main():
|
||||
quiet = "--quiet" in sys.argv
|
||||
rounds = build_fixpoint()
|
||||
rows = scan()
|
||||
fam = [r for r in rows if r["symmetric"] and "MESSAGE" in r["sinks"]
|
||||
and "RETURN" not in r["sinks"]]
|
||||
valued = [r for r in rows if r["symmetric"] and "RETURN" in r["sinks"]]
|
||||
print("SYMMETRIC-PREDICATE FAMILY - argument order load-bearing for a "
|
||||
"MESSAGE only")
|
||||
print("=" * 78)
|
||||
print(f"fixpoint reached after {rounds} round(s); "
|
||||
f"{sum(len(v) for v in _SYM.values())} interchangeable positional "
|
||||
f"and {sum(len(v) for v in _SYM_KW.values())} keyword pair(s) "
|
||||
f"across {len(set(_SYM) | set(_SYM_KW))} function(s) in app/")
|
||||
print(f"functions formatting >=2 of their own parameters into one "
|
||||
f"sentence: {len(rows)} pair(s)")
|
||||
print(f"of those, pairs whose SWAP LEAVES EVERY DECISION IDENTICAL: "
|
||||
f"{len(fam) + len(valued)}")
|
||||
print(f" ...of which the sentence is RETURNED (so the swap is a VALUE "
|
||||
f"change an ordinary check can see): {len(valued)}")
|
||||
print(f" ...and the sentence goes ONLY TO AN OPERATOR - THE FAMILY: "
|
||||
f"{len(fam)}")
|
||||
print()
|
||||
print("THE FAMILY - a swap here is invisible to every behavioural check "
|
||||
"BY CONSTRUCTION:")
|
||||
for r in sorted(fam, key=lambda r: (r["module"], r["line"])):
|
||||
kind = ("POSITIONAL - a transposition at a call site costs ZERO BYTES"
|
||||
if r["positional"] else
|
||||
"KEYWORD-ONLY at the definition - a transposition has to be "
|
||||
"written out by name, which is not free and not silent")
|
||||
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
||||
f"({r['a']}, {r['b']})")
|
||||
print(f" sentence at line(s): "
|
||||
f"{', '.join(f'{l} -> {s}' for l, s in r['msg_lines'])}")
|
||||
print(f" {kind}")
|
||||
sites = call_sites(r["func"])
|
||||
print(f" CALL SITES ({len(sites)}) - each one is a separate pin, "
|
||||
f"and the receiver says which detector it is:")
|
||||
for site in sites or ["(none inside app/)"]:
|
||||
print(f" {site}")
|
||||
print()
|
||||
print("SYMMETRIC, BUT THE SENTENCE IS THE RETURN VALUE - READ THIS BUCKET "
|
||||
"BY HAND.")
|
||||
print("A returned string is sometimes a KEY (a swap renames a store key or "
|
||||
"an MQTT topic,")
|
||||
print("which is behaviour every ordinary check can see - MAINT-05 is the "
|
||||
"card about it)")
|
||||
print("and sometimes a SENTENCE (a swap is message-only after all, and it "
|
||||
"IS this family):")
|
||||
for r in sorted(valued, key=lambda r: (r["module"], r["line"])):
|
||||
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
||||
f"({r['a']}, {r['b']})")
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("CENSUS B - SENTENCES THE CALLER BUILDS, interpolating two values of "
|
||||
"the SAME SHAPE")
|
||||
print("A transposition inside one of these is ZERO BYTES and no predicate "
|
||||
"is involved at")
|
||||
print("all, so nothing behavioural can ever see it. Same rule: assert the "
|
||||
"ORDERED PAIR.")
|
||||
cb = scan_caller()
|
||||
print(f"candidates: {len(cb)}")
|
||||
for mod, line, fn, ua, ub, sa, sb in cb:
|
||||
print(f" {mod}:{line} in {fn}()")
|
||||
print(f" {ua} <-> {ub}")
|
||||
print(_pair_line(sa, sb))
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("CENSUS C - SENTENCES AN OBJECT RETURNS ABOUT ITSELF (SAFETY-38 "
|
||||
"round 4)")
|
||||
print("A method formatting its OWN ATTRIBUTES and RETURNING the string was "
|
||||
"in neither")
|
||||
print("census above: census A only reads a callee's PARAMETERS, census B "
|
||||
"only scored a")
|
||||
print("MESSAGE sink. The verdict below is resolved through the CALL SITES "
|
||||
"of the")
|
||||
print("returning method - a returned string is only in this family once "
|
||||
"some caller")
|
||||
print("hands it to an operator (contrast store.unit_key(), whose return "
|
||||
"value is a KEY).")
|
||||
cc = scan_returned()
|
||||
fam_c = [r for r in cc if r["verdict"] == "MESSAGE"]
|
||||
hot = [r for r in fam_c if r["spec_a"] == r["spec_b"]]
|
||||
print(f"returned sentences with a same-shape pair: {len(cc)} pair(s); "
|
||||
f"{len(fam_c)} reach an operator;")
|
||||
print(f"of those, {len(hot)} ALSO share a format spec - two values "
|
||||
f"rendered alike, which is")
|
||||
print("the hazard gap 4 below names: a transposition there is silent and "
|
||||
"reads as a fact.")
|
||||
print()
|
||||
print("*** = reaches an operator AND shares a format spec - THE ACTIONABLE "
|
||||
"SUBSET")
|
||||
print(" ! = reaches an operator, specs differ (a swap is likelier to look "
|
||||
"broken)")
|
||||
print(" = does not reach an operator, or could not be resolved")
|
||||
# grouped by the sentence, so the CONSUMERS list is printed once per
|
||||
# method rather than once per pair - the ungrouped form was 20 identical
|
||||
# lines under each of six pairs and nobody reads that.
|
||||
for key in sorted({(r["module"], r["line"], r["func"]) for r in cc}):
|
||||
mod, line, fname = key
|
||||
group = [r for r in cc if (r["module"], r["line"], r["func"]) == key]
|
||||
v = group[0]["verdict"]
|
||||
print(f" {mod}:{line} {fname}() verdict: {v}")
|
||||
for r in group:
|
||||
mark = ("***" if r["verdict"] == "MESSAGE"
|
||||
and r["spec_a"] == r["spec_b"]
|
||||
else " ! " if r["verdict"] == "MESSAGE" else " ")
|
||||
print(f" {mark} {r['a']} <-> {r['b']}")
|
||||
print(f" {_pair_line(r['spec_a'], r['spec_b']).strip()}")
|
||||
if not quiet:
|
||||
sites = group[0]["sites"]
|
||||
print(f" CONSUMERS ({len(sites)}) - bare-name match, so read "
|
||||
f"the receiver:")
|
||||
for label, sink, _v in sites or [("(none inside app/)", "", "")]:
|
||||
print(f" {label} -> {sink}")
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("WHAT THIS SCANS, AND WHAT IT DOES NOT - stated because round 4's "
|
||||
"blocker was")
|
||||
print("a completeness this file ASSERTED and did not have. The sink "
|
||||
"classifier is a")
|
||||
print("syntactic walk up the parent chain plus ONE step of local data "
|
||||
"flow, and it")
|
||||
print("recognises exactly these sinks and no others:")
|
||||
print(f" SCANNED as a MESSAGE : a call to {sorted(_LOG_SINKS)}")
|
||||
print(" ...or to `append`; a `raise`; an "
|
||||
"assignment to an")
|
||||
print(f" attribute or local named {sorted(_MSG_ATTRS)}")
|
||||
print(" ...or to a local that is then interpolated "
|
||||
"into one of")
|
||||
print(" those (the one data-flow step).")
|
||||
print(" SCANNED as a RETURN : census C - resolved through the returning "
|
||||
"method's")
|
||||
print(" call sites, one hop plus two of recursion.")
|
||||
print(" NOT SCANNED AT ALL, and each of these can hold a member:")
|
||||
lazy = lazy_log_sites()
|
||||
print(" * `%s`-style lazy logging - `_LOG.error('%s ... %s', a, b)`. The "
|
||||
"arguments")
|
||||
print(" are not an f-string, so no census here looks at them, and a "
|
||||
"transposition")
|
||||
print(" of two `%s` arguments is the same zero-byte edit. THIS IS THE "
|
||||
"LARGEST")
|
||||
print(f" REMAINING HOLE, and it is MEASURED, not asserted: "
|
||||
f"{len(lazy)} such call(s)")
|
||||
print(f" in app/, across "
|
||||
f"{len({s.split(':')[0] for s in lazy})} module(s). None of them is "
|
||||
f"scanned by anything")
|
||||
print(" above. Listed here so the size of the hole is a number:")
|
||||
for site in lazy or ["(none)"]:
|
||||
print(f" {site}")
|
||||
print(" * `.format()`, `%`-formatting, and sentences built by `+` or "
|
||||
"`.join()` out of")
|
||||
print(" non-f-string pieces.")
|
||||
print(" * A sentence that leaves the function inside a DICT, LIST or "
|
||||
"dataclass and is")
|
||||
print(" rendered somewhere else - the one data-flow step is local and "
|
||||
"follows only")
|
||||
print(" a bare Name.")
|
||||
print(" * `yield`ed sentences, and sentences stored on an object by any "
|
||||
"attribute")
|
||||
print(f" name outside {sorted(_MSG_ATTRS)}.")
|
||||
print(" * Anything outside `app/`: `sim/`, `tools/`, `web.py`'s "
|
||||
"templates, the")
|
||||
print(" translations JSON, the firmware, the witness.")
|
||||
print()
|
||||
print("WHAT IT CAN SEE BUT GETS WRONG - the shape of each gap, named "
|
||||
"rather than")
|
||||
print("rounded off:")
|
||||
print(" 1. SEMANTIC symmetry no canonicaliser above spells. The list of "
|
||||
"commutative")
|
||||
print(" forms is finite and hand-written; a function symmetric only "
|
||||
"through a form")
|
||||
print(" not on it is REJECTED and never appears. This under-reports.")
|
||||
print(" 2. In CENSUS B and C, two interpolated values of the same KIND "
|
||||
"that are not the")
|
||||
print(" same SHAPE - `a.lo` beside `hi`, or one of them behind a local "
|
||||
"variable.")
|
||||
print(" Only one-leaf-apart expressions are paired.")
|
||||
print(" 3. WHETHER each call site is pinned. The sites are listed above; "
|
||||
"whether a")
|
||||
print(" check asserts the ORDERED PAIR at each one is a fact about "
|
||||
"`test_*.py` and is")
|
||||
print(" recorded in DECISIONS/SAFETY-38-site-standing-pin.md section "
|
||||
"7. Call sites")
|
||||
print(" outside `app/` are not listed either, and every site list here "
|
||||
"matches on the")
|
||||
print(" bare METHOD NAME - three watchdogs are all called `evaluate` "
|
||||
"and at least")
|
||||
print(" four unrelated classes have an `explain()` - so the receiver is "
|
||||
"printed")
|
||||
print(" beside each one and a reader has to pick.")
|
||||
print(" 4. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is "
|
||||
"ERASED before")
|
||||
print(" census A's comparison, so a swap that would make the FORMATTING "
|
||||
"ITSELF fail")
|
||||
print(" - a `DAY_NAMES[start]` where `start` is 1320 - is scored as "
|
||||
"'changes no")
|
||||
print(" decision'. `_raise_gap`'s (start, weekday) pairs are that. The "
|
||||
"hazard is a")
|
||||
print(" SAME-TYPED pair, which is why every census B and C row now "
|
||||
"prints whether")
|
||||
print(" the two format specs match.")
|
||||
print(" 5. CENSUS C's verdict is a bare-name resolution (gap 3) and "
|
||||
"therefore reads")
|
||||
print(" MESSAGE too readily. Every verdict is printed with the sites "
|
||||
"that produced")
|
||||
print(" it; a MESSAGE verdict is a prompt to read them, not a proof.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,238 @@
|
||||
"""UX-01: read config.yaml's option surface and the translations, and compare.
|
||||
|
||||
⚠️ WHY THIS IS A MODULE AND NOT A FUNCTION INSIDE THE CHECK. Two very different
|
||||
programs have to agree about what is in `translations/*.yaml`:
|
||||
|
||||
* `test_translations.py`, which runs in the add-on's own CI image - Alpine,
|
||||
python3 + py3-aiohttp + py3-paho-mqtt and NOTHING ELSE. There is no PyYAML
|
||||
there and a check that skips on ImportError is not a check, so the parser
|
||||
below is written by hand;
|
||||
* `tools/supervisor_shape_probe.py`, which runs inside the real Home Assistant
|
||||
Supervisor image and loads the same files with the loader Home Assistant
|
||||
ACTUALLY uses (`supervisor.utils.yaml.read_json_or_yaml_file`, validated by
|
||||
`SCHEMA_APP_TRANSLATIONS`).
|
||||
|
||||
A hand parser that quietly disagrees with the real loader is precisely the
|
||||
defect this repository keeps finding - a check whose data cannot reach the
|
||||
failure it names. So the probe imports THIS module and asserts, key by key and
|
||||
string by string, that `parse_translation_file()` and the real loader return the
|
||||
same thing. That equality is the licence for CI to use the hand parser at all.
|
||||
|
||||
The parser is DELIBERATELY STRICT. It accepts exactly the shape UX-01 generates
|
||||
- two-space indentation, one `key:` mapping opener or one `key: "double quoted
|
||||
scalar"` per line, `#` comments, blank lines - and RAISES on anything else. A
|
||||
permissive parser would answer "no problems" for a file it had silently skipped
|
||||
half of, which is the same failure wearing a passing grade.
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
|
||||
__all__ = ["parse_translation_file", "parse_translation_text", "config_surface",
|
||||
"audit"]
|
||||
|
||||
|
||||
class TranslationSyntaxError(ValueError):
|
||||
"""The file is not in the subset this parser accepts. Never silently skip."""
|
||||
|
||||
|
||||
_LINE = re.compile(r"^(?P<indent> *)(?P<name>[A-Za-z0-9_.:/-]+):(?P<rest>.*)$")
|
||||
|
||||
|
||||
def _unescape(raw, lineno):
|
||||
"""The body of a YAML double-quoted scalar -> its value."""
|
||||
out, i = [], 0
|
||||
while i < len(raw):
|
||||
ch = raw[i]
|
||||
if ch == "\\":
|
||||
if i + 1 >= len(raw):
|
||||
raise TranslationSyntaxError(f"line {lineno}: trailing backslash")
|
||||
nxt = raw[i + 1]
|
||||
if nxt not in ('"', "\\"):
|
||||
# Deliberately narrow: UX-01's generator emits only these two.
|
||||
# Anything else is a shape this parser has not been measured on.
|
||||
raise TranslationSyntaxError(
|
||||
f"line {lineno}: unsupported escape \\{nxt}")
|
||||
out.append(nxt)
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
raise TranslationSyntaxError(f"line {lineno}: unescaped quote")
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def parse_translation_text(text, where="<text>"):
|
||||
"""A nested dict from the strict two-space YAML subset UX-01 writes.
|
||||
|
||||
Raises TranslationSyntaxError on ANY line it does not fully understand.
|
||||
"""
|
||||
root = {}
|
||||
stack = [(-2, root)] # (indent, mapping)
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
if line.rstrip() != line:
|
||||
raise TranslationSyntaxError(f"{where} line {lineno}: trailing whitespace")
|
||||
m = _LINE.match(line)
|
||||
if not m:
|
||||
raise TranslationSyntaxError(f"{where} line {lineno}: not `key:` -> {line!r}")
|
||||
indent, name, rest = len(m.group("indent")), m.group("name"), m.group("rest")
|
||||
if indent % 2:
|
||||
raise TranslationSyntaxError(
|
||||
f"{where} line {lineno}: indent {indent} is not a multiple of 2")
|
||||
while stack and indent <= stack[-1][0]:
|
||||
stack.pop()
|
||||
if not stack:
|
||||
raise TranslationSyntaxError(f"{where} line {lineno}: dedented past the root")
|
||||
parent_indent, parent = stack[-1]
|
||||
if indent != parent_indent + 2:
|
||||
raise TranslationSyntaxError(
|
||||
f"{where} line {lineno}: indent {indent} does not open under "
|
||||
f"indent {parent_indent}")
|
||||
if name in parent:
|
||||
raise TranslationSyntaxError(f"{where} line {lineno}: duplicate key {name!r}")
|
||||
if rest == "":
|
||||
child = {}
|
||||
parent[name] = child
|
||||
stack.append((indent, child))
|
||||
continue
|
||||
if not rest.startswith(" "):
|
||||
raise TranslationSyntaxError(f"{where} line {lineno}: no space after the colon")
|
||||
value = rest[1:]
|
||||
if len(value) < 2 or value[0] != '"' or value[-1] != '"':
|
||||
raise TranslationSyntaxError(
|
||||
f"{where} line {lineno}: scalars must be double quoted -> {value!r}")
|
||||
parent[name] = _unescape(value[1:-1], lineno)
|
||||
return root
|
||||
|
||||
|
||||
def parse_translation_file(path):
|
||||
return parse_translation_text(io.open(path, encoding="utf-8").read(), str(path))
|
||||
|
||||
|
||||
# --- config.yaml's option surface --------------------------------------------
|
||||
# ⚠️ THE SCHEMA HALF, NOT THE OPTIONS HALF, AND THAT IS A MEASUREMENT RATHER
|
||||
# THAN A PREFERENCE. Supervisor's `App.schema_ui` is `UiOptions(...)(data[
|
||||
# ATTR_SCHEMA])`, so the ORDER the Configuration tab renders in is the order of
|
||||
# the `schema:` block and nothing else. tools/supervisor_shape_probe.py section
|
||||
# F prints that order out of the real image.
|
||||
#
|
||||
# ⚠️ ANCHORED ON THE INDENTATION, never on a stripped line. A top-level schema
|
||||
# key is at EXACTLY two spaces and everything nested is at four or more; the
|
||||
# `max_w` inside `inverters:` and the flat `max_w` are the same text otherwise,
|
||||
# and config.yaml declares the nested one FIRST on purpose so that a first-match
|
||||
# stripped reader is caught. See test_inverter.py part 12.
|
||||
_TOP = re.compile(r"^ ([a-z_0-9]+):(.*)$")
|
||||
_NESTED = re.compile(r"^ {4,}(?:- )?([a-z_0-9]+): *(\S.*?) *$")
|
||||
|
||||
|
||||
def config_surface(config_text):
|
||||
"""(top-level schema keys in render order, {key: [nested per-entry keys]}).
|
||||
|
||||
Nested keys are the ones a list-of-objects option declares per entry - today
|
||||
only `inverters:`. A list of scalars (`- str`) declares none, and comes back
|
||||
absent from the second dict rather than as an empty list.
|
||||
"""
|
||||
marker = config_text.index("\nschema:")
|
||||
half = config_text[marker:]
|
||||
order, nested, current = [], {}, None
|
||||
for line in half.splitlines():
|
||||
m = _TOP.match(line)
|
||||
if m:
|
||||
order.append(m.group(1))
|
||||
current = m.group(1) if not m.group(2).strip() else None
|
||||
continue
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
n = _NESTED.match(line)
|
||||
if n:
|
||||
nested.setdefault(current, []).append(n.group(1))
|
||||
else:
|
||||
current = None
|
||||
if len(order) != len(set(order)):
|
||||
dupes = sorted({k for k in order if order.count(k) > 1})
|
||||
raise ValueError(f"config.yaml declares {dupes} twice at the top level of schema:")
|
||||
return order, nested
|
||||
|
||||
|
||||
# --- the audit ---------------------------------------------------------------
|
||||
def audit(config_text, translation, lang, require_descriptions=True):
|
||||
"""Every disagreement between config.yaml and one translation file.
|
||||
|
||||
Returns a list of strings. EMPTY MEANS AGREEMENT; each string names the key
|
||||
and says which direction the disagreement runs, because "the translations do
|
||||
not match" is not something anybody can act on.
|
||||
"""
|
||||
order, nested = config_surface(config_text)
|
||||
problems = []
|
||||
top = translation.get("configuration")
|
||||
if not isinstance(top, dict):
|
||||
return [f"{lang}: no `configuration:` block at all"]
|
||||
# ⚠️ THE WHOLE FILE IS ALL-OR-NOTHING, AND THAT IS WHY THESE TWO LINES ARE
|
||||
# HERE RATHER THAN ONLY THE `configuration:` COVERAGE BELOW.
|
||||
# `_read_app_translations()` runs the file through SCHEMA_APP_TRANSLATIONS
|
||||
# and, on `vol.Invalid`, LOGS A WARNING AND DROPS THE WHOLE FILE - every
|
||||
# label in it, not just the offending key. So a shape Supervisor refuses is
|
||||
# not a small defect that costs one description; it silently returns the tab
|
||||
# to raw keys. The two ways to write one that this parser can still read are
|
||||
# a top-level block that is not `configuration` or `network`, and a
|
||||
# `network:` block whose values are not strings ({str: str} is the schema).
|
||||
for extra in sorted(set(translation) - {"configuration", "network"}):
|
||||
problems.append(f"{lang}: `{extra}:` is not a block Supervisor reads "
|
||||
"(only `configuration` and `network` are) - "
|
||||
"SCHEMA_APP_TRANSLATIONS refuses the file and the "
|
||||
"Supervisor DROPS ALL OF IT with a log warning")
|
||||
for port, text in sorted((translation.get("network") or {}).items()):
|
||||
if not isinstance(text, str):
|
||||
problems.append(f"{lang}: `network.{port}` is not a string - "
|
||||
"`network:` is {str: str} and a mapping there makes "
|
||||
"Supervisor drop the WHOLE file, every label in it")
|
||||
|
||||
for key in order:
|
||||
entry = top.get(key)
|
||||
if entry is None:
|
||||
problems.append(f"{lang}: option `{key}` has no translation - Home "
|
||||
"Assistant would render the raw key")
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
problems.append(f"{lang}: `{key}` is not a mapping")
|
||||
continue
|
||||
if not str(entry.get("name", "")).strip():
|
||||
problems.append(f"{lang}: option `{key}` has no `name`")
|
||||
if require_descriptions and not str(entry.get("description", "")).strip():
|
||||
problems.append(f"{lang}: option `{key}` has no `description` - the "
|
||||
"operator gets a label and no way to know whether to "
|
||||
"touch it")
|
||||
want = nested.get(key, [])
|
||||
got = entry.get("fields")
|
||||
if want and not isinstance(got, dict):
|
||||
problems.append(f"{lang}: `{key}` is a repeatable section with "
|
||||
f"per-entry keys {sorted(want)} and no `fields:` block")
|
||||
continue
|
||||
if got is not None and not want:
|
||||
problems.append(f"{lang}: `{key}` carries a `fields:` block but "
|
||||
"config.yaml declares no per-entry keys for it")
|
||||
continue
|
||||
for sub in want:
|
||||
se = (got or {}).get(sub)
|
||||
if se is None:
|
||||
problems.append(f"{lang}: `{key}` per-entry key `{sub}` has no "
|
||||
"translation")
|
||||
continue
|
||||
if not str(se.get("name", "")).strip():
|
||||
problems.append(f"{lang}: `{key}.{sub}` has no `name`")
|
||||
if require_descriptions and not str(se.get("description", "")).strip():
|
||||
problems.append(f"{lang}: `{key}.{sub}` has no `description`")
|
||||
for orphan in sorted(set(got or {}) - set(want)):
|
||||
problems.append(f"{lang}: `{key}.{orphan}` is translated but "
|
||||
"config.yaml declares no such per-entry key")
|
||||
|
||||
for orphan in sorted(set(top) - set(order)):
|
||||
problems.append(f"{lang}: `{orphan}` is translated but config.yaml "
|
||||
"declares no such option - a rename or a typo")
|
||||
return problems
|
||||
@@ -0,0 +1,275 @@
|
||||
# Home Assistant add-on translations - ENGLISH (UX-01)
|
||||
#
|
||||
# ⚠️ WHAT THIS FILE IS FOR, AND WHAT HOME ASSISTANT ACTUALLY DOES WITH IT.
|
||||
# Without a `translations/` directory the Configuration tab renders the raw
|
||||
# option KEYS - `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w`.
|
||||
# The frontend keeps a field when `e.name in options || e.required`, and all 70
|
||||
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY are
|
||||
# rendered, always, in `schema:` order, with no description. This file is what
|
||||
# turns that into sentences.
|
||||
#
|
||||
# HOW IT IS READ, measured on the Supervisor image this site runs
|
||||
# (ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0):
|
||||
#
|
||||
# * `supervisor/store/data.py:_read_app_translations()` reads every
|
||||
# `.yaml`/`.yml`/`.json` in this directory, keyed by FILE STEM (so this file
|
||||
# is the `en` translation), and validates each through
|
||||
# `SCHEMA_APP_TRANSLATIONS`: `configuration: {str: {name (required),
|
||||
# description, fields}}` plus `network: {str: str}`. ⚠️ A FILE THAT FAILS
|
||||
# THAT SCHEMA IS DROPPED WITH A LOG WARNING AND NOTHING ELSE - the add-on
|
||||
# goes on rendering raw keys and nobody is told twice. tools/
|
||||
# supervisor_shape_probe.py section F is what says it loaded.
|
||||
# * The frontend resolves each label as
|
||||
# `translations[<user's language>]?...name || translations["en"]?...name ||
|
||||
# schema.name`, and the helper text the same way with `description`. ⚠️ THE
|
||||
# FALLBACK IS PER KEY, so a key missing from nl.yaml degrades to this file,
|
||||
# while a key present but wrong does not degrade at all.
|
||||
#
|
||||
# ⚠️ ORDER IS NOT SET HERE. `App.schema_ui` is `UiOptions(...)(data["schema"])`,
|
||||
# so the Configuration tab renders in the order of config.yaml's `schema:`
|
||||
# block. The order below only mirrors it so the two files read alike.
|
||||
#
|
||||
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST. There is no section-header primitive, no
|
||||
# per-option "advanced" flag, no units on the input and no sliders, so grouping
|
||||
# is approximated by ORDER plus the `Group · ` prefix in each label. Do not
|
||||
# promise the operator visual sections; there are none.
|
||||
#
|
||||
# ⚠️ EVERY DERIVED CONSTANT SAYS WHERE IT CAME FROM. The Configuration tab
|
||||
# presents a number read off 1890 closed-loop test runs and the operator's own
|
||||
# inverter rating as the same kind of field. The descriptions are the only place
|
||||
# that difference can be stated, so a derived value names its campaign and says
|
||||
# "do not change without re-deriving", and a value that is genuinely the
|
||||
# operator's says so too.
|
||||
configuration:
|
||||
setpoint_entity:
|
||||
name: "Hardware · Inverter setpoint entity"
|
||||
description: "The ESPHome number entity this add-on writes the power setpoint to, normally number.*_goodwe_setpoint_w. This is the only thing the add-on commands. Leave it empty if you fill in the inverter list below: a site with a roster must not also name one loose actuator, and the add-on refuses to start when both are set."
|
||||
soc_entity:
|
||||
name: "Hardware · Battery charge level entity (%)"
|
||||
description: "The sensor that reports the pack's state of charge in percent. Use the ESP32's own reading, not a figure computed somewhere else. The maintenance cycle and every charge-level floor read this."
|
||||
batt_entity:
|
||||
name: "Hardware · Battery power entity (W)"
|
||||
description: "The sensor that reports this inverter's own battery power, positive while discharging. On this installation that is holding register 0x0518, read by the ESP32. The sign watchdog and the circulating-power alarm both grade the inverter against this reading."
|
||||
batt_invert:
|
||||
name: "Hardware · Battery power sign is reversed"
|
||||
description: "Only switch this on if your battery power sensor reports discharging as a negative number. The add-on expects positive to mean discharging."
|
||||
inverters:
|
||||
name: "Hardware · Inverter list (leave empty if you have one inverter)"
|
||||
description: "Leave this empty unless the site really has more than one inverter. EMPTY DOES NOT MEAN 'not configured yet': it means the four entity settings above, together with the inverter rating and the battery cut-out under Limits, describe the one inverter you have. That is what every existing installation reads, and nobody has to touch anything. Filling this in REPLACES those settings instead of extending them, and nothing is inherited: each entry has to name its own actuator, its own rating and its own battery cut-out, so that no inverter is ever judged against another unit's numbers. The flat setpoint entity above must then be left empty."
|
||||
fields:
|
||||
name:
|
||||
name: "Name"
|
||||
description: "A short identifier for this inverter, for example inverter-a. It gives this unit its own namespace for its safety latch, heartbeat and write bookkeeping in the add-on's state file, so two units may not share one."
|
||||
setpoint_entity:
|
||||
name: "Setpoint entity"
|
||||
description: "This inverter's own ESPHome number.*_goodwe_setpoint_w. Two entries may not name the same one - two writers on one actuator is refused at startup."
|
||||
max_w:
|
||||
name: "Inverter rating (W)"
|
||||
description: "The supervised ceiling on what may be written to THIS inverter; the site's ceiling is the sum over the list. Required of every entry: an entry without one would have to borrow a rating, and judging one inverter against another unit's numbers is exactly the fault this per-entry list exists to remove."
|
||||
bms_soc_floor:
|
||||
name: "Battery cut-out level (%)"
|
||||
description: "The percent at which THIS pack's own battery management system cuts out. Read it off the pack that is actually installed (register 0x0560) before that pack is ever commanded; do not copy another unit's figure. Required of every entry, deliberately: an optional one would be a defaulted one, and one default for two batteries of different models protects the shallower pack with the deeper pack's number."
|
||||
soc_entity:
|
||||
name: "Battery charge level entity (%) (optional)"
|
||||
description: "This pack's own state of charge. Leave it empty if this unit reports none - that is what it means, and it never falls back to another unit's sensor."
|
||||
batt_entity:
|
||||
name: "Battery power entity (W) (optional)"
|
||||
description: "This unit's own battery power, positive while discharging. Leave it empty if this unit reports none; nothing is inherited from another entry."
|
||||
batt_invert:
|
||||
name: "Battery power sign is reversed (optional)"
|
||||
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485 emergency stop is fitted"
|
||||
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
|
||||
meter_source:
|
||||
name: "Meter · Where the meter reading comes from"
|
||||
description: "'off' keeps the single Home Assistant entity below, which is what an existing installation runs on until you choose otherwise. ha_dsmr subscribes to the DSMR integration; mqtt_p1 reads an MQTT topic; ha_signed reads one signed entity, which is what a HomeWizard P1 publishes; homewizard_local polls the meter's own local API and skips Home Assistant entirely. ⚠️ Only homewizard_local can tell 'the meter has not answered' apart from 'the value has not changed', so it is the only source on which the frozen-meter alarm means anything."
|
||||
meter_entity:
|
||||
name: "Meter · Grid power entity (W)"
|
||||
description: "The net grid power sensor, used when the meter source is 'off'. POSITIVE MUST MEAN DRAWING FROM THE GRID. Get this the wrong way round and the loop drives the battery in the wrong direction; use the sign switch below rather than a template sensor."
|
||||
meter_invert:
|
||||
name: "Meter · Grid power sign is reversed"
|
||||
description: "Switch this on if your grid sensor reports drawing from the grid as a negative number."
|
||||
meter_phases:
|
||||
name: "Meter · Number of phases (1 or 3)"
|
||||
description: "Must match your connection. A telegram whose phase count disagrees is rejected as it arrives and logged, so a wrong value shows up straight away as 'no telegrams accepted' rather than as a quietly wrong number. The range accepts 2, but that is not a real Belgian connection."
|
||||
p1_host:
|
||||
name: "Meter · HomeWizard P1 address"
|
||||
description: "For meter source homewizard_local only: the meter's own address on your network, as host or host:port, for example 192.168.2.250."
|
||||
meter_poll_s:
|
||||
name: "Meter · Seconds between meter polls"
|
||||
description: "For meter source homewizard_local only. 5 s is the meter's own update rate, so there is nothing to gain below it. It must stay well under the staleness limit below - the reading is never fresher than this interval - and the add-on refuses to start if it is at or above that limit."
|
||||
meter_max_age_s:
|
||||
name: "Meter · A reading counts as stale after (s)"
|
||||
description: "Beyond this the grid figure counts as missing. On its own it does not command 0 W: the 'inputs missing' timer under Advanced starts only after this one has expired, and the two stack. 30 s is six polls of headroom at the default poll interval."
|
||||
meter_mqtt_topic:
|
||||
name: "Meter · MQTT topic"
|
||||
description: "For meter source mqtt_p1 only: the topic the P1 readings are published on."
|
||||
p1_import_entity:
|
||||
name: "Meter · Import register entity (W)"
|
||||
description: "The UNSIGNED consumption register. Do not point this at a signed template sensor: the add-on works out net power itself as import minus export, so the sign convention lives in tested code rather than in unreviewed YAML."
|
||||
p1_export_entity:
|
||||
name: "Meter · Export register entity (W)"
|
||||
description: "The UNSIGNED injection register, the counterpart of the import register above. Same rule: no signed template sensor."
|
||||
p1_net_entity:
|
||||
name: "Meter · Signed net power entity (W)"
|
||||
description: "For meter source ha_signed only. ONE signed sensor: positive is drawn from the grid, negative is fed back into it. Do not split it into two template sensors - the add-on does that split itself, so that it is tested."
|
||||
p1_phase_import_entities:
|
||||
name: "Meter · Per-phase import entities (L1, L2, L3)"
|
||||
description: "Optional, in L1..L3 order. Needed for the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases above."
|
||||
p1_phase_export_entities:
|
||||
name: "Meter · Per-phase export entities (L1, L2, L3)"
|
||||
description: "Optional, in L1..L3 order, alongside the per-phase import entities above. The number of entries must equal the number of phases."
|
||||
p1_phase_net_entities:
|
||||
name: "Meter · Per-phase signed net entities (L1, L2, L3)"
|
||||
description: "For meter source ha_signed only. Optional, in L1..L3 order, each signed the same way as the net entity above. Same job as the per-phase import and export entities: the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases."
|
||||
max_w:
|
||||
name: "Limits · Inverter rating (W)"
|
||||
description: "THIS IS YOUR NUMBER, not a derived one: the hard ceiling on what may ever be commanded, in watts. Set it to what your inverter is actually rated for, start low and raise it once the site has been commissioned. It is not read at all when the inverter list is filled in - each entry then carries its own, and the site ceiling is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not."
|
||||
bms_soc_floor:
|
||||
name: "Limits · Battery cut-out level (%)"
|
||||
description: "MEASURED, not chosen: the percent at which the pack's OWN battery management system stops discharging, whatever this add-on is set to. Register 0x0560 reads 10 on the reference GW5000S-BP. Read it off the pack you actually have rather than copying this figure. It is here so the add-on can refuse a capacity-guard floor at or below it: such a floor could never bind, the BMS would win first, and the status page would report an energy backstop that does not exist. Not read when the inverter list is filled in - each entry then states its own pack's cut-out."
|
||||
peak_cap_w:
|
||||
name: "Limits · Capacity-tariff target (W)"
|
||||
description: "THIS IS YOUR NUMBER: the quarter-hour average demand the peak guard tries to stay under. Take it from your grid contract - in Belgium it is the capacity-tariff peak your distribution charge is billed on."
|
||||
peak_forecast_entity:
|
||||
name: "Limits · Quarter-hour demand forecast entity (W)"
|
||||
description: "Optional. A forecast of the current billing quarter's average demand. Leave it empty and there is no capacity cap at all."
|
||||
peak_soc_floor:
|
||||
name: "Limits · Peak guard will not discharge below (%)"
|
||||
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
|
||||
price_provider:
|
||||
name: "Tariff · Dynamic price source"
|
||||
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ Switching it on today fetches, checks and publishes prices and changes no energy decision - nothing consumes the price series yet. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed."
|
||||
price_markup_eur_kwh:
|
||||
name: "Tariff · Supplier markup (EUR/kWh)"
|
||||
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under engie_dynamic, totalenergies_dynamic, eneco_eddy and manual_csv it is ignored, and logged as ignored, never applied twice."
|
||||
price_taxes_eur_kwh:
|
||||
name: "Tariff · Levies and VAT (EUR/kWh)"
|
||||
description: "Taxes and levies per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them."
|
||||
export_price_eur_kwh:
|
||||
name: "Tariff · Injection price (EUR/kWh)"
|
||||
description: "What you are paid for feeding a kWh back into the grid, all in. One number rather than a series: no supplier here publishes a dynamic injection curve. In Belgium it is often close to zero, which on its own strongly favours using your own production over exporting it. It may be negative - at midday in summer you can be paying to export."
|
||||
static_profile:
|
||||
name: "Tariff · Fixed or day/night tariff (one line of JSON)"
|
||||
description: "Your fixed or day/night contract, written as a single line of JSON. RECOMMENDED EVEN ON A DYNAMIC CONTRACT: this is what the add-on falls back to when the price feed dies, and with it empty a dead feed leaves the site with no tariff at all. The periods must cover every day type exactly once, with no gap and no overlap - a gap is refused at startup rather than filled in, because an invented price is one a strategy would trade on. A fully fixed tariff is one period covering 00:00-24:00 on all days. Prices are ALL IN; the markup and levies above are not added to them. Note that a window written 22:00-07:00 for mon-fri does NOT cover Saturday morning, so a weekend row is required rather than decorative."
|
||||
price_csv_path:
|
||||
name: "Tariff · Price CSV file"
|
||||
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
|
||||
price_feed_url:
|
||||
name: "Tariff · Price feed URL override"
|
||||
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere."
|
||||
price_horizon_h:
|
||||
name: "Tariff · How far ahead to fetch prices (hours)"
|
||||
description: "How many hours of prices to ask the source for."
|
||||
price_timeout_s:
|
||||
name: "Tariff · Price request timeout (s)"
|
||||
description: "How long a single request to the price source may take before it is given up on."
|
||||
price_now_entity:
|
||||
name: "Tariff · Current price entity (older price path)"
|
||||
description: "The older way of getting a price, kept because retiring it would take the maintenance cycle's only price signal away from a live installation. The add-on reads this and the average below and derives exactly one thing from them - whether now is cheaper than average - which only the maintenance cycle's cheap-window charging uses. It may not be combined with a dynamic price source."
|
||||
price_avg_entity:
|
||||
name: "Tariff · Average price entity (older price path)"
|
||||
description: "The average the current price is compared against. Only meaningful together with the current-price entity above, and like it, may not be combined with a dynamic price source."
|
||||
arbitrage_enabled:
|
||||
name: "Tariff · Trade on the price spread"
|
||||
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
|
||||
cycle_cost_threshold_eur:
|
||||
name: "Tariff · Cost of one full charge/discharge cycle (EUR/kWh)"
|
||||
description: "What one full round trip costs per kWh: wear on the pack plus conversion losses. A price spread smaller than this loses money and battery life, so it is not traded. ⚠️ THIS ONE IS NOT DERIVED FROM A MEASUREMENT. 0.10 is a product decision and it is still an open question with the owner (OPT-08). At this value a fixed day/night site does not trade at all - the realistic Belgian day/night spread is about 0.08 EUR/kWh - and that is the correct verdict at this number, not a fault."
|
||||
arbitrage_soc_floor:
|
||||
name: "Tariff · Trading may not discharge below (%)"
|
||||
description: "The reserve price trading may not spend. Deliberately not the same key as the maintenance floor: pointing a trading strategy at the monthly balancing cycle's numbers would couple two unrelated policies through one setting."
|
||||
arbitrage_soc_ceiling:
|
||||
name: "Tariff · Trading may not charge above (%)"
|
||||
description: "The headroom price trading may not fill. The counterpart of the trading floor above, and kept separate from the maintenance target for the same reason."
|
||||
maintenance_enabled:
|
||||
name: "Maintenance · Run the monthly battery cycle"
|
||||
description: "A deliberate deep discharge followed by a full recharge and a hold, so the battery management system can recalibrate its charge estimate and balance the cells. Off by default."
|
||||
maintenance_interval_days:
|
||||
name: "Maintenance · Minimum days between cycles"
|
||||
description: "The shortest gap between two maintenance cycles."
|
||||
maintenance_start_hour:
|
||||
name: "Maintenance · Hour of day a due cycle starts"
|
||||
description: "Local hour, 0 to 23, at which a cycle that has become due begins."
|
||||
maintenance_discharge_w:
|
||||
name: "Maintenance · Discharge rate (W)"
|
||||
description: "How fast the cycle drains the pack; the surplus is exported. Above the inverter rating it is CLAMPED to the rating rather than refused - the cycle still completes, just more slowly, and the status page says so. It ships at 2000 to match the shipped rating; raise the rating first, then this."
|
||||
maintenance_charge_w:
|
||||
name: "Maintenance · Recharge rate (W)"
|
||||
description: "⚠️ MUST STAY AT OR BELOW the inverter rating, and the add-on refuses to start otherwise. Above it, the cheap-window charge shaping asks for more than the supervised limit allows, the arbiter resolves that contradiction to 0 W, and maintenance would silently never charge in a cheap window on a default installation. It is also capped again by whatever headroom the peak guard leaves."
|
||||
maintenance_soc_floor:
|
||||
name: "Maintenance · Discharge down to (%)"
|
||||
description: "The depth the monthly recalibration deliberately goes to, once a month, on purpose. ⚠️ THIS IS NOT THE PEAK GUARD'S FLOOR: that one is the depth a money-saving discharge may never reach on any day. The right values genuinely differ - 11 here against 20 there - which is why they are two separate settings and must not be merged."
|
||||
maintenance_soc_target:
|
||||
name: "Maintenance · Recharge up to (%)"
|
||||
description: "The level the recharge phase aims for before the hold begins."
|
||||
maintenance_hold_min:
|
||||
name: "Maintenance · Hold at full for (minutes)"
|
||||
description: "How long to sit at the target so the battery management system can finish balancing the cells."
|
||||
sign_disagree_w:
|
||||
name: "Safety · Sign watchdog threshold (W) — measured, do not change"
|
||||
description: "MEASURED, NOT CHOSEN (SAFETY-02). Both the setpoint being emitted and the grid reading must exceed this before their opposite signs count as a disagreement at all. On the captured healthy day (2026-08-23) sign agreement over the gated samples is 93.4 %; on the captured fault day (2026-08-20) the clusters run 0.0-10.5 % with opposed runs up to 1752 s. There is no reason to move this without a new capture that says so. It must stay below the inverter rating or the watchdog can never fire, and the add-on refuses that configuration."
|
||||
sign_disagree_s:
|
||||
name: "Safety · Sign watchdog hold (s) — measured, do not change"
|
||||
description: "MEASURED, NOT CHOSEN (SAFETY-02). How long the setpoint and the meter must stay opposed, continuously, before the add-on commands 0 W and latches until an operator starts control again. 180 s sits in the middle of a very wide measured gap: the longest legitimate opposed run on the healthy day is 30 s, while the fault day still trips 13 times at 300 s. Do not change it without a new capture."
|
||||
unit_sign_disagree_w:
|
||||
name: "Safety · Per-inverter sign threshold (W) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter this setting does nothing at all. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS on the two-master rig (SAFETY-30), in which nothing was faulty, swept across the whole range this add-on accepts for loop gain and slew rate. 250 W is exactly half the saturation threshold, so the smallest divergence this can see is precisely the gap at which the control law itself already says the inverter is not following. Do not change it without re-deriving it. It must stay below the inverter rating or it can never fire."
|
||||
unit_sign_disagree_s:
|
||||
name: "Safety · Per-inverter sign hold (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs produce (10.0 s), 2.8 times the firmware's own 30 s link veto, and under half the 180 s the site-wide check takes. Do not change it without re-deriving it."
|
||||
site_sign_disagree_w:
|
||||
name: "Safety · Site-wide sign threshold (W) — measured, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY. The same 300 W as the single-inverter threshold, CARRIED ACROSS DELIBERATELY (SAFETY-30): the argument behind it does not depend on how many inverters there are - near zero the sign is noise, the deadband is 15 W, and there is one meter whatever is behind it. Do not change it without a capture that says so."
|
||||
site_sign_disagree_s:
|
||||
name: "Safety · Site-wide sign hold (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30), and deliberately NOT carried across from the single-inverter 180 s. On those runs the worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, because the command has to travel the whole site rail at a slew rate that is not the site's - so carrying 180 s across would have shipped a false trip reachable at the lowest gain and slew with an ordinary 2.2 kW load reversal. 325 s is 1.51 times that, still inside the 900 s billing quarter, and still inside every episode of the captured fault day. Do not change it without re-deriving it."
|
||||
meter_freeze_s:
|
||||
name: "Safety · Frozen-meter alarm after (s) — measured, do not change"
|
||||
description: "MEASURED, NOT CHOSEN (SAFETY-18). How long the meter may keep answering with an UNCHANGED reading, while the command stands at the rail, before the add-on raises an alarm. Over 2399 s of this house at its settled operating point - 1194 polls, 735 of them repeating the previous reading - the longest run without a distinct reading is 15.4 s; 300 s is 19.5 times that and still inside the 900 s billing quarter. ALARM ONLY: it does not command 0 W and does not latch. ⚠️ Only the homewizard_local meter source can see this at all; on every other source the status page says NOT COVERED rather than armed. Do not change it without re-deriving it."
|
||||
circulation_w:
|
||||
name: "Safety · Circulating-power threshold (W) — measured, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter the quantity is identically zero. Two inverters pushing against each other net out to zero at the meter and are invisible to every other guard in this add-on. MEASURED ON 924 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-31): the worst legitimate opposition collapses from 505 s at a 100 W threshold to 5 s at 400 W, and 400 W is the sharp knee this add-on will not let you go below - under it, ordinary operation holds the sensor over the threshold for minutes and nothing shorter than the billing quarter could tell that from a fault. 700 W is 1.75 times the knee, and the extra margin is there because the second inverter's ramp and lag are still copied from the first one rather than measured. Do not change it without re-deriving it."
|
||||
circulation_hold_s:
|
||||
name: "Safety · Circulating-power hold (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
|
||||
auto_start:
|
||||
name: "Advanced · Start controlling automatically on boot"
|
||||
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
|
||||
gain:
|
||||
name: "Advanced · Loop gain"
|
||||
description: "How much of the remaining error the loop corrects each cycle. MEASURED ON REAL HARDWARE and already at the limit - do not raise it. Lower is slower and safer."
|
||||
slew_w:
|
||||
name: "Advanced · Largest change per cycle (W)"
|
||||
description: "How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW THE INVERTER RATING and is the recommended setting: the limit becomes about a ninth of the rating, so the loop takes the plant's own MEASURED settling time to cross its whole output range and the number cannot be left behind when the rating changes. DERIVED (SAFETY-39) from an ~18 s measured step response against a 2 s meter tick. A fixed number is accepted and is never silently reduced, but a number faster than the rating allows is refused at startup - at 1000 W against a 5000 W rating this loop drove the meter from 3.4 kW of export to 2.8 kW of import on 2026-09-05. Lower is always accepted."
|
||||
deadband_w:
|
||||
name: "Advanced · Deadband (W)"
|
||||
description: "Errors smaller than this are ignored. ⚠️ The deadband is a one-way ratchet: any resting point inside it holds forever, and the meter's import register counts every positive watt with no export to cancel it - which is exactly why the resting point below is slightly negative."
|
||||
target_grid_w:
|
||||
name: "Advanced · Resting point at the meter (W)"
|
||||
description: "What the meter should sit at when nothing is happening; negative means a slight export. -10 W is deliberate: resting at 0 W leaks about half the deadband in billed import all day - roughly 0.2 to 0.35 kWh at a 15 W deadband - and biasing the rest point below zero moves that leak into the export register, which is not billed. It costs about this many watts of given-away export, so keep it small."
|
||||
step_w:
|
||||
name: "Advanced · Setpoint quantisation (W)"
|
||||
description: "The setpoint is rounded to a multiple of this before it is written, so the inverter is not handed a new number for every watt of noise."
|
||||
saturation_w:
|
||||
name: "Advanced · Saturation threshold (W)"
|
||||
description: "The gap between what was commanded and what the inverter delivers that counts as 'this inverter is at a limit'. ⚠️ The per-inverter sign threshold under Safety is derived as exactly half of this, so moving this moves that measurement's argument with it."
|
||||
saturation_cycles:
|
||||
name: "Advanced · Saturated cycles before freezing (count)"
|
||||
description: "How many consecutive saturated cycles before the loop stops winding up. ⚠️ Do not set this to 1: a single noisy cycle would then freeze the loop."
|
||||
integrator_max_w:
|
||||
name: "Advanced · Integrator bound (W, 0 means the same as the rating)"
|
||||
description: "Caps how much stale error can be waiting to unwind when the sign flips. MEASURED on the closed-loop simulator with a 4000 W load dropped to zero: at a bound equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating it is 1800 W. ⚠️ Do not raise it above the inverter rating - the output clamp already bounds what reaches the wire, so extra headroom buys nothing but wrong-direction watts after every saturation event. Lowering it below the rating is the useful direction, and the only reason this is a separate setting."
|
||||
heartbeat_s:
|
||||
name: "Advanced · Setpoint refresh interval (s)"
|
||||
description: "How often the setpoint is rewritten even when it has not changed. It must stay well under the firmware's own watchdog, which vetoes a link that has been quiet for 30 s."
|
||||
stale_input_s:
|
||||
name: "Advanced · Command 0 W after inputs missing for (s)"
|
||||
description: "How long the inputs may be missing before the add-on commands 0 W. With a P1 meter this clock starts only AFTER the meter staleness limit has already expired, so the two stack: the total is longer than this number on its own."
|
||||
cycle_floor_s:
|
||||
name: "Advanced · Longest gap between control cycles (s)"
|
||||
description: "A deadline on the gap between cycles, reset by every cycle. On a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than about 10 s, and keep it under the meter staleness limit - past that there is no reading left to evaluate anyway."
|
||||
log_level:
|
||||
name: "Advanced · Log level"
|
||||
description: "How much detail the add-on writes to its log. 'info' is the shipped setting; 'debug' and 'trace' are for diagnosing a problem and are noisy."
|
||||
@@ -0,0 +1,255 @@
|
||||
# Home Assistant add-on-vertalingen - NEDERLANDS (UX-01)
|
||||
#
|
||||
# ⚠️ DIT IS GEEN WOORD-VOOR-WOORDVERTALING VAN en.yaml en hoort dat ook niet te
|
||||
# zijn. De eigenaar draait Home Assistant op Europe/Brussels; dit is de tekst
|
||||
# die een Vlaamse huiseigenaar leest, met de woorden die op zijn factuur en op
|
||||
# zijn digitale meter staan: afname en injectie, kwartierpiek en
|
||||
# capaciteitstarief, laadniveau, uitschakelgrens.
|
||||
#
|
||||
# ⚠️ DE TERUGVAL IS PER SLEUTEL. De frontend probeert eerst de taal van de
|
||||
# gebruiker, dan `en`, dan de kale sleutelnaam. Een sleutel die HIER ONTBREEKT
|
||||
# valt dus netjes terug op het Engels; een sleutel die hier STAAT maar fout is,
|
||||
# valt nergens op terug. Een half vertaalde sleutel weglaten is beter dan hem
|
||||
# verkeerd invullen.
|
||||
#
|
||||
# ⚠️ DE VOLGORDE WORDT HIER NIET BEPAALD. Het tabblad Configuratie rendert in de
|
||||
# volgorde van het `schema:`-blok van config.yaml; de volgorde hieronder is
|
||||
# alleen een spiegel daarvan, zodat beide bestanden gelijk lezen.
|
||||
#
|
||||
# ⚠️ AFGELEIDE CONSTANTEN VERMELDEN WAAR ZE VANDAAN KOMEN. Het tabblad toont een
|
||||
# getal dat uit 1890 gesloten-lustests komt en het omvormervermogen van de
|
||||
# eigenaar als hetzelfde soort veld. De beschrijving is de enige plek waar dat
|
||||
# verschil gezegd kan worden.
|
||||
configuration:
|
||||
setpoint_entity:
|
||||
name: "Hardware · Setpoint-entiteit van de omvormer"
|
||||
description: "De ESPHome number-entiteit waar deze add-on het vermogen naartoe schrijft, meestal number.*_goodwe_setpoint_w. Dit is het enige wat de add-on aanstuurt. Laat leeg als u hieronder de lijst met omvormers invult: een installatie met een lijst mag niet óók één losse aansturing hebben, en de add-on start dan niet op."
|
||||
soc_entity:
|
||||
name: "Hardware · Entiteit met het laadniveau van de batterij (%)"
|
||||
description: "De sensor die de laadtoestand van de batterij in procent doorgeeft. Gebruik de meting van de ESP32 zelf, geen waarde die elders berekend wordt. De onderhoudscyclus en alle ondergrenzen voor het laadniveau lezen deze sensor."
|
||||
batt_entity:
|
||||
name: "Hardware · Entiteit met het batterijvermogen (W)"
|
||||
description: "De sensor die het batterijvermogen van deze omvormer doorgeeft, positief bij ontladen. Op deze installatie is dat holding register 0x0518, uitgelezen door de ESP32. Zowel de tekenbewaking als het alarm voor rondpompend vermogen beoordelen de omvormer aan de hand van deze meting."
|
||||
batt_invert:
|
||||
name: "Hardware · Teken van het batterijvermogen omkeren"
|
||||
description: "Zet dit alleen aan als uw sensor ontladen als een negatief getal doorgeeft. De add-on gaat ervan uit dat positief ontladen betekent."
|
||||
inverters:
|
||||
name: "Hardware · Lijst met omvormers (leeg laten bij één omvormer)"
|
||||
description: "Laat dit leeg tenzij er echt meer dan één omvormer staat. LEEG BETEKENT NIET 'nog niet ingesteld': het betekent dat de vier entiteiten hierboven, samen met het omvormervermogen en de uitschakelgrens van de batterij onder Grenzen, de ene omvormer beschrijven die u hebt. Dat is wat elke bestaande installatie leest, zonder dat iemand iets hoeft aan te passen. Vult u de lijst wel in, dan VERVANGT die de instellingen hierboven in plaats van ze aan te vullen, en wordt er niets overgenomen: elk item moet zijn eigen aansturing, zijn eigen vermogen en zijn eigen uitschakelgrens vermelden, zodat geen enkele omvormer ooit beoordeeld wordt op de cijfers van een andere. De losse setpoint-entiteit hierboven moet dan leeg blijven."
|
||||
fields:
|
||||
name:
|
||||
name: "Naam"
|
||||
description: "Een korte aanduiding voor deze omvormer, bijvoorbeeld inverter-a. Ze geeft deze omvormer een eigen naamruimte voor haar veiligheidsvergrendeling, hartslag en schrijfadministratie in het statusbestand van de add-on, dus twee omvormers mogen er niet één delen."
|
||||
setpoint_entity:
|
||||
name: "Setpoint-entiteit"
|
||||
description: "De eigen ESPHome-entiteit number.*_goodwe_setpoint_w van deze omvormer. Twee items mogen niet dezelfde entiteit noemen - twee schrijvers op één aansturing wordt bij het opstarten geweigerd."
|
||||
max_w:
|
||||
name: "Vermogen van deze omvormer (W)"
|
||||
description: "De bewaakte bovengrens van wat er naar DEZE omvormer geschreven mag worden; het plafond van de installatie is de som over de lijst. Verplicht bij elk item: een item zonder deze waarde zou een vermogen moeten lenen, en een omvormer beoordelen op de cijfers van een andere is precies de fout die deze lijst per omvormer moet wegnemen."
|
||||
bms_soc_floor:
|
||||
name: "Uitschakelniveau van de batterij (%)"
|
||||
description: "Het percentage waarbij het batterijmanagementsysteem van DEZE batterij uitschakelt. Lees dit af van de batterij die er werkelijk staat (register 0x0560) voordat die batterij ooit aangestuurd wordt; neem geen waarde van een andere omvormer over. Bewust verplicht bij elk item: optioneel maken zou neerkomen op een standaardwaarde, en één standaardwaarde voor twee verschillende batterijen beschermt de kleinste batterij met het getal van de grootste."
|
||||
soc_entity:
|
||||
name: "Entiteit met het laadniveau (%) (optioneel)"
|
||||
description: "Het laadniveau van deze batterij zelf. Laat leeg als deze omvormer dat niet doorgeeft - dat is wat het betekent, en er wordt nooit teruggevallen op de sensor van een andere omvormer."
|
||||
batt_entity:
|
||||
name: "Entiteit met het batterijvermogen (W) (optioneel)"
|
||||
description: "Het batterijvermogen van deze omvormer zelf, positief bij ontladen. Laat leeg als deze omvormer dat niet doorgeeft; er wordt niets van een ander item overgenomen."
|
||||
batt_invert:
|
||||
name: "Teken van het batterijvermogen omkeren (optioneel)"
|
||||
description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft."
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485-noodstop is geplaatst"
|
||||
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
|
||||
meter_source:
|
||||
name: "Meter · Waar de meterstand vandaan komt"
|
||||
description: "'off' houdt de losse Home Assistant-entiteit hieronder aan, waar een bestaande installatie op draait tot u iets anders kiest. ha_dsmr luistert mee met de DSMR-integratie; mqtt_p1 leest een MQTT-topic; ha_signed leest één entiteit met teken, wat een HomeWizard P1 publiceert; homewizard_local bevraagt de lokale API van de meter zelf en slaat Home Assistant helemaal over. ⚠️ Alleen homewizard_local kan 'de meter heeft niet geantwoord' onderscheiden van 'de waarde is niet veranderd', en is dus de enige bron waarop het alarm voor een vastgelopen meter iets betekent."
|
||||
meter_entity:
|
||||
name: "Meter · Entiteit met het netvermogen (W)"
|
||||
description: "De sensor met het netto netvermogen, gebruikt wanneer de meterbron 'off' is. POSITIEF MOET AFNAME VAN HET NET BETEKENEN. Staat dit omgekeerd, dan stuurt de regeling de batterij de verkeerde kant op; gebruik daarvoor de tekenschakelaar hieronder en geen template-sensor."
|
||||
meter_invert:
|
||||
name: "Meter · Teken van het netvermogen omkeren"
|
||||
description: "Zet dit aan als uw netsensor afname van het net als een negatief getal doorgeeft."
|
||||
meter_phases:
|
||||
name: "Meter · Aantal fasen (1 of 3)"
|
||||
description: "Moet overeenkomen met uw aansluiting. Een telegram met een ander aantal fasen wordt bij binnenkomst geweigerd en gelogd, zodat een verkeerde waarde meteen zichtbaar wordt als 'geen telegrammen aanvaard' in plaats van als een stil verkeerd getal. Het bereik aanvaardt 2, maar dat is geen echte Belgische aansluiting."
|
||||
p1_host:
|
||||
name: "Meter · Adres van de HomeWizard P1"
|
||||
description: "Alleen voor meterbron homewizard_local: het adres van de meter zelf op uw netwerk, als host of host:poort, bijvoorbeeld 192.168.2.250."
|
||||
meter_poll_s:
|
||||
name: "Meter · Seconden tussen twee uitlezingen"
|
||||
description: "Alleen voor meterbron homewizard_local. 5 s is het tempo waarin de meter zelf ververst, dus lager instellen levert niets op. Deze waarde moet ruim onder de verouderingsgrens hieronder blijven - de meting is nooit verser dan dit interval - en de add-on start niet op als ze eraan gelijk is of erboven ligt."
|
||||
meter_max_age_s:
|
||||
name: "Meter · Een meting is verouderd na (s)"
|
||||
description: "Daarna geldt de netwaarde als ontbrekend. Op zichzelf stuurt dit nog geen 0 W aan: de teller 'invoer ontbreekt' onder Geavanceerd begint pas te lopen nadat deze verstreken is, en de twee stapelen. 30 s is zes uitlezingen speling bij het standaard uitleesinterval."
|
||||
meter_mqtt_topic:
|
||||
name: "Meter · MQTT-topic"
|
||||
description: "Alleen voor meterbron mqtt_p1: het topic waarop de P1-metingen gepubliceerd worden."
|
||||
p1_import_entity:
|
||||
name: "Meter · Entiteit van het afnameregister (W)"
|
||||
description: "Het afnameregister ZONDER teken. Wijs dit niet aan een template-sensor mét teken toe: de add-on berekent het nettovermogen zelf als afname min injectie, zodat de tekenafspraak in geteste code zit en niet in ongecontroleerde YAML."
|
||||
p1_export_entity:
|
||||
name: "Meter · Entiteit van het injectieregister (W)"
|
||||
description: "Het injectieregister ZONDER teken, de tegenhanger van het afnameregister hierboven. Zelfde regel: geen template-sensor mét teken."
|
||||
p1_net_entity:
|
||||
name: "Meter · Entiteit met het nettovermogen mét teken (W)"
|
||||
description: "Alleen voor meterbron ha_signed. ÉÉN sensor mét teken: positief is afname van het net, negatief is injectie. Splits die niet in twee template-sensoren op - de add-on doet die splitsing zelf, juist zodat ze getest is."
|
||||
p1_phase_import_entities:
|
||||
name: "Meter · Afname-entiteiten per fase (L1, L2, L3)"
|
||||
description: "Optioneel, in de volgorde L1..L3. Nodig voor de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen hierboven."
|
||||
p1_phase_export_entities:
|
||||
name: "Meter · Injectie-entiteiten per fase (L1, L2, L3)"
|
||||
description: "Optioneel, in de volgorde L1..L3, naast de afname-entiteiten per fase hierboven. Het aantal items moet gelijk zijn aan het aantal fasen."
|
||||
p1_phase_net_entities:
|
||||
name: "Meter · Netto-entiteiten per fase mét teken (L1, L2, L3)"
|
||||
description: "Alleen voor meterbron ha_signed. Optioneel, in de volgorde L1..L3, elk met hetzelfde teken als de netto-entiteit hierboven. Zelfde rol als de afname- en injectie-entiteiten per fase: de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen."
|
||||
max_w:
|
||||
name: "Grenzen · Vermogen van de omvormer (W)"
|
||||
description: "DIT IS UW GETAL, geen afgeleide waarde: de harde bovengrens van wat er ooit gestuurd mag worden, in watt. Zet het op wat uw omvormer werkelijk aankan, begin laag en verhoog het pas na de indienstname. Het wordt helemaal niet gelezen zodra de lijst met omvormers is ingevuld - elk item draagt dan zijn eigen waarde en het plafond van de installatie is hun som. Verschillende veiligheidsdrempels moeten eronder blijven; de add-on start niet op als er één niet aan voldoet."
|
||||
bms_soc_floor:
|
||||
name: "Grenzen · Uitschakelniveau van de batterij (%)"
|
||||
description: "GEMETEN, niet gekozen: het percentage waarbij het batterijmanagementsysteem van de batterij ZELF stopt met ontladen, wat u hier ook instelt. Register 0x0560 leest 10 op de referentie-GW5000S-BP. Lees deze waarde af van de batterij die er werkelijk staat in plaats van dit getal over te nemen. Ze staat hier zodat de add-on een ondergrens voor de piekbewaking op of onder deze waarde kan weigeren: zo'n ondergrens kan nooit werken, het BMS wint altijd eerst, en de statuspagina zou een reserve tonen die niet bestaat. Wordt niet gelezen zodra de lijst met omvormers is ingevuld - elk item vermeldt dan de uitschakelgrens van zijn eigen batterij."
|
||||
peak_cap_w:
|
||||
name: "Grenzen · Streefwaarde capaciteitstarief (W)"
|
||||
description: "DIT IS UW GETAL: het gemiddelde kwartiervermogen waar de piekbewaking onder probeert te blijven. Neem het over uit uw netcontract - in België is dit de kwartierpiek waarop het capaciteitstarief van uw distributiefactuur berekend wordt."
|
||||
peak_forecast_entity:
|
||||
name: "Grenzen · Entiteit met de kwartierprognose (W)"
|
||||
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende factuurkwartier. Laat u dit leeg, dan is er helemaal geen piekbegrenzing."
|
||||
peak_soc_floor:
|
||||
name: "Grenzen · Piekbewaking ontlaadt niet onder (%)"
|
||||
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
|
||||
price_provider:
|
||||
name: "Tarief · Bron van dynamische prijzen"
|
||||
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ Dit vandaag aanzetten haalt prijzen op, controleert en publiceert ze, en verandert geen enkele energiebeslissing - er is nog niets dat de prijsreeks gebruikt. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten."
|
||||
price_markup_eur_kwh:
|
||||
name: "Tarief · Marge van de leverancier (EUR/kWh)"
|
||||
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij engie_dynamic, totalenergies_dynamic, eneco_eddy en manual_csv wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
|
||||
price_taxes_eur_kwh:
|
||||
name: "Tarief · Heffingen en btw (EUR/kWh)"
|
||||
description: "Belastingen en heffingen per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat."
|
||||
export_price_eur_kwh:
|
||||
name: "Tarief · Injectievergoeding (EUR/kWh)"
|
||||
description: "Wat u krijgt voor een kWh die u op het net zet, alles inbegrepen. Eén getal en geen reeks: geen enkele leverancier hier publiceert een dynamische injectiecurve. In België ligt dit vaak dicht bij nul, wat op zich al sterk pleit voor eigenverbruik in plaats van injectie. De waarde mag negatief zijn - op een zomerse middag betaalt u soms om te injecteren."
|
||||
static_profile:
|
||||
name: "Tarief · Vast of dag/nacht-tarief (één regel JSON)"
|
||||
description: "Uw vaste of dag/nacht-contract, geschreven als één regel JSON. AANBEVOLEN ZELFS BIJ EEN DYNAMISCH CONTRACT: hierop valt de add-on terug wanneer de prijsfeed uitvalt, en staat dit leeg dan blijft de installatie bij een dode feed helemaal zonder tarief achter. De periodes moeten elk dagtype precies één keer bedekken, zonder gat en zonder overlap - een gat wordt bij het opstarten geweigerd in plaats van opgevuld, want een verzonnen prijs is er één waarop een strategie zou handelen. Een volledig vast tarief is één periode van 00:00 tot 24:00 op alle dagen. De prijzen zijn ALLES INBEGREPEN; de marge en heffingen hierboven worden er niet bij opgeteld. Let op: een venster van 22:00 tot 07:00 voor ma-vr dekt zaterdagochtend NIET, dus een regel voor het weekend is noodzakelijk en niet decoratief."
|
||||
price_csv_path:
|
||||
name: "Tarief · CSV-bestand met prijzen"
|
||||
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
|
||||
price_feed_url:
|
||||
name: "Tarief · Afwijkende URL voor de prijsfeed"
|
||||
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen."
|
||||
price_horizon_h:
|
||||
name: "Tarief · Hoe ver vooruit prijzen ophalen (uren)"
|
||||
description: "Hoeveel uur aan prijzen er bij de bron opgevraagd wordt."
|
||||
price_timeout_s:
|
||||
name: "Tarief · Time-out voor een prijsaanvraag (s)"
|
||||
description: "Hoe lang één aanvraag bij de prijsbron mag duren voordat ze wordt opgegeven."
|
||||
price_now_entity:
|
||||
name: "Tarief · Entiteit met de huidige prijs (oudere prijsweg)"
|
||||
description: "De oudere manier om aan een prijs te komen, behouden omdat ze weghalen de onderhoudscyclus op een draaiende installatie zijn enige prijssignaal zou afnemen. De add-on leest deze entiteit en het gemiddelde hieronder en leidt er precies één ding uit af - of het nu goedkoper is dan gemiddeld - wat alleen gebruikt wordt om tijdens de onderhoudscyclus in een goedkoop venster te laden. Mag niet gecombineerd worden met een dynamische prijsbron."
|
||||
price_avg_entity:
|
||||
name: "Tarief · Entiteit met de gemiddelde prijs (oudere prijsweg)"
|
||||
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen zinvol samen met de entiteit hierboven en, net als die, niet te combineren met een dynamische prijsbron."
|
||||
arbitrage_enabled:
|
||||
name: "Tarief · Handelen op het prijsverschil"
|
||||
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
|
||||
cycle_cost_threshold_eur:
|
||||
name: "Tarief · Kostprijs van één volledige laad/ontlaadcyclus (EUR/kWh)"
|
||||
description: "Wat één volledige cyclus per kWh kost: slijtage van de batterij plus omzettingsverliezen. Een prijsverschil dat hier onder ligt, kost geld en levensduur en wordt dus niet verhandeld. ⚠️ DIT GETAL IS NIET AFGELEID UIT EEN METING. 0,10 is een productbeslissing en staat nog open bij de eigenaar (OPT-08). Bij deze waarde handelt een installatie met een vast dag/nachttarief helemaal niet - het realistische Belgische dag/nachtverschil is ongeveer 0,08 EUR/kWh - en dat is bij dit getal het juiste antwoord, geen fout."
|
||||
arbitrage_soc_floor:
|
||||
name: "Tarief · Handel ontlaadt niet onder (%)"
|
||||
description: "De reserve die de prijshandel niet mag aanspreken. Bewust een andere instelling dan de ondergrens van het onderhoud: een handelsstrategie op de cijfers van de maandelijkse balanceercyclus richten, zou twee ongerelateerde keuzes aan één getal koppelen."
|
||||
arbitrage_soc_ceiling:
|
||||
name: "Tarief · Handel laadt niet boven (%)"
|
||||
description: "De ruimte die de prijshandel niet mag volmaken. De tegenhanger van de ondergrens hierboven, en om dezelfde reden losgehouden van het onderhoudsdoel."
|
||||
maintenance_enabled:
|
||||
name: "Onderhoud · Maandelijkse batterijcyclus uitvoeren"
|
||||
description: "Een bewuste diepe ontlading, gevolgd door volledig opladen en een rustperiode op vol, zodat het batterijmanagementsysteem zijn schatting van het laadniveau kan herijken en de cellen kan balanceren. Staat standaard uit."
|
||||
maintenance_interval_days:
|
||||
name: "Onderhoud · Minimum aantal dagen tussen cycli"
|
||||
description: "De kortste tijd tussen twee onderhoudscycli."
|
||||
maintenance_start_hour:
|
||||
name: "Onderhoud · Uur waarop een geplande cyclus start"
|
||||
description: "Lokaal uur, 0 tot 23, waarop een cyclus die aan de beurt is begint."
|
||||
maintenance_discharge_w:
|
||||
name: "Onderhoud · Ontlaadvermogen (W)"
|
||||
description: "Hoe snel de cyclus de batterij leegtrekt; het overschot gaat naar het net. Boven het vermogen van de omvormer wordt dit BEGRENSD tot dat vermogen en niet geweigerd - de cyclus loopt gewoon door, alleen trager, en de statuspagina meldt dat. Standaard 2000, afgestemd op het standaardvermogen; verhoog eerst het omvormervermogen en dan pas dit."
|
||||
maintenance_charge_w:
|
||||
name: "Onderhoud · Laadvermogen (W)"
|
||||
description: "⚠️ MOET GELIJK AAN OF LAGER ZIJN dan het vermogen van de omvormer; anders start de add-on niet op. Erboven vraagt het laden in een goedkoop venster meer dan de bewaakte grens toelaat, lost de arbiter die tegenstrijdigheid op naar 0 W, en zou het onderhoud op een standaardinstallatie stilzwijgend nooit laden in een goedkoop venster. Daarnaast wordt dit nog eens begrensd door de ruimte die de piekbewaking overlaat."
|
||||
maintenance_soc_floor:
|
||||
name: "Onderhoud · Ontladen tot (%)"
|
||||
description: "De diepte waar de maandelijkse herijking bewust naartoe gaat, één keer per maand, met opzet. ⚠️ DIT IS NIET DE ONDERGRENS VAN DE PIEKBEWAKING: die geeft aan hoe diep een besparende ontlading op geen enkele dag mag komen. De juiste waarden verschillen echt - 11 hier tegenover 20 daar - en daarom zijn het twee aparte instellingen die niet samengevoegd mogen worden."
|
||||
maintenance_soc_target:
|
||||
name: "Onderhoud · Opladen tot (%)"
|
||||
description: "Het niveau waar de laadfase naartoe werkt voordat de rustperiode begint."
|
||||
maintenance_hold_min:
|
||||
name: "Onderhoud · Op vol houden gedurende (minuten)"
|
||||
description: "Hoe lang er op het streefniveau gebleven wordt zodat het batterijmanagementsysteem het balanceren van de cellen kan afmaken."
|
||||
sign_disagree_w:
|
||||
name: "Veiligheid · Drempel tekenbewaking (W) — gemeten, niet wijzigen"
|
||||
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Zowel het uitgestuurde setpoint als de netmeting moet boven deze waarde liggen voordat hun tegengestelde tekens überhaupt als onenigheid tellen. Op de vastgelegde gezonde dag (23-08-2026) komt het teken in 93,4 % van de beoordeelde metingen overeen; op de vastgelegde foutdag (20-08-2026) ligt dat tussen 0,0 en 10,5 %, met tegengestelde periodes tot 1752 s. Er is geen reden om hieraan te sleutelen zonder een nieuwe meetcampagne die dat onderbouwt. De waarde moet onder het omvormervermogen blijven, anders kan de bewaking nooit aanslaan, en de add-on weigert die instelling."
|
||||
sign_disagree_s:
|
||||
name: "Veiligheid · Wachttijd tekenbewaking (s) — gemeten, niet wijzigen"
|
||||
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Hoe lang het setpoint en de meter onafgebroken tegengesteld moeten blijven voordat de add-on 0 W stuurt en vergrendelt tot iemand de regeling opnieuw start. 180 s ligt midden in een zeer brede gemeten marge: de langste terechte tegengestelde periode op de gezonde dag duurt 30 s, terwijl de foutdag bij 300 s nog altijd 13 keer aanslaat. Verander dit niet zonder nieuwe metingen."
|
||||
unit_sign_disagree_w:
|
||||
name: "Veiligheid · Tekendrempel per omvormer (W) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer doet deze instelling niets. AFGELEID UIT 1890 GESLOTEN-LUSTESTS op de testopstelling met twee omvormers (SAFETY-30), waarbij niets defect was, over het volledige bereik dat deze add-on toelaat voor versterking en volgsnelheid. 250 W is precies de helft van de verzadigingsdrempel, zodat het kleinste verschil dat hier zichtbaar is, exact de afwijking is waarbij de regeling zelf al zegt dat de omvormer niet volgt. Verander dit niet zonder de afleiding opnieuw te maken. De waarde moet onder het omvormervermogen blijven, anders kan ze nooit aanslaan."
|
||||
unit_sign_disagree_s:
|
||||
name: "Veiligheid · Wachttijd per omvormer (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30): 85 s is 8,5 keer de langste terechte tegengestelde periode die die tests opleveren (10,0 s), 2,8 keer het eigen veto van 30 s in de firmware, en minder dan de helft van de 180 s die de installatiebrede controle neemt. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
site_sign_disagree_w:
|
||||
name: "Veiligheid · Tekendrempel voor de hele installatie (W) — gemeten, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. Dezelfde 300 W als de drempel voor één omvormer, BEWUST OVERGENOMEN (SAFETY-30): de onderbouwing hangt niet af van het aantal omvormers - rond nul is het teken ruis, de dode zone is 15 W, en er is één meter, wat er ook achter hangt. Verander dit niet zonder metingen die dat onderbouwen."
|
||||
site_sign_disagree_s:
|
||||
name: "Veiligheid · Wachttijd voor de hele installatie (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30), en bewust NIET overgenomen van de 180 s voor één omvormer. In die tests duurt de langste terechte tegengestelde periode bij 300 W 110 s met één omvormer en 215 s met twee, omdat het commando de volledige installatiegrens moet doorlopen aan een volgsnelheid die niet die van de installatie is. 180 s overnemen zou dus een vals alarm hebben opgeleverd bij de laagste versterking en volgsnelheid en een gewone belastingomkering van 2,2 kW. 325 s is 1,51 keer die waarde, blijft binnen het factuurkwartier van 900 s en binnen elke episode van de vastgelegde foutdag. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
meter_freeze_s:
|
||||
name: "Veiligheid · Alarm vastgelopen meter na (s) — gemeten, niet wijzigen"
|
||||
description: "GEMETEN, NIET GEKOZEN (SAFETY-18). Hoe lang de meter mag blijven antwoorden met een ONVERANDERDE waarde, terwijl het commando op de grens staat, voordat de add-on alarm slaat. Over 2399 s van deze woning op haar ingeregelde werkpunt - 1194 uitlezingen, waarvan 735 een herhaling van de vorige waarde - duurt de langste periode zonder een nieuwe waarde 15,4 s; 300 s is 19,5 keer zoveel en blijft binnen het factuurkwartier van 900 s. ALLEEN EEN ALARM: er wordt geen 0 W gestuurd en er wordt niets vergrendeld. ⚠️ Alleen de meterbron homewizard_local kan dit überhaupt zien; bij elke andere bron meldt de statuspagina NIET GEDEKT in plaats van actief. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
circulation_w:
|
||||
name: "Veiligheid · Drempel rondpompend vermogen (W) — gemeten, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer is deze grootheid altijd nul. Twee omvormers die tegen elkaar in duwen, heffen elkaar aan de meter op en zijn onzichtbaar voor elke andere bewaking in deze add-on. GEMETEN OVER 924 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-31): de langste terechte tegenwerking valt terug van 505 s bij een drempel van 100 W naar 5 s bij 400 W, en 400 W is de scherpe knik waaronder deze add-on u niet laat gaan - daaronder houdt gewone werking de sensor minutenlang boven de drempel en kan niets korter dan het factuurkwartier dat van een storing onderscheiden. 700 W is 1,75 keer de knik; die extra marge is er omdat het aanloopgedrag van de tweede omvormer nog gekopieerd is van de eerste en niet gemeten. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
circulation_hold_s:
|
||||
name: "Veiligheid · Wachttijd rondpompend vermogen (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
auto_start:
|
||||
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
|
||||
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."
|
||||
gain:
|
||||
name: "Geavanceerd · Versterking van de regellus"
|
||||
description: "Welk deel van de resterende afwijking de regeling per cyclus wegregelt. GEMETEN OP ECHTE HARDWARE en al aan de bovengrens - niet verhogen. Lager is trager en veiliger."
|
||||
slew_w:
|
||||
name: "Geavanceerd · Grootste verandering per cyclus (W)"
|
||||
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. ⚠️ 0 BETEKENT VOLG HET OMVORMERVERMOGEN en is de aanbevolen instelling: de grens wordt ongeveer een negende van dat vermogen, zodat de regeling er de GEMETEN insteltijd van de installatie over doet om haar volledige bereik te doorlopen en het getal niet achterblijft wanneer het vermogen verandert. AFGELEID (SAFETY-39) uit een gemeten stapresponsie van ~18 s bij een meterinterval van 2 s. Een vast getal wordt aanvaard en wordt nooit stilzwijgend verlaagd, maar een getal sneller dan het vermogen toelaat wordt bij het starten geweigerd - bij 1000 W tegen 5000 W dreef deze regeling de meter op 2026-09-05 van 3,4 kW injectie naar 2,8 kW afname. Lager wordt altijd aanvaard."
|
||||
deadband_w:
|
||||
name: "Geavanceerd · Dode zone (W)"
|
||||
description: "Afwijkingen kleiner dan deze waarde worden genegeerd. ⚠️ De dode zone werkt maar één kant op: elk rustpunt erbinnen blijft eeuwig staan, en het afnameregister van de meter telt elke positieve watt mee zonder injectie die dat compenseert - en precies daarom staat het rustpunt hieronder licht negatief."
|
||||
target_grid_w:
|
||||
name: "Geavanceerd · Rustpunt aan de meter (W)"
|
||||
description: "Waar de meter moet staan wanneer er niets gebeurt; negatief betekent een lichte injectie. -10 W is bewust gekozen: rusten op 0 W lekt de hele dag door ongeveer de helft van de dode zone aan gefactureerde afname - grofweg 0,2 tot 0,35 kWh bij een dode zone van 15 W - en het rustpunt onder nul leggen verplaatst dat lek naar het injectieregister, dat niet gefactureerd wordt. Het kost ongeveer evenveel watt weggegeven injectie, dus houd het klein."
|
||||
step_w:
|
||||
name: "Geavanceerd · Afronding van het setpoint (W)"
|
||||
description: "Het setpoint wordt op een veelvoud hiervan afgerond voordat het geschreven wordt, zodat de omvormer niet voor elke watt ruis een nieuw getal krijgt."
|
||||
saturation_w:
|
||||
name: "Geavanceerd · Verzadigingsdrempel (W)"
|
||||
description: "Het verschil tussen wat gevraagd werd en wat de omvormer levert dat geldt als 'deze omvormer zit aan een grens'. ⚠️ De tekendrempel per omvormer onder Veiligheid is afgeleid als precies de helft hiervan, dus deze waarde verschuiven, verschuift ook de onderbouwing daarvan."
|
||||
saturation_cycles:
|
||||
name: "Geavanceerd · Verzadigde cycli voor bevriezen (aantal)"
|
||||
description: "Hoeveel opeenvolgende verzadigde cycli er nodig zijn voordat de regeling stopt met verder oplopen. ⚠️ Zet dit niet op 1: één ruiscyclus zou de regeling dan al bevriezen."
|
||||
integrator_max_w:
|
||||
name: "Geavanceerd · Begrenzing van de integrator (W, 0 betekent gelijk aan het vermogen)"
|
||||
description: "Begrenst hoeveel oude afwijking er kan klaarstaan om af te bouwen wanneer het teken omslaat. GEMETEN op de gesloten-lussimulator met een belasting van 4000 W die naar nul valt: bij een grens gelijk aan het omvormervermogen staat het commando twee cycli later op 1000 W, bij anderhalf keer dat vermogen op 1800 W. ⚠️ Zet dit niet hoger dan het omvormervermogen - de uitgangsbegrenzing bepaalt toch al wat er naar de omvormer gaat, dus extra ruimte levert alleen watts in de verkeerde richting op na elke verzadiging. Lager zetten dan het omvormervermogen is de nuttige richting, en de enige reden dat dit een aparte instelling is."
|
||||
heartbeat_s:
|
||||
name: "Geavanceerd · Verversingsinterval van het setpoint (s)"
|
||||
description: "Hoe vaak het setpoint opnieuw geschreven wordt, ook als het niet veranderd is. Dit moet ruim onder de eigen bewaking van de firmware blijven, die een verbinding die 30 s stil is geweest, blokkeert."
|
||||
stale_input_s:
|
||||
name: "Geavanceerd · 0 W sturen nadat invoer ontbreekt gedurende (s)"
|
||||
description: "Hoe lang de invoer mag ontbreken voordat de add-on 0 W stuurt. Bij een P1-meter begint deze teller pas te lopen NADAT de verouderingsgrens van de meter al verstreken is, dus de twee stapelen: het totaal is langer dan dit getal op zich."
|
||||
cycle_floor_s:
|
||||
name: "Geavanceerd · Langste tijd tussen twee regelcycli (s)"
|
||||
description: "Een uiterste termijn voor de tijd tussen twee cycli, die door elke cyclus opnieuw begint. Bij een gezonde meter verstrijkt hij nooit en verandert hij niets. Verhoog hem alleen als uw meter trager is dan ongeveer 10 s, en houd hem onder de verouderingsgrens van de meter - daarna is er toch geen meting meer om te beoordelen."
|
||||
log_level:
|
||||
name: "Geavanceerd · Logniveau"
|
||||
description: "Hoeveel detail de add-on naar zijn logboek schrijft. 'info' is de standaardinstelling; 'debug' en 'trace' dienen om een probleem te onderzoeken en leveren veel tekst op."
|
||||
Reference in New Issue
Block a user