Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22ca341459 | ||
|
|
9c1a9e0f17 | ||
|
|
638c213944 | ||
|
|
1841416aa4 | ||
|
|
cb8f800180 | ||
|
|
018da00b74 | ||
|
|
e06906d65f | ||
|
|
37dd0f00cc | ||
|
|
1448bd909b | ||
|
|
a0d981731c | ||
|
|
b21e229adf | ||
|
|
2271e147c6 | ||
|
|
6719bd882f | ||
|
|
f76639c7b2 | ||
|
|
57775d4e06 | ||
|
|
9099ceb1af | ||
|
|
295a2aedb6 | ||
|
|
bb469163a6 | ||
|
|
477911afa1 | ||
|
|
21f22b393a | ||
|
|
6e7b23388c | ||
|
|
1ba35f9c99 | ||
|
|
0b653c35a6 | ||
|
|
d0633665c9 | ||
|
|
8e3734f5ea | ||
|
|
86bdf49332 | ||
|
|
fcaa72418d | ||
|
|
04b3330def | ||
|
|
fdb3b69397 | ||
|
|
cab267c4f3 | ||
|
|
8d83ef0dfb | ||
|
|
f8e501393f | ||
|
|
3d2999337d | ||
|
|
bcee2356e0 | ||
|
|
2bb07ccdde | ||
|
|
fdaf3fb54f | ||
|
|
3368231048 | ||
|
|
06661c5e17 | ||
|
|
464d398051 | ||
|
|
090a8ecc35 | ||
|
|
c7eb8c6b97 | ||
|
|
706e625d99 | ||
|
|
0581648c73 | ||
|
|
f156fadac0 | ||
|
|
6c4565659b | ||
|
|
c8dcb38a95 | ||
|
|
8e241a3243 | ||
|
|
6864109a01 | ||
|
|
a4290d39ac |
@@ -14,13 +14,20 @@ goodwe_controller/ the add-on
|
||||
hass.py Supervisor/Core API access
|
||||
store.py persistent state in /data
|
||||
mqtt.py optional status entities
|
||||
web.py ingress UI
|
||||
web.py ingress UI — routes only, status and configuration
|
||||
config_page.py the configuration page's markup, style and script
|
||||
configui.py its field model, built from the Supervisor's own
|
||||
schema + translations — owns no key, label or rule
|
||||
selfconfig.py reading and writing this add-on's own options,
|
||||
with the validate-first / compare-and-swap gate
|
||||
main.py orchestration, heartbeat, failsafe behaviour
|
||||
test_control.py runnable checks — no framework needed
|
||||
test_maintenance.py runnable checks — walks a full cycle in fake time
|
||||
DOCS.md the add-on's documentation tab
|
||||
MUTATION.md how to run a mutation so the result means something
|
||||
tools/mutation_run.sh the mutation harness - purges bytecode, then grades
|
||||
tools/supervisor_selfapi_probe.py what the Supervisor really permits and
|
||||
returns, run against its own release image
|
||||
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
|
||||
estop/rs485_log.py optional RS485 e-stop / bus witness
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
|
||||
|
||||
@@ -1,5 +1,327 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.0
|
||||
|
||||
⚠️ **MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK - AND THAT DISTINCTION IS THE
|
||||
POINT.** 0.8.0 was minor because a config that started on 0.7.0 could be
|
||||
REFUSED on it (rule 10 against 0.7.0's own default `slew_w: 1000`). Nothing of
|
||||
that kind is true here, and it was checked rather than assumed:
|
||||
|
||||
- `options:` and `schema:` both hold **71 top-level keys**, the same 71 as
|
||||
0.8.0. The one schema addition is a per-entry key, `capacity_kwh:
|
||||
float(0.1,200)?` - **note the trailing `?`, it is OPTIONAL**.
|
||||
- An unmodified 0.8.0-era config validates clean on 0.9.0. Measured on both the
|
||||
flat single-inverter shape the reference install runs and an N=2 roster with
|
||||
no `capacity_kwh` on any entry: **0 problems each**.
|
||||
- The two refusals [[OPT-09]] adds fire only on configs that USE the new key -
|
||||
an unparseable value, or the key stated on one roster entry and left empty on
|
||||
another. Neither can reach a config written before the key existed.
|
||||
|
||||
So: existing installs upgrade untouched, and the bump is minor because the
|
||||
option surface and the behaviour grew.
|
||||
|
||||
Contents, in merge order since 0.8.0 (`06661c5`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `3d29993` | UX-02 - the validator's refusals, next to the field, before the save |
|
||||
| `fdb3b69` | OPT-09 - the weights parameter nothing ever passed, and what it cannot do |
|
||||
| `fcaa724` | PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject |
|
||||
|
||||
**UX-02** builds the real configuration surface in the ingress panel, beside the
|
||||
Supervisor Configuration tab that cannot be removed. The prize is not styling:
|
||||
`validate.py` writes long cross-field refusals the Supervisor UI *structurally
|
||||
cannot show*, and until now an operator met them as **exit 78 in a log**, after
|
||||
saving and restarting. ⚠️ Two config surfaces now exist and the DECISIONS record
|
||||
names the **stored options blob** as authoritative, neither UI.
|
||||
|
||||
**OPT-09** makes the architecture's one free parameter real. `main.py` called
|
||||
`resolve()` without weights, so the split was a fixed 50/50 regardless of what
|
||||
either pack could deliver; it now passes SoC- and capacity-aware weights.
|
||||
|
||||
⚠️ **OPT-09 SHIPS A DISCLOSED DETECTION REGRESSION AT N=2.** `delivery.
|
||||
delinquent()` reads an ABSOLUTE 500 W gap, so weighting a low-headroom unit down
|
||||
to a small share puts a unit delivering **none of what it was asked** under the
|
||||
threshold. Measured: a wedged inverter goes from named on 199 of 200 cycles to
|
||||
**0 of 200** at a load its sibling can carry alone. The alarm returns once the
|
||||
healthy pack saturates - swept, that is a 5000 W load on the fixture, **below**
|
||||
the proportional-regime formula's 6500 W and not above it. ⚠️ **N=1 IS
|
||||
UNAFFECTED**: `allocator.plan()` returns before weights are read at all.
|
||||
⚠️ **CLOSED BY SAFETY-43 ON THIS BRANCH** - see its section below; the regression
|
||||
above is history, and the record of it is kept because the measurement that
|
||||
found it is what the fix is graded against.
|
||||
|
||||
**PKG-09** adds `tools/predeploy_check.py`. ⚠️ **RUN IT BEFORE DEPLOYING THIS
|
||||
RELEASE** - it is what caught the fact that 0.8.0 had five runtime commits
|
||||
behind it, which is why this section exists:
|
||||
|
||||
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
|
||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||
--deployed-version 0.8.0 --options /w/live-options.json
|
||||
|
||||
Exit `0` cleared, `1` REFUSED, `2` NOT CLEARED. ⚠️ **2 is not a pass.**
|
||||
|
||||
✅ **RUN AGAINST THIS RELEASE, 2026-09-07: CLEARED, exit 0.** The reference
|
||||
install's options were exported from the Supervisor (70 keys) and checked twice
|
||||
- the stored blob as-is, and the **effective post-upgrade config**, being those
|
||||
70 keys merged over 0.9.0's 71 defaults. Both pass. `max_w` 5000, `slew_w` 400
|
||||
against a rule-10 ceiling of 555.6 W.
|
||||
|
||||
⚠️ **THE STORED BLOB HAS 70 KEYS, NOT 71, AND THAT IS THE INTERESTING PART.**
|
||||
`delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running 0.7.0
|
||||
has never stored it - and it is REQUIRED in the schema, no trailing `?`, which
|
||||
is the shape that becomes exit 78. It resolves to its `options:` default of
|
||||
**185**, because the Supervisor sets `App.options = deepmerge(config.yaml
|
||||
defaults, stored options)`; `tools/supervisor_selfapi_probe.py` §D pins that
|
||||
against a real Supervisor image rather than assuming it. The second check above
|
||||
exists because "your saved settings are valid" and "what the box will actually
|
||||
run is valid" are different questions, and only the second one matters.
|
||||
|
||||
⚠️ **THE VERSION WENT UNBUMPED FOR THE THIRD TIME IN THREE DAYS** - PKG-06 six
|
||||
merges, PKG-07 six, this one five - and this time it was caught by PKG-09's own
|
||||
tool during PKG-09's review rather than by a person. `ha apps update` against an
|
||||
unchanged version installs nothing and reports success. PKG-10 carries the
|
||||
post-merge alarm so that noticing stops being a human's job.
|
||||
|
||||
## Unreleased
|
||||
|
||||
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
|
||||
PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`,
|
||||
so `ha apps update` is a silent no-op on an unbumped change. This branch
|
||||
deliberately does **not** pick the number, because the right one depends on a
|
||||
fact the branch cannot observe - whether 0.8.0's image has already been built
|
||||
and pulled. If it has not, this belongs inside 0.8.0's section before it ships;
|
||||
if it has, it needs 0.8.1. Whoever deploys decides, and PKG-07 is the record of
|
||||
what happens when nobody does.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
|
||||
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
|
||||
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
|
||||
| _this branch_ | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression |
|
||||
|
||||
⚠️ **SAFETY-43 CLOSES THE DETECTION REGRESSION OPT-09 DISCLOSED, AND IT IS A
|
||||
TWO-INVERTER FIX. N=1 IS BYTE-IDENTICAL.** `delivery.delinquent()` named a unit
|
||||
whose shortfall exceeded an **absolute** `min_w = saturation_w = 500 W`. OPT-09's
|
||||
weights shrink a low-headroom unit's share, and shrinking the share shrinks the
|
||||
gap - so a unit standing at +125 W and delivering **0 W**, 100 % delinquent, was
|
||||
never named. It now names a unit whose gap exceeds `min_w` **OR** exceeds
|
||||
`DELIVERY_SHARE_FRACTION` (0.85) of that unit's **own share**. Measured on the
|
||||
same closed-loop fixture, same weights, same detector:
|
||||
|
||||
load 1500 W OPT-09 weights the old rule named on 0/200 cycles
|
||||
load 1500 W OPT-09 weights the shipped one named on 200/200 cycles
|
||||
|
||||
⚠️ **`saturation_w` WAS NOT LOWERED, AND THAT IS THE POINT.** Lowering it would
|
||||
report normal saturation as delinquency on every unit at once - SAFETY-41's
|
||||
four-attempt false-trip regime, *"i'm getting really annoyed by the notifications
|
||||
in my HA"*. This ADDS a second way to be named: all seven of `test_delivery.py`
|
||||
part 1's existing fixtures answer identically under the old rule and the new one,
|
||||
and the absolute arm's suppression window is still exactly 1000 W / 6500 W.
|
||||
|
||||
**0.85 is derived, not chosen.** `admin/goodwe:sim/delivery_window.py --campaign
|
||||
--fractions` grades it over the SAME 2640 no-fault runs that produced
|
||||
`delivery_hold_s`'s floor. The worst continuous legitimate episode is **35.0 s**
|
||||
under the absolute arm alone (SAFETY-42 §3.3, reproduced in the same run) and
|
||||
**40.0 s** at 0.85. ⚠️ **THE CURVE HAS NO CLIFF AND THAT IS THE FINDING**: swept
|
||||
at twenty fractions from 0.05 to 0.99, everything from **0.45 to 0.99** is 40.0
|
||||
or 45.0 s - within one control cycle of every other - and below 0.45 it rises
|
||||
gradually to 90.0 s at 0.05. ⚠️ **"Zero false namings" is not achievable at any
|
||||
fraction and the record says so**; what is zero is false ALARMS - the longest
|
||||
legitimate episode in the whole sweep is 90.0 s against a 185 s hold, so nothing
|
||||
comes within 95 s of notifying anybody at any fraction measured, and 145 s at the
|
||||
shipped one. The N=1 control arm is 0.0 s at every fraction in all 2640
|
||||
one-inverter runs, and the flapping family is 0.0 s in all 840.
|
||||
|
||||
⚠️ **[[CFG-07]] GETS ONE CONTROL CYCLE WORSE AND IS NOT FIXED HERE.**
|
||||
`delivery_hold_s` is declared `int(30,900)` while the measured legitimate worst
|
||||
was 35.0 s; it is now 40.0 s, so the band of schema-legal false-tripping values
|
||||
widens from `[30, 35]` to `[30, 40]`. CFG-07 should be re-derived against 40.0 s.
|
||||
⚠️ **IT MUST CLOSE BEFORE INVERTER B IS COMMISSIONED (~2026-09-12)**, not before
|
||||
the next deploy: the band is UNREACHABLE at N=1 - `delinquent()` answers `{}` for
|
||||
every input at a roster of one, so no `delivery_hold_s` can false-trip on the
|
||||
install running today - and commissioning B is the moment it becomes reachable.
|
||||
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE** - it exists as a card
|
||||
reference here and in SAFETY-43's record and nowhere else, so whoever takes it
|
||||
writes `DECISIONS/CFG-07-*.md` first.
|
||||
**SAFETY-43 CHANGES NO OPTION VALUE, NO SCHEMA BOUND AND NO DEFAULT** - the
|
||||
fraction is a derived constant with a committed harness behind it, deliberately
|
||||
not a knob, for the reason there is no `delivery_w`. The one edit to
|
||||
`config.yaml` is a COMMENT: the `delivery_hold_s` block described the check as
|
||||
"short by more than `saturation_w`", which is now only half of it.
|
||||
|
||||
**No behaviour change at one inverter, asserted rather than argued**: `plan()` is
|
||||
handed a `weights` mapping that raises if anything reads it and still answers
|
||||
identically at a roster of one (and the same mapping raises at a roster of two,
|
||||
so the tripwire is real), and `delinquent()` answers `{}` on all 84 single-unit
|
||||
(share, measured, rule) combinations including 0 of 125 W.
|
||||
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` carries the curve, the
|
||||
positive controls and the CFG-07 assessment.
|
||||
|
||||
⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD
|
||||
CLAIMED.** `delivery_hold_s: 185` shipped resting on *"30.0 s, the worst
|
||||
continuous legitimate episode at the shipped threshold across all 2640 runs"* -
|
||||
from a harness `DECISIONS/SAFETY-42-unit-delivery.md` §1 deliberately did **not
|
||||
commit**, because it imports `plantsim` and `frame_check` out of `admin/goodwe`.
|
||||
A shipped safety constant the next reviewer cannot re-run is PROC-01's shape.
|
||||
|
||||
The harness is committed now, **in `admin/goodwe` as `sim/delivery_window.py`**,
|
||||
beside `sitesim.py`. It is there and not here on purpose: those two modules are
|
||||
that repo's, and restating them in this tree is the CI-08 coupling that has
|
||||
broken three times. The dependency runs one way instead - this repo's `control`,
|
||||
`arbiter` and the shipped `delivery.delinquent()` are imported live out of
|
||||
whichever checkout is under test. Re-run it with:
|
||||
|
||||
```
|
||||
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
python3 -B delivery_window.py --selftest --campaign --blag
|
||||
```
|
||||
|
||||
⚠️ **AND 30.0 s DID NOT REPRODUCE - IT IS 35.0 s.** Reported as measured rather
|
||||
than adjusted to agree. What DID reproduce is the part the compatibility promise
|
||||
rests on: the **N=1 control arm is 0.0 s at every threshold in every run**, and
|
||||
the **flapping family is 0.0 s everywhere** in all 840 runs. What did not: the
|
||||
headline (one control cycle worse), the reversal family (20.0 → 30.0 s), the
|
||||
non-monotone curve (this rebuild's is monotone, so that finding is now an
|
||||
argument and not a measurement), and the low-threshold rows, badly. The B-lag
|
||||
rows moved the other way - a hypothetical unit four times slower than A gives
|
||||
**75.0 s**, not 90.0 s - and they are relabelled throughout as a **sensitivity
|
||||
study on a hypothesis about inverter B, which does not physically exist yet**,
|
||||
rather than as a measurement of it.
|
||||
|
||||
**`delivery_hold_s` is NOT changed.** 185 s is 5.29× the re-measured worst
|
||||
(was claimed 6.2×), 2.47× the four-times-slower case, 6.2× the firmware's
|
||||
`LINK_STALE_S` veto and 4.9× inside the billing quarter: all four bounds still
|
||||
clear and none narrowly. ⚠️ One consequence is worth an operator's attention:
|
||||
at 35.0 s the rig's floor is now **above** the schema's own `int(30,900)`
|
||||
minimum, so `delivery_hold_s: 30` is below a measured legitimate episode and
|
||||
would report on ordinary saturation. The option is marked *derived, do not
|
||||
change*; raising the schema floor is left to its own card.
|
||||
|
||||
⚠️ **UX-02 ADDS A SECOND PLACE TO CONFIGURE THIS ADD-ON, AND NO NEW WAY TO
|
||||
COMMAND THE INVERTER.** The ingress panel gains a Configuration page beside
|
||||
Home Assistant's Configuration tab. Both edit the same options blob; neither is
|
||||
authoritative, the stored blob is. What the new one can do that the old one
|
||||
structurally cannot is show `validate.py`'s CROSS-FIELD refusals - two inverters
|
||||
naming one `setpoint_entity`, a `maintenance_charge_w` above `max_w`, a flat
|
||||
`setpoint_entity` left set beside a roster - **next to the field, before saving**,
|
||||
instead of as exit 78 in a log afterwards. The Supervisor's tab knows only types
|
||||
and ranges and has no way to express a rule spanning two keys.
|
||||
|
||||
Four properties are the ones that could hurt somebody, and each is checked
|
||||
rather than asserted (`test_selfconfig.py`):
|
||||
|
||||
* a blob the shipped validator refuses is **never written** - because an invalid
|
||||
blob plus a restart is exit 78, and the panel goes down with the add-on;
|
||||
* a blob that **changed underneath** the page is refused, not clobbered, and the
|
||||
keys that moved are named. Two surfaces, one blob;
|
||||
* **every message comes from the shipped validator.** Proven by inserting one
|
||||
new rule into a COPY of `validate.py` and watching it arrive on the page with
|
||||
every UI file byte-identical;
|
||||
* **nothing on the surface commands an inverter or clears a latch.** Saving does
|
||||
not even restart: applying options costs a control interruption - the
|
||||
wind-down commands 0 W on every inverter - so that is a separate button that
|
||||
says so first.
|
||||
|
||||
⚠️ **NO SCHEMA CHANGE AND NO CONTROL CHANGE.** `config.yaml` is not modified at
|
||||
all by this card: the permissions the panel needs - `ingress: true`,
|
||||
`ingress_port: 8099`, `hassio_api: true`, `homeassistant_api: true` - were
|
||||
already declared, and the panel was already live.
|
||||
`tools/supervisor_selfapi_probe.py` re-measures them against Supervisor
|
||||
2026.08.0's own code, and found things the card had wrong: Supervisor's own
|
||||
`/addons/self/options/validate` is **not** reachable from inside an add-on, and
|
||||
the Configuration tab **can** be removed - at the cost of all 71 options, which
|
||||
is why it stays. `DECISIONS/UX-02-two-config-surfaces.md` carries both.
|
||||
|
||||
⚠️ **UX-04 CHANGES NO BEHAVIOUR, NO SCHEMA AND NO DEFAULT.** It adds one check
|
||||
file, three assertions to a probe, and a decisions record; `config.yaml`,
|
||||
`translations/` and every module under `app/` are byte-identical to `6e7b233`.
|
||||
It closes out [[UX-01]], which shipped in **0.7.0 and is live**, with three
|
||||
criteria that were never met:
|
||||
|
||||
* **AC 3 - "the visible count drops to ~15" - is WITHDRAWN as unsatisfiable.**
|
||||
`options:` and `schema:` declare the same 71 keys, so the set HA's *show
|
||||
unused optional configuration options* toggle can hide is **empty**, and
|
||||
Supervisor's own `UiOptions` marks **59 of the 71 required** - the frontend
|
||||
shows those unconditionally, so 59 is the floor even with `options:` emptied
|
||||
altogether. The target was ~4x below what any manifest can reach. ⚠️ And the
|
||||
12 hides that *are* expressible would be bought with the deep-merge: a key
|
||||
absent from `options:` has no default for the Supervisor to merge into a
|
||||
0.7.0 box's stored blob, which is exactly the `delivery_hold_s` mechanism
|
||||
that makes this release's upgrade safe rather than exit 78. The count was
|
||||
**printed** by `tools/supervisor_shape_probe.py` §F from the day it was
|
||||
written and never graded, which is how this passed review; it is now three
|
||||
assertions, and removing one key from `options:` turns two of them red.
|
||||
* **AC 4's third clause - the AST check for stray in-code literal fallbacks -
|
||||
is BUILT**, as `test_defaults_pin.py`. It discovers the six
|
||||
`*_OPTION_DEFAULTS` mirrors from the tree rather than naming them, grades all
|
||||
30 mirrored keys against `config.yaml`, and freezes the CFG-03 backlog at
|
||||
exactly today's **27 (module, key) pairs** so it can only shrink. It found
|
||||
one previously unreported divergence: `main.py` answers a missing
|
||||
`target_grid_w` with **0** where `config.yaml` ships **-10** - unreachable on
|
||||
a Supervisor install, recorded rather than corrected. Its red direction runs
|
||||
on every invocation, and a literal planted in the real tree was shown red.
|
||||
* **AC 5 / the CFG-04 double-literal `max_w` hole is closed, by
|
||||
[[UX-02]]'s `bcee235` and not by UX-01's `fef6ef2`.** Re-measured on this
|
||||
branch: the `2000 -> 2500` triple mutation is **RED at `test_selfconfig.py`**,
|
||||
where it survived 21/21 green on 0.7.0. The attribution is corrected in
|
||||
`DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md`; nothing was
|
||||
re-done.
|
||||
|
||||
The 8 per-entry `inverters:` translations **stay**. They are shipped, validated
|
||||
and measured *not* to reach the Supervisor tab - `ha-selector-object` labels
|
||||
those fields `fields[name].label || name` and the converter sets no `label` -
|
||||
and UX-02's ingress panel is the surface that can render them. A written
|
||||
decision, not a change.
|
||||
|
||||
## 0.8.0
|
||||
|
||||
⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
|
||||
THIS IS A MINOR BUMP.** [[SAFETY-39]] adds validator rule 10, which refuses an
|
||||
explicit `slew_w` above `SLEW_RAIL_FRAC` x the site rail, where `SLEW_RAIL_FRAC`
|
||||
is `LOOP_TICK_S / PLANT_SETTLE_S` = 2/18. On a 5000 W rail that ceiling is
|
||||
**555.6 W**.
|
||||
|
||||
⚠️ **THE VALUE THAT BREAKS IS 0.7.0'S OWN SHIPPED DEFAULT.** `slew_w` defaulted
|
||||
to **1000** and the schema was `int(50,5000)`; 1000 > 555.6, so an install that
|
||||
never touched the key and simply took the default is refused on 0.8.0 until the
|
||||
value is lowered. That is as backward-incompatible as it gets, and it is the
|
||||
whole justification for MINOR over PATCH.
|
||||
|
||||
The schema also widens from `int(50,5000)` to `int(0,5000)`, and `slew_w: 0` -
|
||||
**a value that was not legal before** - now means *follow the rail* and is the
|
||||
shipped default. This is a new capability rather than a changed meaning.
|
||||
|
||||
⚠️ Refusal is `SystemExit(78)` (`EX_CONFIG`): the add-on does not start. **Read
|
||||
the live options and check them against the ceiling BEFORE `ha apps update`.**
|
||||
That check is NOT performed by this PR and cannot be - see the PR body.
|
||||
|
||||
CFG-04's rule and PKG-06's: the Supervisor keys the built image by `version:`,
|
||||
so shipping a change without a bump means `ha apps update` is a silent no-op.
|
||||
**Six merges accumulated behind an unbumped 0.7.0 before this was caught, for
|
||||
the second time in two days.** The automated check that would catch it without
|
||||
a human noticing is still unwritten, and that is PKG-06's standing follow-up.
|
||||
|
||||
Contents, in merge order since 0.7.0 (`bbc903b`). ⚠️ Each row carries the merge
|
||||
subject's **whole** claim, including the half that is not flattering:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `9e42cec` | CI-06 - `test_peakguard.py` answered two of its checks with the wall clock |
|
||||
| `24c3d87` | SAFETY-38 - pin `site_standing` with shares that cannot answer for the site |
|
||||
| `a4290d3` | SAFETY-39 - `slew_w` is a ratio to the rail, **and `gain` is the constant that is still wrong** |
|
||||
| `c8dcb38` | SAFETY-37 - a stale reading is `None`, and arrival cannot be what says so |
|
||||
| `0581648` | SAFETY-13 - the output freeze gets an exit, and only where it needs one |
|
||||
| `464d398` | SAFETY-42 - a unit that stops delivering, **and the discriminator the card asked for does not exist** |
|
||||
|
||||
⚠️ **TWO DISCLOSED RESIDUALS SHIP IN THIS RELEASE.** SAFETY-13's bounded
|
||||
freeze-exit shows 0 spurious releases on the sweep's measured-plant slice but
|
||||
**2 of 272 on a finer grid**, and the docs carry the 2-of-272 figure rather than
|
||||
the flattering one. SAFETY-42's card proposed a discriminator that **turned out
|
||||
not to exist**, and the branch says so rather than shipping a weaker check under
|
||||
the original name.
|
||||
|
||||
## 0.7.0
|
||||
|
||||
⚠️ **SCHEMA CHANGE, AND IT IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.**
|
||||
@@ -29,7 +351,280 @@ is a defect in this file, not in the release: the deployed 0.6.0 is `2d025ee` an
|
||||
the table above is what is new since it. Splitting it retroactively would mean
|
||||
guessing a boundary; saying so is more useful than a guess.
|
||||
|
||||
## Unreleased
|
||||
## Detail notes — accumulated, NOT a separate release
|
||||
|
||||
⚠️ **THIS HEADING USED TO SAY "Unreleased" AND IT WAS WRONG.** Everything below
|
||||
has SHIPPED. The section accumulated across 0.6.0, 0.7.0 and 0.8.0 because each
|
||||
release was cut without clearing it, so it names tickets from all three
|
||||
([[SAFETY-18]] and [[SAFETY-30]] from the 0.6.0 era through [[SAFETY-42]] in
|
||||
0.8.0). A reader who trusted the old heading would have concluded that shipped,
|
||||
running behaviour was still pending — and there was a SECOND heading saying the
|
||||
same thing further down, from the 0.4.x era.
|
||||
|
||||
Retitled rather than re-sorted, on purpose: splitting ~3000 lines by release is
|
||||
real work that does not belong inside a version bump, where it would bury the
|
||||
one line that changes what the Supervisor installs. Card PKG-08 carries it.
|
||||
|
||||
⚠️ **THE OUTPUT FREEZE NOW HAS AN EXIT, AND ONLY WHERE IT CANNOT END ON ITS OWN
|
||||
([[SAFETY-13]]).** While the loop is frozen the command's magnitude may fall but
|
||||
never rise; at `prev_w == 0` there is no magnitude left to fall, so a charge
|
||||
cannot start. That refusal is deliberate - the inverter reporting power we did
|
||||
not command means another writer is on the actuator, the §1.5 non-goal - and it
|
||||
is kept. What it did not have was an END: nothing cleared it but the other
|
||||
writer leaving.
|
||||
|
||||
The exit is now explicit, and *which* freezes get one is the decision rather
|
||||
than *how long*:
|
||||
|
||||
- A divergence a **limit** could have produced - the inverter doing less than
|
||||
the command, in the command's own direction - **clears itself**, because the
|
||||
command is pinned at something the inverter can reach. It gets no exit, and
|
||||
that is release/1.0's behaviour kept unchanged.
|
||||
- A divergence a limit could **not** have produced - the inverter overshooting
|
||||
the command, or producing the opposite sign - may not clear itself at all, so
|
||||
it gets one. It releases once the divergence has outlasted the plant's own
|
||||
measured settling time: `SETTLE_CYCLES` = **12** cycles at the measured tick.
|
||||
|
||||
⚠️ **THE BOUND COMES FROM THE SLOWER OF THE TWO DIRECTIONS THE PLANT WAS
|
||||
MEASURED IN.** `NOTES.md` §5.10 timed the same 1.78 kW step both ways - ~18 s
|
||||
up, ~23 s down - so `PLANT_SETTLE_DOWN_S` (23 s) joins `PLANT_SETTLE_S` (18 s)
|
||||
as a named constant. SAFETY-39 uses 18 correctly, because its bound is a
|
||||
*refusal* and the looser number refuses less; this bound asks how long the plant
|
||||
may still be the *explanation*, and there the longer number is the safe one. A
|
||||
bound of 9 derived from 18 fires the exit on a **single inverter with nothing
|
||||
else on the bus** - measured in the container, 62 of 272 load-step pairs at the
|
||||
plant's own measured shape, against 2 at 12.
|
||||
|
||||
⚠️ **`freeze-released` IS A PLACE TO START LOOKING, NOT A FINDING.** It says the
|
||||
divergence outlasted the plant's measured settling time. It is **not** proof of
|
||||
a second writer: a large load or PV swing can leave our own power coasting
|
||||
outside our own command's interval, and a plant slower than the one that was
|
||||
measured does the same. DOCS.md now gives the innocent causes first and the
|
||||
second writer only when the state *stands*.
|
||||
|
||||
⚠️ **RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP.** The integrator
|
||||
freeze is untouched, so the command can only walk, one slew step per cycle,
|
||||
toward an accumulator that stopped accumulating when the freeze engaged.
|
||||
Measured at the live install's shape (`max_w 5000`, `slew_w 400`,
|
||||
`target_grid_w -10`) with another writer holding the pack at +700 W and 2 kW of
|
||||
export standing: the command holds at 0 W, starts on the twelfth saturated
|
||||
cycle and settles at **-1190 W** against a frozen integrator of -1194 W, not at
|
||||
the 5000 W rail. On `release/1.0` @ `c8dcb38` the same run held **0.0 W on every
|
||||
cycle**, for as long as the other writer stayed.
|
||||
|
||||
⚠️ **THIS IS NOT A REGRESSION FIX AND IT ESCALATES AT TWO INVERTERS.** The
|
||||
unbounded block is `release/1.0` behaviour, confirmed cycle for cycle against
|
||||
SAFETY-04's branch. At a roster of two, `actual_w` is the SITE SUM, so the
|
||||
comparison is a site command against two units' power and one unit still moving
|
||||
after the other has stopped can put the site into the block with no external
|
||||
actor at all. ⚠️ That escalation is read from the code and from single-unit
|
||||
runs; **it is not measured on two inverters.** Inverter B does not physically
|
||||
exist yet and `sim/plantsim.py`'s `INVERTER_B` is `INVERTER_A` copied with
|
||||
`measured=False`, so any two-inverter figure would be two copies of A.
|
||||
|
||||
Graded against `release/1.0` over the 3780-case sweep in `test_control.py`:
|
||||
**3267 cases bit-identical, 432 releases, 27 of those change the command** -
|
||||
every one a charge from `prev_w == 0` that `release/1.0` refused to start - and
|
||||
**81 differ in the saturation counter alone**, because its ceiling moved with
|
||||
the bound from 10 to 13. That ceiling change also removes a degenerate setting:
|
||||
`config.yaml` accepts `saturation_cycles` up to 10, which is now strictly below
|
||||
the release bound, so **no accepted configuration can stop the block engaging**.
|
||||
At the old bound of 9 a setting of 9 or 10 deleted it silently.
|
||||
|
||||
⚠️ **THE SITE TARGET IS NO LONGER SPLIT 50/50 BETWEEN TWO PACKS ([[OPT-09]]) -
|
||||
AND THIS ADDS A PER-ENTRY OPTION, SO IT IS A SCHEMA CHANGE.**
|
||||
`capacity_kwh: float(0.1,200)?` joins the `inverters` roster as an **optional**
|
||||
per-entry key. Optional, where SAFETY-32 made `bms_soc_floor` required, and for
|
||||
that card's own reason: a missing cut-out would have to be *defaulted* and one
|
||||
default for two batteries protects the shallower pack with the deeper pack's
|
||||
number, whereas a missing capacity has an honest answer - no opinion about the
|
||||
split, every unit weighs 1.0, which is exactly what shipped. It is **all or
|
||||
nothing across the roster** and rule 9 refuses a partial one. There is no flat
|
||||
`capacity_kwh`: at one inverter there is no split to weight.
|
||||
|
||||
The allocator has had a `weights` argument since [[ALLOC-01]] and **nothing had
|
||||
ever passed one** - `main.py` called `resolve()` without it and `arbiter.py`
|
||||
defaulted it to 1.0 per unit - so the split was fixed 50/50 regardless of what
|
||||
either pack could do. Two things now feed it, and they are not the same kind of
|
||||
thing:
|
||||
|
||||
- **an ENVELOPE, which is a permission.** A new `pack` UNIT claim at `P_SAFETY`
|
||||
closes a unit's `hi` at 0 W when its own SoC is at or under its own
|
||||
`bms_soc_floor`, and its `lo` at 0 W at 100 %. The allocator obeys it
|
||||
absolutely, so an empty pack is not asked to discharge and its share moves to
|
||||
the sibling. ⚠️ **Not filed at all at a roster of one** - every source routes
|
||||
to the site there, so it would bound the SITE TARGET, and [[SAFETY-25]]'s
|
||||
Option A (no loop-side SoC floor) stands untouched on the shipped install.
|
||||
- **WEIGHTS, which are a preference.** Between those limits each pack is asked
|
||||
for the fraction matching its **energy** headroom in kWh - not its power
|
||||
rating. The two packs are asymmetric in opposite directions (5000 W /
|
||||
15.36 kWh against 4600 W / 20.48 kWh **from datasheet**), so the two ways of
|
||||
dividing hand the work to different packs.
|
||||
|
||||
⚠️ **AND WEIGHTS ARE A COMFORT CHOICE FOR STABILITY, NOT FOR DELIVERY.**
|
||||
[[ARCH-01]]'s convex combination guarantees a bad split cannot destabilise the
|
||||
site; it says nothing about whether the site can regulate. Measured (two 2000 W
|
||||
units, one at its floor, modelled plant, real `compute()`): at a 3000 W load the
|
||||
weighted arm and the shipped arm command the **identical** +2000 W into the empty
|
||||
pack and deliver the **identical** 2000 W, because `_fill()`'s last-resort pass
|
||||
hands the remainder to a zero-weighted unit rather than leave watts unplaced.
|
||||
Closing the envelope does not recover those watts either - it reports them:
|
||||
`unmet_w` becomes +2000 W and the action log says so, where before the record
|
||||
read impeccable. A unit that cannot deliver still parks the site short by its
|
||||
share, and that is now visible instead of silent.
|
||||
|
||||
Also: `arbitrage.decide()` is sized per pack rather than at the site rating on
|
||||
both sides, so a two-pack site cannot ask to buy 9600 W with one pack able to
|
||||
absorb it; and a 0 W sizing is refused by name, because a 0 W arbitrage claim is
|
||||
a park order that outranks the loop. ⚠️ The sell side is a **no-op on every
|
||||
roster today** - the sell gate reads the emptiest pack, so it refuses outright in
|
||||
exactly the states where the sum could be smaller - and moving that gate is
|
||||
deliberately not this card's; `DECISIONS/OPT-09-soc-aware-weights.md` §6 says
|
||||
why.
|
||||
|
||||
⚠️ **[[SAFETY-42]]'s end-to-end fixture moved, and its finding did not.** That
|
||||
card's evidence was that a wedged unit and a pack at its floor are byte-identical
|
||||
in the power domain. After this card the cold pack is no longer *asked*, so the
|
||||
shares differ - but the two quantities SAFETY-42's rejected discriminator reads
|
||||
(saturation persistence and |grid error|) are still inseparable on both arms, so
|
||||
the conclusion is unchanged and is now stated on the predicate's own inputs.
|
||||
`test_delivery.py` part 4 carries the whole argument. The two cards meet at one
|
||||
hazard from opposite sides: **OPT-09 stops the site ASKING a pack whose SoC says
|
||||
it cannot deliver; SAFETY-42 alarms when a pack that said it could does not.**
|
||||
|
||||
⚠️ **AND THIS CARD NARROWS SAFETY-42's DETECTION WINDOW. IT IS A DETECTION
|
||||
REGRESSION AT TWO INVERTERS AND IT IS DISCLOSED RATHER THAN FIXED.** An earlier
|
||||
draft of this entry and of `DECISIONS/OPT-09-soc-aware-weights.md` §5 said a
|
||||
wedged unit's weight does not move, so OPT-09 could not see the case at all. The
|
||||
ENVELOPE half is true - a wedged box reads a healthy SoC and nothing closes. The
|
||||
WEIGHT half was **false**: a weight is a function of a pack's ENERGY HEADROOM,
|
||||
not of what the pack delivers, so a wedged unit's weight moves whenever its
|
||||
headroom differs from its sibling's. And `delivery.delinquent()` names a unit
|
||||
only while its shortfall exceeds `saturation_w` - **500 W in absolute watts** -
|
||||
so shrinking the share shrinks the gap below the threshold and the alarm stops
|
||||
firing. Measured end to end with the real loop, the real allocator and the real
|
||||
detector, on a unit wedged at a healthy 15 % against its own 10 % floor (a
|
||||
SAFETY-42 true positive by that card's own definition): at a 1500 W load the
|
||||
healthy pack can carry alone, equal weights name it on **199 of 200** cycles and
|
||||
these weights name it on **none of the 200** - shares 1505/125, gap 125 W, and
|
||||
125 W is under the threshold. The suppression window widens from a site target
|
||||
below 1000 W to a site target below `saturation_w * (w_A + w_B) / w_B`, which on
|
||||
that fixture is **6500 W of a 9600 W site**; above it the healthy pack hits its
|
||||
own rating, the allocator's last-resort pass hands the wedged unit a big share
|
||||
again and the alarm returns. So in the quiet regime - where `unmet_w` is 0, the
|
||||
meter is satisfied and no action-log line fires - **a wedged inverter with less
|
||||
energy headroom than its sibling can now go unreported at ordinary house
|
||||
loads.**
|
||||
|
||||
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
|
||||
`test_packshare.py` part 8b grades it and pins the CURRENT behaviour, so
|
||||
the day it changes a named check moves. Closing it means making `delinquent()`
|
||||
relative to the ask rather than absolute, which is SAFETY-42's code and a design
|
||||
change; that is filed as its own card and is deliberately not done here.
|
||||
⚠️ Every two-unit figure above is **two copies of inverter A** through a modelled
|
||||
plant - inverter B does not exist yet - and B's ratings are datasheet and serial.
|
||||
|
||||
⚠️ **AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME
|
||||
([[SAFETY-42]]) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.**
|
||||
`delivery_hold_s: 185` joins `options:` and `schema:` (`int(30,900)`). The
|
||||
Supervisor keys the built image by `version:`, so this needs a bump before it
|
||||
ships - see the 0.7.0 heading above, which makes the same point about
|
||||
`bms_soc_floor`. The key is ADDITIVE and defaulted, so an existing options blob
|
||||
stays valid; what a missing bump costs is that nobody's Supervisor re-reads the
|
||||
schema.
|
||||
|
||||
**Two or more inverters only, and at one inverter it is inert by construction.**
|
||||
A unit standing at a discharge share it is not producing - short by more than
|
||||
`saturation_w`, with nothing this add-on can read to explain it, while at least
|
||||
one sibling is NOT in that state - is named after `delivery_hold_s`. That fault
|
||||
was invisible to everything: the per-unit sign check needs OPPOSITION and a unit
|
||||
at 0 W is not opposing but ABSENT, the site sum still agrees with the meter in
|
||||
direction, circulating power is exactly zero when one unit does nothing, and the
|
||||
only remaining trace is the saturation freeze - which `clamp.py` itself calls an
|
||||
alarm operators learn to ignore. It is [[HW-01]]'s shape, which ran 6 h 25 m.
|
||||
|
||||
⚠️ **IT REPORTS. IT DOES NOT ACT.** Nothing is commanded differently, the loop is
|
||||
untouched, `control.py` is not modified, and the site goes on running short by
|
||||
that unit's share. Commanding an inverter that has stopped listening achieves
|
||||
nothing (HW-01), and the only re-allocation this add-on can express is
|
||||
all-or-nothing - measured on the rig, dropping a unit derated to 50 % from the
|
||||
split leaves the site **400 W worse** than keeping it, and this detector cannot
|
||||
tell that unit from a wedged one. The status row is a CAUTION, not a failure,
|
||||
because the controller has not stopped.
|
||||
|
||||
⚠️ **THE CARD'S OWN DISCRIMINATOR WAS FALSIFIED, AND THAT IS THE MAIN CONTENT.**
|
||||
SAFETY-42 asked for "saturation persisting past N while |grid error| > X, both
|
||||
derived". Built and run on the two-master rig first, it fails in both
|
||||
directions, and no (N, X) fixes either: a pack at its own floor and a wedged unit
|
||||
are **identical on all 120 cycles** of a run in grid, site target, every share
|
||||
and every measured watt - so the benign case fires - and with a sibling that has
|
||||
headroom the standing grid error collapses to **30 W** while the wedged unit
|
||||
stands at a +770 W share delivering nothing, so the real case does not. What
|
||||
replaces it is a per-unit shortfall plus two reductions the card did not ask for:
|
||||
an ACCOUNT (the pack's own `bms_soc_floor`, the only non-power fact that
|
||||
separates an empty pack from a wedged one) and a PROPER SUBSET of the units asked
|
||||
to discharge (which is what keeps a whole-site derate out, and what makes this
|
||||
inert at N=1 - measured zero in all 1890 one-inverter runs).
|
||||
|
||||
The report **survives a Home Assistant restart** ([[SAFETY-34]]) under
|
||||
`unit/<name>/delivery_tripped`, with one notification id per unit, re-raised on
|
||||
the first cycle of every process and cleared by the same **Start** press as the
|
||||
other latches. **Charge shares are deliberately not graded** - there is no
|
||||
configured charge ceiling to explain a constant-voltage taper with, and arming
|
||||
that direction would fire on every maintenance charge.
|
||||
`DECISIONS/SAFETY-42-unit-delivery.md` carries the campaign (2640 healthy
|
||||
two-inverter runs plus 1890 at one inverter as the control), the
|
||||
inverter-B sensitivity sweep the margin is sized on, and the mutation ledger.
|
||||
|
||||
⚠️ **AND THE TWO THRESHOLDS IT READS ARE NOW GRADED AT A CONFIG THAT IS NOT THE
|
||||
SHIPPED ONE.** `saturation_w` and each unit's `bms_soc_floor` are handed to this
|
||||
check from the running config, and the promise that comes with that - raise
|
||||
`saturation_w` and this check moves with you, there is no fourth copy of a
|
||||
default to keep in step - is only true while the call site passes the
|
||||
**configured** value. A literal equal to the shipped value keeps every fixture
|
||||
green and removes the promise silently, and two such literals were survivors of
|
||||
all 23 suites - measured, not argued - because no fixture that reaches this
|
||||
check had ever run at a `saturation_w` other than the shipped 500 or at a
|
||||
`bms_soc_floor` other than the shipped example's 10. Part 4b of
|
||||
`test_delivery.py` now runs the same fault at a raised `saturation_w` and at two
|
||||
cut-outs that bracket 10 from both sides, and each pair must answer two
|
||||
different ways - so no constant passes. §9 of the decision record enumerates
|
||||
every number this check reads and says which were already pinned this way and
|
||||
which were not.
|
||||
|
||||
⚠️ **A PER-INVERTER POWER READING THAT HAS STOPPED FOLLOWING ITS OWN COMMAND IS
|
||||
NOW `None`, NOT A HELD NUMBER ([[SAFETY-37]]).** `Inverter.batt` (register
|
||||
`0x0518`) goes through a freshness gate: unchanged for >= 35 s *while* the
|
||||
standing command is over 250 W *and* has travelled over 250 W since the reading
|
||||
last moved. A stale reading is a **missing input**, which §11.1 rule 1 already
|
||||
answers with 0 W and keeps commanding.
|
||||
|
||||
**This is a behaviour change at the shipped roster of one, and it is deliberate.**
|
||||
Before it, a frozen power register read as a healthy measurement for ever - which
|
||||
is [[HW-01]], where a value held for 6 h 25 m while the pack drained to its floor
|
||||
and every detector read healthy. A healthy install is byte-identical: sixty reads
|
||||
of a dithering register under a reversing command all reach the consumers
|
||||
unchanged, asserted rather than assumed. A false verdict fails toward 0 W, behind
|
||||
the existing `stale_input_s` hold, and clears itself the moment the register
|
||||
reports a different value.
|
||||
|
||||
At two or more inverters it also removes two false alarms that had no gate at
|
||||
all: [[SAFETY-30]]'s per-unit sign check no longer trips on a frozen reading
|
||||
(measured: it trips without the gate and NEVER with it - the tick index is
|
||||
deliberately not quoted, because it is counted on the wall clock inside
|
||||
`cycle()` and is therefore load-dependent; the checkable statement is the
|
||||
presence or absence of the trip, which part 3 grades), and
|
||||
[[SAFETY-31]]'s circulating-power sensor reports `None` instead of a loop that is
|
||||
not happening.
|
||||
|
||||
**No new configuration key, and no schema change.** The bound is derived from
|
||||
`circulation_hold_s` and `unit_sign_disagree_*`, which the checks read out of
|
||||
`config.yaml` at both ends - so moving either default takes the gate red rather
|
||||
than leaving it silently too slow. `DECISIONS/SAFETY-37-freshness-policy.md`
|
||||
carries the derivation, what [[TEL-06]] and [[TEL-08]] still owe, and three
|
||||
named survivors - including the largest: **this detects a value that STOPPED, not
|
||||
a value that is WRONG.**
|
||||
|
||||
⚠️ **`slew_w` IS A RATIO TO THE RAIL NOW, AND ITS DEFAULT CHANGED FROM 1000 TO 0
|
||||
([[SAFETY-39]]).** `0` does not mean "off" and does not mean "unset" - it means
|
||||
@@ -3214,7 +3809,12 @@ human to read rather than alarmed on - at a converged -10 W a 1 Wh register need
|
||||
about six minutes to move, so any threshold would false-trip at the target
|
||||
operating point.
|
||||
|
||||
## Unreleased
|
||||
## Detail notes from the 0.4.x era — NOT unreleased
|
||||
|
||||
⚠️ Retitled with the same correction as the section above: [[TEL-05]] and the
|
||||
entries under this heading shipped long ago. Two headings in one file both
|
||||
claiming "Unreleased" is how a reader ends up believing running behaviour is
|
||||
still pending.
|
||||
|
||||
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
|
||||
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# OPT-09 - the weights parameter, and what it is honestly for
|
||||
|
||||
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3d29993` (rebased onto the UX-02 merge; it read `3368231`, and before that `464d398` - each rebase moved it and review caught it each time, which is why the counts in `test_translations.py` are now computed rather than typed)
|
||||
(`git merge-base HEAD 3368231`; the branch was rebased and this line used to name
|
||||
the pre-rebase base, `464d398`).
|
||||
|
||||
## 0. What was actually wrong
|
||||
|
||||
`allocator.plan()` has taken a `weights` argument since ALLOC-01, `_fill()`
|
||||
consumes it in two passes, and **nothing had ever passed one**. Confirmed on the
|
||||
base commit:
|
||||
|
||||
arbiter.py:535 def resolve(claims, *, units=None, weights=None, live=None, ...)
|
||||
main.py:2422 resolve(claims, units=self.units, live=self.live_units(), ...)
|
||||
|
||||
So `weights` was None on every tick of every install, `plan()` turned that into
|
||||
1.0 per unit, and the site target was split **50/50 regardless of what either
|
||||
pack could do**. The card was filed as "per-pack arbitrage sizing"; the architect
|
||||
pass found the sharper content, and it is the sharper content that this branch is
|
||||
mostly about.
|
||||
|
||||
## 1. Two instruments, and they are not interchangeable
|
||||
|
||||
`allocator.py` states the rule this card is built on, and it is mechanical rather
|
||||
than advisory:
|
||||
|
||||
> WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
|
||||
> "please do not use this unit", and it is honoured while any other unit has
|
||||
> headroom - but it is NOT a constraint.
|
||||
|
||||
`_fill()` runs a deliberate **last-resort pass** over the zero-weight units when
|
||||
the preferred ones saturate, so that a preference can never manufacture a
|
||||
standing error at the meter. A pack at its own cut-out that is only *weighted*
|
||||
to zero is therefore still commanded to discharge the moment its sibling fills
|
||||
up. So this branch ships both:
|
||||
|
||||
| | question | mechanism |
|
||||
|---|---|---|
|
||||
| `packshare.envelope()` | MAY this pack be asked, at all, in this direction? | a `pack` UNIT claim at `P_SAFETY`, `hi = 0` at the cut-out, `lo = 0` at 100 % |
|
||||
| `packshare.weights()` | GIVEN that both may, which should carry more? | energy headroom in kWh, handed to `resolve()` |
|
||||
|
||||
## 2. ⚠️ What the weights CANNOT do
|
||||
|
||||
ARCH-01's convex-combination argument - the gain at the meter is
|
||||
`sum(a_i * g_i)`, so it lies in `[min g_i, max g_i]` for **any** split -
|
||||
guarantees that a bad split cannot **destabilise** the site. It says nothing
|
||||
about whether the site can **regulate**. A unit at its energy limit has
|
||||
`g_i = 0`; the combination is still inside the interval and the load it was
|
||||
asked to carry simply does not move.
|
||||
|
||||
Measured, `test_packshare.py` part 8, two units rated 2000 W each, B sitting on
|
||||
its own floor and therefore answering 0 W to any discharge command. The plant is
|
||||
modelled in that file (`lag_s = 0`, obeys within one cycle, bounded by its
|
||||
rating) and the loop is the real `compute()`:
|
||||
|
||||
| load | arm | site target | shares | grid | unmet |
|
||||
|---|---|---|---|---|---|
|
||||
| 1500 W | shipped | +3000 W | A +1500, B +1500 | 0 W | 0 W |
|
||||
| 1500 W | weights | +1500 W | A +1500, B +0 | 0 W | 0 W |
|
||||
| 1500 W | envelope | +1500 W | A +1500, B +0 | 0 W | 0 W |
|
||||
| 3000 W | shipped | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
|
||||
| 3000 W | weights | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
|
||||
| 3000 W | envelope | +4000 W | A +2000, B **+0** | +1000 W | **+2000 W** |
|
||||
|
||||
Three things are worth reading off that table, and the second and third are the
|
||||
uncomfortable ones:
|
||||
|
||||
1. At a load the healthy pack can cover, the weights alone deliver the whole
|
||||
improvement. The shipped arm reaches the same meter reading only by winding
|
||||
the site target to **twice the load** and standing a +1500 W command on a
|
||||
battery that answers nothing, permanently saturation-frozen. That is not a
|
||||
working site, it is a site with no headroom left for the next load step.
|
||||
2. At a load it cannot cover, **the weights stop working entirely**. The
|
||||
weighted arm and the shipped arm command the identical +2000 W into the empty
|
||||
pack, because `_fill()`'s last-resort pass hands the remainder to the
|
||||
zero-weighted unit. Same shares, same watts, same error.
|
||||
3. **And the envelope does not rescue the delivery either - it names the
|
||||
shortfall.** Same 2000 W delivered, same 1000 W standing at the meter. What
|
||||
changes is that the empty pack is commanded 0 W instead of 2000 W, and 2000 W
|
||||
of site target is *reported* as unplaceable instead of appearing to have been
|
||||
placed.
|
||||
|
||||
> **Weights are a comfort choice for STABILITY and NOT for DELIVERY.** Nothing in
|
||||
> this add-on shifts a unit's load to its sibling once the sibling is at its
|
||||
> rating, and weighting a failing unit down is not a remedy for it.
|
||||
|
||||
## 2b. ⚠️ The card's own premise is only half true
|
||||
|
||||
The card opens with *"a unit that stops delivering its share (empty, full,
|
||||
curtailed, wedged) parks the site with a standing grid error and no alarm."*
|
||||
After this branch: the **ask** stops, and while the sibling has headroom the site
|
||||
regulates to zero error instead of winding to twice the load. The **standing grid
|
||||
error does not stop** once the sibling is at its rating - §2's table measures
|
||||
1000 W standing in all three arms - and there is **still no alarm** for it, only
|
||||
a report. SAFETY-42 will not alarm on a pack at its own floor; that is one of its
|
||||
acceptance criteria and its evidence is that the benign and the wedged case are
|
||||
indistinguishable in the power domain.
|
||||
|
||||
So the two cards together mean: an empty pack limiting the site is now REPORTED
|
||||
(`unmet_w`, an action-log line) and still, deliberately, NOT ALARMED. Whether it
|
||||
should be alarmed is a decision - and the alarm it would need is about a site's
|
||||
stored ENERGY, not about an inverter's delivery, so it is not this card's and not
|
||||
SAFETY-42's. Recorded rather than quietly closed against a premise this card only
|
||||
half meets.
|
||||
|
||||
## 3. Which floor, and which ceiling - the two places this could have become policy
|
||||
|
||||
**The floor is `bms_soc_floor` alone, never `peakguard.pack_floor_pct()`.** That
|
||||
function's conjunction with `peak_soc_floor` is right for the peak guard and
|
||||
wrong here: SAFETY-25 decided (2026-09-02, Option A) that `peak_soc_floor` bounds
|
||||
the peak guard **only**, ordinary self-consumption discharges straight past it,
|
||||
and closing an envelope at it would turn an operator preference into a hard stop
|
||||
on the grid-following loop - the memo's **deferred Option B**, built by accident.
|
||||
`delivery.py`'s `account()` made the same choice one step out, for the same
|
||||
reason. Graded: a pack at 15 % with a 10 % cut-out is not closed, though a 20 %
|
||||
EMS floor would have closed it.
|
||||
|
||||
**The ceiling is 100 %, and the coarseness is the honest answer.** There is no
|
||||
per-pack charge ceiling anywhere in this add-on - `delivery.py` says so at length
|
||||
and calls it a card, and it is why SAFETY-42 is armed on discharge shares only.
|
||||
The only other number available is `arbitrage_soc_ceiling`, which is one
|
||||
site-wide **trade policy**; filing it as a `P_SAFETY` envelope on the loop would
|
||||
be the mistake above in the charge direction. So the envelope closes only where
|
||||
the ask is physically impossible, and everything between is left to the weights -
|
||||
which is the right instrument for a graded preference and is honest about being
|
||||
only a preference.
|
||||
|
||||
## 4. N=1, and the tripwire this had to argue past
|
||||
|
||||
`actuator_for()` routes **every** source to the site at one inverter, so a `pack`
|
||||
interval filed there would bound the SITE TARGET rather than a share. `main.py`
|
||||
therefore does not file it at all below a roster of two - the same guard, for the
|
||||
same reason, that SAFETY-30's `sign_site` carries. AC 4 ("at N=1 allocation is
|
||||
byte-identical") is that `if`.
|
||||
|
||||
`test_peakguard.py` part 10f pins the claim-source set against `main.py`'s own
|
||||
AST precisely so that a state-of-charge-conditioned claim cannot arrive quietly.
|
||||
`pack` is state-of-charge-conditioned, so the argument is written at that set
|
||||
rather than the count edited: the number is not an option, the claim is
|
||||
unit-scoped so it cannot bound the site target, it is not filed at N=1, and part
|
||||
10f's own note names this as the revisit trigger - *"under ALLOC-01 the reserve
|
||||
becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a ninth claim
|
||||
here"*. Stage 1 is how an interval reaches the allocator and stage 1's input is
|
||||
claims, so a unit-scoped claim is the shape that sentence asks for.
|
||||
|
||||
**What N=1 still does not get, said plainly:** nothing stops the loop discharging
|
||||
into the BMS cut-out at one inverter. That is unchanged, it is SAFETY-25's
|
||||
deferred option, and the BMS is what stops it.
|
||||
|
||||
## 5. SAFETY-42 - the same hazard from the opposite side
|
||||
|
||||
The seam is **the state of charge** - and they overlap in one direction, which
|
||||
is what the rest of this section is about:
|
||||
|
||||
* **OPT-09 stops the site ASKING** a pack whose own SoC says it cannot deliver.
|
||||
* **SAFETY-42 alarms** when a pack that said it could does not.
|
||||
|
||||
A wedged unit reads a healthy SoC, so **no envelope closes** - that half is
|
||||
absolute, and it is why this card cannot stop the ask on a wedged box. A pack at
|
||||
its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after
|
||||
this card that ask does not arrive in the first place, which removes the benign
|
||||
arm from the detector's input rather than teaching it a new discriminator.
|
||||
|
||||
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWED SAFETY-42's DETECTION WINDOW.
|
||||
THAT WAS A DETECTION REGRESSION AT N=2, IT WAS NOT FIXED HERE, AND SAFETY-43
|
||||
CLOSED IT - see the block at the end of this section.** An earlier draft of
|
||||
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
|
||||
at all". The second clause was **false**, and it was load-bearing for the claim
|
||||
that the two cards do not overlap. A weight is a function of the pack's ENERGY
|
||||
HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever
|
||||
its headroom differs from its sibling's - which is the ordinary case this card
|
||||
exists to exploit. And `delivery.delinquent()`
|
||||
named a unit only while its `short_by()` gap exceeded `min_w =
|
||||
Tuning.saturation_w`, and that was **500 W in absolute watts** with nothing
|
||||
else beside it. Shrinking a unit's share shrinks its gap. Shrink the
|
||||
share far enough and the gap drops below 500 W while the unit is still delivering
|
||||
nothing, and the alarm stops firing.
|
||||
|
||||
Measured on the closed loop - real `compute()`, real `plan()`, real
|
||||
`delinquent()`, modelled plant, B wedged at 15 % against its own 10 % floor so
|
||||
nothing closes and `account()` explains nothing, i.e. a SAFETY-42 **true
|
||||
positive** by that card's own definition:
|
||||
|
||||
load 1500 W equal weights (shipped) named on 199/200 cycles
|
||||
shares {a:1500, b:1500} grid +0 W named=['inverter-b']
|
||||
load 1500 W OPT-09 weights named on 0/200 cycles
|
||||
shares {a:1505, b: 125} grid -5 W named=(nobody)
|
||||
|
||||
⚠️ That plant is modelled in `test_packshare.py`, and **inverter B does not
|
||||
exist** (expected ~2026-09-12): it is two copies of A driven with different
|
||||
numbers, and B's 4600 W / 20.48 kWh / 10 % floor are from the datasheet and the
|
||||
serial. The claim is about the arithmetic of the split and the threshold, which
|
||||
needs no hardware, and not about a GW5000S-BP.
|
||||
|
||||
At a load the healthy pack can cover - the regime where nothing else tells the
|
||||
operator anything, `unmet_w` is 0, the meter is satisfied and no action-log line
|
||||
fires - a wedged inverter goes from named on essentially every cycle to **never
|
||||
named**. The suppression window widens from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B`, which on that fixture is **6500 W** of
|
||||
a 9600 W site; above it - once the healthy pack is at its own rating and
|
||||
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
|
||||
returns. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
|
||||
`test_packshare.py` part 8b graded all of it and **pinned the unflattering
|
||||
behaviour**, so that the day it changed a named check would move. It has moved.
|
||||
|
||||
### ⚠️ CLOSED BY SAFETY-43 - THIS IS NO LONGER A DISCLOSED RESIDUAL
|
||||
|
||||
This section used to end *"until then this is a disclosed residual [...] at two
|
||||
inverters, a wedged unit with materially less energy headroom than its sibling
|
||||
can go unreported at ordinary house loads"*. That sentence is **retired, not
|
||||
softened**, and what replaces it is a measurement rather than an intention.
|
||||
|
||||
`delivery.delinquent()` is now relative to the ask **as well as** absolute:
|
||||
|
||||
named if short_by() > min_w OR short_by() > frac * share
|
||||
|
||||
with `frac = delivery.DELIVERY_SHARE_FRACTION`. A unit delivering 0 of 125 W is
|
||||
100 % delinquent and is named; a unit delivering 0 of 1500 W was already named
|
||||
and still is. On the **same fixture, under the same weights**, part 8b now
|
||||
grades both rules in one run - `share_frac=None` is SAFETY-42's absolute arm,
|
||||
exactly:
|
||||
|
||||
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
|
||||
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
|
||||
|
||||
⚠️ **`min_w` WAS NOT LOWERED, AND THAT IS THE HALF THAT MATTERS HERE.** The
|
||||
absolute arm's suppression window is still exactly the arithmetic above - 1000 W
|
||||
at equal weights, 6500 W at these - and part 8b still asserts both numbers.
|
||||
Lowering `saturation_w` would have traded a missed alarm for a false one on
|
||||
every unit at once, which is SAFETY-41's four-attempt regime. SAFETY-43 added a
|
||||
**second** way to be named and left the first where it was.
|
||||
|
||||
⚠️ **AND WHAT OPT-09 STILL DOES NOT DO IS UNCHANGED.** This card stops the site
|
||||
ASKING a pack whose own SoC says it cannot deliver; it does not re-allocate a
|
||||
wedged unit's watts, and SAFETY-43 did not make it. The alarm got louder; the
|
||||
loop did not move. The derivation of `frac` - swept over the same 2640 no-fault
|
||||
runs that produced `delivery_hold_s`'s floor - is in
|
||||
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md`.
|
||||
|
||||
So the seam, stated accurately **as of SAFETY-43**: OPT-09 stops the ask, and it
|
||||
still narrows the ABSOLUTE arm's window for a low-headroom unit exactly as
|
||||
described above - but it no longer narrows the DETECTOR, because the detector no
|
||||
longer reads only that arm.
|
||||
|
||||
⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without
|
||||
connecting it.** That module's rejection of re-allocation rests on *"the only
|
||||
re-allocation this add-on can express is ALL OR NOTHING [...] a partial
|
||||
re-allocation is what would make acting safe, and the allocator has no way to
|
||||
express it today"*. Passing weights **is** that expression. Wiring delinquency
|
||||
into them is deliberately **not** done here: it would be acting on the alarm,
|
||||
which SAFETY-42 refused with rig evidence (a unit derated to 50 % is 400 W worse
|
||||
dropped than kept, and no instrument this add-on has can tell it from a wedged
|
||||
one), and - see §2 - a weight cannot stop a unit being commanded anyway. That is
|
||||
a card, and this paragraph is its input.
|
||||
|
||||
## 6. Per-pack arbitrage sizing, and the half that is deliberately not moved
|
||||
|
||||
`arbitrage.decide()` was handed `site_max_w` on **both** sides, so a two-pack
|
||||
site would buy at the sum of the ratings whatever either pack's state of charge
|
||||
was. It is now sized to the packs whose own SoC permits that direction
|
||||
(`Controller.trade_power_w()`), using **arbitrage's own** bounds rather than the
|
||||
BMS cut-out - sizing a trade is the trade's business; permitting a command is the
|
||||
pack's.
|
||||
|
||||
Two honest limits:
|
||||
|
||||
* **The sell side is a no-op on every roster today.** `decide()` is handed
|
||||
`pack_floor_soc` - the emptiest pack - so a sell is refused outright whenever
|
||||
*any* pack is at or under the floor, i.e. in exactly the states where the sum
|
||||
could be smaller than the site rating. The arithmetic is graded directly
|
||||
(`test_packshare.py` part 9); it becomes reachable the day that gate is
|
||||
per-pack.
|
||||
* **Moving that gate is not this card's.** Sizing a sell off the *fullest* pack
|
||||
would let the trade proceed while the emptiest pack was below
|
||||
`arbitrage_soc_floor`, and the only per-pack instrument that could keep it out
|
||||
of that pack is the weights - which are a preference, and `_fill()` will hand
|
||||
it watts anyway. Doing it safely needs an envelope that depends on WHICH CLAIM
|
||||
WON the tick, which the arbiter's partition does not express. §2 is why that is
|
||||
a real objection and not a hedge.
|
||||
|
||||
`arbitrage.decide()` also now refuses a **0 W sizing** by name (`IDLE_NO_SIZE`),
|
||||
because a `Claim.set(..., 0.0)` at `P_ARBITRAGE` is a park order that outranks
|
||||
the loop - the failure the note on `claim()` is already written against. The
|
||||
sizing can never be zero on a tick that trades today; that is a coincidence of
|
||||
the existing gate, and this makes it structural instead.
|
||||
|
||||
## 7. The fixture, which is the hard part
|
||||
|
||||
Two packs that differ in only ONE of SoC and capacity cannot separate a
|
||||
SoC-aware weighting from a capacity-aware one; two identical packs cannot
|
||||
separate ANY weighting from any other. `test_packshare.py` part 1 grades the
|
||||
fixture itself before anything is asserted with it - four candidate schemes
|
||||
(equal, power rating, SoC only, energy), four distinct splits of the same
|
||||
1000 W:
|
||||
|
||||
equal A +500, B +500
|
||||
power A +521, B +479
|
||||
soc only A +125, B +875
|
||||
energy A +97, B +903
|
||||
|
||||
and the counterfactuals: on two identical packs all four collapse to one answer;
|
||||
at the same SoC, `soc only` collapses onto `equal`; at the same capacity,
|
||||
`energy` collapses onto `soc only`.
|
||||
|
||||
## 8. Provenance of every number
|
||||
|
||||
* A: **5000 W, 15.36 kWh, 10 % floor** - measured (GW5000S-BP, register 0x0560).
|
||||
* B: **4600 W, 20.48 kWh, 10 % floor** - **FROM DATASHEET AND SERIAL**
|
||||
(`goodwe/NOTES.md` §1.1b, GW5048D-ES `55048ESU22AW0992`). ⚠️ **Inverter B does
|
||||
not exist** (expected ~2026-09-12).
|
||||
* Every two-unit run in `test_packshare.py` is arithmetic over intervals and
|
||||
weights, or a plant modelled in that file. `sim/plantsim.py:216`'s `INVERTER_B`
|
||||
is **inverter A copied** with `measured=False`, so any two-inverter simulation
|
||||
result anywhere in this project is **two copies of A**.
|
||||
* No rig was taken, no hardware was touched, and nothing here is evidence about
|
||||
a second GoodWe inverter.
|
||||
|
||||
## 9. Config
|
||||
|
||||
`capacity_kwh` joins the `inverters` roster as an **optional** per-entry key
|
||||
(`float(0.1,200)?`) - the opposite choice to SAFETY-32's required
|
||||
`bms_soc_floor`, and for that card's own reason: a missing cut-out would have to
|
||||
be *defaulted*, and one default for two batteries protects the shallower pack
|
||||
with the deeper pack's number, whereas a missing capacity has an honest answer -
|
||||
no opinion about the split, every unit weighs 1.0, which is exactly what shipped.
|
||||
|
||||
It is **all or nothing across the roster** and rule 9 refuses a partial one: a
|
||||
pack measured in kWh beside a sibling weighing a dimensionless 1.0 is not a
|
||||
comparison, `weights()` answers "no opinion" for the whole roster in that case,
|
||||
and a key that silently changes nothing is worse than one that refuses to load.
|
||||
|
||||
There is deliberately **no flat `capacity_kwh`**: at one inverter there is no
|
||||
split to weight, so it would be an option an operator can set and nothing reads.
|
||||
@@ -0,0 +1,396 @@
|
||||
# SAFETY-13 — the output freeze gets an exit, and only where it cannot end on its own
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-13`, off `release/1.0` @ `c8dcb38`.
|
||||
**Card:** SAFETY-13 — *"Bound the no-start-while-saturated output block"*
|
||||
([Trello](https://trello.com/c/uZjmuF7B)).
|
||||
**Nothing here reached the inverter.** Every figure below is from the pure control
|
||||
law run in a container against a plant model, on this branch and on a pristine
|
||||
`c8dcb38` for the before-figures. No rig, no ESP32, no Home Assistant.
|
||||
|
||||
---
|
||||
|
||||
## 0. The four things a reader should take away
|
||||
|
||||
1. **The card's headline case is real, and the route to it is not the obvious
|
||||
one.** The block does not engage on the way down into an export; the slew
|
||||
limit moves the command off zero before the counter reaches 3. It engages
|
||||
from the **deadband** — a site resting at its target with another writer on
|
||||
the bus, counting saturated cycles while nothing is wrong — and 0 W is then a
|
||||
fixed point. §2.
|
||||
2. **`prev_w == 0` is not a special case, so the fix is not one either.** The
|
||||
same run pins at −800 W against its own integrator standing at −2388 W, at a
|
||||
non-zero `prev_w`, with
|
||||
the same absence of an exit. Zero is only the instance where the pinned
|
||||
magnitude is the whole of what the battery could have done. §3.
|
||||
3. **The decision is not "how long", it is "which".** A divergence a *limit*
|
||||
could have produced clears itself and gets no exit — `release/1.0` unchanged.
|
||||
A divergence a limit could not have produced may not, and gets one. The
|
||||
predicate is physical, not statistical — and it is **not** a second-writer
|
||||
detector; §4.1 and §8 say what it can and cannot tell apart.
|
||||
4. **Releasing the output freeze does not unfreeze the loop**, and that is what
|
||||
makes the release bounded rather than merely different. §6.
|
||||
5. **The bound comes from the SLOWER half of a two-sided measurement, and the
|
||||
first version of this branch took the faster half.** §8 is the correction and
|
||||
the measurement behind it; it is the reason `SETTLE_CYCLES` is 12 and not 9.
|
||||
|
||||
---
|
||||
|
||||
## 1. What was there, in the terms the code uses
|
||||
|
||||
`control.py`, in `compute()`, after the clamp and the slew limit:
|
||||
|
||||
```python
|
||||
if frozen:
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
```
|
||||
|
||||
"Magnitude may fall, never rise." At `prev_w == 0` the magnitude is zero and
|
||||
`max(target, 0)` forbids any negative command, so a charge cannot **start**.
|
||||
|
||||
The reason is written in the file and it is a good one: `frozen` means the
|
||||
inverter's reported power has diverged from the command by more than
|
||||
`saturation_w` for `saturation_cycles` consecutive cycles, and at a command of
|
||||
zero that means the actuator is producing power nobody in this add-on asked for.
|
||||
Starting to push the other way into it is the configuration §1.5 of the
|
||||
specification rules out — *"any configuration in which two writers can reach one
|
||||
actuator"*.
|
||||
|
||||
**The defect is that it has no end.** Not the error growing, not the integrator
|
||||
moving, not time. `sat_count` stops rising at its cap and the state is a fixed
|
||||
point.
|
||||
|
||||
---
|
||||
|
||||
## 2. Reproducing it — and correcting the route
|
||||
|
||||
Driven at the live install's shape: `max_w 5000`, `slew_w 400`,
|
||||
`target_grid_w -10`, `saturation_w 500`, `saturation_cycles 3`, `gain 0.6`.
|
||||
Another writer holds the pack at **+700 W** whatever is commanded.
|
||||
|
||||
**The route that does NOT reach it.** Start at `prev_w = 0` with 2 kW of export
|
||||
already standing. On `c8dcb38` the loop moves off zero at the slew limit before
|
||||
the counter reaches 3 — commands −400, −800 — so by the time `frozen` is true
|
||||
`prev_w` is −800 and the block that gets applied is `max(target, −800)`. The
|
||||
site charges at 800 W, not 0 W, for as long as the run lasts.
|
||||
|
||||
**The route that does.** The site is *resting*. `grid == target_grid_w`, so the
|
||||
loop is in its deadband and the command is 0 — and `saturated_now` is computed
|
||||
**before** the deadband branch, so the counter climbs anyway. Three cycles later
|
||||
the loop is frozen at `prev_w == 0` with nothing wrong. Then the export arrives:
|
||||
|
||||
| cycle | grid | actual | prev | command | integrator | sat | reason |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 0–1 | −10 | +700 | 0 | 0 | 0 | 1–2 | `deadband` |
|
||||
| 2–5 | −10 | +700 | 0 | **0** | 0 | 3–6 | `saturated-freeze` |
|
||||
| 6 | −2000 | +700 | 0 | **0** | −1194 | 7 | `saturated-freeze` |
|
||||
| 7… | −2000 | +700 | 0 | **0** | −1194 | 8→10 | `saturated-freeze` |
|
||||
|
||||
On `c8dcb38` the command is `0.0` on **every** cycle from there on. The
|
||||
integrator has moved to −1194 W and stopped (its own freeze holds it), the site
|
||||
exports 2 kW, and the battery is idle until the other writer leaves.
|
||||
|
||||
That is the card's sentence — *"the site can sit exporting 2 kW with an idle
|
||||
battery indefinitely"* — reproduced, with the route corrected: it is entered
|
||||
from the **deadband**, not from a correction that ran out of room.
|
||||
|
||||
---
|
||||
|
||||
## 3. …and `prev_w == 0` is not where it stops
|
||||
|
||||
The same tuning, same writer, but the loop starts from zero with the export
|
||||
already standing (the route from §2 that "does not reach it"):
|
||||
|
||||
| | `c8dcb38` |
|
||||
|---|---|
|
||||
| command after the freeze engages | **−800 W**, on every cycle |
|
||||
| the loop's integrator, frozen | −2388 W |
|
||||
| what ends it | nothing |
|
||||
|
||||
So the missing exit is a property of the **output freeze**, not of the zero
|
||||
case. `prev_w == 0` is the instance where the pinned magnitude happens to be
|
||||
zero. A rule written for zero alone would have left −800 W exactly as unbounded
|
||||
as it found it — which is why the change below is written against the freeze and
|
||||
not against a special case of it.
|
||||
|
||||
**And this is the half that escalates at two inverters.** `actual_w` is
|
||||
`site_batt_w`, the SUM (ALLOC-01, DEV-02), so the comparison is a site command
|
||||
against two units' power. One unit still moving after the other has stopped is
|
||||
enough to put the site into the freeze with no external actor at all.
|
||||
|
||||
⚠️ **What that claim is and is not.** The escalation is stated from the code —
|
||||
`prev_w` is `self.target`, the site total, and `actual_w` is the site sum — and
|
||||
from the single-unit runs above. **It is not measured on two inverters.**
|
||||
Inverter B does not physically exist (`NOTES.md` §1.1b: on paper, from datasheet
|
||||
and serial), and `sim/plantsim.py:216` `INVERTER_B` is `INVERTER_A` copied with
|
||||
`measured=False`. Any two-inverter number would be two copies of A.
|
||||
|
||||
---
|
||||
|
||||
## 4. The decision: which freezes get an exit, not how long the block lasts
|
||||
|
||||
Three answers were on the table, and the card named them: lift after N frozen
|
||||
cycles, exempt above some export magnitude, or keep it unbounded and write down
|
||||
the cost.
|
||||
|
||||
**A plain cycle bound was rejected**, because it bounds the wrong thing. The
|
||||
freeze is right to be permanent in one case and cannot be permanent in the
|
||||
other, and a single N treats them alike:
|
||||
|
||||
| the inverter is… | can the freeze clear itself? |
|
||||
|---|---|
|
||||
| doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap, the plant still ramping | **Yes.** The command is pinned at something the inverter can reach, so it catches up, `saturated_now` goes false, and the freeze ends. Asking for more would achieve nothing anyway. |
|
||||
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **No.** Clearing the freeze requires the inverter to track *our* command, and the other writer is precisely what stops that. |
|
||||
|
||||
**An export-magnitude exemption was rejected** for a narrower reason: it makes
|
||||
the block's end depend on how big the other actor's disturbance happens to be,
|
||||
which is not a property of the thing the block is about.
|
||||
|
||||
So the rule implemented is:
|
||||
|
||||
> The output freeze applies as before, **unless** the inverter's reported power
|
||||
> cannot be explained by the command not being met **and** the divergence has
|
||||
> outlasted the plant that would otherwise have explained it.
|
||||
|
||||
### 4.1 The predicate is physical
|
||||
|
||||
`unasked_power(prev_w, actual_w, saturation_w)`. A limit can only make the
|
||||
inverter do **less** than the command, in the command's own direction. It cannot
|
||||
make it overshoot the command and it cannot make it produce the opposite sign.
|
||||
So everything a limit can produce lies in the closed interval spanned by `0` and
|
||||
`prev_w`, widened by `saturation_w` for noise, ramp lag and idle draw.
|
||||
|
||||
⚠️ **What it does NOT establish is that anything outside was put there by
|
||||
something else.** The first version of this branch said exactly that, in the
|
||||
docstring, in `control.py` and in DOCS.md, and it is false: the predicate
|
||||
compares against **one cycle's** command while the pack carries every command
|
||||
before it, so our own coast-down through zero lands outside our own interval.
|
||||
§8 measures it. What the predicate guarantees is only its own first sentence —
|
||||
*this cycle's command does not account for this cycle's reading* — and the time
|
||||
bound below is what turns that into a reason to act.
|
||||
|
||||
| prev | actual | interval | verdict |
|
||||
|---|---|---|---|
|
||||
| +2000 | +700 | [0, 2000] | a limit |
|
||||
| −5000 | −700 | [−5000, 0] | a limit |
|
||||
| 0 | +700 | [−500, +500] | **unasked** |
|
||||
| −800 | +700 | [−1300, +500] | **unasked** |
|
||||
|
||||
⚠️ **At `prev_w == 0` this predicate and the saturation detector are the same
|
||||
test** — asserted in `test_control.py` over 801 readings. That is not a
|
||||
coincidence to tidy away: with nothing commanded there is no interval left for a
|
||||
limit to live in, which is exactly why the zero case is the one that strands.
|
||||
|
||||
⚠️ **It is not a detector and nothing alarms on it.** A stale, wrong-signed or
|
||||
noisy reading lands outside the interval too. All it decides is whether the
|
||||
freeze gets an exit; being wrong about it costs a bounded command (§6).
|
||||
SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on sign, and it is
|
||||
deliberately a different test with a different threshold.
|
||||
|
||||
### 4.2 The bound, and which half of the measurement it comes from
|
||||
|
||||
`SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)` = **12**.
|
||||
|
||||
⚠️ **`PLANT_SETTLE_DOWN_S` is new, and adding it is the point rather than a
|
||||
cost.** `NOTES.md` §5.10 did not measure *a* settling time; it measured the same
|
||||
1.78 kW step in **both directions** — "Up: ~18 s from step to deadband. Down:
|
||||
~23 s from a −1784 W export excursion" (`NOTES.md:1550`). So the plant's
|
||||
settling time is a **pair**, and which half is the safe one depends on which way
|
||||
being wrong hurts:
|
||||
|
||||
| the bound | the question | the safe half | why |
|
||||
|---|---|---|---|
|
||||
| SAFETY-39's `SLEW_RAIL_FRAC` | may this configuration be **refused**? | **18 s**, the shorter | a looser bound refuses fewer working configurations |
|
||||
| this one | may the plant still be the **explanation**? | **23 s**, the longer | a shorter bound stops believing the plant too early, and then it accuses |
|
||||
|
||||
The first version of this branch reused `PLANT_SETTLE_S` and got 9 — three
|
||||
cycles short of the plant's own measured down-ramp, on the side that produces a
|
||||
false positive. §8 is the measurement and the correction.
|
||||
|
||||
⚠️ **It is no longer the reciprocal of `SLEW_RAIL_FRAC`, and it never should
|
||||
have been read as one.** The two were equal only while both were being derived
|
||||
from the same half of the pair, and that coincidence read as a derivation. They
|
||||
are 9 and 12 now; `test_control.py` asserts the inequality so they cannot be
|
||||
quietly re-fused.
|
||||
|
||||
⚠️ **It inherits SAFETY-39's caveat about `LOOP_TICK_S`, in the opposite
|
||||
direction.** A meter **slower** than 2 s makes 12 cycles cover more than 23 s, so
|
||||
the freeze holds longer than the plant needs: that costs energy, never safety. A
|
||||
meter **faster** makes them cover less, so the freeze can release while the
|
||||
plant is still moving — bounded by §6 rather than by luck.
|
||||
|
||||
**`_SAT_COUNT_CAP` moved with it, from 10 to 13**, because it is written as
|
||||
`SETTLE_CYCLES + 1` — a counter capped below the longest bound read from it
|
||||
would silently delete the exit. That has a consequence worth stating on its own:
|
||||
`config.yaml` accepts `saturation_cycles` as `int(1,10)`, so at the old bound of
|
||||
9 a legal setting could sit **above** the exit and delete the block. At 12 the
|
||||
whole accepted range is strictly below the bound, and the degenerate
|
||||
configuration this branch used to document no longer exists. §7.
|
||||
|
||||
---
|
||||
|
||||
## 5. What actually changed, graded against `release/1.0`
|
||||
|
||||
`test_control.py`'s sweep still compares this law against a transcription of
|
||||
`release/1.0` — the transcription was **not** taught the new rule, because an
|
||||
oracle taught the change compares the law against itself. What changed is the
|
||||
assertion: every case must be identical, or be a release with both of its
|
||||
conditions true, or differ **in the saturation counter alone**.
|
||||
|
||||
⚠️ **The sweep had to be widened to keep grading anything.** Its `sat_count`
|
||||
fixtures ran to 9; with the bound at 12 the counter never reached the exit and
|
||||
every release silently vanished from the table, which is what the assertion on
|
||||
the count caught. It runs to 12 now, at 3780 cases.
|
||||
|
||||
| | cases |
|
||||
|---|---|
|
||||
| total | 3780 |
|
||||
| **bit-identical to `release/1.0`** | **3267** |
|
||||
| releases | 432 |
|
||||
| …of those, reason-only (the freeze was not binding) | 405 |
|
||||
| …of those, **the command changes** | **27** |
|
||||
| differ in the **saturation counter alone** | 81 |
|
||||
|
||||
All 27 command changes are at `prev_w == 0`, all with `release/1.0` commanding
|
||||
exactly `0.0 W`, and every new command is **negative** — a charge start that was
|
||||
refused.
|
||||
|
||||
⚠️ **The 81 are the counter's ceiling moving, and they reach nothing.**
|
||||
`_SAT_COUNT_CAP` went from 10 to 13 with the bound, so above `release/1.0`'s own
|
||||
ceiling the two counters differ. Nothing else may differ in those cases, and the
|
||||
sweep asserts that: `frozen` is `sat_count >= saturation_cycles`, `config.yaml`
|
||||
accepts that at most 10, and no other line in `compute()` reads the counter's
|
||||
value — so a counter reading 13 instead of 10 cannot change what is commanded.
|
||||
The 81 all sit at an `sc` above 10, which is a state `release/1.0` could never
|
||||
hold at all.
|
||||
|
||||
The behaviour after the fix, on §2's run: the command holds at 0 W through
|
||||
`sat_count` 3–11, releases on the **twelfth** saturated cycle, and walks
|
||||
0 → −400 → −800 → **−1190 W**, where it stops — the same settling point as at
|
||||
the old bound, three cycles later.
|
||||
|
||||
The genuine-limit case, driven for 30 cycles with the inverter curtailed to
|
||||
±1000 W against a 4 kW import: `freeze-released` never appears and the command
|
||||
stays pinned at +2400 W. Unchanged, deliberately.
|
||||
|
||||
---
|
||||
|
||||
## 6. Why the release cannot become windup (the card's fourth criterion)
|
||||
|
||||
**The integrator freeze is not lifted with the output freeze.** The command is
|
||||
derived from `i_w`, then clamped, then slew-limited from `prev_w` — so a
|
||||
released cycle can only move the command *toward* an accumulator that stopped
|
||||
accumulating when the freeze engaged, by at most one slew step, and never past
|
||||
it. `test_control.py` sweeps 107 625 states, of which **67 500 release**: no
|
||||
released case leaves the interval between the previous command and the frozen
|
||||
integrator, and none moves further than one slew step.
|
||||
|
||||
⚠️ **107 625 was previously reported as the number of released states. It is
|
||||
the number of states swept**, and the assertion only ever graded the subset that
|
||||
released. Both numbers are counted and printed now.
|
||||
|
||||
On §2's run that shows up as the settling point: **−1190 W against a frozen
|
||||
integrator of −1194 W**, not the 5000 W rail. The tech lead's note on SAFETY-04
|
||||
— that the carried `i_w` and `release/1.0`'s recomputed value land on the same
|
||||
slew-limited command — is preserved: the `i_w=None` path is still graded against
|
||||
the transcription, cycle for cycle, everywhere except the 432 releases.
|
||||
|
||||
⚠️ **The first form of this check was wrong and the sweep said so**, which is
|
||||
left in the test file. It asserted `|target| <= |i_w|` on a released cycle and
|
||||
produced `prev −5000, i_w −3000, target −4600`: the command was walking back
|
||||
from −5000 toward the integrator and had not arrived. It was moving the right
|
||||
way and the assertion still failed, because "never exceeds the integrator" is
|
||||
not what no-windup means here.
|
||||
|
||||
---
|
||||
|
||||
## 7. What this does NOT buy, said plainly
|
||||
|
||||
**The site still does not reach its target while another writer is on the bus.**
|
||||
It settles at the frozen integrator, not at the meter's target. What the exit
|
||||
buys is that the battery stops being *idle* while the site exports; it does not
|
||||
buy correct regulation against an actuator somebody else is also writing to, and
|
||||
nothing in a rules engine can. A standing grid error with a second writer
|
||||
present is the symptom of a configuration this add-on refuses to support.
|
||||
|
||||
**`freeze-released` is a place to start looking, not a finding.** It says the
|
||||
divergence outlasted the plant's own measured settling time, and that is all it
|
||||
says — §8. DOCS.md now tells the operator the innocent causes first (a large
|
||||
load or PV swing, a plant slower than the one that was measured) and the second
|
||||
writer only when the state **stands**. It is carried on `Decision.reason`, which
|
||||
nothing after the freeze overwrites. No new alarm was added: SAFETY-41 removed
|
||||
one that narrated normal anti-windup, and this state is not yet known to be
|
||||
worth waking anybody for.
|
||||
|
||||
**No accepted configuration removes the block any more.** `config.yaml` accepts
|
||||
`saturation_cycles` as `int(1,10)`; the bound is 12, so every accepted value is
|
||||
strictly below it and the freeze always engages before its exit is due. This
|
||||
paragraph used to say the opposite — at a bound of 9, settings of 9 and 10
|
||||
deleted the block silently and the branch documented that as a known
|
||||
consequence. Moving the bound closed it by construction. `test_control.py` pins
|
||||
it against `config.yaml`'s own schema line rather than a transcription of it.
|
||||
|
||||
**Nothing was measured on two inverters.** See the warning in §3.
|
||||
|
||||
---
|
||||
|
||||
## 8. ⚠️ ROUND 2: the bound was derived from the wrong half, and it accused
|
||||
|
||||
**What was wrong.** `SETTLE_CYCLES` was `ceil(PLANT_SETTLE_S / LOOP_TICK_S)` = 9.
|
||||
`PLANT_SETTLE_S` is 18 s, the **up**-ramp of `NOTES.md` §5.10's kettle step;
|
||||
the same measurement recorded ~23 s coming back **down** (`NOTES.md:1550`).
|
||||
SAFETY-39 takes 18 correctly — its bound is a refusal, and the looser number
|
||||
refuses less. This bound asks the opposite question, and the looser number stops
|
||||
believing the plant three cycles too early.
|
||||
|
||||
**Why that is not a tuning quibble.** The predicate has no memory. `prev_w` is
|
||||
one cycle's command; the pack carries every command before it. A command that
|
||||
falls **through zero** — the ordinary end of a load or PV step — leaves the pack
|
||||
coasting in the old direction, so the reading has the sign the command *used* to
|
||||
have and lands outside the interval spanned by 0 and the new `prev_w`.
|
||||
`unasked_power()` calls that "unasked". With the bound three cycles short of the
|
||||
plant's own down-ramp, the counter reaches it while the coast-down is still
|
||||
going, and the law emits `freeze-released` on **one inverter with nothing else
|
||||
on the bus.**
|
||||
|
||||
**Measured, in the container, N = 1.** One `compute()`, one plant model, one
|
||||
command; no second writer anywhere in the harness. The plant is a first-order
|
||||
lag whose two time constants come from the two measured settling times
|
||||
(tau = t/3, since a first-order step is 95 % done at 3 tau) with
|
||||
`sim/plantsim.py`'s measured 800 W/s ramp cap. It is a **model**, and it is
|
||||
labelled one wherever it is used.
|
||||
|
||||
| | bound 9 | bound 12 |
|
||||
|---|---|---|
|
||||
| 1280 closed-loop scenarios (a grid of plant pairs, slews, `saturation_cycles`, load steps) | **113 fire** | **24 fire** |
|
||||
| 272 load-step pairs at the measured 18 s / 23 s plant, `slew_w 400` | **62 fire** | **2 fire** |
|
||||
|
||||
The single case now pinned in `test_control.py`: a 4 kW export falling to
|
||||
2.5 kW. At cycle 34 the command is −310 W, the pack is still at −1343.4 W
|
||||
coasting down from −4860 W a few cycles earlier, `unasked_power(−310, −1343.4,
|
||||
500)` is True, `sat_count` is 9, and the law says `freeze-released`. At 12 the
|
||||
same run never releases.
|
||||
|
||||
**What the fix does not fix.** The bound makes the false positive rare on *the
|
||||
plant that was measured*. It cannot make it impossible, because it is a
|
||||
measurement and the plant can be slower than the measurement. Same sweep, bound
|
||||
held at 12, lengthening only the plant's down-ramp: 8 of 320 scenarios at the
|
||||
measured 23 s, 59 of 320 at 34 s, 268 of 320 at 50 s. That residual is why the
|
||||
three statements below were corrected rather than merely re-numbered.
|
||||
|
||||
**Three shipped statements were falsified by this and are now rewritten:**
|
||||
|
||||
1. `unasked_power()`'s docstring — *"Anything outside that interval was put
|
||||
there by something other than this loop."* It was our own power. The
|
||||
docstring now says what the predicate does guarantee (this cycle's command
|
||||
does not account for this cycle's reading) and states the coast-down as its
|
||||
known blind spot.
|
||||
2. `control.py`, in `compute()` — *"SO THE BOUND BELONGS TO (b) AND ONLY TO
|
||||
(b)"*. It fired in case (a). The comment now says the bound is a reason to
|
||||
stop believing (a), not a proof of (b), and that the release is written to be
|
||||
survivable when the inference is wrong.
|
||||
3. **DOCS.md — *"If you see `freeze-released` in the action log, look for the
|
||||
second writer."*** This is the one that reaches a person, and on a
|
||||
single-inverter install it sent them hunting something that does not exist.
|
||||
Replaced by a section that gives the innocent causes first and the second
|
||||
writer only when the state stands.
|
||||
@@ -149,8 +149,70 @@ each swept across the **whole range `config.yaml` accepts** for the two settings
|
||||
that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and
|
||||
`slew_w` `int(50,5000)`.
|
||||
|
||||
### ⚠️ THE HARNESS BEHIND EVERY FIGURE ABOVE IS NOT COMMITTED, AND UNTIL 2026-09-07 THAT MADE THEM UNCHECKABLE
|
||||
|
||||
**Read this before quoting any number in this section.** The paragraph below
|
||||
said the campaign harness "is not committed here" and treated that as a
|
||||
packaging detail. It is not: a figure produced by a script that exists in
|
||||
nobody's tree cannot be re-run, cannot be reviewed, and cannot be defended when
|
||||
it is contradicted. It has now been contradicted twice.
|
||||
|
||||
| sweep | worst peak | worst continuous | trips |
|
||||
|---|---:|---:|---:|
|
||||
| the 924-run campaign below — **harness uncommitted** | 1700 W | 505.0 s at 100 W; 5.0 s above 700 W | 0 / 924 |
|
||||
| independent reviewer sweep, 2026-09-07, 48 honest reversals | **2750 W** | **15.0 s above 700 W** | 0 / 48 |
|
||||
| `admin/goodwe` `two_master_circulation`, 840 runs — **committed** | 1048 W | 10.0 s at 100 W; 5.0 s above 700 W | **0 / 840** |
|
||||
|
||||
**This is not proof that anything was fabricated, and it should not be reported
|
||||
as one.** The three swept different grids. What the third one adds is a
|
||||
measurement of *why* the spread is that wide — on one grid, changing nothing but
|
||||
the model:
|
||||
|
||||
| plant / stagger | worst legitimate peak |
|
||||
|---|---:|
|
||||
| modelled plant (lag 10 s, ramp 800 W/s), staggers up to a whole cycle | 1048 W |
|
||||
| `INSTANT` (lag 0, unbounded ramp), staggers up to a whole cycle | 2250 W |
|
||||
| `INSTANT`, staggers strictly under a whole cycle | **0 W** |
|
||||
|
||||
A factor of 2.1×, and a factor of infinity, between three defensible
|
||||
descriptions of the same rig. **The plant model and the write-stagger set are
|
||||
terms of the number**, and a worst case quoted without them is not a
|
||||
measurement. That is enough to explain the whole spread above without anybody
|
||||
having invented anything — and it is exactly what cannot be established when the
|
||||
instrument is missing.
|
||||
|
||||
**What survives, and what does not.**
|
||||
|
||||
* **The window argument survives, and is now committed.** The claim that
|
||||
decides `circulation_hold_s: 75` is *"the worst continuous legitimate episode
|
||||
above 700 W is about one control cycle"*. All three sweeps agree on the shape
|
||||
of that (5.0 s, 15.0 s, 5.0 s — one to three cycles against a fifteen-cycle
|
||||
window), and **all three trip zero times**. `two_master_circulation` re-runs
|
||||
it on every pull request in `admin/goodwe`.
|
||||
* **The threshold curve does NOT survive as stated.** `505.0 s at 100 W` is the
|
||||
number the "knee at 400 W" argument for `circulation_w: 700` rests on, and the
|
||||
committed sweep measures **10.0 s** at 100 W on its grid — 50× smaller. The
|
||||
curve still *falls* with the threshold (126 of 840 runs cross 100 W against 1
|
||||
that crosses 700 W), so the knee is real in direction; its depth is not
|
||||
established by anything anybody can run. **Do not quote 505.0 s.**
|
||||
* **Nothing about the guard is compromised.** 0 trips in 924 + 48 + 840
|
||||
non-faulty runs, from three independent grids.
|
||||
|
||||
**What would settle it:** the original harness, committed. Until then the
|
||||
committed sweep is the only figure in this section anybody can check, and it is
|
||||
labelled as its own grid's rather than as a reproduction.
|
||||
|
||||
### Reproducing it
|
||||
|
||||
⚠️ **THE COMMITTED VERSION IS `admin/goodwe`'s `sim/inject.py
|
||||
two_master_circulation`** (`sim/sitesim.py:circulation_findings`, runnable on its
|
||||
own as `python3 sim/sitesim.py --circulation`, `norig`, ~36 s, graded by
|
||||
`checks.yml` on every pull request there). It produces the fault with
|
||||
`plantsim.Inverter.wedge()` — a unit answering its bus and no longer steering its
|
||||
power stage, which is HW-01 — and sweeps the legitimate family with the
|
||||
serialised write path staggered up to a whole control cycle. The paragraph below
|
||||
describes the ORIGINAL campaign, which is the one that is not committed.
|
||||
|
||||
The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness
|
||||
is not committed here because it imports that repo's `plantsim`/`frame_check`,
|
||||
which are not present in this one or in CI. What it does is small enough to
|
||||
@@ -206,7 +268,11 @@ margin above the knee is what absorbs that without re-opening this card.
|
||||
**Revisit when SPIKE-04 lands, not before.**
|
||||
|
||||
The **schema floor is the knee itself**, 400 W, so a legally configured install
|
||||
cannot land in the 445–505 s regime. What the threshold costs: a circulating
|
||||
cannot land in the 445–505 s regime — ⚠️ and those two figures are from the
|
||||
uncommitted campaign; the committed sweep measures 10.0 s at 100 W on its own
|
||||
grid. See the disclosure at the head of this section. The floor is still the
|
||||
right shape (fewer runs cross a higher threshold, measured 126/840 at 100 W
|
||||
against 1/840 at 700 W); its DEPTH is not established by anything re-runnable. What the threshold costs: a circulating
|
||||
loop smaller than 350 W per unit is not reported, which is deliberate — at that
|
||||
scale the round-trip loss is a few watts.
|
||||
|
||||
@@ -289,6 +355,32 @@ SAFETY-25 — so no stale `.pyc` could exist to be read. Byte deltas recorded;
|
||||
| M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check |
|
||||
| M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks |
|
||||
| M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED |
|
||||
| **M20** | `main.py` | **`pos, neg = opposing(...)` → `([], [])`** — the attribution clause deleted from the alarm | −102 B | **SURVIVED ALL 26 FILES on the shipped battery — GAP CLOSED**, then RED on §6a |
|
||||
| M21 | `main.py` | `pos` and `neg` exchanged — the alarm names both packs, both the wrong way round | **+0 B** | **RED** — §6a ×3 (the mirrored run, and "only its own sentence") |
|
||||
| M22 | `main.py` | `opposing(..., min_w=cfg.min_w / 2.0)` → `min_w=0.0` | −25 B | **RED** — §6a's resting-pack check |
|
||||
|
||||
**And four in `admin/goodwe`**, graded through `python3 sim/sitesim.py
|
||||
--circulation` rather than through the injector — deliberately, because
|
||||
`inject.py` is the file that takes the rig lock, and "run the injector to grade a
|
||||
mutation" is not a sentence anybody should have to type. Byte deltas over
|
||||
`plantsim.py` + `sitesim.py` together.
|
||||
|
||||
| # | file | mutation | Δ | result |
|
||||
|---|---|---|---|---|
|
||||
| S1 | `plantsim.py` | `wedge()` no longer stops the latch reaching the power stage | −20 B | **RED** — the opposing-signs precondition **and** "the watchdog did not trip" |
|
||||
| S2 | `sitesim.py` | the sensor computed over the **allocation record** instead of register 0x0518 | **+0 B** | **RED** ×3 — this is M10 restated on the rig |
|
||||
| S3 | `sitesim.py` | the campaign run on `plant=INSTANT` | −51 B | **RED** — the plant is a *term* of the figures; it was a SURVIVOR until that check was added, and finding out why produced §3's 1048/2250/0 W table |
|
||||
| S4 | `sitesim.py` | the write stagger removed from the legitimate campaign | −19 B | **RED** — "the legitimate campaign produced no opposition at all" |
|
||||
|
||||
**S3 is recorded as a survivor because it was one, and it is the most useful
|
||||
result in this table.** The scenario first shipped with a vacuity check whose
|
||||
text claimed `plant=INSTANT` would make the sweep measure 0 W. S3 was run and
|
||||
**passed**: `INSTANT` with a stagger of exactly one control cycle samples the
|
||||
plant before the second unit has stepped, so it produces opposition anyway — and
|
||||
a *larger* worst peak (2250 W) than the modelled plant does (1048 W). The claim
|
||||
in the check's own text was wrong. It was corrected against the measurement, the
|
||||
plant was pinned as an explicit term, and S3 is red now for a stated reason
|
||||
rather than by accident. §3's spread table is that measurement.
|
||||
|
||||
**M19 is recorded as a survivor because it was one.** The first battery found no
|
||||
check grading what `publish()` does when a per-unit reading is missing, so
|
||||
@@ -297,6 +389,30 @@ watches went unnoticed — the sensor's own `None` rule, defeated one layer out.
|
||||
A check was added and the mutation re-run; it is red now. The run that found it
|
||||
was purged, so it is a result and not an artefact.
|
||||
|
||||
**M20 IS RECORDED AS A SURVIVOR BECAUSE IT WAS ONE, AND IT IS THE MORE
|
||||
UNCOMFORTABLE OF THE TWO.** The commit message for this card claimed *"18
|
||||
mutations, all RED"*, and M1–M19 above have no entry for the notification's
|
||||
attribution clause. Verification on 2026-09-07 ran it and it **survived the
|
||||
whole 26-file suite**: the alarm still fires, still stops the site, still says
|
||||
the meter cannot see it — and no longer says *which two packs* or *which way
|
||||
round*. `circulation.py`'s own docstring calls that sentence the first thing
|
||||
somebody woken by this alarm needs and *"impossible to reconstruct from the
|
||||
number alone"*.
|
||||
|
||||
It survived for one reason, and it is not reachability: **M18 (the whole call
|
||||
removed) is red**, so `check_circulating()` is exercised. The three checks on
|
||||
that notification asserted `METER CANNOT SEE`, `CONTROL HAS BEEN STOPPED` and
|
||||
`isolator` — and **never an inverter name**. Nothing graded the attribution.
|
||||
|
||||
`test_watchdog.py` §6a closes it, and closes it on a fixture that can tell the
|
||||
three failures apart. It runs a roster of **`["garage", "shed", "cellar"]`** —
|
||||
operator names, none of them `UNIT_A`, for test_maintenance.py §11's reason:
|
||||
on a fixture whose names are the module's own constants a check cannot tell the
|
||||
roster from the constant. It runs the site **twice, mirrored**, so the sentence
|
||||
is not symmetric and a swap (M21, `+0 B`) is a different red from a deletion.
|
||||
And it carries a **third pack resting at 5 W**, so the floor that stops a pack
|
||||
in standby being named as a participant in a 6 kW loop (M22) is graded too.
|
||||
|
||||
## 7. What this card does NOT close
|
||||
|
||||
* **Inverter B's ramp and lag are placeholders.** Every transient number in §3
|
||||
@@ -305,7 +421,9 @@ was purged, so it is a result and not an artefact.
|
||||
margin. **Do not present any figure here as a measurement of inverter B.**
|
||||
* **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the
|
||||
notification says so; there is no software fix and this card does not pretend
|
||||
to one.
|
||||
to one. It *can* now be produced on demand: `plantsim.Inverter.wedge()`.
|
||||
* **The depth of the threshold curve is not established by anything committed.**
|
||||
§3's disclosure. The window is; the knee's magnitude is not.
|
||||
* **A loop below ~350 W per unit is not reported.** Deliberate, §3.
|
||||
* **The saturation freeze fires under a wedged unit and leaves a standing grid
|
||||
error.** Observed on the rig while producing §2's numbers: with B wedged, the
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
# SAFETY-37 — the freshness policy, and the one sentence in the card that had to be rewritten
|
||||
|
||||
**Status: DECIDED 2026-09-06.** A per-unit reading that has stopped following its
|
||||
own command is reported to its consumers as **`None`**, never as a held number.
|
||||
The verdict is a **conjunction**, not an age threshold, and this document records
|
||||
why an age threshold is not available at this layer — which is the one place the
|
||||
card's wording could not be implemented as written.
|
||||
|
||||
Filed with SAFETY-37. Card `6a9a7eda7932387c3acbd700`, scope widened by the
|
||||
architect pass of 2026-09-06 to carry the single policy for [[TEL-06]] and
|
||||
[[TEL-08]] as well.
|
||||
|
||||
Every figure below was measured or read out of this repository on **2026-09-06**
|
||||
and is re-derived by `test_freshness.py` on every run. Where a number is carried
|
||||
forward from an earlier measurement, the section and the date it was taken are
|
||||
given at the number.
|
||||
|
||||
---
|
||||
|
||||
## 0. The three faces, and which one this card built
|
||||
|
||||
| where | the defect | this card |
|
||||
|---|---|---|
|
||||
| **Layer 1** — `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` | the ingest-age veto tests the **VALUE** of `sensor.goodwe_p1_sample_age`, not its arrival. A dead publisher freezes it at a healthy number and the veto can never fire | **not touched.** It is firmware, behind a flash. §5 says what it owes |
|
||||
| **The add-on** — `app/hass.py:69-82` | returns a held value as a value. Nothing separates "read 40 %" from "still says 40 % because nothing updated it" | **built here** |
|
||||
| **The evidence** — [[HW-01]] | SoC held 40 % for 6 h 25 m while the pack drained to its floor, every detector reading healthy | the case the policy is answerable to |
|
||||
|
||||
The two consumers the card names — SAFETY-30's per-unit sign check and
|
||||
SAFETY-31's circulating-power sensor — both read `Inverter.batt` (register
|
||||
`0x0518`) and **neither had a freshness gate of any kind**. Both already do the
|
||||
right thing with a `None`: `watchdog.SignWatchdog.evaluate` **clears** its window
|
||||
on a `None` measurement, and `circulation.circulating_w` returns `None` for a
|
||||
roster with a missing term, which clears that window too. **Neither could ever
|
||||
receive one.** The gate is the piece that was missing *between* the shipped read
|
||||
and the shipped checks, which is why the change to each consumer is zero lines.
|
||||
|
||||
---
|
||||
|
||||
## 1. ⚠️ The sentence that had to be rewritten, and why saying so is the finding
|
||||
|
||||
The card says: *"A pack legitimately resting at 40 % and a dead sensor reading
|
||||
40 % are the same number; **only arrival separates them**."*
|
||||
|
||||
**For a signal this add-on polls out of Home Assistant's state cache, arrival
|
||||
does not separate them, and cannot.** `read_inputs()` runs about once a second
|
||||
(`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), and while the
|
||||
entity exists Home Assistant answers `200` with a number **whether or not the
|
||||
publisher behind it is alive**. So both members of the pair arrive, identically,
|
||||
for ever. `test_freshness.py` part 1a builds an arrival-age gate and grades it:
|
||||
the age since arrival is `0.0 s` at every instant of **both** runs. A threshold
|
||||
on it is a threshold that can never fire — which is TEL-06's own defect rebuilt
|
||||
one layer up.
|
||||
|
||||
The other half of the card's rule survives intact and is honoured absolutely:
|
||||
|
||||
> ⚠️ Home Assistant's `last_updated` does **not** move when a value is rewritten
|
||||
> unchanged, so it cannot be the source of truth.
|
||||
|
||||
Neither does `last_changed`, and this project measured on the ENV-01 rig (70 s of
|
||||
a frozen meter) and independently against the live house that `last_reported`
|
||||
does not advance on a repeat on either serialiser either —
|
||||
`p1.HomeWizardLocalSource`'s docstring records both. **`app/freshness.py` reads
|
||||
no Home Assistant timestamp at all.** Every clock in the verdict is
|
||||
`time.monotonic()` inside this process, stamped at the instant an arrival carried
|
||||
a different number. That is what "arrival-stamped" can mean here, and it is
|
||||
graded (part 1a asserts the module's body contains no `last_updated`).
|
||||
|
||||
**So the criterion was rewritten rather than approximated:** *a reading is stale
|
||||
when it has not moved through a change it was obliged to follow.* The obligation,
|
||||
not the age, is what separates the pair.
|
||||
|
||||
---
|
||||
|
||||
## 2. The verdict
|
||||
|
||||
```
|
||||
STALE ⟺ unchanged for ≥ 35.0 s
|
||||
AND |standing command now| ≥ 250 W
|
||||
AND |standing command now − standing command when the reading
|
||||
last changed| ≥ 250 W
|
||||
```
|
||||
|
||||
Latched: **only an arrival carrying a different number clears it.**
|
||||
|
||||
`0x0518` is the inverter reporting **itself** — r = 0.998 with battery power
|
||||
against r = 0.09 with the real meter (`goodwe/NOTES.md` §3.3) — and it tracks its
|
||||
own command: command and actual agreeing within 4 W once settled (§5.10,
|
||||
measured 2026-08-22), the register dithering ±1 W while holding (§5.11, same
|
||||
day). A unit told to move 250 W and still answering the same integer watt is not
|
||||
tracking.
|
||||
|
||||
### 2.1 Why a bare "unchanged for N seconds" threshold is not available
|
||||
|
||||
**Measured, not feared.** Replaying the committed 30-day capture of this house's
|
||||
own 1 W-resolution power entity — `scenarios/p1-net-30d-2026-08/`, 485,841
|
||||
samples straight out of Home Assistant's recorder, where a row exists only when
|
||||
the state string **changed**, so the gap between two rows *is* the unchanged
|
||||
duration every consumer saw:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| longest unchanged run over 30 days | **263.2 s** (held −899.0 W, 2026-08-11 17:04) |
|
||||
| runs ≥ 35 s | 10 of 485,840 (0.0021 %) |
|
||||
| runs ≥ 17 s | 386 of 485,840 (0.0794 %) |
|
||||
|
||||
A real power figure at 1 W resolution held **one value for four and a half
|
||||
minutes with nothing whatever wrong**. So an unchanged threshold free of false
|
||||
positives must be over 263 s — and 263 s is **3.5× too slow** to protect a
|
||||
consumer that trips at 75 s. There is no admissible threshold. The conjunction
|
||||
is not a refinement; without it the card has no implementable verdict.
|
||||
|
||||
⚠️ **That is the METER's register, not `0x0518`, and it is used as a surrogate
|
||||
and named as one.** No 30-day capture of the inverter's own power exists — it is
|
||||
not in the recorder export. The single thing this measurement is used for is the
|
||||
statement above: that the legitimate unchanged duration of a power entity in this
|
||||
house is *minutes*. It is not used to set the bound.
|
||||
|
||||
⚠️ **And every one of those long runs happened under an unmoving load** — the
|
||||
worst is 263 s of a steady −899 W export, a house doing one thing. That is why
|
||||
the conjunction keys on command travel: none of them arms it.
|
||||
|
||||
### 2.2 Why the magnitude term exists — a surviving mutation put it there
|
||||
|
||||
Deleting `abs(standing_w) < move_w` from `_grade()` left **every check in the
|
||||
first draft green**, because in all of them a command that had *travelled*
|
||||
250 W had also *ended up* above 250 W. The case that separates them is a site
|
||||
being brought to **rest**: the write path is serialised, so a register can reach
|
||||
its new value on the same read at which the *old* command is still standing —
|
||||
`standing_at_change` then captures 1300 W, the site is commanded to 0 W, and the
|
||||
travel term alone reads 1300 W of unfollowed command for as long as the site
|
||||
rests. It would call a perfectly truthful +5 W reading dead. The magnitude term
|
||||
is what says *a site at rest is not owed movement*, and part 4 now grades it.
|
||||
|
||||
**The hole that term leaves cannot mislead either consumer**, which is why it is
|
||||
the right trade: a unit commanded under 250 W is one `watchdog.disagrees()` is
|
||||
`False` for whatever the measurement says, and a frozen term beside a live unit
|
||||
that is itself resting nets to **zero** circulating power rather than to a false
|
||||
alarm. Both halves are asserted, not argued.
|
||||
|
||||
---
|
||||
|
||||
## 3. The bounds — one per signal, each with its derivation
|
||||
|
||||
⚠️ **One constant for both signals would be useless on SoC or trigger-happy on
|
||||
power**, which is the third clause of the policy. `freshness.BOUNDS` carries them
|
||||
as data with a `wired` column, and `test_freshness.py` part 7 walks the AST of
|
||||
every shipped module to check that column tells the truth.
|
||||
|
||||
### 3.1 `batt` — `0x0518`, S16, 1 W, published every 10 s — **WIRED**
|
||||
|
||||
**Floor, 17.0 s** — the fastest a healthy register can be *obliged* to have
|
||||
answered. Three terms, each read out of this repository:
|
||||
|
||||
| | | source |
|
||||
|---|---|---|
|
||||
| 6.0 s | the plant | `goodwe/NOTES.md` §5.9, measured 2026-08-22 with the poll temporarily at 1 s: 26 W at +1.43 s, 469 W (94 %) at +3.34 s, settled 507 W at +6.05 s |
|
||||
| 10.0 s | the publisher | `firmware/goodwe-master.yaml:181`, `update_interval: 10s` on the `modbus_controller` carrying both `0x0518` and `0x050E` |
|
||||
| 1.0 s | this add-on's read period | `main.run_control()`'s `await asyncio.sleep(1)` |
|
||||
|
||||
**Ceiling, 75.0 s** — the tighter of the two consumers, both read out of the
|
||||
shipped `config.yaml`: `circulation_hold_s` **75** (line 512) and
|
||||
`unit_sign_disagree_s` **85** (line 451). A gate slower than its consumer hands
|
||||
over the frozen number and explains afterwards.
|
||||
|
||||
**Chosen: 35.0 s.** The admissible band `[2 × 17.0, 0.5 × 75.0] = [34.0, 37.5]`
|
||||
contains four integers, and **the band is what is derived — 35 is a choice inside
|
||||
it**, deliberately not 30 (that is `validate.FIRMWARE_INGEST_VETO_S`, and would
|
||||
read as a borrowed constant nobody re-derived) and not 60 (round). Part 6
|
||||
computes both ends **from `config.yaml`** and goes red if an operator default
|
||||
moves: drop `circulation_hold_s` to 60 and this fails.
|
||||
|
||||
**`MOVE_W` = 250 W** is the **largest** admissible value, not a sensitivity
|
||||
preference. The gate must arm everywhere a stale reading could mislead a
|
||||
consumer; the earliest either consumer speaks is `unit_sign_disagree_w`, 250 W
|
||||
(config.yaml:450), because below that `watchdog.disagrees()` is `False` whatever
|
||||
the measurement says. Taking the ceiling of the admissible range minimises
|
||||
spurious arming. It is pinned to `config.yaml` by part 6, not to a literal.
|
||||
|
||||
### 3.2 `soc` — `0x050E`, U16, 1 %, published every 10 s — **STATED, NOT WIRED**
|
||||
|
||||
SoC **cannot use the conjunction above.** A pack resting at its floor holds one
|
||||
percent for hours with nothing wrong — the card's own example — and the standing
|
||||
command says nothing about it, because 0 W is exactly what a rested pack is
|
||||
given. The obliged movement for SoC is **energy, not command travel**: one
|
||||
percent of pack A is ≈ **153.6 Wh** (15.36 kWh nominal, `goodwe/NOTES.md` §1.1b's
|
||||
table — ⚠️ **NOMINAL and FROM DATASHEET**, not a measured usable capacity), so at
|
||||
pack A's 5000 W ceiling one percent takes at least **110 s**, and at a realistic
|
||||
1000 W about **553 s**.
|
||||
|
||||
So the bound is *"SoC unchanged while ∫|battery power| dt since its last change
|
||||
exceeded K × 153.6 Wh"*, and **K cannot be chosen from anything written down
|
||||
today**. Wiring it with a guessed K would be a gate that fires on a rested pack,
|
||||
which is the hazard, not the fix. `Freshness` takes its obligation term from the
|
||||
caller precisely so an energy integral can be handed in where command travel is
|
||||
handed in for `batt` — TEL-08 needs no second mechanism.
|
||||
|
||||
---
|
||||
|
||||
## 4. What changes on a running install
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| healthy register, N=1 | value reaches consumers | **identical** — 60 reads of a dithering register under a reversing command, asserted in part 5 |
|
||||
| healthy register, N≥2 | value reaches consumers | identical |
|
||||
| **frozen register, N=1** | reads as a healthy measurement, for ever | **site stops**: `inputs_ok` False → `stale_input_s` hold → 0 W, and keeps commanding |
|
||||
| **frozen register, N≥2** | per-unit sign check false-trips at 85 s and parks a healthy inverter; circulation sensor reports a loop that is not happening | both consumers get `None`, both windows **clear** |
|
||||
|
||||
⚠️ **The N=1 row is a behaviour change on the roster that is running in a house
|
||||
today, and it is named rather than hidden.** It is also the point of the card:
|
||||
HW-01 was a one-inverter site. The direction it fails in is the safe one — a
|
||||
false verdict commands 0 W and keeps commanding (§11.1 rule 1), behind the
|
||||
existing `stale_input_s` hold, and clears itself on the register's next move.
|
||||
|
||||
The counterfactual is run, not argued. `test_freshness.py` part 3 drives real
|
||||
`cycle()` calls at N=2 with A's register frozen at −1200 W and B's live at
|
||||
+900 W, the command travel produced by the shipped `compute()` and allocator:
|
||||
|
||||
```
|
||||
standing on A: [0, 445, 890, 1335, 1335, ...] (the shipped control law)
|
||||
gated : sign trip tick=None circulating_w=[1800, 1800, 1800, None, None, ...]
|
||||
counterfact : sign trip tick=11 circulating_w=[1800, 1800, 1800, 1800, 1800, ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. What TEL-06 and TEL-08 still owe
|
||||
|
||||
**[[TEL-06]] — layer 1, Blocked, and nothing in this card can reach it.** The
|
||||
ESPHome veto tests the *value* of `sensor.goodwe_p1_sample_age`. This policy
|
||||
lives in the add-on, on the other side of a flash, and cannot help. What TEL-06
|
||||
owes is the same mechanism **inside the firmware**: a `millis()` stamp taken when
|
||||
the setpoint/age entity is *written*, and a veto on `millis() − last_write_ms`
|
||||
rather than on the published number. It is narrowed to that one substitution —
|
||||
the policy question ("arrival, not value") is answered here; the remaining work
|
||||
is a firmware stamp and a flash, which is a supervised human step.
|
||||
|
||||
**[[TEL-08]] — stale SoC.** It owes exactly three things, and no mechanism:
|
||||
1. a **measured** usable capacity for pack A (or a defensible bound on it) — the
|
||||
153.6 Wh per percent above is nominal and from the datasheet;
|
||||
2. the **energy integral** since the reading last changed;
|
||||
3. the constant **K** — how many percent-equivalents of throughput must pass
|
||||
before an unmoved SoC is a fault.
|
||||
|
||||
It should consume `freshness.Freshness` with `BOUNDS["soc"]` filled in, not
|
||||
restate the policy. The row is already in the table with `wired=False` and the
|
||||
reason, and part 7 goes red the day that column stops being true.
|
||||
|
||||
**⚠️ Neither of them, and not this card, closes HW-01's whole class.** See §6.
|
||||
|
||||
---
|
||||
|
||||
## 6. Known survivors, with their size
|
||||
|
||||
Graded in `test_freshness.py` part 9, so they are residuals rather than
|
||||
surprises.
|
||||
|
||||
| # | kind | what survives | size |
|
||||
|---|---|---|---|
|
||||
| 1 | **false positive** | an inverter physically unable to follow — saturating, thermally derating, or a BMS refusing a charge — is declared stale while telling the truth | one spurious **site stop**, behind `stale_input_s`, self-clearing the moment the register moves. It cannot drive the battery anywhere |
|
||||
| 2 | **miss** | a register frozen under a command that never travels 250 W is never detected, for 400 s or for ever | ⚠️ scoped for ONE consumer, NOT both, and the earlier claim here was wrong. TRUE for the per-unit sign check: no such check can speak about that unit (`unit_sign_disagree_w` = 250 W). **FALSE for circulation.** "Contributing at most 250 W" bounds the COMMAND; once the register is frozen its REPORTED value is unrelated to the command, which is exactly HW-01's shape - commanded ~0 W, register stuck on the last large number. Measured: a unit frozen at −1200 W beside a healthy +900 W sibling yields `circulating_w` = **1800 W against the 700 W floor, 2.6× over**, with no gate armed. Found in review; part 9's S2 check asserts only `not stale` and part 4 pairs the frozen unit with a RESTING sibling, so neither graded the sentence |
|
||||
| 3 | **miss — the largest** | a dead publisher whose last values **alternate**, or a cache replaying two old samples, never starts the unchanged clock at all | **this gate detects a value that STOPPED; it cannot detect a value that is WRONG.** Nothing in this add-on can. TEL-06's arrival stamp inside the firmware is the only layer that could |
|
||||
|
||||
Recorded because it looks like a survivor and is not: a publisher failure that
|
||||
freezes **both** registers is caught on both units independently, on each unit's
|
||||
own command travel and each unit's own number.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mutation record — 2026-09-06
|
||||
|
||||
`tools/mutation_run.sh`'s rule, honoured on every run: purge first (count printed,
|
||||
`0` remaining), tree mounted `:ro`, `python3 -B`, fingerprint the tree, **and
|
||||
fingerprint again after the revert**. Clean tree `app/freshness.py`
|
||||
`sha256:f40fa3e8819014ce` / 20,065 B, `app/inverter.py` `7e760c6e8cdfa5b7` /
|
||||
30,778 B, `app/main.py` `7d7ff4ba0697ea64` / 238,535 B. Each mutant graded by
|
||||
`test_freshness.py` + `test_inverter.py`.
|
||||
|
||||
| # | mutation | file | Δ B | result |
|
||||
|---|---|---|---|---|
|
||||
| M0 | none (control) | freshness | +0 | **green** — the harness is not red on everything |
|
||||
| M1 | `reading()` returns `self.last` unconditionally | freshness | −24 | red, 3 named checks |
|
||||
| M2 | drop the command-travel term | freshness | −59 | red |
|
||||
| M3 | drop the magnitude term | freshness | −54 | **SURVIVED**, then red — §2.2. This is the finding |
|
||||
| M4 | latch never holds | freshness | +13 | red, 3 named checks |
|
||||
| M5 | bound 90 s (slower than both consumers) | freshness | **+0** | red |
|
||||
| M6 | bound 10 s (under the physical floor) | freshness | **+0** | red |
|
||||
| M7 | `MOVE_W` 700 W | freshness | **+0** | red, on part 6's `config.yaml` pin |
|
||||
| M8 | the wiring in `_read_unit` removed | main | −5 | red, 3 named checks |
|
||||
| M9 | reset the unchanged clock on every arrival (value-stamped) | freshness | −43 | red |
|
||||
| M10 | `standing_w()` drops `last_write` | inverter | −73 | red, and takes `test_inverter.py` with it |
|
||||
| M11 | `standing_at_change` refreshed on every arrival | freshness | +121 | ⚠️ **DISCOUNTED — the mutant does not parse** |
|
||||
| M11b | the same defect, written parseably | freshness | +105 | red |
|
||||
| M12 | `BOUNDS["soc"].wired = True` — the table lies | freshness | −1 | red |
|
||||
| M13 | the wrong signal submitted (`inv.soc` for `inv.batt`) | main | +4 | red, and `test_inverter.py` too |
|
||||
| M14 | one `Freshness` shared by every unit | inverter | +29 | red |
|
||||
| M15 | latch cleared by a failed read | freshness | +31 | **SURVIVED**, then red — see below |
|
||||
|
||||
⚠️ **M5, M6 and M7 are `+0 B`, which MUTATION.md names as the risky delta.** All
|
||||
three are **reds**, which is the direction stale bytecode cannot fabricate, and
|
||||
every run purged with the count printed and graded a `:ro` tree that physically
|
||||
cannot hold a cache.
|
||||
|
||||
⚠️ **M11 IS NOT A RESULT AND IS NOT COUNTED.** Its edit dedents a statement into
|
||||
the middle of an `if` body, so the module raises `IndentationError` at line 312
|
||||
and both files go red without a single check being evaluated. Verified by
|
||||
`ast.parse` rather than assumed. **M11b** is the same defect written so it
|
||||
parses, and it is a real red.
|
||||
|
||||
### The two survivors, and what each one taught
|
||||
|
||||
**M3, the magnitude term** — see §2.2. Two terms of a conjunction that move
|
||||
together in every fixture are one term as far as the checks are concerned.
|
||||
Recorded as `MUTATION.md` **habit 17**, twelfth instance of "a check whose DATA
|
||||
cannot reach the failure it names".
|
||||
|
||||
**M15, the latch cleared by a failed read** — found only because the sweep went
|
||||
looking for what the checks would NOT see. No fixture had ever injected a read
|
||||
failure *while latched*, and it matters: a stale verdict stops the site, the site
|
||||
is then commanded 0 W, and one unreachable-Home-Assistant read would drop the
|
||||
latch into a state where the conjunction is no longer armed — so the held
|
||||
−1200 W would come back to the consumers on the very next successful read. Part 4
|
||||
now grades exactly that and M15 is red.
|
||||
|
||||
### And one process defect worth more than the ledger
|
||||
|
||||
One mutation's container run overran a two-minute command budget and the wrapper
|
||||
was killed **between the edit and the revert**, leaving `app/freshness.py`
|
||||
mutated on disk. Nothing announced it; the next mutation's harness cheerfully
|
||||
fingerprinted and graded the mutant *plus* a second mutation. It was caught by
|
||||
comparing the tree fingerprint against the recorded clean one, restored, and
|
||||
verified back to byte and `sha256` before anything else ran. Same defect as stale
|
||||
bytecode, different cause. Recorded as `MUTATION.md` **habit 18**: *fingerprint
|
||||
after the revert, not only before the run.*
|
||||
|
||||
### A third, in this file's own fixture
|
||||
|
||||
M2 originally reported "red" with **no named check behind it**. The mutant makes
|
||||
the FRESH run stale, so `test_freshness.py`'s own diagnostic
|
||||
`print(f"… {sorted(set(_fresh_read))}")` raised `TypeError` comparing `None` with
|
||||
a float **before check one ran**. MUTATION.md's reporting rule is *"which suite
|
||||
went red, and on which named check"*, and a print that can crash first destroys
|
||||
exactly that. Sorted with `key=str` now; M2 reports its check.
|
||||
|
||||
### Re-verified at the committed head
|
||||
|
||||
The 16-row sweep above graded `test_freshness.py` as it stood when each mutation
|
||||
ran; the two closures (M3's part-4 check, M15's failed-read check), the `key=str`
|
||||
fix and the wall-clock hardening all landed afterwards. So six mutations were
|
||||
re-run against **the exact tree that is committed** —
|
||||
`test_freshness.py sha256:a62f3e1f1ac856cf`, 44,913 B:
|
||||
|
||||
| # | result at head |
|
||||
|---|---|
|
||||
| M0 control | **green** |
|
||||
| M2 drop the command-travel term | red, on part 1's "FRESH AND UNCHANGED is not stale" |
|
||||
| M3 drop the magnitude term | red, on part 4's magnitude check |
|
||||
| M8 wiring removed | red, on three named checks in parts 2 and 3 |
|
||||
| M11b `standing_at_change` drifts | red, on three named checks in part 1 |
|
||||
| M15 latch cleared by a failed read | red, on part 4's failed-read check |
|
||||
|
||||
The other ten are unaffected by those edits: each is red on a check in parts 1,
|
||||
2, 4, 6, 7 or 9 that none of them touches, and a mutation red on one check
|
||||
cannot be made green by adding another.
|
||||
|
||||
### The wall clock, which part 3 cannot avoid and therefore measures
|
||||
|
||||
The freshness verdict runs on an injected monotonic clock and is deterministic.
|
||||
The sign watchdog inside `cycle()` cannot be injected, so part 3 runs against the
|
||||
real one — which is CI-06's failure mode exactly (`test_peakguard.py` answered
|
||||
two checks with the wall clock, flaked at ~20 %, and cost a wrong merge
|
||||
diagnosis). So the margin is measured and asserted rather than assumed:
|
||||
|
||||
gate closed after 0.61 s of WALL clock, against the 2 s watchdog window (3.3x margin)
|
||||
|
||||
If a loaded machine ever narrows that, the check fails **naming the number**,
|
||||
which is the difference between "the box was slow" and "the code is broken".
|
||||
@@ -0,0 +1,807 @@
|
||||
# SAFETY-42 — a unit that stops delivering its share, the discriminator the card asked for, and why that discriminator does not exist
|
||||
|
||||
**Status: DECIDED 2026-09-06.** A new per-inverter check, `app/delivery.py`,
|
||||
names an inverter that is **standing at a discharge share it is not producing**
|
||||
while at least one sibling is not in that state and nothing this add-on can read
|
||||
explains it. It **reports and does not act**. At a roster of one it is inert by
|
||||
construction.
|
||||
|
||||
⚠️ **This record also FALSIFIES the discriminator the card proposed.** SAFETY-42
|
||||
asked for *"saturation persisting past N while |grid error| stays above X, with N
|
||||
and X derived"*. That predicate was built and run on the rig before anything was
|
||||
written, and it fails in **both** directions — not for want of better N and X,
|
||||
but because of what the quantity is. §2 is the measurement. This is the
|
||||
SAFETY-39 shape: saying the criterion is wrong was worth more than five
|
||||
near-misses at satisfying it.
|
||||
|
||||
Filed with SAFETY-42, card `6a9d498a49082c0086e525c1`.
|
||||
|
||||
---
|
||||
|
||||
## 0. What is uncovered, and why it is structural rather than a threshold
|
||||
|
||||
A unit delivering **0 W against a non-zero share** is seen by nothing:
|
||||
|
||||
| guard | why it is silent |
|
||||
|---|---|
|
||||
| [[SAFETY-30]] per-unit sign | gates on **opposition ≥ 250 W on both sides**. A unit at 0 W never opens it — it is not opposing, it is **absent** |
|
||||
| SAFETY-30 site sign | the sum still **agrees with the meter in direction**; only the magnitude is short |
|
||||
| [[SAFETY-31]] circulation | `Σ\|Bᵢ\| − \|ΣBᵢ\|` is **zero** when one unit does nothing — circulation needs two units pulling *against* each other |
|
||||
| the saturation freeze | **fires** — and `clamp.py`:93-96 says of exactly this: *"A loop can be legally saturated all day — a cold pack, a curtailed inverter, an export limit — so a saturation-shaped alarm is one an operator learns to ignore"* |
|
||||
|
||||
So the one signal that does fire is the one this codebase documents as
|
||||
unreadable, and that documentation is **right**. This card therefore needed a
|
||||
different signal, not a louder one.
|
||||
|
||||
It is [[HW-01]]'s shape. On 2026-09-02 the comms subsystem failed and the power
|
||||
stage went on executing its last instruction for **6 h 25 m** with all three
|
||||
layers spectators. At N=2 the same fault leaves a healthy sibling regulating
|
||||
alone, the site short by up to one unit's share, and nothing saying so.
|
||||
|
||||
## 1. The rig
|
||||
|
||||
Everything in §2 and §3 was measured on `admin/goodwe`'s two-master rig
|
||||
(TEST-19), used **read-only** and run container-free, from a harness in a
|
||||
scratch tree. Real and imported, never transcribed: the control law
|
||||
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
|
||||
`allocator.plan`), the per-unit envelope the shipped `supervised` and `device`
|
||||
claims build, the sign predicate (`watchdog.disagrees`), the circulating sensor
|
||||
(`circulation.circulating_w`), the check under test (`delivery.delinquent`,
|
||||
`short_by`, `account`), the wire (`frame_check.setpoint_atomic`, the firmware's
|
||||
own encoder, in and back out through `plantsim.parse`/`decode`), and the plant
|
||||
(`plantsim.Plant`). The per-unit reading is **holding register 0x0518**, the one
|
||||
the live `batt_entity` binds.
|
||||
|
||||
Modelled, and therefore not evidence about the thing modelled: the master's
|
||||
failsafe machine (absent), and the two fault injections —
|
||||
|
||||
* **wedged** — the write never reaches the plant, so its latched command stands.
|
||||
Started at 0 W, which is *"delivering 0 W against a non-zero share"*.
|
||||
* **at its floor** — the write arrives and `Plant.limit()` zeroes it, because
|
||||
`soc <= soc_min`. This is the benign case the acceptance criterion names.
|
||||
|
||||
### ⚠️ Inverter B does not physically exist
|
||||
|
||||
`plantsim.INVERTER_B` is **inverter A copied**, `measured=False` (NOTES.md
|
||||
§1.1b; the datasheet-and-serial figures 4600 W / 20.48 kWh / floor 10 % are
|
||||
deliberately *not* in the model). **Every two-inverter figure in this document
|
||||
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
|
||||
margin that costs.
|
||||
|
||||
### Reproducing it — ⚠️ THE HARNESS IS COMMITTED NOW (TEST-21, 2026-09-07)
|
||||
|
||||
⚠️ **THIS SECTION USED TO SAY THE HARNESS WAS NOT COMMITTED, AND THAT WAS THE
|
||||
DEFECT TEST-21 FOUND.** A shipped safety constant whose floor the next reviewer
|
||||
cannot re-run is PROC-01's shape, and PROC-01 had eleven instances in three
|
||||
days. The paragraph this replaces was true — what the harness does *is* small
|
||||
enough to restate — and it was not enough: a restatement in prose leaves every
|
||||
axis it does not name to the next person's judgement, and §3.3 below now records
|
||||
how far that moves the number. **It moved. See §3.3's second table.**
|
||||
|
||||
It is committed in the **other** repository, `admin/goodwe`, as
|
||||
`sim/delivery_window.py`, beside `sitesim.py` — the container-free two-master
|
||||
harness it is built on. From clean checkouts of both repos:
|
||||
|
||||
```
|
||||
cd goodwe/sim
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --selftest
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --campaign --blag
|
||||
```
|
||||
|
||||
stdlib only. No container, no HA, no esphome, **no rig and no rig lock** — it is
|
||||
a `plantsim` campaign, not a rig job. About fifteen minutes for the whole thing.
|
||||
|
||||
⚠️ **AND IT IS IN `admin/goodwe` RATHER THAN HERE, WHICH IS CI-08's RULE
|
||||
APPLIED RATHER THAN IGNORED.** It imports `plantsim` (the plant, the per-unit
|
||||
wire, `Site`) and `frame_check` (the firmware's own encoder), both **owned by
|
||||
that repo** and neither present in this one's CI image. Committed here it had
|
||||
two possible fates and both are the coupling CI-08 has been broken by three
|
||||
times: skipped for want of its imports — a green that establishes nothing — or
|
||||
the plant and the encoder **restated in this tree**, which is a second copy of
|
||||
something the other repo owns, free to drift, and drift in a fixture is
|
||||
invisible until the number it defends is wrong. There the dependency runs one
|
||||
way and stays one-directional: **this** repo's `control`, `arbiter` and
|
||||
`delivery` are imported live out of whichever checkout is under test
|
||||
(`$GOODWE_ADDON_REPO`), never copied — so a change to `delinquent()` moves the
|
||||
number on the next run instead of silently disagreeing with a fixture. That is
|
||||
the seam `sitesim.py` already stands on, which is the other reason the campaign
|
||||
is its neighbour and not a third arrangement.
|
||||
|
||||
⚠️ **THE SELFTEST IS CI-GATED; THE CAMPAIGN IS NOT, AND THE SPLIT IS
|
||||
DELIBERATE.** `admin/goodwe`'s `.gitea/workflows/checks.yml` gained one step —
|
||||
`python3 -B delivery_window.py --selftest`, seconds, beside the existing
|
||||
`norig, no lock` TEST-19 and TEST-20 steps, in the workflow that **already**
|
||||
checks out this repo for `two_writers` and accepts that boundary in writing. So
|
||||
a change **here** that moved `saturation_w`, `max_w`, `delivery_hold_s` or the
|
||||
shape of `delinquent()` turns that step red rather than leaving a stale number
|
||||
in this record. The **campaign** (`--campaign --blag`, ~15 minutes) is not
|
||||
gated: it is a campaign, not a gate, and `failure-injection.yml`'s jobs run on
|
||||
the **rig** runner, which a container-free simulation has no business holding.
|
||||
What TEST-21 asked for is a command a reviewer can run from a clean checkout —
|
||||
that is the block above.
|
||||
|
||||
⚠️ **`--selftest` IS A FALSIFIABILITY ARM, NOT A SMOKE TEST, AND IT RUNS
|
||||
FIRST.** A campaign whose N=1 column and whose flapping column are 0.0 s
|
||||
everywhere is indistinguishable from a campaign whose detector was never wired
|
||||
up — `sitesim.broadcast_onset` exists for the same reason. So the selftest
|
||||
drives a genuinely **wedged** unit through the same loop and refuses to proceed
|
||||
unless the shipped `delinquent()` names it for longer than the shipped hold
|
||||
(it names it for 275.0 s). Every 0.0 s in §3.3 is only worth reading because
|
||||
that arm passes.
|
||||
|
||||
What it does, unchanged from the restatement it replaces: build two
|
||||
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`; close the loop with the
|
||||
add-on's own `compute()` on the SITE sum (`Controller.site_batt_w`) and
|
||||
`arbiter.allocate_record()` over per-unit envelopes of `±min(max_w, rating)`;
|
||||
put each share on its own wire through `Inverter.command()`, honouring a
|
||||
configurable **write stagger** so B's number can land up to four cycles after
|
||||
A's (the serialised write path); advance `Site.settle()` one control cycle; and
|
||||
record, per unit, the command standing *before* the write and that unit's 0x0518
|
||||
power. Then grade each cycle with the **shipped** `delivery.delinquent()` and
|
||||
take the **longest run of consecutive cycles** on which it names anybody — not a
|
||||
count of naming samples, because the window is cleared by any cycle that does
|
||||
not.
|
||||
|
||||
⚠️ **SINCE [[SAFETY-43]] THE ROWS OF §3.3's TABLE ARE GRADED WITH
|
||||
`share_frac=None`** — `delinquent()`'s ABSOLUTE arm alone, which is this card's
|
||||
rule exactly. Said here because it is the difference between §3.3 still meaning
|
||||
what it is labelled as and quietly becoming a table about a different predicate:
|
||||
SAFETY-43 added a SECOND way to be named, the harness grades it in its own rows
|
||||
(`--fractions`), and the numbers below are unchanged and were re-measured to
|
||||
confirm it. `DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` §3 carries
|
||||
those rows and what they cost — the worst legitimate episode goes from 35.0 s to
|
||||
40.0 s at the shipped fraction, which does not move `delivery_hold_s`.
|
||||
|
||||
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
|
||||
command standing before the write" is the **controller's** standing command —
|
||||
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
|
||||
wire. A write that has been issued but has not landed yet is standing as far as
|
||||
this add-on is concerned, which is the whole of the write-stagger axis. Grading
|
||||
against the plant's latch instead reads a number no add-on has, and it makes
|
||||
both the stagger *and a wedged unit* invisible: a unit whose writes never arrive
|
||||
shows a standing command of 0 W, is never `asked`, and is never named. That is
|
||||
not a conservative harness, it is a harness that cannot fail — and the rebuild
|
||||
did exactly this on its first run, which is how the falsifiability arm earned
|
||||
its place above.
|
||||
|
||||
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
|
||||
trees. Run it with `python3 -B` and purge.
|
||||
|
||||
## 2. The card's discriminator, measured — and it does not work
|
||||
|
||||
### 2.1 It fires on the benign case, and no (N, X) can stop it
|
||||
|
||||
`Plant.limit()` returns 0 W for a discharge command into a pack at its own
|
||||
floor. A wedged unit that never receives its command also produces 0 W. The two
|
||||
are not merely similar:
|
||||
|
||||
> **Load 2000 W, equal weights, 2000 W per-unit cap, 120 control cycles.
|
||||
> The two arms are IDENTICAL on all 120 cycles** in (grid, site target, each
|
||||
> unit's share, each unit's measured power), to the last emitted watt. Both
|
||||
> report `reason == "saturated-freeze"` on **116 of the 120** cycles (the other
|
||||
> four are `slew-limited`, before it engages on cycle 4), both hold |grid error|
|
||||
> over 300 W on **all 120** and settle at **1200 W**, and `circulating_w` is
|
||||
> **0.0 W** in both.
|
||||
|
||||
A predicate whose inputs are *saturation duration* and *|grid error|* reads the
|
||||
same number on both arms at every cycle. There is no N and no X. And *"a
|
||||
legitimately saturated site — cold pack at its floor — does **not** alarm"* is
|
||||
this card's own acceptance criterion.
|
||||
|
||||
`test_delivery.py` part 4 asserts that identity on the add-on's own end-to-end
|
||||
fixture, so it goes red if it ever stops being true.
|
||||
|
||||
### 2.2 It is silent on the headline case, because a healthy sibling erases it
|
||||
|
||||
The standing grid error is exactly the thing a working sibling removes.
|
||||
|
||||
> **Load 800 W, equal weights, 2000 W per-unit cap, B wedged, 200 cycles.**
|
||||
> Over the last 30 cycles the loop is saturation-frozen on **30 of 30** — so
|
||||
> "saturation persisting past N" is true for every N — while the worst
|
||||
> **|grid error| in that window is 30 W**. B is standing at a **+770 W**
|
||||
> discharge share and delivering **0 W**.
|
||||
|
||||
Any X above 30 W leaves the card's predicate silent for the whole run, on the
|
||||
exact fault it was written for. The card's own sentence — *"a healthy sibling
|
||||
regulating alone, the site short by up to one unit's rating"* — describes the
|
||||
case, and the grid error is the one number that cannot report it.
|
||||
|
||||
### 2.3 What that leaves
|
||||
|
||||
The derivation instruction stands and is honoured in §3. What could not be
|
||||
derived was a threshold on the wrong quantity. Both failures are written into
|
||||
`delivery.py`'s docstring as well, because the next person to read the card will
|
||||
propose the same predicate.
|
||||
|
||||
## 3. The quantity that does work
|
||||
|
||||
Per unit, for a share that asks it to **discharge**:
|
||||
|
||||
```
|
||||
short_by(i) = share_i − measured_i # watts that did not arrive
|
||||
```
|
||||
|
||||
and the reduction that makes it readable:
|
||||
|
||||
> a unit is **delinquent** while it is short by more than `saturation_w` with
|
||||
> **no account for it**, and the set of units in that state is a **proper
|
||||
> subset** of the units that were asked to discharge.
|
||||
|
||||
### 3.1 The account — the only thing that separates a wedged unit from a cold pack
|
||||
|
||||
§2.1 says there is nothing in the power domain. There is exactly one non-power
|
||||
fact this add-on holds about a pack: **its own state of charge against its own
|
||||
`bms_soc_floor`**, which SAFETY-32 made a required per-entry key. `account()`
|
||||
asks it through **`peakguard.discharge_permitted()`** — public since SAFETY-32
|
||||
precisely so that this question has one answer — and writes its own sentence,
|
||||
because that function's wording names the *peak guard's* floor and would be a
|
||||
false statement here.
|
||||
|
||||
Three things account for a shortfall, and each is a state already being handled:
|
||||
|
||||
| account | why it is not this fault |
|
||||
|---|---|
|
||||
| the unit is not live | `live_units()` has already dropped it from the split and the action log says so — firing inside that is the SAFETY-15/19 failure |
|
||||
| the unit is parked by a sign trip | it is commanded 0 W on purpose; not delivering is compliance |
|
||||
| `discharge_permitted(soc, bms_soc_floor)` | the pack is at or below its own cut-out and physically cannot |
|
||||
|
||||
⚠️ **The floor passed in is `bms_soc_floor` alone, NOT
|
||||
`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`.** That
|
||||
conjunction is right for the peak guard and wrong here: `peak_soc_floor` bounds
|
||||
a money-saving discharge only (SAFETY-25), ordinary self-consumption discharges
|
||||
straight past it, and using it would **explain away** a wedged unit sitting
|
||||
anywhere between the two floors.
|
||||
|
||||
⚠️ **Every unknown accounts for the shortfall**, i.e. fails toward silence —
|
||||
the opposite direction to `discharge_permitted()`'s own caller, and the right
|
||||
one here: there it withholds a discharge, here it withholds an **alarm**. The
|
||||
cost is bounded, because the same absence stops the site through `inputs_ok`
|
||||
(an unreadable SoC) or is refused by the roster schema (an unstated floor).
|
||||
|
||||
### 3.2 The proper subset — what only exists at N ≥ 2
|
||||
|
||||
A derate this add-on cannot see — thermal, grid-code, a DC-side restriction —
|
||||
hits **every** unit that was asked. One box that has stopped listening does not.
|
||||
So if every asked unit is short and unexplained, this module says **nothing**:
|
||||
it has no instrument that tells that from a plant which simply cannot deliver,
|
||||
and reporting it anyway would be the saturation-shaped alarm produced by the
|
||||
guard written to replace it.
|
||||
|
||||
**At N = 1 the subset is never proper, for every input**, so the check is inert
|
||||
there — not by a roster guard, but by the algebra, the same way
|
||||
`circulation.circulating_w()` is identically zero at N ≤ 1. That is AC 5, and
|
||||
§3.3's control arm measures it rather than asserting it.
|
||||
|
||||
### 3.3 The window — the one number this card chose
|
||||
|
||||
**2640 closed-loop runs at a roster of two 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(0,5000)` (0 = follow the rail), across three families:
|
||||
|
||||
* **1050 load reversals** — 7 sizes (±300 W … ±5000 W) × 5 write staggers;
|
||||
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes;
|
||||
* **750 weighted reversals** — 5 splits (0.5, 0.7, 0.9, 0.99, 0.1) × 5 staggers
|
||||
on a ±2200 W reversal. ⚠️ **This family did not exist in SAFETY-30's campaign
|
||||
and it is the one that dominates here**, for a mechanical reason: an
|
||||
asymmetric split gives one unit a much larger step than its sibling, so the
|
||||
big one is still ramping while the small one has settled — which is precisely
|
||||
the *proper subset* this check keys on.
|
||||
|
||||
Every run is executed a second time at a **roster of one** on the same stimulus.
|
||||
**The N=1 arm is the control**, and it is what separates *"a property of the
|
||||
check"* from *"a property of the second inverter"*.
|
||||
|
||||
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
|
||||
threshold. **ORIGINAL CAMPAIGN, 2026-09-06, from the harness that was not
|
||||
committed — SUPERSEDED, and kept here because a record that quietly replaced its
|
||||
own numbers would be worth less than one that shows both:**
|
||||
|
||||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 100 W | 40.0 s (608) | 0.0 s (0) | 75.0 s (576) | **0.0 s (0)** |
|
||||
| 250 W | 55.0 s (490) | 0.0 s (0) | 60.0 s (480) | **0.0 s (0)** |
|
||||
| **500 W** | **20.0 s (206)** | **0.0 s (0)** | **30.0 s (368)** | **0.0 s (0)** |
|
||||
| 700 W | 20.0 s (125) | 0.0 s (0) | 20.0 s (174) | **0.0 s (0)** |
|
||||
| 1000 W | 15.0 s (70) | 0.0 s (0) | 15.0 s (111) | **0.0 s (0)** |
|
||||
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
|
||||
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
|
||||
|
||||
#### ⚠️ TEST-21, 2026-09-07 — THE RE-RUN, AND 30.0 s DID NOT REPRODUCE
|
||||
|
||||
`admin/goodwe:sim/delivery_window.py --campaign`, same three families, same
|
||||
counts, same thresholds, the shipped `delinquent()`:
|
||||
|
||||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640 runs)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 100 W | 120.0 s (770) | 0.0 s (0) | 165.0 s (685) | **0.0 s (0)** |
|
||||
| 250 W | 65.0 s (687) | 0.0 s (0) | 100.0 s (590) | **0.0 s (0)** |
|
||||
| **500 W** | **30.0 s (460)** | **0.0 s (0)** | **35.0 s (403)** | **0.0 s (0)** |
|
||||
| 700 W | 25.0 s (342) | 0.0 s (0) | 30.0 s (318) | **0.0 s (0)** |
|
||||
| 1000 W | 15.0 s (4) | 0.0 s (0) | 25.0 s (131) | **0.0 s (0)** |
|
||||
| 1500 W | 0.0 s (0) | 0.0 s (0) | 20.0 s (43) | **0.0 s (0)** |
|
||||
| 2000 W | 0.0 s (0) | 0.0 s (0) | 0.0 s (0) | **0.0 s (0)** |
|
||||
|
||||
**The floor is 35.0 s, not 30.0 s** — weighted family, and the worst single run
|
||||
is `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W reversal. The
|
||||
argmax is printed with the table on purpose (see below): a worst case sitting on
|
||||
an edge of the grid would be a number about the grid.
|
||||
|
||||
⚠️ **AND BY THAT STANDARD, TWO OF ITS FOUR AXES ARE ON AN EDGE. Measured, at the
|
||||
argmax's other axes held fixed:**
|
||||
|
||||
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
|
||||
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
|
||||
0.10 -> 35.0 s
|
||||
|
||||
Strictly increasing in stagger and maximal at 4, the largest modelled stagger;
|
||||
and maximal at the most asymmetric split. It is interior only in `gain` (2nd of
|
||||
6) and `slew_w` (3rd of 5).
|
||||
|
||||
⚠️ **SO THE HEADLINE IS 0.0 s AT stagger=0, AND 35.0 s IS ENTIRELY A PRODUCT OF
|
||||
THE MODELLED SERIALISED-WRITE STAGGER.** That stagger is declared MODELLED at
|
||||
`sim/delivery_window.py:60-64` — it is a term of this figure, not a detail of
|
||||
it. A site whose two writes land in the same cycle does not produce this episode
|
||||
at all.
|
||||
|
||||
This does not move `delivery_hold_s`: 185 s must cover the worst case the
|
||||
modelled site can produce, and a stagger of four cycles is inside what a real
|
||||
two-inverter write path can do. But a reader entitled to ask "is 35.0 s about
|
||||
the plant or about the grid?" deserves the answer, and the answer is: **partly
|
||||
about the grid, and the grid's shape is the record's own, fixed by §3.3 before
|
||||
this rebuild ran.** Found in review of TEST-21; the PR body had claimed the
|
||||
argmax was interior, and it is not.
|
||||
|
||||
**WHAT REPRODUCED, EXACTLY:**
|
||||
|
||||
* the **N=1 control column is 0.0 s at every threshold**, in every run. That is
|
||||
§3.2's algebra measured, and it is the claim the compatibility promise rests
|
||||
on. (This rebuild ran the control arm over all three families — 2640 runs —
|
||||
where the original ran 1890, i.e. the two families whose stimulus is not
|
||||
weighted. The weighted stimulus at a roster of one is the same stimulus with
|
||||
inert weights, and it too reports nothing at any threshold.)
|
||||
* the **flapping family is 0.0 s at every threshold**, in all 840 runs. The
|
||||
proper-subset rule really does defeat the family that defeated SAFETY-30's
|
||||
site window.
|
||||
* **45.0 s at twice A's lag**, to the cycle. See the B-lag table below.
|
||||
|
||||
**WHAT DID NOT:**
|
||||
|
||||
* the headline. **35.0 s against a recorded 30.0 s** — one control cycle worse,
|
||||
and 20.0 s → 30.0 s on the reversal family, two cycles worse.
|
||||
* **the non-monotonicity.** §3.3 finding 3 read the original 55.0 s at 250 W
|
||||
against 40.0 s at 100 W as a property of the reduction. **This rebuild's
|
||||
curve is monotone decreasing on both N=2 families.** The *mechanism* finding 3
|
||||
describes is real and is still the reason not to read either table as a
|
||||
sensitivity curve — lowering the threshold really can turn a proper subset
|
||||
into the whole roster and break the run — but **this grid does not exhibit
|
||||
it**, so the claim now rests on the mechanism alone and no longer on a
|
||||
measurement. That is a weaker claim than the one this record made, and it is
|
||||
written down as weaker.
|
||||
* the low-threshold rows, badly: 120.0 s / 165.0 s at 100 W against 40.0 s /
|
||||
75.0 s. Nothing ships at 100 W, but the size of that gap is the honest measure
|
||||
of how much of the original table was a property of axes nobody wrote down.
|
||||
|
||||
⚠️ **WHY THE NUMBERS MOVED, AS FAR AS IT CAN BE ESTABLISHED.** §3.3 fixes the
|
||||
*counts* of every axis (6 gains × 5 slews; 7 reversal sizes; 7 flap periods × 4
|
||||
amplitudes; 5 staggers; and the 5 splits, which it lists) and the *ranges*
|
||||
(`config.yaml`'s `gain` `float(0.05,1.0)` and `slew_w` `int(0,5000)`). It does
|
||||
**not** fix the individual values, the run length, or the per-unit cap. The
|
||||
rebuild spread them over the admissible ranges and **prints every one of them
|
||||
above its own table**, so the caveat travels with the number instead of living
|
||||
in somebody's memory. The residual disagreement — one to two control cycles at
|
||||
the shipped threshold — is inside the sensitivity to that choice; the
|
||||
low-threshold rows are not, and are reported as not.
|
||||
|
||||
⚠️ **AND THE HARNESS WAS NOT ADJUSTED UNTIL IT AGREED.** The one substantive
|
||||
correction made during the rebuild was made *against* the doc's number, not
|
||||
towards it, and it is recorded in §1: grading on the plant's latch rather than
|
||||
the controller's standing command produced a harness that could not name even a
|
||||
wedged unit. Fixing that made the numbers **larger**. Nothing else was tuned.
|
||||
|
||||
Findings 1-3 below are the original campaign's, kept with finding 3 now
|
||||
qualified by the re-run above:
|
||||
|
||||
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
|
||||
threshold.** That is the compatibility promise measured, not argued.
|
||||
2. **The flapping family is zero everywhere too** — and it is the family that
|
||||
defeated SAFETY-30's site window (980 s at two inverters, 1000 s at one, no
|
||||
window inside the billing quarter surviving it). It cannot touch this check,
|
||||
because a common stimulus makes both units short at the same time and the
|
||||
subset stops being proper. That is the proper-subset rule earning its place
|
||||
on a case it was not designed for.
|
||||
3. **The curve is NOT monotone** — 55.0 s at 250 W against 40.0 s at 100 W —
|
||||
and that is a property of the reduction rather than noise: lowering the
|
||||
threshold puts MORE units into the short set, which can turn a proper subset
|
||||
into the whole roster and **break** the run. Both the numerator and the
|
||||
denominator move. Do not read this table as a sensitivity curve.
|
||||
|
||||
#### The watts side is not a new option — it is `Tuning.saturation_w`
|
||||
|
||||
`main.py` hands the check `self.tuning.saturation_w` (500 W) and there is no
|
||||
`delivery_w` key anywhere. That is a derivation, not a saving:
|
||||
|
||||
* it is already **the gap at which `compute()` itself concludes the inverter is
|
||||
not following** and freezes the integrator. Below it the shipped control law
|
||||
calls the divergence ordinary tracking, and an alarm under a threshold the
|
||||
control law calls normal is an alarm about nothing;
|
||||
* `saturation_cycles` (3) consecutive cycles over that same gap are needed
|
||||
before the freeze engages, so **every cycle this counts is a cycle the freeze
|
||||
has already seen**. This is a reduction over the freeze's input, not a second
|
||||
opinion about it;
|
||||
* CFG-03 already tracks three places a default has to be kept in step. A fourth
|
||||
copy could drift, and the drift that matters has a direction: an operator who
|
||||
raises `saturation_w` has told the loop that a bigger gap is ordinary
|
||||
tracking, and a check that stayed put would then alarm on it.
|
||||
|
||||
**Rejected:** a `delivery_w` option, for the three reasons above. A margin above
|
||||
`saturation_w` (the way `circulation_w` sits 1.75× above its knee) was also
|
||||
rejected: there is no knee here — the table above is not monotone — so a margin
|
||||
would be a preference, and all the margin this check needs is in the window.
|
||||
|
||||
#### `delivery_hold_s: 185` — schema `int(30,900)`
|
||||
|
||||
Four bounds, and the value clears all four.
|
||||
|
||||
* **lower, from the rig — 35.0 s** (TEST-21, 2026-09-07; **the original record
|
||||
said 30.0 s and it did not reproduce**). The worst continuous legitimate
|
||||
episode at the shipped threshold across all 2640 runs — weighted family, worst
|
||||
single run `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W
|
||||
reversal; 30.0 s on reversals, 0.0 s on flapping. Re-run it with
|
||||
`admin/goodwe:sim/delivery_window.py --campaign` (§1).
|
||||
⚠️ **THIS IS NOW STRICTLY THE BINDING LOWER BOUND, WHICH IT WAS NOT BEFORE.**
|
||||
At 30.0 s it coincided with the firmware's veto below and a reader could not
|
||||
tell which of the two was doing the work. At 35.0 s the rig's number is on its
|
||||
own, above the firmware's — so **the rig is what stops `delivery_hold_s` going
|
||||
low**, and the schema's `int(30,900)` floor no longer sits above the worst
|
||||
legitimate episode. An operator who sets it to the schema minimum of 30 s is
|
||||
now **below** a measured legitimate episode and will get false reports; the
|
||||
option is marked *derived, do not change* and this is what that marking is
|
||||
for. Raising the schema floor to 35 is a change to a shipped option's
|
||||
contract and is left to its own card rather than smuggled in here.
|
||||
* **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 it from the split. Reporting
|
||||
inside that window would be a second, louder alarm about a state already
|
||||
being handled — the SAFETY-15/SAFETY-19 failure exactly.
|
||||
* **margin, from inverter B not existing.** ⚠️ **THIS ROW IS A SENSITIVITY
|
||||
STUDY ON A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, AND THE ORIGINAL
|
||||
WORDING ("MEASURED, not felt") OVERSTATED IT.** Inverter B is not installed
|
||||
(~2026-09-12; NOTES.md §1.1b). The campaign's B is **A copied**, `measured=
|
||||
False`, so the 35.0 s above is **two identical machines** and every row below
|
||||
is a *what-if*: what a slower second unit would cost this window. **No row
|
||||
here is B's lag.** What it is genuinely evidence for is the SHAPE of the
|
||||
margin — which term the window has to be sized against — and that is what it
|
||||
is used for. Sweeping a *different* second unit through the weighted family
|
||||
(750 runs each). Second column is the original record, third is TEST-21's
|
||||
re-run with the committed harness (`--blag`):
|
||||
|
||||
| B lag | B ramp | 2026-09-06 (uncommitted) | **TEST-21 re-run** | runs naming |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 10.0 s (A) | 800 W/s (A) | 30.0 s | **35.0 s** | 403/750 |
|
||||
| 20.0 s | 800 W/s | 45.0 s | **45.0 s** | 454/750 |
|
||||
| 10.0 s | 400 W/s | 30.0 s | **35.0 s** | 411/750 |
|
||||
| 20.0 s | 400 W/s | 45.0 s | **50.0 s** | 454/750 |
|
||||
| 40.0 s | 200 W/s | 90.0 s | **75.0 s** | 478/750 |
|
||||
|
||||
**The LAG moves it roughly linearly and the RAMP barely moves it**, which is
|
||||
the original finding and it survives — with one correction: *"the RAMP does
|
||||
not move it at all"* is too strong. Halving the ramp is worth 0 s at A's lag
|
||||
and **+5 s at twice it** (45.0 → 50.0), so the ramp is a second-order term
|
||||
rather than no term. The shortfall lasts as long as the unit's first-order lag
|
||||
holds it more than `saturation_w` below its share; at these step sizes the
|
||||
ramp is rarely the binding one. So the number to carry margin against is still
|
||||
B's lag — and a hypothetical unit **four times slower than A** produces
|
||||
**75.0 s**, where the original record said 90.0 s. ⚠️ Note the direction: this
|
||||
row moved **in the margin's favour**, while the floor above moved against it.
|
||||
Neither was adjusted to suit the other.
|
||||
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
|
||||
|
||||
**185 s** is thirty-seven control cycles. Against the TEST-21 re-run it is
|
||||
**5.29×** the measured worst legitimate episode (35.0 s), **2.47×** the
|
||||
hypothetical four-times-slower second unit (75.0 s), **6.2×** the firmware veto
|
||||
(30 s) and **4.9×** inside the quarter (900 s). ⚠️ Deliberately **not 180** —
|
||||
that is SAFETY-02's site hold, and a reader must not be able to think this was
|
||||
carried across — and not 150 or 200.
|
||||
|
||||
⚠️ **185 STANDS, AND HERE IS THE ARITHMETIC RATHER THAN THE ASSURANCE.** The
|
||||
record claimed 6.2× the measured worst; the re-run makes that **5.29×**. All
|
||||
four bounds still clear, none of them narrowly, and the *ordering* of the
|
||||
argument is unchanged: the rig's floor is the binding one, the quarter is the
|
||||
ceiling, and 185 sits between them with margin at both ends. So the value is
|
||||
**not changed** — a shipped safety constant is not moved because its margin
|
||||
went from 6.2× to 5.3× while every bound still holds, and moving it would
|
||||
invalidate SAFETY-41's four-attempt false-trip regime for nothing. What IS
|
||||
changed is the claim: this record no longer says 6.2×, and no longer says 30.0
|
||||
s, because neither is what the harness produces.
|
||||
|
||||
**What it costs, stated rather than hidden:** up to 185 s of a site running
|
||||
short by one unit's share before anybody is told. Against a fault that ran
|
||||
6 h 25 m unnoticed, and against a guard that takes **no action**, the window
|
||||
buys its margin with reporting latency and nothing else.
|
||||
|
||||
## 4. It reports. It does not act — and the strongest argument is for acting
|
||||
|
||||
Card requirement 3 asks which, and why. The answer is recorded with the
|
||||
evidence **against** it first, because "alarm-only" is the weaker-looking answer.
|
||||
|
||||
**Command it harder — no.** HW-01 settles it: the power stage executed its last
|
||||
instruction for 6 h 25 m through every command this add-on sent. A command is
|
||||
not a remedy for a box that is not listening.
|
||||
|
||||
**Stop the site, as SAFETY-31 does — no**, and the asymmetry with SAFETY-31 is
|
||||
exact. Circulating power is stopped because *this controller is one of the two
|
||||
participants*: removing our half halves the loop and un-hides the residue at a
|
||||
meter that was netting it to zero. Here this controller is **not** a participant,
|
||||
nothing is netting, and the sibling is the only thing still delivering. Parking
|
||||
it takes the site from "short by one unit" to "short by all of them".
|
||||
|
||||
**Re-allocate its share to the sibling — no, and NOT because the loop already
|
||||
does it.** That comfortable answer is false, and the rig says so. Dropping the
|
||||
wedged unit from `live` — the mechanism `live_units()` already uses for a dead
|
||||
link — is a real improvement at loads the site cannot cover on one unit's
|
||||
*share*:
|
||||
|
||||
| B wedged, per-unit cap 2000 W | kept in the split | dropped from the split |
|
||||
|---|---|---|
|
||||
| load 800 W | settles inside 100 W from cycle 6 | from cycle 8 |
|
||||
| load 1500 W | **800 W delivered, 700 W error, NEVER inside 100 W in 150 cycles** | 1500 W delivered, inside 100 W from cycle 14 |
|
||||
| load 2000 W | **800 W delivered, 1200 W error, never settles** | 2000 W delivered, inside 100 W from cycle 8 |
|
||||
|
||||
The mechanism is the saturation freeze doing its job on a site comparison that a
|
||||
non-delivering unit has poisoned: `|site target − site batt|` stays over
|
||||
`saturation_w` forever, so the target cannot rise and the sibling's *share*
|
||||
never grows. That is not three cycles of latency; it is the difference between a
|
||||
site that recovers and one that does not.
|
||||
|
||||
**What refuses it anyway** is that the only re-allocation this add-on can
|
||||
express is **all or nothing**. There is no way to say *"give the sibling the
|
||||
watts that did not arrive and leave this unit the ones that did"*. And a unit
|
||||
delivering **part** of its share is short by more than `saturation_w` too, with
|
||||
nothing here to explain it — so this detector names it, and cannot tell a 50 %
|
||||
derate from a wedged box, because no instrument this add-on has can:
|
||||
|
||||
| B derated to 50 %, load 3000 W, cap 2000 W | site delivers | B contributes | grid error |
|
||||
|---|---:|---:|---:|
|
||||
| kept in the split | 2400 W | 800 W | 600 W |
|
||||
| **dropped** | **2000 W** | **0 W** | **1000 W — 400 W worse** |
|
||||
|
||||
**SAFETY-42 names B in that run.** So the remedy would make the site worse, in a
|
||||
case the detector cannot rule out, on a live control law, driven by a brand-new
|
||||
alarm. A **partial** re-allocation is what would make acting safe, and the
|
||||
allocator has no way to express it today. That is a card, and this section is
|
||||
its evidence.
|
||||
|
||||
`test_delivery.py` part 5 asserts the decision as a **counterfactual** rather
|
||||
than by reading the code: the same fixture run with the report tripping and with
|
||||
it never tripping produces byte-identical grid, site target, shares and measured
|
||||
power on every cycle.
|
||||
|
||||
## 5. Requirement 4: the saturation freeze is untouched
|
||||
|
||||
`control.py` is not modified by this card, at all. The freeze still shapes the
|
||||
loop; this reports on it. `test_delivery.py` part 6 asserts what each
|
||||
neighbouring guard concludes on the same fixture — SAFETY-30 per-unit silent,
|
||||
SAFETY-30 site silent, SAFETY-31 identically zero, the freeze firing at the
|
||||
same counter on **both** arms — which is the honest version of "every existing
|
||||
guard is silent", including the one that is not.
|
||||
|
||||
## 6. Surviving a restart (SAFETY-34)
|
||||
|
||||
`persistent_notification` is **in memory**. Home Assistant restarts, the card is
|
||||
gone, and the fault runs on — precisely how the 2026-09-02 outage went unseen
|
||||
for 6 h 25 m with the detector correct throughout. It matters more here than
|
||||
anywhere else in this add-on, because SAFETY-02 and SAFETY-31 at least leave a
|
||||
**stopped** site for somebody to notice; this one leaves a site that looks like
|
||||
it is working.
|
||||
|
||||
* durable half: `unit/<name>/delivery_tripped` / `delivery_reason`, namespaced
|
||||
per unit for `store.unit_key()`'s reason, written **reason first**.
|
||||
* visible half: `_delivery_announced`, a per-**process** set of unit names,
|
||||
deliberately not persisted. A restart empties the set and keeps the latch, so
|
||||
the alarm is re-raised on the first cycle of every process — **one code path**
|
||||
for a fresh trip and for a restart into an existing one, `check_circulating()`'s
|
||||
argument.
|
||||
* **no flat mirror**, where SAFETY-02 has one. `store.py`'s rule 2 keeps the flat
|
||||
keys so a rollback still finds its latch, and the version being rolled back to
|
||||
drives exactly one actuator — a roster at which this check is inert.
|
||||
* one **notification id per unit**, built from the same `unit_slugs()` TEL-07
|
||||
announces the MQTT entities under. Two wedged inverters must produce two cards,
|
||||
not one replacing the other.
|
||||
* it **latches**, and not for SAFETY-02's reason. Nothing here removes its own
|
||||
evidence, because nothing here changes what is commanded. It latches so that a
|
||||
unit which stopped delivering for minutes and then resumed cannot erase its own
|
||||
record — the intermittent fault is the one an operator most needs told about.
|
||||
Cleared by the same **Start** press as the others.
|
||||
* the status row is `ok: False, warn: True` — this list's **caution**. Every
|
||||
other latched row means the site is at 0 W and a red cross is the truth; this
|
||||
one means the site is still running and quietly short, and a red cross would
|
||||
say the controller had stopped.
|
||||
|
||||
## 7. What this does NOT catch, said plainly
|
||||
|
||||
* **Charge shares are not graded.** The account gate needs a bound this
|
||||
controller reads in the commanded direction. There is one for discharge
|
||||
(`bms_soc_floor`, required of every roster entry since SAFETY-32) and **none**
|
||||
for charge. A pack in constant-voltage taper legitimately takes hundreds of
|
||||
watts less than it is told for tens of minutes, and arming that direction would
|
||||
fire on the taper of every §12 maintenance charge. The same fault in the other
|
||||
direction wants the same treatment; what it needs first is a per-pack charge
|
||||
ceiling this add-on does not have. **That is a card.**
|
||||
* **Every unit wedged at once is not caught.** The subset is not proper, and
|
||||
there is no instrument here that separates that from a plant which simply
|
||||
cannot deliver. The site is short at the meter in that case, which is where the
|
||||
existing guards can see it.
|
||||
* **A pack that refuses above its configured floor is reported.** That is
|
||||
deliberate: either the unit has stopped delivering, or `bms_soc_floor` does not
|
||||
describe that battery. The notification says both, in those words.
|
||||
* **A unit with a share of 0 W is not graded**, and neither is it a peer. With
|
||||
only one unit asked to discharge there is nothing to compare against.
|
||||
|
||||
## 8. Mutation ledger
|
||||
|
||||
Every run on a **freshly copied tree of its own, `__pycache__` purged and the
|
||||
count printed, mounted `:ro`, `python3 -B`** — MUTATION.md's rule, followed
|
||||
because the first SAFETY-30 battery reused one directory, a container mounted it
|
||||
mid-copy, read a missing `config.yaml` as a failing check, and the whole run was
|
||||
discarded. **The baseline is green** (23/23) or nothing below is a result. 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 | `delivery.py` | the SoC account removed — a cold pack at its floor alarms | −31 B | **RED** — `test_delivery.py`. This is the card |
|
||||
| M2 | `delivery.py` | the proper-subset rule removed — a whole-site cause names every unit | −34 B | **RED** |
|
||||
| **M3** | `delivery.py` | **the `len(asked) < 2` fast path relaxed to `< 1`** | **+0 B** | **SURVIVOR — and an EQUIVALENT MUTANT.** See below |
|
||||
| M4 | `delivery.py` | the strict bound relaxed to `>=` — a gap the control law calls tracking is named | +1 B | **RED** |
|
||||
| **M5** | `delivery.py` | `share <= 0` relaxed to `share == 0` — charge shares graded | **+0 B** | **SURVIVOR on the first battery — GAP CLOSED**, then **RED**. See below |
|
||||
| M6 | `delivery.py` | `delivery_hold_s` 185 → a round 300 | **+0 B** | **RED** — the CFG-03 pin |
|
||||
| M7 | `main.py` | the watts threshold no longer `Tuning.saturation_w` (×4) | +6 B | **RED** |
|
||||
| M8 | `main.py` | the latch never persisted — nothing survives a restart | −35 B | **RED** |
|
||||
| M9 | `main.py` | `_delivery_announced` never set — one notification per cycle | −34 B | **RED** |
|
||||
| M10 | `main.py` | `peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor` used instead of the pack's own cut-out | +151 B | **RED** — the between-the-floors fixture |
|
||||
| **M11** | `main.py` | **`inv.is_live(now)[0]` replaced by a literal `True`** | −15 B | **SURVIVOR at review — GAP CLOSED**, then **RED**. See below |
|
||||
| M12 | `inverter.py` | the latch not restored from the store on start-up | −46 B | **RED** |
|
||||
|
||||
**11 of 12 red. One survivor, argued rather than left.**
|
||||
|
||||
### M5 was a real gap, and the two obvious checks did not close it
|
||||
|
||||
`test_delivery.py` already asserted `short_by(-2000, 0) is None` and
|
||||
`short_by(-2000, -100) is None` — and **both stayed green under the mutant**.
|
||||
For a charge share that is *under*-delivered, `share − measured` is negative and
|
||||
the `gap > 0` test answers `None` anyway, so those two inputs cannot separate
|
||||
`share <= 0` from `share == 0`. The one input that can is a unit moving **more**
|
||||
than its charge share — `-2000 W` told, `-3000 W` measured, `share − measured` =
|
||||
`+1000 W` — which the mutant reports as a shortfall. It is not one; it is
|
||||
something else driving that pack, which is SAFETY-30's and SAFETY-31's question.
|
||||
The check now uses that input and the mutant is red.
|
||||
|
||||
This is MUTATION.md's third failure mode in miniature: **the assertion ran, the
|
||||
sentence was true, and the data could not reach the failure it named.**
|
||||
|
||||
### M11 was a wiring gap the pure checks structurally could not see
|
||||
|
||||
`account(live=False, …)` was already graded — as a **function**. What was not
|
||||
graded was that `cycle()` actually *asks* `inv.is_live(now)`, and replacing that
|
||||
call with a literal `True` left every check green, because **no fixture had a
|
||||
unit whose writes were being rejected**. A dropped unit still has its last
|
||||
*accepted* command standing on the wire (`write_setpoint` leaves `last_write`
|
||||
alone on a rejected write) and still measures nothing, so it looks exactly like a
|
||||
wedged one to every number this check reads — and reporting it would be the
|
||||
second, louder alarm about a state already being handled that this module takes
|
||||
its 30 s floor from. `test_delivery.py` part 6 now builds that unit (with
|
||||
`rejected_since` back-dated past `LINK_STALE_S`, because the fixture runs in two
|
||||
seconds and the timer is `test_inverter.py`'s to grade) and the mutant is red.
|
||||
|
||||
### M3 is recorded as a survivor because it is one — and it is EQUIVALENT
|
||||
|
||||
`len(asked) < 2` → `len(asked) < 1`. With **one** asked unit, `unaccounted` is a
|
||||
subset of a one-element set, so it is either empty — `not unaccounted` returns
|
||||
`{}` — or equal to `asked` — `len(unaccounted) == len(asked)` returns `{}`.
|
||||
There is no third case, and with **zero** asked units both spellings return `{}`
|
||||
as well. **No input separates them**, so no check can, and one written to try
|
||||
would be a check about nothing.
|
||||
|
||||
⚠️ **And finding it corrected the module's own docstring, which is the more
|
||||
useful half.** That paragraph claimed `len(asked) < 2` was *what makes N=1
|
||||
inert*. It is not: the **proper-subset rule** is, and the line is a fast path and
|
||||
a signpost. A false attribution of the mechanism in the file's own prose is
|
||||
exactly what this repository keeps finding, and a mutation that survives is how
|
||||
it surfaced. The line stays — it tells a reader at a glance that a one-inverter
|
||||
install never reaches any of this — with the equivalence written down beside it,
|
||||
the way `circulation.circulating_w()` records its own `max(0.0, …)`.
|
||||
|
||||
### Not separately mutated, and said rather than left implicit
|
||||
|
||||
`account()`'s `parked` branch is graded as a function (part 1) but its **wiring**
|
||||
— `parked=inv.sign_wd.tripped` — is not mutated on its own. Part 6's reversed-unit
|
||||
fixture exercises the path end to end and asserts the account it produces, which
|
||||
is weaker than a mutation and is stated as such.
|
||||
|
||||
## 9. Round 2 — the thresholds this check reads, and which of them are pinned at a **changed** config
|
||||
|
||||
Everything in §8 mutated the *logic*. This section asks a narrower question that
|
||||
§8 never asked, and it found two live survivors of the whole 23-file suite:
|
||||
|
||||
> for each number `main.py` hands this reduction, is the check still red when
|
||||
> that number is replaced by a **literal equal to the value the fixtures use**?
|
||||
|
||||
That is not pedantry about style. Two of these numbers are **options**, and
|
||||
`delivery.MIN_W_IS_SATURATION_W` and the `delivery_hold_s` block in `config.yaml`
|
||||
both make a promise about them *in shipped, operator-readable prose*: there is no
|
||||
fourth copy of a default to keep in step, and no way for the two to drift,
|
||||
because an operator who raises `saturation_w` has told the loop that a bigger gap
|
||||
is ordinary tracking. A literal at the call site keeps every fixture green and
|
||||
removes exactly that promise — and the promise is why the design was chosen over
|
||||
a fourth option key.
|
||||
|
||||
### The enumeration
|
||||
|
||||
| # | what the call site reads | mutated to | Δ | before round 2 | now |
|
||||
|---|---|---|---|---|---|
|
||||
| R5 | `self.tuning.saturation_w` | `250.0` | −19 B | **SURVIVED all 23 suites** | red — part 4b(a) |
|
||||
| R6 | `bms_floor=inv.bms_soc_floor` | `10.0` | −13 B | **SURVIVED all 23 suites** | red — part 4b(b) |
|
||||
| R7 | `arm(self.delivery_cfg)` | `arm(DeliveryConfig())` | −1 B | red | red — part 4 already ran at a hold of 1 s, not the shipped 185 s |
|
||||
| R8 | `inv.is_live(now)[0]` | `inv.is_live(now, 3600.0)[0]` | +8 B | red | red — part 6's link-dead fixture |
|
||||
| R9 | `bms_floor=inv.bms_soc_floor` | the **conjoined** floor, `max(pack cut-out, site peak_soc_floor)` | +105 B | red | red — and now red **twice**: the pre-existing between-floors check at shipped floors, and part 4b(b)'s shallow arm at a cut-out that is not shipped |
|
||||
|
||||
**R7 and R8 were already pinned across a changed setting, and R5 and R6 were
|
||||
not.** The difference is entirely in the fixtures, not in the code:
|
||||
`delivery_hold_s` had to be moved off 185 s for any of this file to run inside a
|
||||
two-second test, and part 6 had to back-date `rejected_since` past
|
||||
`LINK_STALE_S` to build a dropped unit at all. Nothing ever forced
|
||||
`saturation_w` off 500 or a `bms_soc_floor` off 10 — so those two sat at the
|
||||
shipped value in every fixture, and a literal equal to the shipped value is
|
||||
invisible to a fixture that only ever uses the shipped value. **That is this
|
||||
repository's recurring defect stated in one line: a fixture whose values cannot
|
||||
reach the failure it names.**
|
||||
|
||||
`LINK_STALE_S` (R8) is the one number here with **no option behind it** — it is a
|
||||
module constant in `inverter.py` and no test reassigns it — so "pinned across a
|
||||
changed config" is not available for it, and is not claimed. What part 6 pins is
|
||||
that *widening* the window changes the answer. Spelling `is_live(now, 30.0)` at
|
||||
the call site would be an equivalent mutant today and would only drift if the
|
||||
constant moved, which is a source change and not a config change.
|
||||
|
||||
### Why R5 and R6 are not equivalent mutants — the inputs that separate them
|
||||
|
||||
Both are separating inputs found by asking where the shipped answer and the
|
||||
literal's answer can differ, and both are configs the shipped schema accepts.
|
||||
|
||||
**R5, watts.** Two copies of inverter A, B wedged, `saturation_w: 1800` (inside
|
||||
its own schema `int(100,2000)`), load 1600 W. B's standing share and its
|
||||
shortfall then land strictly **between** `250.0` and the configured 1800 W:
|
||||
|
||||
* head → `named = {}` — the operator raised the threshold and this check moved
|
||||
with them, which is the whole promise;
|
||||
* R5 → `named = {inverter-b}` — an alarm at a gap the control law has been told
|
||||
is ordinary tracking.
|
||||
|
||||
At the shipped `saturation_w: 500` **both** name it, which is why every fixture
|
||||
in part 4 is blind: their gaps are all above both thresholds.
|
||||
|
||||
**R6, percent.** The literal is invisible because `bms_soc_floor: 10` is what
|
||||
`config.yaml`'s own `inverters:` example states and what every fixture in
|
||||
`test_delivery.py` used. Two arms, on either side of it, both inside the schema
|
||||
`int(0,50)` and both `validate.py` rule-8-valid:
|
||||
|
||||
* a pack at **15 %** against its own **25 %** cut-out is genuinely cold. Head
|
||||
stays silent; R6 grades it against 10 %, finds it "permitted", and **names an
|
||||
empty pack** — the crying-wolf failure this card exists to remove.
|
||||
* a pack at **8 %** against its own **5 %** cut-out is genuinely wedged. Head
|
||||
names it; R6 grades it against 10 %, calls it a cold pack, and **loses the
|
||||
fault**.
|
||||
|
||||
One arm on each side is what leaves no constant at all: a single deeper arm
|
||||
kills `10.0` but not `50.0`, a single shallower arm kills `10.0` but not `0.0`.
|
||||
The two arms are byte-identical in the power domain on every cycle — the
|
||||
configured cut-out is the only thing that differs, which is §3.1's claim made
|
||||
again at cut-outs that are not the shipped one.
|
||||
|
||||
### What is still not pinned this way, said rather than left implicit
|
||||
|
||||
`parked=inv.sign_wd.tripped` and `standing[inv.name]`/`inv.batt` are wiring, not
|
||||
thresholds: there is no "value the fixture happens to use" for a boolean or for a
|
||||
measurement, so this question does not apply to them, and §8's last note already
|
||||
records that the `parked` wiring is graded end-to-end rather than by mutation.
|
||||
@@ -0,0 +1,381 @@
|
||||
# SAFETY-43 — delinquency measured against the ask, not against a flat 500 W
|
||||
|
||||
**Status: closed.** `delivery.delinquent()` names a unit whose `short_by()` gap
|
||||
exceeds `min_w` **or** exceeds a fraction of that unit's own share. The fraction
|
||||
is `delivery.DELIVERY_SHARE_FRACTION = 0.85`, derived on the campaign in §3.
|
||||
|
||||
⚠️ **INVERTER B DOES NOT PHYSICALLY EXIST** (expected ~2026-09-12; NOTES.md
|
||||
§1.1b). `plantsim.INVERTER_B` is inverter A **copied**, `measured=False`, and
|
||||
`test_packshare.py`'s plant is modelled in that file. **Every two-inverter
|
||||
figure in this document is two copies of A.** Nothing here is a measurement of
|
||||
inverter B.
|
||||
|
||||
## 0. The defect, and why it is the quiet regime that matters
|
||||
|
||||
[[OPT-09]] passes SoC- and capacity-aware weights to the allocator. A weight is
|
||||
a function of a pack's **energy headroom**, not of what the pack delivers, so a
|
||||
**wedged** unit — one that accepts its share and produces nothing — is weighted
|
||||
down exactly like a legitimately low-headroom one. `delinquent()` read an
|
||||
**absolute** gap, `min_w = Tuning.saturation_w = 500 W`. Shrinking the share
|
||||
shrinks the gap. Measured end to end on the real loop (`test_packshare.py`
|
||||
part 8b: real `compute()`, real `plan()`, real `packshare.weights()`, real
|
||||
`delivery.account()`, real `delinquent()`; B wedged at a healthy 15 % against its
|
||||
own 10 % cut-out, so no envelope closes and `account()` explains nothing — a
|
||||
SAFETY-42 **true positive** by that card's own definition):
|
||||
|
||||
load 1500 W equal weights shares {a:1500, b:1500} named on 199/200
|
||||
load 1500 W OPT-09 weights shares {a:1505, b: 125} named on 0/200
|
||||
|
||||
A unit delivering **0 of 125 W** is 100 % delinquent and 125 W short. Only the
|
||||
absolute threshold disagreed.
|
||||
|
||||
⚠️ **THE SUPPRESSED REGIME IS THE DANGEROUS ONE.** At a load the healthy sibling
|
||||
covers, the meter is satisfied (grid within 5 W of zero on that fixture),
|
||||
`unmet_w` is 0 because both units *accepted* their shares, no envelope closes and
|
||||
no action-log line fires. The delinquency alarm is the **only** instrument that
|
||||
would have told the operator the site was quietly running one inverter.
|
||||
|
||||
## 1. What was NOT done, and it is the larger half of this card
|
||||
|
||||
⚠️ **`min_w` WAS NOT LOWERED.** It is `Tuning.saturation_w` — the gap at which
|
||||
the control law itself concludes the inverter is not following — and it exists to
|
||||
stop **normal saturation** being reported as delinquency. Lowering it trades a
|
||||
missed alarm for a false one on every unit at once. [[SAFETY-41]] spent **four
|
||||
attempts** on notification cadence in this system and the owner's words were
|
||||
*"i'm getting really annoyed by the notifications in my HA"*.
|
||||
|
||||
So the change is an **`or`**, never a looser first arm:
|
||||
|
||||
named if short_by() > min_w OR short_by() > frac * share
|
||||
|
||||
Everything `min_w` named before is still named. Nothing `min_w` refused becomes
|
||||
noisy **unless the unit is delivering almost none of its ask**, which is not
|
||||
saturation-shaped at all. `test_delivery.py` part 1 runs all seven of its
|
||||
existing `delinquent()` fixtures under **both** rules — `share_frac=None` is
|
||||
SAFETY-42's rule exactly — and requires identical answers on every one.
|
||||
|
||||
The absolute arm's suppression window is therefore **unchanged**: still
|
||||
`site target < 2·min_w` = 1000 W at equal weights, still
|
||||
`site target < min_w·(w_A+w_B)/w_B` = 6500 W under OPT-09's weights on part 8b's
|
||||
fixture. Part 8b still asserts both numbers, now as evidence that the first arm
|
||||
did not move.
|
||||
|
||||
## 2. The evidence is a diff between two rules on ONE run
|
||||
|
||||
`delinquent()` takes `share_frac`, and **`None` means the absolute arm alone**.
|
||||
That parameter exists for one reason: so the red and the green come out of one
|
||||
run over one tree instead of out of two commits that a reader has to trust.
|
||||
`test_packshare.py` part 8b grades every cycle twice:
|
||||
|
||||
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
|
||||
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
|
||||
|
||||
load 1500 W equal weights absolute arm alone named on 199/200 cycles
|
||||
load 1500 W equal weights the shipped rule named on 200/200 cycles
|
||||
|
||||
The equal-weights arm gains **exactly one cycle** — the first, where the slew
|
||||
limit holds the split at 500/500, a 500 W gap is not *over* a 500 W threshold
|
||||
(the absolute bound is strict) and the relative arm names a box delivering none
|
||||
of its 500 W. That single cycle is the whole of what this card costs an arm that
|
||||
was already firing.
|
||||
|
||||
⚠️ **THE PIN THAT INVERTED.** Part 8b was written by OPT-09 to pin the
|
||||
**suppressed** behaviour, and its comment said: *"if it ever goes red because the
|
||||
alarm fired, read the card that made it fire before 'fixing' this line."* This is
|
||||
that card. The pin now asserts the opposite answer and keeps the old one beside
|
||||
it, measured rather than remembered.
|
||||
|
||||
## 3. ⭐ Deriving the fraction
|
||||
|
||||
⚠️ **DERIVED ON THE SAME CAMPAIGN AS `delivery_hold_s`'s FLOOR**, not chosen.
|
||||
`admin/goodwe:sim/delivery_window.py` — the harness [[TEST-21]] committed —
|
||||
gained a second grading axis: the same **2640 no-fault runs at N=2** (three
|
||||
families × 6 gains × 5 slews × write staggers; every unit executes the share the
|
||||
allocator gave it) and the same **2640 N=1 control runs**, graded at the shipped
|
||||
`min_w = 500 W` for each candidate fraction, on every cycle of every run — so a
|
||||
pass grades every fraction it is given, and the absolute arm beside them, over
|
||||
one tree and one set of runs. Every row in a pass is comparable to every other
|
||||
row in it, run for run.
|
||||
|
||||
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
python3 -B delivery_window.py --selftest --campaign --fractions
|
||||
|
||||
⚠️ **THE TABLE BELOW WAS PRODUCED BY TWO PASSES; `FRACTIONS` NOW HOLDS ALL
|
||||
TWENTY SO IT TAKES ONE.** The derivation ran the shipped ten
|
||||
`(0.25 … 0.99)` and then the ten that resolve the low end
|
||||
`(0.05 … 0.65)` with only that tuple edited between them. **The absolute-arm
|
||||
control row came out identical in both** (30.0 / 0.0 / 35.0 / 0.0, same argmax),
|
||||
which is what says the two passes are one campaign. A table a reviewer cannot
|
||||
reproduce without knowing to edit a line nobody wrote down is PROC-01's shape in
|
||||
miniature, so the committed `FRACTIONS` was widened to all twenty in review: the
|
||||
command above now reproduces every row below.
|
||||
|
||||
### 3.1 The curve
|
||||
|
||||
Worst **continuous** legitimate episode (the measure `DeliveryWatchdog` actually
|
||||
sees — its window is cleared by any cycle that does not name), runs naming in
|
||||
brackets:
|
||||
|
||||
| `share_frac` | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| absolute only | 30.0 s (460) | 0.0 s (0) | 35.0 s (403) | **0.0 s (0)** |
|
||||
| 0.05 | 85.0 s (826) | 0.0 s (0) | 90.0 s (616) | **0.0 s (0)** |
|
||||
| 0.10 | 75.0 s (824) | 0.0 s (0) | 90.0 s (607) | **0.0 s (0)** |
|
||||
| 0.15 | 70.0 s (836) | 0.0 s (0) | 70.0 s (609) | **0.0 s (0)** |
|
||||
| 0.20 | 70.0 s (839) | 0.0 s (0) | 60.0 s (629) | **0.0 s (0)** |
|
||||
| 0.25 | 55.0 s (832) | 0.0 s (0) | 60.0 s (611) | **0.0 s (0)** |
|
||||
| 0.30 | 55.0 s (840) | 0.0 s (0) | 50.0 s (614) | **0.0 s (0)** |
|
||||
| 0.35 | 55.0 s (840) | 0.0 s (0) | 55.0 s (618) | **0.0 s (0)** |
|
||||
| 0.40 | 50.0 s (840) | 0.0 s (0) | 60.0 s (658) | **0.0 s (0)** |
|
||||
| 0.45 | 45.0 s (840) | 0.0 s (0) | 45.0 s (627) | **0.0 s (0)** |
|
||||
| 0.50 | 45.0 s (840) | 0.0 s (0) | 45.0 s (636) | **0.0 s (0)** |
|
||||
| 0.55 | 40.0 s (840) | 0.0 s (0) | 40.0 s (636) | **0.0 s (0)** |
|
||||
| 0.60 | 40.0 s (840) | 0.0 s (0) | 40.0 s (645) | **0.0 s (0)** |
|
||||
| 0.65 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.70 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.75 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.80 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| **0.85** | **35.0 s (840)** | **0.0 s (0)** | **40.0 s (635)** | **0.0 s (0)** |
|
||||
| 0.90 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
| 0.95 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
| 0.99 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
|
||||
Worst of the three families, which is what `delivery_hold_s` has to cover:
|
||||
|
||||
frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
|
||||
worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
|
||||
|
||||
frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
|
||||
worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
|
||||
|
||||
⚠️ **THE FIRST ROW IS THE CONTROL AND IT REPRODUCED SAFETY-42 §3.3 EXACTLY** —
|
||||
30.0 s / 0.0 s / 35.0 s, and the same argmax (`gain=0.2 slew_w=1000 stagger=4
|
||||
split=0.10/0.90`, ±2200 W reversal). It is measured **in the same run** as every
|
||||
row below it, which is what makes those rows a comparison rather than a claim.
|
||||
|
||||
### 3.2 What the curve says
|
||||
|
||||
* ⚠️ **THERE IS NO CLIFF ANYWHERE IN THE CURVE, AND THAT IS THE FINDING.** From
|
||||
**0.45 to 0.99** every figure is 40.0 or 45.0 s — within **one control cycle
|
||||
(5.0 s)** of every other — and below 0.45 it rises **gradually**: 60.0 s at
|
||||
0.25, 70.0 s at 0.15, 90.0 s at 0.05. A reviewer asking "why 0.85 and not
|
||||
0.90?" is asking about a difference this campaign cannot resolve, and the
|
||||
honest answer is that it cannot.
|
||||
* ⚠️ **IT IS NOT MONOTONE** — 0.40 reports 60.0 s where 0.35 reports 55.0, and
|
||||
0.65–0.80 report 45.0 s where 0.55/0.60 and 0.85–0.99 report 40.0 — and that is
|
||||
SAFETY-42 §3.3's own mechanism (c) rather than noise in the plant: **a more
|
||||
sensitive rule puts MORE units into the short set, which can turn a proper
|
||||
subset into the WHOLE roster and END the episode.** The reduction is not
|
||||
monotone in its own threshold. Do not read this column as a sensitivity curve
|
||||
in the ordinary sense.
|
||||
* **The shoulder is at about 0.45**, and it is a shoulder rather than an edge:
|
||||
the reversal family's naming count saturates at 840 of 1050 runs from 0.30
|
||||
upward, so below the shoulder what grows is the LENGTH of an episode, not the
|
||||
number of runs that have one.
|
||||
* **The N=1 column is 0.0 s at every fraction, in all 2640 one-inverter runs.**
|
||||
The compatibility promise is measured for the new arm, not argued.
|
||||
* **The flapping family is 0.0 s at every fraction, in all 840 runs.** A common
|
||||
stimulus makes both units short at once and the subset stops being proper —
|
||||
the property that defeated SAFETY-30's site window still holds.
|
||||
|
||||
### 3.3 ⚠️ "Zero false namings" is not available at ANY fraction
|
||||
|
||||
The card asked for *the largest fraction that yields zero false namings across
|
||||
the whole no-fault campaign*. **There is none.** Every fraction adds naming
|
||||
cycles: the reversal family goes from 460 runs naming to 840, the weighted family
|
||||
from 403 to 635. A relative arm that never fired on a legitimate transient would
|
||||
be a relative arm that never fired at all — the write stagger lands B's number up
|
||||
to four cycles late, and a unit whose new command has not arrived is, for those
|
||||
cycles, delivering a small fraction of what is standing on its wire.
|
||||
|
||||
What **is** zero at every fraction is **false alarms**:
|
||||
|
||||
> the longest continuous legitimate episode anywhere in the 20-point sweep is
|
||||
> **90.0 s** — at 0.05, the most sensitive fraction measured — against a
|
||||
> `delivery_hold_s` of **185 s**. Nothing in 2640 no-fault runs comes within
|
||||
> **95 s** of notifying an operator, **at any fraction measured**. At the
|
||||
> shipped 0.85 it is 40.0 s, i.e. 145 s of slack.
|
||||
|
||||
That is the quantity SAFETY-41 cares about and it is the one this card had to
|
||||
protect. Stated as margin, at the shipped fraction: **185 s is 4.62×** the worst
|
||||
legitimate episode, where the absolute arm alone gave 5.29×.
|
||||
|
||||
### 3.4 The bounds 0.85 answers to
|
||||
|
||||
* **LOWER, from the campaign** — the shoulder at about **0.45**, below which the
|
||||
added episode starts to grow (60.0 s at 0.25, 90.0 s at 0.05). 0.85 is 0.40
|
||||
above it.
|
||||
* **UPPER, from the card's own case** — strictly **below 1.0**. At 1.0 the arm
|
||||
can only fire on a unit that is *importing* while asked to discharge, which is
|
||||
SAFETY-30's fault and not this one, so a unit delivering **none** of its share
|
||||
(gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
|
||||
wedged box reporting a small standby figure instead of a clean zero.
|
||||
* **UPPER, from the hold** — every fraction in the flat range leaves
|
||||
`delivery_hold_s` between **4.11×** (45.0 s) and **4.62×** (40.0 s) the worst
|
||||
legitimate episode, well inside the four bounds SAFETY-42 §3.3 requires of it.
|
||||
**`delivery_hold_s` IS NOT CHANGED BY THIS CARD.**
|
||||
* ⚠️ **NOT DERIVED, AND SAID SO: the exact value inside [0.45, 1.0).** The
|
||||
campaign cannot separate them. 0.85 is the midpoint of the argument above —
|
||||
0.40 of margin above the shoulder, 0.15 below the ceiling — and a
|
||||
re-derivation that moved it to 0.90 would be moving a number this campaign
|
||||
never resolved. It is not moved for that reason.
|
||||
|
||||
## 4. The positive control — what the relative arm must still NOT say
|
||||
|
||||
A card that adds a way to raise an alarm has to show what it did **not** add.
|
||||
`test_packshare.py` part 8b runs the same fixture, the same weights and the same
|
||||
125 W share with the one fact that makes the shortfall legitimate changed:
|
||||
|
||||
| fixture | B's share | named | why it is silent |
|
||||
|---|---|---|---|
|
||||
| B wedged at a healthy 15 % (the fault) | 77–154 W | **200 / 200** | — it is the fault |
|
||||
| B at its own 10 % cut-out | **0 W, all 200 cycles** | **0 / 200** | it is never **asked** |
|
||||
| B delivering **half** its share | 120 W | **0 / 200** | short by 50 %, inside both arms |
|
||||
|
||||
### ⚠️ The cut-out row does NOT grade `account()`, and this record said it did
|
||||
|
||||
**Corrected in review.** The first draft of this section claimed the wedged and
|
||||
cut-out arms had *"identical power traces — 0 W against a positive share, 100 %
|
||||
of the ask missing"* and that *"what separates them is `account()`"*. **Every
|
||||
part of that was false**, and the check that carried it was **vacuous**:
|
||||
|
||||
* at its own cut-out the pack's usable energy **above that cut-out is zero**, so
|
||||
`packshare.weights()` answers **0.0** for it, `_fill()`'s preferred pass gives
|
||||
it nothing, the healthy sibling covers the load alone so the last-resort pass
|
||||
never runs, and its share is **0 W on all 200 cycles**;
|
||||
* a unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns `{}`
|
||||
**before either arm is evaluated** and `account()` is **never consulted**;
|
||||
* the traces are not identical — the cut-out arm's share is **0 W on every
|
||||
cycle** (asserted in part 8b and printed on its line) while the wedged arm's
|
||||
is **125 W** at the last cycle (asserted in the GREEN check) and takes four
|
||||
distinct values 77/125/133/154 W across the 200, measured by re-running the
|
||||
same loop with per-cycle recording during review — and "100 % of the ask" is
|
||||
0/0.
|
||||
|
||||
Measured, by deleting the gate the claim rested on:
|
||||
|
||||
cut-out arm, account() live named 0/200, distinct B shares: [0]
|
||||
cut-out arm, account() DELETED named 0/200, distinct B shares: [0]
|
||||
wedged arm, account() live named 200/200, shares [77,125,133,154]
|
||||
|
||||
⚠️ **The fact that replaces it is better, not weaker.** At its own cut-out
|
||||
**OPT-09 stops asking the pack at all**, so SAFETY-43's relative arm is never
|
||||
reached — a **second, independent protection sitting upstream of `account()`**:
|
||||
the allocator declines to ask before the detector has to decline to alarm. Part
|
||||
8b now asserts the 0 W share and prints it, and runs the arm a second time with
|
||||
`account()` deleted to show which mechanism is doing the work.
|
||||
|
||||
⚠️ **It was invisible for one reason worth keeping**: that line was the only one
|
||||
in part 8b that did not print its shares. `inverter-b+0` would have been on
|
||||
screen from the first run. Every line in that part prints them now, and the
|
||||
derated control's vacuity guard now asserts **its own** unit is asked, rather
|
||||
than re-asserting a different run's 200/200 — which is exactly why the old guard
|
||||
could not see this.
|
||||
|
||||
**The account gate is graded where it is real**, on a positive 125 W share, in
|
||||
`test_delivery.py` part 1 (*"THE ACCOUNT GATE IS UNTOUCHED BY IT"* — the same
|
||||
0 of 125 W with the shortfall explained names nobody). That part also carries
|
||||
that the relative bound is **strict** (a gap of exactly `frac · share` is not
|
||||
over it) and that the proper-subset rule still refuses a site-wide cause (both
|
||||
units delivering none of a 125 W share names nobody).
|
||||
|
||||
## 5. N=1 is byte-identical, and it is asserted rather than argued
|
||||
|
||||
The live install is **N=1**. Two independent statements, each graded against the
|
||||
thing that would break it (`test_packshare.py` part 8b):
|
||||
|
||||
1. **`allocator.plan()` returns before `weights` is read at all.** Graded by
|
||||
handing it a `weights` mapping that **raises on any read**: at a roster of one
|
||||
it answers identically to the same call with no weights; at a roster of two
|
||||
the same mapping is read and raises, so the tripwire is a real one.
|
||||
2. **`delinquent()` answers `{}` at a roster of one for every input** — 84
|
||||
combinations of (share, measured, rule), including **0 of 125 W**, the exact
|
||||
input this card added an arm for. `len(asked) < 2` returns before either
|
||||
threshold is compared, so the fraction is never even multiplied.
|
||||
|
||||
Nothing in this card is reachable at one inverter. It must land before inverter B
|
||||
is commissioned (~2026-09-12), not before the next deploy.
|
||||
|
||||
## 5b. Mutation — the fraction is pinned from BOTH directions
|
||||
|
||||
⚠️ **RUN, NOT REASONED ABOUT.** Each mutation was applied to a tree **copied
|
||||
fresh** from this branch and mounted `:ro` inside the checks image, so no `.pyc`
|
||||
could go stale — MUTATION.md's defect, and all three of these are **reds**, the
|
||||
direction that defect cannot fabricate. Recorded in `test_packshare.py` part 11
|
||||
as M12–M14.
|
||||
|
||||
| mutation | named checks | where |
|
||||
|---|---:|---|
|
||||
| **M12** drop the relative arm, keep `gap > min_w` — *the card itself reverted* | **7** | `test_delivery.py` part 1 (3: the 0-of-125 W naming, the proportion in the sentence, the strict relative bound) + `test_packshare.py` 8b (4: GREEN back to 0/200, the equal arm's 200 back to 199, the 6000 W arm back to 195/199, the derated control's guard). `test_alloc.py` / `test_control.py` stay green — nothing outside this check moves. |
|
||||
| **M13** `DELIVERY_SHARE_FRACTION` → **1.0** — *too loose* | **7** | part 1 (2) + 8b (5), including the PRECONDITION that the fraction is strictly below 1.0. At 1.0 the card is a no-op: gap/share of exactly 1.0 is not *over* 1.0. |
|
||||
| **M14** `DELIVERY_SHARE_FRACTION` → **0.05** — *too sensitive* | **6** | part 1 (2: the strict bound, and *"IT TOOK NOTHING AWAY"* stops being true) + part **4b** (3: the `saturation_w` pin goes **vacuous**, the relative arm naming B at every threshold) + 8b's derated control, where a unit delivering half its share starts being named. |
|
||||
|
||||
M13 and M14 are the pair that matters: the constant cannot be moved in **either**
|
||||
direction without a named check moving with it.
|
||||
|
||||
### ⚠️ Two of these counts were wrong, and the reason is worth keeping
|
||||
|
||||
**Corrected in review.**
|
||||
|
||||
* **M12 and M13 read 1 and 1 in `test_delivery.py`, not 3 and 2.** Part 1 printed
|
||||
the alarm sentence with a bare subscript, `delinquent(_tiny, 500)["b"]`. On any
|
||||
tree where the relative arm is broken or absent that raises `KeyError` and
|
||||
**aborts the file at part 1**, leaving parts 2–8 ungraded — so the recorded
|
||||
coverage of a mutation run read *smaller* than the truth. The direction is safe
|
||||
(an abort is a red, and MUTATION.md's defect can only fabricate a green), but a
|
||||
figure that does not reproduce is a figure that does not reproduce. It is a
|
||||
`.get` now, with the reason written beside it, and the counts above are from
|
||||
re-runs with the guard in place.
|
||||
* **M14 was recorded as 4 named checks in `test_delivery.py` and it is 5** — part
|
||||
1's pre-existing *"a shortfall AT the threshold is not one"* also goes red. That
|
||||
one under-claimed, so it never inflated the evidence; it is still a recorded
|
||||
number that did not reproduce.
|
||||
|
||||
## 6. ⚠️ [[CFG-07]] is adjacent and is NOT absorbed here
|
||||
|
||||
CFG-07 is that `delivery_hold_s` is declared `int(30,900)` in `config.yaml`'s
|
||||
schema while the measured worst legitimate episode at the shipped threshold is
|
||||
**35.0 s** — so the schema offers an operator a value (30) that is **below a
|
||||
measured legitimate episode** and would report on ordinary saturation.
|
||||
|
||||
**This card makes it worse, and by exactly one control cycle.** The relative arm
|
||||
takes the worst legitimate episode from **35.0 s to 40.0 s** at the shipped
|
||||
fraction, so the band of schema-legal but false-tripping values widens from
|
||||
`[30, 35]` to `[30, 40]`. The floor the schema should have is now **40 s**, not
|
||||
35 s. It is not fixed here: raising a schema floor is an option-surface change
|
||||
with its own compatibility argument (an existing config carrying `35` would start
|
||||
being refused), and folding it into a safety fix would hide it. **CFG-07 should
|
||||
be re-derived against 40.0 s, not 35.0 s.**
|
||||
|
||||
⚠️ **WHEN IT HAS TO CLOSE: BEFORE INVERTER B IS COMMISSIONED (~2026-09-12).**
|
||||
Not before the next deploy. The band is **unreachable at N=1** — `delinquent()`
|
||||
returns `{}` for every input at a roster of one, so no value of
|
||||
`delivery_hold_s` can false-trip on the install that is running today — and the
|
||||
live install is N=1 until B exists. Commissioning B is the moment the band
|
||||
becomes reachable, and that is the deadline this card hands it.
|
||||
|
||||
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE.** It exists as a
|
||||
`[[CFG-07]]` mention in `CHANGELOG.md` and in this section, and nowhere else —
|
||||
so there is no file for the 40.0 s to be recorded in, and a reader who goes
|
||||
looking will find a card reference with no record behind it. Whoever takes
|
||||
CFG-07 writes `DECISIONS/CFG-07-*.md` first; the number it has to answer to is
|
||||
**40.0 s**, measured here, and the harness that produces it is
|
||||
`admin/goodwe:sim/delivery_window.py --campaign --fractions`.
|
||||
|
||||
## 7. Provenance of every number in this document
|
||||
|
||||
| number | where it came from |
|
||||
|---|---|
|
||||
| 199/200, 0/200, 200/200, shares 1505/125 | `test_packshare.py` part 8b, run |
|
||||
| 35.0 s absolute-arm floor, and its argmax | `delivery_window.py --campaign`, re-run here, reproduces SAFETY-42 §3.3 |
|
||||
| every row of §3.1 | `delivery_window.py --campaign --fractions`, two passes over the same campaign with `FRACTIONS` changed between them |
|
||||
| 1000 W / 6500 W suppression windows | part 8b's arithmetic, asserted there |
|
||||
| 185 s, 5.29× | SAFETY-42 §3.3 / TEST-21, unchanged by this card |
|
||||
| 4.62× | 185 / 40.0, printed by the campaign |
|
||||
| 0/200 on both positive controls, and B's 0 W share at the cut-out | part 8b, run and asserted |
|
||||
| 0 W cut-out share / 125 W wedged share | part 8b, asserted and printed |
|
||||
| 77/125/133/154 W (the wedged arm's four distinct shares) | the same loop re-run in review with per-cycle recording — NOT part 8b's printed line, which shows the last cycle only |
|
||||
| M12/M13/M14 named-check counts (7 / 7 / 6) | re-run in review on fresh `:ro` trees, after the `KeyError` abort in part 1 was guarded |
|
||||
| 84 single-unit rosters | part 8b, run |
|
||||
|
||||
⚠️ Every two-inverter figure above is **two copies of inverter A**.
|
||||
@@ -0,0 +1,207 @@
|
||||
# UX-02 — two configuration surfaces, one blob, and which one is authoritative
|
||||
|
||||
**Status: DECIDED 2026-09-06 — the STORED OPTIONS BLOB is authoritative and
|
||||
neither user interface is.** The ingress panel is the surface an operator
|
||||
should normally use, because it is the only one that can show the add-on's
|
||||
cross-field refusals *before* a save. The Supervisor's Configuration tab stays,
|
||||
unchanged, and is the **recovery** surface: it is the only one of the two that
|
||||
still exists when the add-on is not running.
|
||||
|
||||
Filed with UX-02. Card 6a9afc52d4e3662463274e9d. Depends on [[UX-01]], which is
|
||||
not superseded by this — see §4.
|
||||
|
||||
---
|
||||
|
||||
## 1. The question
|
||||
|
||||
The owner asked, 2026-09-04: *"can't we use some kind of webUI in HA to have a
|
||||
cleaner config with custom styling?"* Yes — `config.yaml` already declares
|
||||
`ingress: true`, `ingress_port: 8099` and `hassio_api: true`, and the panel is
|
||||
live. But building a second editor for the same settings creates a question the
|
||||
first one never had: **when two surfaces can write one blob, which one is
|
||||
right?**
|
||||
|
||||
Getting that wrong is not a cosmetic problem. `POST /addons/self/options`
|
||||
*replaces* the stored user options, so a page that writes a blob it read five
|
||||
minutes ago silently reverts whatever the other surface did in between.
|
||||
|
||||
## 2. What was measured, not assumed
|
||||
|
||||
All of it out of `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` —
|
||||
the version this site runs — by `tools/supervisor_selfapi_probe.py`, which runs
|
||||
Supervisor's own code rather than re-reading it. It touches nothing: no
|
||||
Supervisor is contacted, no options are posted and nothing is restarted.
|
||||
|
||||
**(a) An add-on may read and write its own options, and needs no role.**
|
||||
`_V1_PATTERNS.api_bypass` was run against the exact paths. `/addons/self/info`,
|
||||
`/addons/self/options` and `/addons/self/restart` all match; `security` and
|
||||
`update` do not. `token_validation()` tests that bypass *before* it consults
|
||||
`access_hassio_api` or any role, so no `hassio_role` is involved.
|
||||
|
||||
Two results the card did not predict, and both changed the design:
|
||||
|
||||
* `/addons/self/options/validate` — Supervisor's **own** options validator — is
|
||||
**not** reachable: it is two path segments after `self`, so `[^/]+` cannot
|
||||
match it, and it is not one of the listed literals. There is exactly one
|
||||
validator an add-on can call from inside itself, and it is `app/validate.py`.
|
||||
Which is the one we want: Supervisor's knows only types and ranges.
|
||||
* `/addons/self/info` is admitted by the `default` role *as well as* by the
|
||||
bypass (`/.+/info`), while `/addons/self/options` and `/addons/self/restart`
|
||||
are admitted by the **bypass only**. The two *writing* paths rest on that one
|
||||
list. The first draft of the probe asserted that the role admitted none of
|
||||
the three, and it went red — which is why the split is written down here.
|
||||
|
||||
**(b) `POST …/options` replaces; an omitted key reverts to the config.yaml
|
||||
default.** `App.options` is `deepmerge(config defaults, persisted user
|
||||
options)`, and the setter is `persist[options] = deepcopy(value)`. Run on real
|
||||
objects: with `max_w: 4200` stored, a POST that simply omits `max_w` leaves the
|
||||
effective value at **2000**, config.yaml's default — not at 4200.
|
||||
|
||||
That is why the panel posts the **whole** blob it read, and why it refuses to
|
||||
post one built on a stale read.
|
||||
|
||||
**(c) Validation does not coerce.** The full merged blob goes through
|
||||
`AppOptions` unchanged, key for key. That is what makes the panel's *"stored
|
||||
but not running"* comparison honest: a difference shown is a difference that
|
||||
exists.
|
||||
|
||||
**(d) The Configuration tab *can* be removed — and doing so destroys every
|
||||
setting on the site.** The card said it cannot be. It can: `schema:` is
|
||||
optional, a manifest without it is accepted, and `schema_ui` is then `[]`, so
|
||||
the tab renders nothing. What goes with it is the add-on. `AppOptions` drops
|
||||
every option the schema does not declare — **0 of 71 survive** — so
|
||||
`/data/options.json` becomes `{}`, the controller starts with no actuator, the
|
||||
startup gate refuses it (exit 78), and the ingress panel goes down with the
|
||||
process serving it.
|
||||
|
||||
So the tab stays **by decision, not by constraint**. That distinction matters:
|
||||
a constraint nobody re-examines is not a decision, and this one has a cost
|
||||
(§4) that has to be paid knowingly.
|
||||
|
||||
## 3. The decision
|
||||
|
||||
> **The authoritative configuration is the options blob the Supervisor stores.
|
||||
> Both surfaces are editors of it. Neither is a cache of the other, and neither
|
||||
> owns a copy of anything.**
|
||||
|
||||
Consequences, in the order they bind:
|
||||
|
||||
1. **The panel never holds state.** Every page load re-reads
|
||||
`GET /addons/self/info`. There is no local copy of the options, the schema
|
||||
or the labels — the form is *built* from that response by
|
||||
`app/configui.py`, which contains no option key, no label and no rule.
|
||||
2. **A save is a compare-and-swap.** The read hands the page a
|
||||
`fingerprint()` (a sha256 of the canonical blob); the save hands it back;
|
||||
`selfconfig.save()` re-reads the stored blob **immediately before writing**
|
||||
and refuses if the digest moved. The window in which the other surface can
|
||||
slip a change past is one HTTP round trip, not the length of an editing
|
||||
session.
|
||||
3. **Validation happens before the compare, and a refused blob is never
|
||||
written.** Not warned about — not written.
|
||||
4. **Saving never restarts.** Applying options costs a control interruption
|
||||
(§5), so it is a separate, explicitly confirmed act.
|
||||
|
||||
### Why a digest and not a version number
|
||||
|
||||
The Supervisor exposes no revision or ETag for an add-on's options. The only
|
||||
thing a page can hold between reading and writing is the *content* it read —
|
||||
and that is enough, because the question is not "how many times has this
|
||||
changed" but "is what I am about to overwrite still what I was shown".
|
||||
Identical content is by definition safe to overwrite.
|
||||
|
||||
### This is [[SAFETY-28]]'s discipline, applied to configuration
|
||||
|
||||
`arbiter.py` and `Controller.write_refusal()` enforce *exactly one writer per
|
||||
actuator* not by convention but by a check on the write path:
|
||||
|
||||
> A NUMBER REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR PLACED IT, ON THIS
|
||||
> TICK, FOR THIS UNIT.
|
||||
|
||||
The configuration analogue is written the same way and for the same reason:
|
||||
|
||||
> A BLOB REACHES THE SUPERVISOR ONLY IF IT WAS DERIVED FROM THE BLOB THAT IS
|
||||
> STORED RIGHT NOW.
|
||||
|
||||
Note what is *not* claimed. SAFETY-28's rule makes a second writer
|
||||
inexpressible; this one does not — the Supervisor's tab is a second writer and
|
||||
stays one. What this gets is the weaker, honest property: **a second writer is
|
||||
detected rather than silently overwritten.** That is the most a
|
||||
compare-and-swap can give, and pretending otherwise would be the "check that
|
||||
observes something other than what it claims to observe" this repository keeps
|
||||
finding.
|
||||
|
||||
## 4. What this costs, stated plainly
|
||||
|
||||
**Two surfaces means two places to be confused.** The Configuration tab still
|
||||
renders all 71 keys as a flat list, and it can still store a configuration the
|
||||
add-on will refuse to start on — it knows only types and ranges. UX-01's
|
||||
translations are therefore **not** superseded by this card: they are the only
|
||||
thing standing between that tab and raw key names, and they are also where this
|
||||
panel's labels, descriptions and section headings come from. The two surfaces
|
||||
share one set of words on purpose; two vocabularies for one setting would be
|
||||
worse than one plain list.
|
||||
|
||||
**The panel is not available when it is most needed.** If a configuration that
|
||||
refuses to start is ever stored — from the Configuration tab, or by a hand-edit,
|
||||
or by this panel in some future state it does not currently permit — the add-on
|
||||
exits 78 and the panel goes with it. Recovery is the Configuration tab. That is
|
||||
the reason it stays, and the reason the panel refuses to write a blob the
|
||||
validator rejected: this surface must never be the thing that removes itself.
|
||||
|
||||
**The panel shows fewer refusals than the startup gate can, never more.** Two
|
||||
of `validate.py`'s rules compare a configured ceiling against the setpoint
|
||||
device's *own* reported range, which is only knowable by asking Home Assistant.
|
||||
When ESPHome is asleep the range is `None` and those two rules are skipped —
|
||||
here and at startup, identically. The page says so rather than looking green
|
||||
for a reason the operator cannot see.
|
||||
|
||||
## 5. The restart cost, and why it is a separate button
|
||||
|
||||
Applying options needs a restart, and `Controller.shutdown()` commands **0 W on
|
||||
every inverter** on the way out ("shutting down - commanding 0 W on every
|
||||
inverter", main.py). A configuration save is therefore a real, if brief, loss
|
||||
of control of the battery — and the operator is told that *before* they commit,
|
||||
in the page's own markup, not in a message that appears afterwards.
|
||||
|
||||
So the panel splits the act in two:
|
||||
|
||||
* **Save** stores the blob. Nothing restarts, nothing is applied, and the page
|
||||
then says which settings are stored but not running.
|
||||
* **Restart to apply** asks again, names the 0 W again, and only then posts
|
||||
`/addons/self/restart`. The request carries an intent token, so a reload, a
|
||||
retry or a stray POST cannot spend a control interruption on the operator's
|
||||
behalf.
|
||||
|
||||
A restart does **not** clear anything: SAFETY-02's latches are restored from
|
||||
the Store on the way back up, which is what `Inverter.__init__` is written to
|
||||
do and what [[SAFETY-38]] depends on.
|
||||
|
||||
## 6. The scope line, and it is a safety line
|
||||
|
||||
**Nothing on this surface commands an inverter, clears a latch or bypasses a
|
||||
safety rule.** The set of things an operator can do to the hardware through the
|
||||
panel is exactly what `/action` offered before this card. `app/selfconfig.py`
|
||||
talks to the Supervisor about a JSON blob and to `validate.py` about whether
|
||||
that blob is startable; the only actuator-shaped verb in it is `restart()`,
|
||||
which restarts *this add-on* and whose 0 W is the existing, documented
|
||||
wind-down.
|
||||
|
||||
test_selfconfig.py part 7 asserts that mechanically — it lists the routes, and
|
||||
it greps `selfconfig.py` and the page for `set_number`, `write_setpoint`,
|
||||
`handle_action`, `command_site_zero`, `sign_wd` and `reset(`. Adding "and also
|
||||
set the setpoint while we are here" would put a second writer on the actuator
|
||||
through the configuration surface, which is the non-goal this add-on is built
|
||||
around.
|
||||
|
||||
## 7. How "every message comes from the shipped validator" is proven
|
||||
|
||||
Not by inspection. `test_selfconfig.py` part 6 copies the package, inserts **one
|
||||
new rule** into `validate_startup_config()` in the copy, hashes every file under
|
||||
`app/` to show that **validate.py is the only one that differs**, and then
|
||||
serves the real `/config/validate` route out of that copy in a subprocess. The
|
||||
new sentence arrives on the page, filed under the option key it names, with no
|
||||
UI file touched.
|
||||
|
||||
That is the acceptance criterion run rather than argued, and it is what makes
|
||||
"do not reimplement any rule in JavaScript" a property of the build instead of
|
||||
a promise in a comment.
|
||||
@@ -0,0 +1,379 @@
|
||||
# UX-04 — what the Configuration tab renders, and the pin that was never built
|
||||
|
||||
**Status: DECIDED 2026-09-07.** Three decisions and one confirmation, in the
|
||||
order [[UX-01]]'s unmet acceptance criteria are numbered:
|
||||
|
||||
1. **UX-01 AC 3 (hide the optional fields behind HA's toggle) is WITHDRAWN as
|
||||
unsatisfiable**, not deferred. The target was arithmetically unreachable, and
|
||||
the part of it that *was* reachable would be bought with the upgrade path.
|
||||
The measurement is now **graded** in `tools/supervisor_shape_probe.py` §F
|
||||
rather than printed, so the withdrawal cannot be quietly reversed.
|
||||
2. **UX-01 AC 4's third clause is now BUILT**: `test_defaults_pin.py`, an AST
|
||||
check, with its red direction executed on every run.
|
||||
3. **UX-01 AC 5 / the CFG-04 double-literal hole is CONFIRMED CLOSED on
|
||||
`release/1.0`, by [[UX-02]]'s `bcee235`, not by UX-01's `fef6ef2`.** The
|
||||
attribution is corrected here; no work was re-done.
|
||||
4. **The 8 per-entry `inverters:` translations STAY.** They do not reach the
|
||||
Supervisor tab and cannot be made to; [[UX-02]]'s ingress panel is where they
|
||||
become visible.
|
||||
|
||||
Filed with UX-04. Card `6a9ec94617e9c48ed74ca5dd`. Every number below was
|
||||
measured on `release/1.0` at `6e7b233` on 2026-09-07, against
|
||||
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` — the version
|
||||
`ha info` reports on the live install — and against the 26-file check suite in
|
||||
`goodwe-addon-checks:1`. Nothing here contacted the live Supervisor at
|
||||
192.168.2.158, posted options, or restarted anything.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hiding: withdrawn, and why that is the *safe* answer
|
||||
|
||||
### 1.1 What AC 3 claimed
|
||||
|
||||
> "The visible count drops to ~15, with the rest behind HA's *show unused
|
||||
> optional configuration options* toggle."
|
||||
|
||||
### 1.2 What the tab actually renders
|
||||
|
||||
`config.yaml` declares **71 keys under `options:` and the same 71 under
|
||||
`schema:`**. The set that HA's toggle is *able* to hide — schema-only keys — is
|
||||
therefore **empty**:
|
||||
|
||||
```
|
||||
hideable = set(schema) - set(options) -> [] (0 of 71)
|
||||
```
|
||||
|
||||
On the deployed 0.7.0 tree the same measurement is 70/70. Nothing was hidden
|
||||
before UX-01, nothing is hidden after it, and no line of `fef6ef2` moved a key
|
||||
either way. AC 3 is not partially met; it is unstarted.
|
||||
|
||||
### 1.3 The rule that decides it, replayed rather than paraphrased
|
||||
|
||||
From the frontend bundle shipped **in this image**,
|
||||
`api/panel/frontend_latest/5680.*.js` (frontend 20250925.1):
|
||||
|
||||
```js
|
||||
_filteredSchema = (opts, schema) => schema.filter(e => e.name in opts || e.required)
|
||||
```
|
||||
|
||||
`opts` is the add-on's options **as Supervisor serves them** — the operator's
|
||||
stored blob after `options:` has been deep-merged into it. So a field is hidden
|
||||
only when it is **both** absent from `options:` **and** not `required`. The
|
||||
probe now runs that filter on this manifest rather than quoting it.
|
||||
|
||||
### 1.4 Reason one: the target was never reachable
|
||||
|
||||
Supervisor's own `UiOptions` marks `required` on **59** of the 71 fields; 12 are
|
||||
optional. The filter above shows a required field unconditionally. So the
|
||||
**floor** — what the tab renders with `options:` emptied *altogether*, which is
|
||||
the most hiding that is even expressible — is 59 fields.
|
||||
|
||||
| `options:` block | fields rendered |
|
||||
|---|---|
|
||||
| as shipped (71 keys) | **71** |
|
||||
| emptied entirely (0 keys) | **59** |
|
||||
| AC 3's target | ~15 |
|
||||
|
||||
AC 3 asked for a number roughly four times below the floor. No arrangement of
|
||||
`options:` reaches it. The criterion could not have been met by any
|
||||
implementation; it was mis-specified, and the honest disposal of a
|
||||
mis-specified criterion is withdrawal, not a partial gesture toward it.
|
||||
|
||||
### 1.5 Reason two: the 12 hides that *are* expressible cost the deep-merge
|
||||
|
||||
Moving a key out of `options:` is not free. `App.options` is
|
||||
`deepmerge(config.yaml defaults, the stored user options)`, so **a key absent
|
||||
from `options:` has no default for the Supervisor to merge**.
|
||||
|
||||
That is exactly the mechanism the 0.9.0 upgrade rests on. From `CHANGELOG.md`,
|
||||
the pre-deploy check run against this release on 2026-09-07:
|
||||
|
||||
> The reference install's stored blob has **70 keys, not 71**.
|
||||
> `delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running
|
||||
> 0.7.0 has never stored it — and it is REQUIRED in the schema, no trailing
|
||||
> `?`, which is the shape that becomes **exit 78**. It resolves to its
|
||||
> `options:` default of **185** because the Supervisor deep-merges.
|
||||
|
||||
Hiding a key is done by taking it *out* of `options:` and marking it `?`. That
|
||||
is, precisely, the edit that removes a key's merge default — the thing standing
|
||||
between a 0.7.0 box and exit 78 on upgrade. It would be traded for at most 12
|
||||
fewer rows on a form, on a site where the add-on is holding a real house's grid
|
||||
exchange at zero.
|
||||
|
||||
### 1.6 Reason three: it would promote 27 unpinned literals to load-bearing
|
||||
|
||||
And the cost compounds with §2 of this document. `test_defaults_pin.py` part 4
|
||||
ledgers **27 (module, key) pairs** in `app/` that still answer a missing option
|
||||
with an in-code literal. Today those literals are unreachable on a Supervisor
|
||||
install, because the deep-merge guarantees every `options:` key is present in
|
||||
the served blob. Remove keys from `options:` to hide them and those literals
|
||||
stop being dead code: they become **the actual source of the defaults on a live
|
||||
install** — the CFG-03 defect the same card's AC 4 exists to police, made
|
||||
load-bearing by the card's own AC 3.
|
||||
|
||||
### 1.7 What now grades this
|
||||
|
||||
`tools/supervisor_shape_probe.py` §F. The count was printed there from the day
|
||||
it was written — *"the Configuration tab renders 71 fields"* — and the probe
|
||||
exited 0 through UX-01's whole review while displaying the number that
|
||||
contradicted the criterion it was evidence for. **A measurement that is
|
||||
displayed but not asserted is how this passed review.** Three assertions
|
||||
replace the bare print:
|
||||
|
||||
```
|
||||
PASS every one of the 71 `schema:` keys is ALSO in `options:`, so schema-only
|
||||
- the only set HA's toggle can hide - is EMPTY. Hideable: []
|
||||
PASS ...so replaying the frontend's own _filteredSchema on the served options
|
||||
hides NOTHING: 71 of 71 fields render
|
||||
PASS ...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is
|
||||
expressible - the tab still renders 59 required fields. THAT is the
|
||||
floor, and AC 3 asked for ~15.
|
||||
```
|
||||
|
||||
Every number in them is computed from the manifest and from Supervisor's own
|
||||
`required` flag; none is typed. **The red direction was run**: deleting one key
|
||||
(`price_feed_url`) from `options:` and nothing else turns the first two to FAIL
|
||||
— `Hideable: ['price_feed_url']`, `70 of 71 fields render` — and the probe
|
||||
exits 1. The counterfactual is isolated: one key varied, nothing else.
|
||||
|
||||
So if a future editor empties `options:` to shorten the form, this goes red and
|
||||
somebody has to read §1.5 before merging.
|
||||
|
||||
---
|
||||
|
||||
## 2. The defaults pin: `test_defaults_pin.py`
|
||||
|
||||
### 2.1 What AC 4 asked for and what shipped
|
||||
|
||||
AC 4 has three clauses.
|
||||
|
||||
| clause | state before UX-04 |
|
||||
|---|---|
|
||||
| "every `schema:` key absent from `options:` has exactly one defaults-table entry" | **vacuous** — that set is empty (§1.2), so the clause is true of any tree, including one with no defaults at all |
|
||||
| "the table changed → red" | **works**, six times over, once per mirror in its own suite |
|
||||
| "no module carries its own literal fallback for a schema default — an AST check, not a grep" | **never built** |
|
||||
|
||||
The only literal scan in the repository before this card is
|
||||
`test_validate.py:1025`: a `re.findall` restricted to eight `maintenance_*`
|
||||
keys across three named files. It is a grep, it predates UX-01, and it was not
|
||||
written for this.
|
||||
|
||||
### 2.2 What an "in-code literal fallback" is, structurally
|
||||
|
||||
A call in `app/` taking a string literal that names a `schema:` key, with a
|
||||
constant in the next positional slot:
|
||||
|
||||
```python
|
||||
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
|
||||
```
|
||||
|
||||
Found by walking `ast.Call` nodes — so a multi-line call and a renamed `opts`
|
||||
are found, and the same text inside a comment or docstring is not. Two
|
||||
exclusions, both structural and both argued at the site:
|
||||
|
||||
* **`getattr(obj, "max_w", 0.0)`** reads an attribute off an object, not an
|
||||
option off a blob. Excluded by callee name.
|
||||
* **the empty string.** `opts.get("static_profile", "")` is Python's "not
|
||||
stated", and all six such sites in `validate.py` normalise it immediately —
|
||||
`str(opts.get(k, "") or "").strip()`. It cannot be a stale copy of a *number*,
|
||||
which is the whole of the CFG-04 defect class. Excluded by value. **`0` is
|
||||
not excluded**: `slew_w: 0` and `integrator_max_w: 0` are real shipped
|
||||
defaults with real meanings.
|
||||
|
||||
### 2.3 What it grades, measured on `release/1.0` @ `6e7b233`
|
||||
|
||||
```
|
||||
2. the mirrors, DISCOVERED from the tree rather than listed here
|
||||
6 table(s): circulation.py:CIRC_OPTION_DEFAULTS, delivery.py:DELIVERY_OPTION_DEFAULTS,
|
||||
maintenance.py:MAINT_OPTION_DEFAULTS, peakguard.py:PEAK_OPTION_DEFAULTS,
|
||||
price.py:PRICE_OPTION_DEFAULTS, watchdog.py:SIGN_OPTION_DEFAULTS
|
||||
30 mirrored key(s) of 71 in `schema:`
|
||||
3. the AST scan
|
||||
scanned 29 modules, 34 literal fallback site(s): 0 over mirrored keys,
|
||||
34 over unmirrored ones
|
||||
4. the CFG-03 ledger
|
||||
27 (module, key) pair(s) carry a literal for a key no mirror answers for
|
||||
```
|
||||
|
||||
Four properties, none of them vacuous:
|
||||
|
||||
1. **The mirrors are discovered, not listed.** Six suites already pin their own
|
||||
table to `config.yaml` — and every one of those pins names its table by
|
||||
hand, so a *seventh* table added tomorrow would be pinned by nobody and
|
||||
nothing would say so. This walks `app/` for module-level `*_OPTION_DEFAULTS`
|
||||
assignments and grades whatever it finds against `config.yaml`. That is the
|
||||
property the six hand-written pins cannot have.
|
||||
2. **No literal survives for a key a mirror already answers for.** 30 keys,
|
||||
0 violations.
|
||||
3. **The CFG-03 ledger is FROZEN, by (module, key) pair, not by count.** A
|
||||
count goes green again the moment somebody deletes one site and adds
|
||||
another. Line numbers are deliberately *not* in the key: a ledger that goes
|
||||
red on an unrelated reformat is a ledger people learn to re-bless without
|
||||
reading. The 27 pairs are the open CFG-03 backlog; they are **not** removed
|
||||
here, because that is behaviour change across `main.py`, `p1.py`,
|
||||
`inverter.py` and `validate.py` that no single card should make in passing.
|
||||
The set can now only shrink.
|
||||
4. **Every remaining literal is graded against `config.yaml`** — the half that
|
||||
catches CFG-04's actual failure mode, two copies of one number and one moves.
|
||||
|
||||
### 2.4 One divergence found, and it is new
|
||||
|
||||
```
|
||||
main.py target_grid_w: code 0 vs config.yaml -10
|
||||
```
|
||||
|
||||
`main.py:290` reads `target_grid_w=float(opts.get("target_grid_w", 0))` while
|
||||
`config.yaml` ships **-10**. A dict handed to `Controller` without the key holds
|
||||
net exchange at 0 W instead of the shipped -10 W bias into import. **It cannot
|
||||
reach an operator**: on a Supervisor install the deep-merge guarantees the key
|
||||
is present, and the only paths that hand over a partial dict are
|
||||
`sim/inject.py` and this repository's own fixtures. It is recorded in the check
|
||||
as the single permitted divergence, with the reason, rather than corrected —
|
||||
changing it alters the control loop's target and belongs to a card that grades
|
||||
the loop. A *second* divergence goes red.
|
||||
|
||||
### 2.5 ⭐ The red direction, shown rather than claimed
|
||||
|
||||
A check that has never been shown failing is not a check — which is exactly
|
||||
what clause one of this AC was. Two things follow from that:
|
||||
|
||||
**(a) The red direction runs on every invocation.** Part 5 feeds the scanner a
|
||||
synthetic module containing a deliberate violation of each rule and *requires*
|
||||
it to be flagged, in-process, through the same `literal_fallbacks()` the graded
|
||||
parts call. A scanner that goes blind — a renamed helper, an `ast` change, a
|
||||
`SCHEMA` that comes back empty — stops working here too, and the total site
|
||||
count is additionally graded against a floor.
|
||||
|
||||
**(b) A literal was planted in the real tree and the check went red.** Planted
|
||||
at `main.py:290`, `float(opts.get("maintenance_charge_w", 2000))` — a mirrored
|
||||
key:
|
||||
|
||||
```
|
||||
3. the AST scan: no module carries its own literal fallback for a MIRRORED schema default
|
||||
scanned 29 modules, 35 literal fallback site(s): 1 over mirrored keys, 34 over unmirrored ones
|
||||
ok the scanner is not blind: it found 35 sites (floor 20)
|
||||
main.py:290 get('maintenance_charge_w', 2000)
|
||||
FAIL no literal fallback survives for any of the 30 keys a mirror already
|
||||
answers for - those sites must index the mirror
|
||||
|
||||
1 FAILED (exit 1)
|
||||
```
|
||||
|
||||
And part 4's two directions, planted the same way with
|
||||
`opts.get("p1_host", "x")` and `opts.get("slew_w", 999)`:
|
||||
|
||||
```
|
||||
4. the CFG-03 ledger
|
||||
28 (module, key) pair(s) carry a literal for a key no mirror answers for
|
||||
FAIL no NEW module/key pair has started carrying a literal fallback [('main.py', 'p1_host')]
|
||||
main.py p1_host: code 'x' vs config.yaml ''
|
||||
main.py slew_w: code 999 vs config.yaml 0
|
||||
main.py target_grid_w: code 0 vs config.yaml -10
|
||||
FAIL every remaining literal still AGREES with config.yaml, except the 1
|
||||
divergence(s) recorded above it
|
||||
|
||||
(exit 1)
|
||||
```
|
||||
|
||||
The second of those is the CFG-04 defect class itself: a literal that has
|
||||
drifted away from `config.yaml` while the file still says something else. Both
|
||||
plants were removed; the tree is byte-identical to `6e7b233` apart from this
|
||||
card's own files.
|
||||
|
||||
---
|
||||
|
||||
## 3. CFG-04: closed on `release/1.0`, by UX-02, not by UX-01
|
||||
|
||||
UX-01's AC 5 claimed the double-literal `max_w` hole. It did not close it.
|
||||
`fef6ef2` touched `config.yaml`, `translations/`, `test_translations.py` and the
|
||||
probe — **no defaults code at all** — and on the 0.7.0 tree the triple mutation
|
||||
survives: 21 files, 0 RED.
|
||||
|
||||
**Confirmed on current `release/1.0` @ `6e7b233`, 2026-09-07.** The same triple
|
||||
mutation, `2000 -> 2500` at the three descendants of 0.7.0's
|
||||
`inverter.py:438` / `validate.py:383` / `validate.py:684`:
|
||||
|
||||
| 0.7.0 | `release/1.0` @ `6e7b233` |
|
||||
|---|---|
|
||||
| `inverter.py:438` `max_w=float(opts.get("max_w", 2000))` | `inverter.py:640` `raw = opts.get("max_w", 2000)` |
|
||||
| `validate.py:383` `cap = _number(opts, "max_w", 2000.0)` | `validate.py:466`, same line |
|
||||
| `validate.py:684` `max_w = _number(opts, "max_w", 2000.0)` | `validate.py:836`, same line |
|
||||
|
||||
Result — 26 files, **RED at `test_selfconfig.py`**, exit 1:
|
||||
|
||||
```
|
||||
1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the
|
||||
rail collapsing to the 2000 default under an explicit 400 W slew, so the box
|
||||
cannot be emptied into a quietly halved rail
|
||||
```
|
||||
|
||||
`git log -L 310,325:goodwe_controller/test_selfconfig.py` attributes that check
|
||||
to **`bcee235`, "UX-02: pin both halves of the max_w fix, because neither was
|
||||
graded", glenn schrooyen, 2026-09-06** — not to UX-01. The hole ships closed
|
||||
with the pending 0.9.0 deploy. **The AC is met by UX-02.** No work was re-done
|
||||
here; the mutation was run once, on a throwaway copy, to confirm it.
|
||||
|
||||
---
|
||||
|
||||
## 4. The 8 per-entry translations: they stay
|
||||
|
||||
### 4.1 The measurement, and it is negative
|
||||
|
||||
`translations/{en,nl}.yaml` carry a name and a description for all 8 per-entry
|
||||
keys of the repeatable `inverters:` section. They are loaded by Supervisor's own
|
||||
loader, they pass `SCHEMA_APP_TRANSLATIONS`, and `test_translations.py` grades
|
||||
their coverage. **They do not reach the screen**, and this is not a defect in
|
||||
them. `UiOptions` gives `inverters` `type: "schema"` with `multiple: True`, and
|
||||
the frontend's `_convertSchemaElement` sends exactly that case to a selector
|
||||
rather than to a nested `ha-form`:
|
||||
|
||||
```js
|
||||
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 `fields[name].label || name`, while
|
||||
`_convertSchemaElementToSelector` sets **neither `label` nor `translation_key`**.
|
||||
So an operator sees raw `name`, `setpoint_entity`, `max_w`, `bms_soc_floor`. The
|
||||
probe already asserts this, negatively and by name.
|
||||
|
||||
### 4.2 Decision: keep them. Four reasons.
|
||||
|
||||
1. **The Supervisor tab cannot be made to render them.** The gap is in
|
||||
somebody else's converter, not in this repository. There is no change here
|
||||
that fixes it; deleting the translations does not fix it either.
|
||||
2. **[[UX-02]]'s ingress panel is where they become visible**, and it renders
|
||||
the roster itself rather than handing it to `ha-selector-object`. The
|
||||
`fields:` block is the shape Supervisor's own schema defines for exactly
|
||||
this, and it is what a surface that wires `label` through consumes.
|
||||
3. **They cost nothing.** No runtime path reads them; they are two YAML blocks
|
||||
in files the Supervisor already parses, and the parse is graded green.
|
||||
4. **Deleting them would remove the only thing that catches a per-entry key
|
||||
added without a translation.** `test_translations.py` grades top-level and
|
||||
per-entry coverage symmetrically; drop the per-entry half and SAFETY-32's
|
||||
`bms_soc_floor` — a *required* per-entry key added after UX-01 — would have
|
||||
joined the roster with no label and nothing to say so.
|
||||
|
||||
This is a written decision, not a change. The tree is unmodified in
|
||||
`translations/`.
|
||||
|
||||
### 4.3 Not re-filed
|
||||
|
||||
`tools/supervisor_selfapi_probe.py` has been known red since OPT-09 — it
|
||||
hardcodes `len(_sub) == 7` against 8 per-entry keys. That is **TOOL-04**,
|
||||
already filed, and it is not re-filed or fixed here. It is named only so a
|
||||
reader of §4.1 knows why the *other* probe is the one quoted.
|
||||
|
||||
---
|
||||
|
||||
## 5. What this card did not do
|
||||
|
||||
* It did not remove a key from `options:`. See §1.5 — the deep-merge default is
|
||||
what makes the pending 0.9.0 upgrade safe on a 0.7.0 box.
|
||||
* It did not pay down the 27-pair CFG-03 ledger. See §2.3.
|
||||
* It did not correct `main.py`'s `target_grid_w` fallback. See §2.4.
|
||||
* It did not touch `translations/`. See §4.2.
|
||||
* It did not re-do CFG-04. See §3.
|
||||
* It did not touch hardware, the live Home Assistant at 192.168.2.158, or
|
||||
`sim/`. Both probe runs were throwaway containers over a read-only mount.
|
||||
+535
-3
@@ -40,6 +40,69 @@ hardware holds it there.
|
||||
|
||||
## Configuration
|
||||
|
||||
### ⚠️ There are TWO places to configure this add-on (UX-02)
|
||||
|
||||
Since **UX-02** the ingress panel has a **Configuration** page of its own,
|
||||
beside Home Assistant's *Configuration* tab. Both edit the **same** settings —
|
||||
there is one options blob, and neither surface is a copy of it.
|
||||
|
||||
| | Ingress panel → **Configuration** | Supervisor → **Configuration** tab |
|
||||
|---|---|---|
|
||||
| **Use it for** | everyday editing | recovery, and when the add-on is down |
|
||||
| **Sections, units, help** | real headings, units beside the boxes, the description under each field | one flat list of 71 fields, no headings, no units |
|
||||
| **Tells you a setting is wrong** | **yes — before you save**, next to the field | no. It knows types and ranges only |
|
||||
| **Can express a rule spanning two keys** | yes, because it runs the add-on's own validator | **no.** The schema grammar cannot say "must be at or below `max_w`" |
|
||||
| **Available when the add-on is not running** | **no** — it is served *by* the add-on | **yes** |
|
||||
| **Editing the inverter list** | cards you add and remove, with the per-entry labels rendered | a repeatable section whose per-entry keys render **raw** on frontend 20250925.1 |
|
||||
|
||||
**Which one is authoritative: neither.** The stored options blob is, and both
|
||||
are editors of it. `DECISIONS/UX-02-two-config-surfaces.md` is the record, with
|
||||
the measurements behind it.
|
||||
|
||||
The one thing you need to know in practice: **the panel refuses to save a
|
||||
configuration the add-on would not start on, and the Configuration tab does
|
||||
not.** The tab will happily store `maintenance_charge_w: 2500` beside
|
||||
`max_w: 2000`; the add-on then exits 78 on its next start, the panel goes down
|
||||
with it, and the tab is where you fix it. That asymmetry is the reason both
|
||||
exist.
|
||||
|
||||
#### What the panel does when you press Save
|
||||
|
||||
1. It runs the **shipped** validator — the same `validate_startup_config()` and
|
||||
`validate_unit_ranges()` the add-on runs at startup, not a copy of them — and
|
||||
if anything is refused, **nothing is written** and your edits stay on screen.
|
||||
2. It re-reads the stored blob and refuses if it changed while you were editing
|
||||
— someone in the Configuration tab, or you on another device. It names the
|
||||
keys that moved and shows both values; pressing Save again then writes yours.
|
||||
3. It writes the whole blob. It does **not** restart: `POST …/options` replaces
|
||||
the stored options, and a key left out would revert to the shipped default,
|
||||
so the panel always sends everything it read.
|
||||
|
||||
#### ⚠️ Saving costs nothing. Applying costs a control interruption
|
||||
|
||||
Stored settings take effect when the add-on next starts. The panel shows which
|
||||
stored settings are not what the add-on is currently running, and **Restart to
|
||||
apply** is a separate button with its own confirmation — because the wind-down
|
||||
commands **0 W on every inverter** before the process exits, and the battery
|
||||
does nothing until it is back. Nothing else is changed by that restart: a
|
||||
latched sign-disagreement failsafe is restored from the Store on the way back
|
||||
up, not cleared.
|
||||
|
||||
#### Two range checks can be skipped, and the page says so
|
||||
|
||||
Two of the refusals compare a configured ceiling against the setpoint device's
|
||||
**own** reported range, which means asking Home Assistant. If ESPHome has not
|
||||
come up, that range is unknown and those two checks are skipped — on the page
|
||||
and at startup, identically. The page prints a note saying so rather than
|
||||
looking green for a reason you cannot see. If the device answers later and
|
||||
disagrees, the add-on refuses to start then, with the same sentence.
|
||||
|
||||
#### On a phone
|
||||
|
||||
One column, big targets, sections collapsed until you open them, and the
|
||||
refusals pinned where you cannot scroll past them. It is the screen for
|
||||
standing in front of the inverter.
|
||||
|
||||
### What the Configuration tab actually shows you (UX-01)
|
||||
|
||||
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
|
||||
@@ -47,12 +110,13 @@ 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
|
||||
rather than assumed — `tools/supervisor_shape_probe.py` section F GRADES (it
|
||||
used only to print) 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 |
|
||||
| **All 71 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 |
|
||||
@@ -73,6 +137,7 @@ either direction, and the check is mutation-tested both ways.
|
||||
| `soc_entity` | yes | Battery state of charge — use the **ESP32's own read** |
|
||||
| `batt_entity` | yes | Battery power — again the ESP32's read, `+` = discharging |
|
||||
| `batt_invert` | | Flip if needed |
|
||||
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
|
||||
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
|
||||
|
||||
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
|
||||
@@ -98,11 +163,13 @@ inverters:
|
||||
soc_entity: sensor.goodwe_a_soc
|
||||
batt_entity: sensor.goodwe_a_battery_power
|
||||
max_w: 5000
|
||||
capacity_kwh: 15.36
|
||||
- name: inverter-b
|
||||
setpoint_entity: number.goodwe_b_setpoint_w
|
||||
soc_entity: sensor.goodwe_b_soc
|
||||
batt_entity: sensor.goodwe_b_battery_power
|
||||
max_w: 4600
|
||||
capacity_kwh: 20.48
|
||||
```
|
||||
|
||||
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
|
||||
@@ -166,6 +233,54 @@ in the log rather than silent, but it is an outage until someone puts the
|
||||
single-inverter entity back in that field. **Before downgrading, write down the
|
||||
`setpoint_entity` of the unit you want to keep running.**
|
||||
|
||||
#### How the site target is divided between the packs (OPT-09)
|
||||
|
||||
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
|
||||
version before it the split is a fixed 50/50 regardless of what either pack can
|
||||
do** — that is the defect this section describes the fix for, so if the add-on
|
||||
you are running reports 0.7.0, none of what follows is happening yet. From 0.8.0
|
||||
the split is driven by two separate things, and it is worth knowing which is
|
||||
which, because only one of them is a rule:
|
||||
|
||||
* **The envelope — a permission.** A pack whose own state of charge is at or
|
||||
under its own `bms_soc_floor` is **not asked to discharge**; a pack at 100 %
|
||||
is **not asked to charge**. Its share goes to the sibling, inside that
|
||||
sibling's own rating. This is absolute: the allocator cannot exceed it.
|
||||
* **The weights — a preference.** Between those limits, each pack is asked for
|
||||
the fraction of the site target that matches its **energy headroom**: the kWh
|
||||
it has above its own cut-out when the site is discharging, the kWh it has
|
||||
below full when the site is charging. That is what `capacity_kwh` is for.
|
||||
|
||||
⚠️ **`capacity_kwh` is optional, and it is all-or-nothing.** Leave it out
|
||||
everywhere and the add-on has no opinion about the split — the target is divided
|
||||
evenly, exactly as it was before. Fill it in on **every** entry or on none: a
|
||||
pack measured in kWh beside one that is not is not a comparison, and the add-on
|
||||
refuses to start rather than ignore half of what you typed.
|
||||
|
||||
⚠️ **Why energy and not the rating.** The two packs here pull in opposite
|
||||
directions — A is 5000 W with 15.36 kWh, B is 4600 W with 20.48 kWh, so B has
|
||||
about a third more energy and about 8 % less power. Dividing by rating hands the
|
||||
work to A; dividing by energy hands it to B. The question a split is answering is
|
||||
"which pack can sustain its share for the hour", so it is the energy one. Each
|
||||
inverter's own rating still binds its own share, separately, through `max_w`.
|
||||
|
||||
⚠️ **What the weights will NOT do for you, stated plainly.** They are a comfort
|
||||
choice about which pack does the work. They are **not** a way to make the site
|
||||
deliver. A unit that is at its energy limit — or that has simply stopped
|
||||
responding — does not hand its load to its sibling: the loop sees the shortfall,
|
||||
freezes, and the site runs short by that unit's share. Weighting a failing unit
|
||||
down does not fix that, because when the healthy unit reaches its own rating the
|
||||
allocator hands the remainder to the down-weighted one anyway rather than leave
|
||||
watts unplaced. What you get instead is honesty: the action log reports the watts
|
||||
that could not be placed (`ALLOCATOR: +2000 W of the site target could not be
|
||||
placed on any inverter`), and a unit that accepts its share and does not produce
|
||||
it is named by the delivery alarm (SAFETY-42, above).
|
||||
|
||||
⚠️ **At one inverter none of this applies and nothing changed.** There is no
|
||||
split to weight, `capacity_kwh` is not read, and no envelope is closed —
|
||||
self-consumption still discharges down to the battery's own cut-out exactly as
|
||||
it always has.
|
||||
|
||||
### What appears in Home Assistant at two inverters
|
||||
|
||||
**Nothing you already have changes.** The eight entities this add-on has always
|
||||
@@ -295,6 +410,42 @@ the P1 sample already went stale. Size the pair together: the ESP32's own
|
||||
watchdog commands 0 W after ~30 s of silence from this add-on regardless, and
|
||||
that layer is unaffected by either option.
|
||||
|
||||
#### How long a FROZEN inverter power register takes to reach 0 W
|
||||
|
||||
A third clock, and it is a different failure from the one above: the entity is
|
||||
still there and still answering — it has simply stopped changing. Before
|
||||
SAFETY-37 that read as a healthy measurement for ever, which is what happened on
|
||||
2026-09-02 (HW-01): a value held for 6 h 25 m while the pack drained to its
|
||||
floor, and every detector read healthy throughout.
|
||||
|
||||
`Inverter.batt` — the inverter's own AC power, register `0x0518` — is now
|
||||
withheld from the control path when **all three** of these are true:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the reading has not changed for | **35 s** |
|
||||
| **and** the command standing on that inverter is over | **250 W** |
|
||||
| **and** that command has travelled, since the reading last moved, over | **250 W** |
|
||||
|
||||
All three, because a converged site legitimately repeats one power figure for
|
||||
minutes — measured at up to **263 s** over 30 days of this house's own meter —
|
||||
and a site at rest is not owed movement. It is only when the controller has
|
||||
*asked the inverter to move* and the register has not answered that the reading
|
||||
is treated as missing.
|
||||
|
||||
**A withheld reading is a MISSING INPUT**, so it joins the `stale_input_s` clock
|
||||
above: 35 s to withhold, then 15 s of hold, then 0 W. It clears itself the
|
||||
instant the register reports a different value — one watt is enough, and this
|
||||
register dithers ±1 W while holding — so a working inverter clears it on its
|
||||
next 10 s publish. There is **no option to configure**: the 35 s is derived from
|
||||
the ESPHome publish interval and the two watchdog windows it has to stay inside,
|
||||
and the add-on's own checks refuse a value that does not.
|
||||
|
||||
⚠️ **What this does NOT catch: a register whose value is WRONG rather than
|
||||
STOPPED**, including a dead publisher whose last two samples happen to
|
||||
alternate. Detecting that needs an arrival stamp inside the ESP32 firmware
|
||||
itself, which is TEL-06.
|
||||
|
||||
There is **no fallback to an inverter-side power figure**, deliberately. The
|
||||
inverter's own AC power tracks its battery almost perfectly and the real meter
|
||||
hardly at all, so a controller that failed over to it would be regulating
|
||||
@@ -437,7 +588,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||||
| `step_w` | 10 | Quantisation |
|
||||
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
||||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1** |
|
||||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1**. Every value this file accepts (1-10) is below the freeze's own release bound of 12 cycles, so no accepted setting can stop the block engaging - see SAFETY-13, below |
|
||||
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
|
||||
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
|
||||
| `cycle_floor_s` | 15 | The longest the loop may go without evaluating. 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 ~10 s; it must stay under `meter_max_age_s`, past which there is no reading to evaluate anyway |
|
||||
@@ -449,6 +600,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
| `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 |
|
||||
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
|
||||
|
||||
#### The sign-disagreement failsafe
|
||||
|
||||
@@ -548,6 +700,70 @@ agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
|
||||
10.5 % and 0.0 %. There is no reason to move either value without a capture that
|
||||
says so.
|
||||
|
||||
|
||||
#### ⚠️ A unit that stops DELIVERING is a third fault again (SAFETY-42)
|
||||
|
||||
The two checks above both key on **direction**. A unit sitting at **0 W against
|
||||
a non-zero share** has no direction: it is not pushing the wrong way, it is
|
||||
**absent**. So the per-unit sign gate never opens (it needs opposition on both
|
||||
sides of `unit_sign_disagree_w`), the site sum still agrees with the meter in
|
||||
direction, and circulating power is exactly **zero** — that quantity needs two
|
||||
units pulling *against* each other, and one of these is pulling nowhere.
|
||||
|
||||
The only thing that does fire is the **saturation freeze**, and this add-on's own
|
||||
code calls that an alarm operators learn to ignore, because a loop can be legally
|
||||
saturated all day: a cold pack, a curtailed inverter, an export limit.
|
||||
|
||||
So `delivery_hold_s` grades a different quantity — **how many watts of a
|
||||
discharge share did not arrive** — and makes it readable with a reduction that
|
||||
only exists once there is more than one inverter:
|
||||
|
||||
> a unit is named while it is short of its share by more than `saturation_w`,
|
||||
> **with nothing this add-on can read to explain it**, and the units in that
|
||||
> state are **fewer than the units that were asked to discharge**.
|
||||
|
||||
Both halves matter, and the second is why this cannot fire on a
|
||||
one-inverter install at all:
|
||||
|
||||
- **"nothing explains it"** is what keeps a legitimately empty pack out. A pack
|
||||
at or below its own `bms_soc_floor` cannot discharge, and that is the *only*
|
||||
fact this add-on holds that distinguishes an empty pack from a wedged one —
|
||||
the two are otherwise identical at the unit and at the meter, watt for watt.
|
||||
A unit the add-on has already dropped from the split, or parked by a sign
|
||||
trip, is likewise accounted for.
|
||||
- **"fewer than were asked"** is what keeps a whole-site cause out. A derate this
|
||||
add-on cannot see — thermal, grid-code, DC-side — hits every unit. One box that
|
||||
has stopped listening does not. If *every* unit that was asked is short and
|
||||
unexplained, nothing is reported: there is no instrument here that tells that
|
||||
from a plant which simply cannot deliver.
|
||||
|
||||
**It reports. It does not act.** Commanding an inverter that has stopped
|
||||
listening achieves nothing — that happened on this hardware once for 6 h 25 m —
|
||||
and the only re-allocation this add-on can express is all-or-nothing, which
|
||||
would throw away the output of a unit delivering *part* of its share. So the
|
||||
loop is untouched, the sibling keeps regulating, and the site keeps running
|
||||
**short by that unit's share** until somebody looks. The status page shows it as
|
||||
a caution rather than a failure for exactly that reason: the controller has not
|
||||
stopped.
|
||||
|
||||
The report **survives a Home Assistant restart** (`unit/<name>/delivery_tripped`
|
||||
in the add-on's own state file) and the notification is re-raised on the next
|
||||
start, because a `persistent_notification` is in memory and that is precisely how
|
||||
the 2026-09-02 outage went unseen. It is cleared by pressing **Start**, like the
|
||||
other latches.
|
||||
|
||||
The banner reads **`NOT DELIVERING — <unit> …`** as a *warning*, not a failure,
|
||||
and it **displaces** `Running — holding grid at zero`. That last sentence is a
|
||||
claim the site cannot make while a unit is absent, and a page that goes on
|
||||
reading healthy while the fault runs is the whole shape this check exists to
|
||||
remove.
|
||||
|
||||
**Charge shares are not graded**, and that is a stated gap rather than an
|
||||
oversight: there is no configured charge ceiling anywhere in this add-on, so a
|
||||
pack in constant-voltage taper — which legitimately takes hundreds of watts less
|
||||
than it is told, for tens of minutes — could not be explained, and arming that
|
||||
direction would fire on the taper of every maintenance charge.
|
||||
|
||||
#### Saturation is counted in cycles, not seconds
|
||||
|
||||
The specification states the saturation window as **"> 10 s"**. This add-on counts
|
||||
@@ -592,6 +808,94 @@ correct toward that wrong value instead of holding a stale command. It is bounde
|
||||
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
|
||||
safety property.
|
||||
|
||||
#### When the freeze lets go, and when it deliberately never does (SAFETY-13)
|
||||
|
||||
While the loop is frozen the command's **magnitude may fall but never rise**. That is
|
||||
the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with
|
||||
nothing commanded there is no magnitude left to fall, so a charge cannot begin. That
|
||||
refusal is deliberate. Commanding 0 W while the inverter reports hundreds of watts
|
||||
means something is driving the actuator that this add-on did not ask, and starting to
|
||||
push power the other way into it is how two controllers end up fighting over one
|
||||
actuator — the configuration §1.5 of the specification rules out.
|
||||
|
||||
**What was missing was an end to it.** Nothing cleared the block: not the error growing,
|
||||
not the integrator moving, not time. The exit is now explicit, and *which* freezes get
|
||||
one is the decision:
|
||||
|
||||
| the inverter is… | can it clear itself? | what happens now |
|
||||
|---|---|---|
|
||||
| doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap | **yes.** The command is pinned at something the inverter can reach, so it catches up and the freeze clears | unchanged: frozen for as long as it lasts, with no exit |
|
||||
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **not necessarily.** If another writer is on the actuator, clearing the freeze needs the inverter to track *our* command and the other writer is what stops it, so nothing ends it | the freeze releases once the divergence has outlasted the plant that could otherwise have explained it — see *what `freeze-released` actually tells you*, below, because this row is also where our own ramp lands |
|
||||
|
||||
The second row's threshold is the plant's own measured settling time — **the slower of
|
||||
the two directions it was measured in.** The 2026-08-22 step test timed the same 1.78 kW
|
||||
step both ways: about 18 s to settle going up, about 23 s coming back down. 23 s is the
|
||||
one that belongs here, because the question this bound answers is *how long may the plant
|
||||
still be the explanation*, and the safe answer is to keep believing the plant for as long
|
||||
as the plant has ever been seen to take. At the measured 2 s meter tick that is
|
||||
**12 cycles** (`control.py`, `SETTLE_CYCLES`).
|
||||
|
||||
⚠️ **The first version of this feature used 18 s, and 9 cycles was wrong in the direction
|
||||
that accuses.** Nine cycles is three short of the plant's own down-ramp, so an ordinary
|
||||
load step on a **single** inverter — the command falling through zero while the pack is
|
||||
still coasting the old way — could produce `freeze-released` with nothing else on the bus
|
||||
at all. Driven in the container against a model of the measured plant, one inverter and
|
||||
no second writer anywhere: over 272 load-step pairs the 9-cycle bound produced a release
|
||||
in 62 of them and the shipped 12-cycle bound in 2.
|
||||
|
||||
**Releasing the output freeze does not unfreeze the loop.** The *integrator* freeze is
|
||||
untouched, so the command is free to move only as far as an accumulator that stopped
|
||||
accumulating cycles ago, one slew step per cycle. Measured on the simulator at the live
|
||||
install's shape (`max_w 5000`, `slew_w 400`, `target_grid_w -10`), with another writer
|
||||
holding the pack at +700 W and 2 kW of export standing: the command holds at 0 W while
|
||||
the counter climbs, starts on the **twelfth** saturated cycle, walks 0 → −400 → −800, and
|
||||
settles at **−1190 W** against a frozen integrator of −1194 W — not at the 5000 W rail.
|
||||
Before this change the same run held 0.0 W on every cycle, for as long as the other
|
||||
writer stayed.
|
||||
|
||||
⚠️ **The site still does not reach its target in that state, and it is not supposed
|
||||
to.** What the exit buys is that the battery stops being idle while the site exports;
|
||||
what it does not buy is correct regulation against an actuator somebody else is also
|
||||
writing to. A standing grid error with another writer on the bus is the symptom of a
|
||||
configuration this add-on refuses to support, not a tuning problem.
|
||||
|
||||
#### What `freeze-released` in the action log actually tells you
|
||||
|
||||
**It says: the inverter's power did not match the command for longer than this plant has
|
||||
ever been measured to take to settle.** That is all it says. It is a reason to go
|
||||
looking; it is not a finding, and in particular it is **not** proof that a second writer
|
||||
exists.
|
||||
|
||||
The test behind it compares the reading against **one cycle's** command, and the pack
|
||||
carries more than one cycle of history. So our own power, arriving late, can look like
|
||||
somebody else's:
|
||||
|
||||
- **A big, fast load step is the ordinary innocent cause.** The command falls through
|
||||
zero while the pack is still coasting in the old direction, and for a few cycles the
|
||||
reading has the sign the command *used* to have. Measured, one inverter, nothing else
|
||||
on the bus: eight cycles after a +1990 W command the command was 0 and the pack still
|
||||
read +522.3 W.
|
||||
- **A plant slower than the one that was measured does it too.** The 12-cycle bound is
|
||||
derived from a 23 s down-ramp. A colder or larger pack that takes longer than that will
|
||||
produce releases on its own, and no constant read off the 2026-08-22 measurement can
|
||||
prevent it. In the same container sweep, holding the bound at 12 and lengthening only
|
||||
the plant's down-ramp, single-inverter releases went from 8 of 320 scenarios at the
|
||||
measured 23 s to 59 of 320 at 34 s and 268 of 320 at 50 s.
|
||||
|
||||
**So, in order:** if you see `freeze-released` **once, during or just after a large load
|
||||
or PV swing**, that is most likely the loop's own ramp and needs nothing. If you see it
|
||||
**standing** — cycle after cycle, at a steady load, with a standing grid error the
|
||||
battery is not correcting — then look for the second writer: another controller, a
|
||||
scheduler in the inverter's own app, or an installer tool left connected. That is the
|
||||
configuration §1.5 of the specification rules out, and it is the one this exit exists to
|
||||
stop the loop stalling on forever.
|
||||
|
||||
⚠️ **No `saturation_cycles` you can set removes the block.** This file accepts 1-10, and
|
||||
all of those are below the 12-cycle release bound, so the freeze always engages before
|
||||
its own exit is due. (The first version of this feature had the bound at 9, where a
|
||||
setting of 9 or 10 *did* silently delete the block; moving the bound to 12 closed that
|
||||
by construction rather than by warning about it.)
|
||||
|
||||
#### Why `target_grid_w` is not zero
|
||||
|
||||
The deadband is a one-way ratchet: any resting point inside it holds until
|
||||
@@ -1257,9 +1561,237 @@ is, and a **Commissioning** checklist that names any problem in words. It also
|
||||
carries the three buttons: start/stop control, force a maintenance cycle, and
|
||||
abort one.
|
||||
|
||||
Since **UX-02** it has a second page, **Configuration** — see *There are TWO
|
||||
places to configure this add-on*, above. ⚠️ **It adds nothing you can do to the
|
||||
inverter.** The configuration page reads and writes the add-on's own settings
|
||||
and can restart the add-on; the set of commands that reach the hardware is
|
||||
exactly the three buttons it always had.
|
||||
|
||||
## Status entities
|
||||
|
||||
If an MQTT broker is available the add-on publishes setpoint, grid power,
|
||||
battery power, circulating power, state of charge, maintenance phase and
|
||||
controller status by MQTT discovery. This is observability only — the controller works fine without a
|
||||
broker, and MQTT problems can never affect control.
|
||||
|
||||
## Before you commission inverter B: `tools/rehearse_roster.py` (ARCH-02)
|
||||
|
||||
The day the `inverters:` roster is declared, two things change that are
|
||||
**one-way on a live box and invisible until after the restart that applies
|
||||
them**: the store migration renames the keys that hold the SAFETY-02 latch and
|
||||
the maintenance history, and the entity ids Home Assistant composes change
|
||||
shape. This tool runs the **real `Controller`** through that transition against
|
||||
a **copy** of the state file and reports the difference.
|
||||
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v <repo>:/w:ro \
|
||||
goodwe-addon-checks:1 sh -c 'cd /w/goodwe_controller && \
|
||||
python3 tools/rehearse_roster.py \
|
||||
--state /w/state-copy.json \
|
||||
--before /w/live-options.json \
|
||||
--after /w/roster-options.json \
|
||||
--live-entities /w/live-entities.txt \
|
||||
--reverse --round-trip'
|
||||
|
||||
Exit `0` clean · `1` at least one finding · `2` could not run. ⚠️ **`2` is not a
|
||||
pass.**
|
||||
|
||||
### The owner steps, because this session could not take them
|
||||
|
||||
⚠️ **The rehearsal is only as good as the state it is pointed at, and nothing in
|
||||
this repository can fetch that.** SSH to the live box is blocked by policy and
|
||||
that is correct — `/data/state.json` is the file the running add-on is writing.
|
||||
So three things have to be exported by hand, onto the machine that runs the
|
||||
check, **before** the roster is declared:
|
||||
|
||||
1. **The state file.** From the Supervisor host:
|
||||
|
||||
ha addons info local_goodwe_controller # confirm the slug first
|
||||
docker cp addon_local_goodwe_controller:/data/state.json ./state-copy.json
|
||||
|
||||
or take it out of a full add-on backup, which is the same bytes. ⚠️ **Copy
|
||||
it, never move it, and never write to the original.** The tool opens the path
|
||||
it is given exactly once and does all of its writing on a scratch copy — but
|
||||
the safe habit is to hand it a file the add-on has never heard of.
|
||||
|
||||
2. **The options blob**, as `--before`. It is not reachable through the Home
|
||||
Assistant REST API and no entity carries it; export it from the Supervisor,
|
||||
the same way `tools/predeploy_check.py` needs it. Write the roster you intend
|
||||
to deploy into a second file and pass it as `--after`.
|
||||
|
||||
3. **The entity list**, as `--live-entities` — one entity id per line:
|
||||
|
||||
Developer tools -> Template:
|
||||
{{ states | map(attribute='entity_id') | select('search','goodwe') | join('\n') }}
|
||||
|
||||
⚠️ **Omitting it is a REFUSAL (exit 2), not a quieter run.** The orphan check
|
||||
is graded against the ids the install actually has, and without that file it
|
||||
is not graded against reality at all — so the tool will not run rather than
|
||||
hand back a clean bill of health for a half of the transition it did not
|
||||
check. If you genuinely have to run without it, pass `--no-live-entities`:
|
||||
the run then carries a standing banner in the report header **and** on the
|
||||
exit line saying the orphan check was not graded. The asymmetry used to be
|
||||
the other way round — an unreadable file refused, a missing flag passed with
|
||||
exit 0 and no disclosure at all — and the owner runs this once, at
|
||||
commissioning, under time pressure.
|
||||
|
||||
The install carries roughly 49 `goodwe*` entities and this add-on announces
|
||||
eight of them. The tool separates the ids it owns from the ids it does not,
|
||||
rather than claiming orphans it cannot cause.
|
||||
|
||||
### The reference, which is loaded on every run
|
||||
|
||||
`--reference` defaults to `tools/rehearsal/announced-reference.txt` and is read
|
||||
**whether or not** you pass an entity list. It records what the build is
|
||||
supposed to announce, three columns per row:
|
||||
|
||||
<entity_id> <unique_id> <device identifier>
|
||||
|
||||
⚠️ **All three, and the second and third are the ones an entity_id-only list
|
||||
cannot see.** The tool's own before/after diff is composed twice by the same
|
||||
build, so a change to the composition moves both sides equally and the diff
|
||||
reports nothing. Worse, two changes orphan an install *without moving a single
|
||||
composed entity_id*: renaming `mqtt.BASE` (every `unique_id` changes) and
|
||||
changing `DEVICE["identifiers"]` (HA sees a new device). Home Assistant keys its
|
||||
entity registry on `unique_id` and its device registry on the identifier, so
|
||||
either one creates a **new** entity beside the old — and because discovery is
|
||||
published `retain=True` with no removal payload, the old config stays on the
|
||||
broker and the old entity stays in the registry, still named by every automation
|
||||
that referenced it, never updating again. Both were measured coming back
|
||||
**exit 0** before the reference carried three columns.
|
||||
|
||||
`object_id` is deliberately **not** a column: `app/mqtt.py` records that HA
|
||||
2026.8 does not honour it in a discovery payload, so renaming one is a true
|
||||
no-op, and `test_rehearse.py` requires that mutation to **survive** — otherwise
|
||||
"the reference notices any edit to `mqtt.py`" would be indistinguishable from
|
||||
"the reference notices what HA keys on".
|
||||
|
||||
⚠️ **No result in this repository was produced against real state.** Everything
|
||||
recorded here and in `test_rehearse.py` was measured on the synthetic fixtures
|
||||
in `tools/rehearsal/`.
|
||||
|
||||
### What it confirms on the synthetic fixtures
|
||||
|
||||
⚠️ **This is a SECOND INSTRUMENT reaching a conclusion this repository already
|
||||
holds, not a discovery.** `test_maintenance.py` section 13 — *"RECORDED, NOT
|
||||
FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a
|
||||
LATCH"* — already states it, already names `namespaced_latched`, and already
|
||||
carries the sentence *"the guard is defeated by the very key the defect wrote"*.
|
||||
That file is on `release/1.0` and this card does not touch it. What is new here
|
||||
is that the finding is now reachable **from a state file the owner supplies**
|
||||
rather than only from a fixture built in a test, and that it is reached by a
|
||||
different route: a full `Controller` start diffed against its predecessor,
|
||||
rather than assertions over a `MemStore`. Two instruments, one answer.
|
||||
|
||||
**The finding.** A pre-DEV-02 flat store that declares a multi-inverter roster
|
||||
comes up with its SAFETY-02 latch **DISARMED**, unless the first roster entry
|
||||
happens to be named `inverter-a`. `Controller.__init__` calls
|
||||
`migrate_unit_keys(store, UNIT_A)` with a hardcoded constant, so the latch lands
|
||||
in `unit/inverter-a/sign_tripped`; a roster of `["garage", "shed"]` reads
|
||||
`unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()` —
|
||||
the guard written for exactly this — sees the migration's own misdirected key
|
||||
satisfy its `namespaced_latched` test and returns without re-arming. The file
|
||||
says `sign_tripped: true`, the banner is green, and nothing is held at 0 W.
|
||||
|
||||
**Both routes reach it, and they are NOT the same defect.** The rehearsal
|
||||
reports them as separate legs because they need separate fixes:
|
||||
|
||||
| leg | how it gets there |
|
||||
|---|---|
|
||||
| `SAME-STEP` | the roster is declared in the same step as the upgrade. The migration runs, and is handed `UNIT_A`. |
|
||||
| `STAGED` | the flat config runs once under 0.7.0+ first. That start stamps `schema_version = 1`, so the roster start's migration takes its `from_version >= SCHEMA_VERSION` early return and **the latch never moves at all**. |
|
||||
|
||||
⚠️ **So "hand `migrate_unit_keys()` the roster's own first name" is NOT the
|
||||
whole fix, and the rehearsal measures that rather than asserting it.**
|
||||
`test_rehearse.py` part 5b builds exactly that change and reports:
|
||||
|
||||
LEG STAGED: DISARMED
|
||||
LEG SAME-STEP: clean
|
||||
|
||||
The staged path never reaches the line that was changed. A fix that stops there
|
||||
closes the half an operator is least likely to take and leaves the half that
|
||||
"upgrade first, declare the roster later" produces. The guard side —
|
||||
`namespaced_latched` counting a namespace that is not on the roster — is what
|
||||
covers both, and a change there turns every SAFETY-44 check in part 5 red, which
|
||||
is what a fix should do to a pin that specifies a defect.
|
||||
|
||||
The fix belongs to its own card, shown red first. Part 5 pins the current
|
||||
behaviour as a requirement so it cannot drift green by accident.
|
||||
|
||||
### The reverse direction: the retreat is supported, the round trip is not
|
||||
|
||||
If commissioning B goes wrong, **backing out to the flat single-inverter config
|
||||
is supported and does not corrupt state.** Measured on the fixtures:
|
||||
|
||||
* the SAFETY-02 latch and the first pack's maintenance history both survive,
|
||||
because `store.py` rule 2 keeps the flat spellings **live** rather than merely
|
||||
present — `maintenance.UnitHistory.mirror_flat` writes them on every change,
|
||||
so a single-FSM add-on finds a current history rather than one dated the day
|
||||
the roster was declared;
|
||||
* **it orphans eight Home Assistant entities** — the four per-unit rows for each
|
||||
of the two units. **Measured:** the add-on stops announcing them and stops
|
||||
publishing their state, so their history gaps and any automation whose
|
||||
trigger names them stops firing, silently and without an error.
|
||||
|
||||
⚠️ **What is NOT measured, and was previously overstated here:** whether
|
||||
advancing again restores them. An earlier draft of this section said the
|
||||
orphaning was "not recoverable by advancing again: HA keeps the id it composed
|
||||
at first discovery" — that reasoning cuts the other way, since keeping the
|
||||
composed id is precisely what would let a re-advertised entity land back on
|
||||
it, and discovery is published `retain=True` with no removal payload. Nothing
|
||||
in this repository has tested a re-advance against a real Home Assistant, so
|
||||
the claim is withdrawn rather than restated in the other direction. Plan the
|
||||
retreat around the measured part: **the eight rows stop updating for as long
|
||||
as you are retreated.**
|
||||
|
||||
⚠️ **What is NOT supported is the round trip.** A maintenance cycle that
|
||||
completes *while the install is rolled back* is **discarded** when it
|
||||
re-advances: `maint_schema_version` is already stamped, so `migrate_maint_keys()`
|
||||
takes its early return and the pack reads the namespaced history from before the
|
||||
retreat. The pack is then recalibrated on a schedule that does not know it
|
||||
already was. **So: retreat freely, but treat a retreat as ending the current
|
||||
maintenance bookkeeping — and if a cycle ran during it, correct
|
||||
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
|
||||
decision, not a discovery made at the inverter.
|
||||
|
||||
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
|
||||
|
||||
Two ways a deploy has gone wrong on this system, both of which the Supervisor
|
||||
reports as success:
|
||||
|
||||
**The silent no-op.** The Supervisor keys the built image by `version:` in
|
||||
`config.yaml`. `ha apps update` against an unchanged version installs nothing
|
||||
and says it worked. This happened twice: PKG-06 (six merges behind an unbumped
|
||||
version) and PKG-07 (six again). Both times a human noticed. Nothing else would have.
|
||||
|
||||
**The refused config.** A config the shipped validator rejects is
|
||||
`SystemExit(78)` — the add-on does not start. ⚠️ **And the ingress panel goes
|
||||
with it**, because the add-on serving it is not running, so recovery means the
|
||||
Supervisor UI. SAFETY-39's rule 10 made this reachable from a config that
|
||||
started fine on the previous version: 0.7.0's own default `slew_w: 1000`
|
||||
exceeds the 555.6 W ceiling on a 5000 W rail.
|
||||
|
||||
Run it inside the container, so the validator half actually runs:
|
||||
|
||||
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
|
||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||
--deployed-version 0.7.0 --options /w/live-options.json
|
||||
|
||||
Export `live-options.json` from the Supervisor first — the options blob is not
|
||||
reachable through the Home Assistant REST API, and no entity carries it.
|
||||
|
||||
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
|
||||
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
|
||||
add-on's dependencies the validator half skips and returns 2 rather than
|
||||
pretending; `test_version_gate.py` pins that a skipped check can never exit 0,
|
||||
because the first draft of that test reported four silent skips as passes.
|
||||
|
||||
⚠️ **Why this is not a CI check.** The obvious gate — "a pull request touching
|
||||
runtime code must bump the version" — is the wrong shape here. This project
|
||||
deliberately batches many merges into one deploy, so a per-PR bump would put
|
||||
every branch in conflict over one line and still not prevent the failure, which
|
||||
happens at deploy and not at merge. The check belongs where the mistake is made.
|
||||
|
||||
⚠️ This file's own change does **not** need a version bump, and the gate agrees:
|
||||
`tools/` and `test_*.py` are not in its runtime set, because demanding a bump
|
||||
for a typo fix trains people to bump without reading — the habit the check
|
||||
exists to prevent.
|
||||
|
||||
@@ -225,6 +225,78 @@ same place twice, the check is decoration.
|
||||
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.
|
||||
|
||||
* ⚠️ **ARCH-02's OWN REHEARSAL HARNESS, CAUGHT BY ITS OWN MUTATION BATTERY —
|
||||
the THIRTEENTH instance, and the third in an INSTRUMENT rather than in a
|
||||
check.** `tools/rehearse_roster.py` reports orphaned Home Assistant entities
|
||||
by composing the ids the add-on announces **before** the roster switch and
|
||||
**after** it, and diffing. Both sides are composed by the same build. So the
|
||||
two mutations that change the id composition itself — renaming the MQTT device
|
||||
(`"GoodWe"` -> `"GoodWe Controller"`), renaming one site sensor (`"Grid power"`
|
||||
-> `"Grid power W"`) — moved **every id on both sides equally**, and the
|
||||
harness reported *"no orphans"* about a change that orphans the entire live
|
||||
entity list. It read exactly like a clean transition. The first draft's
|
||||
M5/M6 came back `exit 0` and `exit 2`, and neither was the failure they name.
|
||||
|
||||
The fix is habit 2 with a filename: **the reference has to be data that does
|
||||
not move with the code.** `--live-entities` takes a list of the ids the
|
||||
install actually has, `tools/rehearsal/live-entities-recorded.txt` is the
|
||||
recorded stand-in that keeps it live in CI, and a build that announces an id
|
||||
the list does not carry is now a `COMPOSITION DRIFT` finding rather than a
|
||||
silence. ⚠️ **The anchor moved for the same reason**: it had been
|
||||
`compose(mqtt.DEVICE["name"], p1["name"]) == "sensor.goodwe_p1_sample_age"`,
|
||||
which any renaming of the device satisfies by moving both operands, and it is
|
||||
now composed from two recorded **literals**.
|
||||
|
||||
Ask of an instrument what habit 4 asks of a check: *where did each side of
|
||||
this comparison come from* — and if a mutation of the subject moves both
|
||||
sides, the instrument is decoration.
|
||||
|
||||
⚠️ **AND REVIEW FOUND TWO MORE IN THE SAME CARD, ONE OF THEM INSIDE THE FIX
|
||||
FOR THE OTHER — the FOURTEENTH and FIFTEENTH.** Both are the pin, not the
|
||||
tool, and both are the same sentence in a new costume: *a check whose data
|
||||
cannot reach the failure it names.*
|
||||
|
||||
* **Fourteenth — a COUNT cannot tell one red leg from two.** The rehearsal
|
||||
reports two independent routes to the disarmed latch (`SAME-STEP` and
|
||||
`STAGED`) and prints every finding **twice per red leg**: once in that
|
||||
leg's own block and once in the closing summary. The check named *"NOT THE
|
||||
SAME-STEP PATH ALONE"* was written `out.count("LATCH CAME UP DISARMED")
|
||||
>= 2`, so **one** red leg always satisfied it. Measured on three builds:
|
||||
shipped code (both legs red) → 4; `migrate_unit_keys()` handed the roster
|
||||
name (SAME-STEP fixed) → 2, **passes**; a guard-side fix (both fixed) → 0.
|
||||
The check reported `ok` on the build whose only remaining failure *is* the
|
||||
path it says is not the only one. The fix is to parse the legs and require
|
||||
the finding in **each**, which is habit 2 again: grade the thing, not a
|
||||
total over the thing.
|
||||
|
||||
* **Fifteenth — and it is INSIDE THAT FIX, which is the third time this file
|
||||
has had to write that sentence.** The first leg-splitter opened on a
|
||||
`LEG <id>` marker and never closed, so every line after the *last* marker
|
||||
was attributed to that leg — including the closing summary, which repeats
|
||||
every finding from every leg. The last leg therefore read red whenever
|
||||
**any** leg was red, and the new per-leg check was the old count with extra
|
||||
steps. It was caught by building the one fixture that separates them: a
|
||||
tree in which `SAME-STEP` is genuinely clean and `STAGED` is not. The tool
|
||||
now prints `END LEG <id>` and the splitter closes on it.
|
||||
|
||||
The rule that falls out, and it is the cheap one: **a per-thing check that
|
||||
has never seen the things DISAGREE is a total.** Part 5b exists to make
|
||||
them disagree on every run.
|
||||
|
||||
⚠️ **AND ONE THAT HAS ONLY SEEN THEM DISAGREE IN ONE ARRANGEMENT IS A TOTAL
|
||||
OVER THAT ARRANGEMENT.** Review found this by attacking the fix itself. Part
|
||||
5b does make the legs disagree, on every run - and was still blind to a build
|
||||
that merely REORDERS them, because it had only ever seen the clean leg
|
||||
printed LAST. Three edits, two of them ordinary refactors (reorder the report
|
||||
legs, reword a summary line), produced `all checks passed` from a splitter
|
||||
that closed ZERO legs.
|
||||
|
||||
A fixture cannot enumerate the arrangements, so the STRUCTURE is asserted
|
||||
instead: a leg may not open while one is open, and every leg that opens must
|
||||
be explicitly closed. That fires on the FIRST edit of all three attacks,
|
||||
whatever the order or the wording. **When a fixture cannot enumerate the
|
||||
arrangements, assert the invariant they share.**
|
||||
|
||||
* **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
|
||||
@@ -546,6 +618,46 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
instance of habit 4 in this file, and the second one in an instrument
|
||||
rather than in a check.**
|
||||
|
||||
17. ⚠️ **TWO TERMS OF A CONJUNCTION THAT MOVE TOGETHER IN EVERY FIXTURE ARE
|
||||
ONE TERM AS FAR AS THE CHECKS ARE CONCERNED - TWELFTH INSTANCE OF HABIT 4,
|
||||
and the third found by an author's own battery rather than by review.**
|
||||
SAFETY-37's freshness gate is `unchanged >= 35 s AND |standing| >= 250 W
|
||||
AND |travel| >= 250 W`. Deleting the MIDDLE term left every check in the
|
||||
first draft green, because in all of them a command that had *travelled*
|
||||
250 W had also *ended up* above 250 W: the fixture never separated the two,
|
||||
so one of them was decoration as far as the suite could tell. The case that
|
||||
separates them is a site being brought to REST - the register reaches its
|
||||
new value on the same read at which the OLD command is still standing, so
|
||||
the travel term alone reads 1300 W of unfollowed command while the site
|
||||
sits truthfully at +5 W. Ask of every conjunction: **is there a fixture in
|
||||
which each term is the ONLY one that is false?** If not, the suite cannot
|
||||
tell an AND from an OR.
|
||||
|
||||
18. ⚠️ **AN UNVERIFIED REVERT IS THE SAME DEFECT AS STALE BYTECODE, AND TWO
|
||||
HARNESSES SHARING ONE BACKUP PATH IS THE WORST VERSION OF IT.** SAFETY-37's
|
||||
sweep hit this twice, in ascending order of nastiness.
|
||||
* A mutation's container run overran a two-minute command budget and the
|
||||
wrapper was killed **between the edit and the `cp` that puts the file
|
||||
back**, leaving `app/freshness.py` mutated on disk. Nothing announced it;
|
||||
the next mutation's harness cheerfully fingerprinted and graded the mutant
|
||||
*plus* a second mutation.
|
||||
* Then two copies of the sweep were started - a scheduling mistake, not a
|
||||
tool defect - and the harness kept its backup at the FIXED path
|
||||
`/tmp/mut.bak`. One copy saved `app/main.py` there; the other restored it
|
||||
**over `app/freshness.py`**. The result was a 238 KB `freshness.py` that
|
||||
still parsed as Python, plus `main.py` left holding a live mutation. No
|
||||
error, no warning, and the only reason it was noticed is that the tree
|
||||
fingerprint did not match the recorded clean one.
|
||||
|
||||
Two rules, and the first is the cheap one:
|
||||
* **Fingerprint after the revert, not only before the run.**
|
||||
`tools/mutation_run.sh` prints the tree it is about to grade for exactly
|
||||
this reason; the revert deserves the same treatment, and a mismatch is a
|
||||
stop-everything result rather than a note.
|
||||
* **A harness's scratch file must be unique per invocation** (`mktemp`), so
|
||||
that two of it cannot silently overwrite each other's work. A fixed path
|
||||
in a tool that edits source is a shared mutable global with a filename.
|
||||
|
||||
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
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
|
||||
@@ -394,6 +394,14 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
`.pinned_by` and `.contradiction` so that this module does not
|
||||
import the arbiter and cannot grow a dependency on claims.
|
||||
`weights` unit -> non-negative comfort weight. Missing units weigh 1.0.
|
||||
⚠️ A MAPPING, ALWAYS - never a function. OPT-09 lets `resolve()`
|
||||
accept a callable of the site target, because an energy-headroom
|
||||
weighting is direction-dependent and the direction is stage 2's
|
||||
answer; `resolve()` calls it and hands the RESULT here. Keeping
|
||||
that out of this module is what keeps `plan()` a pure function of
|
||||
numbers and intervals: a callback invoked inside the fill would
|
||||
put a caller's code, and whatever it reads, back into the module
|
||||
whose whole discipline is that it reads nothing.
|
||||
`live` the units whose master has reported recently enough to be
|
||||
commanded, or None for "all of them". A unit not listed is
|
||||
DROPPED and the record says so; the survivors then carry the
|
||||
|
||||
@@ -205,6 +205,27 @@ CLAIM_SCOPE = {
|
||||
# At N=1 the two are the same comparison and this claim is the one filed,
|
||||
# exactly as it shipped.
|
||||
"failsafe": SCOPE_UNIT,
|
||||
# main.py: `Claim.limit("pack", P_SAFETY, lo, hi, ...)`, OPT-09. THIS PACK'S
|
||||
# OWN STATE OF CHARGE against THIS PACK'S OWN `bms_soc_floor`, filed only
|
||||
# where it BINDS and only at a roster of two or more. It says "this battery
|
||||
# cannot do that", which is a fact about one cupboard in the same way
|
||||
# `device`'s rating is a fact about one box, so it is scoped like `device`
|
||||
# and filed at the same priority.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT SAFETY-25's DEFERRED LOOP-SIDE RESERVE, WHICH LOOKS
|
||||
# EXACTLY LIKE IT AND IS THE REASON test_peakguard.py PART 10f EXISTS. That
|
||||
# one was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
|
||||
# `loop_soc_floor` option - an EMS preference, chosen by an operator,
|
||||
# stopping the grid-following loop at a depth somebody picked. This is the
|
||||
# pack's own BMS cut-out, which is measured off register 0x0560 and required
|
||||
# of every roster entry since SAFETY-32; no option is added, `peak_soc_floor`
|
||||
# is deliberately NOT consulted, and the SITE target is not touched - only
|
||||
# which unit carries it. Part 10f's own note names the revisit trigger:
|
||||
# "under ALLOC-01 the reserve becomes a PER-INVERTER INTERVAL AT THE
|
||||
# ALLOCATOR rather than a ninth claim here", and stage 1 is how an interval
|
||||
# reaches the allocator, so a unit-scoped claim is the shape that sentence
|
||||
# asks for rather than an evasion of it.
|
||||
"pack": SCOPE_UNIT,
|
||||
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
|
||||
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
|
||||
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
|
||||
@@ -544,7 +565,10 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
|
||||
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
|
||||
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
|
||||
wear - and cannot destabilise the site whatever it says. `live` is the set
|
||||
wear - and cannot destabilise the site whatever it says. ⚠️ OPT-09 LET IT BE
|
||||
A CALLABLE as well as a mapping: called with the resolved site target once
|
||||
stage 2 has run, because an energy-headroom weighting is direction-dependent
|
||||
and the direction is stage 2's answer. See the call site below. `live` is the set
|
||||
of units whose master has reported recently enough to be commanded; a unit
|
||||
left out of it is DROPPED from the envelope set and the record says so,
|
||||
because the survivor carrying a dead unit's share silently is the failure
|
||||
@@ -600,6 +624,27 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
# left as a log line nothing grades.
|
||||
out.scope_errors = scope_violations(claims)
|
||||
out.unit_bounds = bounds
|
||||
# OPT-09. THE WEIGHTS MAY ARRIVE AS A FUNCTION OF THE SITE TARGET, and this
|
||||
# is the one line that makes SoC-aware weighting expressible at all.
|
||||
#
|
||||
# ⚠️ THE REASON IS THAT THE DIRECTION IS NOT KNOWN ANY EARLIER. A weighting
|
||||
# by ENERGY HEADROOM is direction-dependent - room above the floor when the
|
||||
# site is discharging, room below full when it is charging - and the two
|
||||
# give opposite answers on the same roster. The site target is chosen by
|
||||
# stage 2, three lines up, so a caller computing weights before `resolve()`
|
||||
# would have to guess which way the site is about to go, and would be wrong
|
||||
# in one of the two directions on every tick.
|
||||
#
|
||||
# ⚠️ IT IS CALLED ONCE, HERE, BETWEEN STAGE 2 AND STAGE 3, AND NEVER FROM
|
||||
# INSIDE THE ALLOCATOR. `allocator.plan()` goes on taking a plain mapping,
|
||||
# which is what keeps it a pure function of numbers and intervals: a
|
||||
# callback invoked inside the fill would put a caller's code - and whatever
|
||||
# it reads - back into the module whose whole discipline is that it reads
|
||||
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
|
||||
# own direction can differ from the target's sign under a pin, in which case
|
||||
# the weighting is a worse preference and still cannot be unsafe).
|
||||
if callable(weights):
|
||||
weights = weights(out.target_w)
|
||||
out.shares, out.allocated, out.allocation = allocate_record(
|
||||
out.target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=out.scope_errors, site_priority=out.winner_priority,
|
||||
|
||||
@@ -173,6 +173,17 @@ IDLE_NO_SOC = (
|
||||
"state of charge is not being read, and a battery whose fill nobody knows "
|
||||
"cannot be told to empty or to fill")
|
||||
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
|
||||
# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE
|
||||
# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating
|
||||
# and are now SIZED to the packs whose state of charge permits that direction, so
|
||||
# a caller can hand this function a zero. A zero must not reach `claim()`: a
|
||||
# `Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the loop,
|
||||
# which is the failure the note on `claim()` is written against - it would
|
||||
# silently disable self-consumption for the whole hour. So the sizing is checked
|
||||
# where the decision is made rather than trusted to arrive positive.
|
||||
IDLE_NO_SIZE = (
|
||||
"no pack's state of charge permits this side of the trade, so it is sized "
|
||||
"at 0 W - and a 0 W arbitrage claim is a park order, not a trade")
|
||||
NEGATIVE_PRICE = (
|
||||
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
|
||||
"energy away and pay for the privilege. Arbitrage must not export at a "
|
||||
@@ -372,6 +383,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
|
||||
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
|
||||
f"arbitrage may not spend (§10.1 priority 4)", **common)
|
||||
if not abs(float(discharge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
return ArbitrageDecision(
|
||||
action=DISCHARGE, power_w=abs(float(discharge_w)),
|
||||
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
|
||||
@@ -388,6 +401,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
|
||||
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
|
||||
f"nowhere to put the cheap energy", **common)
|
||||
if not abs(float(charge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
return ArbitrageDecision(
|
||||
action=CHARGE, power_w=-abs(float(charge_w)),
|
||||
reason=(f"{CHARGE}: this is the cheapest firm hour at "
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
"""UX-02: the configuration page itself - markup, style and the form's script.
|
||||
|
||||
⚠️ WHY THIS IS ITS OWN FILE. `web.py` is the ROUTES: three status handlers and,
|
||||
since this card, five configuration ones. The page is ~500 lines of HTML, CSS
|
||||
and JavaScript, and inlining it there would bury the routing - which is the
|
||||
half a reviewer has to check for "does anything here command an inverter".
|
||||
|
||||
⚠️ AND WHAT IS NOT IN THIS FILE, WHICH IS THE POINT OF THE CARD. There is no
|
||||
option key, no label, no unit, no range, no default and NO RULE in the script
|
||||
below. It renders a model the server built out of `GET /addons/self/info`
|
||||
(app/configui.py) and it renders refusals the server got out of
|
||||
`validate_startup_config()` (app/selfconfig.py). Its longest function is the one
|
||||
that decides which HTML input a `type` deserves.
|
||||
|
||||
That is deliberate and it is checkable: test_selfconfig.py part 5 greps this
|
||||
file for the option keys and for phrases out of validate.py's refusals and
|
||||
asserts it contains none of them, and part 6 adds a NEW rule to a copy of
|
||||
validate.py and watches it appear on the page with this file untouched.
|
||||
|
||||
⚠️ EVERY URL IS RELATIVE. Ingress serves the add-on under a generated path
|
||||
prefix (`/api/hassio_ingress/<token>/`), so an absolute `/config/model` 404s in
|
||||
the field while working perfectly on a laptop. web.py's own header has said so
|
||||
since the status page shipped; this file has five more chances to get it wrong.
|
||||
|
||||
⚠️ AND IT IS BUILT FOR A PHONE FIRST. This is the screen someone stands in
|
||||
front of the inverter with: one column, big touch targets, sections that start
|
||||
collapsed so 71 options do not arrive as one wall, and the refusal block pinned
|
||||
where it cannot be scrolled past.
|
||||
"""
|
||||
|
||||
CONFIG_PAGE = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>GoodWe - Configuration</title>
|
||||
<style>
|
||||
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
|
||||
--ok:#137333;--warn:#b25e02;--bad:#c5221f;--accent:#1a73e8;
|
||||
--badtint:rgba(197,34,31,.10);--warntint:rgba(178,94,2,.10);
|
||||
--oktint:rgba(19,115,51,.10)}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
|
||||
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82;
|
||||
--accent:#8ab4f8;--badtint:rgba(242,139,130,.14);
|
||||
--warntint:rgba(253,214,99,.14);--oktint:rgba(129,201,149,.14)}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;padding:0 0 96px;background:var(--bg);color:var(--ink);
|
||||
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:14px}
|
||||
h1{font-size:18px;margin:0 0 2px}
|
||||
a{color:var(--accent)}
|
||||
.sub{color:var(--muted);font-size:13px;margin:0 0 12px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
||||
padding:14px;margin-bottom:12px}
|
||||
.note{border-left:4px solid var(--warn);background:var(--warntint);
|
||||
border-radius:8px;padding:11px 13px;margin-bottom:12px;font-size:14px}
|
||||
.note.bad{border-left-color:var(--bad);background:var(--badtint)}
|
||||
.note.ok{border-left-color:var(--ok);background:var(--oktint)}
|
||||
.note b{display:block;margin-bottom:3px}
|
||||
details.grp{background:var(--card);border:1px solid var(--line);
|
||||
border-radius:10px;margin-bottom:10px;overflow:hidden}
|
||||
details.grp>summary{cursor:pointer;padding:14px;font-weight:600;
|
||||
list-style:none;display:flex;justify-content:space-between;gap:10px;
|
||||
align-items:center}
|
||||
details.grp>summary::-webkit-details-marker{display:none}
|
||||
details.grp>summary::after{content:"\\25be";color:var(--muted);font-weight:400}
|
||||
details.grp[open]>summary::after{content:"\\25b4"}
|
||||
details.grp[open]>summary{border-bottom:1px solid var(--line)}
|
||||
.gbody{padding:4px 14px 14px}
|
||||
.pill{font-size:12px;font-weight:600;border-radius:999px;padding:2px 9px;
|
||||
background:var(--badtint);color:var(--bad)}
|
||||
.pill.q{background:var(--warntint);color:var(--warn)}
|
||||
.f{padding:13px 0;border-bottom:1px solid var(--line)}
|
||||
.f:last-child{border-bottom:0}
|
||||
.f>label{display:block;font-weight:600;margin-bottom:2px}
|
||||
.k{font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)}
|
||||
.row{display:flex;gap:8px;align-items:center;margin:6px 0 0}
|
||||
.row input[type=text],.row input[type=number],.row select{flex:1 1 auto;
|
||||
min-width:0;font:inherit;padding:10px;border-radius:8px;
|
||||
border:1px solid var(--line);background:var(--bg);color:var(--ink)}
|
||||
.row .unit{color:var(--muted);font-size:13px;white-space:nowrap}
|
||||
.rng{color:var(--muted);font-size:12px;margin-top:4px}
|
||||
.help{color:var(--muted);font-size:13px;margin-top:6px;cursor:pointer;
|
||||
max-height:3.1em;overflow:hidden;position:relative}
|
||||
.help.open{max-height:none}
|
||||
.help::after{content:" more";color:var(--accent)}
|
||||
.help.open::after{content:" less";color:var(--accent)}
|
||||
.prob{margin-top:8px;border-left:4px solid var(--bad);background:var(--badtint);
|
||||
border-radius:6px;padding:9px 11px;font-size:13.5px}
|
||||
.prob+.prob{margin-top:6px}
|
||||
.sw{display:flex;align-items:center;gap:10px;margin-top:6px}
|
||||
.sw input{width:20px;height:20px}
|
||||
.entry{border:1px solid var(--line);border-radius:9px;padding:10px 12px;
|
||||
margin:10px 0;background:var(--bg)}
|
||||
.entry h4{margin:0 0 2px;font-size:14px}
|
||||
button{font:inherit;padding:10px 14px;border-radius:8px;
|
||||
border:1px solid var(--line);background:var(--card);color:var(--ink);
|
||||
cursor:pointer}
|
||||
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
|
||||
button[disabled]{opacity:.5;cursor:not-allowed}
|
||||
button.small{padding:6px 10px;font-size:13px}
|
||||
.bar{position:fixed;left:0;right:0;bottom:0;background:var(--card);
|
||||
border-top:1px solid var(--line);padding:10px 14px;z-index:5}
|
||||
.bar .in{max-width:760px;margin:0 auto;display:flex;gap:10px;
|
||||
align-items:center;flex-wrap:wrap}
|
||||
.bar .msg{flex:1 1 200px;min-width:0;font-size:13px;color:var(--muted)}
|
||||
dialog{border:1px solid var(--line);border-radius:12px;background:var(--card);
|
||||
color:var(--ink);max-width:520px;width:92%;padding:18px}
|
||||
dialog::backdrop{background:rgba(0,0,0,.5)}
|
||||
.diff{font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
white-space:pre-wrap;word-break:break-word;margin-top:6px}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<h1>GoodWe - Configuration</h1>
|
||||
<p class="sub"><a href="./">← Status and controls</a></p>
|
||||
|
||||
<div class="note" id="authority">
|
||||
<b>This page and the Supervisor's Configuration tab edit the same settings.</b>
|
||||
Both write one options blob. This page is the one that can tell you a
|
||||
setting is wrong <i>before</i> you save it; the Configuration tab cannot,
|
||||
because it only knows types and ranges. If the add-on is not running, the
|
||||
Configuration tab is the only one of the two that still exists - that is
|
||||
what it is for.
|
||||
</div>
|
||||
|
||||
<div class="note" id="restartcost">
|
||||
<b>Saving does not restart anything, and does not take effect on its own.</b>
|
||||
Stored settings are applied when the add-on next starts. Restarting to
|
||||
apply them costs a short loss of control: the add-on commands
|
||||
<b>0 W on every inverter</b> as it shuts down, and the battery does nothing
|
||||
until it is back up. That is a separate button, below, with its own
|
||||
confirmation.
|
||||
</div>
|
||||
|
||||
<div id="banner"></div>
|
||||
<div id="staleness"></div>
|
||||
<div id="form"></div>
|
||||
</div>
|
||||
|
||||
<div class="bar"><div class="in">
|
||||
<button class="primary" id="save" disabled>Save settings</button>
|
||||
<button id="restart" disabled>Restart to apply</button>
|
||||
<button id="reload">Discard edits</button>
|
||||
<span class="msg" id="msg">loading…</span>
|
||||
</div></div>
|
||||
|
||||
<dialog id="confirm">
|
||||
<h3 style="margin-top:0">Restart the add-on?</h3>
|
||||
<p>The add-on will command <b>0 W on every inverter</b>, stop, and start
|
||||
again with the stored settings. Control of the battery is lost for as long as
|
||||
that takes. Nothing else about the inverter is changed, and no latched
|
||||
failsafe is cleared.</p>
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end">
|
||||
<button id="cancel">Cancel</button>
|
||||
<button class="danger" id="go">Yes, restart now</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
var MODEL = null; // groups/fields, from the server
|
||||
var STORED = null; // the options blob the server last read
|
||||
var FP = ""; // its fingerprint - the compare-and-swap token
|
||||
var VALS = {}; // what is on screen right now
|
||||
var timer = null;
|
||||
|
||||
function el(tag, cls, text){
|
||||
var e = document.createElement(tag);
|
||||
if(cls) e.className = cls;
|
||||
if(text !== undefined && text !== null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
function msg(text, kind){
|
||||
var m = document.getElementById('msg');
|
||||
m.textContent = text;
|
||||
m.style.color = kind === 'bad' ? 'var(--bad)'
|
||||
: kind === 'ok' ? 'var(--ok)' : 'var(--muted)';
|
||||
}
|
||||
function note(host, kind, title, body){
|
||||
var d = el('div', 'note ' + (kind || ''));
|
||||
if(title) d.appendChild(el('b', null, title));
|
||||
if(body) d.appendChild(document.createTextNode(body));
|
||||
host.appendChild(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
// --- the value of one input, by the type the SERVER gave the field ---------
|
||||
// The only "knowledge" in this script: which HTML control a Supervisor type
|
||||
// deserves, and how to read it back. No key names, no bounds, no rules.
|
||||
function readInput(input, type){
|
||||
if(type === 'boolean') return input.checked;
|
||||
if(type === 'integer' || type === 'float'){
|
||||
if(input.value === '') return '';
|
||||
var n = Number(input.value);
|
||||
return isNaN(n) ? input.value : n;
|
||||
}
|
||||
return input.value;
|
||||
}
|
||||
|
||||
function makeInput(field, value, onchange){
|
||||
var row = el('div', 'row');
|
||||
var input;
|
||||
if(field.type === 'boolean'){
|
||||
row.className = 'sw';
|
||||
input = el('input');
|
||||
input.type = 'checkbox';
|
||||
input.checked = !!value;
|
||||
var lab = el('span', 'k', field.key);
|
||||
row.appendChild(input); row.appendChild(lab);
|
||||
} else if(field.type === 'select'){
|
||||
input = el('select');
|
||||
(field.options || []).forEach(function(o){
|
||||
var opt = el('option', null, o); opt.value = o;
|
||||
if(String(value) === o) opt.selected = true;
|
||||
input.appendChild(opt);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else {
|
||||
input = el('input');
|
||||
if(field.type === 'integer' || field.type === 'float'){
|
||||
input.type = 'number';
|
||||
input.inputMode = field.type === 'integer' ? 'numeric' : 'decimal';
|
||||
input.step = field.type === 'integer' ? '1' : 'any';
|
||||
if(field.min !== undefined) input.min = field.min;
|
||||
if(field.max !== undefined) input.max = field.max;
|
||||
} else {
|
||||
input.type = 'text';
|
||||
input.autocapitalize = 'none';
|
||||
input.spellcheck = false;
|
||||
}
|
||||
input.value = (value === undefined || value === null) ? '' : value;
|
||||
row.appendChild(input);
|
||||
if(field.unit) row.appendChild(el('span', 'unit', field.unit));
|
||||
}
|
||||
input.addEventListener('input', onchange);
|
||||
input.addEventListener('change', onchange);
|
||||
return {row: row, input: input};
|
||||
}
|
||||
|
||||
function helpBlock(field){
|
||||
if(!field.help) return null;
|
||||
var h = el('div', 'help', field.help);
|
||||
h.title = field.help;
|
||||
h.addEventListener('click', function(){ h.classList.toggle('open'); });
|
||||
return h;
|
||||
}
|
||||
|
||||
function rangeHint(field){
|
||||
var bits = [];
|
||||
if(field.min !== undefined || field.max !== undefined){
|
||||
bits.push('accepted: ' + (field.min !== undefined ? field.min : '-')
|
||||
+ ' to ' + (field.max !== undefined ? field.max : '-')
|
||||
+ (field.unit ? ' ' + field.unit : ''));
|
||||
}
|
||||
bits.push(field.key);
|
||||
return el('div', 'rng', bits.join(' \\u00b7 '));
|
||||
}
|
||||
|
||||
// --- a list of scalars (`- str` in the schema) -----------------------------
|
||||
function scalarList(field, value){
|
||||
var box = el('div');
|
||||
var items = Array.isArray(value) ? value.slice() : [];
|
||||
function draw(){
|
||||
box.textContent = '';
|
||||
items.forEach(function(v, i){
|
||||
var row = el('div', 'row');
|
||||
var input = el('input'); input.type = 'text'; input.value = v;
|
||||
input.autocapitalize = 'none'; input.spellcheck = false;
|
||||
input.addEventListener('input', function(){
|
||||
items[i] = input.value; VALS[field.key] = items.slice(); queue();
|
||||
});
|
||||
var rm = el('button', 'small', 'Remove');
|
||||
rm.addEventListener('click', function(){
|
||||
items.splice(i, 1); VALS[field.key] = items.slice(); draw(); queue();
|
||||
});
|
||||
row.appendChild(input); row.appendChild(rm);
|
||||
box.appendChild(row);
|
||||
});
|
||||
var add = el('button', 'small', 'Add');
|
||||
add.addEventListener('click', function(){
|
||||
items.push(''); VALS[field.key] = items.slice(); draw(); queue();
|
||||
});
|
||||
box.appendChild(add);
|
||||
}
|
||||
draw();
|
||||
return box;
|
||||
}
|
||||
|
||||
// --- a list of objects: the roster editor ----------------------------------
|
||||
function objectList(field, value){
|
||||
var box = el('div');
|
||||
var entries = Array.isArray(value) ? JSON.parse(JSON.stringify(value)) : [];
|
||||
function push(){ VALS[field.key] = JSON.parse(JSON.stringify(entries)); }
|
||||
function draw(){
|
||||
box.textContent = '';
|
||||
if(!entries.length){
|
||||
var e = el('div', 'rng',
|
||||
'No entries. With none, the settings above describe the one inverter.');
|
||||
box.appendChild(e);
|
||||
}
|
||||
entries.forEach(function(entry, i){
|
||||
var card = el('div', 'entry');
|
||||
card.appendChild(el('h4', null, 'Entry ' + (i + 1)
|
||||
+ (entry && entry.name ? ' \\u2014 ' + entry.name : '')));
|
||||
(field.fields || []).forEach(function(sub){
|
||||
var f = el('div', 'f');
|
||||
// The label is the translation's own, verbatim - UX-01 already says
|
||||
// "(optional)" in the ones that are, and adding a second one here
|
||||
// would be this panel putting words next to a field.
|
||||
f.appendChild(el('label', null, sub.label));
|
||||
var made = makeInput(sub, entry ? entry[sub.key] : undefined,
|
||||
function(){
|
||||
entries[i][sub.key] = readInput(made.input, sub.type);
|
||||
push(); queue();
|
||||
});
|
||||
f.appendChild(made.row);
|
||||
f.appendChild(rangeHint(sub));
|
||||
var h = helpBlock(sub); if(h) f.appendChild(h);
|
||||
card.appendChild(f);
|
||||
});
|
||||
var rm = el('button', 'small danger', 'Remove this inverter');
|
||||
rm.addEventListener('click', function(){
|
||||
entries.splice(i, 1); push(); draw(); queue();
|
||||
});
|
||||
card.appendChild(rm);
|
||||
box.appendChild(card);
|
||||
});
|
||||
var add = el('button', 'small', 'Add an inverter');
|
||||
add.addEventListener('click', function(){
|
||||
var fresh = {};
|
||||
(field.fields || []).forEach(function(sub){
|
||||
fresh[sub.key] = sub.type === 'boolean' ? false
|
||||
: (sub.type === 'integer' || sub.type === 'float') ? ''
|
||||
: '';
|
||||
});
|
||||
entries.push(fresh); push(); draw(); queue();
|
||||
});
|
||||
box.appendChild(add);
|
||||
}
|
||||
draw();
|
||||
return box;
|
||||
}
|
||||
|
||||
// --- rendering the whole form ---------------------------------------------
|
||||
var FIELD_NODES = {};
|
||||
|
||||
function render(){
|
||||
var host = document.getElementById('form');
|
||||
host.textContent = '';
|
||||
FIELD_NODES = {};
|
||||
MODEL.groups.forEach(function(group){
|
||||
var d = el('details', 'grp');
|
||||
var s = el('summary');
|
||||
s.appendChild(el('span', null, group.name || 'Not grouped'));
|
||||
var pill = el('span', 'pill'); pill.style.display = 'none';
|
||||
s.appendChild(pill);
|
||||
d.appendChild(s);
|
||||
var body = el('div', 'gbody');
|
||||
group.fields.forEach(function(field){
|
||||
var f = el('div', 'f');
|
||||
f.appendChild(el('label', null, field.label));
|
||||
if(field.type === 'schema'){
|
||||
f.appendChild(objectList(field, VALS[field.key]));
|
||||
} else if(field.multiple){
|
||||
f.appendChild(scalarList(field, VALS[field.key]));
|
||||
} else {
|
||||
var made = makeInput(field, VALS[field.key], function(){
|
||||
VALS[field.key] = readInput(made.input, field.type); queue();
|
||||
});
|
||||
f.appendChild(made.row);
|
||||
}
|
||||
if(field.type !== 'schema') f.appendChild(rangeHint(field));
|
||||
var h = helpBlock(field); if(h) f.appendChild(h);
|
||||
var probs = el('div'); f.appendChild(probs);
|
||||
FIELD_NODES[field.key] = {probs: probs, group: d, pill: pill};
|
||||
body.appendChild(f);
|
||||
});
|
||||
d.appendChild(body);
|
||||
host.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
// --- refusals: printed exactly as the validator wrote them -----------------
|
||||
function showProblems(payload){
|
||||
Object.keys(FIELD_NODES).forEach(function(k){
|
||||
FIELD_NODES[k].probs.textContent = '';
|
||||
});
|
||||
document.querySelectorAll('.pill').forEach(function(p){
|
||||
p.style.display = 'none'; p.textContent = '';
|
||||
});
|
||||
var banner = document.getElementById('banner');
|
||||
banner.textContent = '';
|
||||
var counts = new Map();
|
||||
Object.keys(payload.by_key || {}).forEach(function(key){
|
||||
var node = FIELD_NODES[key];
|
||||
payload.by_key[key].forEach(function(text){
|
||||
var box = el('div', 'prob', text);
|
||||
if(node) node.probs.appendChild(box); else banner.appendChild(box);
|
||||
});
|
||||
if(node){
|
||||
counts.set(node.pill, (counts.get(node.pill) || 0)
|
||||
+ payload.by_key[key].length);
|
||||
node.group.open = true;
|
||||
}
|
||||
});
|
||||
counts.forEach(function(n, pill){
|
||||
pill.textContent = n + (n === 1 ? ' problem' : ' problems');
|
||||
pill.style.display = '';
|
||||
});
|
||||
(payload.unanchored || []).forEach(function(text){
|
||||
banner.appendChild(el('div', 'prob', text));
|
||||
});
|
||||
var total = (payload.problems || []).length;
|
||||
var save = document.getElementById('save');
|
||||
save.disabled = total > 0;
|
||||
if(total){
|
||||
note(banner, 'bad', 'This configuration would not start.',
|
||||
'The add-on refuses to run on it and would stop with exit 78. '
|
||||
+ 'Saving is blocked until these are fixed; nothing has been written. '
|
||||
+ 'Each message below is the add-on\\u2019s own words, from the same '
|
||||
+ 'check that runs at startup.');
|
||||
banner.insertBefore(banner.lastChild, banner.firstChild);
|
||||
msg(total + (total === 1 ? ' problem' : ' problems')
|
||||
+ ' - saving is blocked', 'bad');
|
||||
} else {
|
||||
msg('this configuration would start', 'ok');
|
||||
}
|
||||
if(payload.device_note){
|
||||
note(banner, '', 'Two range checks could not run.', payload.device_note);
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ THE DEBOUNCE IS WHAT BOUNDS THE READS THIS PAGE MAKES OF HOME ASSISTANT.
|
||||
// Each validation asks the setpoint device(s) for their own range, and those
|
||||
// are real Core API reads on the same session the control loop uses. Because
|
||||
// every keystroke RESETS the timer, sustained typing produces no requests at
|
||||
// all and one lands 350 ms after the operator stops - which is the property
|
||||
// that matters, not the number.
|
||||
function queue(){
|
||||
msg('checking\\u2026');
|
||||
document.getElementById('save').disabled = true;
|
||||
if(timer) clearTimeout(timer);
|
||||
timer = setTimeout(validate, 350);
|
||||
}
|
||||
|
||||
async function validate(){
|
||||
try{
|
||||
const r = await fetch('config/validate', {method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({options: VALS})});
|
||||
showProblems(await r.json());
|
||||
}catch(e){
|
||||
msg('cannot reach the add-on: ' + e, 'bad');
|
||||
}
|
||||
}
|
||||
|
||||
function showStaleness(keys, stored){
|
||||
var host = document.getElementById('staleness');
|
||||
host.textContent = '';
|
||||
if(!keys || !keys.length) return;
|
||||
var d = note(host, '', 'Saved, but not running yet.',
|
||||
'These settings are stored and differ from what this add-on is running '
|
||||
+ 'right now. They take effect when it next starts:');
|
||||
var pre = el('div', 'diff', keys.map(function(k){
|
||||
return k + ': running ' + JSON.stringify(stored.running[k])
|
||||
+ ' -> stored ' + JSON.stringify(stored.stored[k]);
|
||||
}).join('\\n'));
|
||||
d.appendChild(pre);
|
||||
}
|
||||
|
||||
async function load(){
|
||||
msg('loading\\u2026');
|
||||
try{
|
||||
const r = await fetch('config/model?lang='
|
||||
+ encodeURIComponent((navigator.language || 'en')
|
||||
.slice(0, 2)),
|
||||
{cache: 'no-store'});
|
||||
const m = await r.json();
|
||||
MODEL = m; STORED = m.options; FP = m.fingerprint;
|
||||
VALS = JSON.parse(JSON.stringify(m.options));
|
||||
render();
|
||||
showStaleness(m.running_differs, {running: m.running, stored: m.options});
|
||||
document.getElementById('restart').disabled = false;
|
||||
await validate();
|
||||
}catch(e){
|
||||
msg('cannot load the configuration: ' + e, 'bad');
|
||||
}
|
||||
}
|
||||
|
||||
async function save(){
|
||||
msg('saving\\u2026');
|
||||
document.getElementById('save').disabled = true;
|
||||
var r, body;
|
||||
try{
|
||||
r = await fetch('config/save', {method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({options: VALS, fingerprint: FP})});
|
||||
body = await r.json();
|
||||
}catch(e){
|
||||
msg('cannot reach the add-on: ' + e, 'bad');
|
||||
document.getElementById('save').disabled = false;
|
||||
return;
|
||||
}
|
||||
if(r.ok){
|
||||
FP = body.fingerprint;
|
||||
msg('saved - restart to apply', 'ok');
|
||||
var host = document.getElementById('staleness');
|
||||
host.textContent = '';
|
||||
note(host, 'ok', 'Saved. Not applied yet.',
|
||||
'The add-on is still running the settings it started with. Press '
|
||||
+ '"Restart to apply" when you are ready to lose control of the '
|
||||
+ 'battery for a few seconds.');
|
||||
return;
|
||||
}
|
||||
if(body.kind === 'stale'){
|
||||
var host2 = document.getElementById('staleness');
|
||||
host2.textContent = '';
|
||||
var d = note(host2, 'bad', 'Nothing was saved: these settings changed '
|
||||
+ 'somewhere else while you were editing.',
|
||||
'Somebody - most likely the Supervisor\\u2019s Configuration tab - '
|
||||
+ 'wrote these settings after this page loaded. Your edits are still on '
|
||||
+ 'screen and nothing has been overwritten. Read what changed, then '
|
||||
+ 'press Save again if you still want your version.');
|
||||
d.appendChild(el('div', 'diff', (body.changed || []).map(function(k){
|
||||
return k + ': theirs ' + JSON.stringify(body.stored[k])
|
||||
+ ' -> mine ' + JSON.stringify(VALS[k]);
|
||||
}).join('\\n') || 'no key that this page also edits'));
|
||||
FP = body.fingerprint;
|
||||
msg('not saved - the stored settings had changed', 'bad');
|
||||
document.getElementById('save').disabled = false;
|
||||
return;
|
||||
}
|
||||
if(body.kind === 'invalid'){
|
||||
showProblems(body);
|
||||
msg('not saved - the configuration would not start', 'bad');
|
||||
return;
|
||||
}
|
||||
msg('not saved: ' + (body.error || r.status), 'bad');
|
||||
document.getElementById('save').disabled = false;
|
||||
}
|
||||
|
||||
document.getElementById('save').addEventListener('click', save);
|
||||
document.getElementById('reload').addEventListener('click', load);
|
||||
document.getElementById('restart').addEventListener('click', function(){
|
||||
document.getElementById('confirm').showModal();
|
||||
});
|
||||
document.getElementById('cancel').addEventListener('click', function(){
|
||||
document.getElementById('confirm').close();
|
||||
});
|
||||
document.getElementById('go').addEventListener('click', async function(){
|
||||
document.getElementById('confirm').close();
|
||||
msg('restarting\\u2026');
|
||||
try{
|
||||
await fetch('config/restart', {method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({confirm: 'restart'})});
|
||||
msg('restart requested - this page will stop answering for a moment');
|
||||
}catch(e){
|
||||
msg('restart requested - this page will stop answering for a moment');
|
||||
}
|
||||
});
|
||||
load();
|
||||
</script></body></html>
|
||||
"""
|
||||
@@ -0,0 +1,315 @@
|
||||
"""UX-02: the config surface's MODEL - built from what the Supervisor returns.
|
||||
|
||||
WHAT THIS MODULE IS FOR, in one sentence: turn the three things
|
||||
`GET /addons/self/info` already carries - `schema`, `translations` and
|
||||
`options` - into the shape a form renders from, WITHOUT this repository owning
|
||||
a second copy of any of them.
|
||||
|
||||
⚠️ THE RULE THIS FILE EXISTS TO OBEY. There is exactly one description of the
|
||||
option surface (config.yaml's `schema:` block), exactly one set of labels
|
||||
(translations/*.yaml, UX-01) and exactly one set of cross-field rules
|
||||
(app/validate.py). A config UI is the classic place where all three get
|
||||
transcribed a second time - a hand-written field list, a hand-written label, a
|
||||
hand-written "max_w must not exceed the device" in JavaScript - and the second
|
||||
copy is what drifts. So:
|
||||
|
||||
* the FIELDS come from `info["schema"]`, which is Supervisor's own
|
||||
`App.schema_ui` == `UiOptions(...)(config.yaml["schema"])`. Nothing here
|
||||
lists an option key.
|
||||
* the LABELS come from `info["translations"]`, which is Supervisor's own
|
||||
`_read_app_translations()` over translations/*.yaml. Nothing here contains
|
||||
an English or Dutch sentence about an option.
|
||||
* the RULES are not here at all. `problems_by_field()` takes strings the
|
||||
SHIPPED validator produced and decides only WHERE ON THE PAGE to put them.
|
||||
|
||||
The consequence is the acceptance criterion: a rule added to validate.py, an
|
||||
option added to config.yaml, or a label improved in translations/en.yaml all
|
||||
reach the panel with no change to this file, to web.py, or to the page's
|
||||
JavaScript. test_selfconfig.py part 6 proves the first of those by running the
|
||||
panel against a MUTATED COPY of validate.py in a subprocess.
|
||||
|
||||
⚠️ STDLIB ONLY, AND THAT IS LOAD-BEARING. This module is imported by
|
||||
tools/supervisor_selfapi_probe.py, which runs inside the Home Assistant
|
||||
Supervisor release image so that the model can be built from the REAL loader's
|
||||
output rather than from a transcription of it. That image has no paho-mqtt, so
|
||||
an import of anything under `app/` that reaches `mqtt.py` would make the probe
|
||||
unrunnable and the measurement would quietly become a re-reading.
|
||||
|
||||
--- WHAT THE SUPERVISOR CANNOT RENDER, AND WHY THAT IS THE POINT -------------
|
||||
|
||||
Add-on options are a FLAT LIST. UX-01's own header says it: no section-header
|
||||
primitive, no units on an input, no per-option grouping. UX-01 answered that by
|
||||
prefixing every label with `Group · `, which is the best a flat list allows.
|
||||
This module reads that prefix back off and turns it into a real heading, so the
|
||||
grouping the operator already has in words becomes grouping they have on the
|
||||
screen - in whichever language their labels resolved in, because the prefix is
|
||||
translated too (`Limits` / `Grenzen`).
|
||||
|
||||
⚠️ NOTHING IS INVENTED WHEN THE PREFIX IS ABSENT. A key whose label carries no
|
||||
` · ` is NOT quietly filed under the last heading seen - that would put a new
|
||||
option in a section nobody chose and it would look deliberate. It goes into the
|
||||
model's `ungrouped` list, is rendered under a heading that says so, and
|
||||
test_selfconfig.py asserts the list is EMPTY today, so the day config.yaml
|
||||
grows a key UX-01 has not grouped, a check says so instead of the page guessing.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
__all__ = ["resolve_entry", "label_of", "help_of", "split_group", "unit_of",
|
||||
"field_model", "problems_by_field", "changed_keys"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. LABELS - the frontend's own resolution order, transcribed once
|
||||
#
|
||||
# ⚠️ THIS IS A TRANSCRIPTION OF SOMEBODY ELSE'S CODE AND IT IS LABELLED AS ONE.
|
||||
# tools/supervisor_shape_probe.py section F reads the resolution out of the
|
||||
# shipped frontend bundle in the release image this site runs
|
||||
# (`5680.*.js`, frontend 20250925.1):
|
||||
#
|
||||
# computeLabel = (s,_,o) => this._getTranslationEntry(this.hass.language,s,o)?.name
|
||||
# || this._getTranslationEntry("en",s,o)?.name
|
||||
# || s.name
|
||||
#
|
||||
# i.e. THE FALLBACK IS PER KEY, `en` is the second chance and the raw key is the
|
||||
# third. The panel resolves labels the same way ON PURPOSE: two config surfaces
|
||||
# that disagree about what a field is CALLED would be worse than one ugly one.
|
||||
# If that resolution ever changes upstream, this is a copy that can go stale -
|
||||
# it is three lines, it is named here, and the probe prints both renderings side
|
||||
# by side so a divergence is visible rather than argued about.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def resolve_entry(translations, lang, key, path=()):
|
||||
"""Yield `(language, entry)` for `key`, in the per-key `lang -> en` order.
|
||||
|
||||
`translations` is `info["translations"]` verbatim: `{lang: {"configuration":
|
||||
{key: {"name", "description", "fields": {...}}}}}`. `path` names the
|
||||
enclosing repeatable section for a per-entry key, e.g. `("inverters",)`.
|
||||
"""
|
||||
for language in (lang, "en"):
|
||||
node = ((translations or {}).get(language) or {}).get("configuration")
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
for step in path:
|
||||
node = ((node.get(step) or {}).get("fields")) or {}
|
||||
entry = node.get(key)
|
||||
if isinstance(entry, dict):
|
||||
yield language, entry
|
||||
|
||||
|
||||
def label_of(translations, lang, key, path=()):
|
||||
"""(label, source-language) with the raw key as the last resort."""
|
||||
for language, entry in resolve_entry(translations, lang, key, path):
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if name:
|
||||
return name, language
|
||||
return key, "raw"
|
||||
|
||||
|
||||
def help_of(translations, lang, key, path=()):
|
||||
"""(description, source-language). "" when nothing was translated."""
|
||||
for language, entry in resolve_entry(translations, lang, key, path):
|
||||
text = str(entry.get("description") or "").strip()
|
||||
if text:
|
||||
return text, language
|
||||
return "", "none"
|
||||
|
||||
|
||||
# UX-01's separator, U+00B7 with a space each side. It is not a general
|
||||
# delimiter guess: it is the exact string that file's generator writes, and the
|
||||
# audit in tools/translations_lint.py is what keeps every label carrying one.
|
||||
GROUP_SEP = " · "
|
||||
|
||||
|
||||
def split_group(label):
|
||||
"""`"Limits · Inverter rating (W)"` -> `("Limits", "Inverter rating (W)")`.
|
||||
|
||||
A label with no separator comes back as `("", label)` - see the module
|
||||
docstring on why that is not filed under the previous heading.
|
||||
"""
|
||||
if GROUP_SEP in label:
|
||||
group, _, rest = label.partition(GROUP_SEP)
|
||||
return group.strip(), rest.strip()
|
||||
return "", label
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. UNITS - presentation, derived from the key's own suffix
|
||||
#
|
||||
# ⚠️ THIS IS THE ONE PLACE THIS MODULE PUTS WORDS NEXT TO A FIELD, and it is
|
||||
# deliberately the weakest possible rule: a suffix map, applied to a key name,
|
||||
# affecting nothing but what is printed to the right of an input box. It states
|
||||
# no bound, no default and no relation, so it cannot disagree with config.yaml
|
||||
# or with validate.py about anything - the worst a wrong entry here can do is
|
||||
# print "W" beside a number that is in seconds, which is visible to the first
|
||||
# person who reads the screen.
|
||||
#
|
||||
# It is NOT a schema, and it must never become one: a unit string here that
|
||||
# named a bound - "h (0-23)", say - would be a second copy of a range that
|
||||
# config.yaml already states and the panel already renders from the schema. One
|
||||
# such entry was written and removed; test_selfconfig.py part 5 is what removed
|
||||
# it, by asserting that no unit string contains a digit.
|
||||
#
|
||||
# Anything the map does not know about gets no unit rather than a guessed one.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_UNITS = (
|
||||
("_eur_kwh", "€/kWh"),
|
||||
("_eur", "€"),
|
||||
("_soc_floor", "%"),
|
||||
("_soc_target", "%"),
|
||||
("_soc_ceiling", "%"),
|
||||
("_days", "days"),
|
||||
("_min", "min"),
|
||||
("_hour", "h"),
|
||||
("_horizon_h", "h"),
|
||||
("_cycles", "cycles"),
|
||||
("_phases", "phases"),
|
||||
("_w", "W"),
|
||||
("_s", "s"),
|
||||
)
|
||||
|
||||
|
||||
def unit_of(key):
|
||||
for suffix, unit in _UNITS:
|
||||
if key.endswith(suffix):
|
||||
return unit
|
||||
return ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. THE MODEL
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _one_field(node, translations, lang, path=()):
|
||||
"""One entry of Supervisor's `schema_ui` -> one field of the model.
|
||||
|
||||
⚠️ THE TYPE, THE RANGE AND THE ENUM ARE COPIED, NOT DECIDED. `type`,
|
||||
`lengthMin`/`lengthMax` (which is what `UiOptions` calls a NUMERIC range,
|
||||
misleadingly, and measured rather than guessed - see
|
||||
tools/supervisor_selfapi_probe.py section C) and `options` come straight off
|
||||
the node. This function chooses no bound of its own, so a range edited in
|
||||
config.yaml is a range the panel enforces on the very next page load.
|
||||
"""
|
||||
key = str(node.get("name") or "")
|
||||
raw_label, label_lang = label_of(translations, lang, key, path)
|
||||
group, label = split_group(raw_label)
|
||||
text, help_lang = help_of(translations, lang, key, path)
|
||||
field = {
|
||||
"key": key,
|
||||
"label": label,
|
||||
"group": group,
|
||||
"help": text,
|
||||
"label_lang": label_lang,
|
||||
"help_lang": help_lang,
|
||||
"type": str(node.get("type") or "string"),
|
||||
"required": bool(node.get("required")),
|
||||
"multiple": bool(node.get("multiple")),
|
||||
"unit": unit_of(key),
|
||||
}
|
||||
if node.get("lengthMin") is not None:
|
||||
field["min"] = node["lengthMin"]
|
||||
if node.get("lengthMax") is not None:
|
||||
field["max"] = node["lengthMax"]
|
||||
if node.get("options"):
|
||||
field["options"] = list(node["options"])
|
||||
if node.get("type") == "schema":
|
||||
field["fields"] = [_one_field(sub, translations, lang, path + (key,))
|
||||
for sub in node.get("schema") or ()]
|
||||
return field
|
||||
|
||||
|
||||
def field_model(schema, translations, lang="en"):
|
||||
"""`info["schema"]` + `info["translations"]` -> the groups a page renders.
|
||||
|
||||
Returns `{"groups": [{"name", "fields": [...]}], "ungrouped": [key, ...],
|
||||
"keys": [key, ...]}`.
|
||||
|
||||
⚠️ ORDER IS THE SCHEMA'S, AND SO IS GROUP ORDER. `App.schema_ui` renders
|
||||
config.yaml's `schema:` block in file order (measured in
|
||||
tools/supervisor_shape_probe.py section F), and a group takes its position
|
||||
from the FIRST field that names it. So the panel's section order is
|
||||
config.yaml's section order, and moving a block in that file moves it here -
|
||||
which is the property that stops this model needing an order of its own.
|
||||
"""
|
||||
groups, order, ungrouped, keys = {}, [], [], []
|
||||
for node in schema or ():
|
||||
field = _one_field(node, translations, lang)
|
||||
keys.append(field["key"])
|
||||
name = field["group"]
|
||||
if not name:
|
||||
ungrouped.append(field["key"])
|
||||
if name not in groups:
|
||||
groups[name] = []
|
||||
order.append(name)
|
||||
groups[name].append(field)
|
||||
return {
|
||||
"groups": [{"name": name, "fields": groups[name]} for name in order],
|
||||
"ungrouped": ungrouped,
|
||||
"keys": keys,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4. WHERE A REFUSAL GOES ON THE PAGE
|
||||
#
|
||||
# ⚠️ READ THIS BEFORE ADDING ANYTHING TO IT. The entire value of this card is
|
||||
# that validate.py's cross-field sentences reach the operator BEFORE a save.
|
||||
# Those sentences are written by validate.py and are shown BYTE FOR BYTE. This
|
||||
# function decides one thing and one thing only: which field to print each
|
||||
# sentence under. It does not shorten, re-word, classify by severity, or map a
|
||||
# message to a rule number - every one of those would be a second, drifting
|
||||
# opinion about a rule this module is not allowed to hold.
|
||||
#
|
||||
# THE MECHANISM IS NAME OCCURRENCE, and it knows no keys of its own: the caller
|
||||
# passes the key list from the schema. A message that names `max_w` is filed
|
||||
# under `max_w`. That is why validate.py's habit of naming the option it is
|
||||
# refusing - which it already had, for the log - is what makes the anchoring
|
||||
# work, and why nothing here breaks when a rule is added.
|
||||
#
|
||||
# ⚠️ WORD BOUNDARIES, AND THE COLLISION THEY EXIST FOR. `sign_disagree_w` is a
|
||||
# substring of `unit_sign_disagree_w` and of `site_sign_disagree_w` - three
|
||||
# distinct options, two of them SAFETY-30's. A plain `in` test files every
|
||||
# per-unit refusal under the site key as well, which would put a message about
|
||||
# unit-0 next to a field that has nothing to do with it. The boundary class
|
||||
# excludes `_` on purpose so that the longer names do not match the shorter key.
|
||||
#
|
||||
# ⚠️ AND THE HONEST LIMIT, STATED RATHER THAN DISCOVERED: a refusal whose
|
||||
# sentence never spells an option key cannot be anchored, and is shown in the
|
||||
# page's top-level refusal block instead. Nothing is lost - it is the same
|
||||
# sentence, on the same screen, before the same save - it simply is not beside
|
||||
# a box. test_selfconfig.py part 4 MEASURES how many of the shipped rules
|
||||
# anchor and prints the ones that do not, rather than this comment claiming a
|
||||
# coverage figure.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def problems_by_field(problems, keys):
|
||||
"""(by_key, unanchored) for a list of validator sentences.
|
||||
|
||||
A sentence naming several keys is filed under EVERY one of them - a
|
||||
cross-field rule is about all of its fields, and hiding it from one of them
|
||||
would be this card's own defect in miniature.
|
||||
"""
|
||||
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_])" + re.escape(key)
|
||||
+ r"(?![A-Za-z0-9_])")) for key in keys]
|
||||
by_key, unanchored = {}, []
|
||||
for problem in problems:
|
||||
hit = False
|
||||
for key, pattern in patterns:
|
||||
if pattern.search(problem):
|
||||
by_key.setdefault(key, []).append(problem)
|
||||
hit = True
|
||||
if not hit:
|
||||
unanchored.append(problem)
|
||||
return by_key, unanchored
|
||||
|
||||
|
||||
def changed_keys(left, right):
|
||||
"""Sorted keys whose values differ between two option blobs.
|
||||
|
||||
Used for one thing: saying WHICH settings are stored but not yet running,
|
||||
and which the other configuration surface moved underneath this one. It
|
||||
compares values as they are, so a list reordered counts as changed - which
|
||||
is correct for `inverters`, where order is the roster order.
|
||||
"""
|
||||
return sorted(k for k in set(left) | set(right)
|
||||
if left.get(k) != right.get(k))
|
||||
@@ -16,6 +16,7 @@ the short version is in the comments below. Do not "clean this up" - each rule
|
||||
exists because its absence produced a specific, observed failure.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -65,6 +66,17 @@ from dataclasses import dataclass
|
||||
# validate.py refuse the impossible case only, never "close
|
||||
# to the rail".
|
||||
#
|
||||
# ⚠️ AND THAT CHOICE IS LOCAL TO THIS BOUND. §5.10 MEASURED THE STEP IN BOTH
|
||||
# DIRECTIONS, so "the plant's settling time" is a PAIR and not a number, and
|
||||
# which half is the safe one depends on which way being wrong hurts. Taking
|
||||
# 18 for a REFUSAL is right: the looser number refuses less. Taking 18 for a
|
||||
# question of the form "how long does the plant remain a candidate
|
||||
# explanation" is WRONG, because the looser number stops believing the plant
|
||||
# too early - and there the answer is a false accusation. SAFETY-13 asks that
|
||||
# second question and so reads PLANT_SETTLE_DOWN_S below, deliberately not
|
||||
# this constant. Anything else added here has to say which of the two it
|
||||
# means and why.
|
||||
#
|
||||
# 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
|
||||
@@ -86,6 +98,99 @@ 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
|
||||
|
||||
# The other half of the same measurement: the descent of the same 1.78 kW step,
|
||||
# ~23 s from a -1784 W export excursion back into the deadband (goodwe/NOTES.md
|
||||
# §5.10, the same run, the same day, the same plug). It is slower than the ramp
|
||||
# up, which is the ordinary asymmetry of a pack that is pushed one way and
|
||||
# coasts the other. Nothing above this line reads it; SAFETY-13 below does.
|
||||
PLANT_SETTLE_DOWN_S = 23.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-13. HOW LONG THE PLANT STAYS A CANDIDATE EXPLANATION.
|
||||
#
|
||||
# The output freeze below stops the command growing while the inverter is not
|
||||
# following. Two completely different things produce that divergence and the
|
||||
# freeze cannot tell them apart on magnitude alone:
|
||||
#
|
||||
# (a) THE PLANT HAS NOT CAUGHT UP YET, or it is at a limit - a cold pack, a
|
||||
# curtailment, an export cap. Asking for more is pointless, and the
|
||||
# freeze is right to hold. It also CLEARS ITSELF: the command is pinned
|
||||
# at something the inverter can do, so the inverter catches up to it and
|
||||
# `saturated_now` goes false.
|
||||
#
|
||||
# ⚠️ AND (a) INCLUDES THE CASE `unasked_power()` BELOW CANNOT SEE AS (a):
|
||||
# the command has fallen THROUGH ZERO and the pack is still coasting in
|
||||
# the old direction, so the reading has the sign the command used to have
|
||||
# and not the one it has now. That is this loop's own power, arriving
|
||||
# late. It is the reason the time bound is what does the work here and
|
||||
# the predicate alone is not enough.
|
||||
#
|
||||
# (b) SOMETHING ELSE IS DRIVING THE ACTUATOR - the §1.5 non-goal, two writers
|
||||
# on one actuator. The freeze CANNOT clear itself here, because clearing
|
||||
# it needs the inverter to track OUR command and the other writer is what
|
||||
# stops it. Nothing ends this but the other writer going away.
|
||||
#
|
||||
# ⚠️ SO THE BOUND IS ONLY EVER A REASON TO STOP BELIEVING (a). It does not
|
||||
# establish (b) and nothing below claims it does: what it says is that the
|
||||
# plant has run out of alibi, and (b) is then the only explanation this loop
|
||||
# has left - not the only one there is. Everything downstream of the release is
|
||||
# written to be safe when that inference is wrong, because sometimes it is.
|
||||
#
|
||||
# Case (a) is bounded by the plant, and the plant was measured:
|
||||
#
|
||||
# SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)
|
||||
#
|
||||
# ⚠️ THE **DOWN** NUMBER, AND THE DIRECTION IS THE WHOLE POINT. §5.10 measured
|
||||
# the same step both ways - 18 s up, 23 s down - and SAFETY-39 takes 18 because
|
||||
# 18 is the LOOSER bound for a REFUSAL. This is the opposite job. Here the
|
||||
# question is "how long may the plant still be the explanation", and the
|
||||
# conservative answer is the LONGER of the two: believe the plant for as long
|
||||
# as the plant has ever been observed to take. Deriving this from 18 gives 9,
|
||||
# which is three cycles SHORT of the plant's own measured down-ramp - and being
|
||||
# short here does not refuse anything, it ACCUSES. Measured, single inverter,
|
||||
# no second writer anywhere, 1280 closed-loop scenarios: at 9 the exit fires in
|
||||
# 113 of them and at 12 in 24. The direction of that error is why the pair is
|
||||
# now two named constants instead of one.
|
||||
#
|
||||
# ⚠️ AND IT IS NO LONGER THE RECIPROCAL OF SLEW_RAIL_FRAC. It was, at 9, and
|
||||
# that coincidence read as a derivation - two different questions that happened
|
||||
# to share an answer while both were being asked of the same half of a two-
|
||||
# sided measurement. They are 9 and 12 now. If they ever coincide again it is
|
||||
# arithmetic, not agreement.
|
||||
#
|
||||
# ⚠️ IT INHERITS SAFETY-39's CAVEAT ABOUT LOOP_TICK_S, in the opposite
|
||||
# direction, which is why it is repeated rather than cross-referenced. A meter
|
||||
# SLOWER than 2 s makes these 12 cycles cover MORE than 23 s, so the freeze
|
||||
# holds longer than the plant needs: that costs energy - see the case in
|
||||
# DOCS.md - and never safety. A meter FASTER than 2 s makes them cover LESS,
|
||||
# so the freeze can release while the plant is genuinely still moving. That one
|
||||
# is bounded twice over and by neither this constant nor luck: the INTEGRATOR
|
||||
# freeze is not released with the output freeze, so the command can only reach
|
||||
# the integrator's frozen value, and the output clamp bounds that in turn.
|
||||
#
|
||||
# ⚠️ AND A PLANT SLOWER THAN THE ONE THAT WAS MEASURED DOES THE SAME THING,
|
||||
# which no constant derived from a measurement can fix. In the same 1280
|
||||
# scenarios, holding the bound at 12 and lengthening only the plant's down-ramp
|
||||
# past the measured 23 s, the exit starts firing again on a single inverter:
|
||||
# 8/320 at 23 s, 59/320 at 34 s, 268/320 at 50 s. A re-measured plant means a
|
||||
# re-derived constant here, exactly as for the two above it.
|
||||
SETTLE_CYCLES = math.ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) # 12
|
||||
|
||||
# The saturation counter's ceiling, one above the longest bound read from it. A
|
||||
# counter capped BELOW that bound would silently make the bound unreachable -
|
||||
# i.e. delete the exit condition - so it is written against SETTLE_CYCLES and
|
||||
# not as a literal, and it moves when SETTLE_CYCLES moves.
|
||||
#
|
||||
# ⚠️ IT WAS 10, WHICH IS ALSO config.yaml's MAXIMUM FOR `saturation_cycles`,
|
||||
# AND THAT COLLISION IS GONE. At a bound of 9 the largest accepted
|
||||
# `saturation_cycles` (10) sat ABOVE the exit, so a legal configuration could
|
||||
# push the freeze's engagement past its own release and the no-start block
|
||||
# stopped existing. At 12 every accepted value 1..10 is strictly below the
|
||||
# bound, so the freeze always engages before its exit is due and no accepted
|
||||
# configuration can delete the block. `test_control.py` pins that at the schema
|
||||
# edge rather than in prose, because it is a property of two files agreeing.
|
||||
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
@@ -185,6 +290,71 @@ def slew_limit_w(tuning: Tuning) -> float:
|
||||
return SLEW_RAIL_FRAC * abs(float(tuning.max_w))
|
||||
|
||||
|
||||
def unasked_power(prev_w: float, actual_w: float, saturation_w: float) -> bool:
|
||||
"""True when `actual_w` cannot be explained by THIS cycle's command not being met.
|
||||
|
||||
SAFETY-13. The one question the saturation magnitude test cannot answer:
|
||||
is the inverter FAILING TO REACH what we asked, or is it doing something
|
||||
the command in front of it does not account for?
|
||||
|
||||
⚠️ THE ANSWER IS PHYSICAL, NOT STATISTICAL, WHICH IS WHY IT IS A PREDICATE
|
||||
AND NOT A THRESHOLD. A limit can only make the inverter do LESS than the
|
||||
command, in the command's own direction. It cannot make it overshoot the
|
||||
command, and it cannot make it produce power of the OPPOSITE sign. So the
|
||||
whole set of readings a limit can produce is the closed interval spanned by
|
||||
0 and `prev_w`, widened by `saturation_w` for measurement noise, ramp lag
|
||||
and the inverter's own idle draw.
|
||||
|
||||
⚠️ WHAT IT DOES NOT SAY - AND AN EARLIER VERSION OF THIS DOCSTRING DID -
|
||||
IS THAT ANYTHING OUTSIDE THE INTERVAL WAS PUT THERE BY SOMETHING OTHER THAN
|
||||
THIS LOOP. THAT IS FALSE AND IT WAS MEASURED FALSE. This predicate has no
|
||||
memory: `prev_w` is one cycle's command, while the plant carries every
|
||||
command before it. Drive ONE inverter, with nothing else on the bus, from a
|
||||
discharge down through zero, and the pack is still coasting in the old
|
||||
direction after the command has crossed. Measured in the container, one
|
||||
`compute()` against one plant: eight cycles after a +1990 W command the
|
||||
command is 0 and the pack still reads +522.3 W, so
|
||||
`unasked_power(0, 522.3, 500)` is True - and every watt of that 522.3 was
|
||||
asked for by this loop. In 1280 single-inverter closed-loop scenarios with
|
||||
no second writer in them at all, the exit built on this predicate fires in
|
||||
24 of them.
|
||||
|
||||
So what it guarantees is exactly the sentence at the top: this cycle's
|
||||
command does not account for this cycle's reading. What it CANNOT do is
|
||||
tell "somebody else is writing" from "we are". That gap is the reason
|
||||
SETTLE_CYCLES is derived from the plant's slower direction, why the caller
|
||||
checks both, and why the reason string it produces is a place to start
|
||||
looking rather than a finding.
|
||||
|
||||
Worked, at `saturation_w = 500`:
|
||||
|
||||
prev +2000, actual +700 -> inside [0, 2000] a limit. False.
|
||||
prev -5000, actual -700 -> inside [-5000, 0] a limit. False.
|
||||
prev 0, actual +700 -> outside [-500, 500] unasked. True.
|
||||
prev -800, actual +700 -> outside [-1300, 500] unasked. True.
|
||||
|
||||
⚠️ AT `prev_w == 0` THIS IS EXACTLY `saturated_now`, and that is not a
|
||||
coincidence to be tidied away - it is the reason the freeze's zero case is
|
||||
the one the card was written about. With nothing commanded there is no
|
||||
interval left for a LIMIT to live in, so at zero this test cannot be
|
||||
answered by "the inverter is not reaching what we asked" at all. It can
|
||||
still be answered by "the inverter has not finished leaving what we asked
|
||||
for LAST time", which is the coast-down above and is why zero needs the
|
||||
time bound as much as any other `prev_w` does.
|
||||
|
||||
⚠️ IT IS NOT A DETECTOR AND MUST NOT BE ALARMED ON. A saturated inverter
|
||||
whose reading is stale, wrong-signed or simply noisy lands outside the
|
||||
interval too, and so does our own ramp, above. All this predicate decides
|
||||
is whether the output freeze gets an EXIT; being wrong about it costs at
|
||||
most a bounded command that the frozen integrator and the output clamp both
|
||||
still bound. SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on
|
||||
sign, and it is deliberately a different test with a different threshold.
|
||||
"""
|
||||
lo = min(0.0, float(prev_w)) - abs(float(saturation_w))
|
||||
hi = max(0.0, float(prev_w)) + abs(float(saturation_w))
|
||||
return not (lo <= float(actual_w) <= hi)
|
||||
|
||||
|
||||
def compute(
|
||||
prev_w: float,
|
||||
grid_w: float,
|
||||
@@ -232,7 +402,7 @@ def compute(
|
||||
# clockless; the remaining ceiling is that saturation_cycles expresses a
|
||||
# bound in seconds rather than an exact number of them.
|
||||
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
||||
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
||||
sat_count = min(sat_count + 1, _SAT_COUNT_CAP) if saturated_now else 0
|
||||
frozen = sat_count >= tuning.saturation_cycles
|
||||
|
||||
# --- deadband ----------------------------------------------------------
|
||||
@@ -346,8 +516,69 @@ def compute(
|
||||
# implementation: commanding 0 while the inverter reports >500 W means
|
||||
# something else is driving the bus, and that is not the moment to
|
||||
# start pushing power the other way.
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
#
|
||||
# --- SAFETY-13: ...AND IT IS NOT THE MOMENT FOREVER EITHER ----------
|
||||
# Everything above stands. What was missing is an END. Held at
|
||||
# `prev_w == 0` this is a refusal to START, and nothing in this
|
||||
# function or above it clears it - not the error growing, not the
|
||||
# integrator moving, not time. Measured on release/1.0 @ c8dcb38, at
|
||||
# the live install's shape (max_w 5000, slew_w 400, target_grid_w -10):
|
||||
# a site resting in the deadband with another writer holding the pack
|
||||
# at +700 W reaches sat_count 3 with the command at 0, and 0 W is then
|
||||
# a FIXED POINT - a 2 kW export arriving afterwards leaves the command
|
||||
# at exactly 0.0 W on every subsequent cycle, with the integrator
|
||||
# standing at -1194 W the whole time.
|
||||
#
|
||||
# ⚠️ AND THE ZERO CASE IS NOT SPECIAL, WHICH IS WHY THE FIX IS NOT
|
||||
# SPECIAL EITHER. The same run pinned at prev_w == -800 W, against its
|
||||
# own integrator standing at -2388 W, for as long as it ran.
|
||||
# `prev_w == 0` is only the instance where the pinned magnitude happens
|
||||
# to be zero - i.e. where the standing cost is the whole of what the
|
||||
# battery could have done.
|
||||
# A rule written for the zero case alone would have left the other
|
||||
# instances exactly as unbounded as it found them.
|
||||
#
|
||||
# ⚠️ WHAT DECIDES IS NOT HOW LONG, IT IS WHETHER THE FREEZE CAN EVER
|
||||
# END ON ITS OWN. See SETTLE_CYCLES and `unasked_power()` at the top:
|
||||
# a divergence a LIMIT could produce clears itself once the inverter
|
||||
# catches up to the pinned command, so it needs no exit and gets none -
|
||||
# unbounded, exactly as shipped. A divergence a limit CANNOT produce
|
||||
# does not clear itself by being waited on, so it gets one.
|
||||
#
|
||||
# ⚠️ AND THAT IS NOT THE SAME SENTENCE AS "THE OTHER WRITER". An
|
||||
# earlier version of this comment said the two conditions below fire
|
||||
# only in case (b); they do not, and the sweep says so. Our own plant
|
||||
# coasting through a command that has just crossed zero satisfies
|
||||
# `unasked_power()` too, because that predicate compares against ONE
|
||||
# cycle's command and the pack remembers more than one - measured, a
|
||||
# single inverter with nothing else on the bus reaching this branch in
|
||||
# 24 of 1280 closed-loop scenarios. `SETTLE_CYCLES` is what keeps that
|
||||
# rare rather than routine (113 of the same 1280 at the old bound of
|
||||
# 9), and it does it by believing the plant for as long as the plant
|
||||
# has ever been measured to take. It is a bound on how long we wait,
|
||||
# not a proof about what we found.
|
||||
#
|
||||
# ⚠️ SO THE RELEASE IS WRITTEN TO BE SURVIVABLE WHEN IT IS WRONG, and
|
||||
# that is the paragraph below, not this one: what a false release costs
|
||||
# is one slew step per cycle toward a frozen integrator. If it ever has
|
||||
# to cost less than that, the thing to change is what happens after the
|
||||
# release, not the confidence of the sentence before it.
|
||||
#
|
||||
# ⚠️ RELEASING IS NOT UNFREEZING. The INTEGRATOR freeze above is not
|
||||
# lifted, and that is what makes this bounded rather than merely
|
||||
# different: the command is free to move, but only as far as an
|
||||
# integrator that stopped accumulating cycles ago, through the same
|
||||
# slew limit as any other cycle. It buys back the standing error, not
|
||||
# the rail.
|
||||
#
|
||||
# `reason` is authoritative here in a way it is not elsewhere in this
|
||||
# function: nothing after this line writes it.
|
||||
if unasked_power(prev_w, actual_w, tuning.saturation_w) \
|
||||
and sat_count >= SETTLE_CYCLES:
|
||||
reason = "freeze-released"
|
||||
else:
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
|
||||
# --- quantise ----------------------------------------------------------
|
||||
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
"""SAFETY-42 - a unit that stops delivering its share, which nothing else can see.
|
||||
|
||||
At one inverter this file is inert by construction (see `delinquent()`), and
|
||||
that is deliberate rather than a limitation: the whole signal it reads is a
|
||||
DIFFERENCE BETWEEN SIBLINGS, and a roster of one has none.
|
||||
|
||||
WHAT IT CATCHES, in one sentence: an inverter that accepts its share and does
|
||||
not produce it, beside a sibling that does - HW-01's shape at N=2, where the
|
||||
2026-09-02 fault wedged inverter A for 6 h 25 m with every layer a spectator.
|
||||
|
||||
WHY EVERY EXISTING GUARD IS SILENT, AND IT IS STRUCTURAL
|
||||
=======================================================
|
||||
SAFETY-30 per unit gates on OPPOSITION: both the command and the unit's own
|
||||
power over `unit_sign_disagree_w` (250 W) with opposite
|
||||
signs. A unit sitting at 0 W never opens that gate. It is
|
||||
not opposing, it is ABSENT.
|
||||
SAFETY-30 site the sum of the commands still AGREES with the meter in
|
||||
direction. Only the magnitude is short, and the site
|
||||
comparison is a sign test.
|
||||
SAFETY-31 `sum(|Bi|) - |sum(Bi)|` is ZERO when one unit does
|
||||
nothing: circulation needs two units pulling AGAINST each
|
||||
other, and one of these is pulling nowhere at all.
|
||||
the saturation freeze fires - and `clamp.py`:93-96 says of exactly this: "A
|
||||
loop can be legally saturated all day - a cold pack, a
|
||||
curtailed inverter, an export limit - so a
|
||||
saturation-shaped alarm is one an operator learns to
|
||||
ignore."
|
||||
|
||||
⚠️ SO THE ONE SIGNAL THAT DOES FIRE IS THE ONE THIS CODEBASE DOCUMENTS AS
|
||||
UNREADABLE, and that documentation is right. This module therefore does not make
|
||||
that signal louder; it computes a different one.
|
||||
|
||||
⚠️ THE ONE PLACE IT OVERLAPS SAFETY-30, AND THE OVERLAP RESOLVES ITSELF. A unit
|
||||
measured at -2000 W against a +2000 W share is OPPOSING - SAFETY-30's fault -
|
||||
and is ALSO short by 4000 W with nothing here to explain it. Both detectors have
|
||||
evidence, and only one alarm results, because SAFETY-30's per-unit hold is the
|
||||
shorter of the two (85 s against 185 s at the shipped defaults): it trips first,
|
||||
files its `failsafe` SET, the share goes to 0 W, and this check's gate closes on
|
||||
`share > 0` while `account()` answers "parked" as well. That is asserted on a
|
||||
reversed-unit fixture in `test_delivery.py` part 6 rather than left to the
|
||||
reader, because two alarms for one fault is how an operator learns to skim.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE CARD'S OWN DISCRIMINATOR WAS MEASURED AND IT DOES NOT WORK - READ THIS FIRST
|
||||
==============================================================================
|
||||
SAFETY-42 proposed: alarm on `saturation persisting past N` while
|
||||
`|grid error| > X`, with N and X derived rather than chosen. That predicate was
|
||||
built and run on the two-master rig before this module was written, and it
|
||||
fails in BOTH directions. Neither failure is a matter of picking better N and X;
|
||||
both are properties of the quantity.
|
||||
|
||||
1. IT FIRES ON EVERY BENIGN SATURATION, and the two cases are not merely
|
||||
similar - they are THE SAME TRACE. `plantsim.Plant.limit()` returns 0 W for
|
||||
a discharge command into a pack at its own floor; a wedged unit that never
|
||||
receives its command also produces 0 W. Run both against the same stimulus
|
||||
(load 2000 W, equal weights, 2000 W per-unit cap) and every emitted watt
|
||||
agrees: over 120 control cycles the two arms are IDENTICAL on all 120 in
|
||||
(grid, site target, each unit's share, each unit's measured power). Both
|
||||
report `reason == "saturated-freeze"` on 116 of those 120 cycles - the
|
||||
other four are `slew-limited`, before the freeze engages on cycle 4 - and
|
||||
both hold |grid error| over 300 W on all 120, settling at 1200 W. A
|
||||
predicate reading only those
|
||||
numbers cannot separate them at ANY (N, X), because nothing in its inputs
|
||||
differs. And "a cold pack at its floor must not alarm" is this card's own
|
||||
acceptance criterion. `test_delivery.py` part 4 asserts that identity on
|
||||
the add-on's own end-to-end fixture, so it goes red if it ever stops being
|
||||
true.
|
||||
|
||||
2. IT IS SILENT ON THE HEADLINE CASE, because the standing grid error is
|
||||
exactly what a HEALTHY SIBLING ERASES. Same rig, load 800 W, equal weights,
|
||||
2000 W per-unit cap, inverter B wedged: over the last 30 of 200 cycles the
|
||||
loop is saturation-frozen on every one of them - so "saturation persisting
|
||||
past N" is true for any N - while the worst |grid error| in that window is
|
||||
30 W. B is standing at a +770 W discharge share and delivering 0 W. Any X
|
||||
above 30 W leaves the card's predicate silent for the whole run, on the
|
||||
exact fault it was written for: a healthy sibling regulating alone with the
|
||||
site short by one unit's share.
|
||||
|
||||
The card's derivation instruction stands and is honoured; what could not be
|
||||
derived was a threshold on the wrong quantity. This is written down rather than
|
||||
quietly replaced, because the next person to read the card will propose it again.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE QUANTITY THAT DOES WORK, AND WHY IT NEEDS TWO INVERTERS
|
||||
===========================================================
|
||||
Per unit, for a share that asks it to DISCHARGE:
|
||||
|
||||
short_by(i) = share_i - measured_i (watts that did not arrive)
|
||||
|
||||
`share_i` is the command STANDING ON THAT UNIT'S WIRE - `Inverter.standing_w()`,
|
||||
the same operand SAFETY-30's per-unit comparison uses - and `measured_i` is that
|
||||
unit's own power, holding register 0x0518, per-instance since DEV-02.
|
||||
|
||||
That number alone is the saturation signal again, per unit. What makes it
|
||||
readable is the reduction over the roster:
|
||||
|
||||
a unit is DELINQUENT while it is short with NO ACCOUNT FOR IT, and the set
|
||||
of units in that state is a PROPER SUBSET of the units that were asked to
|
||||
discharge.
|
||||
|
||||
Both halves are load-bearing:
|
||||
|
||||
* THE ACCOUNT is what keeps the cold pack out, and it is the only thing that
|
||||
can. The two cases are identical in the power domain (see 1 above), so the
|
||||
discriminator has to be a fact this controller reads that is NOT power - and
|
||||
there is exactly one: the pack's own state of charge against its own
|
||||
`bms_soc_floor`. `account()` below asks it through `peakguard.
|
||||
discharge_permitted()`, the predicate SAFETY-32 already made public, rather
|
||||
than transcribing the comparison a second time.
|
||||
* THE PROPER SUBSET is what keeps a site-wide cause out, and it is the half
|
||||
that only exists at N>=2. A derate this add-on cannot see - a thermal
|
||||
limit, a grid-code curtailment, an inverter-side DC restriction - hits the
|
||||
whole roster. One box that has stopped listening does not. If EVERY unit
|
||||
that was asked to deliver is short and unexplained, this module says
|
||||
nothing: it cannot tell that from a plant that simply cannot do it, and
|
||||
saying so anyway is the saturation-shaped alarm all over again.
|
||||
|
||||
⚠️ AT N=1 THE SUBSET IS NEVER PROPER AND THIS IS INERT. One asked unit is either
|
||||
accounted for (nothing to say) or unaccounted for (the whole roster, so nothing
|
||||
to compare it against). `delinquent()` returns `{}` for every input at a roster
|
||||
of one - not by a guard on `len`, but because a proper subset of a one-element
|
||||
set that contains the element is impossible. That is AC 5, and it is asserted
|
||||
rather than assumed in `test_delivery.py` part 1.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
DISCHARGE SHARES ONLY, AND THE COST OF THAT IS STATED RATHER THAN HIDDEN
|
||||
=======================================================================
|
||||
`short_by()` answers None for a charge share. The account gate needs a bound
|
||||
this controller actually reads in the commanded direction, and there is one for
|
||||
discharge - `bms_soc_floor`, required of every roster entry since SAFETY-32 -
|
||||
and NONE for charge. A pack in constant-voltage taper legitimately takes
|
||||
hundreds of watts less than it is told for tens of minutes, and there is no
|
||||
configured ceiling anywhere in this add-on to explain that with. Armed on charge
|
||||
shares this module would alarm on the taper of every §12 maintenance charge,
|
||||
which is the crying-wolf failure it exists to avoid.
|
||||
|
||||
What that costs, said plainly: a unit that stops ABSORBING its share is not
|
||||
caught here. It is the same fault in the other direction and it wants the same
|
||||
treatment; what it needs first is a per-pack charge ceiling this add-on does not
|
||||
have. That is a card, and this paragraph is its evidence.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
IT REPORTS. IT DOES NOT ACT - AND THAT IS A DECISION, NOT AN OMISSION
|
||||
====================================================================
|
||||
Card requirement 3 asks which, and why. Three candidates, and the reasoning is
|
||||
recorded because "alarm-only" is the weaker-looking answer and is still right:
|
||||
|
||||
COMMAND IT HARDER - no. HW-01 settles it: on 2026-09-02 the power stage went
|
||||
on executing its last instruction for 6 h 25 m through every command this
|
||||
add-on sent it. A command is not a remedy for a box that is not listening,
|
||||
and pretending otherwise is theatre.
|
||||
|
||||
RE-ALLOCATE ITS SHARE TO THE SIBLING - no, AND NOT FOR THE COMFORTABLE
|
||||
REASON. The comfortable reason would be that the loop already does it, and
|
||||
the rig says otherwise: with B wedged and equal weights, dropping B from
|
||||
`live` - the mechanism `live_units()` already uses for a dead link - is a
|
||||
real improvement at loads the site cannot cover on one unit's SHARE.
|
||||
Measured, per-unit cap 2000 W, B wedged:
|
||||
|
||||
load 1500 W kept in the split: the site settles at 800 W with a 700 W
|
||||
grid error and NEVER comes inside 100 W in 150 cycles -
|
||||
the saturation freeze pins the site target, so A's share
|
||||
never rises. Dropped: 1500 W delivered, inside 100 W from
|
||||
cycle 14.
|
||||
load 2000 W kept: 800 W delivered, 1200 W error, never settles.
|
||||
Dropped: 2000 W delivered, inside 100 W from cycle 8.
|
||||
|
||||
That is not three cycles of latency; it is the difference between a site
|
||||
that recovers and one that does not. It is the strongest argument on this
|
||||
card and it is stated first, because the answer still has to be no.
|
||||
|
||||
WHAT REFUSES IT is that the only re-allocation this add-on can express is
|
||||
ALL OR NOTHING. `live_units()` drops a unit entirely and the survivors carry
|
||||
the whole target; there is no way to say "give the sibling the watts that
|
||||
did not arrive and leave this unit the ones that did". And a unit delivering
|
||||
PART of its share is short by more than `saturation_w` too, with nothing
|
||||
here to explain it, so this detector names it - it cannot tell a 50 %
|
||||
derate from a wedged box, because neither can any instrument this add-on
|
||||
has. Measured, B derated to 50 %, load 3000 W, per-unit cap 2000 W, and
|
||||
SAFETY-42 names B in that run:
|
||||
|
||||
kept in the split site delivers 2400 W (B contributing 800 W),
|
||||
grid error 600 W
|
||||
dropped site delivers 2000 W (B contributing 0 W),
|
||||
grid error 1000 W - 400 W WORSE
|
||||
|
||||
So the remedy would make the site worse in a case this detector cannot rule
|
||||
out, on a live control law, driven by a brand-new alarm. A partial
|
||||
re-allocation - the sibling gets the undelivered watts without the short
|
||||
unit being commanded to zero - is what would make acting safe, and the
|
||||
allocator has no way to express it today. That is a card, and this block is
|
||||
its evidence.
|
||||
|
||||
STOP THE SITE, as SAFETY-31 does - no, and the asymmetry with SAFETY-31 is
|
||||
exact. Circulating power is stopped because THIS CONTROLLER IS ONE OF THE
|
||||
TWO PARTICIPANTS: removing our half halves the loop and un-hides the residue
|
||||
at a meter that was netting it to zero. Here this controller is not a
|
||||
participant in the fault, nothing is netting, and the sibling is the only
|
||||
thing still delivering. Parking it would take the site from "short by one
|
||||
unit" to "short by all of them" and would remove the evidence.
|
||||
|
||||
So: a durable latch, a named unit, an action-log row, and an operator
|
||||
notification - and the loop is left exactly as it was. Requirement 4 of the card
|
||||
("do not weaken the saturation freeze to make room") is met by not touching
|
||||
`control.py` at all.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
IT SURVIVES A RESTART, WHICH IS SAFETY-34 AND IS WHY THE LATCH EXISTS AT ALL
|
||||
===========================================================================
|
||||
`persistent_notification` is IN MEMORY. Home Assistant restarts and the card is
|
||||
gone with the fault still running, which is precisely how the 2026-09-02 outage
|
||||
went unseen for 6 h 25 m. So the durable half is `unit/<name>/delivery_tripped`
|
||||
and `unit/<name>/delivery_reason` in `/data/state.json` - namespaced per unit,
|
||||
for `store.unit_key()`'s reason - and `main.py` re-raises the notification from
|
||||
it on the first cycle of every process.
|
||||
|
||||
⚠️ AND THAT IS WHY THIS LATCHES THOUGH IT DOES NOT ACT. A detector that cleared
|
||||
itself when the unit resumed would erase its own record of an intermittent
|
||||
fault, which is the one shape an operator most needs to be told about. It is
|
||||
cleared by the same operator press that clears SAFETY-02's and SAFETY-31's -
|
||||
a deliberate human act, after looking at why.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .peakguard import discharge_permitted
|
||||
|
||||
# The reason code. Distinct from every other one in this add-on by construction:
|
||||
# nothing else uses it, and it deliberately does not contain the word
|
||||
# "saturation" - see the module docstring on why a saturation-shaped alarm is
|
||||
# the thing this module must not produce.
|
||||
REASON_CODE = "UNIT_NOT_DELIVERING"
|
||||
|
||||
# CFG-03. THE ONE IN-CODE COPY OF THIS DEFAULT. `config.yaml`'s `options:` block
|
||||
# is the source of truth an operator edits and the Supervisor reads; `schema:`
|
||||
# holds the bounds. This dict exists for the reason
|
||||
# `circulation.CIRC_OPTION_DEFAULTS` and `watchdog.SIGN_OPTION_DEFAULTS` do -
|
||||
# the Dockerfile does not put config.yaml in the image, so the running add-on
|
||||
# cannot read it - and `test_delivery.py` pins the two together by READING
|
||||
# config.yaml, so a drift goes red in CI rather than shipping.
|
||||
#
|
||||
# ⚠️ THERE IS ONLY ONE KEY HERE, AND THE MISSING SECOND ONE IS THE POINT. The
|
||||
# other half of this check is a WATTS threshold, and it is not an option and not
|
||||
# a constant: it is `control.Tuning.saturation_w`, read at runtime. See
|
||||
# `MIN_W_IS_SATURATION_W` below.
|
||||
DELIVERY_OPTION_DEFAULTS = {
|
||||
# --- THE WINDOW, AND IT IS THE ONLY NUMBER THIS CARD CHOSE ------------
|
||||
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT PICKED. 2640 closed-loop
|
||||
# runs at a roster of two in which NOTHING is faulty - every unit executes
|
||||
# the share the allocator gave it - over 6 gains x 5 slews, the whole range
|
||||
# config.yaml accepts for `gain` float(0.05,1.0) and `slew_w` int(0,5000),
|
||||
# across three families of stimulus that can produce a legitimate shortfall:
|
||||
#
|
||||
# 1050 load reversals 7 sizes x 5 write staggers (the serialised write
|
||||
# path landing B's number up to four cycles late)
|
||||
# 840 flapping loads 7 periods x 4 amplitudes
|
||||
# 750 weighted splits 5 weightings x 5 staggers on a +-2200 W reversal
|
||||
#
|
||||
# ⚠️ THE WEIGHTED FAMILY IS NEW HERE AND IT IS THE ONE THAT DOMINATES. An
|
||||
# asymmetric split hands one unit a much larger step than its sibling, so
|
||||
# the big one is still ramping while the small one has settled - which is
|
||||
# exactly the PROPER SUBSET this check keys on. SAFETY-30's campaign had no
|
||||
# such family, so reading its numbers across would have understated this.
|
||||
#
|
||||
# Every run was executed a second time at a roster of ONE, on the same
|
||||
# stimulus, as the control.
|
||||
#
|
||||
# ⚠️ RE-RUN BY TEST-21 (2026-09-07) AND THE HEADLINE DID NOT REPRODUCE. The
|
||||
# harness that produced the original table was never committed, which is
|
||||
# PROC-01's shape; it is committed now, in the OTHER repository, as
|
||||
# `admin/goodwe:sim/delivery_window.py` (it imports `plantsim` and
|
||||
# `frame_check`, which that repo owns - restating them here would be CI-08's
|
||||
# coupling, and CI-08 has broken three times). Re-derive it with:
|
||||
#
|
||||
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
# python3 -B delivery_window.py --selftest --campaign --blag
|
||||
#
|
||||
# Worst CONTINUOUS legitimate episode. Second block is the re-run:
|
||||
#
|
||||
# threshold reversals flapping weighted N=1 control (1890 runs)
|
||||
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s } 2026-09-06
|
||||
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s } harness
|
||||
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s } NOT
|
||||
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s } COMMITTED
|
||||
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s } - see
|
||||
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s } DECISIONS
|
||||
#
|
||||
# threshold reversals flapping weighted N=1 control (2640 runs)
|
||||
# 100 W 120.0 s 0.0 s 165.0 s 0.0 s } TEST-21
|
||||
# 250 W 65.0 s 0.0 s 100.0 s 0.0 s } 2026-09-07
|
||||
# * 500 W 30.0 s 0.0 s 35.0 s 0.0 s } committed
|
||||
# 700 W 25.0 s 0.0 s 30.0 s 0.0 s } harness,
|
||||
# 1000 W 15.0 s 0.0 s 25.0 s 0.0 s } re-runnable
|
||||
# 1500 W 0.0 s 0.0 s 20.0 s 0.0 s }
|
||||
# 2000 W 0.0 s 0.0 s 0.0 s 0.0 s }
|
||||
#
|
||||
# * is the shipped threshold, `Tuning.saturation_w`.
|
||||
#
|
||||
# ⚠️ THREE THINGS IN THAT TABLE ARE WORTH MORE THAN THE ROW THEY SIT ON.
|
||||
# (a) The N=1 column is ZERO EVERYWHERE, at every threshold, IN BOTH
|
||||
# CAMPAIGNS. The compatibility promise is measured, not argued, and it
|
||||
# is the part that reproduced most cleanly.
|
||||
# (b) The FLAPPING column is zero everywhere too, in both - and that is the
|
||||
# family that defeated SAFETY-30's site window (980 s at N=2, 1000 s at
|
||||
# N=1, no window inside the billing quarter surviving it). It cannot
|
||||
# touch this check, because a common stimulus makes BOTH units short at
|
||||
# once and the subset stops being proper.
|
||||
# (c) THE ORIGINAL CURVE WAS NOT MONOTONE - 55.0 s at 250 W against 40.0 s
|
||||
# at 100 W - and that was read as a property of the reduction: a lower
|
||||
# threshold puts MORE units into the short set, which can turn a proper
|
||||
# subset into the whole roster and BREAK the run. ⚠️ THE RE-RUN'S CURVE
|
||||
# IS MONOTONE. The mechanism is real and is still the reason not to read
|
||||
# either table as a sensitivity curve, but it is no longer something
|
||||
# these numbers demonstrate - it is an argument now, not a measurement.
|
||||
#
|
||||
# THE BOUNDS THE VALUE ANSWERS TO:
|
||||
#
|
||||
# LOWER, from the rig - 35.0 s (TEST-21; the original record said 30.0 s
|
||||
# and it did not reproduce), the worst continuous legitimate episode at
|
||||
# the shipped threshold across all 2640 runs. ⚠️ THIS IS NOW STRICTLY THE
|
||||
# BINDING LOWER BOUND: at 30.0 s it coincided with the firmware's veto
|
||||
# below and a reader could not tell which was doing the work; at 35.0 s it
|
||||
# is ABOVE it, and it is above the schema's own `int(30,900)` floor too -
|
||||
# so an operator who sets `delivery_hold_s` to 30 is below a MEASURED
|
||||
# legitimate episode. That is what "derived, do not change" is for.
|
||||
#
|
||||
# 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 the add-on already answers by dropping it from the split with
|
||||
# a line in the action log. Reporting inside that window would be a
|
||||
# second, louder alarm about a state already being handled - the
|
||||
# SAFETY-15/SAFETY-19 failure exactly.
|
||||
#
|
||||
# MARGIN, from INVERTER B NOT EXISTING. ⚠️ THIS IS A SENSITIVITY STUDY ON
|
||||
# A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, and the wording here used
|
||||
# to say "MEASURED rather than GUESSED", which overstated it. B is not
|
||||
# installed (~2026-09-12). The campaign's B is A COPIED
|
||||
# (`plantsim.INVERTER_B`, `measured=False`), so the 35.0 s above is TWO
|
||||
# IDENTICAL MACHINES and every row below is a what-if. NO ROW HERE IS B's
|
||||
# LAG. Sweeping a DIFFERENT second unit through the weighted family
|
||||
# (2026-09-06 -> TEST-21 re-run):
|
||||
#
|
||||
# B lag / ramp original re-run
|
||||
# 10.0 s / 800 W/s 30.0 s 35.0 s (= A, both copies)
|
||||
# 20.0 s / 800 W/s 45.0 s 45.0 s
|
||||
# 10.0 s / 400 W/s 30.0 s 35.0 s
|
||||
# 20.0 s / 400 W/s 45.0 s 50.0 s
|
||||
# 40.0 s / 200 W/s 90.0 s 75.0 s (4x A's lag)
|
||||
#
|
||||
# The LAG moves it roughly linearly; the RAMP barely does. "The ramp does
|
||||
# not move it AT ALL" was too strong - halving it is worth 0 s at A's lag
|
||||
# and +5 s at twice it - so the ramp is second-order, not absent. That is
|
||||
# the sensitivity the margin has to carry until SPIKE-04 measures the real
|
||||
# unit.
|
||||
#
|
||||
# UPPER, from the bill - 900 s, the quarter that sets the capacity tariff.
|
||||
# A site running short of one unit's share must be reported well inside
|
||||
# one.
|
||||
#
|
||||
# 185 s: thirty-seven control cycles. Against the TEST-21 re-run: 5.29x the
|
||||
# worst legitimate episode this rig produces (35.0 s), 2.47x the worst a
|
||||
# hypothetical second unit FOUR TIMES slower than A produces (75.0 s), 6.2x
|
||||
# the firmware's own veto window (30 s), and 4.9x inside the quarter
|
||||
# (900 s). ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
|
||||
# is a different check on a different quantity, so a reader must not be
|
||||
# able to think it was carried across - and not 150 or 200.
|
||||
#
|
||||
# ⚠️ 185 STANDS AND IS NOT CHANGED. The record claimed 6.2x the measured
|
||||
# worst; the re-run makes it 5.29x. All four bounds still clear and none of
|
||||
# them narrowly, and the ordering of the argument is unchanged. A shipped
|
||||
# safety constant is not moved because a margin went from 6.2x to 5.3x while
|
||||
# every bound holds - moving it would re-open SAFETY-41's four-attempt
|
||||
# false-trip regime for nothing. What changed is the CLAIM, not the value.
|
||||
#
|
||||
# WHAT IT COSTS, stated rather than hidden: up to 185 s of a site running
|
||||
# short by one unit's share before anybody is told. Against a fault that ran
|
||||
# 6 h 25 m unnoticed, and against a guard that TAKES NO ACTION, this window
|
||||
# buys its margin with reporting latency and with nothing else.
|
||||
"delivery_hold_s": 185,
|
||||
}
|
||||
|
||||
|
||||
# ⚠️ WHY THERE IS NO `delivery_w` OPTION, AND WHY THAT IS A DERIVATION RATHER
|
||||
# THAN A SAVING. The watts side of this check asks "how far short is far
|
||||
# enough to be worth saying anything about", and the shipped control law
|
||||
# already answers that question in one number: `Tuning.saturation_w`, 500 W,
|
||||
# the gap at which `compute()` itself concludes THE INVERTER IS NOT FOLLOWING
|
||||
# and freezes the integrator. Below it the loop calls the divergence ordinary
|
||||
# tracking, and an alarm under a threshold the control law calls normal is an
|
||||
# alarm about nothing.
|
||||
#
|
||||
# So `main.py` hands this check `self.tuning.saturation_w` and there is no
|
||||
# fourth copy of a default to keep in step (CFG-03's complaint) and no way for
|
||||
# the two to drift: an operator who raises `saturation_w` has told the loop
|
||||
# that a bigger gap is ordinary tracking, and this check moves with them by
|
||||
# construction rather than by somebody remembering.
|
||||
#
|
||||
# ⚠️ AND THAT SENTENCE IS A CHECKED CLAIM, NOT A DESCRIPTION OF AN INTENTION.
|
||||
# It is only true while the call site passes the CONFIGURED value; a literal
|
||||
# there that happens to equal 500 today keeps every fixture green and quietly
|
||||
# removes the whole guarantee - `250.0` in place of `self.tuning.saturation_w`
|
||||
# was a survivor of all 23 suites, because every fixture's gap was far above
|
||||
# both numbers. test_delivery.py part 4b is what makes it checkable: it runs
|
||||
# the same wedged unit at the shipped `saturation_w` and at a raised one inside
|
||||
# the shipped schema, with a shortfall that falls BETWEEN them, and the two
|
||||
# arms must answer differently. No constant passes both.
|
||||
#
|
||||
# ⚠️ IT IS ALSO WHY THIS CANNOT DOUBLE-REPORT THE SATURATION FREEZE'S OWN
|
||||
# EVENT. `compute()` needs `saturation_cycles` (3) CONSECUTIVE cycles over
|
||||
# that same gap before it freezes anything, so every cycle this module counts
|
||||
# is a cycle the freeze has already seen. This is a reduction over the freeze's
|
||||
# input, not a second opinion about it.
|
||||
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-43. THE SECOND WAY TO BE NAMED, AND WHY IT IS A FRACTION OF THE ASK.
|
||||
#
|
||||
# `min_w` above is an ABSOLUTE gap, and OPT-09 made that a hole. `weights()`
|
||||
# shrinks a low-headroom unit's share, and shrinking the share shrinks the gap:
|
||||
# a unit standing at +125 W and delivering 0 W is 100 % delinquent and 125 W
|
||||
# short, so an absolute 500 W never names it. Measured end to end
|
||||
# (`test_packshare.py` part 8b, real `compute()`, real `plan()`, real
|
||||
# `delinquent()`, B wedged at a healthy 15 % against its own 10 % cut-out):
|
||||
#
|
||||
# load 1500 W, equal weights the wedged unit named on 199/200 cycles
|
||||
# load 1500 W, OPT-09 weights named on 0/200 cycles
|
||||
#
|
||||
# ⚠️ AND THE SUPPRESSED REGIME IS THE QUIET ONE. At a load the healthy sibling
|
||||
# covers, the meter is satisfied, `unmet_w` is 0 and no action-log line fires,
|
||||
# so this alarm is the ONLY instrument that would have told the operator.
|
||||
#
|
||||
# ⚠️ THE FIX IS NOT A SMALLER `min_w`, AND THAT IS THE WHOLE SHAPE OF THIS
|
||||
# CARD. `min_w` is `Tuning.saturation_w` and it exists to stop NORMAL
|
||||
# SATURATION being reported as delinquency - the crying-wolf failure the whole
|
||||
# module is written against, and the one SAFETY-41 spent four attempts on
|
||||
# ("i'm getting really annoyed by the notifications in my HA"). Lowering it
|
||||
# trades a missed alarm for a false one on every unit at once. A fraction of
|
||||
# the unit's OWN SHARE is a SECOND way to be named that leaves the first
|
||||
# exactly where it was: `gap > min_w OR gap > frac * share`. Nothing that was
|
||||
# silent before this card becomes noisy unless the unit is delivering almost
|
||||
# none of what it was asked for, which is not saturation-shaped at all.
|
||||
#
|
||||
# WHERE 0.85 COMES FROM - MEASURED ON THE SAME CAMPAIGN AS `delivery_hold_s`.
|
||||
# `admin/goodwe:sim/delivery_window.py --campaign --fractions` grades this
|
||||
# constant over the SAME 2640 no-fault runs (three families x 6 gains x 5 slews
|
||||
# x the write staggers, every unit executing the share it was given) that
|
||||
# produced the 35.0 s worst legitimate episode, at the shipped `min_w`, and
|
||||
# reports what the relative arm ADDS. Worst continuous legitimate episode,
|
||||
# worst of the three families:
|
||||
#
|
||||
# frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
|
||||
# worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
|
||||
#
|
||||
# frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
|
||||
# worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
|
||||
#
|
||||
# absolute arm alone: 35.0 s - SAFETY-42 §3.3's figure, re-measured in the
|
||||
# SAME runs, which is what makes every row above a comparison and not a claim.
|
||||
# N=1 control: 0.0 s at EVERY fraction, in all 2640 one-inverter runs.
|
||||
# Flapping family: 0.0 s at EVERY fraction, in all 840 runs.
|
||||
#
|
||||
# ⚠️ READ THE CURVE, NOT THE ROW. THERE IS NO CLIFF ANYWHERE IN IT. From 0.45
|
||||
# to 0.99 every figure is 40.0 or 45.0 s - within ONE CONTROL CYCLE (5.0 s) of
|
||||
# every other - and below 0.45 it rises gradually to 90.0 s at 0.05. It is not
|
||||
# monotone either (0.40 reports 60.0 s where 0.35 reports 55.0), which is
|
||||
# §3.3's own mechanism (c) rather than noise in the plant: a more sensitive rule
|
||||
# puts MORE units into the short set, which can turn a proper subset into the
|
||||
# WHOLE roster and END the episode. So this constant is INSENSITIVE over the
|
||||
# whole usable range, and that is the finding rather than a caveat on it. 0.85
|
||||
# sits in the flat part, 0.40 above the shoulder at ~0.45 and 0.15 below 1.0.
|
||||
#
|
||||
# ⚠️ AND "ZERO FALSE NAMINGS" IS NOT AVAILABLE AT ANY FRACTION - said here
|
||||
# because the card asked for exactly that. Every fraction adds naming CYCLES to
|
||||
# the no-fault campaign; a relative arm that never fired on a legitimate
|
||||
# transient would be one that never fired at all, because the modelled write
|
||||
# stagger lands B's number up to four cycles late and a unit whose new command
|
||||
# has not arrived IS delivering a small fraction of what is standing on its
|
||||
# wire. What is zero at every fraction is false ALARMS: the longest legitimate
|
||||
# episode anywhere in the 20-point sweep is 90.0 s - at 0.05, the most sensitive
|
||||
# fraction measured and far below anything this card would ship - against a
|
||||
# `delivery_hold_s` of 185 s, so NOTHING in 2640 no-fault runs comes within 95 s
|
||||
# of notifying an operator at any fraction measured. At 0.85 the hold is 4.62x
|
||||
# the worst legitimate episode where the absolute arm alone gave 5.29x. That is
|
||||
# the price, in full.
|
||||
#
|
||||
# ⚠️ THE DIRECTION IS THE OPPOSITE OF `min_w`'s AND IT IS EASY TO GET BACKWARDS.
|
||||
# A SMALLER fraction names MORE units - it is the more sensitive setting and the
|
||||
# more dangerous one - so the campaign puts a LOWER bound on this number, not an
|
||||
# upper one. It is bounded ABOVE by the card's own case: at 1.0 the arm can only
|
||||
# fire on a unit that is IMPORTING while asked to discharge, which is
|
||||
# SAFETY-30's fault and not this one, so a unit delivering NONE of its share
|
||||
# (gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
|
||||
# wedged box reporting a small standby figure rather than a clean zero.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT AN OPTION, FOR `delivery_w`'s REASON ONE PARAGRAPH UP. An
|
||||
# operator has no instrument to derive it with, a knob here is a knob on how
|
||||
# loud this add-on is, and SAFETY-41 is the record of what that costs. It is a
|
||||
# derived constant with a committed harness behind it, and the way to move it
|
||||
# is to re-run the campaign.
|
||||
DELIVERY_SHARE_FRACTION = 0.85
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryConfig:
|
||||
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
||||
|
||||
hold_s: float = float(DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitDelivery:
|
||||
"""ONE unit's four facts for this cycle, and nothing else.
|
||||
|
||||
`name` the roster name. An alarm that cannot say which box to look at
|
||||
is the whole reason SAFETY-30 split per-unit from site.
|
||||
`share_w` what is STANDING on this unit's wire - `Inverter.standing_w()`,
|
||||
the same operand SAFETY-30's per-unit comparison takes, after
|
||||
the deadband, the slew limit, quantisation and the arbiter.
|
||||
Not the optimizer's wish: the wire carries the emitted value.
|
||||
`measured_w` this unit's OWN power, holding register 0x0518, + = discharge.
|
||||
None when the reading did not arrive.
|
||||
`accounted` why a shortfall on this unit is already explained, or None if
|
||||
nothing this controller reads explains one. Built by
|
||||
`account()`; it is a field rather than a method because the
|
||||
facts behind it (liveness, the sign latch, SoC) belong to
|
||||
main.py's `Inverter` and this module imports none of it.
|
||||
"""
|
||||
|
||||
name: str
|
||||
share_w: float | None
|
||||
measured_w: float | None
|
||||
accounted: str | None = None
|
||||
|
||||
|
||||
def short_by(share_w, measured_w):
|
||||
"""Watts of a DISCHARGE share that did not arrive, or None if unanswerable.
|
||||
|
||||
Positive means short. Zero and negative are folded into None rather than
|
||||
reported, because "delivered its share" and "delivered more than its share"
|
||||
are both not-this-fault and a caller comparing a float against a threshold
|
||||
should not have to know which.
|
||||
|
||||
⚠️ CHARGE SHARES ANSWER None, AND THAT IS THE STATED LIMIT AT THE TOP OF
|
||||
THIS FILE, not an oversight to be tidied away. There is no configured charge
|
||||
ceiling in this add-on to explain a taper with.
|
||||
|
||||
⚠️ AND A MISSING READING ANSWERS None RATHER THAN "the whole share". Zero
|
||||
would be this check inventing a fault from an absence, which is the shape of
|
||||
the 2026-09-02 outage read the other way round; and the site is already
|
||||
stopped for a missing per-unit reading anyway (`inputs_ok` requires every
|
||||
unit's `batt`, and §11.1 rule 1 commands 0 W without consulting this).
|
||||
"""
|
||||
if share_w is None or measured_w is None:
|
||||
return None
|
||||
share = float(share_w)
|
||||
if share <= 0.0:
|
||||
return None
|
||||
gap = share - float(measured_w)
|
||||
return gap if gap > 0.0 else None
|
||||
|
||||
|
||||
def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None:
|
||||
"""Why a shortfall on this unit is ALREADY EXPLAINED, or None if nothing is.
|
||||
|
||||
Every branch here is a state this add-on can read and already has an answer
|
||||
for, and naming one of them a fault would be raising a second, louder alarm
|
||||
about something already being handled - the SAFETY-15/SAFETY-19 failure that
|
||||
`circulation.py` and `watchdog.py` both take their 30 s lower bound from.
|
||||
|
||||
`live` `Inverter.is_live()`. A unit whose link is stale or whose
|
||||
writes are being rejected has ALREADY been dropped from the
|
||||
split with a line in the action log saying so.
|
||||
`parked` that unit's sign watchdog has latched. It is being commanded
|
||||
0 W on purpose; not delivering is compliance, not a fault.
|
||||
`soc` that pack's measured state of charge, percent. None is not a
|
||||
full battery and is not permission - see below.
|
||||
`bms_floor` that pack's OWN cut-out, `bms_soc_floor`. Required of every
|
||||
roster entry since SAFETY-32, so at the only roster this
|
||||
module is live at it is always configured.
|
||||
|
||||
⚠️ THE SoC BRANCH IS THE WHOLE DISCRIMINATOR AND IT IS ASKED THROUGH
|
||||
`peakguard.discharge_permitted()`, WHICH IS PUBLIC FOR EXACTLY THIS REASON:
|
||||
"a safety predicate transcribed into its own check is one copy plus a future
|
||||
disagreement about what the check means". What is NOT reused is its wording,
|
||||
which is peak-guard flavoured ("the peak guard's SoC floor") and would be a
|
||||
false statement here - this module is not the peak guard and its floor is
|
||||
the pack's own cut-out, not `peak_soc_floor`. One predicate, two sentences.
|
||||
⚠️ AND THE FLOOR PASSED IN IS `bms_soc_floor` ALONE, NOT
|
||||
`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`. That
|
||||
conjunction is right for the peak guard and wrong here: `peak_soc_floor`
|
||||
bounds a money-saving discharge only (SAFETY-25), ordinary self-consumption
|
||||
discharges straight past it, and using it would EXPLAIN AWAY a wedged unit
|
||||
sitting anywhere between the two floors.
|
||||
|
||||
⚠️ EVERY UNKNOWN ACCOUNTS FOR THE SHORTFALL, i.e. FAILS TOWARD SILENCE, and
|
||||
that direction is the opposite of the one `discharge_permitted()` was
|
||||
written for. There it withholds a discharge; here it withholds an ALARM. An
|
||||
unreadable SoC or an unstated floor means this module cannot say whether the
|
||||
pack is empty, and an alarm nobody can justify is the crying-wolf failure
|
||||
this whole file is written against. The cost is bounded: the same absence
|
||||
stops the site through `inputs_ok` (SoC) or is refused at startup by the
|
||||
roster schema (the floor).
|
||||
"""
|
||||
if not live:
|
||||
return ("its link is stale or its writes are being rejected, and the "
|
||||
"allocator has already dropped it from the split")
|
||||
if parked:
|
||||
return "it is latched at 0 W by a sign trip, so it is doing as it is told"
|
||||
why = discharge_permitted(soc, bms_floor)
|
||||
if why is not None:
|
||||
if soc is None:
|
||||
return "its state of charge did not read, so its pack cannot be graded"
|
||||
if bms_floor is None:
|
||||
return ("no bms_soc_floor is configured for it, so there is nothing "
|
||||
"to say whether its pack is empty")
|
||||
return (f"its pack is at {float(soc):.0f} %, at or below its own "
|
||||
f"{float(bms_floor):.0f} % cut-out (bms_soc_floor)")
|
||||
return None
|
||||
|
||||
|
||||
def delinquent(units, min_w: float,
|
||||
share_frac: float = DELIVERY_SHARE_FRACTION) -> dict:
|
||||
"""Which units are short with NO ACCOUNT, when the roster disagrees with them.
|
||||
|
||||
`units` one `UnitDelivery` per inverter, in roster order.
|
||||
`min_w` the ABSOLUTE shortfall that counts - `control.Tuning.
|
||||
saturation_w`. See `MIN_W_IS_SATURATION_W`.
|
||||
`share_frac` SAFETY-43. The RELATIVE shortfall that counts, as a fraction
|
||||
of that unit's own share. See `DELIVERY_SHARE_FRACTION`; it
|
||||
is a parameter rather than a read of the constant so the
|
||||
campaign that derived it can sweep it over the shipped code.
|
||||
⚠️ `None` MEANS THE ABSOLUTE ARM ALONE - SAFETY-42's rule,
|
||||
exactly - and it exists for ONE caller: the campaign in
|
||||
`admin/goodwe:sim/delivery_window.py`, which has to be able
|
||||
to measure what this card ADDS against what it replaced, run
|
||||
for run, over ONE tree. Nothing in the add-on passes it, and
|
||||
`main.py` passes nothing at all.
|
||||
|
||||
⚠️ THE TWO ARMS ARE AN `or` AND THE ORDER OF THAT SENTENCE MATTERS. The
|
||||
absolute arm is untouched by SAFETY-43: everything `min_w` named before it
|
||||
is still named, and nothing `min_w` refused becomes noisy unless the unit
|
||||
is delivering almost NONE of its ask. Lowering `min_w` would have been the
|
||||
other change - the one that reports normal saturation on every unit at once
|
||||
- and `DELIVERY_SHARE_FRACTION` says at length why it was refused.
|
||||
|
||||
Returns `{name: sentence}`, empty when there is nothing to say. The sentence
|
||||
is the operator's first line and it carries the peer clause, because "B is
|
||||
not delivering" and "B is not delivering while A is" are different facts and
|
||||
only the second one is this module's conclusion.
|
||||
|
||||
⚠️ THE PROPER-SUBSET RULE IS THE CHECK, NOT A FILTER ON IT. A unit is named
|
||||
only while the units in its state are FEWER than the units that were asked
|
||||
to discharge. Everything asked and nothing explained is a SITE condition -
|
||||
a derate, a curtailment, a grid-code limit, a plant that simply cannot -
|
||||
and this module has no instrument that can tell that from every box being
|
||||
wedged at once. Reporting it anyway would be the saturation-shaped alarm
|
||||
`clamp.py` warns about, produced by the guard written to replace it.
|
||||
|
||||
⚠️ AND THAT IS WHY N=1 IS INERT WITHOUT A ROSTER GUARD. A `if n_units > 1`
|
||||
in the caller would be a second place the same decision lives; the algebra
|
||||
is the guard, the same way `circulation.circulating_w()` is identically zero
|
||||
at N<=1.
|
||||
|
||||
⚠️ BUT THE ALGEBRA THAT DOES IT IS THE PROPER-SUBSET RULE, NOT THE
|
||||
`len(asked) < 2` LINE - AND THIS PARAGRAPH USED TO SAY OTHERWISE. The
|
||||
mutation battery found it: relaxing that line to `len(asked) < 1` is a
|
||||
SURVIVOR at +0 B, and it is an EQUIVALENT MUTANT rather than a gap in the
|
||||
checks. With one asked unit, `unaccounted` is a subset of a one-element set,
|
||||
so it is either empty (`not unaccounted` returns `{}`) or equal to `asked`
|
||||
(`len(unaccounted) == len(asked)` returns `{}`). There is no third case and
|
||||
no input on which the two spellings differ. The line is a FAST PATH and a
|
||||
signpost, not the mechanism - said here rather than left for the next
|
||||
mutation sweep to rediscover, exactly as `circulation.circulating_w()` says
|
||||
of its own `max(0.0, ...)`. Do not report its survival as a gap in the
|
||||
checks, and do not delete it either: it is what tells a reader at a glance
|
||||
that a one-inverter install never reaches any of this.
|
||||
"""
|
||||
asked = [u for u in units
|
||||
if u.share_w is not None and float(u.share_w) > 0.0]
|
||||
if len(asked) < 2:
|
||||
return {}
|
||||
|
||||
gaps = {}
|
||||
for u in asked:
|
||||
gap = short_by(u.share_w, u.measured_w)
|
||||
if gap is None:
|
||||
continue
|
||||
# SAFETY-43: absolute OR relative. `share_w` is > 0 for every member
|
||||
# of `asked`, so the second comparison is always well formed.
|
||||
if gap > float(min_w) or (share_frac is not None
|
||||
and gap > float(share_frac)
|
||||
* float(u.share_w)):
|
||||
gaps[u.name] = gap
|
||||
|
||||
unaccounted = {u.name: gaps[u.name] for u in asked
|
||||
if u.name in gaps and u.accounted is None}
|
||||
if not unaccounted or len(unaccounted) == len(asked):
|
||||
return {}
|
||||
|
||||
peers = [u.name for u in asked if u.name not in unaccounted]
|
||||
out = {}
|
||||
for u in asked:
|
||||
if u.name not in unaccounted:
|
||||
continue
|
||||
out[u.name] = (
|
||||
f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W "
|
||||
f"and delivering {float(u.measured_w):+.0f} W - "
|
||||
f"{unaccounted[u.name]:.0f} W of its share "
|
||||
# SAFETY-43. The PROPORTION is in the sentence because the watts
|
||||
# alone stopped being the whole story the moment a unit could be
|
||||
# named on the relative arm: "125 W is not arriving" reads like a
|
||||
# rounding error, and "125 W (100 % of its share)" is the fault.
|
||||
f"({100.0 * unaccounted[u.name] / float(u.share_w):.0f} % of it) "
|
||||
f"is not arriving - while "
|
||||
f"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
|
||||
f"that state. Nothing this add-on reads explains it")
|
||||
return out
|
||||
|
||||
|
||||
class DeliveryWatchdog:
|
||||
"""ONE PER INVERTER, evaluated once per control cycle.
|
||||
|
||||
Per unit rather than per site, and that is card requirement 2: the site
|
||||
figure cannot say which box to look at, and naming the box is the whole
|
||||
reason SAFETY-30 split per-unit from site in the first place. It sits beside
|
||||
`Inverter.sign_wd` on the same object, under the same namespaced store keys,
|
||||
for the same reason.
|
||||
|
||||
⚠️ NOT SHARED ACROSS CADENCES, for `SignWatchdog`'s reason: `since` is a
|
||||
wall-clock start time, so two loops at two rates would each reset the
|
||||
other's window.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: DeliveryConfig | None = None, *,
|
||||
tripped: bool = False, reason: str = ""):
|
||||
self.cfg = cfg or DeliveryConfig()
|
||||
self.since: datetime | None = None
|
||||
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED - SAFETY-34, and it is the
|
||||
# requirement the card states in its own words. See the module note.
|
||||
self.tripped = bool(tripped)
|
||||
self.reason = reason if self.tripped else ""
|
||||
|
||||
def arm(self, cfg: DeliveryConfig) -> None:
|
||||
"""Set the window. Nothing else.
|
||||
|
||||
⚠️ IT IS CONFIGURATION, NOT STATE, AND IT DELIBERATELY DOES NOT TOUCH
|
||||
`since`, `tripped` OR `reason` - `SignWatchdog.arm()`'s argument,
|
||||
which was found the expensive way: a detector that cleared its own
|
||||
window when it was told what it was measuring would lose the episode it
|
||||
is in the middle of, every cycle, and would never accumulate a hold.
|
||||
"""
|
||||
self.cfg = cfg
|
||||
|
||||
def evaluate(self, sentence, now: datetime) -> bool:
|
||||
"""Advance one cycle. `sentence` is this unit's entry from `delinquent()`
|
||||
(None when it has none). Returns True while tripped.
|
||||
|
||||
`now` is the caller's clock - `SignWatchdog.evaluate()`'s and
|
||||
`CirculationWatchdog.evaluate()`'s discipline, so this is testable and
|
||||
replayable rather than reading a clock of its own.
|
||||
"""
|
||||
if self.tripped:
|
||||
# Latched. It does not re-evaluate, and unlike SAFETY-02 and
|
||||
# SAFETY-31 that is NOT because the trip removed the evidence -
|
||||
# nothing here changes what is commanded, so the evidence is still
|
||||
# standing. It is because an intermittent unit that resumed must not
|
||||
# erase its own record. See the module note.
|
||||
return True
|
||||
|
||||
if sentence is None:
|
||||
self.since = None
|
||||
return False
|
||||
|
||||
if self.since is None:
|
||||
self.since = now
|
||||
return False
|
||||
|
||||
held = (now - self.since).total_seconds()
|
||||
if held < self.cfg.hold_s:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
self.reason = (f"{sentence} - held for {held:.0f} s continuously "
|
||||
f"(limit: over {self.cfg.hold_s:.0f} s)")
|
||||
return True
|
||||
|
||||
def held_s(self, now: datetime) -> float:
|
||||
"""How long the current shortfall has stood, for the status page.
|
||||
|
||||
Takes the clock rather than reading one, for `SignWatchdog.held_s()`'s
|
||||
reason: an unclocked function is one nothing can test, which is exactly
|
||||
what happened to that one.
|
||||
"""
|
||||
if self.since is None:
|
||||
return 0.0
|
||||
return (now - self.since).total_seconds()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.since = None
|
||||
self.tripped = False
|
||||
self.reason = ""
|
||||
@@ -0,0 +1,385 @@
|
||||
"""SAFETY-37 - a reading that holds its last value and reads healthy.
|
||||
|
||||
THE DEFECT, in one sentence: `hass.number()` returns a held value as a value,
|
||||
and nothing anywhere in this add-on distinguishes "the inverter reports 40 %"
|
||||
from "the entity still says 40 % because nothing has updated it since Tuesday".
|
||||
|
||||
The architect pass of 2026-09-06 put three cards on one mechanism. This module
|
||||
is that mechanism; the other two are its consumers.
|
||||
|
||||
LAYER 1 `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` - the
|
||||
(TEL-06) ingest-age veto tests the VALUE of `sensor.goodwe_p1_sample_age`.
|
||||
A dead publisher leaves the entity frozen at a healthy number and
|
||||
the veto can never fire. NOT FIXED HERE - it is firmware, it is on
|
||||
the other side of a flash, and nothing in this module can reach
|
||||
it. What it owes is written at the bottom of this docstring.
|
||||
THIS ADD-ON `app/hass.py:69-82`, consumed by SAFETY-30's per-unit sign check
|
||||
(SAFETY-37) and SAFETY-31's circulating-power sensor. Both read `Inverter.batt`
|
||||
(register 0x0518) and NEITHER had a freshness gate of any kind.
|
||||
THE EVIDENCE HW-01, 2026-09-02: SoC held 40 % for 6 h 25 m while the pack
|
||||
drained to its floor. Every detector read healthy throughout.
|
||||
|
||||
⚠️ AT N>=2 EVERY ONE OF THOSE READINGS DOUBLES, which is why this card is the
|
||||
one that had to build it: the per-unit sign check and the circulation sensor
|
||||
only exist at N>=2, and both are fed straight from `Inverter.batt`.
|
||||
|
||||
|
||||
WHAT CANNOT BE THE SOURCE OF TRUTH, AND IT IS NOT A DETAIL
|
||||
==========================================================
|
||||
Home Assistant's `last_updated` DOES NOT MOVE when a value is rewritten
|
||||
unchanged. Neither does `last_changed`, and this project measured that
|
||||
`last_reported` does not advance on a repeat on either serialiser either - see
|
||||
`p1.HomeWizardLocalSource`'s docstring, which records the ENV-01 rig measurement
|
||||
over 70 s of a frozen meter and an independent one against the live house.
|
||||
|
||||
So a value-side timestamp out of Home Assistant is a "time since the number
|
||||
changed" sensor wearing an arrival sensor's name. Thresholding it is how a
|
||||
detector ends up unable to fire, which is TEL-06's defect exactly. That trap
|
||||
produced two false diagnoses on 2026-09-05, both by a reader who already knew
|
||||
about it, which is why it is written here in full rather than referenced.
|
||||
|
||||
⚠️ AND THE CONVERSE TRAP IS THE ONE THIS MODULE HAD TO SOLVE. The add-on polls
|
||||
Home Assistant's state cache roughly once a second (`run_control()`'s
|
||||
`await asyncio.sleep(1)` bounds the loop body and `read_inputs()` runs every
|
||||
pass). While the entity exists, HA answers 200 with a number whether or not the
|
||||
publisher behind it is alive. So AN ARRIVAL AT THE ADD-ON IS NOT AN ARRIVAL FROM
|
||||
THE INVERTER, and an arrival age alone cannot see HW-01 at all.
|
||||
|
||||
What IS arrival-stamped here, and what makes this module honest, is the clock
|
||||
the verdict is measured on: `changed_mono` is the MONOTONIC INSTANT OF THE
|
||||
ARRIVAL THAT LAST CARRIED A DIFFERENT NUMBER. Every term below is a fact about
|
||||
arrivals this process observed, on its own clock. Nothing is read out of a Home
|
||||
Assistant timestamp, and nothing would change if Home Assistant stopped
|
||||
publishing timestamps altogether.
|
||||
|
||||
|
||||
THE VERDICT, AND WHY IT IS A CONJUNCTION
|
||||
========================================
|
||||
A bare "unchanged for N seconds" threshold is not available, and that is
|
||||
MEASURED rather than feared. Over the committed 30-day capture of this house's
|
||||
own 1 W-resolution power entity - `scenarios/p1-net-30d-2026-08/`, 485,841
|
||||
samples straight out of Home Assistant's recorder, where a row exists only when
|
||||
the state string CHANGED, so the gap between two rows IS the unchanged duration
|
||||
every consumer saw:
|
||||
|
||||
longest unchanged run, 30 days 263.2 s (held -899.0 W, 2026-08-11 17:04)
|
||||
runs >= 35 s 10 of 485,840 (0.0021 %)
|
||||
runs >= 17 s 386 of 485,840 (0.0794 %)
|
||||
|
||||
Measured on 2026-09-06 by replaying that capture. A real power figure at 1 W
|
||||
resolution held ONE value for four and a half minutes with nothing whatever
|
||||
wrong. ⚠️ That is the METER's register, not 0x0518, and it is named as a
|
||||
surrogate rather than passed off as the thing: no 30-day capture of 0x0518
|
||||
exists, because that register is not in the recorder export. What it does
|
||||
establish - and this is all it is used for - is that the LEGITIMATE unchanged
|
||||
duration of a power entity in this house is minutes, so an unchanged threshold
|
||||
alone must be minutes, and minutes is three times too slow to protect consumers
|
||||
that trip at 75 s and 85 s. The conjunction is not a refinement; without it
|
||||
there is no admissible threshold at all.
|
||||
|
||||
⚠️ AND EVERY ONE OF THOSE LONG RUNS HAPPENED UNDER AN UNMOVING LOAD. That is
|
||||
the way out, and it is the same shape `freeze.looks_frozen()` already uses for
|
||||
the meter (unchanged AND the command pinned at the rail). A reading is stale
|
||||
when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
|
||||
|
||||
STALE <=> unchanged for >= UNCHANGED_S
|
||||
AND |standing command now| >= MOVE_W
|
||||
AND |standing command now - standing command when the reading
|
||||
last changed| >= MOVE_W
|
||||
|
||||
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
|
||||
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
|
||||
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
|
||||
register dithering +/-1 W while holding (§5.11, same day). A unit told to move
|
||||
250 W and still answering the same integer watt cannot be tracking.
|
||||
|
||||
|
||||
THE BOUNDS - ONE PER SIGNAL, EACH WITH ITS DERIVATION
|
||||
=====================================================
|
||||
⚠️ ONE CONSTANT FOR BOTH SIGNALS WOULD BE USELESS ON SoC OR TRIGGER-HAPPY ON
|
||||
POWER, which is the third clause of the policy. They are separated here, and
|
||||
only one of them is WIRED - see `BOUNDS` below and the note on SoC.
|
||||
|
||||
`batt` / 0x0518, S16, 1 W, published every 10 s
|
||||
----------------------------------------------
|
||||
LOWER BOUND, 17.0 s, the fastest a healthy register can be OBLIGED to have
|
||||
answered. Three terms, each read out of this repository on 2026-09-06:
|
||||
|
||||
6.0 s the plant. goodwe/NOTES.md §5.9, measured 2026-08-22 with the poll
|
||||
temporarily at 1 s and a 0 -> +500 W step: 26 W at +1.43 s, 469 W
|
||||
(94 %) at +3.34 s, settled 507 W at +6.05 s.
|
||||
10.0 s the publisher. `firmware/goodwe-master.yaml:181`,
|
||||
`update_interval: 10s` on the `modbus_controller` that carries both
|
||||
0x0518 and 0x050E. A command that moves right after a poll is not
|
||||
visible until the next one.
|
||||
1.0 s this add-on's own read period - `run_control()`'s
|
||||
`await asyncio.sleep(1)`, which bounds the loop body that calls
|
||||
`read_inputs()`.
|
||||
|
||||
UPPER BOUND, 75.0 s, the tighter of the two consumers this card must protect,
|
||||
both read from `config.yaml` on 2026-09-06:
|
||||
|
||||
75 s `circulation_hold_s` (config.yaml:512) - SAFETY-31 trips here.
|
||||
85 s `unit_sign_disagree_s` (config.yaml:451) - SAFETY-30's per-unit sign
|
||||
check trips here.
|
||||
|
||||
A gate slower than either is a gate that hands its consumer the frozen number
|
||||
and then explains afterwards. ⚠️ AND THAT INEQUALITY IS GRADED, NOT ASSERTED -
|
||||
`test_freshness.py` part 6 reads BOTH ends out of the shipped `config.yaml`
|
||||
and goes red if an operator's default moves under this bound.
|
||||
|
||||
35.0 s IS THE CHOSEN VALUE, and the admissible band is narrow enough that it
|
||||
is nearly forced: at least 2x the 17.0 s floor (34.0) and at most half the
|
||||
75.0 s consumer window (37.5) leaves [34.0, 37.5]. 35 is the integer in it.
|
||||
⚠️ DELIBERATELY NOT 30 AND NOT 60. 30 is `validate.FIRMWARE_INGEST_VETO_S` and
|
||||
would read as a borrowed constant nobody re-derived; 60 is round, and a round
|
||||
number here would be a guess wearing a measurement's clothes.
|
||||
|
||||
WHAT IT COSTS, stated rather than absorbed: a frozen 0x0518 on a unit that is
|
||||
never asked to move 250 W is not detected by this module at all.
|
||||
|
||||
⚠️ THAT COST IS BOUNDED FOR ONE CONSUMER AND NOT FOR THE OTHER, and an earlier
|
||||
version of this note claimed both. TRUE for the per-unit sign check: no such
|
||||
check can speak about that unit either, since `unit_sign_disagree_w` is 250 W.
|
||||
FALSE for circulation. "Contributing at most 250 W" bounds the COMMAND - but a
|
||||
frozen register's REPORTED value is unrelated to its command, and that is
|
||||
precisely HW-01's shape: commanded ~0 W, register stuck on the last large
|
||||
number. Measured in review: a unit frozen at -1200 W beside a healthy +900 W
|
||||
sibling gives `circulating_w` = 1800 W against a 700 W floor, 2.6x over, with
|
||||
no gate armed. So this gate is scoped to where the SIGN consumer can be misled;
|
||||
the circulation consumer keeps an unbounded exposure below MOVE_W and that is
|
||||
a known, stated gap rather than a covered one.
|
||||
|
||||
`soc` / 0x050E, U16, 1 %, published every 10 s - STATED, NOT WIRED
|
||||
-----------------------------------------------------------------
|
||||
⚠️ THIS ROW IS THE POLICY'S ANSWER FOR TEL-08 AND IT IS NOT CONNECTED TO
|
||||
ANYTHING. It is here because the third clause of the policy is "state each
|
||||
bound and its derivation", and because a reader who finds only `batt` wired
|
||||
would reasonably conclude SoC was forgotten. It was not; it needs a
|
||||
corroborator this card cannot derive.
|
||||
|
||||
SoC CANNOT USE THE CONJUNCTION ABOVE. A pack resting at its floor holds one
|
||||
percent for hours with nothing wrong - the card's own example - and the
|
||||
standing command says nothing about it, because a command of 0 W is exactly
|
||||
what a rested pack is given. The physically obliged movement for SoC is
|
||||
ENERGY, not command travel: one percent of pack A is about 153.6 Wh (15.36 kWh
|
||||
nominal, goodwe/NOTES.md §1.1b's table - ⚠️ NOMINAL and FROM DATASHEET, not a
|
||||
measured usable capacity), so at pack A's 5000 W ceiling one percent takes at
|
||||
least 110 s and at a realistic 1000 W about 553 s. The bound TEL-08 needs is
|
||||
therefore "SoC unchanged while the integral of |battery power| since its last
|
||||
change exceeded K x 153.6 Wh", and K cannot be chosen from anything written
|
||||
down today: nobody has measured this pack's usable capacity, its BMS reporting
|
||||
granularity, or how far its reported percent lags true state of charge.
|
||||
|
||||
So TEL-08 owes: that capacity measurement (or a defensible bound on it), the
|
||||
integrator, and K. It does NOT owe a second freshness mechanism - `Freshness`
|
||||
below takes its obligation term from the caller precisely so that an energy
|
||||
integral can be handed in where command travel is handed in here.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
# --- the three terms of the lower bound, each read out of this repository ----
|
||||
# See the module docstring for where each one is measured. They are separate
|
||||
# names rather than a literal 17.0 so that a reader who changes the firmware's
|
||||
# `update_interval` can find this file by grepping for the number they moved.
|
||||
PLANT_SETTLE_S = 6.0 # goodwe/NOTES.md §5.9, measured 2026-08-22
|
||||
PUBLISH_PERIOD_S = 10.0 # firmware/goodwe-master.yaml:181
|
||||
READ_PERIOD_S = 1.0 # main.run_control()'s `await asyncio.sleep(1)`
|
||||
BATT_OBLIGED_S = PLANT_SETTLE_S + PUBLISH_PERIOD_S + READ_PERIOD_S # 17.0
|
||||
|
||||
# The unchanged bound for register 0x0518. See the derivation above; the band is
|
||||
# [2 * BATT_OBLIGED_S, 0.5 * circulation_hold_s] = [34.0, 37.5].
|
||||
BATT_UNCHANGED_S = 35.0
|
||||
|
||||
# How far the standing command must have travelled for the register to be
|
||||
# OBLIGED to have followed it.
|
||||
#
|
||||
# ⚠️ IT IS THE LARGEST ADMISSIBLE VALUE, NOT A SENSITIVITY PREFERENCE, and that
|
||||
# is what makes it a derivation. The gate has to arm everywhere a stale reading
|
||||
# could mislead a consumer; the earliest either consumer speaks is
|
||||
# `unit_sign_disagree_w`, 250 W (config.yaml:450), because below that
|
||||
# `watchdog.disagrees()` is False whatever the measurement says. Taking the
|
||||
# ceiling of the admissible range is what minimises spurious arming. A smaller
|
||||
# number would arm the gate in states where nothing downstream can be misled,
|
||||
# and every such arming is a spurious site stop bought for nothing.
|
||||
#
|
||||
# ⚠️ NOT A CONFIG KEY. CFG-03 tracks the places a default has to be kept in
|
||||
# step and this does not earn another one - it is derived from a default
|
||||
# `config.yaml` already carries, and `test_freshness.py` part 6 READS that file
|
||||
# and goes red if the two drift.
|
||||
MOVE_W = 250.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bound:
|
||||
"""One signal's freshness bound, and whether anything consumes it."""
|
||||
|
||||
signal: str
|
||||
unchanged_s: float
|
||||
move_w: float
|
||||
wired: bool
|
||||
derivation: str
|
||||
|
||||
|
||||
# ⚠️ THE POLICY AS DATA, AND THE `wired` COLUMN IS LOAD-BEARING. A reader must be
|
||||
# able to tell, without reading main.py, which of these bounds is actually
|
||||
# gating a consumer today. `test_freshness.py` part 7 greps the shipped app/ for
|
||||
# each row and refuses a row that claims to be wired and is not - and a row that
|
||||
# claims not to be and is.
|
||||
BOUNDS = {
|
||||
"batt": Bound(
|
||||
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
|
||||
unchanged_s=BATT_UNCHANGED_S,
|
||||
move_w=MOVE_W,
|
||||
wired=True,
|
||||
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
|
||||
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]"),
|
||||
"soc": Bound(
|
||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||
unchanged_s=float("inf"),
|
||||
move_w=float("inf"),
|
||||
wired=False,
|
||||
derivation="TEL-08. Command travel is the WRONG obligation term for "
|
||||
"SoC - a rested pack holds a percent for hours under a 0 W "
|
||||
"command. The term is energy: >= K x 153.6 Wh through the "
|
||||
"pack since the reading last changed. K needs a measured "
|
||||
"usable capacity nobody has taken."),
|
||||
}
|
||||
|
||||
|
||||
class Freshness:
|
||||
"""One signal's arrival record, and the stale verdict it supports.
|
||||
|
||||
⚠️ ARRIVAL-DRIVEN THROUGHOUT. Every clock reading this object ever makes is
|
||||
handed to `submit()` by the caller at the moment a value arrived; nothing
|
||||
here reads a clock of its own and nothing here reads a Home Assistant
|
||||
timestamp. That is what makes the verdict replayable - `test_freshness.py`
|
||||
drives real seconds through it without sleeping - and it is the difference
|
||||
between this and the value-stamped veto TEL-06 is blocked on.
|
||||
|
||||
⚠️ THE VERDICT LATCHES, AND WITHOUT THAT IT OSCILLATES AT ABOUT 1 Hz.
|
||||
Declaring the reading stale makes `Controller.inputs_ok` False, which
|
||||
commands 0 W (§11.1 rule 1), which drops the standing command below
|
||||
`MOVE_W`, which un-arms the conjunction, which would return the held number
|
||||
to the consumers - and round again every read. So the ONLY thing that clears
|
||||
the latch is an arrival carrying a DIFFERENT number, which is exactly the
|
||||
evidence the verdict was about. A register that is working clears it on its
|
||||
next 10 s publish; a register that is not, does not.
|
||||
"""
|
||||
|
||||
def __init__(self, bound: Bound):
|
||||
self.bound = bound
|
||||
# The last number that ARRIVED, or None if the most recent read did not
|
||||
# produce one. This is what `reading()` hands back when the gate is open,
|
||||
# so a read failure is still an immediate None exactly as it is today.
|
||||
self.last = None
|
||||
# The number the reading is currently HOLDING, and the monotonic instant
|
||||
# of the arrival that last carried a different one. `held` survives a
|
||||
# failed read; the unchanged clock is about the value, not about our
|
||||
# ability to fetch it.
|
||||
self.held = None
|
||||
self.changed_mono = None
|
||||
# The standing command at that same instant. The conjunction compares
|
||||
# against THIS, not against the previous cycle's command: what matters
|
||||
# is whether the register has followed anything since it last moved.
|
||||
self.standing_at_change = None
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.arrivals = 0
|
||||
|
||||
def submit(self, value, standing_w, now_mono) -> None:
|
||||
"""One READ of this signal. `value` None means nothing arrived.
|
||||
|
||||
`value` the number `hass.number()` produced, or None.
|
||||
`standing_w` what this controller currently has standing on this unit's
|
||||
wire, signed, + = discharge. None when nothing is known.
|
||||
`now_mono` the caller's monotonic clock, in seconds.
|
||||
|
||||
⚠️ A FAILED READ IS NOT AN ARRIVAL AND IS NOT AN AGE EITHER. It clears
|
||||
`last` immediately - which is byte-for-byte what shipping code does
|
||||
today, a missing reading being a missing reading - and it leaves the
|
||||
unchanged clock running, because the value has not changed and our
|
||||
inability to fetch it is not evidence that it has.
|
||||
"""
|
||||
if value is None:
|
||||
self.last = None
|
||||
return
|
||||
value = float(value)
|
||||
self.arrivals += 1
|
||||
if self.changed_mono is None or value != self.held:
|
||||
self.held = value
|
||||
self.changed_mono = float(now_mono)
|
||||
self.standing_at_change = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
# Movement is the ONLY thing that clears the latch. See the class
|
||||
# docstring on the 1 Hz oscillation this prevents.
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.last = value
|
||||
self._grade(standing_w, now_mono)
|
||||
|
||||
def _grade(self, standing_w, now_mono) -> None:
|
||||
"""The conjunction. Sets the latch; never clears it."""
|
||||
if self.stale or self.changed_mono is None:
|
||||
return
|
||||
held_s = self.unchanged_s(now_mono)
|
||||
if held_s < self.bound.unchanged_s:
|
||||
return
|
||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
|
||||
# always answers a float, so neither branch is reachable from the wired
|
||||
# consumer today. They are here for TEL-08, whose obligation term is an
|
||||
# energy integral that has nothing to say until it has a window - and
|
||||
# "nothing to say" must withhold the verdict, not manufacture one.
|
||||
if standing_w is None or self.standing_at_change is None:
|
||||
return
|
||||
standing_w = float(standing_w)
|
||||
if abs(standing_w) < self.bound.move_w:
|
||||
return
|
||||
moved_w = abs(standing_w - self.standing_at_change)
|
||||
if moved_w < self.bound.move_w:
|
||||
return
|
||||
self.stale = True
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
|
||||
f"while the standing command moved {moved_w:.0f} W to "
|
||||
f"{standing_w:+.0f} W - the reading is not following, so it is "
|
||||
f"reported as MISSING rather than as a measurement (limits: "
|
||||
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
|
||||
f"{self.bound.move_w:.0f} W and moved by over "
|
||||
f"{self.bound.move_w:.0f} W)")
|
||||
|
||||
def unchanged_s(self, now_mono) -> float | None:
|
||||
"""Seconds since an arrival carried a different number. Status page."""
|
||||
if self.changed_mono is None:
|
||||
return None
|
||||
return max(0.0, float(now_mono) - self.changed_mono)
|
||||
|
||||
def reading(self):
|
||||
"""What the consumers get: the value, or None when it cannot be trusted.
|
||||
|
||||
⚠️ `None`, NEVER A HELD NUMBER, and never a zero. Both consumers already
|
||||
do the right thing with None and neither could ever receive one:
|
||||
`watchdog.SignWatchdog.evaluate` CLEARS its window on a None measurement
|
||||
rather than accumulating it, and `circulation.circulating_w` returns None
|
||||
for a roster with a missing term, which clears the circulation window
|
||||
too. This module is the thing that was missing between them.
|
||||
"""
|
||||
return None if self.stale else self.last
|
||||
|
||||
def note(self, unit_name: str) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
return f"{unit_name}: {self.reason}" if self.stale else ""
|
||||
|
||||
|
||||
def monotonic() -> float:
|
||||
"""The clock this module's callers use, in one place so tests can find it.
|
||||
|
||||
⚠️ `time.monotonic()`, not the wall clock, for `p1.P1Sample.age_s`'s reason:
|
||||
an NTP step on a Raspberry Pi that has just found the network is routinely
|
||||
minutes, and a freshness verdict computed across one would either fake a
|
||||
stale reading or, worse, hide a real one.
|
||||
"""
|
||||
return time.monotonic()
|
||||
@@ -122,6 +122,10 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
from .delivery import DeliveryConfig, DeliveryWatchdog
|
||||
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
|
||||
# freshness.py imports nothing from this package, so the direction is one-way.
|
||||
from .freshness import BOUNDS, Freshness
|
||||
# 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
|
||||
@@ -163,6 +167,13 @@ UNIT_A = "inverter-a"
|
||||
# migration mechanical rather than transcribed: `store.migrate_unit_keys()`
|
||||
# reads this same mapping, so a key renamed here cannot silently stop being
|
||||
# migrated. See store.py.
|
||||
# ⚠️ SAFETY-42's PAIR IS DELIBERATELY NOT IN THIS TUPLE, AND THE ABSENCE IS THE
|
||||
# STATEMENT. The values here are the PRE-DEV-02 FLAT key names that
|
||||
# `store.migrate_unit_keys()` moves under a unit; `delivery_tripped` /
|
||||
# `delivery_reason` never existed flat, because the check that writes them
|
||||
# cannot fire at a roster of one (see delivery.py) and a one-inverter install is
|
||||
# the only install that ever had flat keys. Adding them here would make the
|
||||
# migration look for keys that cannot exist and would report them as absent.
|
||||
UNIT_STORE_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
|
||||
@@ -181,7 +192,8 @@ class Inverter:
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
bms_soc_floor=None, store=None, sign_cfg=None):
|
||||
bms_soc_floor=None, capacity_kwh=None, store=None,
|
||||
sign_cfg=None, delivery_cfg=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
@@ -203,6 +215,20 @@ class Inverter:
|
||||
# the config by name long before that, at startup.
|
||||
self.bms_soc_floor = (None if bms_soc_floor is None
|
||||
else float(bms_soc_floor))
|
||||
# OPT-09. THIS PACK'S USABLE CAPACITY IN kWh, or None when the roster
|
||||
# entry did not state one.
|
||||
#
|
||||
# ⚠️ OPTIONAL WHERE `bms_soc_floor` IS REQUIRED, AND THE ASYMMETRY IS
|
||||
# THE POINT RATHER THAN AN INCONSISTENCY. A missing cut-out would have
|
||||
# to be defaulted, and one default for two batteries protects the
|
||||
# shallower pack with the deeper pack's number - SAFETY-32's whole card.
|
||||
# A missing capacity has an honest answer that costs nothing: this pack
|
||||
# states no energy headroom, so `packshare.weights()` has NO OPINION
|
||||
# about the split and every unit weighs 1.0, which is exactly the
|
||||
# behaviour that shipped. None, never 0.0: a zero-capacity pack is one
|
||||
# that may never be used, and nobody decides that by omitting a key.
|
||||
self.capacity_kwh = (None if capacity_kwh is None
|
||||
else float(capacity_kwh))
|
||||
|
||||
# --- the device's own reported range, read from its own entity -------
|
||||
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
|
||||
@@ -213,7 +239,22 @@ class Inverter:
|
||||
|
||||
# --- this unit's own measurements ------------------------------------
|
||||
self.soc = None
|
||||
# ⚠️ SAFETY-37: `batt` IS NOW THE GATED READING, NOT THE RAW ONE. It is
|
||||
# None whenever `batt_fresh` says the register is not following its own
|
||||
# command - see freshness.py - and a None here is a MISSING input, which
|
||||
# §11.1 rule 1 answers with 0 W. The raw number that arrived is on
|
||||
# `batt_fresh.last`; read that only for diagnostics, never for control.
|
||||
self.batt = None
|
||||
# SAFETY-37. One arrival record per SIGNAL per UNIT. Per unit for
|
||||
# DEV-02's reason - a freshness verdict about B is not evidence about A,
|
||||
# and at N>=2 a shared record would be the flat-scalar defect rebuilt
|
||||
# inside the gate that exists to protect the per-unit checks.
|
||||
#
|
||||
# ⚠️ ONLY `batt` IS WIRED. `BOUNDS["soc"]` exists and is deliberately not
|
||||
# constructed here: SoC's obligation term is energy through the pack, not
|
||||
# command travel, and the constant it needs has not been measured. That
|
||||
# is TEL-08's, and freshness.py's docstring says exactly what it owes.
|
||||
self.batt_fresh = Freshness(BOUNDS["batt"])
|
||||
|
||||
# --- what this unit was last told, and what it last accepted ---------
|
||||
# `target` is THIS unit's share of the site target from the last
|
||||
@@ -256,6 +297,25 @@ class Inverter:
|
||||
tripped=bool(data.get(self.key("sign_tripped"), False)),
|
||||
reason=str(data.get(self.key("sign_reason"), "") or ""))
|
||||
|
||||
# --- SAFETY-42, one instance per unit, under NAMESPACED keys ---------
|
||||
# ⚠️ PER UNIT BECAUSE THE CONCLUSION IS ABOUT A UNIT. "This box is not
|
||||
# producing what it was told, while its sibling is" names a device, and
|
||||
# naming the device is card requirement 2 - the site figure cannot say
|
||||
# which one to look at, which is the whole reason SAFETY-30 split
|
||||
# per-unit from site. The site-level REDUCTION that decides whether a
|
||||
# unit qualifies lives in `delivery.delinquent()` and is computed once
|
||||
# per cycle in `Controller.cycle()`; what is per-unit here is the
|
||||
# WINDOW and the LATCH.
|
||||
#
|
||||
# ⚠️ AND IT IS RESTORED FROM THIS UNIT'S OWN KEYS, LIKE `sign_wd`. A
|
||||
# latch held only in memory is not a latch: Home Assistant restarts, the
|
||||
# `persistent_notification` goes with it, and the fault runs on unseen.
|
||||
# That is SAFETY-34, and it is how the 2026-09-02 outage lasted 6 h 25 m.
|
||||
self.delivery_wd = DeliveryWatchdog(
|
||||
delivery_cfg or DeliveryConfig(),
|
||||
tripped=bool(data.get(self.key("delivery_tripped"), False)),
|
||||
reason=str(data.get(self.key("delivery_reason"), "") or ""))
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
||||
@@ -266,6 +326,24 @@ class Inverter:
|
||||
self.store.set(self.key(name), value)
|
||||
|
||||
# -- the numbers other modules ask this object for -----------------------
|
||||
def standing_w(self) -> float:
|
||||
"""What this controller has standing on THIS unit's wire right now.
|
||||
|
||||
⚠️ ONE DEFINITION, TWO CALLERS, and it was a transcription before
|
||||
SAFETY-37. `cycle()` computes this for the sign watchdog and
|
||||
`_read_unit()` needs the identical number for the freshness conjunction;
|
||||
two spellings of "what is standing" would be two answers to the question
|
||||
the gate and the check it protects are both asking, and the copy that
|
||||
drifts is always the one further from the wire.
|
||||
|
||||
`last_write` is the value after the device clamp and confirmed accepted;
|
||||
`target` covers the case where nothing has been written yet and,
|
||||
deliberately, the case where the last write was REJECTED - the inverter
|
||||
is then still latched on `last_write`, which is what `last_write` holds.
|
||||
"""
|
||||
return float(self.last_write if self.last_write is not None
|
||||
else self.target)
|
||||
|
||||
def rating_w(self) -> float:
|
||||
"""The largest magnitude this unit can be commanded, in W.
|
||||
|
||||
@@ -319,6 +397,18 @@ class Inverter:
|
||||
f"(limit {float(stale_s):.0f} s)")
|
||||
return True, ""
|
||||
|
||||
def stale_note(self) -> str:
|
||||
"""SAFETY-37's one line for the status page. Empty when nothing to say.
|
||||
|
||||
Separate from `link_note()` because they are about different halves of
|
||||
the device and want opposite remedies: liveness is about the WRITE path
|
||||
and its answer is to drop this unit and let the survivors carry it,
|
||||
while a stale MEASUREMENT is about the read path and its answer is to
|
||||
stop the site (`inputs_ok`). Folding one into the other is how a
|
||||
measurement fault ends up handing a sibling extra power.
|
||||
"""
|
||||
return self.batt_fresh.note(self.name)
|
||||
|
||||
def link_note(self, now: datetime) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
if not self.has_writer():
|
||||
@@ -388,7 +478,8 @@ def roster_names(opts: dict) -> list:
|
||||
return names
|
||||
|
||||
|
||||
def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
def build_roster(opts: dict, store, sign_cfg=None,
|
||||
delivery_cfg=None) -> list:
|
||||
"""The site's inverters, in roster order.
|
||||
|
||||
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
|
||||
@@ -405,7 +496,9 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
it - see the options-block note on `inverters:`.
|
||||
|
||||
NON-EMPTY each entry describes one inverter and the flat keys describe
|
||||
none of them. ⚠️ NOTHING IS INHERITED. An entry that omits
|
||||
none of them. OPT-09's `capacity_kwh` is an entry key too, and an
|
||||
OPTIONAL one - see `_entry_capacity_kwh()` and `Inverter.__init__`
|
||||
on why that absence is answerable where a missing cut-out is not. ⚠️ NOTHING IS INHERITED. An entry that omits
|
||||
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
|
||||
because "this unit's number came from somewhere else" is the
|
||||
defect DEV-02 spent a card removing and a fallback here would
|
||||
@@ -435,14 +528,21 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
soc_entity=opts.get("soc_entity", ""),
|
||||
batt_entity=opts.get("batt_entity", ""),
|
||||
batt_invert=bool(opts.get("batt_invert")),
|
||||
max_w=float(opts.get("max_w", 2000)),
|
||||
max_w=_flat_max_w(opts),
|
||||
# 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)]
|
||||
# ⚠️ OPT-09 ADDS NO FLAT `capacity_kwh`, AND THE ABSENCE IS THE
|
||||
# DECISION. Capacity is read for ONE purpose - weighting a split
|
||||
# between two packs - and at the single-inverter shape there is no
|
||||
# split: `allocator.plan()` is a pass-through at N<=1 and a weight
|
||||
# is arithmetic nobody reads. A flat key would be an option an
|
||||
# operator can set, that the add-on reads, and that changes nothing,
|
||||
# which is the worst of the three.
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)]
|
||||
|
||||
roster = []
|
||||
names = roster_names(opts)
|
||||
@@ -469,7 +569,12 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
# 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))
|
||||
# OPT-09, and NOT inherited either, for the reason above it: a
|
||||
# capacity borrowed from a sibling would weight a 15.36 kWh pack as
|
||||
# though it were a 20.48 kWh one, which is the split this card
|
||||
# exists to stop being wrong.
|
||||
capacity_kwh=_entry_capacity_kwh(entry),
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg))
|
||||
return roster
|
||||
|
||||
|
||||
@@ -511,6 +616,60 @@ def _entry_bms_floor(entry: dict):
|
||||
return None
|
||||
|
||||
|
||||
def _flat_max_w(opts: dict) -> float:
|
||||
"""The single-inverter shape's cap, tolerating what rule 9 tolerates.
|
||||
|
||||
⚠️ THIS USED TO BE A BARE `float(opts.get("max_w", 2000))` AND IT
|
||||
DISAGREED WITH THE VALIDATOR. `validate.py`'s `_number()` reads `""` as
|
||||
"use the default", so rule 9 looks at an empty `max_w` box, sees 2000 and
|
||||
is content - while the bare `float("")` here raised ValueError from inside
|
||||
`Controller.__init__`. Through UX-02's panel that surfaced as HTTP 500 on a
|
||||
non-JSON body, which the page reported as "cannot reach the add-on": untrue,
|
||||
and for the most prominent limit field's most likely transient state.
|
||||
|
||||
That inverted this module's own docstring rule - "keep the two in step: a
|
||||
shape this tolerates that rule 9 does not name is a shape that runs" - in
|
||||
the other direction: a shape rule 9 TOLERATED could not be composed at all.
|
||||
The roster path never had the bug, because `_entry_max_w` below already
|
||||
guards; only the flat path did.
|
||||
|
||||
Empty or absent is the default, exactly as `_number()` reads it.
|
||||
Unparseable is 0.0, exactly as `_entry_max_w` reads it - and rule 9 refuses
|
||||
it by name at line 790, with a sentence, before a control loop exists.
|
||||
"""
|
||||
raw = opts.get("max_w", 2000)
|
||||
if raw is None or raw == "":
|
||||
return 2000.0
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def _entry_capacity_kwh(entry: dict):
|
||||
"""One entry's OWN usable capacity in kWh, or None when it stated none.
|
||||
|
||||
None rather than 0.0 and rather than a borrowed number: `_entry_max_w()` can
|
||||
answer 0.0 because a zero rating commands nothing and rule 9 refuses it by
|
||||
name, but a zero CAPACITY is a pack with no energy headroom in either
|
||||
direction, i.e. a pack the weighting would silently stop preferring. "Not
|
||||
stated" and "empty" must not be the same answer - `packshare.weights()`
|
||||
turns the first into "no opinion at all" and the second into a weight of
|
||||
zero, and those are different splits.
|
||||
|
||||
A NON-POSITIVE or unparseable value also answers None. Rule 9 refuses it at
|
||||
startup with a message; until it does, withholding is the direction that
|
||||
cannot invent a preference.
|
||||
"""
|
||||
raw = entry.get("capacity_kwh")
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return val if val > 0.0 else None
|
||||
|
||||
|
||||
def _entry_max_w(entry: dict) -> float:
|
||||
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
|
||||
|
||||
|
||||
+585
-11
@@ -38,6 +38,10 @@ from .arbiter import (
|
||||
Claim, Resolution, actuator_for, resolve,
|
||||
)
|
||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .delivery import (
|
||||
DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery,
|
||||
account as delivery_account, delinquent,
|
||||
)
|
||||
from .circulation import (
|
||||
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
|
||||
circulating_w, opposing,
|
||||
@@ -45,6 +49,7 @@ from .circulation import (
|
||||
from .clamp import (
|
||||
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
|
||||
)
|
||||
from . import freshness
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
)
|
||||
@@ -60,6 +65,7 @@ from .maintenance import (
|
||||
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
|
||||
unit_state_key)
|
||||
from . import peakguard
|
||||
from . import packshare
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
@@ -140,6 +146,23 @@ def load_options() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
def pack_energy_of(inv):
|
||||
"""OPT-09. ONE roster entry as the view `packshare` consumes.
|
||||
|
||||
⚠️ ONE BUILDER, TWO CALLERS, AND THAT IS THE POINT. `Controller.pack_energy`
|
||||
needs the whole roster (for the weights and the trade sizing) and
|
||||
`Controller.cycle()` needs one entry at a time (for that unit's `pack`
|
||||
claim). Written out twice, the two would drift - and the direction they
|
||||
would drift in is a unit whose envelope is computed from a different set of
|
||||
numbers than its weight, which is precisely the class of defect DEV-02 spent
|
||||
a card removing. It is a module function rather than a method on `Inverter`
|
||||
so that `inverter.py` does not have to import `packshare`.
|
||||
"""
|
||||
return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor,
|
||||
inv.capacity_kwh, inv.max_w)
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||
peak_db_path: str = "/data/tariff.db"):
|
||||
@@ -215,8 +238,17 @@ class Controller:
|
||||
hold_s=float(opts.get("site_sign_disagree_s",
|
||||
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])),
|
||||
)
|
||||
# SAFETY-42. THE WINDOW A UNIT MAY BE SHORT OF ITS SHARE BEFORE IT IS
|
||||
# NAMED. There is no second key: the WATTS half of that check is
|
||||
# `Tuning.saturation_w`, read at runtime rather than copied - see
|
||||
# `delivery.MIN_W_IS_SATURATION_W`, and CFG-03 on why a fourth copy of a
|
||||
# default is worth avoiding.
|
||||
self.delivery_cfg = delivery_cfg = DeliveryConfig(
|
||||
hold_s=float(opts.get("delivery_hold_s",
|
||||
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
|
||||
)
|
||||
self.store_migration = migrate_unit_keys(store, UNIT_A)
|
||||
self.inverters = build_roster(opts, store, sign_cfg)
|
||||
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
|
||||
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
|
||||
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
|
||||
# of `soc_entity`, `batt_entity`, `batt_invert` or `max_w`, and an
|
||||
@@ -364,6 +396,32 @@ class Controller:
|
||||
"opposed the meter.", inv.name,
|
||||
inv.sign_wd.reason or "(reason not recorded)")
|
||||
|
||||
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
|
||||
# ⚠️ A LATCH THAT DOES NOT ACT, WHICH IS THE ONE ASYMMETRY WITH EVERY
|
||||
# OTHER RESTORED LATCH ON THIS OBJECT, AND IT IS DELIBERATE. SAFETY-02's
|
||||
# and SAFETY-31's latches survive a restart because they command 0 W and
|
||||
# an add-on that came back armed would drive straight into the fault
|
||||
# again. This one commands nothing at all: it is restored so that the
|
||||
# ALARM survives, which is the whole of SAFETY-34 and is exactly how the
|
||||
# 2026-09-02 outage went unseen for 6 h 25 m with the detector correct
|
||||
# the entire time. `delivery.py` carries the argument for reporting
|
||||
# rather than acting, with the rig measurements on both sides of it.
|
||||
#
|
||||
# `_delivery_announced` is the per-PROCESS half - see `_circ_announced`,
|
||||
# which is the same pattern for the same reason. The store says "this
|
||||
# unit is tripped"; the flag says "this process has put that in front of
|
||||
# a human". A restart clears the flag and keeps the latch.
|
||||
self._delivery_announced = set()
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
_LOG.error(
|
||||
"SAFETY-42 was TRIPPED on %s before this restart and stays "
|
||||
"tripped: %s. NOTHING IS BEING COMMANDED DIFFERENTLY - this "
|
||||
"guard reports and does not act - so if that inverter is "
|
||||
"still not delivering, the site is still short by its "
|
||||
"share. Restarting does not clear this.",
|
||||
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
|
||||
|
||||
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
||||
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
|
||||
# The per-unit watchdogs above ask "is THIS actuator doing what I told
|
||||
@@ -588,6 +646,12 @@ class Controller:
|
||||
# edge-triggered. Starts as the empty tuple - nothing has been dropped
|
||||
# yet and nothing has been said about it.
|
||||
self._dropped_last = ()
|
||||
# OPT-09. The last cycle's `unmet_w`, so the line that reports it is
|
||||
# edge-triggered for `_dropped_last`'s reason: a site that cannot place
|
||||
# every watt stays that way for as long as the pack is empty, and a line
|
||||
# a second would flush the 40-row action log of the context explaining
|
||||
# it. 0 is "every watt was placed", which is the state at boot.
|
||||
self._unmet_last = 0
|
||||
# ⚠️ THE SITE MEASUREMENT, AND THE ONLY ONE LEFT ON THIS OBJECT. `soc`
|
||||
# and `batt` were per-device scalars and now live on each `Inverter`;
|
||||
# `grid` is P1, one meter, one connection, and belongs here.
|
||||
@@ -975,6 +1039,55 @@ class Controller:
|
||||
"""
|
||||
return float(sum(inv.max_w for inv in self.inverters))
|
||||
|
||||
def trade_power_w(self, floor_pct, ceiling_pct) -> tuple:
|
||||
"""OPT-09. `(charge_w, discharge_w)` - how hard a trade may be SIZED.
|
||||
|
||||
The site rating is what the site can push; this is what the site can
|
||||
push AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` used to be
|
||||
handed `site_max_w` on both sides, so on a roster of two the trade was
|
||||
sized at the sum of the ratings whatever either pack's state of charge
|
||||
was - the site would buy at 9600 W with only one pack able to absorb it,
|
||||
and the watts that could not be placed would come back as `unmet_w` for
|
||||
the site integrator to chase for the rest of the hour.
|
||||
|
||||
⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` /
|
||||
`arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, because SIZING
|
||||
A TRADE IS THE TRADE'S BUSINESS. This method answers "how much may this
|
||||
strategy ask for"; the ENVELOPE - `packshare.envelope()`, filed as a
|
||||
`pack` claim - answers "what may this pack be commanded at all", and
|
||||
that one is the pack's own physical cut-out for the reason SAFETY-25
|
||||
gives. Sizing with the physical floor would let the strategy ask for
|
||||
watts its own reserve forbids; bounding the envelope with the trade's
|
||||
reserve would let a trade preference stop the grid-following loop.
|
||||
THEY ARE DIFFERENT QUESTIONS AND THEY GET DIFFERENT NUMBERS.
|
||||
|
||||
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack ends up
|
||||
carrying the trade is the allocator's answer, and the only per-pack
|
||||
instrument it has for a TRADE reserve is the weights - which are a
|
||||
preference, not a permission (`allocator._fill()` will hand watts to a
|
||||
zero-weighted unit rather than leave them unmet). So a pack between its
|
||||
arbitrage floor and its BMS cut-out can still take part of a sell. That
|
||||
is stated rather than fixed: fixing it needs a per-pack envelope that
|
||||
depends on WHICH claim won the tick, which the arbiter's partition does
|
||||
not express, and is a card of its own.
|
||||
|
||||
A pack whose SoC did not read is counted on NEITHER side. It is not
|
||||
reachable with control running - `inputs_ok` stops the site when any
|
||||
pack's SoC is missing - and withholding is the direction that cannot
|
||||
over-size.
|
||||
|
||||
⚠️ NEITHER SIDE CAN BE ZERO ON A TICK THAT ACTUALLY TRADES, and it is
|
||||
not left to that coincidence: `arbitrage.decide()` refuses a 0 W sizing
|
||||
by name (`IDLE_NO_SIZE`), because a 0 W arbitrage claim is a park order
|
||||
that outranks the loop.
|
||||
"""
|
||||
# ⚠️ THE REDUCTION ITSELF LIVES IN `packshare`, NOT HERE, and this
|
||||
# method is the roster view it is applied to. A transcription of the
|
||||
# arithmetic into a check is one copy plus a future disagreement about
|
||||
# what it means, and the copy that drifts is always the grading one -
|
||||
# so `test_packshare.py` part 9 calls the SAME function this does.
|
||||
return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct)
|
||||
|
||||
@property
|
||||
def site_rating_w(self) -> float:
|
||||
"""The largest magnitude the SITE can actually be commanded, in W.
|
||||
@@ -1092,6 +1205,44 @@ class Controller:
|
||||
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
|
||||
for inv in self.inverters]
|
||||
|
||||
@property
|
||||
def pack_energy(self) -> list:
|
||||
"""OPT-09. Each pack as the FOUR numbers a split needs, in roster order.
|
||||
|
||||
The fourth is `capacity_kwh`, and it is why this is not `packs` above.
|
||||
That list answers the FLOOR question - may this pack be spent - and its
|
||||
consumers (`pack_permissions`, `binding_soc_floor`) have no use for a
|
||||
capacity and would have to default one. This list answers the SPLIT
|
||||
question - which pack should carry more of the site target - and cannot
|
||||
be asked without one.
|
||||
|
||||
⚠️ EVERY unit, live or not, for `packs`'s reason. The allocator drops
|
||||
what `live_units()` does not list and `plan()` ignores a weight for a
|
||||
unit it dropped, so filtering here would only move the same decision to
|
||||
a worse place - and `packshare.weights()` needs to see a pack that did
|
||||
not answer in order to withhold its opinion about the whole roster.
|
||||
"""
|
||||
return [pack_energy_of(inv) for inv in self.inverters]
|
||||
|
||||
def pack_weights(self, target_w):
|
||||
"""OPT-09. The comfort weights for a site target of `target_w`.
|
||||
|
||||
⚠️ IT IS A METHOD HANDED TO `resolve()`, NOT A DICT COMPUTED BEFORE IT,
|
||||
and that is not indirection for its own sake. An energy-headroom
|
||||
weighting is direction-dependent - room above the floor when the site is
|
||||
discharging, room below full when it is charging - and the two give
|
||||
opposite answers on the same roster. The direction is the SIGN OF THE
|
||||
SITE TARGET, which is stage 2's output, so nothing here can know it
|
||||
until `resolve()` has run stage 2. `arbiter.resolve()` calls this once,
|
||||
between stage 2 and stage 3, with the number it just chose.
|
||||
|
||||
⚠️ AND IT IS READ FRESH ON EVERY CALL, NEVER CACHED, for the reason the
|
||||
allocator refuses to cache an envelope: a pack's SoC moves for reasons
|
||||
that have nothing to do with the allocator, and a weighting held from
|
||||
construction prefers a pack that has since emptied.
|
||||
"""
|
||||
return packshare.weights(self.pack_energy, target_w)
|
||||
|
||||
@property
|
||||
def inputs_ok(self) -> bool:
|
||||
"""The site meter, and EVERY pack's own two readings.
|
||||
@@ -1109,8 +1260,18 @@ class Controller:
|
||||
for inv in self.inverters)
|
||||
|
||||
# -- io ------------------------------------------------------------------
|
||||
async def read_inputs(self) -> None:
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
|
||||
`now_mono` the monotonic clock the per-unit freshness records are
|
||||
stamped on. Defaulted rather than read inside `_read_unit()`
|
||||
so that ONE instant covers every unit in a gather - two
|
||||
units stamped from two clock reads would carry a freshness
|
||||
skew nobody could reproduce - and so that a check can drive
|
||||
real seconds through the gate without sleeping through them.
|
||||
"""
|
||||
o = self.o
|
||||
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
|
||||
if self.p1_enabled:
|
||||
# ⚠️ P1 is the only authoritative measurement of what the utility
|
||||
# sees (§5.1). When it is stale this is None, which falls into the
|
||||
@@ -1130,7 +1291,8 @@ class Controller:
|
||||
# names for the WRITE path, one function up. Within a unit the order is
|
||||
# unchanged (SoC, then battery power), so at the shipped roster of one
|
||||
# this is the same two calls in the same order.
|
||||
await asyncio.gather(*(self._read_unit(inv) for inv in self.inverters))
|
||||
await asyncio.gather(*(self._read_unit(inv, now_mono)
|
||||
for inv in self.inverters))
|
||||
if o.get("peak_forecast_entity"):
|
||||
raw = await self.hass.number(o["peak_forecast_entity"])
|
||||
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
|
||||
@@ -1176,10 +1338,41 @@ class Controller:
|
||||
elif self.inputs_bad_since is None:
|
||||
self.inputs_bad_since = datetime.now(timezone.utc)
|
||||
|
||||
async def _read_unit(self, inv) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities."""
|
||||
async def _read_unit(self, inv, now_mono: float) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities.
|
||||
|
||||
⚠️ SAFETY-37: THE POWER READING GOES THROUGH THE FRESHNESS GATE AND THE
|
||||
SoC READING DOES NOT, AND THE ASYMMETRY IS DELIBERATE. `batt` feeds the
|
||||
two consumers this card is about - SAFETY-30's per-unit sign check and
|
||||
SAFETY-31's circulating-power sensor - and its obligation term is
|
||||
command travel, which this method already has. SoC's obligation term is
|
||||
ENERGY through the pack and the constant it needs has never been
|
||||
measured on this site; wiring it here with a guessed constant would be a
|
||||
gate that fires on a rested pack, which is TEL-08's whole hazard.
|
||||
`freshness.BOUNDS["soc"]` states the bound and what TEL-08 owes.
|
||||
|
||||
⚠️ THE STANDING COMMAND IS ASKED OF THE UNIT, NOT RECOMPUTED. `cycle()`
|
||||
grades the sign watchdog against `inv.standing_w()` and this gate arms
|
||||
off the same method, because the gate exists to protect that check and a
|
||||
second spelling of "what is standing" would let the two disagree about
|
||||
the number they are both reasoning about.
|
||||
"""
|
||||
inv.soc = await self.hass.number(inv.soc_entity)
|
||||
inv.batt = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
raw = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
was_stale = inv.batt_fresh.stale
|
||||
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
||||
# ⚠️ `inv.batt` IS THE GATED VALUE. A stale register is a MISSING input,
|
||||
# not a measurement - `inputs_ok` sees the None, the site holds for
|
||||
# `stale_input_s` and then commands 0 W and keeps commanding, which is
|
||||
# §11.1 rule 1 and is already what a missing reading does here.
|
||||
inv.batt = inv.batt_fresh.reading()
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
|
||||
_LOG.error("SAFETY-37: %s battery-power register is not following "
|
||||
"its own command - %s. Treating it as MISSING, which "
|
||||
"commands 0 W. It clears itself the moment the register "
|
||||
"reports a different value.",
|
||||
inv.name, inv.batt_fresh.reason)
|
||||
|
||||
def write_refusal(self, value: float, inv=None) -> str | None:
|
||||
"""Why `value` may NOT be written to `inv` right now, or None if it may.
|
||||
@@ -1469,9 +1662,20 @@ class Controller:
|
||||
return tuple(live)
|
||||
|
||||
def liveness_notes(self) -> list:
|
||||
"""Why each unit is or is not commandable, for the status page."""
|
||||
"""Why each unit is or is not commandable, for the status page.
|
||||
|
||||
⚠️ SAFETY-37's STALE-READING LINE RIDES HERE TOO, AND IT IS A DIFFERENT
|
||||
FAULT WITH A DIFFERENT REMEDY. A unit that is not commandable is dropped
|
||||
and its share is carried by the survivors; a unit whose power register
|
||||
has stopped following its command stops the SITE. They share this list
|
||||
because they share one place on the status page - the operator asking
|
||||
"why is nothing happening" needs both - and nothing downstream reads it.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
return [n for n in (inv.link_note(now) for inv in self.inverters) if n]
|
||||
return [n for n in
|
||||
[x for inv in self.inverters
|
||||
for x in (inv.link_note(now), inv.stale_note())]
|
||||
if n]
|
||||
|
||||
async def cycle(self) -> None:
|
||||
"""Gather claims, resolve precedence, write the winner.
|
||||
@@ -1560,7 +1764,10 @@ class Controller:
|
||||
standing = {}
|
||||
for inv in self.inverters:
|
||||
was_tripped = inv.sign_wd.tripped
|
||||
stood = inv.last_write if inv.last_write is not None else inv.target
|
||||
# ⚠️ `Inverter.standing_w()`, NOT THE EXPRESSION IT REPLACES.
|
||||
# SAFETY-37's freshness gate arms off the same method, and this used
|
||||
# to be the only spelling of it. See that method's docstring.
|
||||
stood = inv.standing_w()
|
||||
standing[inv.name] = stood
|
||||
against = self.grid if n_units <= 1 else inv.batt
|
||||
if inv.sign_wd.evaluate(stood, against, self.following, now) \
|
||||
@@ -1681,6 +1888,117 @@ class Controller:
|
||||
"add-on restart AND a Home Assistant restart; restarting will "
|
||||
"not clear it.", self.circ_wd.reason)
|
||||
|
||||
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON
|
||||
# Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but
|
||||
# not yet stale" hold below. A run of held cycles would otherwise leave
|
||||
# each unit's `since` untouched across the gap and its window would
|
||||
# resume from a start time that predates it - the ordering bug the sign
|
||||
# watchdog's own note describes, in a third detector.
|
||||
#
|
||||
# ⚠️ THE OPERANDS ARE `standing` AND `inv.batt`, THE SAME PAIR SAFETY-30
|
||||
# GRADES, AND THE CONCLUSION IS A DIFFERENT ONE. SAFETY-30 asks whether
|
||||
# they OPPOSE; this asks whether the second one ARRIVED. A unit sitting
|
||||
# at 0 W against a +2000 W share opposes nothing - it is not pushing the
|
||||
# wrong way, it is absent - so the sign gate never opens, the site sum
|
||||
# still agrees with the meter in direction, circulating power is
|
||||
# identically zero, and the only trace left is `reason ==
|
||||
# "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an
|
||||
# operator learns to ignore. That is this card.
|
||||
#
|
||||
# ⚠️ AND `delinquent()` IS CALLED ONCE, OVER THE WHOLE ROSTER, BECAUSE
|
||||
# THE CHECK IS A REDUCTION ACROSS UNITS AND NOT A PER-UNIT PREDICATE.
|
||||
# What separates a wedged box from a legitimately saturated site is that
|
||||
# the site's shortfall is a PROPER SUBSET of the units asked to deliver.
|
||||
# Called per unit it would be a shortfall test, which is the saturation
|
||||
# signal again. delivery.py carries the measurement: a pack at its own
|
||||
# cut-out and a wedged unit produce IDENTICAL traces at the unit and at
|
||||
# the meter, so nothing computed from one unit alone can tell them
|
||||
# apart. At a roster of one the reduction is empty for every input.
|
||||
#
|
||||
# ⚠️ EVERY NUMBER BELOW IS THE CONFIGURED ONE, NEVER A LITERAL THAT
|
||||
# HAPPENS TO EQUAL IT TODAY, AND THAT IS A SHIPPED PROMISE RATHER THAN
|
||||
# A STYLE RULE. `delivery.MIN_W_IS_SATURATION_W` and config.yaml's
|
||||
# `delivery_hold_s` block both tell an operator, in prose, that there is
|
||||
# no fourth copy of a threshold to keep in step and no way for the two
|
||||
# to drift - an operator who raises `saturation_w` has told the loop a
|
||||
# bigger gap is ordinary tracking, and this check moves with them BY
|
||||
# CONSTRUCTION. A literal here breaks exactly that promise, silently.
|
||||
#
|
||||
# Both of these were once literals that no check in this repository
|
||||
# could see, because every fixture sat at the shipped value:
|
||||
#
|
||||
# self.tuning.saturation_w -> 250.0 (-19 B) - a survivor of all
|
||||
# 23 suites, because part 4's
|
||||
# gaps are far above BOTH
|
||||
# bms_floor=inv.bms_soc_floor -> 10.0 (-13 B) - a survivor of all
|
||||
# 23 suites, because every
|
||||
# fixture's cut-out WAS 10 %
|
||||
#
|
||||
# test_delivery.py part 4b is what stands between this call and both of
|
||||
# them: it grades this reduction at a `saturation_w` and at two
|
||||
# `bms_soc_floor`s that are NOT the shipped ones, and its arms are
|
||||
# answered opposite ways at two configured values, so no constant can
|
||||
# pass. Change an argument here to a number and read that part before
|
||||
# you decide the red is wrong.
|
||||
for inv in self.inverters:
|
||||
# ⚠️ `self.delivery_cfg`, NOT `DeliveryConfig()`. Same promise: the
|
||||
# window is `delivery_hold_s`, and part 4b's neighbours already
|
||||
# grade this one at a hold that is not the shipped 185 s.
|
||||
inv.delivery_wd.arm(self.delivery_cfg)
|
||||
_delinquent = delinquent(
|
||||
[UnitDelivery(
|
||||
inv.name, standing[inv.name], inv.batt,
|
||||
delivery_account(
|
||||
# ⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is
|
||||
# `LINK_STALE_S` - the SAME call `live_units()` makes for
|
||||
# the allocator. It has to be: what this branch means is
|
||||
# "the allocator has already dropped this unit and said so",
|
||||
# and a different window here would be this check answering
|
||||
# a question about a roster the allocator did not build.
|
||||
# It is the ONE number here with no option behind it, so
|
||||
# "graded at a changed config" is not available for it;
|
||||
# what part 6 grades instead is that WIDENING the window
|
||||
# changes the answer.
|
||||
live=inv.is_live(now)[0],
|
||||
parked=inv.sign_wd.tripped,
|
||||
# ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not
|
||||
# the site's `peak_soc_floor` either - that one bounds a
|
||||
# money-saving discharge only, and grading a pack against it
|
||||
# explains a wedged unit away in a band an ordinary install
|
||||
# has ten points of.
|
||||
soc=inv.soc, bms_floor=inv.bms_soc_floor))
|
||||
for inv in self.inverters],
|
||||
self.tuning.saturation_w)
|
||||
for inv in self.inverters:
|
||||
was_short = inv.delivery_wd.tripped
|
||||
if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \
|
||||
and not was_short:
|
||||
# REASON FIRST, FLAG SECOND, and each `set` writes the file -
|
||||
# the same atomicity ordering the three trips above use: a crash
|
||||
# between the two must not persist a latch with no explanation
|
||||
# for the operator who has to decide about clearing it.
|
||||
#
|
||||
# ⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02
|
||||
# HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback
|
||||
# still finds its latch", and the version being rolled back TO
|
||||
# drives exactly ONE actuator - a roster at which this check is
|
||||
# inert by construction. A flat mirror would hand a downgraded
|
||||
# add-on a latch taken on evidence about a roster it cannot have.
|
||||
inv.store_set("delivery_reason", inv.delivery_wd.reason)
|
||||
inv.store_set("delivery_tripped", True)
|
||||
self._delivery_announced.discard(inv.name)
|
||||
self.log_event(f"NOT DELIVERING {inv.name}: "
|
||||
+ inv.delivery_wd.reason)
|
||||
_LOG.error(
|
||||
"SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED "
|
||||
"DIFFERENTLY: this guard reports and does not act, because "
|
||||
"commanding a unit that is not listening achieves nothing "
|
||||
"(HW-01) and the only re-allocation this add-on can express "
|
||||
"would throw away a partially-delivering unit's remaining "
|
||||
"output. The site is short by that unit's share until "
|
||||
"somebody looks at it. This survives an add-on AND a Home "
|
||||
"Assistant restart.", inv.name, inv.delivery_wd.reason)
|
||||
|
||||
stale_after = float(self.o.get("stale_input_s", 15))
|
||||
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
|
||||
# does not become a fault; a LATCHED inverter must still reach the claim
|
||||
@@ -1723,6 +2041,39 @@ class Controller:
|
||||
# setpoint entity (§11.3), not a bound on the grid connection.
|
||||
claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w",
|
||||
actuator=self.actuator_of("supervised", inv)))
|
||||
# --- OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS -------------
|
||||
# ⚠️ THE POINT OF THE CARD, AND IT IS AN ENVELOPE RATHER THAN A
|
||||
# WEIGHT. Weighting an empty pack down does NOT stop it being
|
||||
# commanded: `allocator._fill()` runs a deliberate last-resort pass
|
||||
# over the zero-weight units when the preferred ones saturate, so
|
||||
# that a preference can never manufacture a standing error at the
|
||||
# meter. Only a closed envelope stops the ask, and `cap` is obeyed
|
||||
# absolutely. So a pack at or under its own BMS cut-out gets
|
||||
# `hi = 0` and a pack at 100 % gets `lo = 0`, and the site target
|
||||
# goes to the sibling instead of being asked of a battery that
|
||||
# cannot answer it.
|
||||
#
|
||||
# ⚠️ AT A ROSTER OF ONE THIS IS NEVER FILED, AND THAT IS THE SAME
|
||||
# IDENTITY `sign_site` IS GUARDED BY RATHER THAN A SPECIAL CASE.
|
||||
# `actuator_for()` routes every source to the SITE at N=1 - with one
|
||||
# inverter the fabricated grid reading and the battery setpoint are
|
||||
# the same number - so this interval would bound THE SITE TARGET
|
||||
# there, not a share. That is a behaviour change on the live
|
||||
# single-inverter install and it is the shape SAFETY-25 decided
|
||||
# against on 2026-09-02 (Option A): ordinary self-consumption is not
|
||||
# given an SoC floor by this card. There is also nothing for it to
|
||||
# do at N=1 - no sibling to move the share to - so the whole of AC 4
|
||||
# ("at N=1 allocation is byte-identical to today") is this `if`.
|
||||
#
|
||||
# WHAT IT COSTS, SAID PLAINLY: at N=1 nothing here stops the loop
|
||||
# discharging into the BMS cut-out. That is unchanged, it is
|
||||
# SAFETY-25's deferred Option B, and the BMS itself is what stops it.
|
||||
if n_units > 1:
|
||||
_pe = packshare.envelope(pack_energy_of(inv))
|
||||
if _pe.binding:
|
||||
claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi,
|
||||
_pe.why,
|
||||
actuator=self.actuator_of("pack", inv)))
|
||||
# The SITE bound, for the site-level consumers below. It is the roster
|
||||
# sum: what this site can deliver, not what one of its inverters can.
|
||||
max_w = self.site_max_w
|
||||
@@ -1995,6 +2346,7 @@ class Controller:
|
||||
self.arb = None
|
||||
if arbitrage.enabled(self.o):
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
|
||||
self.arb = arbitrage.decide(
|
||||
self.fresh_tariff_plan(now), now,
|
||||
peak_guard_active=arbitrage.guard_state(self.peak_guard),
|
||||
@@ -2007,7 +2359,22 @@ class Controller:
|
||||
soc=self.pack_floor_soc,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
charge_w=max_w, discharge_w=max_w,
|
||||
# OPT-09. PER-PACK SIZING. Not `max_w` - the site rating - on
|
||||
# both sides any more: the trade is sized to the packs whose own
|
||||
# state of charge permits that direction, so it can never ask
|
||||
# for more than the roster can take. `trade_power_w()` carries
|
||||
# the argument, including which of the two SoC bounds belongs
|
||||
# here and which belongs on the envelope. ⚠️ AT N=1 BOTH SIDES
|
||||
# ARE `max_w` EXACTLY, whenever the trade is permitted at all:
|
||||
# the one pack that passed the gate is the whole roster.
|
||||
# ⚠️ AND ON THE SELL SIDE THIS IS A NO-OP TODAY on any roster,
|
||||
# because `soc=` below is the EMPTIEST pack, so a sell is
|
||||
# refused outright whenever any pack is at or under the floor -
|
||||
# i.e. whenever this sum could be smaller than the site rating.
|
||||
# It becomes reachable the day that gate becomes per-pack;
|
||||
# `test_packshare.py` part 9 grades it directly rather than
|
||||
# letting an unreachable branch go ungraded.
|
||||
charge_w=charge_w, discharge_w=discharge_w,
|
||||
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
|
||||
# SITE, and arbitrage.claim() says so on the object it builds. It
|
||||
# buys and sells at the grid against a day-ahead price: the price is
|
||||
@@ -2212,8 +2579,24 @@ class Controller:
|
||||
# stale from this line onwards. Incremented BEFORE resolve() so that the
|
||||
# record resolve() builds is stamped with the tick it is for.
|
||||
self.alloc_tick += 1
|
||||
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. ARCH-01 left the
|
||||
# allocator one free parameter and ALLOC-01 built the machinery that
|
||||
# consumes it (`allocator._fill`); this line is the first thing that has
|
||||
# ever passed one. Without it every unit weighed 1.0 and the split was
|
||||
# a fixed 50/50 regardless of what either pack could do.
|
||||
#
|
||||
# ⚠️ A METHOD, NOT A DICT. The weighting is by ENERGY HEADROOM and is
|
||||
# therefore direction-dependent, and the direction is the sign of the
|
||||
# site target - which stage 2 chooses inside `resolve()`. See
|
||||
# `pack_weights()` and `packshare.weights()`; the arbiter calls it once,
|
||||
# between stage 2 and stage 3.
|
||||
#
|
||||
# ⚠️ AND IT IS A PREFERENCE, NOT A PERMISSION. Nothing about the site's
|
||||
# ability to DELIVER rests on this argument - a weight cannot stop a
|
||||
# unit being commanded and cannot move a failing unit's load to its
|
||||
# sibling. The `pack` claim filed above is the half that constrains.
|
||||
resolution = resolve(claims, units=self.units, live=self.live_units(),
|
||||
tick=self.alloc_tick)
|
||||
weights=self.pack_weights, tick=self.alloc_tick)
|
||||
self.allocation = resolution.allocation
|
||||
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
|
||||
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
|
||||
@@ -2307,6 +2690,25 @@ class Controller:
|
||||
if self._dropped_last and not _dropped:
|
||||
self.log_event("ALLOCATOR: every inverter is back in the split")
|
||||
self._dropped_last = _dropped
|
||||
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
|
||||
# `unmet_w` is the site target the envelopes could not absorb. It is
|
||||
# NOT a broken allocation - `Allocation`'s own docstring says why
|
||||
# fusing it with `ok` would let one saturated inverter park the whole
|
||||
# site - but it IS the site failing to deliver what the loop asked
|
||||
# for, and before this card nothing anywhere reported it. It is the
|
||||
# state a closed envelope produces when BOTH packs are at their
|
||||
# limits: the ask has nowhere left to go, and an operator watching a
|
||||
# standing grid error needs the reason rather than the symptom.
|
||||
# Edge-triggered on the VALUE, for `_dropped` reason.
|
||||
_unmet = int(self.allocation.unmet_w if self.allocation else 0)
|
||||
if _unmet != self._unmet_last:
|
||||
if _unmet:
|
||||
self.log_event(
|
||||
f"ALLOCATOR: {_unmet:+d} W of the site target could not be "
|
||||
f"placed on any inverter - {self.allocation.explain()}")
|
||||
else:
|
||||
self.log_event("ALLOCATOR: the whole site target fits again")
|
||||
self._unmet_last = _unmet
|
||||
if resolution.scope_errors:
|
||||
# A claim reached the arbiter on an actuator its source's scope does
|
||||
# not permit. It is a BUG in the filing site, not a state to survive:
|
||||
@@ -2401,6 +2803,15 @@ class Controller:
|
||||
# fresh trip instead of getting its own.
|
||||
await self.check_circulating()
|
||||
|
||||
# --- SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's
|
||||
# It grades `inv.delivery_wd.tripped`, which was advanced near the top
|
||||
# of this method beside SAFETY-02 and SAFETY-31 and which nothing since
|
||||
# has touched. Called on EVERY cycle rather than only on the cycle that
|
||||
# trips, because its second job is re-raising the alarm after a restart
|
||||
# into an existing trip - see its docstring on why that shares one code
|
||||
# path with the fresh trip instead of getting its own.
|
||||
await self.check_not_delivering()
|
||||
|
||||
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
|
||||
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
|
||||
# method assigned above and nothing since has touched.
|
||||
@@ -2738,6 +3149,85 @@ class Controller:
|
||||
f"know why two inverters were opposing."),
|
||||
})
|
||||
|
||||
async def check_not_delivering(self) -> None:
|
||||
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
|
||||
|
||||
⚠️ THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm.
|
||||
`unit/<name>/delivery_tripped` in `/data/state.json` survives everything,
|
||||
but nothing looks at a JSON file at 3 a.m. What the operator sees is a
|
||||
`persistent_notification`, and THAT IS IN MEMORY: a Home Assistant
|
||||
restart deletes it while the fault runs on. That is precisely how the
|
||||
2026-09-02 outage went unseen for 6 h 25 m with the detector correct the
|
||||
whole time (SAFETY-34), and it matters more here than anywhere else in
|
||||
this file, because SAFETY-02 and SAFETY-31 at least leave a stopped site
|
||||
for somebody to notice. This one leaves a site that looks like it is
|
||||
working and is quietly delivering one unit less than it has.
|
||||
|
||||
So the durable half and the visible half are joined HERE, and the join
|
||||
is `_delivery_announced`: a per-PROCESS set of unit names, deliberately
|
||||
not persisted. The latch says "this unit is tripped"; membership says
|
||||
"this process has put that in front of a human". A restart empties the
|
||||
set and keeps the latch, so the notification is RE-RAISED on the first
|
||||
cycle after every add-on start - which is also the first cycle after a
|
||||
Home Assistant restart, because the add-on is restarted with it.
|
||||
|
||||
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s
|
||||
argument. A fresh trip and a restart into an existing trip raise the
|
||||
alarm through these same lines; two paths would be two chances for the
|
||||
restart one to rot unnoticed, and the restart one is the case nobody
|
||||
exercises by hand.
|
||||
|
||||
⚠️ AND ONE NOTIFICATION ID PER UNIT. Every other alarm in this file has
|
||||
a single fixed id because its subject is the site; this one's subject is
|
||||
a named box, and two wedged inverters must produce two cards rather than
|
||||
one replacing the other. The id is built from the same `unit_slugs()`
|
||||
TEL-07 announces the MQTT entities under, so the card and the entity an
|
||||
operator correlates it with carry the same token.
|
||||
"""
|
||||
names = self.units
|
||||
slugs = unit_slugs(names)
|
||||
for inv, slug in zip(self.inverters, slugs):
|
||||
if not inv.delivery_wd.tripped or inv.name in self._delivery_announced:
|
||||
continue
|
||||
# Added BEFORE the await, for `check_circulating()`'s reason:
|
||||
# `hass.call()` can raise or hang, and a retry every cycle for a
|
||||
# broker that is down would be an alarm storm on the event bus. The
|
||||
# latch is already durable and the log line already fired; this
|
||||
# surface is best-effort by design.
|
||||
self._delivery_announced.add(inv.name)
|
||||
peers = ", ".join(n for n in names if n != inv.name) or "no sibling"
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": f"goodwe_not_delivering_{slug}",
|
||||
"title": f"GoodWe: {inv.name} is not delivering its share",
|
||||
"message": (
|
||||
f"{inv.delivery_wd.reason}\n\nWHY NOTHING ELSE SAID SO. A "
|
||||
f"unit sitting at 0 W against a non-zero share is not "
|
||||
f"pushing the wrong way, it is ABSENT: the per-unit sign "
|
||||
f"check needs opposition on both sides of "
|
||||
f"{self.unit_sign_cfg.min_w:.0f} W and never opens, the "
|
||||
f"site sum still agrees with the meter in "
|
||||
f"direction, and circulating power is exactly zero when one "
|
||||
f"unit does nothing. The only other trace is a saturation "
|
||||
f"freeze, which this add-on's own code calls an alarm "
|
||||
f"operators learn to ignore.\n\nNOTHING HAS BEEN COMMANDED "
|
||||
f"DIFFERENTLY. This guard reports and does not act. "
|
||||
f"Commanding an inverter that has stopped listening "
|
||||
f"achieves nothing - that has happened on this hardware, "
|
||||
f"once for 6 h 25 m - and the only re-allocation this "
|
||||
f"add-on can express is all-or-nothing, which would throw "
|
||||
f"away the output of a unit that is delivering PART of its "
|
||||
f"share. So {peers} is still regulating and the site is "
|
||||
f"short by up to {inv.rating_w():.0f} W until somebody "
|
||||
f"looks at {inv.name}.\n\nWHAT TO CHECK, in order: is "
|
||||
f"{inv.name} answering on RS485 at all; is its pack colder "
|
||||
f"or emptier than its configured bms_soc_floor says (if it "
|
||||
f"is, that setting does not describe this battery and this "
|
||||
f"alarm is telling you so); and is anything else writing to "
|
||||
f"it.\n\nThis alarm survives a Home Assistant restart. "
|
||||
f"Restarting will not clear it; start control from the "
|
||||
f"add-on UI once you know why."),
|
||||
})
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
|
||||
@@ -3500,6 +3990,49 @@ class Controller:
|
||||
+ (f" - now {_circ:.0f} W" if _circ else "")
|
||||
+ (f", opposed for {held:.0f} s" if held else "")})
|
||||
|
||||
# --- SAFETY-42 -------------------------------------------------------
|
||||
# ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE ONE PLACE THE
|
||||
# "reports, does not act" DECISION IS VISIBLE TO AN OPERATOR. Every
|
||||
# other latched row in this list means the site is at 0 W and a red
|
||||
# cross is the truth. This one means the site is STILL RUNNING and is
|
||||
# quietly short by one unit's share; rendering it as a hard failure
|
||||
# would say the controller had stopped, which it has not. It is not "ok"
|
||||
# either - `ok: False` with `warn: True` is this list's caution, and a
|
||||
# caution is what a fault nobody is acting on actually is.
|
||||
if len(self.inverters) > 1:
|
||||
for inv in self.inverters:
|
||||
tag = f" ({inv.name})"
|
||||
if inv.delivery_wd.tripped:
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"NOT DELIVERING{tag} - "
|
||||
+ inv.delivery_wd.reason
|
||||
+ ". Nothing has been commanded differently and "
|
||||
"the rest of the roster is still regulating, "
|
||||
"so the site is short by this unit's share. "
|
||||
"Starting control clears this row; restarting "
|
||||
"the add-on does NOT, and neither does "
|
||||
"restarting Home Assistant."})
|
||||
else:
|
||||
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"delivery watchdog{tag}: armed (reports after "
|
||||
f"{inv.delivery_wd.cfg.hold_s:.0f} s short of "
|
||||
f"its share by over "
|
||||
f"{self.tuning.saturation_w:.0f} W, with a "
|
||||
f"sibling that is not)"
|
||||
+ (f" - short for {_dheld:.0f} s now"
|
||||
if _dheld else "")})
|
||||
else:
|
||||
# AC 5, and the same honesty `circulating power: n/a` uses one block
|
||||
# up. At one inverter the reduction this check is has no proper
|
||||
# subset to find, for EVERY input, so "armed" would be a claim to be
|
||||
# watching something that cannot happen.
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": "delivery watchdog: n/a at one inverter - a unit "
|
||||
"short of its share is indistinguishable from a "
|
||||
"plant that cannot deliver until there is a "
|
||||
"sibling to compare it with"})
|
||||
|
||||
if not o.get("estop_fitted", False):
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": "no e-stop fitted: if this host dies the battery stays "
|
||||
@@ -3559,6 +4092,26 @@ class Controller:
|
||||
level, banner = "bad", f"NOT READY — {bad[0].get('banner') or bad[0]['text']}"
|
||||
elif not self.auto:
|
||||
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
||||
elif any(i.delivery_wd.tripped for i in self.inverters):
|
||||
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
||||
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
|
||||
# Every `bad` banner above means the site is at 0 W; this one means
|
||||
# the site is STILL RUNNING and quietly short by one unit's share,
|
||||
# and a red headline would say the controller had stopped.
|
||||
#
|
||||
# ⚠️ BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT
|
||||
# IS NOT COSMETIC. That sentence is a claim the site cannot make
|
||||
# while a unit is absent - the loop is saturated and the meter is
|
||||
# short - and "the page reads healthy while the fault runs" is
|
||||
# precisely the shape this whole card exists to remove. It sits
|
||||
# BELOW "Stopped" because a stopped site is the more urgent fact and
|
||||
# the banner carries one sentence.
|
||||
_nd = ", ".join(i.name for i in self.inverters
|
||||
if i.delivery_wd.tripped)
|
||||
level, banner = "warn", (
|
||||
f"NOT DELIVERING — {_nd} is standing at a share it is not "
|
||||
f"producing. The site is still running and short by it; "
|
||||
f"nothing has been commanded differently.")
|
||||
elif self.maint.phase != IDLE:
|
||||
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
|
||||
# of one this is the string it has always been, character for
|
||||
@@ -3709,6 +4262,27 @@ class Controller:
|
||||
self.store.set("circ_reason", "")
|
||||
self.log_event("circulating-power trip cleared by operator")
|
||||
self._circ_announced = False
|
||||
# SAFETY-42, cleared by the SAME press, and for a reason that is
|
||||
# NOT the one above. The other three latches are cleared here
|
||||
# because their trip removed its own evidence - a site commanding
|
||||
# 0 W cannot oppose anything - so only a human can say the fault
|
||||
# is gone. This one commands nothing, so its evidence is still
|
||||
# standing and it could in principle clear itself. It does not:
|
||||
# a unit that stopped delivering for minutes and then resumed is
|
||||
# an INTERMITTENT fault, and a detector that erased its own
|
||||
# record of that would be the least useful thing in this file.
|
||||
# ⚠️ AND `_delivery_announced` GOES WITH IT, like
|
||||
# `_circ_announced`: leaving a name in it through a clear would
|
||||
# mean the NEXT trip on that unit latched and raised no
|
||||
# notification at all.
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
self.log_event(f"not-delivering report on {inv.name} "
|
||||
f"cleared by operator")
|
||||
inv.delivery_wd.reset()
|
||||
inv.store_set("delivery_tripped", False)
|
||||
inv.store_set("delivery_reason", "")
|
||||
self._delivery_announced = set()
|
||||
if not self.auto:
|
||||
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
|
||||
# Zeroing `self.target` alone would leave each unit's last share
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
"""OPT-09 - what each pack can still do, in the two forms stage 3 accepts.
|
||||
|
||||
ARCH-01 left the allocator ONE free parameter - `weights` - and ALLOC-01 built
|
||||
the machinery that consumes it (`allocator._fill`). Nothing ever passed one.
|
||||
`main.py` called `resolve()` without `weights=`, `arbiter.resolve()` defaulted
|
||||
it to None and `allocator.plan()` turned that into 1.0 for every unit, so the
|
||||
split was a fixed 50/50 REGARDLESS OF WHAT EITHER PACK COULD DO. This module is
|
||||
what fills that argument, and it fills the other one too - the per-unit
|
||||
interval - because on their own the weights cannot do the job. Read the two
|
||||
sections below in order; the second is the important one.
|
||||
|
||||
⚠️ THE TWO ARE NOT INTERCHANGEABLE, AND THE ALLOCATOR SAYS SO FIRST
|
||||
===================================================================
|
||||
`allocator.py`'s docstring states the rule this module is built on:
|
||||
|
||||
WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
|
||||
"please do not use this unit", and it is honoured while any other unit has
|
||||
headroom - but it is NOT a constraint [...] To make a unit genuinely
|
||||
unusable, close its envelope or leave it out of `live`.
|
||||
|
||||
That is mechanical, not advisory: `_fill()` runs a SECOND, last-resort pass over
|
||||
the zero-weight units when the preferred ones fill up, deliberately, so that a
|
||||
preference can never manufacture a standing error at the meter. So a pack at its
|
||||
own cut-out that is only WEIGHTED to zero is still commanded to discharge the
|
||||
moment its sibling saturates. Closing its `hi` is what actually stops the ask.
|
||||
|
||||
Hence this module produces two things and they answer two different questions:
|
||||
|
||||
envelope(pack) MAY this pack be asked, at all, in this direction?
|
||||
-> a `hi`/`lo` that main.py files as a UNIT claim, and the
|
||||
allocator obeys absolutely (`cap` is never overridden).
|
||||
weights(packs,S) GIVEN that both may, which should carry more of it?
|
||||
-> a comfort choice, in the sense ARCH-01 means: the gain
|
||||
seen at the meter is a CONVEX COMBINATION of the
|
||||
per-unit gains for ANY split, so this cannot destabilise
|
||||
the site whatever it says.
|
||||
|
||||
⚠️ AND WHAT THE WEIGHTS CANNOT DO, BECAUSE THE CONVEX-COMBINATION ARGUMENT IS
|
||||
EASY TO OVER-READ
|
||||
============================================================================
|
||||
ARCH-01 guarantees that a bad split cannot DESTABILISE the site. It says nothing
|
||||
whatever about whether the site can REGULATE. A unit sitting at its energy limit
|
||||
has g_i = 0 - it answers no watts to any command - and the convex combination
|
||||
then contains a zero: `gbar = sum(a_i * g_i)` is still inside [min g_i, max g_i]
|
||||
and the site is still stable, while the load it was asked to carry simply does
|
||||
not move. `compute()` sees the resulting standing error, trips its saturation
|
||||
freeze and holds the site target where it is, so the share of a unit that cannot
|
||||
deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
|
||||
|
||||
⚠️ SO: WEIGHTS ARE A COMFORT CHOICE FOR STABILITY AND NOT FOR DELIVERY.
|
||||
Weighting a failing unit down is not a remedy for it. The remedies this
|
||||
add-on has are the ENVELOPE (here, when the reason is the pack's own state
|
||||
of charge - a fact this controller reads) and `live_units()` (when the unit
|
||||
cannot be commanded at all). SAFETY-42 measured what the third case costs:
|
||||
with one unit wedged and equal weights, the site settles 700 W short and
|
||||
never recovers, because the freeze pins the target and the sibling's share
|
||||
never rises. That module DETECTS it; this one cannot fix it, and saying so
|
||||
is worth more than a weighting scheme that looks like it might.
|
||||
|
||||
⚠️ AND IT WAS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROWED SAFETY-42's
|
||||
DETECTION WINDOW - A DETECTION REGRESSION AT N=2, NOW CLOSED BY SAFETY-43
|
||||
=======================================================================
|
||||
This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said
|
||||
with it - that a wedged unit's weight does not move, so OPT-09 cannot see the
|
||||
case at all. THE ENVELOPE HALF IS TRUE: a wedged box reads a healthy SoC and
|
||||
nothing here closes. THE WEIGHT HALF WAS FALSE. `weights()` is a function of a
|
||||
pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight
|
||||
moves whenever its headroom differs from its sibling's - the ordinary case this
|
||||
module exists to exploit.
|
||||
|
||||
`delivery.delinquent()` named a unit only while its `short_by()` gap exceeded
|
||||
`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's
|
||||
share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real
|
||||
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
|
||||
own 10 % cut-out, so `envelope()` does not close and `account()` explains
|
||||
nothing): at a 1500 W load the healthy pack can carry alone, equal weights named
|
||||
the wedged unit on 199 of 200 cycles and these weights named it on NONE. The
|
||||
suppression window widened from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it had
|
||||
an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass
|
||||
hands the wedged unit a big share again and the absolute arm names it.
|
||||
|
||||
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE - KEPT HERE BECAUSE IT IS A MEASUREMENT, EVEN THOUGH SAFETY-43 HAS SINCE MADE THE WINDOW IRRELEVANT TO THE DETECTOR. Swept on this fixture with the ABSOLUTE arm alone, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returned BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm named the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
|
||||
⚠️ THAT IS NO LONGER A RESIDUAL. SAFETY-43 made `delinquent()` relative to
|
||||
the ask AS WELL AS absolute - `gap > min_w OR gap > frac * share`, and a
|
||||
unit delivering 0 of 125 W is as delinquent as one delivering 0 of 1500 W.
|
||||
On the SAME fixture, under the SAME weights, the wedged unit is now named
|
||||
on 200 of 200 cycles. Part 8b grades both rules on one run:
|
||||
`share_frac=None` is SAFETY-42's absolute arm alone and still says nothing,
|
||||
which is what keeps the paragraph above honest rather than nostalgic.
|
||||
|
||||
⚠️ AND `min_w` WAS NOT LOWERED. The absolute arm's suppression window is
|
||||
still exactly the arithmetic above - 1000 W at equal weights, 6500 W at
|
||||
these - because a lower `saturation_w` would report NORMAL SATURATION as
|
||||
delinquency on every unit at once, which is the false-trip regime SAFETY-41
|
||||
spent four attempts in. What changed is that there is a SECOND way to be
|
||||
named; nothing that was quiet before became noisy.
|
||||
|
||||
⚠️ WHAT THIS MODULE STILL CANNOT DO IS UNCHANGED. OPT-09 stops the site
|
||||
ASKING a pack whose own SoC says it cannot deliver; it does not, and will
|
||||
not, re-allocate a wedged unit's watts to its sibling - `delivery.py`'s
|
||||
"IT REPORTS. IT DOES NOT ACT" block is the measurement behind that, and
|
||||
SAFETY-43 did not touch it. The alarm is louder; the loop is identical.
|
||||
|
||||
WHY ENERGY AND NOT POWER
|
||||
========================
|
||||
The two packs at this site are asymmetric IN OPPOSITE DIRECTIONS. From the
|
||||
datasheet and the serials (NOTES.md 1.1b): A is 5000 W / 15.36 kWh, B is 4600 W
|
||||
/ 20.48 kWh - B has about 33 % more ENERGY and about 8 % less POWER. A split by
|
||||
power rating hands the trade to A; a split by energy headroom hands it to B. The
|
||||
question the split is answering is "which pack can sustain its share for the
|
||||
hour", so it is the energy one, and the ratings are already applied - as the
|
||||
`supervised` and `device` claims, per unit, which is where a POWER bound belongs.
|
||||
|
||||
⚠️ INVERTER B DOES NOT EXIST YET (expected ~2026-09-12) AND NOTHING HERE IS
|
||||
MEASURED ON IT. `sim/plantsim.py`'s INVERTER_B is inverter A copied, with
|
||||
`measured=False` on it. Every figure above is FROM DATASHEET AND SERIAL, and
|
||||
every two-unit result this card reports is TWO COPIES OF A driven with different
|
||||
numbers, which is a statement about the arithmetic and not about the hardware.
|
||||
|
||||
WHICH FLOOR, AND WHICH CEILING - THE TWO CHOICES THAT ARE POLICY IF YOU GET
|
||||
THEM WRONG
|
||||
==========================================================================
|
||||
⚠️ THE FLOOR IS `bms_soc_floor` ALONE, NOT `peakguard.pack_floor_pct()`'s
|
||||
conjunction with `peak_soc_floor`. This is the same choice `delivery.py`'s
|
||||
`account()` had to make and it is made for the same reason, one direction
|
||||
further out: `peak_soc_floor` bounds the PEAK GUARD ONLY (SAFETY-25), ordinary
|
||||
self-consumption discharges straight past it, and closing a unit's envelope at
|
||||
it would silently turn an EMS preference into a hard stop on the grid-following
|
||||
loop - which is the memo's DEFERRED Option B, decided against on 2026-09-02, and
|
||||
not this card's to build. `bms_soc_floor` is a property of the battery in the
|
||||
cupboard: below it the pack's own BMS stops, so a discharge command sent there
|
||||
is a command that cannot be executed by anything.
|
||||
|
||||
⚠️ AND THE CEILING IS 100 %, WHICH IS COARSE, AND THE COARSENESS IS THE HONEST
|
||||
ANSWER RATHER THAN A GAP TO BE PAPERED OVER. There is no per-pack charge ceiling
|
||||
anywhere in this add-on - `delivery.py` says so at length and calls it a card,
|
||||
and it is why SAFETY-42 is armed on discharge shares only. The numbers that
|
||||
exist are `arbitrage_soc_ceiling` (one site-wide number, and a TRADE policy) and
|
||||
nothing else. Filing a trade policy as a P_SAFETY envelope on the loop would be
|
||||
exactly the mistake the paragraph above refuses, in the charge direction. So the
|
||||
envelope closes only where the ask is physically impossible - a pack reporting
|
||||
100 % being told to take more - and everything between is left to the WEIGHTS,
|
||||
which is the right instrument for a graded preference and is honest about being
|
||||
only a preference.
|
||||
|
||||
CAPACITY IS OPTIONAL AND "UNKNOWN" MEANS "NO OPINION"
|
||||
=====================================================
|
||||
`capacity_kwh` is a new, OPTIONAL per-entry key. Optional, where SAFETY-32 made
|
||||
`bms_soc_floor` required, because the two absences mean different things: a
|
||||
missing cut-out would have to be DEFAULTED and a default protects the shallower
|
||||
pack with the deeper pack's number, whereas a missing capacity can be answered
|
||||
with "then this module has no opinion about the split" - which is exactly
|
||||
today's behaviour, 1.0 for every unit. `weights()` therefore answers None unless
|
||||
EVERY pack in the roster states both a capacity and a state of charge: a
|
||||
half-known roster would weight the packs that answered against a sibling
|
||||
weighing 1.0, which is a comparison between a kWh and a dimensionless one.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import inf, isfinite
|
||||
|
||||
from .peakguard import discharge_permitted
|
||||
|
||||
# The only charge ceiling that is a property of a PACK rather than of a policy.
|
||||
# See the module docstring: `arbitrage_soc_ceiling` is a trade preference and
|
||||
# must not become a P_SAFETY bound on the grid-following loop.
|
||||
FULL_PCT = 100.0
|
||||
|
||||
# The claim source name main.py files these intervals under.
|
||||
#
|
||||
# ⚠️ MAIN.PY STILL WRITES THE STRING LITERALLY AND MUST GO ON DOING SO, so this
|
||||
# constant is a NAME FOR THE CHECKS rather than something to import at the
|
||||
# filing site. test_peakguard.py part 10f reads `Claim.limit(...)`'s FIRST
|
||||
# POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set, and part
|
||||
# 10g reads `actuator_of('<literal>')` the same way; both go blind on a name
|
||||
# they would have to resolve. So the two spellings are tied by a check instead -
|
||||
# `CLAIM_SCOPE[packshare.SOURCE] == SCOPE_UNIT` in test_packshare.py part 10 -
|
||||
# which is the one direction that cannot make the scanners vacuous.
|
||||
SOURCE = "pack"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackEnergy:
|
||||
"""ONE pack's four numbers for the two questions this module answers.
|
||||
|
||||
`name` the roster name. An envelope that does not say WHICH pack it
|
||||
closed sends an operator to the wrong cupboard - the defect
|
||||
`peakguard.Pack` and CFG-04's `unit_caps_indexed()` were
|
||||
both written for.
|
||||
`soc` THIS pack's measured state of charge, in percent. None when
|
||||
its sensor did not answer, and ⚠️ None IS NOT A FULL
|
||||
BATTERY - `discharge_permitted()` says why at length.
|
||||
`bms_floor` THIS pack's OWN `bms_soc_floor`, the percent at which its
|
||||
BMS cuts out. Required of every roster entry since
|
||||
SAFETY-32, so at a declared roster this is never None.
|
||||
`capacity_kwh` THIS pack's usable capacity. None when the entry did not
|
||||
state one, which means "no opinion" and never 0 - a zero
|
||||
capacity is a pack that may never be used, which is a
|
||||
decision nobody made by omitting a key.
|
||||
`max_w` THIS unit's supervised rating. ⚠️ IT IS NOT AN INPUT TO THE
|
||||
SPLIT AND MUST NOT BECOME ONE - the whole of deliverable 2
|
||||
is that the split is by ENERGY and the rating is applied
|
||||
separately, per unit, as the `supervised` and `device`
|
||||
claims. It is here for `trade_power_w()` alone, which
|
||||
answers a different question: how many watts the packs that
|
||||
are ALLOWED to take a trade can take between them.
|
||||
|
||||
⚠️ IT IS A SEPARATE TYPE FROM `peakguard.Pack` RATHER THAN AN EXTENSION OF
|
||||
IT, AND THAT IS DELIBERATE. `Pack` carries the three numbers the FLOOR
|
||||
question needs and is consumed by `pack_permissions()` and
|
||||
`binding_soc_floor()`, whose answers are site verdicts about spending a pack
|
||||
for money. This one carries a fourth number that is meaningless to those
|
||||
reductions and would have to be defaulted into them. Two small types with
|
||||
one purpose each beat one type with a field half its callers must ignore.
|
||||
"""
|
||||
|
||||
name: str
|
||||
soc: float | None
|
||||
bms_floor: float | None
|
||||
capacity_kwh: float | None
|
||||
max_w: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackEnvelope:
|
||||
"""The interval OPT-09 asks for on one pack, and the sentence that says why.
|
||||
|
||||
`(-inf, +inf)` is "no opinion" and is the common answer; `binding` is how a
|
||||
caller asks whether there is anything to file. A claim filed for every pack
|
||||
on every cycle regardless would put an open limit into the arbiter's
|
||||
considered list on every tick, which costs nothing arithmetically and buries
|
||||
the one cycle where it means something.
|
||||
"""
|
||||
|
||||
lo: float = -inf
|
||||
hi: float = inf
|
||||
why: str = ""
|
||||
|
||||
@property
|
||||
def binding(self) -> bool:
|
||||
return self.lo != -inf or self.hi != inf
|
||||
|
||||
|
||||
def usable_kwh(pack) -> float | None:
|
||||
"""Energy this pack can still DISCHARGE before its own BMS cuts out.
|
||||
|
||||
None when it cannot be computed - no capacity, no reading, or no floor - and
|
||||
never 0.0 for those, because "no opinion" and "nothing left" are the two
|
||||
answers a weighting must not confuse: the first weighs 1.0 and the second
|
||||
weighs nothing at all.
|
||||
|
||||
Clamped at 0.0 from below. A pack measured BELOW its own cut-out is at
|
||||
-0.4 kWh of usable energy by arithmetic and at zero in fact.
|
||||
"""
|
||||
if (pack.capacity_kwh is None or pack.soc is None
|
||||
or pack.bms_floor is None):
|
||||
return None
|
||||
return max(0.0, float(pack.capacity_kwh)
|
||||
* (float(pack.soc) - float(pack.bms_floor)) / 100.0)
|
||||
|
||||
|
||||
def spare_kwh(pack) -> float | None:
|
||||
"""Energy this pack can still ACCEPT before it is full.
|
||||
|
||||
`FULL_PCT`, not `arbitrage_soc_ceiling`: the module docstring carries the
|
||||
argument, and it is the same one that keeps `peak_soc_floor` out of
|
||||
`usable_kwh()`. A pack's own fullness is a fact; a trade's reserve is not.
|
||||
|
||||
⚠️ AND IT IS A WEIGHTING INPUT, NOT A PERMISSION. It says nothing about the
|
||||
constant-voltage taper, in which a pack legitimately accepts far less than
|
||||
it is told for tens of minutes - there is no configured ceiling in this
|
||||
add-on that could describe that, which is `delivery.py`'s stated reason for
|
||||
being armed on discharge shares only.
|
||||
"""
|
||||
if pack.capacity_kwh is None or pack.soc is None:
|
||||
return None
|
||||
return max(0.0, float(pack.capacity_kwh)
|
||||
* (FULL_PCT - float(pack.soc)) / 100.0)
|
||||
|
||||
|
||||
def envelope(pack) -> PackEnvelope:
|
||||
"""The interval this pack may be commanded in, from its state of charge.
|
||||
|
||||
Positive is DISCHARGE throughout this add-on, so:
|
||||
|
||||
* a pack at or below its own cut-out gets `hi = 0` - it may still be
|
||||
charged, it may not be asked to discharge;
|
||||
* a pack at 100 % gets `lo = 0` - it may still be discharged, it may not
|
||||
be asked to charge.
|
||||
|
||||
⚠️ THE DISCHARGE HALF ASKS `peakguard.discharge_permitted()`, THE PREDICATE
|
||||
SAFETY-32 MADE PUBLIC, rather than writing `soc <= floor` a third time. That
|
||||
function is already the one place this question is answered - for the peak
|
||||
guard's own verdict and for `delivery.account()`'s discriminator - and a
|
||||
safety predicate transcribed into a third copy is two future disagreements
|
||||
about what it means. What is NOT reused is its wording, which is peak-guard
|
||||
flavoured; the floor handed in is the pack's own cut-out, exactly as
|
||||
`delivery.account()` hands it.
|
||||
|
||||
⚠️ AND A MISSING READING OR A MISSING FLOOR WITHHOLDS THE CLOSURE, NOT THE
|
||||
DISCHARGE - WHICH IS THE OPPOSITE DIRECTION FROM `discharge_permitted()`'s
|
||||
OWN, ON PURPOSE. That predicate answers "may this site SPEND this pack for
|
||||
money", where an unread SoC must not authorise the spend and a full battery
|
||||
is exactly the value that would disarm the floor. The question here is
|
||||
different: whether to take a unit's share away from it and hand it to a
|
||||
sibling. An absent measurement is not evidence that a battery is flat, and
|
||||
closing on it would park a healthy unit's discharge on every tick for as
|
||||
long as its sensor was missing - moving load onto its sibling on the
|
||||
strength of a reading nobody took.
|
||||
|
||||
Both absences already have a stronger answer arriving first, which is why
|
||||
this one costs nothing: a missing SoC stops the WHOLE SITE through
|
||||
`inputs_ok` (a site-level P_SAFETY set of 0 W), and a roster entry with no
|
||||
`bms_soc_floor` is refused at startup by validate.py rule 8 and by
|
||||
config.yaml's own schema, which requires the key of every entry. So the
|
||||
conjunction below is "positive evidence only": both numbers present, and the
|
||||
predicate says the pack is at or under its own cut-out.
|
||||
|
||||
⚠️ `discharge_permitted()` IS STILL THE PREDICATE, and it is still the only
|
||||
place the comparison is written - what this module decides for itself is the
|
||||
two None cases, which it decides differently and says why. The BOUNDARY
|
||||
(`<=`, so a pack sitting exactly on its floor is closed) comes from there
|
||||
and is graded against it over the whole percent range in test_packshare.py
|
||||
part 2, so a future change to that boundary moves both.
|
||||
|
||||
⚠️ AT P_SAFETY IT BINDS THE MAINTENANCE DRAIN TOO, AND THAT IS RIGHT RATHER
|
||||
THAN AN OVERSIGHT. main.py files this as a `P_SAFETY` limit, so clause 2 of
|
||||
the arbiter's rule applies it to the §12 recalibration drain (a SET at
|
||||
P_MAINTENANCE): a drain into a pack already at its BMS cut-out is clamped to
|
||||
0 W. On the shipped numbers it cannot even bite - `maintenance_soc_floor`
|
||||
defaults to 11 % against a 10 % cut-out, so the drain finishes above this
|
||||
interval. On a config that sets the drain target BELOW the cut-out
|
||||
(`int(5,30)` against `int(0,50)`, so the schema permits it) the drain stalls
|
||||
and the FSM's 12 h DRAIN timeout eventually reports "drain stuck" - which is
|
||||
what ALREADY happens without this card, because the pack's own BMS refuses
|
||||
the command whatever the allocator sends. The failure is the config's, the
|
||||
remedy is the same, and this envelope does not make it worse; it only makes
|
||||
the refusal visible one layer earlier, in the allocation record.
|
||||
|
||||
⚠️ BOTH ENDS CANNOT CLOSE AT ONCE ON ANY CONFIGURATION THE SCHEMA ACCEPTS.
|
||||
It would take a pack reporting at least 100 % while sitting at or under its
|
||||
own cut-out, and `config.yaml` bounds `bms_soc_floor` at `int(0,50)` on both
|
||||
the flat key and every roster entry - so the two conditions cannot both hold.
|
||||
If they somehow did, the result is `[0, 0]`, which is an interval containing
|
||||
exactly one integer rather than a contradiction, and the allocator commands
|
||||
that unit 0 W. Nothing is special-cased for it here: inventing a second
|
||||
opinion about an unreachable state is how unreachable states get opinions
|
||||
that are wrong.
|
||||
"""
|
||||
hi, lo, why = inf, -inf, ""
|
||||
if (pack.soc is not None and pack.bms_floor is not None
|
||||
and discharge_permitted(pack.soc, pack.bms_floor) is not None):
|
||||
hi = 0.0
|
||||
why = (f"{pack.name} is at {float(pack.soc):g} %, at or under its own "
|
||||
f"{float(pack.bms_floor):g} % BMS cut-out - it is not asked to "
|
||||
f"discharge")
|
||||
if pack.soc is not None and float(pack.soc) >= FULL_PCT:
|
||||
lo = 0.0
|
||||
why = (f"{pack.name} is at {float(pack.soc):g} % and cannot take a "
|
||||
f"charge - it is not asked to")
|
||||
return PackEnvelope(lo=lo, hi=hi, why=why)
|
||||
|
||||
|
||||
def weights(packs, target_w) -> dict | None:
|
||||
"""Per-unit comfort weights for THIS tick's site target, or None for none.
|
||||
|
||||
The weight of a pack is the energy it has left IN THE DIRECTION THE SITE IS
|
||||
ABOUT TO TRAVEL - usable energy above its own cut-out when the site is
|
||||
discharging, room below full when it is charging. `allocator._fill()` uses
|
||||
only the RATIOS, so the unit is free and kWh is the honest one to leave it
|
||||
in: it is what a reader of the action log can check against the datasheet.
|
||||
|
||||
⚠️ THE DIRECTION IS AN ARGUMENT BECAUSE IT IS NOT KNOWABLE ANY EARLIER. The
|
||||
two directions give OPPOSITE answers on the same roster - A at 20 % of
|
||||
15.36 kWh and B at 80 % of 20.48 kWh weigh 1.5 : 14.3 to discharge and
|
||||
12.3 : 4.1 to charge - so a direction-free weighting is not a compromise
|
||||
between them, it is wrong in one of the two on every tick. The site target
|
||||
is chosen by `arbiter.site_resolve()`, i.e. by stage 2, so the earliest
|
||||
honest moment to ask this is between stage 2 and stage 3. That is why
|
||||
`resolve()` accepts a CALLABLE here and calls it with the resolved target,
|
||||
and why main.py hands it a method rather than a dict.
|
||||
|
||||
⚠️ `target_w >= 0` IS THE DISCHARGE SIDE, AND IT IS THE SITE TARGET'S SIGN
|
||||
RATHER THAN THE FILL'S. `allocator.plan()` decides its own direction from
|
||||
`need = remainder - placed`, which differs from `sign(target_w)` only when a
|
||||
PIN or an envelope that excludes zero has already moved the base - a
|
||||
maintenance drain, a failsafe park. On those ticks the weighting is computed
|
||||
for the other direction and is therefore merely a worse preference; it
|
||||
cannot be unsafe, for ARCH-01's reason, and it cannot break the sum, which
|
||||
is integer arithmetic in the allocator. Chasing the exact direction would
|
||||
mean handing this function into `plan()` and calling it from inside the
|
||||
fill, which would make the allocator's purity depend on a caller's callback.
|
||||
|
||||
None means "no opinion, weigh everything 1.0", and there are three ways to
|
||||
get it, all of which are today's behaviour rather than a new one:
|
||||
|
||||
* fewer than two packs. At N<=1 `plan()` is a pass-through and a weighting
|
||||
would be arithmetic nobody reads;
|
||||
* any pack that cannot state its headroom - see the module docstring on
|
||||
why a half-known roster is worse than an unweighted one;
|
||||
* every pack at zero headroom in this direction. All-zero weights would
|
||||
put the whole roster into `_fill()`'s LAST-RESORT pass, where the shares
|
||||
come out equal anyway and every `UnitPlan.why` reads "last resort: the
|
||||
preferred units were full" - a true sentence about the wrong thing.
|
||||
"""
|
||||
packs = list(packs)
|
||||
if len(packs) < 2:
|
||||
return None
|
||||
room = (usable_kwh if float(target_w) >= 0.0 else spare_kwh)
|
||||
out = {}
|
||||
for p in packs:
|
||||
kwh = room(p)
|
||||
if kwh is None or not isfinite(kwh):
|
||||
return None
|
||||
out[p.name] = float(kwh)
|
||||
if not any(v > 0.0 for v in out.values()):
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
def trade_power_w(packs, floor_pct, ceiling_pct) -> tuple:
|
||||
"""`(charge_w, discharge_w)` - how hard an arbitrage trade may be SIZED.
|
||||
|
||||
The site rating is what the site can push; this is what the site can push
|
||||
AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` was handed
|
||||
`site_max_w` on BOTH sides, so a two-pack site would ask to buy at the sum of
|
||||
the ratings whatever either pack's state of charge was - and the watts that
|
||||
could not be placed would come back as `unmet_w` for the site integrator to
|
||||
chase for the rest of the hour.
|
||||
|
||||
⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` /
|
||||
`arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, BECAUSE SIZING A
|
||||
TRADE IS THE TRADE'S BUSINESS. This answers "how much may this strategy ask
|
||||
for"; `envelope()` above answers "what may this pack be commanded at all",
|
||||
and that one is the pack's own physical cut-out for SAFETY-25's reason.
|
||||
Sizing with the physical floor would let the strategy ask for watts its own
|
||||
reserve forbids; bounding the envelope with the trade's reserve would let a
|
||||
trade preference stop the grid-following loop. TWO QUESTIONS, TWO NUMBERS.
|
||||
|
||||
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack carries the trade
|
||||
is the allocator's answer, and the only per-pack instrument it has for a
|
||||
TRADE reserve is the weights - which are a preference, not a permission. So
|
||||
a pack between its arbitrage floor and its BMS cut-out can still take part
|
||||
of a sell. That is stated rather than fixed: fixing it needs an envelope
|
||||
that depends on WHICH CLAIM WON the tick, which the arbiter's partition does
|
||||
not express, and it is a card of its own.
|
||||
|
||||
A pack whose SoC did not read is counted on NEITHER side: withholding is the
|
||||
direction that cannot over-size, and `inputs_ok` has stopped the site by
|
||||
then anyway.
|
||||
|
||||
⚠️ NEITHER SIDE MAY REACH `arbitrage.claim()` AS A ZERO - a 0 W arbitrage
|
||||
claim is a PARK ORDER that outranks the loop. It cannot be zero on a tick
|
||||
that trades today, because the sell gate refuses on the emptiest pack and
|
||||
the buy gate refuses when every pack is at the ceiling; `arbitrage.decide()`
|
||||
refuses a 0 W sizing by name anyway, so that is structural rather than a
|
||||
coincidence of the gates.
|
||||
"""
|
||||
return (float(sum(p.max_w for p in packs
|
||||
if p.soc is not None and float(p.soc) < ceiling_pct)),
|
||||
float(sum(p.max_w for p in packs
|
||||
if p.soc is not None and float(p.soc) > floor_pct)))
|
||||
@@ -0,0 +1,401 @@
|
||||
"""UX-02: reading and writing THIS add-on's own options, through the Supervisor.
|
||||
|
||||
WHAT THIS IS. An add-on may read and write its own options. That is not a
|
||||
guess: `supervisor/api/middleware/security.py` in
|
||||
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` - the version this
|
||||
site runs - matches an installed app's own token against
|
||||
|
||||
api_bypass = ^(?:|/addons/self/(?!security|update)[^/]+
|
||||
|/addons/self/options/config|/info|/services.*
|
||||
|/discovery.*|/auth)$
|
||||
|
||||
BEFORE it looks at `access_hassio_api` or at any role, so `GET
|
||||
/addons/self/info`, `POST /addons/self/options` and `POST /addons/self/restart`
|
||||
need no `hassio_role` at all. tools/supervisor_selfapi_probe.py RUNS those
|
||||
compiled patterns against these exact paths rather than re-reading the regex,
|
||||
because a bypass list is precisely the kind of thing that is read optimistically.
|
||||
|
||||
⚠️ AND THE PROBE FOUND ONE THE CARD DID NOT PREDICT, WHICH IS WHY IT WAS RUN.
|
||||
`/addons/self/options/validate` - Supervisor's OWN options validator - has TWO
|
||||
path segments after `self`, so `[^/]+` does not match it and it is not in the
|
||||
list of literals either. An add-on cannot call it. That is not a problem for
|
||||
this card, because the Supervisor's validator only knows the schema (types and
|
||||
ranges) and this panel's whole reason to exist is the CROSS-FIELD rules it
|
||||
structurally cannot express - but it does mean there is exactly one validator
|
||||
reachable from here, `app/validate.py`, which is the one we want.
|
||||
|
||||
--- THE THREE THINGS THIS MODULE REFUSES TO DO ------------------------------
|
||||
|
||||
1. ⚠️ IT NEVER WRITES A BLOB THE SHIPPED VALIDATOR REFUSED. Not "warns and
|
||||
writes"; does not write. The reason is specific to an INGRESS panel and it
|
||||
is the failure mode that makes this card dangerous: an invalid blob plus a
|
||||
restart is exit 78, and an add-on that is not running is not serving the
|
||||
panel either. The operator would be left with no way back except the
|
||||
Supervisor UI - the surface this card exists because it is hard to read.
|
||||
So the refusal happens here, before the POST, and the edits stay on screen.
|
||||
|
||||
2. ⚠️ IT NEVER OVERWRITES A BLOB THAT MOVED UNDERNEATH IT. The Supervisor's
|
||||
Configuration tab CANNOT be removed (see DECISIONS/UX-02-two-config-
|
||||
surfaces.md), so there are two editors of one blob, and `POST
|
||||
/addons/self/options` REPLACES the stored user options - measured, not
|
||||
assumed: `App.options`'s setter is `self.persist[ATTR_OPTIONS] = deepcopy(
|
||||
value)`, and the effective options are `deepmerge(config defaults, persist)`.
|
||||
A key omitted from the POST therefore falls back to config.yaml's DEFAULT
|
||||
rather than to its previous value.
|
||||
|
||||
This is the same shape of hazard as two writers on one actuator, and it gets
|
||||
the same discipline. `Controller.write_refusal()` (SAFETY-28) says: A NUMBER
|
||||
REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR PLACED IT, ON THIS TICK, FOR
|
||||
THIS UNIT. Here: A BLOB REACHES THE SUPERVISOR ONLY IF IT WAS DERIVED FROM
|
||||
THE BLOB THAT IS STORED RIGHT NOW. The read hands out a `fingerprint()`, the
|
||||
save hands it back, and the save re-reads and compares before writing. A
|
||||
change made in the other tab in the meantime is a REFUSAL with the changed
|
||||
keys named, not a silent clobber.
|
||||
|
||||
⚠️ THE COMPARISON IS RE-READ IMMEDIATELY BEFORE THE WRITE, not taken from
|
||||
the page load, so the window in which the other surface can slip in is one
|
||||
HTTP round trip rather than however long the operator spent typing.
|
||||
|
||||
3. ⚠️ IT NEVER RESTARTS AS A SIDE EFFECT OF SAVING. Applying options costs a
|
||||
restart, and `Controller.shutdown()` commands 0 W on every inverter on the
|
||||
way out (main.py, "shutting down - commanding 0 W on every inverter"). That
|
||||
is a real, if brief, loss of control of the battery, so it is its own
|
||||
deliberate action with its own confirmation - `restart()` below requires an
|
||||
explicit token in the request body, so no stray POST can spend it.
|
||||
|
||||
--- WHAT THIS MODULE MAY NOT GROW INTO --------------------------------------
|
||||
|
||||
⚠️ NOTHING HERE COMMANDS AN INVERTER, CLEARS A LATCH, OR REACHES A SAFETY
|
||||
MECHANISM. The panel's configuration half talks to the Supervisor about a JSON
|
||||
blob and to `validate.py` about whether that blob is startable; the only
|
||||
actuator-shaped verb in the file is `restart()`, which restarts THIS ADD-ON and
|
||||
whose 0 W is the existing, documented wind-down. Adding "and also set the
|
||||
setpoint while we are here" would put a second writer on the actuator through
|
||||
the config surface, which is the non-goal this add-on is built around.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .configui import changed_keys
|
||||
from .inverter import build_roster
|
||||
from .validate import validate_startup_config, validate_unit_ranges
|
||||
|
||||
_LOG = logging.getLogger("goodwe.selfconfig")
|
||||
|
||||
SUPERVISOR_API = "http://supervisor"
|
||||
SELF_INFO = f"{SUPERVISOR_API}/addons/self/info"
|
||||
SELF_OPTIONS = f"{SUPERVISOR_API}/addons/self/options"
|
||||
SELF_RESTART = f"{SUPERVISOR_API}/addons/self/restart"
|
||||
|
||||
# The word the page has to send with a restart request. It is not a password -
|
||||
# ingress means Home Assistant has already authenticated the caller - it is an
|
||||
# INTENT token, so that a reload, a retry or a stray fetch cannot spend a
|
||||
# control interruption on the operator's behalf.
|
||||
RESTART_CONFIRM = "restart"
|
||||
|
||||
# ⚠️ LONGER THAN hass.py's 10 s, AND FOR THE OPPOSITE REASON. `hass.py` would
|
||||
# rather abandon a write and re-issue it on the next tick than hold the control
|
||||
# loop. Nothing here is on the control loop - these calls are made by an
|
||||
# operator standing at a screen - and a Supervisor busy with a backup can take
|
||||
# a while to answer. Abandoning a `POST /addons/self/options` early would leave
|
||||
# the panel unsure whether the blob was stored, which is the one state this
|
||||
# module must never be in.
|
||||
TIMEOUT_S = 30.0
|
||||
|
||||
|
||||
class SupervisorUnavailable(Exception):
|
||||
"""The Supervisor could not be reached, or answered with an error."""
|
||||
|
||||
|
||||
class NotConfirmed(Exception):
|
||||
"""A control interruption was asked for without the intent token.
|
||||
|
||||
Its own exception rather than a `SaveRefused`, because it is not a refusal
|
||||
ABOUT a configuration - nothing was validated and nothing was compared. It
|
||||
means the request did not say what it was asking for.
|
||||
"""
|
||||
|
||||
|
||||
class SaveRefused(Exception):
|
||||
"""The save did not happen, and NOTHING WAS WRITTEN.
|
||||
|
||||
`kind` is `"invalid"` (the shipped validator refused the blob) or `"stale"`
|
||||
(the stored blob changed underneath). `problems` carries the validator's
|
||||
own sentences, byte for byte; `changed` names the keys that moved.
|
||||
"""
|
||||
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None):
|
||||
self.kind = kind
|
||||
self.problems = list(problems)
|
||||
self.changed = list(changed)
|
||||
self.stored = stored
|
||||
super().__init__(f"save refused ({kind})")
|
||||
|
||||
|
||||
def canonical(options) -> str:
|
||||
"""The one textual form of an options blob, for identity only.
|
||||
|
||||
Sorted keys, no insignificant whitespace, `ensure_ascii=False` so an entity
|
||||
id with a non-ASCII character is one character rather than an escape - the
|
||||
string is hashed, never displayed, so the only property that matters is
|
||||
that equal blobs produce equal text and different blobs do not.
|
||||
"""
|
||||
return json.dumps(options, sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def fingerprint(options) -> str:
|
||||
"""The compare-and-swap token for one options blob.
|
||||
|
||||
⚠️ A DIGEST, NOT A VERSION NUMBER, because there is no version to read. The
|
||||
Supervisor exposes no revision or ETag for an add-on's options, so the only
|
||||
thing the panel can hold onto between reading and writing is the CONTENT it
|
||||
read. That is enough for what this is for: the question is not "how many
|
||||
times has this changed" but "is what I am about to overwrite still what I
|
||||
was shown". Identical content is by definition safe to overwrite.
|
||||
"""
|
||||
return hashlib.sha256(canonical(options).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class SelfConfig:
|
||||
"""The add-on's own options, read and written through the Supervisor."""
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
|
||||
self.session = session
|
||||
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict:
|
||||
return {"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"}
|
||||
|
||||
async def _call(self, method, url, payload=None):
|
||||
try:
|
||||
async with self.session.request(
|
||||
method, url, headers=self._headers, json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=TIMEOUT_S),
|
||||
) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status >= 400:
|
||||
# The Supervisor's own message is carried through unedited:
|
||||
# `{"result": "error", "message": "..."}`. A rewritten
|
||||
# error is a second opinion about somebody else's failure.
|
||||
try:
|
||||
detail = (json.loads(text) or {}).get("message") or text
|
||||
except ValueError:
|
||||
detail = text
|
||||
raise SupervisorUnavailable(
|
||||
f"{method} {url} -> HTTP {resp.status}: "
|
||||
f"{str(detail)[:400]}")
|
||||
try:
|
||||
body = json.loads(text) if text else {}
|
||||
except ValueError as err:
|
||||
raise SupervisorUnavailable(
|
||||
f"{method} {url} -> unreadable answer: {err}") from None
|
||||
return body.get("data", body)
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
|
||||
raise SupervisorUnavailable(f"{method} {url} -> {err}") from None
|
||||
|
||||
async def info(self) -> dict:
|
||||
"""`GET /addons/self/info`.
|
||||
|
||||
⚠️ THREE OF THIS PANEL'S FOUR INGREDIENTS COME OUT OF THIS ONE CALL, and
|
||||
that is the reason the panel owns no copy of any of them. Measured on
|
||||
2026.08.0 (`supervisor/api/apps.py:info_data`):
|
||||
|
||||
`options` - `App.options`, i.e. deepmerge(config.yaml defaults,
|
||||
the stored user options). Every key is present, which
|
||||
is what makes a whole-blob read-modify-write safe.
|
||||
`schema` - `App.schema_ui`, i.e. `UiOptions(...)(config.yaml
|
||||
["schema"])`. The field list, types, ranges and
|
||||
enums, in config.yaml's own order.
|
||||
`translations` - `App.translations`, i.e. Supervisor's own
|
||||
`_read_app_translations()` over translations/*.yaml,
|
||||
which is UX-01's work reaching a second surface for
|
||||
free.
|
||||
|
||||
The fourth ingredient is `app/validate.py`, and it is not fetched.
|
||||
"""
|
||||
return await self._call("GET", SELF_INFO)
|
||||
|
||||
async def write_options(self, options: dict) -> None:
|
||||
"""`POST /addons/self/options` - REPLACES the stored user options.
|
||||
|
||||
⚠️ CALL THIS ONLY THROUGH `save()`. On its own it is the unguarded
|
||||
write: no validation, no compare-and-swap. It is a separate method so
|
||||
that the gate above it can be read - and tested - as a gate.
|
||||
"""
|
||||
await self._call("POST", SELF_OPTIONS, {"options": options})
|
||||
|
||||
async def restart(self, confirm: str) -> None:
|
||||
"""`POST /addons/self/restart`. Costs a control interruption.
|
||||
|
||||
The add-on's own wind-down runs first: `Controller.shutdown()` commands
|
||||
0 W on every inverter before the process exits. So this is not an
|
||||
abrupt loss of control, it is a deliberate one - but it IS one, and the
|
||||
page says so before the operator presses it.
|
||||
"""
|
||||
if confirm != RESTART_CONFIRM:
|
||||
raise NotConfirmed(
|
||||
"a restart interrupts control and must be confirmed")
|
||||
_LOG.warning("operator asked for a restart from the ingress panel - "
|
||||
"the wind-down commands 0 W on every inverter first")
|
||||
await self._call("POST", SELF_RESTART)
|
||||
|
||||
|
||||
async def problems_for(opts: dict, hass=None) -> list:
|
||||
"""Every reason the SHIPPED validator would refuse to start on `opts`.
|
||||
|
||||
⚠️ THIS IS THE STARTUP GATE'S OWN COMPOSITION, NOT A SUBSET OF IT. The
|
||||
gate runs in TWO places in main.py and this is the union of both, which is
|
||||
what an operator meets:
|
||||
|
||||
* `amain()` calls `validate_startup_config(opts, dev_min, dev_max)`
|
||||
UNCONDITIONALLY, before anything starts - so this does too, with the
|
||||
flat `setpoint_entity`'s device range when that entity was readable and
|
||||
None when it was not;
|
||||
* `run_control()` adds `validate_unit_ranges(roster, skip_entity=flat)`
|
||||
once every unit's own ESPHome has answered - so this does too, with the
|
||||
same `skip_entity`, so one inverter is not reported twice under two
|
||||
wordings.
|
||||
|
||||
A panel that ran only half the gate would go green on a config that then
|
||||
exits 78 - which is worse than no panel, because the operator would have
|
||||
been told it was fine.
|
||||
|
||||
⚠️ AND THE DEVICE RANGES ARE READ, NOT INVENTED. `hass.limits()` is a READ
|
||||
of a Home Assistant state; nothing here writes anything to any device. When
|
||||
a device does not answer - ESPHome asleep, an entity that does not exist
|
||||
yet, or `hass=None` in a check - the range is None and the range rules are
|
||||
SKIPPED, exactly as they are at startup. That is the honest behaviour and it
|
||||
is also the conservative one: the panel then shows fewer refusals than the
|
||||
startup gate might, never more, so it cannot bless a config the gate refuses
|
||||
on a rating it could not see. It is stated on the page rather than hidden.
|
||||
|
||||
`build_roster(opts, None)` builds the roster with NO Store, so this reads
|
||||
no latch and writes no state; it exists to give `validate_unit_ranges()`
|
||||
the same objects `main.py` gives it, rather than a second transcription of
|
||||
what counts as a roster entry.
|
||||
"""
|
||||
problems, _unread = await problems_and_ranges(opts, hass)
|
||||
return problems
|
||||
|
||||
|
||||
async def problems_and_ranges(opts: dict, hass=None):
|
||||
"""`problems_for()`, plus WHICH setpoint entities did not report a range.
|
||||
|
||||
⚠️ THE SECOND HALF EXISTS SO THE PAGE CANNOT LOOK GREEN FOR A REASON THE
|
||||
OPERATOR CANNOT SEE. Two rules compare a configured ceiling against the
|
||||
device's own rating, and when the device is silent they are skipped rather
|
||||
than guessed at - so an unread range means the panel is showing FEWER
|
||||
refusals than the startup gate might, and it has to say so, naming the
|
||||
entity that did not answer rather than hinting at one.
|
||||
|
||||
It is a separate function from `problems_for()` because the answer is about
|
||||
the CONFIGURATION UNDER TEST, not about the running roster: an operator who
|
||||
has just typed a new `setpoint_entity` must be told about THAT entity, and
|
||||
a route that asked the running controller instead would report on the old
|
||||
one. That was the first shape of this and it disagreed with itself.
|
||||
"""
|
||||
flat = str(opts.get("setpoint_entity", "") or "").strip()
|
||||
|
||||
# ⚠️ THE ROSTER IS BUILT BEFORE THE VALIDATOR RUNS, AND IT PARSES NUMBERS.
|
||||
# `build_roster` reaches `inverter.py`'s `float(opts.get("max_w", 2000))`,
|
||||
# which raises on a value the validator has a SENTENCE for. Cleared the
|
||||
# `max_w` box and the panel answered HTTP 500 - not JSON, so the page said
|
||||
# "cannot reach the add-on", which was untrue - while `validate.py`'s
|
||||
# "max_w ... is not a number" was unreachable through the panel and reachable
|
||||
# at startup, where `main.py` validates before it composes. That made this
|
||||
# surface WEAKER than the startup gate for one field, which is the opposite
|
||||
# of what this module claims below.
|
||||
#
|
||||
# A malformed scalar therefore falls back to validating WITHOUT device
|
||||
# bounds. Those bounds come from the roster, and there is no roster; the
|
||||
# validator's own complaint about the unparseable value is the answer the
|
||||
# operator needs, and it is strictly more informative than a 500.
|
||||
try:
|
||||
units = build_roster(opts, None)
|
||||
except (TypeError, ValueError) as exc:
|
||||
# ⚠️ AND IT MUST NOT RETURN AN EMPTY LIST. An empty list means "no
|
||||
# problems", and the page enables Save on it. If the roster could not be
|
||||
# composed at all and the validator has nothing to say about why, that
|
||||
# disagreement is itself the finding - so it is reported rather than
|
||||
# swallowed. Returning `[]` here would turn a crash into a silent
|
||||
# acceptance, which is worse than the 500 this replaced.
|
||||
found = list(validate_startup_config(opts))
|
||||
if not found:
|
||||
found = [f"the configuration could not be composed ({type(exc).__name__}: "
|
||||
f"{exc}), and the validator did not name a reason. This is a "
|
||||
f"disagreement between the two and should be reported."]
|
||||
return found, []
|
||||
if hass is not None:
|
||||
async def _limits(unit):
|
||||
if unit.setpoint_entity:
|
||||
unit.dev_min, unit.dev_max, _ = await hass.limits(
|
||||
unit.setpoint_entity)
|
||||
await asyncio.gather(*(_limits(unit) for unit in units))
|
||||
|
||||
dev_min = dev_max = None
|
||||
for unit in units:
|
||||
if flat and unit.setpoint_entity.strip().lower() == flat.lower():
|
||||
dev_min, dev_max = unit.dev_min, unit.dev_max
|
||||
break
|
||||
problems = list(validate_startup_config(opts, dev_min, dev_max))
|
||||
problems += validate_unit_ranges(units, skip_entity=flat)
|
||||
unread = [unit.setpoint_entity for unit in units
|
||||
if unit.setpoint_entity and unit.dev_min is None
|
||||
and unit.dev_max is None]
|
||||
return problems, unread
|
||||
|
||||
|
||||
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
hass=None) -> dict:
|
||||
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
|
||||
|
||||
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
|
||||
with nothing written otherwise.
|
||||
|
||||
THE ORDER IS DELIBERATE AND BOTH HALVES ARE PRE-WRITE GATES:
|
||||
|
||||
1. VALIDATE FIRST. It is local, it costs nothing, and its refusal is
|
||||
about what is on the operator's screen - so it is the answer they can
|
||||
act on without knowing anything about the other surface.
|
||||
2. RE-READ AND COMPARE SECOND, immediately before the POST, so the
|
||||
window in which the Supervisor tab can slip a change past is one round
|
||||
trip rather than the length of an editing session.
|
||||
|
||||
3. Only then write.
|
||||
|
||||
⚠️ `expect` IS REQUIRED AND THERE IS NO "FORCE" DOOR. A caller that has not
|
||||
read the current blob has nothing to compare, and a save that skips the
|
||||
comparison is the clobber this gate exists to prevent. If the operator
|
||||
genuinely wants what the other surface wrote replaced, the page reloads,
|
||||
shows them what changed, and they save again from THAT - which is a
|
||||
decision made in front of the difference rather than through it.
|
||||
"""
|
||||
problems = await problems_for(proposed, hass)
|
||||
if problems:
|
||||
_LOG.warning("panel REFUSED to save: %d configuration problem(s); "
|
||||
"nothing written. First: %s", len(problems), problems[0])
|
||||
raise SaveRefused("invalid", problems=problems)
|
||||
|
||||
info = await selfcfg.info()
|
||||
stored = info.get("options") or {}
|
||||
actual = fingerprint(stored)
|
||||
if actual != expect:
|
||||
changed = changed_keys(stored, proposed)
|
||||
_LOG.warning("panel REFUSED to save: the stored options changed "
|
||||
"underneath this page (keys: %s); nothing written",
|
||||
", ".join(changed) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored)
|
||||
|
||||
await selfcfg.write_options(proposed)
|
||||
_LOG.info("options saved from the ingress panel; a restart is needed "
|
||||
"before they take effect")
|
||||
return {"fingerprint": fingerprint(proposed)}
|
||||
@@ -570,7 +570,7 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
rules up. A rule the Supervisor duplicates costs nothing and covers the
|
||||
callers it does not.
|
||||
|
||||
Five things are refused, and each is a config that would otherwise RUN:
|
||||
Seven things are refused, and each is a config that would otherwise RUN:
|
||||
|
||||
a. `inverters` is not a list. `roster_entries()` wraps a scalar so this
|
||||
can name it; without the rule the roster would be one inverter built
|
||||
@@ -593,6 +593,18 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
config. Refused rather than ignored, for §1.5's reason and for this
|
||||
card's: a config that silently becomes a different config is worse than
|
||||
one that refuses to load.
|
||||
f. OPT-09. An entry whose `capacity_kwh` is stated but is not a usable
|
||||
positive number of kWh. It is read to weight the SPLIT by energy
|
||||
headroom; a zero, a negative or a word is a pack the split would
|
||||
quietly stop preferring while the operator can see a value in the box.
|
||||
g. OPT-09. `capacity_kwh` stated on SOME entries and not others. The
|
||||
weighting is a COMPARISON between packs, so a pack measured in kWh
|
||||
against a sibling weighing a dimensionless 1.0 is not one -
|
||||
`packshare.weights()` answers "no opinion" for the WHOLE roster in that
|
||||
case, which means every key the operator filled in does nothing at all
|
||||
and nothing says so. All or none, and this is clause (e)'s argument
|
||||
restated: a config that silently becomes a different config is worse
|
||||
than one that refuses to load.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
raw = opts.get("inverters")
|
||||
@@ -651,6 +663,42 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
"sibling is exactly what DEV-02 removed. State this unit's own "
|
||||
"rating in watts.")
|
||||
|
||||
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
|
||||
stated, blank = [], []
|
||||
for i, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
continue # already refused above, by name
|
||||
raw = entry.get("capacity_kwh")
|
||||
nm = str(entry.get("name") or "").strip()
|
||||
who = f"inverters[{i}] ({nm})" if nm else f"inverters[{i}]"
|
||||
if raw is None or raw == "":
|
||||
blank.append(who)
|
||||
continue
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
val = None
|
||||
if val is None or not (val > 0.0):
|
||||
problems.append(
|
||||
f"{who} has capacity_kwh {raw!r}, which is not a usable number "
|
||||
"of kilowatt-hours. It is THIS pack's usable capacity and it is "
|
||||
"read to weight the split of the site target by ENERGY headroom "
|
||||
"rather than by power rating; a zero or an unparseable value is "
|
||||
"a pack the split would quietly stop preferring while the box "
|
||||
"still shows a value. State it in kWh, or leave it empty on "
|
||||
"every entry.")
|
||||
else:
|
||||
stated.append(who)
|
||||
if stated and blank:
|
||||
problems.append(
|
||||
f"capacity_kwh is stated on {', '.join(stated)} and left empty on "
|
||||
f"{', '.join(blank)}. It is OPTIONAL but it is not PARTIAL: the "
|
||||
"split weights one pack's energy headroom against another's, so a "
|
||||
"pack measured in kWh beside a sibling that weighs a dimensionless "
|
||||
"1.0 is not a comparison - the add-on answers 'no opinion' for the "
|
||||
"whole roster and the value you filled in changes nothing. State a "
|
||||
"capacity for every inverter, or for none of them.")
|
||||
|
||||
flat_sp = str(opts.get("setpoint_entity") or "").strip()
|
||||
if entries and flat_sp:
|
||||
problems.append(
|
||||
|
||||
@@ -8,12 +8,33 @@ perfectly on a developer's laptop.
|
||||
Design bias: this page must be readable at the end of a long day, on a phone, in
|
||||
a cellar, by someone who did not write it. Numbers alone are not enough - it
|
||||
says what is wrong in words.
|
||||
|
||||
⚠️ SINCE UX-02 THERE ARE TWO PAGES HERE, AND ONLY ONE OF THEM IS THIS FILE'S.
|
||||
`/` is the status and control surface and is unchanged. `/config` is the
|
||||
configuration surface: its markup lives in app/config_page.py, its field model
|
||||
in app/configui.py and its Supervisor conversation in app/selfconfig.py. What
|
||||
stays HERE is the routing, so that the question a reviewer has to answer - does
|
||||
anything the panel exposes reach the inverter? - is answered by reading one
|
||||
short file.
|
||||
|
||||
⚠️ THE ANSWER TO THAT QUESTION, STATED SO IT CAN BE CHECKED: the configuration
|
||||
routes below read `GET /addons/self/info`, run the SHIPPED validator, write
|
||||
`POST /addons/self/options` and, on an explicitly confirmed request, `POST
|
||||
/addons/self/restart`. NONE of them writes a setpoint, clears a latch, changes a
|
||||
mode or reaches `controller.handle_action()`. The set of things an operator can
|
||||
do to the inverter through this panel is exactly what `/action` offered before
|
||||
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
|
||||
actions and comparing them with the shipped set.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import selfconfig
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import changed_keys, field_model, problems_by_field
|
||||
|
||||
_LOG = logging.getLogger("goodwe.web")
|
||||
|
||||
PAGE = """<!doctype html>
|
||||
@@ -49,6 +70,7 @@ PAGE = """<!doctype html>
|
||||
.muted{color:var(--muted);font-size:13px}
|
||||
</style></head><body>
|
||||
<h1>GoodWe RS485 Controller</h1>
|
||||
<p class="muted"><a href="config">Configuration →</a></p>
|
||||
<div id="banner" class="banner b-warn">loading…</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -95,7 +117,33 @@ refresh(); setInterval(refresh, 3000);
|
||||
"""
|
||||
|
||||
|
||||
def build_app(controller) -> web.Application:
|
||||
# ⚠️ WHAT THE PANEL SAYS WHEN IT COULD NOT READ A DEVICE RANGE. Two of
|
||||
# validate.py's rules compare a configured ceiling against the setpoint
|
||||
# device's OWN reported range, and that range is only knowable by asking Home
|
||||
# Assistant. When ESPHome is asleep or the entity does not exist yet the
|
||||
# startup gate SKIPS those two rules rather than guessing - so the panel skips
|
||||
# them too, and says so, because a green page that is green for a reason the
|
||||
# operator cannot see is the exact failure this add-on keeps refusing to ship.
|
||||
DEVICE_NOTE = (
|
||||
"%s did not report a range, so the two checks that compare a configured "
|
||||
"ceiling against what the device itself accepts were skipped - here and at "
|
||||
"startup, identically. Everything else was checked. If the device answers "
|
||||
"later and disagrees, the add-on refuses to start then, with the same "
|
||||
"message you would have seen here.")
|
||||
|
||||
|
||||
def build_app(controller, selfcfg=None) -> web.Application:
|
||||
"""The ingress app. `selfcfg` is injectable so a check can drive it.
|
||||
|
||||
⚠️ IT IS BUILT FROM THE CONTROLLER'S OWN SESSION AND TOKEN, not from a
|
||||
second `ClientSession`. `amain()` owns one session for the process and every
|
||||
Home Assistant call already goes through it; a second one here would be a
|
||||
second connection pool whose failures nothing reports.
|
||||
"""
|
||||
if selfcfg is None:
|
||||
selfcfg = selfconfig.SelfConfig(controller.hass.session,
|
||||
controller.hass.token)
|
||||
|
||||
async def index(_request):
|
||||
return web.Response(text=PAGE, content_type="text/html")
|
||||
|
||||
@@ -113,17 +161,152 @@ def build_app(controller) -> web.Application:
|
||||
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
|
||||
return web.json_response({"ok": True, "result": result})
|
||||
|
||||
# --- UX-02: the configuration surface ---------------------------------
|
||||
async def config_index(_request):
|
||||
return web.Response(text=CONFIG_PAGE, content_type="text/html")
|
||||
|
||||
async def _proposed(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
options = body.get("options")
|
||||
if not isinstance(options, dict):
|
||||
raise web.HTTPBadRequest(text="expected an options object")
|
||||
return body, options
|
||||
|
||||
async def config_model(request):
|
||||
"""Everything the page needs to draw itself, from ONE Supervisor call.
|
||||
|
||||
⚠️ `running_differs` IS NOT COSMETIC. `controller.o` is the blob this
|
||||
PROCESS started with (`/data/options.json`); `info["options"]` is what
|
||||
the Supervisor has STORED and would apply at the next start. They differ
|
||||
exactly when somebody has saved and not restarted - here, or in the
|
||||
Configuration tab. Printing the difference is the only way an operator
|
||||
finds out that the page they are reading is not describing the running
|
||||
system, and it is also how an edit made on the other surface becomes
|
||||
visible on this one.
|
||||
"""
|
||||
try:
|
||||
info = await selfcfg.info()
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("cannot read the add-on's own options: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
options = info.get("options") or {}
|
||||
lang = str(request.query.get("lang", "en") or "en")[:8]
|
||||
model = field_model(info.get("schema") or [],
|
||||
info.get("translations") or {}, lang)
|
||||
model.update({
|
||||
"options": options,
|
||||
"fingerprint": selfconfig.fingerprint(options),
|
||||
"running": controller.o,
|
||||
"running_differs": changed_keys(controller.o, options),
|
||||
"lang": lang,
|
||||
})
|
||||
return web.json_response(model)
|
||||
|
||||
async def config_validate(request):
|
||||
"""The SHIPPED validator, run against a proposed blob. Writes nothing.
|
||||
|
||||
⚠️ IT READS HOME ASSISTANT AND WRITES NOTHING ANYWHERE. The two
|
||||
device-range rules need `hass.limits()`, which is a GET of an entity's
|
||||
state. The page debounces, so a burst of typing is one call after the
|
||||
typing stops rather than one per keystroke.
|
||||
|
||||
⚠️ THE STRINGS ARE PASSED THROUGH UNTOUCHED. Not summarised, not
|
||||
renumbered, not mapped to a severity. validate.py's sentences are the
|
||||
product this card is delivering; anything this layer did to them would
|
||||
be a second opinion about a rule it does not own.
|
||||
"""
|
||||
_body, options = await _proposed(request)
|
||||
problems, unread = await selfconfig.problems_and_ranges(
|
||||
options, controller.hass)
|
||||
by_key, unanchored = problems_by_field(problems, _keys_of(options))
|
||||
payload = {"problems": problems, "by_key": by_key,
|
||||
"unanchored": unanchored}
|
||||
if unread:
|
||||
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
|
||||
return web.json_response(payload)
|
||||
|
||||
def _keys_of(options):
|
||||
# ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is
|
||||
# sound only because the page posts the WHOLE blob it read - which is
|
||||
# `App.options`, the merge of config.yaml's defaults with the stored
|
||||
# ones, so every option key is in it. Nothing here types a key name;
|
||||
# see configui.problems_by_field() for what is done with them.
|
||||
return sorted(options)
|
||||
|
||||
async def config_save(request):
|
||||
"""Validate, compare-and-swap, write. Or refuse and write NOTHING."""
|
||||
body, options = await _proposed(request)
|
||||
expect = str(body.get("fingerprint") or "")
|
||||
try:
|
||||
result = await selfconfig.save(selfcfg, options, expect,
|
||||
hass=controller.hass)
|
||||
except selfconfig.SaveRefused as refused:
|
||||
if refused.kind == "stale":
|
||||
stored = refused.stored or {}
|
||||
return web.json_response(
|
||||
{"kind": "stale", "changed": refused.changed,
|
||||
"stored": stored,
|
||||
# The page needs the CURRENT token to be able to save
|
||||
# again after the operator has read the difference. That
|
||||
# is not a force door: it takes a second, deliberate press
|
||||
# made in front of what changed.
|
||||
"fingerprint": selfconfig.fingerprint(stored)},
|
||||
status=409)
|
||||
by_key, unanchored = problems_by_field(refused.problems,
|
||||
_keys_of(options))
|
||||
return web.json_response(
|
||||
{"kind": "invalid", "problems": refused.problems,
|
||||
"by_key": by_key, "unanchored": unanchored}, status=422)
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("saving options failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
controller.log_event("configuration saved from the panel - "
|
||||
"not applied until the add-on restarts")
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
async def config_restart(request):
|
||||
"""Restart the add-on, at the operator's explicit request.
|
||||
|
||||
⚠️ THIS IS THE ONE ROUTE ON THIS SURFACE THAT COSTS ANYTHING. The
|
||||
wind-down commands 0 W on every inverter (`Controller.shutdown()`), so
|
||||
the request must carry the intent token; a reload, a retry or a stray
|
||||
POST cannot spend a control interruption. It restarts THIS ADD-ON and
|
||||
nothing else: no setpoint is written from here, and a latched failsafe
|
||||
is restored from the Store on the way back up rather than cleared.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
try:
|
||||
await selfcfg.restart(str(body.get("confirm") or ""))
|
||||
except selfconfig.NotConfirmed as err:
|
||||
return web.json_response({"error": str(err)}, status=400)
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("restart request failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
controller.log_event("restart requested from the configuration panel")
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes([
|
||||
web.get("/", index),
|
||||
web.get("/status", status),
|
||||
web.post("/action", action),
|
||||
web.get("/config", config_index),
|
||||
web.get("/config/model", config_model),
|
||||
web.post("/config/validate", config_validate),
|
||||
web.post("/config/save", config_save),
|
||||
web.post("/config/restart", config_restart),
|
||||
])
|
||||
return app
|
||||
|
||||
|
||||
async def start(controller, port: int = 8099):
|
||||
runner = web.AppRunner(build_app(controller))
|
||||
async def start(controller, port: int = 8099, selfcfg=None):
|
||||
runner = web.AppRunner(build_app(controller, selfcfg))
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||
await site.start()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.7.0"
|
||||
version: "0.9.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -108,6 +108,25 @@ options:
|
||||
# EVERY entry and names the one it refuses. The flat key below is unchanged and
|
||||
# is the single-inverter shape's own cut-out.
|
||||
#
|
||||
# ⚠️ OPT-09: `capacity_kwh` IS PER ENTRY AND IS OPTIONAL, WHICH IS THE
|
||||
# OPPOSITE CHOICE TO `bms_soc_floor` ABOVE AND IS MADE FOR THE SAME REASON.
|
||||
# It is THAT pack's usable capacity in kWh, and it is read for exactly one
|
||||
# purpose: weighting the SPLIT of the site target between packs by ENERGY
|
||||
# headroom rather than by power rating. The two packs here are asymmetric in
|
||||
# OPPOSITE directions - 5000 W / 15.36 kWh against 4600 W / 20.48 kWh, from
|
||||
# the datasheet and the serials - so a split by rating and a split by energy
|
||||
# hand the work to DIFFERENT packs, and the energy one is the question ("which
|
||||
# pack can sustain its share for the hour"). A missing cut-out would have to be
|
||||
# DEFAULTED, and one default for two batteries protects the shallower pack with
|
||||
# the deeper pack's number; a missing capacity has an honest answer instead -
|
||||
# this add-on then has NO OPINION about the split and every unit weighs 1.0,
|
||||
# which is exactly what shipped before this card. ⚠️ IT IS ALL OR NOTHING
|
||||
# ACROSS THE ROSTER: state it on every entry or on none, because a pack
|
||||
# weighted in kWh against a sibling weighing a dimensionless 1.0 is not a
|
||||
# comparison. There is deliberately NO flat `capacity_kwh` - at one inverter
|
||||
# there is no split to weight, so the key would be one an operator can set and
|
||||
# nothing reads.
|
||||
#
|
||||
# ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a
|
||||
# roster is declared is two config sites aimed at actuators, one of which
|
||||
# nothing writes; if it names an entity an entry also names, SAFETY-28's
|
||||
@@ -511,6 +530,68 @@ options:
|
||||
circulation_w: 700
|
||||
circulation_hold_s: 75
|
||||
|
||||
# ⚠️ CFG-03. THIS LINE IS THE SOURCE OF TRUTH FOR THIS DEFAULT, and
|
||||
# `app/delivery.py`'s `DELIVERY_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_delivery.py pins the copy by READING this line, so a
|
||||
# drift goes red in CI rather than shipping. Two places - options:, schema: -
|
||||
# plus that one dict, and no fourth.
|
||||
#
|
||||
# ⚠️ THERE IS NO `delivery_w` TO GO WITH IT, AND THAT IS DELIBERATE. The watts
|
||||
# half of this check is `saturation_w`, the option two blocks down, read
|
||||
# directly: it is already the gap at which the control law itself concludes
|
||||
# the inverter is not following, and a second key would be a fourth place a
|
||||
# threshold has to be kept in step and a way for the two to disagree. So
|
||||
# RAISING `saturation_w` raises this check with it, and test_delivery.py part
|
||||
# 4b holds that promise by running the same fault at two different
|
||||
# `saturation_w` values and requiring two different answers - a check that
|
||||
# only ever ran at 500 could not tell this wiring from a hard-coded number.
|
||||
#
|
||||
# How long ONE inverter may stand at a discharge share it is not producing -
|
||||
# short by more than `saturation_w`, OR short by more than a fixed FRACTION
|
||||
# of its own share (SAFETY-43; the fraction is derived and is not an option,
|
||||
# see app/delivery.py) - while at least one sibling is NOT in that state,
|
||||
# before the add-on names it. It REPORTS: nothing is commanded
|
||||
# differently, the loop is untouched, and the site goes on running short by
|
||||
# that unit's share until somebody looks. app/delivery.py carries why, with
|
||||
# the rig measurements on both sides of the decision.
|
||||
#
|
||||
# Ignored at one inverter, where the check is inert by construction: with no
|
||||
# sibling, "this box cannot deliver" and "this plant cannot deliver" are the
|
||||
# same observation.
|
||||
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 2640 closed-loop runs
|
||||
# at a roster of two in which nothing was faulty, over 6 gains x 5 slews - the
|
||||
# whole range this schema accepts - across load reversals, a flapping load and
|
||||
# ASYMMETRIC SPLITS, plus a roster-of-ONE arm as the control. At the shipped
|
||||
# threshold the worst CONTINUOUS legitimate episode is 35.0 s (the weighted
|
||||
# family; 30.0 s on reversals, 0.0 s on flapping, and 0.0 s at every threshold
|
||||
# in every one-inverter run). 185 s is 5.29x that.
|
||||
#
|
||||
# ⚠️ AND THE CAMPAIGN IS RE-RUNNABLE NOW, WHICH IT WAS NOT WHEN THIS NUMBER
|
||||
# SHIPPED. TEST-21 (2026-09-07) committed the harness to the OTHER repository,
|
||||
# `admin/goodwe:sim/delivery_window.py` - it imports plantsim and frame_check,
|
||||
# which that repo owns. From clean checkouts of both, side by side:
|
||||
#
|
||||
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
# python3 -B delivery_window.py --selftest --campaign --blag
|
||||
#
|
||||
# ⚠️ THE ORIGINAL RECORD SAID 30.0 s AND IT DID NOT REPRODUCE - the re-run
|
||||
# says 35.0 s. 185 s still clears all four bounds and is unchanged; what
|
||||
# changed is the claim. DECISIONS/SAFETY-42-unit-delivery.md §3.3 carries both
|
||||
# tables and what did and did not reproduce.
|
||||
#
|
||||
# ⚠️ AND THE MARGIN IS A SENSITIVITY STUDY ON A HYPOTHESIS, NOT A MEASUREMENT,
|
||||
# BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). The rig's B is A
|
||||
# COPIED. Sweeping a DIFFERENT second unit through that campaign: the worst
|
||||
# legitimate episode barely moves with B's ramp (35.0 s at half A's ramp) and
|
||||
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 75.0 s at four
|
||||
# times it. 185 s is 2.47x even the four-times-slower case. NO NUMBER HERE IS
|
||||
# A MEASUREMENT OF INVERTER B.
|
||||
#
|
||||
# DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this is not it -
|
||||
# and not 150 or 200. 37 control cycles.
|
||||
delivery_hold_s: 185
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
@@ -575,6 +656,12 @@ schema:
|
||||
# enforced per entry, a missing required key names the key, a scalar where the
|
||||
# list belongs is refused, and the UI renders it as a repeatable section.
|
||||
#
|
||||
# ⚠️ AND OPT-09's `capacity_kwh` IS DECLARED OPTIONAL (`?`), UNLIKE
|
||||
# `bms_soc_floor`, WHICH IS THE ARGUMENT IN THE OPTIONS BLOCK ABOVE: an
|
||||
# optional cut-out is a defaulted one and a default is one number for two
|
||||
# batteries, whereas an absent capacity has an honest answer - no opinion
|
||||
# about the split, every unit weighs 1.0, which is what shipped.
|
||||
#
|
||||
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
|
||||
# no `name` has no Store namespace and would share another unit's SAFETY-02
|
||||
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
|
||||
@@ -596,6 +683,7 @@ schema:
|
||||
soc_entity: str?
|
||||
batt_entity: str?
|
||||
batt_invert: bool?
|
||||
capacity_kwh: float(0.1,200)?
|
||||
|
||||
estop_fitted: bool
|
||||
|
||||
@@ -772,6 +860,16 @@ schema:
|
||||
# loop must be caught and acted on well inside one.
|
||||
circulation_hold_s: int(30,900)
|
||||
|
||||
# SAFETY-42. The floor of 30 is `inverter.LINK_STALE_S`, for
|
||||
# `circulation_hold_s`'s reason and the same one SAFETY-30's per-unit window
|
||||
# takes it from: below the firmware's own veto window a unit not executing its
|
||||
# share is a LINK fault the add-on already answers by dropping it from the
|
||||
# split and saying so in the action log, and reporting inside that window
|
||||
# would be a second, louder alarm about a state already being handled. The
|
||||
# ceiling of 900 is the quarter that bills the capacity tariff - a site
|
||||
# running short of one unit's share must be reported well inside one.
|
||||
delivery_hold_s: int(30,900)
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
|
||||
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
|
||||
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
|
||||
|
||||
print()
|
||||
print("ARB-03 2. the split: eight sources, each classified where it is BUILT")
|
||||
print("ARB-03 2. the split: eleven sources, each classified where it is BUILT")
|
||||
# The classification is graded against what each claim is ABOUT, restated here
|
||||
# so that the table in arbiter.py and the expectation in a test are not the same
|
||||
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
|
||||
@@ -180,16 +180,25 @@ UNIT_SOURCES = {
|
||||
"measured power (0x0518), so what it learns is about one "
|
||||
"device. The site-wide half moved to `sign_site`",
|
||||
"maintenance": "the monthly recalibration is a pack procedure",
|
||||
# OPT-09, and it is the entry a reader will want to check against
|
||||
# SAFETY-25's deferred loop-side reserve, because it has the same shape.
|
||||
"pack": "THIS pack's own state of charge against THIS pack's own "
|
||||
"`bms_soc_floor` - a fact about one battery in one cupboard, "
|
||||
"exactly as `device`'s rating is a fact about one box. It is "
|
||||
"not SAFETY-25's deferred `loop_soc_floor` reserve: no option "
|
||||
"is added, `peak_soc_floor` is not consulted, the SITE target "
|
||||
"is untouched, and main.py does not file it at all at a "
|
||||
"roster of one",
|
||||
}
|
||||
for _src, _why in sorted(SITE_SOURCES.items()):
|
||||
print(f" site {_src:<11} {_why}")
|
||||
for _src, _why in sorted(UNIT_SOURCES.items()):
|
||||
print(f" unit {_src:<11} {_why}")
|
||||
check("all TEN claim sources are classified, and only those ten",
|
||||
check("all ELEVEN claim sources are classified, and only those eleven",
|
||||
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
|
||||
check("the six that are about the GRID CONNECTION are site claims",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
|
||||
check("the four that are about a PACK, a DEVICE or one write are per-inverter",
|
||||
check("the five that are about a PACK, a DEVICE or one write are per-inverter",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
|
||||
check("a source nobody classified cannot be routed at all - actuator_for() "
|
||||
"raises rather than defaulting to the site, because defaulting to the "
|
||||
@@ -207,7 +216,7 @@ check("with one inverter every source routes to the site, per-inverter ones "
|
||||
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
|
||||
check("...and with none configured at all, likewise",
|
||||
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
|
||||
check("with TWO the identity breaks and the split becomes real: the four "
|
||||
check("with TWO the identity breaks and the split becomes real: the five "
|
||||
"per-inverter sources route to the unit this controller drives, the six "
|
||||
"site sources stay on the grid connection",
|
||||
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
|
||||
|
||||
@@ -17,8 +17,9 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import sys
|
||||
|
||||
from app.control import (LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC,
|
||||
Tuning, compute, slew_limit_w,
|
||||
from app.control import (LOOP_TICK_S, PLANT_SETTLE_DOWN_S, PLANT_SETTLE_S,
|
||||
SETTLE_CYCLES, SLEW_RAIL_FRAC, Tuning, compute,
|
||||
slew_limit_w, unasked_power,
|
||||
maintenance_charge_floor, peak_at_risk)
|
||||
|
||||
# ⚠️ SAFETY-39: `slew_w` IS EXPLICIT HERE NOW, AND IT IS release/1.0's VALUE.
|
||||
@@ -59,6 +60,34 @@ 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 SAFETY-13 ROWS WERE RE-MEASURED IN ROUND 2 AND THREE OF THEM WERE
|
||||
# WRONG AS FIRST WRITTEN. Every figure below marked (S13) was taken by deleting
|
||||
# the mechanism, purging __pycache__, running this file in the CI image and
|
||||
# counting `FAIL` lines, on THIS tree; the release/1.0 comparison figures were
|
||||
# taken the same way on a pristine c8dcb38 worktree.
|
||||
#
|
||||
# - `output freeze` was written as 11. The mutation form its own note
|
||||
# describes - the whole `target = min(...)/max(...)` line removed, `reason`
|
||||
# left alone - gives 6 on this tree AND 6 on the first SAFETY-13 commit,
|
||||
# against 2 on release/1.0. 11 is not reproducible by that form, or by
|
||||
# removing the line and its `reason` (4), or by swapping min/max (12). The
|
||||
# row is 6.
|
||||
# - `saturation detector, saturated_now = False` was left at 11 while the
|
||||
# SAFETY-13 section added checks that grade it. It is 15.
|
||||
# - the footnote's `frozen = False` figure was left at 10 for the same
|
||||
# reason. It is 13.
|
||||
#
|
||||
# The rows below `output freeze` are this ticket's own mechanisms: removing the
|
||||
# EXIT restores release/1.0's unbounded freeze; making `unasked_power()` return
|
||||
# True gives an exit to the limit case as well, which is the half of the
|
||||
# decision that has to stay unbounded; deriving SETTLE_CYCLES from
|
||||
# PLANT_SETTLE_S (18 s, the UP ramp) instead of PLANT_SETTLE_DOWN_S (23 s) is
|
||||
# the round-1 defect itself; and pinning `_SAT_COUNT_CAP` back at release/1.0's
|
||||
# literal 10 caps the counter below the bound and deletes the exit silently.
|
||||
#
|
||||
# ⚠️ ONLY THE ROWS THIS BRANCH MOVES WERE RE-MEASURED. Everything not marked
|
||||
# (S13) still carries 389d9ec's figures and was not re-run here.
|
||||
#
|
||||
# ⚠️ 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
|
||||
@@ -75,10 +104,14 @@ def check(name, cond):
|
||||
# output clamp 3
|
||||
# slew limit 5
|
||||
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
|
||||
# output freeze 2
|
||||
# output freeze (S13) 6
|
||||
# the output freeze's EXIT (S13), freeze made unconditional again 4
|
||||
# `unasked_power()` discriminates, replaced by `return True` (S13) 6
|
||||
# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9
|
||||
# `_SAT_COUNT_CAP` pinned back at release/1.0's 10 (S13) 6
|
||||
# deadband 5
|
||||
# quantisation 2
|
||||
# saturation detector, `saturated_now = False` 11
|
||||
# saturation detector, `saturated_now = False` (S13) 15
|
||||
# saturation duration (AC 2), fires instantly 2
|
||||
# sat counter reset on a good cycle 6
|
||||
# target_grid_w bias 3
|
||||
@@ -86,7 +119,8 @@ def check(name, cond):
|
||||
#
|
||||
# The detector figure is for the `saturated_now = False` form specifically;
|
||||
# disabling it further down as `frozen = False` is a weaker mutation and gives
|
||||
# 10. Reproduce the same form or the number will not match.
|
||||
# 13 on this tree (10 on release/1.0). Reproduce the same form or the number
|
||||
# will not match.
|
||||
#
|
||||
# ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in
|
||||
# for the mechanism under test. Deleting the integrator freeze once failed
|
||||
@@ -282,8 +316,15 @@ check("frozen at i_w=0.0: an export push moves the integrator",
|
||||
# hundreds of watts means something else is driving the bus. Asserted so that
|
||||
# nobody "fixes" it by accident - the integrator moving is what this ticket
|
||||
# owns, the command rule belongs to the output freeze.
|
||||
#
|
||||
# ⚠️ SAFETY-13 KEPT THIS ASSERTION RATHER THAN INVERTING IT, and the fixture is
|
||||
# why it survived unchanged: `sat_count=3` becomes 4, which is below
|
||||
# SETTLE_CYCLES, so this is still inside the window in which the block holds.
|
||||
# SAFETY-13 gave the block an EXIT, not a different opinion about its first
|
||||
# cycles - see the SAFETY-13 section below, where the same state is driven past
|
||||
# the bound and the release is asserted on the other side of it.
|
||||
check("frozen at i_w=0.0: the output freeze still blocks a charge from 0 W",
|
||||
ze.target_w == 0.0)
|
||||
ze.target_w == 0.0 and ze.sat_count < SETTLE_CYCLES)
|
||||
# Where prev_w is already charging the output freeze does NOT block, and there
|
||||
# the difference reaches the wire: held at 0.0 the integrator abandons the
|
||||
# charge mid-export.
|
||||
@@ -358,6 +399,348 @@ check(f"after saturation ends the command is {tight:.0f} W (<= 1000)", tight <=
|
||||
check(f"headroom above max_w makes that worse ({loose:.0f} W) - hence the default",
|
||||
loose > tight)
|
||||
|
||||
print("SAFETY-13: the output freeze has an exit, and only where it needs one")
|
||||
|
||||
# THE SHAPE OF THIS SECTION. release/1.0's output freeze holds the command's
|
||||
# magnitude down while the inverter is not following, and nothing ends it. At
|
||||
# `prev_w == 0` the held magnitude is ZERO, so it reads as "refuses to start a
|
||||
# charge"; that is the case the card was written about, but it is one instance
|
||||
# of a rule that is unbounded at every prev_w. SAFETY-13 bounds it exactly where
|
||||
# it CANNOT end on its own - see `unasked_power()` - and nowhere else.
|
||||
|
||||
# --- 1. the predicate, which is the whole of the decision -------------------
|
||||
# A limit can only make the inverter do LESS than the command, in the command's
|
||||
# own direction, so everything a limit can produce lies in the interval spanned
|
||||
# by 0 and prev_w widened by saturation_w. These are the four cases in the
|
||||
# docstring, run rather than described.
|
||||
check("limit: 700 W under a +2000 W command is explainable",
|
||||
not unasked_power(2000.0, 700.0, 500.0))
|
||||
check("limit: -700 W under a -5000 W command is explainable",
|
||||
not unasked_power(-5000.0, -700.0, 500.0))
|
||||
check("unasked: +700 W under a 0 W command is not",
|
||||
unasked_power(0.0, 700.0, 500.0))
|
||||
check("unasked: +700 W under a -800 W command is not",
|
||||
unasked_power(-800.0, 700.0, 500.0))
|
||||
# The band edges belong to the limit side, because saturation_w is the same
|
||||
# tolerance the detector uses - a reading the detector calls "following" must
|
||||
# never be called "another writer".
|
||||
check("the saturation band is inclusive on both edges",
|
||||
not unasked_power(0.0, 500.0, 500.0)
|
||||
and not unasked_power(0.0, -500.0, 500.0)
|
||||
and unasked_power(0.0, 500.01, 500.0))
|
||||
# ⚠️ AT prev_w == 0 THE PREDICATE AND THE DETECTOR ARE THE SAME TEST, which is
|
||||
# why the zero case is the one that strands: with nothing commanded there is no
|
||||
# interval for a limit to live in.
|
||||
same = all(unasked_power(0.0, a, 500.0) == (abs(a) > 500.0)
|
||||
for a in [x * 5.0 for x in range(-400, 401)])
|
||||
check("at prev_w == 0 unasked_power IS the saturation test, over 801 readings",
|
||||
same)
|
||||
|
||||
# --- 2. the bound is derived, and from WHICH HALF of the measurement --------
|
||||
# ⚠️ THE DIRECTION IS THE ASSERTION. goodwe/NOTES.md 5.10 measured the same
|
||||
# 1.78 kW step both ways: ~18 s up, ~23 s down. SAFETY-39 takes the SHORTER
|
||||
# one because its bound is a REFUSAL and the looser number refuses less. This
|
||||
# bound asks the opposite question - how long the plant may still be the
|
||||
# explanation for a divergence - and for that the conservative half is the
|
||||
# LONGER one. The first version of this file derived it from 18, got 9, and
|
||||
# that is three cycles short of the plant's own measured down-ramp, in the
|
||||
# direction that ACCUSES rather than refuses (section 8 below drives it).
|
||||
check(f"SETTLE_CYCLES is ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) ="
|
||||
f" {SETTLE_CYCLES}",
|
||||
SETTLE_CYCLES == 12
|
||||
and SETTLE_CYCLES * LOOP_TICK_S >= PLANT_SETTLE_DOWN_S
|
||||
and (SETTLE_CYCLES - 1) * LOOP_TICK_S < PLANT_SETTLE_DOWN_S)
|
||||
check("...from the LONGER of the two measured settle times, so it is strictly"
|
||||
" above what the shorter one would have given",
|
||||
PLANT_SETTLE_DOWN_S > PLANT_SETTLE_S
|
||||
and SETTLE_CYCLES > -(-PLANT_SETTLE_S // LOOP_TICK_S))
|
||||
# ...and it is NOT SLEW_RAIL_FRAC's reciprocal any more. It was, while both
|
||||
# were being derived from the same half of the pair, and that coincidence read
|
||||
# as a derivation. Asserted as an INEQUALITY so the two cannot silently be
|
||||
# re-fused into one number again.
|
||||
check("the slew's rail crossing and this bound are now different numbers",
|
||||
SETTLE_CYCLES > 1.0 / SLEW_RAIL_FRAC)
|
||||
# The counter has to be able to REACH the bound or the exit does not exist.
|
||||
# Driven, not read off the constant: run a saturated state and see how high the
|
||||
# counter actually goes.
|
||||
c, sc = 0, 0
|
||||
while c < 50:
|
||||
sc = compute(0.0, -2000.0, 900.0, T, sc).sat_count
|
||||
c += 1
|
||||
check(f"the saturation counter reaches {sc}, which is above the bound",
|
||||
sc >= SETTLE_CYCLES)
|
||||
|
||||
# --- 3. prev_w == 0: blocked below the bound, released above it -------------
|
||||
# The same state either side of the bound. This is the pair the card asks for.
|
||||
blk = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 2, i_w=0.0)
|
||||
check("prev_w=0: below the bound the charge is still refused",
|
||||
blk.target_w == 0.0 and blk.reason == "saturated-freeze"
|
||||
and blk.sat_count == SETTLE_CYCLES - 1)
|
||||
rel = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 1, i_w=0.0)
|
||||
check(f"prev_w=0: at the bound it is released to {rel.target_w:.0f} W",
|
||||
rel.target_w < 0.0 and rel.reason == "freeze-released"
|
||||
and rel.sat_count == SETTLE_CYCLES)
|
||||
# ...and the released command is the SLEW step from 0, not the rail. A release
|
||||
# that jumped to the integrator in one cycle would be the windup this ticket
|
||||
# was told to avoid.
|
||||
check(f"the released command is exactly one slew step"
|
||||
f" ({slew_limit_w(T):.0f} W)",
|
||||
abs(rel.target_w) == slew_limit_w(T))
|
||||
# The mirror. Nothing here is sign-specific, so the discharge side must behave
|
||||
# identically - every freeze rule in this file has a mirror, because the one
|
||||
# that did not is the defect that got through review.
|
||||
mblk = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 2, i_w=0.0)
|
||||
mrel = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T,
|
||||
sat_count=SETTLE_CYCLES - 1, i_w=0.0)
|
||||
check("prev_w=0, discharging: same block, same release, mirrored",
|
||||
mblk.reason == "saturated-freeze" and mrel.reason == "freeze-released"
|
||||
and mrel.target_w == slew_limit_w(T))
|
||||
|
||||
# --- 4. non-zero prev_w, and the two halves are NOT the same --------------
|
||||
# (a) A divergence a LIMIT could have produced is never released, however long
|
||||
# it stands. This is release/1.0's behaviour and it is kept deliberately: the
|
||||
# freeze clears itself here, because the command is pinned at something the
|
||||
# inverter can do.
|
||||
TL = Tuning(max_w=5000.0, slew_w=400.0, target_grid_w=-10.0)
|
||||
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
|
||||
for _ in range(30):
|
||||
d = compute(prev, 4000.0, max(-1000.0, min(1000.0, prev)), TL, sat, i_w)
|
||||
prev, i_w, sat, _r = d.target_w, d.i_w, d.sat_count, reasons.add(d.reason)
|
||||
check("non-zero prev_w, a curtailed inverter: never released over 30 cycles",
|
||||
"freeze-released" not in reasons and prev == 2400.0)
|
||||
# (b) A divergence a limit could NOT have produced is released at the bound,
|
||||
# at a non-zero prev_w exactly as at zero. release/1.0 pinned this run at
|
||||
# -800 W, against its own integrator standing at -2388 W, for as long as it ran.
|
||||
prev, i_w, sat, rows = 0.0, 0.0, 0, []
|
||||
for _ in range(20):
|
||||
d = compute(prev, -2000.0, 700.0, TL, sat, i_w)
|
||||
rows.append((prev, d.target_w, d.i_w, d.reason))
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
pinned = [r for r in rows if r[3] == "saturated-freeze"]
|
||||
check(f"non-zero prev_w, another writer: pinned at {pinned[0][1]:.0f} W...",
|
||||
pinned and all(r[1] == -800.0 for r in pinned))
|
||||
check(f"...then released, settling at {prev:.0f} W", prev == -2390.0)
|
||||
|
||||
# --- 5. THE CARD'S OWN CASE, DRIVEN END TO END -----------------------------
|
||||
# The live install's shape (max_w 5000, slew_w 400, target_grid_w -10). The site
|
||||
# rests in the deadband with another writer holding the pack at +700 W - so the
|
||||
# saturation counter climbs while the command is 0 and nothing is wrong - and
|
||||
# then 2 kW of export arrives. On release/1.0 @ c8dcb38 the command is 0.0 W on
|
||||
# every cycle from there on: 0 W is a FIXED POINT, and the export is given away
|
||||
# for as long as the other writer stays.
|
||||
prev, i_w, sat, cmds = 0.0, 0.0, 0, []
|
||||
for c in range(24):
|
||||
d = compute(prev, -10.0 if c < 6 else -2000.0, 700.0, TL, sat, i_w)
|
||||
cmds.append(d.target_w)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
check("the card's case: the block does hold at first, at 0 W",
|
||||
cmds[6] == 0.0 and cmds[7] == 0.0)
|
||||
started = [i for i, c in enumerate(cmds) if c != 0.0]
|
||||
check(f"the card's case: the charge starts at cycle {started[0]}"
|
||||
f" instead of never", bool(started))
|
||||
# ⚠️ AND IT STOPS AT THE FROZEN INTEGRATOR, NOT AT THE RAIL. This is AC 4: the
|
||||
# integrator moved while the output was blocked, and the release must not turn
|
||||
# that divergence into windup. It cannot, because releasing the OUTPUT freeze
|
||||
# does not release the INTEGRATOR freeze - so the command can only walk, one
|
||||
# slew step per cycle, to a value the integrator stopped accumulating at.
|
||||
check(f"the card's case: it settles at {prev:.0f} W, at the frozen integrator "
|
||||
f"({i_w:.0f} W) and not the {TL.max_w:.0f} W rail",
|
||||
prev == -1190.0 and abs(prev) <= abs(i_w) and abs(prev) < TL.max_w)
|
||||
steps = [abs(cmds[i] - cmds[i - 1]) for i in range(1, len(cmds))]
|
||||
check(f"the card's case: no cycle moves more than the slew limit "
|
||||
f"(worst {max(steps):.0f} W of {slew_limit_w(TL):.0f})",
|
||||
max(steps) <= slew_limit_w(TL))
|
||||
|
||||
# --- 6. AC 4, AS A PROPERTY RATHER THAN A TRAJECTORY -----------------------
|
||||
# ⚠️ THE FIRST FORM OF THIS CHECK WAS WRONG AND THE SWEEP SAID SO, which is
|
||||
# worth leaving in the file. It asserted `|target| <= |i_w|` on a released
|
||||
# cycle, and the sweep produced prev_w -5000, i_w -3000, target -4600: the
|
||||
# command was walking BACK from -5000 toward the integrator and had not
|
||||
# arrived. It was moving the right way and the assertion still failed, because
|
||||
# "never exceeds the integrator" is not what no-windup means here.
|
||||
#
|
||||
# What it means: a released cycle can only move the command TOWARD the
|
||||
# integrator, by at most one slew step, and never past it. So the divergence
|
||||
# that built up while the output was blocked cannot arrive as a jump - it is
|
||||
# spent one slew step at a time, against an integrator that stopped
|
||||
# accumulating when the freeze engaged and is not released with the output.
|
||||
# ⚠️ AND THE COUNT BELOW IS THE RELEASED STATES, NOT THE SWEPT ONES. The first
|
||||
# version of this check named 41*25*21*5 - the size of the sweep - while
|
||||
# grading only the subset that actually releases, so the number in the message
|
||||
# was not the number the assertion was about. It is counted now.
|
||||
step = float(T.step_w)
|
||||
swept, past, jumped = 0, [], []
|
||||
n_released = 0
|
||||
for prev in [x * 250.0 for x in range(-20, 21)]:
|
||||
for grid in [x * 500.0 for x in range(-12, 13)]:
|
||||
for actual in [x * 500.0 for x in range(-10, 11)]:
|
||||
for i0 in (-3000.0, -500.0, 0.0, 500.0, 3000.0):
|
||||
swept += 1
|
||||
d = compute(prev, grid, actual, TL, SETTLE_CYCLES, i0)
|
||||
if d.reason != "freeze-released":
|
||||
continue
|
||||
n_released += 1
|
||||
lo, hi = min(prev, d.i_w), max(prev, d.i_w)
|
||||
if not (lo - step <= d.target_w <= hi + step):
|
||||
past.append((prev, grid, actual, i0, d.target_w, d.i_w))
|
||||
if abs(d.target_w - prev) > slew_limit_w(TL) + step:
|
||||
jumped.append((prev, grid, actual, i0, d.target_w))
|
||||
check(f"of {swept} states swept, the {n_released} that release keep the command"
|
||||
f" between the previous command and the frozen integrator"
|
||||
+ (f" (e.g. {past[0]})" if past else ""), not past and n_released > 0)
|
||||
check("...and no released cycle moves further than one slew step"
|
||||
+ (f" (e.g. {jumped[0]})" if jumped else ""), not jumped)
|
||||
|
||||
# --- 7. NO ACCEPTED CONFIGURATION CAN DELETE THE BLOCK ---------------------
|
||||
# ⚠️ THIS CHECK REPLACES ITS OWN OPPOSITE, AND THE REASON IS WORTH KEEPING.
|
||||
# While the bound was 9, `saturation_cycles` - which config.yaml accepts up to
|
||||
# 10 - could be set AT OR ABOVE it, and then the freeze did not engage until
|
||||
# its own exit was already due: the no-start block silently stopped existing,
|
||||
# and this file asserted that as a known consequence. Moving the bound to 12
|
||||
# put the whole accepted range strictly below it, so the degenerate
|
||||
# configuration is gone rather than documented.
|
||||
#
|
||||
# It is pinned against config.yaml ITSELF and not a transcription, because the
|
||||
# property is "two files agree" and a transcription is what lets them drift.
|
||||
# Regex, not PyYAML: the add-on image ships no PyYAML (test_validate.py says
|
||||
# the same thing at more length).
|
||||
import os # noqa: E402
|
||||
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:
|
||||
_m = re.search(r"^ saturation_cycles: int\((\d+),(\d+)\)\s*$",
|
||||
_fh.read(), re.M)
|
||||
check("config.yaml's saturation_cycles range really was read", _m is not None)
|
||||
_sc_lo, _sc_hi = int(_m.group(1)), int(_m.group(2))
|
||||
check(f"every accepted saturation_cycles ({_sc_lo}..{_sc_hi}) is strictly"
|
||||
f" below the bound ({SETTLE_CYCLES}), so no accepted configuration can"
|
||||
f" push the freeze's engagement past its own exit",
|
||||
_sc_hi < SETTLE_CYCLES)
|
||||
# Driven at the schema's edge rather than argued: the block still engages, and
|
||||
# it engages before the exit is due.
|
||||
TN = Tuning(slew_w=1000.0, saturation_cycles=_sc_hi)
|
||||
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
|
||||
first_freeze = None
|
||||
for _ in range(20):
|
||||
d = compute(prev, -2000.0, 700.0, TN, sat, i_w)
|
||||
reasons.add(d.reason)
|
||||
if d.reason == "saturated-freeze" and first_freeze is None:
|
||||
first_freeze = d.sat_count
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
check(f"at the largest accepted saturation_cycles ({_sc_hi}) the block still"
|
||||
f" engages, at sat_count {first_freeze}, below the bound",
|
||||
"saturated-freeze" in reasons and first_freeze is not None
|
||||
and first_freeze < SETTLE_CYCLES)
|
||||
|
||||
# --- 8. THE BOUND'S DIRECTION, DRIVEN ON ONE INVERTER ----------------------
|
||||
# ⚠️ WHY THIS SECTION EXISTS. The exit's two conditions are read as "another
|
||||
# writer is on the actuator". They are not that, and the first shipped form of
|
||||
# this file said they were. `unasked_power()` compares a reading against ONE
|
||||
# cycle's command while the pack carries every command before it, so a command
|
||||
# that has fallen THROUGH ZERO - or simply fallen faster than the pack coasts -
|
||||
# leaves our own power outside our own interval. With the bound derived from
|
||||
# the 18 s UP measurement that is not a corner case: it is what a normal load
|
||||
# step does.
|
||||
#
|
||||
# The plant here is a MODEL and is labelled one. It is a first-order lag whose
|
||||
# two time constants come from the two measured settling times (a first-order
|
||||
# step is 95 % done at 3 tau, so tau = t / 3), with the ramp cap
|
||||
# sim/plantsim.py measured on inverter A. It is not the rig and it is not the
|
||||
# house; what it is for is that the two settling times ENTER the model in the
|
||||
# same direction they enter the constant, so the check moves if either does.
|
||||
#
|
||||
# ⚠️ AND THERE IS NO SECOND WRITER IN THIS SECTION. One `compute()`, one
|
||||
# plant, one command. Every `freeze-released` below is therefore false.
|
||||
import math # noqa: E402
|
||||
|
||||
|
||||
class _Plant:
|
||||
"""One inverter, obeying our command and nothing else. A MODEL."""
|
||||
|
||||
RAMP_W_S = 800.0 # sim/plantsim.py INVERTER_A, measured=True
|
||||
|
||||
def __init__(self, up_s, down_s):
|
||||
self.a_up = 1.0 - math.exp(-LOOP_TICK_S / (up_s / 3.0))
|
||||
self.a_dn = 1.0 - math.exp(-LOOP_TICK_S / (down_s / 3.0))
|
||||
self.w = 0.0
|
||||
|
||||
def tick(self, cmd):
|
||||
a = self.a_up if abs(cmd) > abs(self.w) else self.a_dn
|
||||
cap = self.RAMP_W_S * LOOP_TICK_S
|
||||
self.w += max(-cap, min(cap, a * (cmd - self.w)))
|
||||
|
||||
|
||||
from app import control as _control # noqa: E402
|
||||
|
||||
TS8 = Tuning(max_w=5000.0, slew_w=400.0, target_grid_w=-10.0,
|
||||
saturation_w=500.0, saturation_cycles=3)
|
||||
|
||||
|
||||
def _drive(bound, load1, load2, cycles=60, switch=25):
|
||||
"""Run one closed loop at an arbitrary bound. -> (release cycles, rows)."""
|
||||
was_b, was_c = _control.SETTLE_CYCLES, _control._SAT_COUNT_CAP
|
||||
# ⚠️ BOTH, OR THE PROBE LIES: `_SAT_COUNT_CAP` is what `compute()` caps the
|
||||
# counter with, so moving the bound without it caps the counter BELOW the
|
||||
# bound and the exit quietly becomes unreachable - which reads as "no false
|
||||
# positives". That is the hazard `_SAT_COUNT_CAP`'s own comment names.
|
||||
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = bound, bound + 1
|
||||
try:
|
||||
p = _Plant(PLANT_SETTLE_S, PLANT_SETTLE_DOWN_S)
|
||||
prev, i_w, sat, rows = 0.0, 0.0, 0, []
|
||||
for c in range(cycles):
|
||||
load = load1 if c < switch else load2
|
||||
d = compute(prev, load - p.w, p.w, TS8, sat, i_w)
|
||||
rows.append((c, prev, p.w, d.target_w, d.sat_count, d.reason))
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
p.tick(prev)
|
||||
return [r for r in rows if r[5] == "freeze-released"], rows
|
||||
finally:
|
||||
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = was_b, was_c
|
||||
|
||||
|
||||
_SHORT = int(-(-PLANT_SETTLE_S // LOOP_TICK_S)) # what 18 s would give: 9
|
||||
|
||||
# A 4 kW export falling to 2.5 kW. Nothing exotic; no second writer.
|
||||
_bad, _rows = _drive(_SHORT, -4000.0, -2500.0)
|
||||
check(f"a bound derived from the SHORTER measurement ({_SHORT} cycles) fires"
|
||||
f" the exit on ONE inverter with nothing else on the bus"
|
||||
+ (f" - {len(_bad)} cycle(s), first at cycle {_bad[0][0]}" if _bad else ""),
|
||||
bool(_bad))
|
||||
if _bad:
|
||||
_c, _prev, _act, _tgt, _sat, _ = _bad[0]
|
||||
check(f"...and what it called unasked is our own coast-down: command"
|
||||
f" {_prev:.0f} W, pack still at {_act:.1f} W, and the pack was"
|
||||
f" commanded {min(r[1] for r in _rows[:_c]):.0f} W a few cycles"
|
||||
f" earlier",
|
||||
unasked_power(_prev, _act, TS8.saturation_w)
|
||||
and abs(_act) > abs(_prev)
|
||||
and min(r[1] for r in _rows[:_c]) < _act)
|
||||
_good, _ = _drive(SETTLE_CYCLES, -4000.0, -2500.0)
|
||||
check(f"the shipped bound ({SETTLE_CYCLES} cycles, the LONGER measurement)"
|
||||
f" does not fire on the same run", not _good)
|
||||
# ...and not on any of these, either. The whole grid of load steps the same
|
||||
# plant can be given, at the tuning every headline figure in this ticket is
|
||||
# quoted from.
|
||||
_L = [x * 500.0 for x in range(-8, 9)]
|
||||
_fired = {}
|
||||
for _b in (_SHORT, SETTLE_CYCLES):
|
||||
_fired[_b] = sum(1 for _a in _L for _z in _L
|
||||
if _a != _z and _drive(_b, _a, _z)[0])
|
||||
_pairs = len(_L) * (len(_L) - 1)
|
||||
check(f"over {_pairs} load-step pairs on the measured plant, one inverter and"
|
||||
f" no second writer: the short bound fires in {_fired[_SHORT]} of them,"
|
||||
f" the shipped bound in {_fired[SETTLE_CYCLES]}",
|
||||
_fired[SETTLE_CYCLES] < _fired[_SHORT])
|
||||
# ⚠️ AND THE SHIPPED FIGURE IS NOT ZERO IN GENERAL. It is small here because
|
||||
# the plant in this model is the one that was measured. A plant slower than
|
||||
# that brings the false release back, and no constant derived from a
|
||||
# measurement can fix that - it is why DOCS.md tells the operator what
|
||||
# `freeze-released` is evidence OF, rather than what it proves.
|
||||
|
||||
print("SAFETY-04: the i_w=None path is still release/1.0, exactly")
|
||||
|
||||
|
||||
@@ -394,8 +777,37 @@ def legacy(prev, grid, actual, t, sat_count):
|
||||
# "i-clamped", because the truncation happens on the integrator before the
|
||||
# command is derived from it. Aliased here rather than papered over - if any
|
||||
# OTHER reason ever diverges, this check goes red.
|
||||
# ⚠️ SAFETY-13 IS A DELIBERATE DIVERGENCE FROM `legacy()`, AND THE SWEEP NOW
|
||||
# GRADES THE DIVERGENCE INSTEAD OF FORBIDDING IT. `legacy()` above stays a
|
||||
# transcription of release/1.0 - it is not edited to agree, for the reason the
|
||||
# note on `slew_w` gives: an oracle taught the new rule compares the new law
|
||||
# against itself. What changed is the assertion. Every case must either be
|
||||
# IDENTICAL to release/1.0, or be a case where release/1.0 said
|
||||
# "saturated-freeze" and this law says "freeze-released" with the two conditions
|
||||
# that permit it both true. Anything else is a regression, and the counts below
|
||||
# say how much of the sweep each answer covers.
|
||||
ALIAS = {"i-clamped": "clamped"}
|
||||
# ⚠️ SAFETY-13 ROUND 2 MOVED THE COUNTER'S CEILING, SO THE SWEEP NOW HAS A
|
||||
# THIRD ANSWER. `_SAT_COUNT_CAP` is `SETTLE_CYCLES + 1`, which was 10 - the
|
||||
# same literal release/1.0 used - and is now 13. Above release/1.0's own
|
||||
# ceiling the two counters therefore differ, and NOTHING ELSE MAY: `frozen` is
|
||||
# `sat_count >= saturation_cycles` and config.yaml accepts that at most 10, so
|
||||
# a counter reading 13 instead of 10 cannot change it, and no other line in
|
||||
# `compute()` reads the counter's value. That is asserted rather than asserted-
|
||||
# by-omission, in `cap_diffs` below.
|
||||
#
|
||||
# ⚠️ AND THE SWEEP HAS TO REACH THE BOUND OR IT GRADES NOTHING. `sc` runs to 12
|
||||
# now; at 9 the counter tops out one short of the exit and every release
|
||||
# silently disappears from this table, which is exactly what happened when the
|
||||
# bound moved and is why the count is asserted and not merely printed. 12 is a
|
||||
# state release/1.0 could never hold - its counter stopped at 10 - so
|
||||
# `legacy()` is being fed one input outside its own reachable domain, on
|
||||
# purpose, and `cap_diffs` is where that shows up.
|
||||
LEGACY_SAT_CAP = 10 # release/1.0's literal, transcribed in legacy()
|
||||
diffs = []
|
||||
released = []
|
||||
value_diffs = []
|
||||
cap_diffs = []
|
||||
seen = set()
|
||||
# ⚠️ `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
|
||||
@@ -409,16 +821,69 @@ for tune in (Tuning(slew_w=1000.0), Tuning(slew_w=1000.0, target_grid_w=-10.0),
|
||||
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):
|
||||
for sc in (0, 2, 3, 9):
|
||||
for sc in (0, 2, 3, 9, 12):
|
||||
d = compute(prev, grid, actual, tune, sc) # i_w defaults to None
|
||||
seen.add(d.reason)
|
||||
got = (d.target_w, d.sat_count, d.frozen,
|
||||
ALIAS.get(d.reason, d.reason))
|
||||
if got != legacy(prev, grid, actual, tune, sc):
|
||||
diffs.append((prev, grid, actual, sc, got,
|
||||
legacy(prev, grid, actual, tune, sc)))
|
||||
check(f"i_w=None reproduces release/1.0 over {3*7*9*4*4} cases, reason included"
|
||||
+ (f" (first diff {diffs[0]})" if diffs else ""), not diffs)
|
||||
was = legacy(prev, grid, actual, tune, sc)
|
||||
if got == was:
|
||||
continue
|
||||
# everything that reaches the wire, without the counter
|
||||
wire_same = (got[0], got[2], got[3]) == (was[0], was[2], was[3])
|
||||
if wire_same:
|
||||
# the ceiling moved and nothing else did. Only legal
|
||||
# above release/1.0's own cap, and only with both
|
||||
# counters standing at their own ceilings.
|
||||
if (sc >= LEGACY_SAT_CAP
|
||||
and was[1] == LEGACY_SAT_CAP
|
||||
and got[1] == _control._SAT_COUNT_CAP):
|
||||
cap_diffs.append((prev, grid, actual, sc, got[1], was[1]))
|
||||
continue
|
||||
diffs.append((prev, grid, actual, sc, got, was))
|
||||
continue
|
||||
explained = (
|
||||
d.reason == "freeze-released"
|
||||
and was[3] == "saturated-freeze"
|
||||
and d.sat_count >= SETTLE_CYCLES
|
||||
and unasked_power(prev, actual, tune.saturation_w))
|
||||
if not explained:
|
||||
diffs.append((prev, grid, actual, sc, got, was))
|
||||
continue
|
||||
released.append((prev, grid, actual, sc))
|
||||
if got[0] != was[0]:
|
||||
value_diffs.append((prev, got[0], was[0]))
|
||||
TOTAL = 3 * 7 * 9 * 4 * 5
|
||||
check(f"i_w=None reproduces release/1.0 over {TOTAL} cases except where"
|
||||
f" SAFETY-13 releases the freeze, reason included"
|
||||
+ (f" (first unexplained diff {diffs[0]})" if diffs else ""), not diffs)
|
||||
# ⚠️ EXACT COUNTS, NOT INEQUALITIES. A weaker form of this line ("some
|
||||
# releases happened") was written while the bound was moving, and the coverage
|
||||
# audit caught it immediately: a `unasked_power()` that stops discriminating
|
||||
# went from 6 checks to 5. The three numbers partition the sweep, so any one of
|
||||
# them moving is a change somebody has to explain.
|
||||
check(f"{TOTAL - len(released) - len(cap_diffs)} of {TOTAL} cases are"
|
||||
f" bit-identical to release/1.0; {len(released)} are releases;"
|
||||
f" {len(cap_diffs)} differ in the saturation counter alone",
|
||||
len(released) == 432 and len(cap_diffs) == 81
|
||||
and TOTAL - len(released) - len(cap_diffs) == 3267)
|
||||
# ⚠️ THE COUNTER-ONLY DIFFERENCES ARE NOT A LOOPHOLE, so they are pinned twice:
|
||||
# they may only occur at an `sc` above release/1.0's ceiling, and the largest
|
||||
# `saturation_cycles` config.yaml accepts is below BOTH ceilings, so no
|
||||
# accepted configuration can see the difference at all.
|
||||
check(f"the {len(cap_diffs)} counter-only differences all sit above"
|
||||
f" release/1.0's cap of {LEGACY_SAT_CAP}, where no accepted"
|
||||
f" saturation_cycles can read them",
|
||||
all(r[3] >= LEGACY_SAT_CAP for r in cap_diffs) and _sc_hi <= LEGACY_SAT_CAP)
|
||||
# ⚠️ AND THE RELEASES ARE MOSTLY A LABEL. Only these change what reaches the
|
||||
# wire; the rest report "freeze-released" on a command the freeze was not
|
||||
# binding on anyway. Every one of them is a charge that release/1.0 refused to
|
||||
# start from exactly 0 W, which is the ticket, stated as a count.
|
||||
check(f"{len(value_diffs)} of those {len(released)} change the command,"
|
||||
f" and every one is a charge start from prev_w == 0 that was refused",
|
||||
len(value_diffs) == 27
|
||||
and all(p == 0.0 and new_ < 0.0 and old_ == 0.0
|
||||
for p, new_, old_ in value_diffs))
|
||||
|
||||
# ...and the rename is not a quiet deletion: the signal SAFETY-03 alarms on has
|
||||
# to actually occur in that sweep, or its hook is dead.
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""UX-04 - the defaults pin UX-01 AC 4 promised and never built.
|
||||
`python3 -B test_defaults_pin.py`
|
||||
|
||||
WHAT UX-01 AC 4 ASKED FOR, IN ITS OWN WORDS: "every `schema:` key absent from
|
||||
`options:` has exactly one defaults-table entry ... the table changed goes red
|
||||
... and NO MODULE CARRIES ITS OWN LITERAL FALLBACK FOR A SCHEMA DEFAULT - an AST
|
||||
check, not a grep."
|
||||
|
||||
WHAT SHIPPED. Clause one is VACUOUS: `options:` and `schema:` declare the same
|
||||
71 keys, so "every schema key absent from options" quantifies over the empty set
|
||||
and is true of any tree, including one with no defaults at all. Clause two -
|
||||
"the table changed goes red" - does work, six times over, once per mirror in its
|
||||
own suite. Clause three, THE ONE WITH THE CONTENT, was never written. The only
|
||||
literal scan in this repository before this file is
|
||||
`test_validate.py`'s `re.findall(r'"(maintenance_[a-z_]+)"...')`, which is a
|
||||
grep, is restricted to eight `maintenance_*` keys and three named files, and
|
||||
predates UX-01.
|
||||
|
||||
⚠️ AND A CHECK THAT CANNOT GO RED IS NOT A CHECK, so this file does not repeat
|
||||
clause one's mistake. Every part below states the size of the set it quantifies
|
||||
over and grades that size against a floor derived from the tree, and part 5
|
||||
plants a literal in a synthetic module and REQUIRES the scanner to catch it, so
|
||||
the red direction is exercised on every run rather than attested in a commit
|
||||
message. MUTATION.md habit 4.
|
||||
|
||||
--------------------------------------------------------------------------- #
|
||||
WHAT AN "IN-CODE LITERAL FALLBACK" IS, STRUCTURALLY, so a reader can argue with
|
||||
the definition rather than guess it. It is a call in `app/` that takes a STRING
|
||||
LITERAL naming a `schema:` key and, in the next positional slot, a CONSTANT:
|
||||
|
||||
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
|
||||
|
||||
Two shapes, one rule, found by walking `ast.Call` nodes - not by matching text,
|
||||
so `opts.get(\n "max_w",\n 2000,\n)` and a renamed `opts` are found too and
|
||||
a `max_w` inside a comment or a docstring is not.
|
||||
|
||||
TWO EXCLUSIONS, both structural and both narrow:
|
||||
|
||||
* `getattr(obj, "max_w", 0.0)` reads an ATTRIBUTE off an object, not an option
|
||||
off the operator's blob. Its third argument is an absent-attribute guard and
|
||||
has no relationship to config.yaml. Excluded by function name.
|
||||
|
||||
* THE EMPTY STRING. `opts.get("static_profile", "")` is Python's "this key is
|
||||
not stated", and every one of the six sites in `validate.py` immediately
|
||||
normalises it - `str(opts.get(k, "") or "").strip()`. It cannot be a stale
|
||||
copy of a NUMBER, which is the whole of the CFG-04 defect class, and
|
||||
rewriting those six to index a mirror would replace a sentence that reads
|
||||
"absent" with one that reads "the shipped default" at sites where absent is
|
||||
what is meant. Excluded by value. `0` is NOT excluded: `slew_w: 0` and
|
||||
`integrator_max_w: 0` are real shipped defaults with real meanings.
|
||||
|
||||
--------------------------------------------------------------------------- #
|
||||
WHAT THIS FILE DOES *NOT* DO, said before the parts rather than after them.
|
||||
|
||||
It does not remove the 27 (module, key) pairs in part 4's ledger. That is
|
||||
CFG-03's open ticket - option defaults living in four places - and it is a body
|
||||
of behaviour change across `main.py`, `p1.py`, `inverter.py` and `validate.py`
|
||||
that no single card should make in passing. What this file does instead is
|
||||
FREEZE the ledger: the set is graded EXACTLY, so the twenty-eighth pair is red
|
||||
on the commit that introduces it and the ledger can only ever shrink. A backlog
|
||||
that cannot grow is a backlog being paid down; one that is merely written in a
|
||||
comment is the state UX-01 left.
|
||||
|
||||
MUTATION.md is the method for the red-direction evidence in part 5, and
|
||||
`tools/mutation_run.sh` is the harness.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
APP = HERE / "app"
|
||||
|
||||
FAILED = 0
|
||||
|
||||
|
||||
def check(what, ok):
|
||||
global FAILED
|
||||
print((" ok " if ok else " FAIL ") + what)
|
||||
if not ok:
|
||||
FAILED += 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# config.yaml, parsed by hand.
|
||||
#
|
||||
# ⚠️ NO PyYAML, AND NOT AS A PREFERENCE. The add-on image installs py3-aiohttp
|
||||
# and py3-paho-mqtt only; `import yaml` raises in the checks container and a
|
||||
# check that skips on ImportError is not a check. `test_maintenance.py` and
|
||||
# `test_price.py` each carry their own two-line reader for the same reason, and
|
||||
# this is the third - which is itself an instance of the defect this file
|
||||
# grades, one level up. It is written down rather than tidied away because
|
||||
# tidying it into a shared helper is a change to two suites that are green.
|
||||
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
_SCHEMA_AT = CFG_TEXT.index("\nschema:")
|
||||
_OPTS_BLOCK, _SCHEMA_BLOCK = CFG_TEXT[:_SCHEMA_AT], CFG_TEXT[_SCHEMA_AT:]
|
||||
|
||||
|
||||
def _scalar(raw):
|
||||
"""One `options:` value, as the yaml module this image lacks would read it."""
|
||||
raw = raw.split("#", 1)[0].strip().strip('"').strip("'")
|
||||
if raw in ("true", "false"):
|
||||
return raw == "true"
|
||||
if raw == "[]":
|
||||
return []
|
||||
for cast in (int, float):
|
||||
try:
|
||||
return cast(raw)
|
||||
except ValueError:
|
||||
pass
|
||||
return raw
|
||||
|
||||
|
||||
def _block_keys(block):
|
||||
return [m.group(1) for m in re.finditer(r"^ ([a-z0-9_]+):", block, re.M)]
|
||||
|
||||
|
||||
SHIPPED = {m.group(1): _scalar(m.group(2))
|
||||
for m in re.finditer(r"^ ([a-z0-9_]+): *(.*)$", _OPTS_BLOCK, re.M)}
|
||||
SCHEMA = set(_block_keys(_SCHEMA_BLOCK))
|
||||
|
||||
print("UX-04 - the defaults pin\n")
|
||||
print("1. the surface this file quantifies over")
|
||||
print(f" config.yaml: {len(SHIPPED)} keys under `options:`, {len(SCHEMA)} "
|
||||
f"under `schema:`")
|
||||
# The floor, for part 1's own reason: a reader that returned {} would make every
|
||||
# check below vacuously true. 60 is the count of REQUIRED schema keys and is the
|
||||
# number `tools/supervisor_shape_probe.py` section F now grades; any floor well
|
||||
# inside the real size does the job, and this one cannot be reached by accident.
|
||||
check(f"the hand reader found a real options block ({len(SHIPPED)} keys, floor 60)",
|
||||
len(SHIPPED) >= 60)
|
||||
check(f"...and a real schema block ({len(SCHEMA)} keys, floor 60)",
|
||||
len(SCHEMA) >= 60)
|
||||
check("every shipped default names a schema key, so a mirror pinned to one is "
|
||||
"pinned to the other",
|
||||
sorted(set(SHIPPED) - SCHEMA) == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. the mirrors, DISCOVERED from the tree rather than listed here")
|
||||
# ⚠️ WHY DISCOVERED. Six suites already pin their own mirror to config.yaml -
|
||||
# test_maintenance, test_price, test_watchdog (twice), test_peakguard,
|
||||
# test_delivery. Every one of those pins names its table by hand, so a SEVENTH
|
||||
# table added tomorrow is pinned by nobody and nothing says so. This walks
|
||||
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
|
||||
# grades whatever it finds, so the seventh is covered on the commit that
|
||||
# introduces it. That is the property the six hand-written pins cannot have.
|
||||
MODULES = sorted(p.name for p in APP.glob("*.py"))
|
||||
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
|
||||
for name in MODULES}
|
||||
|
||||
MIRROR = {} # schema key -> (module, table name, value)
|
||||
TABLES = []
|
||||
for name in MODULES:
|
||||
for node in TREES[name].body:
|
||||
if not (isinstance(node, ast.Assign) and len(node.targets) == 1
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id.endswith("OPTION_DEFAULTS")):
|
||||
continue
|
||||
table = node.targets[0].id
|
||||
TABLES.append((name, table))
|
||||
for k, v in zip(node.value.keys, node.value.values):
|
||||
# A literal key with a literal value is the only shape a mirror
|
||||
# has; anything else is not a table this file can grade, and
|
||||
# saying so out loud beats skipping it silently.
|
||||
MIRROR[k.value] = (name, table, ast.literal_eval(v))
|
||||
|
||||
print(f" {len(TABLES)} table(s): " + ", ".join(f"{m}:{t}" for m, t in TABLES))
|
||||
print(f" {len(MIRROR)} mirrored key(s) of {len(SCHEMA)} in `schema:`")
|
||||
check(f"the walk found the mirrors that exist ({len(TABLES)} tables, floor 6)",
|
||||
len(TABLES) >= 6)
|
||||
_unschemad = sorted(k for k in MIRROR if k not in SCHEMA)
|
||||
check(f"every mirrored key is a real schema key {_unschemad or ''}",
|
||||
_unschemad == [])
|
||||
_unshipped = sorted(k for k in MIRROR if k not in SHIPPED)
|
||||
check(f"...and is shipped in the options block {_unshipped or ''}",
|
||||
_unshipped == [])
|
||||
_drift = sorted(k for k, (m, t, v) in MIRROR.items()
|
||||
if k in SHIPPED and v != SHIPPED[k])
|
||||
check(f"...and reproduces config.yaml exactly, so ANY mirror - including one no "
|
||||
f"suite names by hand - goes red on drift {_drift or ''}",
|
||||
_drift == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. the AST scan: no module carries its own literal fallback for a "
|
||||
"MIRRORED schema default")
|
||||
|
||||
|
||||
def literal_fallbacks(tree, name):
|
||||
"""Every `f(..., "<schema key>", <constant>, ...)` in one module.
|
||||
|
||||
Yields (module, lineno, callee, key, value). See the header for the two
|
||||
exclusions and the argument for each.
|
||||
"""
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn = node.func
|
||||
callee = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", "")
|
||||
if callee == "getattr":
|
||||
continue
|
||||
for i, arg in enumerate(node.args[:-1]):
|
||||
if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)
|
||||
and arg.value in SCHEMA):
|
||||
continue
|
||||
default = node.args[i + 1]
|
||||
if not isinstance(default, ast.Constant):
|
||||
continue
|
||||
if default.value is None or default.value == "":
|
||||
continue
|
||||
yield (name, node.lineno, callee, arg.value, default.value)
|
||||
|
||||
|
||||
ALL_SITES = [s for name in MODULES for s in literal_fallbacks(TREES[name], name)]
|
||||
MIRRORED_SITES = [s for s in ALL_SITES if s[3] in MIRROR]
|
||||
UNMIRRORED_SITES = [s for s in ALL_SITES if s[3] not in MIRROR]
|
||||
|
||||
print(f" scanned {len(MODULES)} modules, {len(ALL_SITES)} literal fallback "
|
||||
f"site(s): {len(MIRRORED_SITES)} over mirrored keys, "
|
||||
f"{len(UNMIRRORED_SITES)} over unmirrored ones")
|
||||
# ⚠️ THE SCANNER'S OWN FLOOR, and it is the line that stops this whole file
|
||||
# rotting into clause one. A scanner that silently stopped matching - a renamed
|
||||
# helper, an ast API change, a `SCHEMA` that came back empty - would report ZERO
|
||||
# sites and every check below would pass. So the total is graded against a floor
|
||||
# the tree already exceeds by a wide margin, and part 5 additionally feeds the
|
||||
# scanner a source it MUST flag.
|
||||
check(f"the scanner is not blind: it found {len(ALL_SITES)} sites (floor 20)",
|
||||
len(ALL_SITES) >= 20)
|
||||
for site in sorted(MIRRORED_SITES):
|
||||
print(f" {site[0]}:{site[1]} {site[2]}({site[3]!r}, {site[4]!r})")
|
||||
check(f"no literal fallback survives for any of the {len(MIRROR)} keys a mirror "
|
||||
f"already answers for - those sites must index the mirror",
|
||||
MIRRORED_SITES == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. the CFG-03 ledger: the unmirrored keys that still carry a literal, "
|
||||
"frozen at exactly today's set")
|
||||
# ⚠️ WHY A FROZEN SET AND NOT A COUNT. A count goes green again the moment
|
||||
# somebody deletes one site and adds another, which is the same-size drift this
|
||||
# repository keeps finding. The pair (module, key) is stable under every edit
|
||||
# that is not the one being guarded against: reformatting, moving the call
|
||||
# within the file, renaming the local `opts`. It changes only when a module
|
||||
# starts or stops carrying a literal for a key - which is exactly the event.
|
||||
#
|
||||
# LINE NUMBERS ARE DELIBERATELY NOT IN THE KEY. A ledger that goes red when an
|
||||
# unrelated edit shifts a line is a ledger people learn to re-bless without
|
||||
# reading, and a ledger re-blessed without reading grades nothing.
|
||||
LEDGER = {
|
||||
("inverter.py", "max_w"),
|
||||
("main.py", "auto_start"),
|
||||
("main.py", "cycle_floor_s"),
|
||||
("main.py", "deadband_w"),
|
||||
("main.py", "estop_fitted"),
|
||||
("main.py", "gain"),
|
||||
("main.py", "heartbeat_s"),
|
||||
("main.py", "log_level"),
|
||||
("main.py", "meter_freeze_s"),
|
||||
("main.py", "meter_max_age_s"),
|
||||
("main.py", "meter_phases"),
|
||||
("main.py", "peak_cap_w"),
|
||||
("main.py", "saturation_cycles"),
|
||||
("main.py", "saturation_w"),
|
||||
("main.py", "sign_disagree_s"),
|
||||
("main.py", "sign_disagree_w"),
|
||||
("main.py", "slew_w"),
|
||||
("main.py", "stale_input_s"),
|
||||
("main.py", "step_w"),
|
||||
("main.py", "target_grid_w"),
|
||||
("p1.py", "meter_poll_s"),
|
||||
("p1.py", "meter_source"),
|
||||
("validate.py", "max_w"),
|
||||
("validate.py", "meter_max_age_s"),
|
||||
("validate.py", "meter_phases"),
|
||||
("validate.py", "sign_disagree_w"),
|
||||
("validate.py", "slew_w"),
|
||||
}
|
||||
_found = {(s[0], s[3]) for s in UNMIRRORED_SITES}
|
||||
_new = sorted(_found - LEDGER)
|
||||
_gone = sorted(LEDGER - _found)
|
||||
print(f" {len(_found)} (module, key) pair(s) carry a literal for a key no "
|
||||
f"mirror answers for")
|
||||
check(f"no NEW module/key pair has started carrying a literal fallback {_new or ''}",
|
||||
_new == [])
|
||||
check(f"...and none has been paid off without the ledger being shortened - "
|
||||
f"shrink LEDGER when it has {_gone or ''}",
|
||||
_gone == [])
|
||||
|
||||
# --- and every one of them is graded AGAINST config.yaml, which is the half
|
||||
# that catches CFG-04's actual failure: two copies of one number, one moves.
|
||||
DIVERGENT = {
|
||||
# main.py:290 `target_grid_w=float(opts.get("target_grid_w", 0))`.
|
||||
# config.yaml ships -10 W. MEASURED here for the first time, 2026-09-07,
|
||||
# and it is a real divergence, not a sentinel: a dict handed to
|
||||
# `Controller` WITHOUT the key holds net exchange at 0 W instead of the
|
||||
# shipped -10 W bias into import. On a Supervisor install the key is always
|
||||
# present - the deep-merge of `options:` guarantees it - so this cannot
|
||||
# reach an operator; the paths that CAN hand over a partial dict are
|
||||
# `sim/inject.py` and this repository's own fixtures. It is recorded rather
|
||||
# than corrected because changing it is a behaviour change to the control
|
||||
# loop's target, which belongs to a card that grades the loop.
|
||||
("main.py", "target_grid_w"): (0, -10),
|
||||
}
|
||||
_diverge = {}
|
||||
for mod, line, callee, key, value in UNMIRRORED_SITES:
|
||||
want = SHIPPED.get(key)
|
||||
same = (isinstance(value, bool) == isinstance(want, bool)) and value == want
|
||||
if not same:
|
||||
_diverge[(mod, key)] = (value, want)
|
||||
for pair, (got, want) in sorted(_diverge.items()):
|
||||
print(f" {pair[0]} {pair[1]}: code {got!r} vs config.yaml {want!r}")
|
||||
check(f"every remaining literal still AGREES with config.yaml, except the "
|
||||
f"{len(DIVERGENT)} divergence(s) recorded above it",
|
||||
_diverge == DIVERGENT)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. the red direction, RUN on every invocation")
|
||||
# ⚠️ A CHECK THAT HAS NEVER BEEN SHOWN FAILING IS NOT A CHECK. Parts 3 and 4
|
||||
# both pass on a clean tree, which is exactly what UX-01 AC 4's clause one did
|
||||
# while grading nothing. So the scanner is fed a source that is a deliberate
|
||||
# violation of each rule and is REQUIRED to flag it - in-process, against the
|
||||
# same `literal_fallbacks()` the parts above call, so a scanner that stops
|
||||
# working stops working here too.
|
||||
_PLANTED = '''
|
||||
def build(opts):
|
||||
a = opts.get("maintenance_charge_w", 2000) # mirrored key -> part 3
|
||||
b = _number(opts, "cycle_floor_s", 15) # unmirrored, main.py's key
|
||||
c = opts.get("static_profile", "") # the "" sentinel: NOT a hit
|
||||
d = getattr(unit, "max_w", 0.0) # attribute guard: NOT a hit
|
||||
e = opts.get("peak_soc_floor", 20) # mirrored key -> part 3
|
||||
return a, b, c, d, e
|
||||
'''
|
||||
_hits = list(literal_fallbacks(ast.parse(_PLANTED, "<planted>"), "<planted>"))
|
||||
_keys = sorted(h[3] for h in _hits)
|
||||
for h in _hits:
|
||||
print(f" caught {h[2]}({h[3]!r}, {h[4]!r}) at line {h[1]}")
|
||||
check("a planted literal for a MIRRORED key is caught - this is part 3's red "
|
||||
"direction, exercised",
|
||||
"maintenance_charge_w" in _keys and "peak_soc_floor" in _keys)
|
||||
check("a planted literal for an unmirrored key is caught too - part 4's",
|
||||
"cycle_floor_s" in _keys)
|
||||
check('...and the two documented exclusions still hold: the "" sentinel and '
|
||||
"getattr are not reported",
|
||||
"static_profile" not in _keys and _keys.count("max_w") == 0)
|
||||
check(f"exactly the three intended sites, no more {_keys}",
|
||||
_keys == ["cycle_floor_s", "maintenance_charge_w", "peak_soc_floor"])
|
||||
|
||||
# ...and the mirror comparison's red direction, likewise executed rather than
|
||||
# claimed: part 2 goes red when a table and config.yaml disagree by one.
|
||||
_drifted = dict(MIRROR)
|
||||
_pick = sorted(MIRROR)[0]
|
||||
_mod, _tab, _val = _drifted[_pick]
|
||||
_drifted[_pick] = (_mod, _tab, "###drifted###")
|
||||
check(f"part 2's comparison rejects a mirror that has drifted (simulated on "
|
||||
f"{_pick!r})",
|
||||
any(v != SHIPPED.get(k) for k, (_, _, v) in _drifted.items()))
|
||||
|
||||
|
||||
print()
|
||||
if FAILED:
|
||||
print(f"{FAILED} FAILED")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,879 @@
|
||||
"""Runnable check for SAFETY-37's freshness policy. `python3 test_freshness.py`
|
||||
|
||||
THE SUBJECT OF THIS FILE IS THE FIXTURE, NOT THE CODE. A pack legitimately
|
||||
resting at 40 % and a dead sensor reading 40 % ARE THE SAME NUMBER. A check that
|
||||
cannot separate "fresh and unchanged" from "stale" cannot see the failure it
|
||||
names, and this repository has recorded that defect - "a check whose DATA cannot
|
||||
reach the failure it names" - eleven times. So part 1 builds the separation and
|
||||
ASSERTS THE VALUE STREAMS ARE BIT-IDENTICAL across the pair; everything after it
|
||||
is downstream of that pair being real.
|
||||
|
||||
⚠️ AND THE CARD'S WORDING HAD TO BE SHARPENED TO BUILD IT, WHICH IS SAID HERE
|
||||
RATHER THAN QUIETLY WORKED AROUND. The card says "only arrival separates them".
|
||||
For a signal this add-on polls out of Home Assistant's state cache, ARRIVAL
|
||||
CANNOT SEPARATE THEM: `read_inputs()` runs about once a second and Home
|
||||
Assistant answers 200 with a number whether or not the publisher behind the
|
||||
entity is alive, so both members of the pair arrive, identically, forever.
|
||||
Part 1a demonstrates exactly that - an arrival-age gate is graded and FAILS to
|
||||
tell the pair apart - and it is the reason the shipped verdict is a conjunction
|
||||
with an OBLIGATION term rather than an age threshold. Every clock in that
|
||||
verdict is still an arrival clock: nothing reads a Home Assistant timestamp,
|
||||
which is the half of "arrival-stamped" that TEL-06 is blocked on and that this
|
||||
module does honour.
|
||||
|
||||
1. THE PAIR. Fresh-and-unchanged vs stale, on identical value streams, on
|
||||
the policy object itself. Plus 1a, the arrival-age gate that cannot.
|
||||
2. THE WIRING. The same pair driven through the REAL `Controller.read_inputs`
|
||||
at a roster of TWO, with the two units carrying DIFFERENT power values.
|
||||
3. THE CONSUMERS REFUSE, and the counterfactual is run beside them: the
|
||||
per-unit sign check and the circulating-power sensor, with the gate and
|
||||
with the gate removed, through real `cycle()` calls.
|
||||
4. THE LATCH, and the ~1 Hz oscillation it exists to prevent.
|
||||
5. N=1. Nothing changes on a healthy install - asserted on the shipped read
|
||||
path, not assumed - and the one thing that DOES change is named.
|
||||
6. THE BOUNDS, graded against the shipped `config.yaml` at both ends.
|
||||
7. THE POLICY TABLE tells the truth about what is wired.
|
||||
8. THE MEASUREMENT the bound rests on, replayed from the committed capture.
|
||||
9. KNOWN SURVIVORS, graded so they are residuals rather than surprises.
|
||||
|
||||
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12; goodwe/NOTES.md §1.1b).
|
||||
Every two-unit case below is two copies of inverter A's behaviour with
|
||||
DIFFERENT numbers passed in, and no number here is a measured parameter for a
|
||||
second device.
|
||||
|
||||
⚠️ 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
|
||||
before you report a survivor.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import glob
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app import freshness as F
|
||||
from app.circulation import circulating_w
|
||||
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness
|
||||
from app.inverter import Inverter
|
||||
from app.main import Controller
|
||||
from app.store import DEFAULTS
|
||||
from app.watchdog import SignWatchdog, SignWatchdogConfig, disagrees
|
||||
|
||||
fails = []
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
class MemStore:
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
def set_time(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Hass:
|
||||
"""A Home Assistant that answers per ENTITY and RECORDS what it served.
|
||||
|
||||
⚠️ PER ENTITY, AND THE RECORD IS LOAD-BEARING. A stub that answered one
|
||||
number whatever it was asked could not tell "B was read from B's sensor"
|
||||
from "B was handed A's reading" - and the served-value log is what lets
|
||||
part 1 assert that the fresh run and the stale run saw the SAME numbers.
|
||||
"""
|
||||
|
||||
def __init__(self, numbers=None, limits=None):
|
||||
self.numbers = dict(numbers or {})
|
||||
self.limits_by = dict(limits or {})
|
||||
self.served = [] # (entity, value) in call order
|
||||
self.writes = []
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = self.numbers.get(entity)
|
||||
self.served.append((entity, v))
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((entity, value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
A_RATING = 5000.0
|
||||
# ⚠️ ILLUSTRATIVE, AND NOT B'S DATASHEET FIGURES. SPIKE-04a established B as a
|
||||
# GW5048D-ES rated 4600 W with a 10 % floor (goodwe/NOTES.md §1.1b), and this
|
||||
# fixture deliberately does not use them: 5000/4600 is a 400 W gap, narrower
|
||||
# than a slew step, and two equal-ish numbers cannot show a check that read the
|
||||
# wrong unit's. Same reasoning as test_inverter.py's `B_RATING_HYPOTHESIS`.
|
||||
B_RATING_SIM = 3000.0
|
||||
A_FLOOR, B_FLOOR_SIM = 10, 15
|
||||
|
||||
ROSTER = [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.sp_a",
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
||||
"max_w": A_RATING, "bms_soc_floor": A_FLOOR},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
|
||||
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
|
||||
"max_w": B_RATING_SIM, "bms_soc_floor": B_FLOOR_SIM},
|
||||
]
|
||||
|
||||
# ⚠️ A AND B CARRY DIFFERENT POWER VALUES IN EVERY FIXTURE BELOW, and the card
|
||||
# names this as the trap it expects: "if both read the same, the test cannot
|
||||
# distinguish a frozen reading from a live one and will pass against the broken
|
||||
# code". SAFETY-30's fix round walked into exactly that with two watchdog holds
|
||||
# both set to 1.
|
||||
A_FROZEN_W = -1200.0 # inverter A's register, stuck on a charge figure
|
||||
B_LIVE_W = 900.0 # inverter B's register, moving, and NOT A's number
|
||||
|
||||
BASE = dict(meter_source="off", meter_entity="sensor.grid", setpoint_entity="",
|
||||
soc_entity="", batt_entity="", 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, inverters=ROSTER)
|
||||
|
||||
|
||||
def site(opts=None, numbers=None):
|
||||
"""A real two-inverter Controller off the shipped `build_roster()`."""
|
||||
hass = Hass(dict({"sensor.grid": 0.0,
|
||||
"sensor.soc_a": 50.0, "sensor.batt_a": A_FROZEN_W,
|
||||
"sensor.soc_b": 55.0, "sensor.batt_b": B_LIVE_W},
|
||||
**(numbers or {})),
|
||||
{"number.sp_a": (-A_RATING, A_RATING, 1.0),
|
||||
"number.sp_b": (-B_RATING_SIM, B_RATING_SIM, 1.0)})
|
||||
c = Controller(dict(BASE, **(opts or {})), hass, MemStore(), Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
|
||||
c.inverters[1].dev_min, c.inverters[1].dev_max = -B_RATING_SIM, B_RATING_SIM
|
||||
return c, hass
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("1. THE PAIR: 'fresh and unchanged' vs 'stale', on IDENTICAL value streams")
|
||||
|
||||
# The two runs below feed the policy object the SAME forty readings of the same
|
||||
# number at the same instants. The ONLY difference is what the controller was
|
||||
# asking the inverter to do while those readings arrived.
|
||||
#
|
||||
# FRESH the site is converged. The command sits where it is, the register
|
||||
# answers the same watt, and nothing is wrong. This is the case the
|
||||
# 30-day capture in part 8 measures at up to 263.2 s.
|
||||
# STALE the site reversed. The command travelled 2500 W and the register did
|
||||
# not move by one watt in 40 s, which is 2.4x the fastest it could
|
||||
# possibly be obliged to answer in.
|
||||
STREAM = [(float(t), A_FROZEN_W) for t in range(0, 40)]
|
||||
|
||||
|
||||
def drive(bound, stream, standing_of):
|
||||
"""Feed a Freshness one arrival per (t, value). Returns (obj, served, verdicts)."""
|
||||
f = Freshness(bound)
|
||||
served, verdicts = [], []
|
||||
for t, v in stream:
|
||||
f.submit(v, standing_of(t), t)
|
||||
served.append(v)
|
||||
verdicts.append(f.reading())
|
||||
return f, served, verdicts
|
||||
|
||||
|
||||
_fresh, _fresh_served, _fresh_read = drive(
|
||||
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W)
|
||||
# The command travels from -1200 W to +1300 W at t=1 and stays there: a site
|
||||
# turnaround, which is the ordinary stimulus SAFETY-31's own campaign sweeps.
|
||||
_stale, _stale_served, _stale_read = drive(
|
||||
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W if t < 1 else 1300.0)
|
||||
|
||||
print(f" served (both runs): {_fresh_served[:3]} ... x{len(_fresh_served)}")
|
||||
# ⚠️ `key=str`, AND IT IS NOT COSMETIC. Without it this line raises TypeError
|
||||
# comparing None with a float the moment a mutation makes the FRESH run stale -
|
||||
# which is what M2 (dropping the command-travel term) did: the file died on a
|
||||
# DIAGNOSTIC PRINT before check one, so the harness reported "red" with no named
|
||||
# check behind it. MUTATION.md's reporting rule is "which suite went red, and on
|
||||
# WHICH NAMED CHECK"; a print that can crash first destroys exactly that.
|
||||
print(f" fresh verdicts: unique={sorted(set(_fresh_read), key=str)}")
|
||||
print(f" stale verdicts: first None at t="
|
||||
f"{next((i for i, v in enumerate(_stale_read) if v is None), None)}")
|
||||
|
||||
check("⚠️ THE FIXTURE'S OWN PRECONDITION: the two runs saw a BIT-IDENTICAL "
|
||||
"value stream. If this ever stops being true the pair below is comparing "
|
||||
"two different experiments and proves nothing about freshness",
|
||||
_fresh_served == _stale_served
|
||||
and len({v for v in _fresh_served}) == 1)
|
||||
check("FRESH AND UNCHANGED is not stale. Forty identical readings over 39 s "
|
||||
"with the command standing still - a converged site - and the reading "
|
||||
"reaches its consumers untouched",
|
||||
not _fresh.stale and _fresh_read == [A_FROZEN_W] * len(STREAM))
|
||||
check("⚠️ STALE IS. The same forty readings, the same instants, the same "
|
||||
"numbers - and the command moved 2500 W underneath them. The reading "
|
||||
"becomes None to its consumers, which is the whole card",
|
||||
_stale.stale and _stale_read[-1] is None)
|
||||
check("...and it becomes None at the BOUND, not before: 35.0 s of unchanged "
|
||||
"arrivals, so the last fresh verdict is at t=34 and the first None at "
|
||||
"t=35",
|
||||
_stale_read[34] == A_FROZEN_W and _stale_read[35] is None
|
||||
and BATT_UNCHANGED_S == 35.0)
|
||||
check("...and NEVER a held number and never a zero - `reading()` has exactly "
|
||||
"two shapes, the arrived value or None",
|
||||
set(type(v) for v in _stale_read) == {float, type(None)}
|
||||
and 0.0 not in [v for v in _stale_read if v is not None])
|
||||
check("...and the reason names the three terms an operator has to check, with "
|
||||
"the numbers that fired it",
|
||||
"STALE_READING" in _stale.reason and "2500 W" in _stale.reason
|
||||
and "unchanged for 35 s" in _stale.reason)
|
||||
|
||||
# --- 1a. the gate the card asked for first, graded and found unable ----------
|
||||
# An arrival-age gate is what "arrival-stamped" reads as on first hearing. It is
|
||||
# built here and run against the SAME pair, because a criterion that cannot be
|
||||
# satisfied is worth more said out loud than approximated in silence.
|
||||
_arrival_ages = []
|
||||
_af = Freshness(BOUNDS["batt"])
|
||||
for t, v in STREAM:
|
||||
_af.submit(v, A_FROZEN_W, t)
|
||||
# Every read produced a number, so on both members of the pair the age
|
||||
# since the last ARRIVAL is 0.0 at every instant. There is nothing to
|
||||
# threshold.
|
||||
_arrival_ages.append(0.0 if _af.last is not None else None)
|
||||
check("⚠️ AN ARRIVAL-AGE GATE CANNOT TELL THE PAIR APART, and that is why the "
|
||||
"shipped verdict is not one. Home Assistant answered every read with a "
|
||||
"number in BOTH runs, so the age since arrival is 0.0 s at every instant "
|
||||
"of both - a threshold on it is a threshold that can never fire, which "
|
||||
"is TEL-06's defect rebuilt in this repository",
|
||||
set(_arrival_ages) == {0.0} and _af.arrivals == len(STREAM))
|
||||
check("...while the arrival record IS what the shipped verdict is measured on: "
|
||||
"`unchanged_s` is the gap between arrivals that carried different "
|
||||
"numbers, on this process's own monotonic clock, and no Home Assistant "
|
||||
"timestamp is read anywhere in the module",
|
||||
_stale.unchanged_s(39.0) == 39.0
|
||||
and "last_updated" not in io.open(HERE / "app" / "freshness.py",
|
||||
encoding="utf-8").read().split(
|
||||
'"""', 2)[2])
|
||||
check("...and a read that produced NOTHING is still an immediate None, exactly "
|
||||
"as it is today - a failed read is not an arrival and is not an age",
|
||||
Freshness(BOUNDS["batt"]).reading() is None
|
||||
and (lambda f: (f.submit(5.0, 0.0, 0.0), f.submit(None, 0.0, 1.0),
|
||||
f.reading() is None and f.held == 5.0)[-1])(
|
||||
Freshness(BOUNDS["batt"])))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("2. THE WIRING: the same pair through the REAL read path, at a roster of 2")
|
||||
|
||||
|
||||
async def _reads(c, hass, plan):
|
||||
"""Drive real `read_inputs()` calls. `plan` is (t, standing_a, batt_a)."""
|
||||
out = []
|
||||
for t, standing_a, batt_a in plan:
|
||||
hass.numbers["sensor.batt_a"] = batt_a
|
||||
# `last_write` is what the write path sets and what `standing_w()`
|
||||
# reads; setting it here is standing in for a write, not for the gate.
|
||||
c.inverters[0].last_write = standing_a
|
||||
await c.read_inputs(now_mono=t)
|
||||
out.append((t, c.inverters[0].batt, c.inverters[1].batt))
|
||||
return out
|
||||
|
||||
|
||||
_PLAN_FRESH = [(float(t), A_FROZEN_W, A_FROZEN_W) for t in range(0, 40)]
|
||||
_PLAN_STALE = [(float(t), A_FROZEN_W if t < 1 else 1300.0, A_FROZEN_W)
|
||||
for t in range(0, 40)]
|
||||
|
||||
_cf, _hf = site()
|
||||
_of = run(_reads(_cf, _hf, _PLAN_FRESH))
|
||||
_cs, _hs = site()
|
||||
_os = run(_reads(_cs, _hs, _PLAN_STALE))
|
||||
|
||||
print(f" fresh run: A={sorted({r[1] for r in _of})} B={sorted({r[2] for r in _of})}")
|
||||
print(f" stale run: A={sorted({r[1] for r in _os}, key=str)} "
|
||||
f"B={sorted({r[2] for r in _os})}")
|
||||
|
||||
check("PRECONDITION: both runs were served the same per-entity numbers, so the "
|
||||
"difference below is the wiring and not the stimulus",
|
||||
[x for x in _hf.served if x[0] == "sensor.batt_a"]
|
||||
== [x for x in _hs.served if x[0] == "sensor.batt_a"])
|
||||
check("⚠️ THE TWO UNITS CARRY DIFFERENT POWER VALUES - A at -1200 W, B at "
|
||||
"+900 W. A fixture whose units read the same number cannot show a gate "
|
||||
"that answered for the wrong unit, which is the trap the card names",
|
||||
A_FROZEN_W != B_LIVE_W and all(r[2] == B_LIVE_W for r in _of))
|
||||
check("THROUGH THE REAL PATH: a converged site keeps its reading",
|
||||
all(r[1] == A_FROZEN_W for r in _of))
|
||||
check("⚠️ THROUGH THE REAL PATH: a frozen register becomes `Inverter.batt is "
|
||||
"None`. Not a held number, not a zero - the same None a missing entity "
|
||||
"produces, which is the state §11.1 rule 1 already answers with 0 W",
|
||||
_os[34][1] == A_FROZEN_W and _os[35][1] is None
|
||||
and all(r[1] is None for r in _os[35:]))
|
||||
check("...and ONLY the frozen unit's. B's own reading is untouched, which is "
|
||||
"the per-unit half: a freshness verdict about A is not evidence about B",
|
||||
all(r[2] == B_LIVE_W for r in _os) and _cs.inverters[1].batt == B_LIVE_W
|
||||
and not _cs.inverters[1].batt_fresh.stale)
|
||||
check("...and `inputs_ok` goes False, so the site stops rather than regulating "
|
||||
"against a number nobody measured",
|
||||
_cs.inputs_ok is False and _cf.inputs_ok is True)
|
||||
check("...and the operator gets a line saying which unit and why, on the same "
|
||||
"status list the liveness notes use",
|
||||
any("STALE_READING" in n and "inverter-a" in n
|
||||
for n in _cs.liveness_notes())
|
||||
and _cf.liveness_notes() == [])
|
||||
check("...and the raw number is still recorded for diagnostics, so the "
|
||||
"operator can see WHAT it was stuck on",
|
||||
_cs.inverters[0].batt_fresh.last == A_FROZEN_W)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("3. THE CONSUMERS REFUSE - and the counterfactual runs beside them")
|
||||
|
||||
# Both consumers already do the right thing with a None. Neither could ever
|
||||
# receive one before this card, which is the entire exposure: the gate is the
|
||||
# thing that was missing BETWEEN the shipped read and the shipped check.
|
||||
_cfg3 = SignWatchdogConfig(min_w=250.0, hold_s=85.0)
|
||||
check("PRECONDITION on the shipped predicate: a None measurement does not "
|
||||
"disagree with anything, so it CLEARS the sign window",
|
||||
disagrees(1300.0, None, True, _cfg3) is False
|
||||
and disagrees(1300.0, A_FROZEN_W, True, _cfg3) is True)
|
||||
check("PRECONDITION on the shipped sensor: a roster with a missing term is "
|
||||
"None, and None clears the circulation window rather than accumulating",
|
||||
circulating_w([1300.0, None]) is None
|
||||
and circulating_w([A_FROZEN_W, B_LIVE_W]) == 2 * min(
|
||||
abs(A_FROZEN_W), abs(B_LIVE_W)))
|
||||
|
||||
|
||||
async def _cycles(gated, ticks=25, step_mono=12.0, wall=0.20):
|
||||
"""Real cycles at N=2 with A's register frozen and the LOOP driving.
|
||||
|
||||
⚠️ THE COMMAND TRAVEL IS PRODUCED BY THE SHIPPED CONTROL LAW, NOT BY THE
|
||||
FIXTURE. The meter is pinned at +3000 W of import, so `compute()` ramps the
|
||||
site into discharge at its own slew rate through the real allocator and the
|
||||
real write path; nothing here assigns `last_write`. A fixture that set the
|
||||
standing command by hand would be feeding the gate its own arming condition,
|
||||
which is the shape of check this repository keeps finding.
|
||||
|
||||
`gated=False` is THE COUNTERFACTUAL - the shipped behaviour before this
|
||||
card, rebuilt by handing the consumers the raw held number the gate
|
||||
withheld. Same cycles, same stimulus, so the difference between the two
|
||||
columns is the gate and nothing else.
|
||||
|
||||
⚠️ TWO CLOCKS, DELIBERATELY, AND THE WALL-CLOCK ONE IS THE FLAKE RISK.
|
||||
The freshness verdict runs on the INJECTED monotonic clock (12 s a tick, so
|
||||
the 35 s bound is crossed on tick 3, deterministically, however loaded the
|
||||
machine is). The sign watchdog runs on the WALL clock inside `cycle()`,
|
||||
which cannot be injected - so `unit_sign_disagree_s` is set to 2 s and the
|
||||
ticks are 0.20 s apart, putting the gate's close at ~0.6 s against a 2 s
|
||||
window. That ORDER is the field's - 35 s < 75 s < 85 s by construction, part
|
||||
6 - and the gate is given no head start beyond it.
|
||||
|
||||
⚠️ AND THE MARGIN IS MEASURED AND ASSERTED RATHER THAN ASSUMED (CI-06).
|
||||
`test_peakguard.py` answered two of its own checks with the wall clock, flaked
|
||||
at ~20 %, and cost a wrong merge diagnosis - a flaky check does not waste a
|
||||
re-run, it manufactures evidence for whichever hypothesis the reader already
|
||||
holds. So this returns the wall clock at which the gate closed, and the check
|
||||
below fails with THAT NUMBER if the box was too slow, rather than reporting a
|
||||
trip that looks like a code defect.
|
||||
"""
|
||||
c, hass = site({"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 2,
|
||||
"sign_disagree_w": 250, "sign_disagree_s": 2,
|
||||
"site_sign_disagree_w": 250, "site_sign_disagree_s": 2,
|
||||
"auto_start": True},
|
||||
{"sensor.grid": 3000.0})
|
||||
c.auto = True
|
||||
circ, tripped_at, standing = [], None, []
|
||||
t_start, t_gate = time.monotonic(), None
|
||||
for k in range(ticks):
|
||||
# A's register is FROZEN on the charge figure it stopped at; B's is
|
||||
# live and is a DIFFERENT number. Neither is touched by the loop.
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W
|
||||
hass.numbers["sensor.batt_b"] = B_LIVE_W
|
||||
await c.read_inputs(now_mono=k * step_mono)
|
||||
if not gated:
|
||||
for inv in c.inverters:
|
||||
inv.batt = inv.batt_fresh.last
|
||||
if t_gate is None and c.inverters[0].batt_fresh.stale:
|
||||
t_gate = time.monotonic() - t_start
|
||||
circ.append(c.site_circulating_w)
|
||||
standing.append(c.inverters[0].standing_w())
|
||||
await c.cycle()
|
||||
if c.inverters[0].sign_wd.tripped and tripped_at is None:
|
||||
tripped_at = k
|
||||
await asyncio.sleep(wall)
|
||||
return c, circ, tripped_at, standing, t_gate
|
||||
|
||||
|
||||
_cg, _circg, _tripg, _standg, _tgate = run(_cycles(True))
|
||||
_cu, _circu, _tripu, _standu, _ = run(_cycles(False))
|
||||
_WD_S = 2.0 # `unit_sign_disagree_s` as this fixture sets it, above
|
||||
print(f" standing on A (gated): {[round(v) for v in _standg]}")
|
||||
print(f" gated : sign trip tick={_tripg} circ={_circg[:6]}...")
|
||||
print(f" gate closed after {_tgate:.2f} s of WALL clock, against the "
|
||||
f"{_WD_S:.0f} s watchdog window ({_WD_S / _tgate:.1f}x margin)")
|
||||
print(f" counterfact: sign trip tick={_tripu} circ={_circu[:6]}...")
|
||||
|
||||
check("⚠️ PRECONDITION ON THE WALL CLOCK, AND IT IS A CHECK RATHER THAN AN "
|
||||
"ASSUMPTION (CI-06). The gate closes on the INJECTED clock, the watchdog "
|
||||
"on the real one, so this fixture only means something while the gate "
|
||||
f"closes well inside the window: it closed at {_tgate:.2f} s against "
|
||||
f"{_WD_S:.0f} s. If this fails the machine was slow, not the code - and "
|
||||
"saying so here is what stops that being read as a defect",
|
||||
_tgate is not None and _tgate < 0.5 * _WD_S)
|
||||
check("PRECONDITION: the SHIPPED control law really did travel the command - "
|
||||
"the gate's arming term is produced by `compute()` and the allocator, "
|
||||
"not written into the fixture",
|
||||
max(abs(v) for v in _standu) > MOVE_W
|
||||
and abs(_standu[3] - _standu[0]) > MOVE_W)
|
||||
|
||||
check("⚠️ COUNTERFACTUAL FIRST - the stimulus is REAL. Without the gate, the "
|
||||
"frozen register false-trips SAFETY-30's per-unit sign check and parks a "
|
||||
"healthy inverter. That is the fault this card was filed for",
|
||||
_tripu is not None and _cu.inverters[0].sign_wd.tripped
|
||||
and "SIGN_DISAGREEMENT" in _cu.inverters[0].sign_wd.reason)
|
||||
check("⚠️ AND WITH THE GATE IT DOES NOT TRIP. The reading is withheld before "
|
||||
"the watchdog's window can accumulate, so the check refuses to conclude "
|
||||
"rather than concluding wrongly - which is what a stale reading deserves",
|
||||
_tripg is None and not _cg.inverters[0].sign_wd.tripped)
|
||||
check("...and inverter B was not parked either way - the verdict is per unit",
|
||||
not _cg.inverters[1].sign_wd.tripped
|
||||
and not _cu.inverters[1].sign_wd.tripped)
|
||||
check("⚠️ COUNTERFACTUAL: without the gate the circulating-power sensor "
|
||||
"reports a loop that is not happening, computed from one frozen term and "
|
||||
"one live one",
|
||||
all(v == 2 * min(abs(A_FROZEN_W), abs(B_LIVE_W)) for v in _circu)
|
||||
and _circu[0] == 1800.0)
|
||||
check("⚠️ AND WITH THE GATE IT REPORTS `None` - an unknown, which CLEARS the "
|
||||
"circulation window instead of accumulating it. Both consumers gated, "
|
||||
"from one place, by one mechanism",
|
||||
_circg[0] == 1800.0 and _circg[-1] is None
|
||||
and all(v is None for v in _circg[_circg.index(None):]))
|
||||
check("...and the circulation watchdog did not latch on the stale evidence",
|
||||
not _cg.circ_wd.tripped and _cg.circ_wd.over_since is None)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("4. THE LATCH, and the ~1 Hz oscillation it exists to prevent")
|
||||
|
||||
# Declaring the reading stale makes inputs_ok False, which commands 0 W, which
|
||||
# drops the standing command below MOVE_W, which un-arms the conjunction. Without
|
||||
# a latch the held number would come straight back to the consumers, and the
|
||||
# whole thing would flip once per read for as long as the register stayed dead.
|
||||
_lat = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_lat.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
_was = _lat.stale
|
||||
_after_zero = []
|
||||
for t in range(40, 50):
|
||||
_lat.submit(A_FROZEN_W, 0.0, float(t)) # the site has been stopped
|
||||
_after_zero.append(_lat.reading())
|
||||
check("PRECONDITION: it is stale before the site is stopped",
|
||||
_was and _lat.reading() is None)
|
||||
check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W. The conjunction is no "
|
||||
"longer armed - |standing| is 0 W, below the 250 W term - so an unlatched "
|
||||
"verdict would hand the held -1200 W straight back and the site would "
|
||||
"restart into it, then stop, then restart, once per read",
|
||||
all(v is None for v in _after_zero))
|
||||
# ⚠️ AND A FAILED READ MUST NOT CLEAR IT EITHER. This check exists because the
|
||||
# mutation that added `self.stale = False` to `submit()`'s None branch SURVIVED
|
||||
# the whole sweep - no fixture had ever injected a read failure while latched.
|
||||
# It matters: a stale verdict stops the site, the site is then commanded 0 W, and
|
||||
# a single unreachable-Home-Assistant read would drop the latch into a state
|
||||
# where the conjunction is no longer armed - so the held -1200 W would come back
|
||||
# to the consumers on the very next successful read.
|
||||
_lat_fail = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_lat_fail.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
_lat_fail.submit(None, 0.0, 40.0) # Home Assistant did not answer
|
||||
_after_fail = _lat_fail.reading()
|
||||
_lat_fail.submit(A_FROZEN_W, 0.0, 41.0) # it answers again, same number,
|
||||
# site now resting at 0 W
|
||||
check("⚠️ A FAILED READ DOES NOT CLEAR THE LATCH - found by a SURVIVING "
|
||||
"mutation, not by design. Our inability to fetch a value is not evidence "
|
||||
"that the value moved, and a stale verdict that a single unreachable read "
|
||||
"can drop is one a stopped site would drop every time",
|
||||
_after_fail is None and _lat_fail.stale
|
||||
and _lat_fail.reading() is None)
|
||||
|
||||
check("⚠️ AND ONLY MOVEMENT CLEARS IT - a different number arriving, which is "
|
||||
"exactly the evidence the verdict was about. Not a timer, not a restart, "
|
||||
"not the operator's start button",
|
||||
(lambda f: (f.submit(A_FROZEN_W + 1.0, 0.0, 60.0),
|
||||
(not f.stale) and f.reading() == A_FROZEN_W + 1.0)[-1])(_lat))
|
||||
# ⚠️ THE MAGNITUDE TERM OF THE CONJUNCTION, AND THIS CHECK EXISTS BECAUSE A
|
||||
# MUTATION SURVIVED WITHOUT IT. Deleting `abs(standing_w) < move_w` from
|
||||
# `_grade()` left every other check in this file green, because in all of them a
|
||||
# command that has TRAVELLED 250 W has also ENDED UP above 250 W. The case that
|
||||
# separates them is a site being brought to REST.
|
||||
_rest = Freshness(BOUNDS["batt"])
|
||||
# The register reaches +5 W on the same read at which the old +1300 W command is
|
||||
# still standing - the write path is serialised, so the register can catch up
|
||||
# BEFORE the command settles - and then the site is commanded to 0 W and the
|
||||
# register sits at +5 W, correctly, for a minute.
|
||||
_rest.submit(1290.0, 1300.0, 0.0)
|
||||
_rest.submit(5.0, 1300.0, 1.0) # standing_at_change captures 1300 W
|
||||
for t in range(2, 60):
|
||||
_rest.submit(5.0, 0.0, float(t)) # site at rest; register truthfully +5 W
|
||||
check("⚠️ THE MAGNITUDE TERM IS LOAD-BEARING, and it took a surviving mutation "
|
||||
"to show it. A register that reached +5 W while the old +1300 W command "
|
||||
"was still standing has a `standing_at_change` of 1300 W, so the TRAVEL "
|
||||
"term alone reads 1300 W of unfollowed command for the whole minute the "
|
||||
"site rests - and calls a perfectly truthful reading dead. Requiring "
|
||||
"|standing| >= 250 W as well is what says 'a site at rest is not owed "
|
||||
"movement'",
|
||||
not _rest.stale and _rest.reading() == 5.0)
|
||||
check("...and the hole that term leaves CANNOT MISLEAD EITHER CONSUMER, which "
|
||||
"is why it is the right trade: a unit commanded under 250 W is one "
|
||||
"`watchdog.disagrees()` is False for whatever the measurement says, and a "
|
||||
"frozen term beside a live unit that is itself resting nets to zero "
|
||||
"circulating power rather than to a false alarm",
|
||||
disagrees(0.0, A_FROZEN_W, True, _cfg3) is False
|
||||
and circulating_w([A_FROZEN_W, 0.0]) == 0.0)
|
||||
|
||||
check("...and one watt is enough, because 0x0518 is a 1 W register that "
|
||||
"dithers +/-1 W while holding (goodwe/NOTES.md §5.11, measured "
|
||||
"2026-08-22) - a working register clears this on its next 10 s publish",
|
||||
_lat.held == A_FROZEN_W + 1.0 and not _lat.stale)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("5. N=1: nothing changes on a healthy install, and what DOES is named")
|
||||
|
||||
_ONE = dict(BASE, inverters=[], setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
||||
bms_soc_floor=A_FLOOR)
|
||||
|
||||
|
||||
def one_site():
|
||||
hass = Hass({"sensor.grid": 0.0, "sensor.soc_a": 50.0,
|
||||
"sensor.batt_a": A_FROZEN_W},
|
||||
{"number.sp_a": (-A_RATING, A_RATING, 1.0)})
|
||||
c = Controller(dict(_ONE), hass, MemStore(), Pub(), stop=asyncio.Event())
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
|
||||
return c, hass
|
||||
|
||||
|
||||
async def _one_healthy():
|
||||
c, hass = one_site()
|
||||
seen = []
|
||||
for t in range(0, 60):
|
||||
# A healthy register under a moving command: it dithers, so it is never
|
||||
# the same integer watt twice running.
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W + (t % 3)
|
||||
c.inverters[0].last_write = 1300.0 if t % 2 else -1200.0
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
seen.append(c.inverters[0].batt)
|
||||
return c, seen
|
||||
|
||||
|
||||
_c1, _seen1 = run(_one_healthy())
|
||||
check("⚠️ AC: AT N=1 NOTHING CHANGES ON A HEALTHY INSTALL, and it is asserted "
|
||||
"rather than assumed. Sixty reads of a dithering register under a command "
|
||||
"reversing every second: every value reaches the consumers unmodified, "
|
||||
"the gate never arms, and the status page says nothing new",
|
||||
_seen1 == [A_FROZEN_W + (t % 3) for t in range(60)]
|
||||
and not _c1.inverters[0].batt_fresh.stale
|
||||
and _c1.liveness_notes() == [] and _c1.inputs_ok is True)
|
||||
check("...and the shipped roster really is one unit, so this is the install "
|
||||
"that is running in a house today and not a fixture shaped like it",
|
||||
len(_c1.inverters) == 1 and _c1.inverters[0].name == "inverter-a")
|
||||
|
||||
|
||||
async def _one_frozen():
|
||||
c, hass = one_site()
|
||||
for t in range(0, 40):
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W
|
||||
c.inverters[0].last_write = A_FROZEN_W if t < 1 else 1300.0
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
return c
|
||||
|
||||
|
||||
_c1f = run(_one_frozen())
|
||||
check("⚠️ AND WHAT DOES CHANGE AT N=1 IS NAMED RATHER THAN HIDDEN: a FROZEN "
|
||||
"register now stops the site, where before it read as a healthy "
|
||||
"measurement. That is a behaviour change on the shipped roster and it is "
|
||||
"the point of the card - HW-01 was a one-inverter site, and the pack "
|
||||
"drained to its floor with every detector reading healthy for 6 h 25 m",
|
||||
_c1f.inverters[0].batt is None and _c1f.inputs_ok is False)
|
||||
check("...and the direction it fails in is the safe one: a false verdict "
|
||||
"commands 0 W and keeps commanding (§11.1 rule 1), behind the existing "
|
||||
"`stale_input_s` hold, and clears itself on the register's next move",
|
||||
float(_c1f.o.get("stale_input_s")) > 0
|
||||
and _c1f.inverters[0].batt_fresh.stale)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("6. THE BOUNDS, graded against the SHIPPED config.yaml at BOTH ends")
|
||||
|
||||
|
||||
def option_default(key):
|
||||
"""`key`'s default out of config.yaml's `options:` block, at two spaces."""
|
||||
body = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
body = body[body.index("\noptions:"):body.index("\nschema:")]
|
||||
for line in body.splitlines():
|
||||
if line.startswith(f" {key}:") and not line[2:3].isspace():
|
||||
return float(line.split(":", 1)[1].strip())
|
||||
return None
|
||||
|
||||
|
||||
_circ_hold = option_default("circulation_hold_s")
|
||||
_unit_hold = option_default("unit_sign_disagree_s")
|
||||
_unit_min_w = option_default("unit_sign_disagree_w")
|
||||
_ceiling = min(_circ_hold, _unit_hold)
|
||||
print(f" floor {BATT_OBLIGED_S:.1f} s = settle 6.0 + publish 10.0 + read 1.0")
|
||||
print(f" bound {BATT_UNCHANGED_S:.1f} s")
|
||||
print(f" ceil {_ceiling:.1f} s = min(circulation_hold_s {_circ_hold:.0f}, "
|
||||
f"unit_sign_disagree_s {_unit_hold:.0f})")
|
||||
|
||||
check("PRECONDITION: both consumer windows were actually found in config.yaml, "
|
||||
"so a rename cannot turn this part into a check of two Nones",
|
||||
_circ_hold == 75.0 and _unit_hold == 85.0 and _unit_min_w == 250.0)
|
||||
check("⚠️ THE BOUND IS AT LEAST 2x THE PHYSICAL FLOOR. Below 17.0 s a healthy "
|
||||
"register has not yet been OBLIGED to answer - 6.0 s of plant "
|
||||
"(NOTES §5.9), one 10 s ESPHome publish (firmware/goodwe-master.yaml:181) "
|
||||
"and one 1 s add-on read - so a shorter bound trips on a working inverter",
|
||||
BATT_UNCHANGED_S >= 2 * BATT_OBLIGED_S and BATT_OBLIGED_S == 17.0)
|
||||
check("⚠️ AND AT MOST HALF THE TIGHTER CONSUMER WINDOW. A gate slower than its "
|
||||
"consumer hands over the frozen number and explains afterwards. THIS IS "
|
||||
"THE CHECK THAT BITES IF AN OPERATOR'S DEFAULT MOVES: drop "
|
||||
"`circulation_hold_s` to 60 in config.yaml and this goes red",
|
||||
BATT_UNCHANGED_S <= 0.5 * _ceiling)
|
||||
# ⚠️ THE FIRST DRAFT OF THIS CHECK CLAIMED THE BAND CONTAINED EXACTLY ONE
|
||||
# INTEGER AND IT DOES NOT - it contains 34, 35, 36 and 37, and the check went
|
||||
# red on its first run and is written the honest way round here. The band is
|
||||
# what is derived; the value inside it is a choice, and the only defensible
|
||||
# claims about the choice are that it IS inside and that it is not a number
|
||||
# borrowed from somewhere else.
|
||||
check("...and the admissible band computed from config.yaml is [34.0, 37.5], "
|
||||
"which contains four integers. 35 is a CHOICE inside a DERIVED band, not "
|
||||
"a derived number, and it is deliberately not 30 - which is "
|
||||
"FIRMWARE_INGEST_VETO_S and would read as a borrowed constant - or 60, "
|
||||
"which is round",
|
||||
[n for n in range(0, 200)
|
||||
if 2 * BATT_OBLIGED_S <= n <= 0.5 * _ceiling] == [34, 35, 36, 37]
|
||||
and BATT_UNCHANGED_S not in (30.0, 60.0))
|
||||
check("⚠️ AND `MOVE_W` IS PINNED TO config.yaml TOO. It is the largest value "
|
||||
"that still arms the gate everywhere the per-unit sign check can speak; "
|
||||
"raise `unit_sign_disagree_w` and the gate would stop arming in states "
|
||||
"that check still fires in, which this catches",
|
||||
MOVE_W == _unit_min_w)
|
||||
check("...and the module's three floor terms are the numbers this repository "
|
||||
"actually carries, not a transcription of them: the ESPHome publish "
|
||||
"interval is read back out of the shipped firmware",
|
||||
"update_interval: 10s" in io.open(
|
||||
HERE.parent / "firmware" / "goodwe-master.yaml",
|
||||
encoding="utf-8").read()
|
||||
and F.PUBLISH_PERIOD_S == 10.0 and F.READ_PERIOD_S == 1.0
|
||||
and F.PLANT_SETTLE_S == 6.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("7. THE POLICY TABLE tells the truth about what is WIRED")
|
||||
|
||||
def bounds_users(key):
|
||||
"""Modules that SUBSCRIPT `BOUNDS[key]` in CODE - parsed, not grepped.
|
||||
|
||||
⚠️ THE FIRST DRAFT GREPPED FOR THE STRING AND ANSWERED WRONG, because both
|
||||
`inverter.py` and `main.py` NAME `BOUNDS["soc"]` in a comment explaining why
|
||||
it is not wired. A text search cannot tell a mention from a use, and this
|
||||
part's whole job is to tell them apart - so it walks the AST instead.
|
||||
"""
|
||||
out = []
|
||||
for path in sorted((HERE / "app").glob("*.py")):
|
||||
if path.name == "freshness.py":
|
||||
continue
|
||||
tree = ast.parse(io.open(path, encoding="utf-8").read())
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Subscript)
|
||||
and isinstance(node.slice, ast.Constant)
|
||||
and node.slice.value == key
|
||||
and ((isinstance(node.value, ast.Name)
|
||||
and node.value.id == "BOUNDS")
|
||||
or (isinstance(node.value, ast.Attribute)
|
||||
and node.value.attr == "BOUNDS"))):
|
||||
out.append(path.name)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
_uses_batt = bounds_users("batt")
|
||||
_uses_soc = bounds_users("soc")
|
||||
for _k, _b in BOUNDS.items():
|
||||
print(f" {_k:5} wired={_b.wired!s:5} unchanged={_b.unchanged_s:>6} "
|
||||
f"move={_b.move_w:>6} {_b.signal}")
|
||||
print(f" BOUNDS['batt'] subscripted in CODE by: {_uses_batt}")
|
||||
print(f" BOUNDS['soc'] subscripted in CODE by: {_uses_soc or 'nothing'}")
|
||||
|
||||
check("⚠️ `wired=True` MEANS SOMETHING CONSUMES IT. A policy table nobody "
|
||||
"checks is a comment; this is the row `batt` claims and the module that "
|
||||
"claims it",
|
||||
BOUNDS["batt"].wired is True and _uses_batt == ["inverter.py"])
|
||||
check("⚠️ AND `wired=False` MEANS NOTHING DOES. SoC is TEL-08's and it is "
|
||||
"STATED here rather than wired, because its obligation term is energy "
|
||||
"through the pack and the constant that needs has never been measured on "
|
||||
"this site. A guessed constant would fire on a pack resting at its floor",
|
||||
BOUNDS["soc"].wired is False and _uses_soc == [])
|
||||
check("...and the SoC row says what TEL-08 owes, in the row itself, so the "
|
||||
"next reader does not have to reconstruct why it is unwired",
|
||||
"energy" in BOUNDS["soc"].derivation
|
||||
and "153.6 Wh" in BOUNDS["soc"].derivation
|
||||
and "TEL-08" in BOUNDS["soc"].derivation)
|
||||
check("...and every wired bound is a real number - an infinite bound is how a "
|
||||
"row silently becomes decorative",
|
||||
all(b.unchanged_s < float("inf") and b.move_w < float("inf")
|
||||
for b in BOUNDS.values() if b.wired))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("8. THE MEASUREMENT the bound rests on, replayed from the committed data")
|
||||
|
||||
# ⚠️ THIS IS THE PART THAT STOPS THE DERIVATION BECOMING A STORY. The docstring
|
||||
# in freshness.py quotes three numbers off this capture; they are recomputed
|
||||
# here from the committed files, so a figure that drifts from its evidence goes
|
||||
# red instead of being re-quoted forever.
|
||||
#
|
||||
# ⚠️ AND IT IS THE METER'S REGISTER, NOT 0x0518. No 30-day capture of the
|
||||
# inverter's own power exists. What this establishes is the only thing it is
|
||||
# used for: that the legitimate unchanged duration of a 1 W power entity in this
|
||||
# house is MINUTES, so an unchanged threshold alone has no admissible value.
|
||||
_cap = sorted(glob.glob(str(HERE / "scenarios" / "p1-net-30d-2026-08"
|
||||
/ "p1-net-*.jsonl.gz")))
|
||||
_runs, _prev = [], None
|
||||
_t0 = time.monotonic()
|
||||
for _f in _cap:
|
||||
with gzip.open(_f, "rt") as _fh:
|
||||
for _line in _fh:
|
||||
_r = json.loads(_line)
|
||||
_t = datetime.fromisoformat(_r["t"]).timestamp()
|
||||
if _prev is not None:
|
||||
_runs.append((_t - _prev, _pw))
|
||||
_prev, _pw = _t, float(_r["w"])
|
||||
_worst = max(_runs)[0] if _runs else 0.0
|
||||
_over_bound = sum(1 for d, _ in _runs if d >= BATT_UNCHANGED_S)
|
||||
print(f" {len(_cap)} days, {len(_runs) + 1} samples, "
|
||||
f"{time.monotonic() - _t0:.1f}s to replay")
|
||||
print(f" longest unchanged run {_worst:.1f} s; "
|
||||
f"{_over_bound} runs >= {BATT_UNCHANGED_S:.0f} s")
|
||||
|
||||
check("PRECONDITION: the committed capture is all thirty days, so this is not "
|
||||
"a claim about whatever files happened to be present",
|
||||
len(_cap) == 30 and len(_runs) + 1 == 485841)
|
||||
check("⚠️ A REAL 1 W POWER ENTITY HELD ONE VALUE FOR 263.2 s WITH NOTHING "
|
||||
"WRONG. That single measurement is why the bound is a conjunction and "
|
||||
"not a threshold: no unchanged threshold under 263 s is free of false "
|
||||
"positives, and 263 s is 3.5x too slow to protect a 75 s consumer",
|
||||
abs(_worst - 263.2) < 0.1 and _worst > _ceiling)
|
||||
check("...and the two figures freshness.py's docstring quotes off this capture "
|
||||
"are the ones it produces today",
|
||||
_over_bound == 10
|
||||
and sum(1 for d, _ in _runs if d >= BATT_OBLIGED_S) == 386)
|
||||
check("⚠️ AND THE LONG RUNS ARE ALL UNDER AN UNMOVING LOAD, which is what the "
|
||||
"conjunction keys on. The worst one is 263 s of a steady -899 W export - "
|
||||
"a house doing one thing - so a gate that also requires the COMMAND to "
|
||||
"have travelled 250 W is not armed by any of them",
|
||||
max(_runs)[1] == -899.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("9. KNOWN SURVIVORS - graded, so they are residuals and not surprises")
|
||||
|
||||
# ⚠️ THE REPO'S RECURRING DEFECT IS A CHECK WHOSE DATA CANNOT REACH THE FAILURE
|
||||
# IT NAMES. The honest end of that habit is to build the mutations this file
|
||||
# would NOT see and record them. Each one below is a real hole in the shipped
|
||||
# gate, demonstrated rather than described.
|
||||
|
||||
# S1. A register pinned by the INVERTER's own limit, not by a fault. If the
|
||||
# device is derating and cannot follow a command that ASKS FOR MORE, the
|
||||
# register can legitimately hold while the command travels.
|
||||
_s1 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_s1.submit(2503.0, 2650.0 if t < 1 else 4600.0, float(t))
|
||||
check("⚠️ SURVIVOR 1, FALSE POSITIVE: a saturated or derating inverter pinned "
|
||||
"at 2503 W while the command climbs to 4600 W is declared stale. The "
|
||||
"register is telling the truth and the gate calls it dead",
|
||||
_s1.stale)
|
||||
check("...and its SIZE is bounded and its direction is the safe one: the "
|
||||
"verdict is a site stop, behind the `stale_input_s` hold, and the moment "
|
||||
"the command comes back down the register moves and the latch clears. "
|
||||
"Cost = one spurious stop; it cannot drive the battery anywhere",
|
||||
(lambda f: (f.submit(2400.0, 0.0, 60.0), not f.stale)[-1])(_s1))
|
||||
|
||||
# S2. A unit whose command never travels 250 W. HW-01's own shape, at a
|
||||
# smaller scale, and the gate is deliberately not armed for it.
|
||||
_s2 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 400):
|
||||
_s2.submit(A_FROZEN_W, -1200.0 if t < 1 else -1400.0, float(t))
|
||||
check("⚠️ SURVIVOR 2, MISS: a register frozen under a command that only ever "
|
||||
"moves 200 W is NEVER detected, for 400 s or forever. This is scoped, "
|
||||
"not overlooked - such a unit is one no per-unit sign check can speak "
|
||||
"about (`unit_sign_disagree_w` is 250 W) and one contributing at most "
|
||||
"250 W to a loop whose reporting floor is 700 W",
|
||||
not _s2.stale and _s2.reading() == A_FROZEN_W)
|
||||
|
||||
# S3. A register that dithers on dead data. A publisher that keeps republishing
|
||||
# a stale WINDOW of values rather than one value defeats this entirely.
|
||||
_s3 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 400):
|
||||
_s3.submit(A_FROZEN_W + (t % 2), 1300.0, float(t))
|
||||
check("⚠️ SURVIVOR 3, MISS, AND IT IS THE LARGEST ONE: a dead publisher whose "
|
||||
"last value happens to ALTERNATE - or a cache replaying two old samples - "
|
||||
"never triggers the unchanged clock at all. This gate detects a value "
|
||||
"that STOPPED; it cannot detect a value that is WRONG. Nothing in this "
|
||||
"add-on can, and TEL-06's arrival stamp inside the firmware is the only "
|
||||
"layer that could",
|
||||
not _s3.stale and _s3.reading() is not None)
|
||||
|
||||
# S4. The gate is per unit, so a whole-site publisher failure that freezes both
|
||||
# registers is caught twice rather than missed - recorded because a reader
|
||||
# might expect the opposite.
|
||||
_s4a, _s4b = Freshness(BOUNDS["batt"]), Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_s4a.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
_s4b.submit(B_LIVE_W, B_LIVE_W if t < 1 else -1300.0, float(t))
|
||||
check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
|
||||
"that freezes BOTH registers is caught on both units independently, on "
|
||||
"each unit's own command travel and each unit's own number",
|
||||
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1529,14 +1529,23 @@ check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
|
||||
"⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS "
|
||||
"`int(0,50)?`, and the difference is the whole of that card: an optional "
|
||||
"per-pack cut-out is a DEFAULTED one, and a default is one number for "
|
||||
"two batteries - the shallower pack protected by the deeper pack's BMS",
|
||||
"two batteries - the shallower pack protected by the deeper pack's BMS. "
|
||||
"⚠️ OPT-09 ADDED `capacity_kwh` AND MADE THE OPPOSITE CHOICE ON PURPOSE: "
|
||||
"it is OPTIONAL (`float(0.1,200)?`) because an absent capacity has an "
|
||||
"honest answer - the add-on then has no opinion about the SPLIT and every "
|
||||
"unit weighs 1.0, which is exactly what shipped - where an absent cut-out "
|
||||
"would have to be invented. If you are here because you added a REQUIRED "
|
||||
"key, read `Inverter.__init__` before changing this line: a required key "
|
||||
"refuses every options blob written before it existed",
|
||||
set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
|
||||
"soc_entity", "batt_entity", "batt_invert"}
|
||||
"soc_entity", "batt_entity", "batt_invert",
|
||||
"capacity_kwh"}
|
||||
and not any(_decl9[k].endswith("?")
|
||||
for k in ("name", "setpoint_entity", "max_w",
|
||||
"bms_soc_floor"))
|
||||
and all(_decl9[k].endswith("?")
|
||||
for k in ("soc_entity", "batt_entity", "batt_invert")))
|
||||
for k in ("soc_entity", "batt_entity", "batt_invert",
|
||||
"capacity_kwh")))
|
||||
|
||||
# ⚠️ NOTHING IS INHERITED, AND THIS IS DEV-02 RESTATED AS A CONFIG RULE.
|
||||
# ⚠️ THE FLAT KEYS ARE SET TO REAL ENTITIES HERE, AND THAT IS THE WHOLE CHECK.
|
||||
@@ -1911,8 +1920,18 @@ _AUDITED = {
|
||||
("test_maintenance.py", "shipped_default"),
|
||||
("test_maintenance.py", "schema_range"),
|
||||
("test_freeze.py", "_cfg_default"),
|
||||
# SAFETY-37. Found by this very check on its first run - `option_default()`
|
||||
# reads the two consumer windows the freshness bound is derived from, so it
|
||||
# is a key-taking reader and belongs here rather than outside the audit.
|
||||
("test_freshness.py", "option_default"),
|
||||
("test_price.py", "shipped"),
|
||||
("test_tariff.py", "shipped_option"),
|
||||
# SAFETY-42. `read_option()` there takes a key and reads config.yaml, so the
|
||||
# signature puts it in this table whether anybody remembered it or not -
|
||||
# which is what this table is for. It answers the SITE value because it is
|
||||
# anchored at exactly two spaces; a loose-whitespace pattern would have
|
||||
# answered a per-inverter `max_w`, which is the defect below.
|
||||
("test_delivery.py", "read_option"),
|
||||
}
|
||||
# Helpers the audited readers call. Not called with a key themselves, so there
|
||||
# is nothing to feed them; they are exec'd so their callers work.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2576,9 +2576,42 @@ check("...and so does the persistent notification, which outlives the page and "
|
||||
# observe exactly {device, supervised, loop} at every SoC, because the shipped
|
||||
# roster is one inverter and this claim cannot be filed there at all. See
|
||||
# DECISIONS/SAFETY-30-per-unit-sign.md.
|
||||
# ⚠️ OPT-09 ADDED THE ELEVENTH, AND IT IS THE ONE THIS TRIPWIRE WAS BUILT FOR
|
||||
# THE SHAPE OF, SO IT NEEDS THE LONGEST PARAGRAPH. `pack` is a
|
||||
# `Claim.limit(... P_SAFETY ...)` on ONE INVERTER'S actuator, filed only where it
|
||||
# BINDS and only at a roster of TWO OR MORE, carrying that pack's own state of
|
||||
# charge against that pack's own `bms_soc_floor`: `hi = 0` for a battery at or
|
||||
# under its cut-out, `lo = 0` for one at 100 %.
|
||||
#
|
||||
# IT IS CONDITIONED ON A STATE OF CHARGE, WHICH IS EXACTLY WHAT THIS SET WATCHES
|
||||
# FOR, so the argument has to be made rather than asserted. SAFETY-25's deferred
|
||||
# Option B was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
|
||||
# `loop_soc_floor` option: an EMS PREFERENCE, chosen by an operator, stopping the
|
||||
# grid-following loop at a depth somebody picked, ON THE SITE. Four things
|
||||
# separate this from it, and all four are graded elsewhere in this repository:
|
||||
#
|
||||
# 1. THE NUMBER IS NOT AN OPTION. It is `bms_soc_floor` - measured off
|
||||
# register 0x0560 and required of every roster entry since SAFETY-32. No
|
||||
# new option is added and `peak_soc_floor` is deliberately NOT consulted
|
||||
# (test_packshare.py part 2 grades that a pack at 15 % with a 10 % cut-out
|
||||
# is NOT closed, though a 20 % EMS floor would have closed it).
|
||||
# 2. IT IS A UNIT CLAIM, SO IT CANNOT BOUND THE SITE TARGET. It changes WHICH
|
||||
# INVERTER carries the target, never how big the target is.
|
||||
# 3. IT IS NOT FILED AT ALL AT A ROSTER OF ONE, which is why the sweeps below
|
||||
# still observe exactly {device, supervised, loop} at every state of charge
|
||||
# from 0 to 100 % on the shipped install. Option A stands untouched there:
|
||||
# ordinary self-consumption still discharges to the BMS cut-out.
|
||||
# 4. AND THE MEMO ITSELF NAMES THIS AS THE REVISIT: "under ALLOC-01 the
|
||||
# reserve becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a
|
||||
# ninth claim here". Stage 1 is how an interval reaches the allocator, and
|
||||
# stage 1's input is claims - so a unit-scoped claim is the shape that
|
||||
# sentence asks for.
|
||||
#
|
||||
# If you are here because you added a TWELFTH, that argument is what this line
|
||||
# wants from you, not an edit.
|
||||
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||||
"arbitrage", "maintenance", "loop", "circulation",
|
||||
"sign_site"}
|
||||
"sign_site", "pack"}
|
||||
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
|
||||
# that a THIRD such indirection reads as an unrecognised append rather than as a
|
||||
# silent gap in the scan.
|
||||
@@ -2619,10 +2652,11 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
|
||||
"- a claim filed through a shape it cannot parse has to fail here rather "
|
||||
"than be counted as nothing, which is how a scan like this goes quietly "
|
||||
"vacuous", len(_appends) >= 10 and None not in _filed)
|
||||
check("...and the sources that cycle can file are EXACTLY these nine. THIS is "
|
||||
"what makes part 10 a tripwire: replay_loop() hand-builds the claim list, "
|
||||
"so it cannot see a claim main.py adds - and a loop-side SoC reserve is a "
|
||||
"NINTH source. Filing one takes this line red on the line it is written",
|
||||
check("...and the sources that cycle can file are EXACTLY these ELEVEN. THIS "
|
||||
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
|
||||
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
|
||||
"reserve is a new source. Filing one takes this line red on the line it "
|
||||
"is written, and OPT-09's `pack` had to argue its way past it above",
|
||||
set(_filed) == CLAIM_SOURCES)
|
||||
|
||||
|
||||
@@ -2749,8 +2783,8 @@ check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into "
|
||||
# structurally impossible. The structure is `CLAIM_SCOPE`, so `CLAIM_SCOPE` is
|
||||
# pinned to the AST-scanned set rather than trusted to agree with it.
|
||||
check("every source main.py can file is CLASSIFIED site or per-inverter, and "
|
||||
"nothing else is. A TENTH source now goes red twice - once here for "
|
||||
"having no classification, once above for being a tenth - and it takes an "
|
||||
"nothing else is. A TWELFTH source now goes red twice - once here for "
|
||||
"having no classification, once above for being a twelfth - and it takes an "
|
||||
"argument about what the claim is ABOUT to make either line green",
|
||||
set(CLAIM_SCOPE) == CLAIM_SOURCES)
|
||||
print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
|
||||
@@ -2758,8 +2792,8 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
|
||||
+ " | per-inverter: "
|
||||
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
|
||||
check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
|
||||
"SAFETY-30's additions: the six about the GRID CONNECTION are site "
|
||||
"claims and the four about a pack, a device or one write are "
|
||||
"SAFETY-30's and OPT-09's additions: the six about the GRID CONNECTION "
|
||||
"are site claims and the five about a pack, a device or one write are "
|
||||
"per-inverter. `circulation` is a site claim because its quantity is "
|
||||
"defined over the roster and is identically zero for any one unit; "
|
||||
"`sign_site` is one because the number that tripped it is the SITE "
|
||||
@@ -2769,7 +2803,7 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
|
||||
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
|
||||
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
|
||||
== {"device", "supervised", "failsafe", "maintenance"})
|
||||
== {"device", "supervised", "failsafe", "maintenance", "pack"})
|
||||
|
||||
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
|
||||
# real cycle files is a site claim. Observed on the claims main.py's own resolve
|
||||
@@ -2853,8 +2887,9 @@ _restore_b()
|
||||
print(" with a second inverter on the roster, main.py routes: "
|
||||
+ ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items())))
|
||||
check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
"inverter and its OWN routing method splits the ten: `device`, "
|
||||
"`supervised`, `failsafe` and `maintenance` bind the unit this controller "
|
||||
"inverter and its OWN routing method splits the eleven: `device`, "
|
||||
"`supervised`, `failsafe`, `maintenance` and OPT-09's `pack` bind the "
|
||||
"unit this controller "
|
||||
"drives, `loop`, `peak`, `arbitrage`, `safety`, SAFETY-31's `circulation` "
|
||||
"and SAFETY-30's `sign_site` stay on the grid connection. Swap any one of "
|
||||
"them - route `peak` to the unit and the site keeps no floor; route "
|
||||
@@ -2865,6 +2900,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
"rest of the roster goes on pushing the same way - and this line goes red",
|
||||
_routed == {"device": "inverter-a", "supervised": "inverter-a",
|
||||
"failsafe": "inverter-a", "maintenance": "inverter-a",
|
||||
"pack": "inverter-a",
|
||||
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
|
||||
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
|
||||
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
|
||||
@@ -2948,16 +2984,16 @@ _unit_sources_unrouted = [
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
|
||||
print(f" {len(_direct)} direct appends, each read at its own line: "
|
||||
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
|
||||
check("all TEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
check("all ELEVEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
|
||||
"of EVERY one of them. An append that computed its actuator, or omitted "
|
||||
"the argument, or hand-wrote a string, fails HERE rather than being "
|
||||
"silently skipped - which is how a scan like this goes quietly vacuous, "
|
||||
"and 10f is in this file because that already happened once",
|
||||
len(_direct) == 10
|
||||
len(_direct) == 11
|
||||
and all(a is not None and a.startswith("self.actuator_of('")
|
||||
for _s, a, _ln in _routing))
|
||||
check("⚠️ AND AT EVERY ONE OF THE TEN THE LITERAL IT ROUTES THROUGH IS THE "
|
||||
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
|
||||
"SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed "
|
||||
"`actuator_of(...)` at a call site goes red on - all nine of them, on "
|
||||
"the line the typo is written, at every roster, INCLUDING the six "
|
||||
@@ -2971,14 +3007,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
|
||||
"site-scoped sources and is the whole defect for a per-inverter one: a "
|
||||
"`device` limit built inside the roster loop and routed without `inv` "
|
||||
"files inverter B's rating on inverter A, where it binds the wrong "
|
||||
"share. Five of the ten appends are SCOPE_UNIT - `device`, "
|
||||
"`supervised`, `failsafe` and maintenance's two - and all five pass one. "
|
||||
"share. Six of the eleven appends are SCOPE_UNIT - `device`, "
|
||||
"`supervised`, `failsafe`, maintenance's two and OPT-09's `pack` - and "
|
||||
"all six pass one. "
|
||||
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
|
||||
"the site, beside `circulation`, because the number that tripped it is "
|
||||
"the site target",
|
||||
_unit_sources_unrouted == []
|
||||
and len([r for r in _routing
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 5)
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 6)
|
||||
check("...and the only appends this pin does NOT reach are the two that file a "
|
||||
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
|
||||
"`actuator=` at the call site to mis-type. Their routing is pinned where "
|
||||
@@ -2987,7 +3024,7 @@ check("...and the only appends this pin does NOT reach are the two that file a "
|
||||
"arbitrage.claim() by test_arbitrage.py",
|
||||
sorted(_indirect) == sorted(CLAIM_BUILDERS)
|
||||
and len(_direct) + len(_indirect) == len(_appends))
|
||||
check("...and between them the eleven appends account for exactly the nine "
|
||||
check("...and between them the twelve appends account for exactly the eleven "
|
||||
"sources 10f pins, so the routing pin and the source set are one scan "
|
||||
"and neither can drift from the other",
|
||||
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
|
||||
@@ -3027,10 +3064,19 @@ check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN "
|
||||
"connection",
|
||||
sorted((c.source, c.actuator) for c in _cl2)
|
||||
== [("device", "inverter-a"), ("device", "inverter-b"),
|
||||
("loop", ACTUATOR_SITE), ("supervised", "inverter-a"),
|
||||
("loop", ACTUATOR_SITE),
|
||||
# ⚠️ OPT-09, AND IT IS OBSERVED HERE RATHER THAN ONLY PINNED
|
||||
# STATICALLY. This fixture leaves both packs at 100 % - the last value
|
||||
# of the SoC sweep above - so each one's `pack` claim closes its `lo`
|
||||
# at 0 W: a full battery is not asked to charge. It is on ITS OWN
|
||||
# actuator, which is the half a `pack` limit filed on the site would
|
||||
# fail, and it appears ONLY here: the one-inverter sweep above files
|
||||
# no `pack` claim at any state of charge, which is AC 4.
|
||||
("pack", "inverter-a"), ("pack", "inverter-b"),
|
||||
("supervised", "inverter-a"),
|
||||
("supervised", "inverter-b")])
|
||||
check("...and naming those three is not an accident of this fixture but the "
|
||||
"reason the static pin exists - SEVEN of the ten sources need a state "
|
||||
"reason the static pin exists - SEVEN of the eleven sources need a state "
|
||||
"no check in this file reaches. SAFETY-31's `circulation` is one of the "
|
||||
"seven: it is filed only while the site is MEASURED to be circulating, "
|
||||
"which no fixture here is. SAFETY-30's `sign_site` is another, and it "
|
||||
@@ -3079,16 +3125,21 @@ check("...and so is each unit's `device` bound, which is the other number the "
|
||||
"inverter's reported range bound nothing at all",
|
||||
_dev2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W),
|
||||
"inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)})
|
||||
_n2_plans = {p.unit: (p.lo, p.hi) for p in (_n2_alloc.plans if _n2_alloc else ())}
|
||||
print(f" the envelopes the ARBITER RESOLVED: {_n2_plans}")
|
||||
check("...and the bounds the ARBITER RESOLVED for inverter B - not just the "
|
||||
"claims filed at it - are B's own, because a claim filed correctly and "
|
||||
"then resolved against the wrong envelope would be the same defect one "
|
||||
"layer down",
|
||||
"layer down. ⚠️ THE `hi` IS B'S OWN RATING AND THE `lo` IS 0 W, and the "
|
||||
"second half is OPT-09 rather than a defect: both packs in this fixture "
|
||||
"sit at 100 % - the last value of the SoC sweep above - so each one's "
|
||||
"`pack` claim closes its charge end. The two units' envelopes are still "
|
||||
"DIFFERENT NUMBERS, which is what this check exists to observe: a bound "
|
||||
"copied from the first unit would read +5000 on B",
|
||||
_n2_alloc is not None
|
||||
and next((p for p in _n2_alloc.plans if p.unit == "inverter-b"), None)
|
||||
is not None
|
||||
and (lambda p: p.lo == -B_RATING_HYPOTHESIS
|
||||
and p.hi == B_RATING_HYPOTHESIS)(
|
||||
next(p for p in _n2_alloc.plans if p.unit == "inverter-b")))
|
||||
and _n2_plans.get("inverter-b") == (0.0, B_RATING_HYPOTHESIS)
|
||||
and _n2_plans.get("inverter-a") == (0.0, LIVE_MAX_W)
|
||||
and _n2_plans["inverter-a"] != _n2_plans["inverter-b"])
|
||||
|
||||
|
||||
# --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER ---
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
"""Runnable check for the roster rehearsal. `python3 test_rehearse.py`
|
||||
|
||||
ARCH-02. `tools/rehearse_roster.py` is the instrument that decides whether the
|
||||
flat -> roster switch on 2026-09-12 loses a safety latch, a month of maintenance
|
||||
history, or a Home Assistant entity that an automation is watching. This file
|
||||
grades the instrument.
|
||||
|
||||
⚠️ AN INSTRUMENT THAT HAS NEVER BEEN SEEN TO GO RED IS NOT EVIDENCE. This
|
||||
repository's most-repeated defect, `MUTATION.md` habit 4 and now FIFTEEN
|
||||
recorded instances, is "a check whose DATA cannot reach the failure it names" -
|
||||
and a rehearsal that reports "migration OK" because it compared a structure to
|
||||
itself is the purest example of it there is. So this file does two things in
|
||||
order, and the first one is the one that makes the second mean anything:
|
||||
|
||||
Part 2 TWO CLEAN CONTROLS. Two (state file, roster) pairs on which the
|
||||
shipped code is CORRECT and the harness must report NOTHING. A
|
||||
harness that is red on every input has measured nothing, and every
|
||||
red below it would be free.
|
||||
|
||||
Part 3 ⭐ SIX DELIBERATE MUTATIONS OF THE SHIPPED MIGRATION AND OF THE
|
||||
ENTITY-ID COMPOSITION, each applied to a private copy of the tree,
|
||||
each required to flip its control from green to red, AND each
|
||||
required to produce the SPECIFIC finding it should - not merely a
|
||||
non-zero exit. A mutation that goes red for the wrong reason is a
|
||||
harness that would go red on the real defect for the wrong reason
|
||||
too.
|
||||
|
||||
Part 4 TWO MUTATIONS THAT DELIBERATELY SURVIVE, with the reason written
|
||||
down. A battery in which everything dies is a battery whose author
|
||||
stopped as soon as the number looked good - and the `object_id` one
|
||||
is what BOUNDS the four `app/mqtt.py` mutations above it: without a
|
||||
change to that file that must NOT be caught, "the reference notices
|
||||
any edit to mqtt.py" and "the reference notices what HA keys on" are
|
||||
the same green.
|
||||
|
||||
Part 5b THE PER-LEG CHECK'S OWN POSITIVE CONTROL: a build in which exactly
|
||||
ONE of the two legs is clean. ⚠️ A per-thing check that has never
|
||||
seen the things DISAGREE is a total with extra steps, and this file
|
||||
shipped that defect twice - see `legs()`.
|
||||
|
||||
Part 5 SAFETY-44's exact path, pinned as a finding, by name, in both
|
||||
directions.
|
||||
|
||||
⚠️ THE MUTATIONS ARE SOURCE EDITS IN A COPIED TREE, NOT MONKEYPATCHES. A
|
||||
monkeypatch grades the name a module was asked for; an edit grades the code that
|
||||
runs. Each copy is made with `mkdtemp()`, so two runs of this file cannot
|
||||
overwrite each other's work - habit 18 - and `__pycache__` is never copied, so
|
||||
no mutant is ever graded through a stale `.pyc` - habit 1 / TEST-15.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
FIX = HERE / "tools" / "rehearsal"
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the harness's own preconditions")
|
||||
# --------------------------------------------------------------------------- #
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import rehearse_roster as RR # noqa: E402
|
||||
|
||||
check("the tool refuses rather than passing when it cannot run: its three exit "
|
||||
"codes are distinct and 2 is not 0",
|
||||
(RR.EXIT_OK, RR.EXIT_FINDING, RR.EXIT_COULD_NOT_RUN) == (0, 1, 2))
|
||||
|
||||
# ⚠️ THE ANCHOR. `app/mqtt.py` records `sensor.goodwe_p1_sample_age` as VERIFIED
|
||||
# against the live system on 2026-08-26, and the ESP32's ingest guard subscribes
|
||||
# to it. The tool's entity-id model is written independently of `mqtt._slug` on
|
||||
# purpose (habit 4), so this is the one external fact that says the independent
|
||||
# model is right. If it fails, every orphan claim the tool makes is worthless
|
||||
# and the tool exits 2 rather than reporting.
|
||||
try:
|
||||
RR._check_anchor()
|
||||
anchored = True
|
||||
except RR.Refused:
|
||||
anchored = False
|
||||
check("the entity-id model reproduces the ONE id verified against the live "
|
||||
f"system ({RR.ANCHOR_ID})", anchored)
|
||||
|
||||
check("...and it is NOT built out of `mqtt._slug`, so a mutation in the "
|
||||
"shipped slugifier moves one side of the comparison and not both",
|
||||
"_slug" not in RR.compose.__code__.co_names)
|
||||
|
||||
_opts = RR.load_options(str(FIX / "roster.yaml"))
|
||||
check("the built-in YAML reader parses the roster fixture the check image "
|
||||
"cannot parse with PyYAML (there is no `yaml` module in it)",
|
||||
[e["name"] for e in _opts["inverters"]] == ["garage", "shed"]
|
||||
and _opts["max_w"] == 5000 and _opts["batt_invert"] is False
|
||||
and _opts["meter_source"] == "off")
|
||||
try:
|
||||
RR._mini_yaml("inverters: [a, b]\n")
|
||||
_refused = False
|
||||
except RR.Refused:
|
||||
_refused = True
|
||||
check("...and it REFUSES a construct it does not understand rather than "
|
||||
"guessing, because a config silently mis-parsed here rehearses a "
|
||||
"configuration nobody is going to deploy", _refused)
|
||||
|
||||
_flat = RR.load_options(str(FIX / "flat.yaml"))
|
||||
check("⚠️ the two configs differ in the ONE way the transition is about: the "
|
||||
"BEFORE roster is empty and the AFTER roster is declared - if they did "
|
||||
"not, every diff below would be a structure compared to itself",
|
||||
_flat.get("inverters") == [] and len(_opts["inverters"]) == 2)
|
||||
check("...and the AFTER roster's first entry is NOT `inverter-a`, which is "
|
||||
"habit 4's seventh instance in one line: with it, `UNIT_A` and "
|
||||
"`inverters[0].name` are the same string and a misdirected namespace "
|
||||
"cannot be seen",
|
||||
_opts["inverters"][0]["name"] != RR.UNIT_A)
|
||||
|
||||
_legacy = json.loads((FIX / "state-legacy.json").read_text())
|
||||
check("⚠️ the synthetic state carries MAINTENANCE HISTORY - state built by the "
|
||||
"happy path cannot detect a migration that drops it",
|
||||
_legacy.get("last_completed") and _legacy.get("phase"))
|
||||
check("⚠️ ...and a LEGACY-SHAPED KEY WHOSE LOSS WOULD BE SILENT: a LATCHED "
|
||||
"`sign_tripped` with no `schema_version` beside it, i.e. written by a "
|
||||
"controller that had never heard of unit namespaces",
|
||||
_legacy.get("sign_tripped") is True
|
||||
and "schema_version" not in _legacy
|
||||
and "maint_schema_version" not in _legacy)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. THE CLEAN CONTROLS: three transitions on which the shipped code is "
|
||||
"right and the harness must say nothing")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THIS PART IS WHAT MAKES PART 3 EVIDENCE. Six reds prove nothing about an
|
||||
# instrument that is red on its own fixtures. Each control below is a genuine
|
||||
# flat -> roster switch that the shipped migration handles correctly, and the
|
||||
# required answer is EXIT 0. CONTROL-C earned its place by failing: it found the
|
||||
# harness reporting a defect on every fresh install.
|
||||
#
|
||||
# CONTROL-A a PRE-DEV-02 flat store, latch tripped, onto a roster whose
|
||||
# first entry IS `inverter-a`. This is the path SAFETY-44's
|
||||
# documented reasoning describes, and on it that reasoning holds.
|
||||
# CONTROL-B a store that has already run flat under 0.7.0 (schema stamped,
|
||||
# no latch), onto a roster named `garage`/`shed`. This is the
|
||||
# MAINT-05 half, and the names are deliberately not `inverter-a`.
|
||||
# CONTROL-C a FRESH INSTALL - an empty state file - onto `garage`/`shed`.
|
||||
# ⚠️ THIS ONE FOUND A DEFECT IN THE HARNESS. A fresh store's
|
||||
# migration writes `sign_tripped = False` and `sign_reason = ""`
|
||||
# under `inverter-a` before any roster exists, and the first draft
|
||||
# reported both as STRANDED - red on every fresh install. Losing a
|
||||
# DEFAULT loses nothing; that is `MigrationReport.lost`'s own
|
||||
# distinction, and an alarm that fires on the harmless case is one
|
||||
# an operator learns to dismiss before the real one arrives.
|
||||
CONTROLS = {
|
||||
"CONTROL-A": ("state-legacy.json", "roster-inverter-a.yaml"),
|
||||
"CONTROL-B": ("state-flat-070.json", "roster.yaml"),
|
||||
"CONTROL-C": ("state-fresh.json", "roster.yaml"),
|
||||
}
|
||||
|
||||
|
||||
def run(tree: Path, state: str, after: str, *extra) -> "tuple[int, str]":
|
||||
"""The harness, as the owner runs it, over `tree`.
|
||||
|
||||
⚠️ ALWAYS WITH `--live-entities`. That file is the only part of the
|
||||
entity-id comparison that does not move with the code, so a run without it
|
||||
is a run that cannot see a renamed device.
|
||||
"""
|
||||
fixtures = tree / "tools" / "rehearsal"
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(tree / "tools" / "rehearse_roster.py"),
|
||||
"--state", str(fixtures / state),
|
||||
"--before", str(fixtures / "flat.yaml"),
|
||||
"--after", str(fixtures / after),
|
||||
"--live-entities", str(fixtures / "live-entities-recorded.txt"),
|
||||
"--reference", str(fixtures / "announced-reference.txt"),
|
||||
*extra],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
|
||||
return proc.returncode, proc.stdout + proc.stderr
|
||||
|
||||
|
||||
def mutant_tree(edits) -> str:
|
||||
"""A private copy of the add-on with `edits` applied.
|
||||
|
||||
⚠️ EVERY EDIT IS ASSERTED TO HAVE MATCHED. Four defects in this session came
|
||||
from `str.replace` calls whose pattern did not match: the call silently
|
||||
returns the original string, the mutation never happens, and the harness
|
||||
reports a SURVIVOR on code it never changed. That is a fabricated result.
|
||||
"""
|
||||
tmp = tempfile.mkdtemp(prefix="rehearse-mut-")
|
||||
dest = Path(tmp) / "tree"
|
||||
ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
|
||||
(dest / "tools").mkdir(parents=True)
|
||||
shutil.copytree(HERE / "app", dest / "app", ignore=ignore)
|
||||
shutil.copytree(FIX, dest / "tools" / "rehearsal", ignore=ignore)
|
||||
shutil.copyfile(HERE / "tools" / "rehearse_roster.py",
|
||||
dest / "tools" / "rehearse_roster.py")
|
||||
for rel, old, new in edits:
|
||||
path = dest / rel
|
||||
text = path.read_text(encoding="utf-8")
|
||||
hits = text.count(old)
|
||||
if hits != 1:
|
||||
raise AssertionError(
|
||||
f"mutation target appears {hits} times in {rel}, expected 1: "
|
||||
f"{old!r} - the mutation did NOT happen and any result from it "
|
||||
f"would be fabricated")
|
||||
path.write_text(text.replace(old, new), encoding="utf-8")
|
||||
return str(dest)
|
||||
|
||||
|
||||
def legs(out: str) -> dict:
|
||||
"""The report, split into its legs on the tool's `LEG <id>` markers.
|
||||
|
||||
⚠️ THIS FUNCTION IS THE FIX FOR A DEFECT IN THIS FILE, AND THE DEFECT IS
|
||||
HABIT 4 ONE LEVEL UP. Part 5's check that the disarmed latch is "NOT THE
|
||||
SAME-STEP PATH ALONE" was written as
|
||||
`out.count("LATCH CAME UP DISARMED") >= 2`. The tool prints every finding
|
||||
TWICE per red leg - once in that leg's own FINDINGS block and once in the
|
||||
closing summary - so ONE red leg yields exactly 2 and the check passes.
|
||||
Measured on three builds:
|
||||
|
||||
shipped code, both legs red count 4
|
||||
fix at main.py:250 only -> STAGED alone red count 2 ok
|
||||
partial guard fix on rep.ran -> SAME-STEP alone red count 2 ok
|
||||
|
||||
The check named "NOT THE SAME-STEP PATH ALONE" reported `ok` on the build
|
||||
whose remaining failure IS the same-step path alone - and, worse, applying
|
||||
the fix DOCS.md names as the cause left all five part-5 checks green. A
|
||||
count over a whole report cannot distinguish one leg from two; only reading
|
||||
the legs separately can.
|
||||
|
||||
⚠️ AND IT CLOSES ON `END LEG`, WHICH IS THE SAME DEFECT ONE LEVEL DOWN.
|
||||
The first version of this splitter opened on `LEG <id>` and never closed,
|
||||
so every line after the LAST marker went to that leg - including the
|
||||
closing summary, which repeats every finding from every leg. The last leg
|
||||
then read red whenever ANY leg was red. Measured on a build whose SAME-STEP
|
||||
leg is genuinely clean: it still reported SAME-STEP disarmed. Part 5b is
|
||||
the running control for exactly that.
|
||||
"""
|
||||
out_legs, current, closed = {}, None, set()
|
||||
for line in out.splitlines():
|
||||
if line.startswith(" LEG "):
|
||||
# ⚠️ AND THIS ASSERTION IS THE ONE THAT KILLS THE CLASS. Review
|
||||
# attacked part 5b directly and got through in three edits: delete
|
||||
# `END LEG` (5b catches it), ALSO reorder the legs (5b goes blind -
|
||||
# it only ever saw them disagree with the clean leg LAST), and ALSO
|
||||
# reword the closing summary (the backstop is a transcription of the
|
||||
# tool's own sentence, so rewording blinds that too). Result: "all
|
||||
# checks passed" with a splitter that closed ZERO legs.
|
||||
#
|
||||
# Two of those three are ordinary refactors - reordering report legs
|
||||
# and rewording a summary line - neither of which reads as touching
|
||||
# a safety check. A fixture cannot cover every arrangement, so the
|
||||
# STRUCTURE is asserted instead: a leg may not open while one is
|
||||
# open, and every leg that opens must be explicitly closed. Under
|
||||
# all three attacks nothing is ever closed, so this fires on the
|
||||
# FIRST edit regardless of order or wording.
|
||||
assert current is None, (
|
||||
f"leg {line.strip()[4:].strip()!r} opened while {current!r} was "
|
||||
"still open - the tool stopped closing its legs, and a splitter "
|
||||
"that never closes swallows the closing summary into the last "
|
||||
"leg. That is the defect this function exists to fix.")
|
||||
current = line.strip()[4:].strip()
|
||||
out_legs[current] = []
|
||||
elif line.startswith(" END LEG "):
|
||||
assert current is not None, "END LEG without an open leg"
|
||||
closed.add(current)
|
||||
current = None
|
||||
elif current is not None:
|
||||
out_legs[current].append(line)
|
||||
assert current is None, f"leg {current!r} was never closed"
|
||||
assert closed == set(out_legs), (
|
||||
f"legs opened {sorted(out_legs)} but closed {sorted(closed)} - a leg "
|
||||
"that is never closed absorbs everything after it")
|
||||
return {k: "\n".join(v) for k, v in out_legs.items()}
|
||||
|
||||
|
||||
for label, (state, after) in CONTROLS.items():
|
||||
code, out = run(HERE, state, after)
|
||||
check(f"{label}: the shipped code, {state} -> {after}, exits 0 with no "
|
||||
f"findings", code == RR.EXIT_OK and "NO FINDINGS" in out)
|
||||
|
||||
# ⚠️ AND THE SOFTENING IS NOT A HOLE. The SAME key stranded with a NON-default
|
||||
# value is still a finding - that is the whole of part 5 - so the two cases are
|
||||
# separated by the VALUE and not by the key name.
|
||||
_fresh_code, _fresh_out = run(HERE, "state-fresh.json", "roster.yaml")
|
||||
# ⚠️ THE NAMESPACE IS READ OUT OF THE TOOL'S OWN REPORT, NOT SPELLED HERE. A
|
||||
# hardcoded `unit/inverter-a/...` makes this check fail for the RIGHT reason on
|
||||
# the shipped code and for the WRONG reason on a build that fixes the migration
|
||||
# to use the roster's own name - it would look like the default-softening broke
|
||||
# when what changed is the namespace. What this check is about is the VALUE.
|
||||
_stranded_default = set(re.findall(
|
||||
r"^\s+stranded\s+(unit/\S+/sign_tripped) = False$", _fresh_out, re.M))
|
||||
check("a fresh install DOES strand a `.../sign_tripped` key - it is written "
|
||||
"and nothing on the roster reads it - and the report SAYS SO under its "
|
||||
f"own heading (found: {sorted(_stranded_default)})",
|
||||
_fresh_code == RR.EXIT_OK and len(_stranded_default) == 1)
|
||||
check("...and does NOT call it a finding, because the value is the DEFAULT; "
|
||||
"the same key at True is the finding part 5 turns on, so the two cases "
|
||||
"are separated by the VALUE and not by the key name",
|
||||
not any(f"STRANDED STORE KEY: {k}" in _fresh_out
|
||||
for k in _stranded_default)
|
||||
and "STRANDED STORE KEY: unit/inverter-a/sign_tripped = True"
|
||||
in run(HERE, "state-legacy.json", "roster.yaml")[1])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2b. ⚠️ OMITTING THE INSTALL'S ENTITY LIST IS A REFUSAL, NOT A QUIETER "
|
||||
"RUN")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THIS PART EXISTS BECAUSE THE ASYMMETRY WAS BACKWARDS AND IT WAS MEASURED.
|
||||
# A `--live-entities` file that could not be READ refused with exit 2; the flag
|
||||
# simply not being PASSED produced "NO FINDINGS" and exit 0 - and
|
||||
# `grep -ci "live-entities|recorded list|composition"` over that report returned
|
||||
# ZERO, so nothing in the artifact an owner reads said half the check had not
|
||||
# run. Measured on the two entity mutations at the time:
|
||||
#
|
||||
# M5 device rename with the flag: exit 1 without: exit 0
|
||||
# M6 sensor rename with the flag: exit 1 without: exit 0
|
||||
#
|
||||
# The owner runs this ONCE, at commissioning, under time pressure. A forgotten
|
||||
# flag must not hand back a clean bill of health for the half of the transition
|
||||
# that orphans entities.
|
||||
def raw(*args):
|
||||
"""`(exit code, THE REPORT, the log)` - and the split matters.
|
||||
|
||||
⚠️ THE REPORT IS STDOUT AND THE CONTROLLER'S LOGGING IS STDERR. Grading
|
||||
"is the degraded banner near the end of the report" over the two
|
||||
CONCATENATED reads the last lines of the LOG - `SAFETY-02 was TRIPPED...`,
|
||||
`unparseable timestamp...` - and reports a missing banner that is in fact
|
||||
printed. That is not a hypothetical; it is how this check first failed.
|
||||
"""
|
||||
fx = HERE / "tools" / "rehearsal"
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(HERE / "tools" / "rehearse_roster.py"),
|
||||
"--state", str(fx / "state-legacy.json"),
|
||||
"--before", str(fx / "flat.yaml"),
|
||||
"--after", str(fx / "roster-inverter-a.yaml"), *args],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
_no_code, _no_out, _no_err = raw()
|
||||
check("omitting --live-entities REFUSES with exit 2 rather than reporting a "
|
||||
"clean transition it did not check",
|
||||
_no_code == RR.EXIT_COULD_NOT_RUN and "REFUSED" in _no_out + _no_err)
|
||||
|
||||
_deg_code, _deg_out, _ = raw("--no-live-entities")
|
||||
check("...and the explicit opt-out RUNS, so the escape hatch exists",
|
||||
_deg_code in (RR.EXIT_OK, RR.EXIT_FINDING))
|
||||
check("⚠️ ...carrying a standing banner in the report HEADER and again in "
|
||||
"the closing block, because a report is skimmed from the bottom",
|
||||
_deg_out.count("DEGRADED RUN") >= 2
|
||||
and "DEGRADED RUN" in "\n".join(
|
||||
_deg_out.strip().splitlines()[-4:]))
|
||||
check("...and the degraded report is not silent about what it did not do - "
|
||||
"the words are in it, which is the grep that returned 0 before",
|
||||
sum(w in _deg_out.lower() for w in
|
||||
("live-entities", "orphan check", "not graded")) == 3)
|
||||
check("contradictory flags are refused rather than one silently winning",
|
||||
raw("--no-live-entities", "--live-entities",
|
||||
str(FIX / "live-entities-recorded.txt"))[0]
|
||||
== RR.EXIT_COULD_NOT_RUN)
|
||||
check("...and the banner really is at the END of the report, which is the "
|
||||
"half of that claim a concatenated stdout+stderr could not have graded",
|
||||
"DEGRADED RUN" in "\n".join(_deg_out.strip().splitlines()[-4:]))
|
||||
|
||||
# ⚠️ AND THE COMPOSITION HALF IS NOT BEHIND THE FLAG AT ALL ANY MORE. The
|
||||
# reference is loaded on EVERY run, so a degraded run still catches a renamed
|
||||
# device - which is what makes `--no-live-entities` a genuinely bounded
|
||||
# concession rather than "half the tool off".
|
||||
_ref_missing = raw("--no-live-entities", "--reference", "/nonexistent.txt")
|
||||
check("a missing or malformed reference is exit 2 on every run, because it is "
|
||||
"the only reference in the tool that does not move with the code",
|
||||
_ref_missing[0] == RR.EXIT_COULD_NOT_RUN)
|
||||
|
||||
# ⚠️ AND THE VANISHED CHECK CARRIES THE SAME DEFAULT/NON-DEFAULT RULE, BOUNDED
|
||||
# THE SAME WAY. `M9` below drops a NON-default key and must still be a finding;
|
||||
# without it, "vanished at the default is not a finding" could widen into
|
||||
# "vanished is not a finding" and nobody would see it.
|
||||
_m9 = mutant_tree([("app/store.py",
|
||||
" value = data.get(legacy, DEFAULTS.get(legacy))\n"
|
||||
" store.set(target, value)",
|
||||
" value = data.get(legacy, DEFAULTS.get(legacy))\n"
|
||||
" store.data.pop(legacy, None)\n"
|
||||
" store.set(target, value)")])
|
||||
_m9_code, _m9_out = run(Path(_m9), "state-legacy.json", "roster-inverter-a.yaml")
|
||||
_m9_vanished = set(re.findall(r"VANISHED STORE KEY: (\S+) =", _m9_out))
|
||||
print(f" deleting the flat key as it migrates -> exit {_m9_code}, "
|
||||
f"vanished {sorted(_m9_vanished)}")
|
||||
check("⭐ a NON-default key that vanishes is STILL a finding - a migration "
|
||||
"made to DELETE the flat rollback view store.py rule 2 requires it to "
|
||||
"leave in place is caught, so the default-softening above is bounded by "
|
||||
"its own positive",
|
||||
_m9_code == RR.EXIT_FINDING
|
||||
and {"last_completed", "phase_started", "last_start_attempt"}
|
||||
<= _m9_vanished)
|
||||
check("...and `phase` is NOT among them, because its value 'idle' IS the "
|
||||
"default - the same mutation, the same file, and the rule splitting the "
|
||||
"two on the VALUE rather than on the key",
|
||||
"phase" not in _m9_vanished)
|
||||
shutil.rmtree(Path(_m9).parent, ignore_errors=True)
|
||||
|
||||
# ⚠️ AND THE INPUT FILE IS NEVER WRITTEN. The owner runs this against a copy of
|
||||
# a live state file; a tool that edited it would be a tool that had to be
|
||||
# trusted rather than one that cannot do harm.
|
||||
_before_bytes = (FIX / "state-legacy.json").read_bytes()
|
||||
run(HERE, "state-legacy.json", "roster.yaml", "--reverse", "--round-trip")
|
||||
check("the state file the harness was pointed at is BYTE-IDENTICAL afterwards, "
|
||||
"including after the reverse and round-trip legs, which write",
|
||||
(FIX / "state-legacy.json").read_bytes() == _before_bytes)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. ⭐ THE MUTATIONS: each one breaks the shipped migration or the "
|
||||
"entity-id composition, and each must turn its control RED")
|
||||
# --------------------------------------------------------------------------- #
|
||||
MUTATIONS = [
|
||||
# (id, control, what it breaks, edits, the finding it must produce)
|
||||
("M1", "CONTROL-B",
|
||||
"the maintenance migration is handed the hardcoded `UNIT_A` instead of "
|
||||
"the roster's own first name - MAINT-05's exact shipped regression, which "
|
||||
"left all 19 suites green",
|
||||
[("app/main.py",
|
||||
"store, self.inverters[0].name, [inv.name for inv in self.inverters])",
|
||||
"store, UNIT_A, [inv.name for inv in self.inverters])")],
|
||||
"MAINTENANCE HISTORY LOST"),
|
||||
|
||||
("M2", "CONTROL-B",
|
||||
"the maintenance namespace is DROPPED: the migration moves no keys at all, "
|
||||
"so a month of history stays under a flat spelling the new packs do not "
|
||||
"read",
|
||||
[("app/store.py",
|
||||
"_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)",
|
||||
"_move_flat_keys(store, unit, (), rep)")],
|
||||
"MAINTENANCE HISTORY LOST"),
|
||||
|
||||
("M3", "CONTROL-A",
|
||||
"the SAFETY-02 pair loses HALF of itself: `sign_reason` drops out of "
|
||||
"`LEGACY_UNIT_KEYS`, so the latch migrates without the sentence that says "
|
||||
"why it tripped",
|
||||
[("app/store.py",
|
||||
'LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")',
|
||||
'LEGACY_UNIT_KEYS = ("sign_tripped",)')],
|
||||
"LATCH KEY NOT UNDER A ROSTER NAME"),
|
||||
|
||||
("M4", "CONTROL-A",
|
||||
"the READER's key composition changes while the migration's does not - "
|
||||
"`Inverter.key()` upper-cases the unit - so the latch is written "
|
||||
"perfectly, read back perfectly by the migration, and looked for under a "
|
||||
"name that is not there",
|
||||
[("app/inverter.py",
|
||||
"return unit_key(self.name, name)",
|
||||
"return unit_key(self.name.upper(), name)")],
|
||||
"LATCH CAME UP DISARMED"),
|
||||
|
||||
("M5", "CONTROL-A",
|
||||
"the entity-id composition changes on the DEVICE half: the MQTT device is "
|
||||
"renamed, which is the 'tidy it up' change `app/mqtt.py` spends thirty "
|
||||
"lines in capitals refusing. ⚠️ THE BEFORE/AFTER DIFF IS BLIND TO THIS - "
|
||||
"both sides are composed by the mutant, so every id moves equally and "
|
||||
"nothing looks orphaned. Only the recorded list catches it",
|
||||
[("app/mqtt.py", '"name": "GoodWe",', '"name": "GoodWe Controller",')],
|
||||
"COMPOSITION DRIFT"),
|
||||
|
||||
("M6", "CONTROL-A",
|
||||
"the entity-id composition changes on the ENTITY half: one site sensor is "
|
||||
"renamed, which orphans exactly one id and leaves the other seven alone",
|
||||
[("app/mqtt.py",
|
||||
'("grid", "goodwe_grid_power", "Grid power", "W", "power", "measurement", None),',
|
||||
'("grid", "goodwe_grid_power", "Grid power W", "W", "power", "measurement", None),')],
|
||||
"COMPOSITION DRIFT: this build announces sensor.goodwe_grid_power_w"),
|
||||
|
||||
# ⚠️ M7 AND M8 ARE THE TWO CHANGES THAT ORPHAN AN INSTALL WITHOUT MOVING A
|
||||
# SINGLE COMPOSED entity_id, so an entity_id-only reference cannot see them.
|
||||
# Both came back exit 0 before the reference carried three columns.
|
||||
("M7", "CONTROL-A",
|
||||
"the `unique_id` composition changes and NOT the entity_id: `mqtt.BASE` "
|
||||
"is renamed, so every announced unique_id moves while every composed "
|
||||
"entity_id stays byte-identical. HA's MQTT entity registry keys on "
|
||||
"unique_id, so this is a NEW entity beside a dead one - and discovery is "
|
||||
"retained with no removal payload, so the dead one stays",
|
||||
[("app/mqtt.py", 'BASE = "goodwe_ctl"', 'BASE = "goodwe_controller"')],
|
||||
"UNIQUE_ID DRIFT"),
|
||||
|
||||
("M8", "CONTROL-A",
|
||||
"the DEVICE IDENTIFIER changes: HA's device registry keys on it, so the "
|
||||
"whole install moves onto a new device and the old one keeps its dead "
|
||||
"entities. Again no composed entity_id moves",
|
||||
[("app/mqtt.py", '"identifiers": ["goodwe_rs485_controller"],',
|
||||
'"identifiers": ["goodwe_rs485_ctl_v2"],')],
|
||||
"DEVICE IDENTIFIER DRIFT"),
|
||||
]
|
||||
|
||||
for mid, control, what, edits, must_say in MUTATIONS:
|
||||
state, after = CONTROLS[control]
|
||||
tree = mutant_tree(edits)
|
||||
code, out = run(Path(tree), state, after)
|
||||
red = code == RR.EXIT_FINDING
|
||||
named = must_say in out
|
||||
print()
|
||||
print(f" --- {mid} ({control}) {what}")
|
||||
print(f" exit {code} (0=clean 1=finding 2=could-not-run)")
|
||||
# ⚠️ THE FINDINGS, DE-DUPLICATED AND IN ORDER. The tool prints each finding
|
||||
# once per transition leg and once more in its summary, and its own header
|
||||
# is full of ⚠️ paragraphs - printing every line that starts with one would
|
||||
# bury the evidence this part exists to show under the tool's docstring.
|
||||
seen = []
|
||||
for line in out.splitlines():
|
||||
text = line.strip()
|
||||
# ⚠️ `_` IS IN THE CLASS. Without it `UNIQUE_ID DRIFT:` does not match
|
||||
# its own finding kind, and M7 - the mutation that renames `mqtt.BASE`
|
||||
# - printed its verdict with NO evidence under it. A filter that
|
||||
# silently drops the very finding the mutation exists to produce is a
|
||||
# small version of the same defect this file is about.
|
||||
if text.startswith("⚠️ ") and re.match(r"⚠️ [A-Z][A-Z _-]+[A-Z()]:",
|
||||
text):
|
||||
if text not in seen:
|
||||
seen.append(text)
|
||||
for text in seen:
|
||||
print(f" {text[:160]}")
|
||||
check(f"{mid} turns {control} RED", red)
|
||||
check(f"{mid} says WHY, and it is the right why: {must_say!r}", named)
|
||||
shutil.rmtree(Path(tree).parent, ignore_errors=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. THE MUTATION THAT SURVIVES, ON PURPOSE, WITH THE REASON WRITTEN DOWN")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ A BATTERY IN WHICH EVERYTHING DIES IS A BATTERY SOMEBODY STOPPED WRITING
|
||||
# AS SOON AS THE NUMBER LOOKED GOOD. `store.unit_key()` is the ONE spelling of
|
||||
# the namespaced key, read by `Inverter.key()`, by `UnitHistory.key()` and by
|
||||
# both migrations - that is store.py's stated design, "one spelling, in one
|
||||
# place". Changing it therefore moves the writer AND every reader together, and
|
||||
# the store comes out internally consistent under a different name.
|
||||
#
|
||||
# The harness is BLIND to it and cannot be otherwise: nothing inside one version
|
||||
# of this add-on can see that the key shape stopped matching what an OLDER
|
||||
# version reads. That is a ROLLBACK contract, and grading it needs two versions
|
||||
# of the code, which is a different instrument (`tools/predeploy_check.py`'s
|
||||
# territory). Stated here rather than left as an unexplained green.
|
||||
_tree = mutant_tree([("app/store.py",
|
||||
'return f"unit/{unit}/{key}"',
|
||||
'return f"ns.{unit}.{key}"')])
|
||||
_code, _out = run(Path(_tree), *CONTROLS["CONTROL-A"])
|
||||
print(f" renaming the ONE key spelling -> exit {_code}")
|
||||
check("renaming `unit_key()` survives, because writer and readers move "
|
||||
"together - a fact about the design, recorded, not a gap being hidden",
|
||||
_code == RR.EXIT_OK)
|
||||
check("...and the store really did change shape underneath it, so this is a "
|
||||
"survivor of a mutation that HAPPENED rather than of one that did not",
|
||||
"ns.inverter-a.sign_tripped" in _out and "unit/inverter-a/" not in _out)
|
||||
shutil.rmtree(Path(_tree).parent, ignore_errors=True)
|
||||
|
||||
# ⚠️ THE SECOND DECLARED SURVIVOR, AND IT IS THE ONE THAT BOUNDS M5-M8. Four
|
||||
# mutations of `app/mqtt.py` are now required to go red; without a mutation of
|
||||
# the same file that must NOT, "the reference notices any edit to mqtt.py"
|
||||
# would be indistinguishable from "the reference notices what HA keys on".
|
||||
# `object_id` is the case: `app/mqtt.py` lines 36-42 and 61-76 record that HA
|
||||
# 2026.8 does NOT honour it in a discovery payload - the entity_id is composed
|
||||
# from device name plus entity name - so renaming one changes nothing an
|
||||
# install can see, and a tool that refused it would refuse a free change.
|
||||
_tree2 = mutant_tree([("app/mqtt.py",
|
||||
'("grid", "goodwe_grid_power", "Grid power",',
|
||||
'("grid", "goodwe_grid_pwr", "Grid power",')])
|
||||
_code2, _out2 = run(Path(_tree2), *CONTROLS["CONTROL-A"])
|
||||
print(f" renaming an object_id (HA does not honour it) -> exit {_code2}")
|
||||
check("renaming an `object_id` survives, because HA composes the entity_id "
|
||||
"from the device and entity NAMES and ignores it - so the four mqtt.py "
|
||||
"mutations above are red for what HA keys on, not for being edits to "
|
||||
"mqtt.py",
|
||||
_code2 == RR.EXIT_OK)
|
||||
shutil.rmtree(Path(_tree2).parent, ignore_errors=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. SAFETY-44: a pre-DEV-02 flat store meeting a DECLARED ROSTER, which "
|
||||
"is the path nobody had established either way")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The question on the card: `migrate_unit_keys(store, UNIT_A)` takes a hardcoded
|
||||
# constant and has no `misdirected` guard, while `migrate_maint_keys(store,
|
||||
# unit, roster)` takes the roster and does. Is that asymmetry safe?
|
||||
#
|
||||
# ⚠️ MEASURED, NOT ARGUED. The two checks below are the answer, and they are
|
||||
# written as REQUIREMENTS so that a fix to `migrate_unit_keys` turns them red
|
||||
# and this file has to be updated deliberately rather than drifting.
|
||||
code44, out44 = run(HERE, "state-legacy.json", "roster.yaml")
|
||||
print(f" flat store + roster ['garage','shed'] -> exit {code44}")
|
||||
check("⚠️ SAFETY-44 IS A DEFECT AGAIN: a store that ran pre-DEV-02 flat and "
|
||||
"declares a multi-inverter roster comes up with its SAFETY-02 latch "
|
||||
"DISARMED - the file says `sign_tripped: true` and not one inverter is "
|
||||
"latched",
|
||||
code44 == RR.EXIT_FINDING and "LATCH CAME UP DISARMED" in out44)
|
||||
check("...and the harness names the two latch keys SAFETY-44 asks about, by "
|
||||
"the namespace they were stranded in",
|
||||
"unit/inverter-a/sign_tripped" in out44
|
||||
and "unit/inverter-a/sign_reason" in out44)
|
||||
check("...and the mechanism is visible in the report: the keys are stranded "
|
||||
"under `inverter-a`, which is not on the roster, so no machine reads them",
|
||||
"STRANDED STORE KEY" in out44 and "['garage', 'shed']" in out44)
|
||||
# ⚠️ PER LEG, NOT A COUNT OVER THE WHOLE REPORT. See `legs()` for the three
|
||||
# builds this distinction was measured on and for what the count missed.
|
||||
_L = legs(out44)
|
||||
print(f" legs in the report: {sorted(_L)}")
|
||||
for _leg in ("STAGED", "SAME-STEP"):
|
||||
print(f" LEG {_leg}: "
|
||||
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _L.get(_leg, '') else 'clean'}")
|
||||
check("the report is split into legs this check can grade SEPARATELY - the "
|
||||
"tool prints a `LEG <id>` marker, because counting a finding's text over "
|
||||
"the whole report cannot tell one red leg from two",
|
||||
set(_L) == {"STAGED", "SAME-STEP"})
|
||||
check("⚠️ SAME-STEP is disarmed: the roster is declared in the SAME step as "
|
||||
"the upgrade, the migration runs and misdirects the latch to "
|
||||
"`inverter-a`",
|
||||
"LATCH CAME UP DISARMED" in _L.get("SAME-STEP", ""))
|
||||
check("⚠️ AND IT IS NOT THE SAME-STEP PATH ALONE - the STAGED leg reaches the "
|
||||
"same disarmed latch by a DIFFERENT route: the flat start stamps "
|
||||
"`schema_version`, so the roster start's migration takes its early "
|
||||
"return and the latch never moves at all",
|
||||
"LATCH CAME UP DISARMED" in _L.get("STAGED", ""))
|
||||
|
||||
# ⚠️ AND THE CONTRAST, WHICH IS WHAT STOPS THE CHECK ABOVE BEING VACUOUS. The
|
||||
# SAME state file onto a roster whose first entry IS `inverter-a` comes up
|
||||
# LATCHED. The two runs differ in one thing - the first roster name - so the
|
||||
# finding is attributable to it and to nothing else.
|
||||
code_ok, out_ok = run(HERE, "state-legacy.json", "roster-inverter-a.yaml")
|
||||
check("...and no leg's text has swallowed the CLOSING SUMMARY, which repeats "
|
||||
"every finding from every leg - the failure that made the last leg read "
|
||||
"red whenever any leg was",
|
||||
not any("FINDING(S) - this transition is not clean" in v
|
||||
for v in _L.values()))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5b. THE SPLITTER'S POSITIVE CONTROL: a build in which exactly ONE leg "
|
||||
"is clean")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ A PER-LEG CHECK THAT HAS NEVER SEEN THE LEGS DISAGREE IS A COUNT WITH
|
||||
# EXTRA STEPS. Every fixture above has both legs red, so nothing so far
|
||||
# separates "reads each leg" from "reads the whole report twice". This part
|
||||
# builds the one case that does: `migrate_unit_keys()` handed the roster's own
|
||||
# first name instead of the hardcoded `UNIT_A`.
|
||||
#
|
||||
# ⚠️ AND WHAT IT MEASURES IS ALSO THE ANSWER TO "IS THAT THE FIX". It is NOT.
|
||||
# It repairs the SAME-STEP path only. The STAGED path is untouched, because
|
||||
# there the flat start stamps `schema_version = 1` and the roster start's
|
||||
# migration takes its `from_version >= SCHEMA_VERSION` early return before the
|
||||
# name it would be handed matters at all. A fix that stops at this line would
|
||||
# close the half an operator is least likely to take and leave the half that
|
||||
# "upgrade first, declare the roster later" produces.
|
||||
_one_leg = mutant_tree([(
|
||||
"app/main.py",
|
||||
" self.store_migration = migrate_unit_keys(store, UNIT_A)",
|
||||
" self.store_migration = migrate_unit_keys(store, "
|
||||
"roster_names(opts)[0])")])
|
||||
_ol_code, _ol_out = run(Path(_one_leg), "state-legacy.json", "roster.yaml")
|
||||
_OL = legs(_ol_out)
|
||||
for _leg in ("STAGED", "SAME-STEP"):
|
||||
print(f" LEG {_leg}: "
|
||||
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _OL.get(_leg, '') else 'clean'}")
|
||||
check("⭐ with `migrate_unit_keys()` handed the roster's own first name, the "
|
||||
"SAME-STEP leg comes up CLEAN - so the splitter really does read the "
|
||||
"legs separately, and a check that only counted would have missed this",
|
||||
"LATCH CAME UP DISARMED" not in _OL["SAME-STEP"])
|
||||
check("⚠️ ...while the STAGED leg is STILL DISARMED on the same build: that "
|
||||
"line is not the whole fix, because the staged path never reaches it - "
|
||||
"the first start stamps `schema_version` and the second start's "
|
||||
"migration returns early",
|
||||
"LATCH CAME UP DISARMED" in _OL["STAGED"])
|
||||
check("...and the run as a whole is still a finding, so nobody reads this "
|
||||
"partial fix as done",
|
||||
_ol_code == RR.EXIT_FINDING)
|
||||
shutil.rmtree(Path(_one_leg).parent, ignore_errors=True)
|
||||
|
||||
print()
|
||||
print("5c. back to the shipped code")
|
||||
check("the SAME state file onto a roster whose first entry IS `inverter-a` "
|
||||
"comes up latched and clean - so the finding above is caused by the "
|
||||
"roster NAME and not by the fixture being tripped",
|
||||
code_ok == RR.EXIT_OK and "LATCH CAME UP DISARMED" not in out_ok)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("6. THE REVERSE DIRECTION: the retreat, and the return from it")
|
||||
# --------------------------------------------------------------------------- #
|
||||
code_r, out_r = run(HERE, "state-flat-070.json", "roster.yaml",
|
||||
"--reverse", "--round-trip")
|
||||
_RL = legs(out_r)
|
||||
print(f" legs in the reverse run: {sorted(_RL)}")
|
||||
_orphaned = set(re.findall(r"ORPHANED ENTITY: (sensor\.\S+)",
|
||||
_RL.get("RETREAT", "")))
|
||||
print(f" the RETREAT leg orphans: {sorted(_orphaned)}")
|
||||
check("the RETREAT is rehearsed as its own leg, and THAT LEG reports the "
|
||||
"entities it orphans - the eight per-unit rows, which is the cost of "
|
||||
"backing out",
|
||||
{"RETREAT", "ROUND-TRIP"} <= set(_RL) and _orphaned == {
|
||||
f"sensor.goodwe_{u}_{k}"
|
||||
for u in ("garage", "shed")
|
||||
for k in ("setpoint", "battery_power", "battery_soc", "sign_latch")})
|
||||
check("...and NOT one site entity: the eight ids the install has today survive "
|
||||
"the retreat, which is what makes the eight above attributable to the "
|
||||
"per-unit rows and not to a blanket rename",
|
||||
not any(o in _orphaned for o in
|
||||
("sensor.goodwe_grid_power", "sensor.goodwe_setpoint",
|
||||
"sensor.goodwe_battery_power", "sensor.goodwe_p1_sample_age")))
|
||||
check("...and the first pack's maintenance history SURVIVES the retreat, "
|
||||
"because `UnitHistory.mirror_flat` keeps the flat keys live - so the "
|
||||
"retreat itself does not lose state. ⚠️ Read from the RETREAT LEG "
|
||||
"alone, so the round trip's own loss cannot be mistaken for it",
|
||||
"MAINTENANCE HISTORY LOST" not in _RL["RETREAT"])
|
||||
check("⚠️ ...but the ROUND TRIP does not: a maintenance cycle completed while "
|
||||
"the install is rolled back is DISCARDED when it re-advances, because "
|
||||
"`maint_schema_version` is already stamped and the migration skips",
|
||||
"RETREAT-WINDOW WORK DISCARDED" in _RL["ROUND-TRIP"])
|
||||
check("...and that is a leg whose data can reach the failure it names: the "
|
||||
"retreat window actually contains a completion, written through the "
|
||||
"shipped `UnitHistory`, at a timestamp the starting fixture does not have",
|
||||
RR.RETREAT_WINDOW_COMPLETION in out_r
|
||||
and RR.RETREAT_WINDOW_COMPLETION
|
||||
not in (FIX / "state-flat-070.json").read_text())
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED {len(fails)}:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,893 @@
|
||||
"""Runnable check for the ingress configuration surface. `python3 test_selfconfig.py`
|
||||
|
||||
UX-02. The panel edits, validates and saves the add-on's own options. Four of
|
||||
its properties are the ones that could hurt somebody, and they are what this
|
||||
file spends its length on:
|
||||
|
||||
1. AN INVALID BLOB IS NEVER WRITTEN. Not "warned about": not written. An
|
||||
invalid blob plus a restart is exit 78, and an add-on that is not running
|
||||
is not serving the panel either - the operator's way back would be the
|
||||
Supervisor UI, which is the surface this card exists because it is hard to
|
||||
read. Part 2 attempts the write and counts what the Supervisor received.
|
||||
2. A BLOB THAT MOVED UNDERNEATH IS NEVER CLOBBERED. There are two config
|
||||
surfaces and only one blob. Part 3 simulates the other one writing between
|
||||
this page's read and its save.
|
||||
3. EVERY MESSAGE COMES FROM THE SHIPPED VALIDATOR. Part 6 adds a NEW RULE to
|
||||
a COPY of validate.py, serves the page out of that copy in a subprocess,
|
||||
and watches the new sentence arrive on the field it names - with every UI
|
||||
file byte-identical, which is asserted by hashing them.
|
||||
4. NOTHING HERE COMMANDS AN INVERTER. Part 7 lists what the panel exposes and
|
||||
compares it with what `/action` already offered.
|
||||
|
||||
⚠️ 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. That is TEST-15, and
|
||||
part 6 below builds a mutated tree on purpose, so it purges its own copy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from aiohttp import ClientSession, web as aioweb
|
||||
|
||||
from app import config_page, configui, selfconfig, web
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures.
|
||||
#
|
||||
# ⚠️ `SCHEMA` AND `TRANSLATIONS` ARE FIXTURES OF SOMEBODY ELSE'S RESPONSE SHAPE,
|
||||
# and they are graded against the real thing somewhere else on purpose.
|
||||
# `tools/supervisor_selfapi_probe.py` builds the same two structures with
|
||||
# Supervisor 2026.08.0's OWN `UiOptions` and `_read_app_translations` and runs
|
||||
# this repository's `field_model()` over them - §C and §E. This file cannot: the
|
||||
# CI image is Alpine + python3 + aiohttp + paho and has neither the Supervisor
|
||||
# nor PyYAML. So the shapes below are small, and the claim that they are the
|
||||
# shapes that arrive is the probe's, not this file's.
|
||||
# --------------------------------------------------------------------------- #
|
||||
SCHEMA = [
|
||||
{"name": "setpoint_entity", "required": True, "type": "string"},
|
||||
{"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0,
|
||||
"required": True, "type": "integer"},
|
||||
{"name": "meter_source", "required": True, "type": "select",
|
||||
"options": ["off", "ha_dsmr"]},
|
||||
{"name": "log_level", "required": True, "type": "select",
|
||||
"options": ["info", "trace"]},
|
||||
{"name": "batt_invert", "required": True, "type": "boolean"},
|
||||
{"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"},
|
||||
{"name": "bms_soc_floor", "lengthMin": 0.0, "lengthMax": 50.0,
|
||||
"required": True, "type": "integer"}]},
|
||||
]
|
||||
TRANSLATIONS = {
|
||||
"en": {"configuration": {
|
||||
"setpoint_entity": {"name": "Hardware · Inverter setpoint entity",
|
||||
"description": "Where the setpoint is written."},
|
||||
"max_w": {"name": "Limits · Inverter rating (W)",
|
||||
"description": "The ceiling this site may command."},
|
||||
"meter_source": {"name": "Meter · Source",
|
||||
"description": "Where the grid reading comes from."},
|
||||
"log_level": {"name": "Advanced · Log level",
|
||||
"description": "How much the add-on writes to its log."},
|
||||
"batt_invert": {"name": "Hardware · Battery power sign is reversed",
|
||||
"description": "Only if your sensor is backwards."},
|
||||
"inverters": {"name": "Hardware · Inverter list",
|
||||
"description": "Leave empty for one inverter.",
|
||||
"fields": {
|
||||
"name": {"name": "Name"},
|
||||
"setpoint_entity": {"name": "Setpoint entity"},
|
||||
"max_w": {"name": "Rating (W)"},
|
||||
"bms_soc_floor": {"name": "Battery cut-out (%)"}}}}},
|
||||
"nl": {"configuration": {
|
||||
"max_w": {"name": "Grenzen · Omvormervermogen (W)",
|
||||
"description": "Het plafond voor deze site."}}},
|
||||
}
|
||||
|
||||
|
||||
def base(**over):
|
||||
"""An options blob that STARTS. Everything else is this, spoilt.
|
||||
|
||||
The same fixture shape as test_validate.py's `base()`, for the same reason
|
||||
it gives: a check that is measuring a broken config has to be able to show
|
||||
that the unbroken one is accepted, or it is measuring nothing.
|
||||
"""
|
||||
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "log_level": "info", "batt_invert": False,
|
||||
"inverters": [],
|
||||
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
class FakeSupervisor:
|
||||
"""The Supervisor's stored options, and a count of what reached them.
|
||||
|
||||
⚠️ IT COUNTS WRITES, NOT CALLS. "Nothing was written" is the property parts
|
||||
2 and 3 are about, and a fake that only recorded that `write_options` was
|
||||
reached could not tell a refusal from a write that failed afterwards.
|
||||
"""
|
||||
|
||||
def __init__(self, options=None):
|
||||
self.options = dict(options or base())
|
||||
self.writes = []
|
||||
self.restarts = 0
|
||||
|
||||
async def info(self):
|
||||
return {"options": dict(self.options), "schema": SCHEMA,
|
||||
"translations": TRANSLATIONS, "state": "started"}
|
||||
|
||||
async def write_options(self, options):
|
||||
self.writes.append(dict(options))
|
||||
self.options = dict(options)
|
||||
|
||||
async def restart(self, confirm):
|
||||
if confirm != selfconfig.RESTART_CONFIRM:
|
||||
raise selfconfig.NotConfirmed("not confirmed")
|
||||
self.restarts += 1
|
||||
|
||||
|
||||
class FakeHass:
|
||||
"""`hass.limits()` and nothing else. It cannot write: that is the point."""
|
||||
|
||||
def __init__(self, ranges=None):
|
||||
self.ranges = ranges or {}
|
||||
self.reads = []
|
||||
|
||||
async def limits(self, entity):
|
||||
self.reads.append(entity)
|
||||
return self.ranges.get(entity, (None, None, None))
|
||||
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, opts=None, hass=None):
|
||||
self.o = dict(opts or base())
|
||||
self.hass = hass or FakeHass()
|
||||
self.inverters = []
|
||||
self.events = []
|
||||
|
||||
def log_event(self, text):
|
||||
self.events.append(text)
|
||||
|
||||
def status_payload(self):
|
||||
return {"banner": "", "level": "ok", "rows": [], "checks": []}
|
||||
|
||||
async def handle_action(self, what):
|
||||
raise AssertionError("the configuration surface must never reach "
|
||||
f"handle_action ({what!r})")
|
||||
|
||||
|
||||
# ⚠️ THE ANCHOR LIST IS EVERY OPTION KEY config.yaml DECLARES, read off the
|
||||
# file with the shipped parser rather than typed here. That is what the running
|
||||
# panel anchors against too: `GET /addons/self/info` returns the MERGED options,
|
||||
# so every key is present in the blob the page posts back. A test that anchored
|
||||
# against a small fixture's keys would report a rule as "unanchored" purely
|
||||
# because its fixture did not mention the key - which is what the first draft
|
||||
# of part 4 did, for the price rules.
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import translations_lint as _tl # noqa: E402
|
||||
|
||||
ALL_KEYS, _NESTED = _tl.config_surface(
|
||||
io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the compare-and-swap token")
|
||||
_a = base()
|
||||
_b = dict(reversed(list(base().items()))) # same content, other key order
|
||||
check("two blobs with the same content have the same fingerprint, whatever "
|
||||
"order their keys were built in - a token that changed on a dict "
|
||||
"reordering would refuse every save for no reason",
|
||||
selfconfig.fingerprint(_a) == selfconfig.fingerprint(_b))
|
||||
check("one changed value changes it",
|
||||
selfconfig.fingerprint(base()) != selfconfig.fingerprint(base(max_w=2001)))
|
||||
check("...and so does a changed ROSTER ORDER, because roster order is meaning",
|
||||
selfconfig.fingerprint(base(inverters=[{"name": "a"}, {"name": "b"}]))
|
||||
!= selfconfig.fingerprint(base(inverters=[{"name": "b"}, {"name": "a"}])))
|
||||
check("it is a hex digest and not the blob - nothing about the configuration "
|
||||
"leaves through it",
|
||||
re.fullmatch(r"[0-9a-f]{64}", selfconfig.fingerprint(base())) is not None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. AN INVALID BLOB IS REFUSED, AND NOTHING IS WRITTEN")
|
||||
sup = FakeSupervisor()
|
||||
fp = selfconfig.fingerprint(sup.options)
|
||||
bad = base(setpoint_entity="") # rule 2: a controller with no actuator
|
||||
refused = None
|
||||
try:
|
||||
run(selfconfig.save(sup, bad, fp))
|
||||
except selfconfig.SaveRefused as err:
|
||||
refused = err
|
||||
check("the save raises SaveRefused(invalid)",
|
||||
refused is not None and refused.kind == "invalid")
|
||||
check("⚠️ AND THE SUPERVISOR RECEIVED NOTHING. Not a write that was rolled "
|
||||
"back, not a write of the old blob - no write at all",
|
||||
sup.writes == [])
|
||||
check("the stored options are byte-identical to what they were",
|
||||
selfconfig.fingerprint(sup.options) == fp)
|
||||
check("...and the refusal carries the validator's OWN sentence, unedited",
|
||||
refused.problems == validate_startup_config(bad, None, None))
|
||||
check("...which is the sentence that names the key and says what it costs",
|
||||
any("setpoint_entity is not set" in p for p in refused.problems))
|
||||
|
||||
_good = base()
|
||||
res = run(selfconfig.save(sup, _good, fp))
|
||||
check("a config that STARTS is saved - or nothing above means anything",
|
||||
len(sup.writes) == 1 and sup.writes[0] == _good)
|
||||
check("...and the caller gets the new token back, so the next save can "
|
||||
"compare against what it just wrote",
|
||||
res["fingerprint"] == selfconfig.fingerprint(_good))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2b. ⚠️ THE TWO HALVES OF THE max_w FIX, EACH PINNED SO IT CANNOT BE UNDONE")
|
||||
# Review found that BOTH halves reverted with the whole 24-file suite still
|
||||
# green - including the half that turns a crash into a SILENT ACCEPTANCE, which
|
||||
# is the more dangerous of the two and was a wrong first attempt at this very
|
||||
# fix. A fix nothing grades is a fix that comes back. MUTATION.md habit 4.
|
||||
from app.inverter import _flat_max_w, build_roster # noqa: E402
|
||||
from app.validate import _number # noqa: E402
|
||||
|
||||
# ⚠️ THIS CHECK EXISTS BECAUSE THE OBVIOUS ONE HAS A HOLE. "problems_and_ranges
|
||||
# does not raise" is ALSO satisfied by the backstop swallowing the exception and
|
||||
# returning [] - which is the silent-acceptance bug half two exists to prevent.
|
||||
# Measured: with BOTH halves reverted, every max_w check below reported ok and
|
||||
# only the forced-boom pins went red. So half one is pinned HERE, on the
|
||||
# composition itself, where the backstop cannot answer for it.
|
||||
_raised = None
|
||||
try:
|
||||
build_roster(base(max_w=""), None)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_raised = exc
|
||||
check("⭐ THE COMPOSITION ITSELF TOLERATES A CLEARED max_w - not merely the "
|
||||
"backstop above it. `validate.py`'s `_number()` reads '' as the default, "
|
||||
"so rule 9 sees 2000 and is content; a bare `float('')` in the flat path "
|
||||
"raised instead, inverting inverter.py's own rule that a shape rule 9 "
|
||||
"tolerates must be composable",
|
||||
_raised is None)
|
||||
|
||||
# HALF ONE: composition tolerates exactly what rule 9 tolerates.
|
||||
for raw in ("", None, 0, False, True, -1, "2000", " 2000 ", "2e3", 1e18):
|
||||
o = base(max_w=raw)
|
||||
expect = _number(o, "max_w", 2000.0)
|
||||
if expect is not None:
|
||||
check(f"_flat_max_w agrees with _number() for max_w={raw!r}",
|
||||
_flat_max_w(o) == expect)
|
||||
|
||||
_raised = None
|
||||
try:
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(base(max_w=""), None))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_raised, _probs = exc, None
|
||||
check("⚠️ A CLEARED max_w BOX ANSWERS WITH A VERDICT, NOT AN EXCEPTION. It "
|
||||
"used to raise ValueError from inside Controller.__init__, which the "
|
||||
"panel served as HTTP 500 on a non-JSON body and the page rendered as "
|
||||
"'cannot reach the add-on' - untrue, for the most prominent limit "
|
||||
"field's most likely transient state",
|
||||
_raised is None)
|
||||
check("...and it agrees with the validator, which reads '' as the default",
|
||||
_probs == list(validate_startup_config(base(max_w=""), None, None)))
|
||||
|
||||
_raised = None
|
||||
try:
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(base(max_w="abc"), None))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_raised, _probs = exc, None
|
||||
check("an UNPARSEABLE max_w answers with the validator's own sentence",
|
||||
_raised is None and any("is not a number" in p for p in (_probs or [])))
|
||||
|
||||
# HALF ONE, THE PART THAT MAKES IT SAFE RATHER THAN MERELY NOT-CRASHING:
|
||||
# clearing the box on the LIVE shape must FAIL CLOSED, not silently halve the
|
||||
# rail. `unit_caps_indexed` reads the flat rail through the same `_number`, so
|
||||
# the rail collapses to 2000 in lockstep and rule 10 catches the collapse.
|
||||
_live = base(max_w=5000, slew_w=400)
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(dict(_live), None))
|
||||
check("the live shape (5000 W rail, slew_w 400) starts", _probs == [])
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(dict(_live, max_w=""), None))
|
||||
check("⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the "
|
||||
"rail collapsing to the 2000 default under an explicit 400 W slew, so "
|
||||
"the box cannot be emptied into a quietly halved rail",
|
||||
any("site rail of 2000 W" in p for p in _probs))
|
||||
|
||||
# HALF TWO: the backstop cannot report "no problems" for a config it could not
|
||||
# compose. Returning [] there enables Save - a crash became a green.
|
||||
_orig_roster = selfconfig.build_roster
|
||||
|
||||
|
||||
def _boom(*_a, **_kw):
|
||||
raise ValueError("forced boom")
|
||||
|
||||
|
||||
selfconfig.build_roster = _boom
|
||||
try:
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(base(), None))
|
||||
finally:
|
||||
selfconfig.build_roster = _orig_roster
|
||||
check("⚠️ THE BACKSTOP NEVER RETURNS AN EMPTY LIST. A roster that cannot be "
|
||||
"composed while the validator names no reason is itself the finding - "
|
||||
"returning [] would turn a crash into a silent acceptance and enable "
|
||||
"Save, which is worse than the 500 it replaced",
|
||||
_probs != [] and len(_probs) == 1)
|
||||
check("...and it says so in words, naming the exception",
|
||||
any("could not be composed" in p and "forced boom" in p
|
||||
for p in _probs))
|
||||
check("...and the patch was undone, so nothing below inherits it",
|
||||
selfconfig.build_roster is _orig_roster)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. THE OTHER SURFACE WRITES WHILE THIS PAGE IS OPEN")
|
||||
# ⚠️ SIMULATED AT THE EXACT MOMENT IT MATTERS. The Supervisor's Configuration
|
||||
# tab is not removable, so this is not a hypothetical race - it is the normal
|
||||
# way two people, or one person on two devices, use this add-on. The blob is
|
||||
# changed AFTER the page's read and BEFORE its save.
|
||||
# ⚠️ EVERY BLOB IN THIS SECTION MUST BE ONE THAT STARTS. The gate validates
|
||||
# BEFORE it compares, so a fixture that is also invalid would be refused for
|
||||
# the wrong reason and this section would be measuring rule 6 instead of the
|
||||
# compare-and-swap. (It was, in the first draft: `max_w: 1800` beneath the
|
||||
# fixture's `maintenance_charge_w: 2000` is a genuine refusal.)
|
||||
sup = FakeSupervisor(base(max_w=2000))
|
||||
page_fp = selfconfig.fingerprint(sup.options) # what the page loaded
|
||||
sup.options = base(max_w=2500) # the other tab saves
|
||||
mine = base(max_w=3000, log_level="trace") # what the operator typed
|
||||
refused = None
|
||||
try:
|
||||
run(selfconfig.save(sup, mine, page_fp))
|
||||
except selfconfig.SaveRefused as err:
|
||||
refused = err
|
||||
check("the save raises SaveRefused(stale)",
|
||||
refused is not None and refused.kind == "stale")
|
||||
check("⚠️ AND AGAIN NOTHING WAS WRITTEN - the other surface's value stands, "
|
||||
"and the operator's edits were not silently thrown at it",
|
||||
sup.writes == [] and sup.options["max_w"] == 2500)
|
||||
check("the refusal NAMES the keys that differ, so the page can print them "
|
||||
"rather than saying 'something changed'",
|
||||
refused.changed == ["log_level", "max_w"])
|
||||
check("...and carries what is stored, so the operator decides in front of the "
|
||||
"difference rather than through it",
|
||||
refused.stored["max_w"] == 2500)
|
||||
_after = run(selfconfig.save(sup, mine, selfconfig.fingerprint(sup.options)))
|
||||
check("saving again against the CURRENT token succeeds - the refusal is a "
|
||||
"gate, not a lock",
|
||||
len(sup.writes) == 1 and sup.options["max_w"] == 3000
|
||||
and _after["fingerprint"] == selfconfig.fingerprint(mine))
|
||||
|
||||
# ⚠️ AND THERE IS NO WAY ROUND IT. A caller with no token cannot save.
|
||||
sup = FakeSupervisor()
|
||||
try:
|
||||
run(selfconfig.save(sup, base(max_w=2400), ""))
|
||||
check("an empty token is refused", False)
|
||||
except selfconfig.SaveRefused as err:
|
||||
check("an empty token is refused as stale rather than treated as 'do not "
|
||||
"check' - there is no force door in this module",
|
||||
err.kind == "stale" and sup.writes == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. WHERE A REFUSAL LANDS ON THE PAGE")
|
||||
# ⚠️ MEASURED, NOT CLAIMED. The anchoring is name occurrence: a sentence is
|
||||
# printed under every option key it spells. Rather than assert a coverage
|
||||
# figure, this runs the SHIPPED validator on a spread of broken configs and
|
||||
# prints what anchored and what did not.
|
||||
_cases = [
|
||||
("no actuator", base(setpoint_entity="")),
|
||||
("max_w over the device", base(max_w=4000)),
|
||||
("charge rate above the ceiling", base(maintenance_charge_w=3000)),
|
||||
("sign threshold at the ceiling", base(sign_disagree_w=2000)),
|
||||
("two writers on one actuator",
|
||||
base(inverters=[{"name": "a", "setpoint_entity": "number.goodwe_setpoint",
|
||||
"max_w": 2000, "bms_soc_floor": 10}])),
|
||||
("a price source that cannot mean what it says",
|
||||
base(price_provider="manual_csv")),
|
||||
]
|
||||
_ranges = {"number.goodwe_setpoint": (-3000.0, 3000.0, 1.0)}
|
||||
_total = _anchored = 0
|
||||
_orphans = []
|
||||
for title, opts in _cases:
|
||||
probs = run(selfconfig.problems_for(opts, FakeHass(_ranges)))
|
||||
by_key, un = configui.problems_by_field(probs, ALL_KEYS)
|
||||
_total += len(probs)
|
||||
_anchored += len(probs) - len(un)
|
||||
_orphans += [(title, p[:70]) for p in un]
|
||||
print(f" {title:34s} {len(probs)} problem(s), "
|
||||
f"{len(probs) - len(un)} beside a field")
|
||||
check(f"every one of the {_total} refusals these six broken configs produce "
|
||||
f"lands beside at least one of the fields it is about. Unanchored: "
|
||||
f"{_orphans}", _total > 0 and not _orphans)
|
||||
|
||||
_probs = run(selfconfig.problems_for(base(max_w=4000), FakeHass(_ranges)))
|
||||
_by, _un = configui.problems_by_field(_probs, ALL_KEYS)
|
||||
check("the device-range rule is REACHED from the panel - the setpoint entity's "
|
||||
"own range is read, so rule 1 fires here exactly as it does at startup",
|
||||
any("THE DEVICE WINS" in p for p in _probs))
|
||||
check("...and it lands on `max_w`, which is the field the operator has to "
|
||||
"change", "max_w" in _by)
|
||||
# ⚠️ THE COLLISION, CHECKED HERE AND NOT ONLY IN THE PROBE. `sign_disagree_w`
|
||||
# is a suffix of both `unit_sign_disagree_w` and `site_sign_disagree_w` - three
|
||||
# real options, two of them SAFETY-30's. A plain substring test would print
|
||||
# every per-unit refusal next to the SITE field as well, which is a message
|
||||
# about unit-0 beside a box that has nothing to do with it. CI runs this file
|
||||
# and not the probe, so the assertion has to live here too.
|
||||
_collide = [k for k in ALL_KEYS
|
||||
if any(o != k and o.endswith("_" + k) for o in ALL_KEYS)]
|
||||
check(f"config.yaml really does declare keys that are suffixes of other keys, "
|
||||
f"so the boundary matching is load-bearing: {_collide}", bool(_collide))
|
||||
check("...and a sentence naming `unit_sign_disagree_w` is NOT also filed under "
|
||||
"`sign_disagree_w`",
|
||||
sorted(configui.problems_by_field(
|
||||
["unit_sign_disagree_w is 3000 W but unit-0's max_w is 2000 W"],
|
||||
ALL_KEYS)[0]) == ["max_w", "unit_sign_disagree_w"])
|
||||
check("a cross-field refusal is filed under EVERY key it names, because a "
|
||||
"cross-field rule is about all of its fields",
|
||||
len(configui.problems_by_field(
|
||||
["max_w is 8000 W but number.x accepts at most 5000 W"],
|
||||
["max_w", "setpoint_entity"])[0]) == 1)
|
||||
check("the panel shows no refusal at all for a config that starts",
|
||||
run(selfconfig.problems_for(base(), FakeHass(_ranges))) == [])
|
||||
|
||||
# ⚠️ AND WHEN A DEVICE DOES NOT ANSWER, THE PAGE SAYS WHICH ONE. The two
|
||||
# device-range rules are then skipped - here and at startup, identically - so a
|
||||
# green page would be green for a reason the operator cannot see.
|
||||
_p, _unread = run(selfconfig.problems_and_ranges(base(max_w=4000), FakeHass()))
|
||||
check("with the setpoint device silent, rule 1 does not fire and the entity "
|
||||
"that did not answer is named, so the page can say what was skipped",
|
||||
_p == [] and _unread == ["number.goodwe_setpoint"])
|
||||
_p2, _unread2 = run(selfconfig.problems_and_ranges(base(max_w=4000),
|
||||
FakeHass(_ranges)))
|
||||
check("...and with the same device answering, the same config IS refused - "
|
||||
"which is what makes the line above a skip rather than a pass",
|
||||
any("THE DEVICE WINS" in p for p in _p2) and _unread2 == [])
|
||||
check("the note is about the config UNDER TEST, not about the running roster: "
|
||||
"a newly typed entity that does not answer is the one named",
|
||||
run(selfconfig.problems_and_ranges(
|
||||
base(setpoint_entity="number.just_typed"), FakeHass(_ranges)))[1]
|
||||
== ["number.just_typed"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. THE UI OWNS NO COPY OF ANY RULE, LABEL OR KEY")
|
||||
_page = io.open(os.path.join(HERE, "app", "config_page.py"),
|
||||
encoding="utf-8").read()
|
||||
_ui_files = {name: io.open(os.path.join(HERE, "app", name),
|
||||
encoding="utf-8").read()
|
||||
for name in ("config_page.py", "configui.py", "web.py")}
|
||||
# The option keys config.yaml declares, read off the file rather than typed
|
||||
# here - a hand-written list would go stale in the direction that hides a
|
||||
# copy.
|
||||
# `name` and `max_w` also occur as ordinary English/JS words or as model field
|
||||
# names, so the test is on the SCRIPT half of the page and on keys that could
|
||||
# only be there deliberately.
|
||||
_script = _page.split("<script>", 1)[1]
|
||||
_leaked = sorted(k for k in ALL_KEYS
|
||||
if len(k) > 6 and re.search(r"[^A-Za-z0-9_]" + k + r"[^A-Za-z0-9_]",
|
||||
_script))
|
||||
check(f"the page's script names NOT ONE option key - so it cannot be rendering "
|
||||
f"a field list of its own. Leaked: {_leaked}", not _leaked)
|
||||
_phrases = ["THE DEVICE WINS", "has no actuator", "two writers",
|
||||
"setpoint_entity is not set", "would silently never happen"]
|
||||
_copied = sorted(p for p in _phrases
|
||||
for f, text in _ui_files.items() if p in text)
|
||||
check(f"no phrase out of a validate.py refusal appears anywhere in the three "
|
||||
f"UI files - the sentences are fetched, never written here. Copied: "
|
||||
f"{_copied}", not _copied)
|
||||
# ⚠️ THIS ONE HAS ALREADY EARNED ITS KEEP. The suffix map shipped with
|
||||
# `("_hour", "h (0-23)")` in it - a RANGE, next to a field, in a second place
|
||||
# from config.yaml's `int(0,23)`. It is exactly the drift this card forbids,
|
||||
# in the one corner of the panel that is allowed to print anything of its own.
|
||||
check("no unit string contains a digit, so the unit map cannot be stating a "
|
||||
"bound, a default or a relation - config.yaml is the only place a range "
|
||||
"is written and the schema is where the form gets it from",
|
||||
all(not any(ch.isdigit() for ch in unit)
|
||||
for _suffix, unit in configui._UNITS))
|
||||
check("...and no entry of it is a whole option key - it is a suffix map, so it "
|
||||
"cannot become a per-key opinion by accident",
|
||||
not [suf for suf, _u in configui._UNITS if suf.lstrip("_") in ALL_KEYS])
|
||||
|
||||
# ⚠️ RELATIVE URLS, WHICH IS WHAT INGRESS REQUIRES. An absolute path works
|
||||
# perfectly on a laptop and 404s in the field, so it cannot be left to review.
|
||||
_abs = re.findall(r"""(?:fetch\(|href=)['"](/[^'"]*)['"]""",
|
||||
_page + _ui_files["web.py"])
|
||||
check(f"no absolute URL in either page - every fetch and every link is "
|
||||
f"relative, because ingress serves this under a generated path prefix. "
|
||||
f"Absolute: {_abs}", not _abs)
|
||||
check("the page declares a viewport, which is the whole of 'readable on the "
|
||||
"phone you are holding at the inverter'",
|
||||
'name="viewport"' in _page and "max-width:760px" in _page)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("6. ⚠️ A RULE ADDED TO validate.py APPEARS IN THE UI WITH NO UI CHANGE")
|
||||
# THE ACCEPTANCE CRITERION, RUN RATHER THAN ARGUED. A copy of this package is
|
||||
# made, ONE new rule is inserted into validate.py's `validate_startup_config()`,
|
||||
# and the real HTTP route is served out of that copy in a subprocess. If the
|
||||
# page carried any list of known rules, the new sentence would not arrive.
|
||||
SENTINEL = ("SENTINEL-UX02: log_level is set to 'trace', which this rule "
|
||||
"refuses so that a check can watch it arrive.")
|
||||
_INSERT = (' if str(opts.get("log_level", "")) == "trace":\n'
|
||||
f' problems.append({SENTINEL!r})\n')
|
||||
|
||||
_probe = r'''
|
||||
import asyncio, json, os, sys
|
||||
sys.path.insert(0, os.environ["TREE"])
|
||||
from aiohttp import ClientSession, web as aioweb
|
||||
from app import web
|
||||
|
||||
class H:
|
||||
session = None
|
||||
token = ""
|
||||
async def limits(self, e): return (None, None, None)
|
||||
class C:
|
||||
o = json.loads(os.environ["OPTS"]); hass = H(); inverters = []
|
||||
def log_event(self, t): pass
|
||||
def status_payload(self): return {}
|
||||
async def handle_action(self, w): raise AssertionError("no")
|
||||
|
||||
async def main():
|
||||
app = web.build_app(C())
|
||||
runner = aioweb.AppRunner(app); await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0); await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
async with ClientSession() as s:
|
||||
async with s.post(f"http://127.0.0.1:{port}/config/validate",
|
||||
json={"options": json.loads(os.environ["OPTS"])}) as r:
|
||||
print(json.dumps(await r.json()))
|
||||
await runner.cleanup()
|
||||
|
||||
asyncio.run(main())
|
||||
'''
|
||||
|
||||
_tmp = tempfile.mkdtemp(prefix="ux02-mutant-")
|
||||
try:
|
||||
_tree = os.path.join(_tmp, "goodwe_controller")
|
||||
shutil.copytree(os.path.join(HERE, "app"), os.path.join(_tree, "app"))
|
||||
# ⚠️ PURGE THE COPY'S BYTECODE. MUTATION.md habit: `python3 -B` stops
|
||||
# bytecode being WRITTEN, never a stale one being READ, and a copytree
|
||||
# brings any `__pycache__` along with it.
|
||||
_purged = 0
|
||||
for root, dirs, files in os.walk(_tree):
|
||||
for d in list(dirs):
|
||||
if d == "__pycache__":
|
||||
shutil.rmtree(os.path.join(root, d))
|
||||
dirs.remove(d)
|
||||
_purged += 1
|
||||
print(f" purged {_purged} __pycache__ director(y|ies) from the copy; "
|
||||
f"{sum(1 for r, _d, fs in os.walk(_tree) for f in fs if f.endswith('.pyc'))}"
|
||||
" .pyc left")
|
||||
|
||||
_vpath = os.path.join(_tree, "app", "validate.py")
|
||||
_src = io.open(_vpath, encoding="utf-8").read()
|
||||
_at = _src.index("def validate_startup_config")
|
||||
_anchor = " problems: list[str] = []\n"
|
||||
_cut = _src.index(_anchor, _at) + len(_anchor)
|
||||
io.open(_vpath, "w", encoding="utf-8").write(
|
||||
_src[:_cut] + _INSERT + _src[_cut:])
|
||||
check("the mutated copy differs from the shipped tree in validate.py, and "
|
||||
"in validate.py ONLY - so anything the page gains is the rule's",
|
||||
[name for name in sorted(os.listdir(os.path.join(HERE, "app")))
|
||||
if name.endswith(".py")
|
||||
and hashlib.sha256(io.open(os.path.join(HERE, "app", name), "rb")
|
||||
.read()).hexdigest()
|
||||
!= hashlib.sha256(io.open(os.path.join(_tree, "app", name), "rb")
|
||||
.read()).hexdigest()] == ["validate.py"])
|
||||
|
||||
_env = dict(os.environ, TREE=_tree, OPTS=json.dumps(base(log_level="trace")),
|
||||
PYTHONDONTWRITEBYTECODE="1")
|
||||
_out = subprocess.run([sys.executable, "-B", "-c", _probe], env=_env,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
if _out.returncode != 0:
|
||||
print(_out.stdout[-2000:])
|
||||
print(_out.stderr[-2000:])
|
||||
check("the page's own /config/validate route answers out of the mutated "
|
||||
"tree", _out.returncode == 0)
|
||||
_payload = json.loads(_out.stdout.strip().splitlines()[-1]) if _out.stdout else {}
|
||||
check("⚠️ THE NEW RULE'S SENTENCE IS ON THE PAGE, and no file under app/ "
|
||||
"other than validate.py was touched to put it there",
|
||||
SENTINEL in (_payload.get("problems") or []))
|
||||
check("...and it is filed under `log_level`, the field it names, by the "
|
||||
"same anchoring that files every other rule - so a new rule arrives "
|
||||
"in the right place rather than in a catch-all",
|
||||
SENTINEL in (_payload.get("by_key") or {}).get("log_level", []))
|
||||
check("...while the SHIPPED tree does not refuse that config at all, which "
|
||||
"is what makes the line above evidence about the rule rather than "
|
||||
"about the fixture",
|
||||
run(selfconfig.problems_for(base(log_level="trace"), None)) == [])
|
||||
finally:
|
||||
shutil.rmtree(_tmp, ignore_errors=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("7. THE PANEL'S REACH INTO THE INVERTER IS UNCHANGED")
|
||||
_app = web.build_app(FakeController(), FakeSupervisor())
|
||||
_routes = sorted({(r.method, r.get_info().get("path"))
|
||||
for r in _app.router.routes() if r.method != "HEAD"})
|
||||
print(" " + " ".join(f"{m} {p}" for m, p in _routes))
|
||||
check("the configuration surface adds five routes and touches none of the "
|
||||
"three that were there",
|
||||
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
|
||||
("GET", "/status"), ("POST", "/action"),
|
||||
("POST", "/config/restart"), ("POST", "/config/save"),
|
||||
("POST", "/config/validate")])
|
||||
_selfconfig_src = io.open(os.path.join(HERE, "app", "selfconfig.py"),
|
||||
encoding="utf-8").read()
|
||||
for forbidden in ("set_number", "write_setpoint", "handle_action",
|
||||
"command_site_zero", "sign_wd", "reset("):
|
||||
check(f"neither selfconfig.py nor the page can reach `{forbidden}`",
|
||||
forbidden not in _selfconfig_src
|
||||
and forbidden not in _ui_files["config_page.py"])
|
||||
# ⚠️ ASSERTED ON THE CONSTANTS, NOT ON A GREP OF THE FILE. The module's own
|
||||
# docstring quotes Supervisor's bypass regex, which names `security`, `update`
|
||||
# and `options/validate`; a grep would find those and report a reach the code
|
||||
# does not have. The three constants are what `_call()` can be given.
|
||||
check("the ONLY Supervisor endpoints this module defines are self/info, "
|
||||
"self/options and self/restart",
|
||||
sorted(v for k, v in vars(selfconfig).items()
|
||||
if k.startswith("SELF_"))
|
||||
== ["http://supervisor/addons/self/info",
|
||||
"http://supervisor/addons/self/options",
|
||||
"http://supervisor/addons/self/restart"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("8. end to end, over real HTTP, against a fake Supervisor")
|
||||
|
||||
|
||||
async def _serve(app):
|
||||
runner = aioweb.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
return runner, site._server.sockets[0].getsockname()[1]
|
||||
|
||||
|
||||
async def _e2e():
|
||||
out = {}
|
||||
sup = FakeSupervisor(base(max_w=2000))
|
||||
ctrl = FakeController(base(max_w=1500), # running != stored
|
||||
FakeHass({"number.goodwe_setpoint":
|
||||
(-3000.0, 3000.0, 1.0)}))
|
||||
runner, port = await _serve(web.build_app(ctrl, sup))
|
||||
root = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
async with s.get(f"{root}/config") as r:
|
||||
out["page"] = (r.status, await r.text())
|
||||
async with s.get(f"{root}/config/model?lang=nl") as r:
|
||||
out["model"] = await r.json()
|
||||
async with s.post(f"{root}/config/validate",
|
||||
json={"options": base(setpoint_entity="")}) as r:
|
||||
out["invalid"] = await r.json()
|
||||
# ⚠️ THE COUNTS ARE SNAPSHOTTED AT EACH STAGE. Asserting
|
||||
# `sup.writes == []` after the whole sequence would be asserting
|
||||
# about the LAST request, not this one - the first draft did, and
|
||||
# went red only because a later, legitimate save had happened.
|
||||
async with s.post(f"{root}/config/save",
|
||||
json={"options": base(setpoint_entity=""),
|
||||
"fingerprint": out["model"]["fingerprint"]}
|
||||
) as r:
|
||||
out["save_invalid"] = (r.status, await r.json(),
|
||||
len(sup.writes))
|
||||
async with s.post(f"{root}/config/save",
|
||||
json={"options": base(max_w=2400),
|
||||
"fingerprint": "not-the-token"}) as r:
|
||||
out["save_stale"] = (r.status, await r.json(), len(sup.writes))
|
||||
async with s.post(f"{root}/config/save",
|
||||
json={"options": base(max_w=2400),
|
||||
"fingerprint": out["model"]["fingerprint"]}
|
||||
) as r:
|
||||
out["save_ok"] = (r.status, await r.json())
|
||||
async with s.post(f"{root}/config/restart", json={}) as r:
|
||||
out["restart_bare"] = (r.status, await r.json(), sup.restarts)
|
||||
async with s.post(f"{root}/config/restart",
|
||||
json={"confirm": "restart"}) as r:
|
||||
out["restart_ok"] = (r.status, await r.json())
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
out["sup"] = sup
|
||||
out["ctrl"] = ctrl
|
||||
return out
|
||||
|
||||
|
||||
E = run(_e2e())
|
||||
check("GET config serves the page", E["page"][0] == 200
|
||||
and "GoodWe - Configuration" in E["page"][1])
|
||||
_m = E["model"]
|
||||
check("the model carries the groups, the stored blob and its token",
|
||||
# `Grenzen`, not `Limits`: this request asked for nl, and nl.yaml
|
||||
# carries `max_w`. The three groups around it fall back to English per
|
||||
# key, which is the frontend's own rule and is asserted two checks below.
|
||||
[g["name"] for g in _m["groups"]] == ["Hardware", "Grenzen", "Meter",
|
||||
"Advanced"]
|
||||
and _m["options"]["max_w"] == 2000
|
||||
and _m["fingerprint"] == selfconfig.fingerprint(_m["options"]))
|
||||
check("...and it says which stored settings are NOT what this add-on is "
|
||||
"running - which is how an edit made in the Configuration tab, or a save "
|
||||
"not yet restarted into, becomes visible here",
|
||||
_m["running_differs"] == ["max_w"] and _m["running"]["max_w"] == 1500)
|
||||
check("...and it honours the browser's language per key, falling back to "
|
||||
"English for the keys nl.yaml does not carry - the frontend's own rule",
|
||||
_m["lang"] == "nl"
|
||||
and [f["label"] for g in _m["groups"] for f in g["fields"]
|
||||
if f["key"] == "max_w"] == ["Omvormervermogen (W)"]
|
||||
and [f["label_lang"] for g in _m["groups"] for f in g["fields"]
|
||||
if f["key"] == "meter_source"] == ["en"])
|
||||
check("POST config/validate returns the validator's sentences, anchored",
|
||||
E["invalid"]["problems"]
|
||||
and "setpoint_entity" in E["invalid"]["by_key"])
|
||||
check("a save of an invalid blob answers 422 and the Supervisor got nothing",
|
||||
E["save_invalid"][0] == 422
|
||||
and E["save_invalid"][1]["kind"] == "invalid"
|
||||
and E["save_invalid"][2] == 0)
|
||||
check("a save with a stale token answers 409, writes nothing, and hands back "
|
||||
"the CURRENT token so the operator can save again in front of the "
|
||||
"difference",
|
||||
E["save_stale"][0] == 409
|
||||
and E["save_stale"][1]["kind"] == "stale"
|
||||
and E["save_stale"][1]["fingerprint"]
|
||||
== selfconfig.fingerprint(E["save_stale"][1]["stored"])
|
||||
and E["save_stale"][2] == 0)
|
||||
check("a valid save with the right token is written, once",
|
||||
E["save_ok"][0] == 200 and len(E["sup"].writes) == 1
|
||||
and E["sup"].writes[0]["max_w"] == 2400)
|
||||
check("...and it does NOT restart anything - applying is a separate, "
|
||||
"confirmed act, because it costs a control interruption",
|
||||
E["restart_bare"][0] == 400 and E["restart_bare"][2] == 0)
|
||||
check("...which the confirmed request performs",
|
||||
E["restart_ok"][0] == 200)
|
||||
check("the action log records both, so §15 can say who changed what",
|
||||
any("configuration saved" in e for e in E["ctrl"].events)
|
||||
and any("restart requested" in e for e in E["ctrl"].events))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("9. the page says what a save costs BEFORE it is pressed")
|
||||
for phrase in ("0 W on every inverter", "Saving does not restart anything",
|
||||
"Restart to apply"):
|
||||
check(f"the page states {phrase!r} in its own markup, not in a message "
|
||||
"that only appears afterwards", phrase in _page)
|
||||
check("the restart confirmation names the cost again at the moment of pressing",
|
||||
"Yes, restart now" in _page
|
||||
and _page.count("0 W on every inverter") >= 2)
|
||||
_flat = " ".join(_page.split())
|
||||
check("and it says which surface is authoritative when the add-on is down",
|
||||
"If the add-on is not running, the Configuration tab is the only one of "
|
||||
"the two that still exists" in _flat)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("10. the Supervisor conversation itself, over real HTTP")
|
||||
# ⚠️ EVERYTHING ABOVE INJECTED A FAKE `selfcfg`, so none of it exercised the
|
||||
# one piece that talks to somebody else's server: the headers, the request
|
||||
# body's shape, the `{"result": "ok", "data": {...}}` envelope and what a
|
||||
# refusal turns into. A gate whose transport is never run is a gate that has
|
||||
# only been read.
|
||||
|
||||
|
||||
async def _transport():
|
||||
seen = []
|
||||
|
||||
async def h_info(request):
|
||||
seen.append(("info", request.headers.get("Authorization"), None))
|
||||
return aioweb.json_response({"result": "ok",
|
||||
"data": {"options": {"max_w": 2000}}})
|
||||
|
||||
async def h_options(request):
|
||||
body = await request.json()
|
||||
seen.append(("options", request.headers.get("Authorization"), body))
|
||||
return aioweb.json_response({"result": "ok", "data": {}})
|
||||
|
||||
async def h_restart(request):
|
||||
seen.append(("restart", request.headers.get("Authorization"), None))
|
||||
return aioweb.json_response({"result": "ok", "data": {}})
|
||||
|
||||
async def h_denied(_request):
|
||||
return aioweb.json_response(
|
||||
{"result": "error", "message": "Missing API permission"},
|
||||
status=403)
|
||||
|
||||
app = aioweb.Application()
|
||||
app.add_routes([aioweb.get("/addons/self/info", h_info),
|
||||
aioweb.post("/addons/self/options", h_options),
|
||||
aioweb.post("/addons/self/restart", h_restart),
|
||||
aioweb.get("/denied", h_denied)])
|
||||
runner, port = await _serve(app)
|
||||
root = f"http://127.0.0.1:{port}"
|
||||
saved = (selfconfig.SELF_INFO, selfconfig.SELF_OPTIONS,
|
||||
selfconfig.SELF_RESTART)
|
||||
selfconfig.SELF_INFO = f"{root}/addons/self/info"
|
||||
selfconfig.SELF_OPTIONS = f"{root}/addons/self/options"
|
||||
selfconfig.SELF_RESTART = f"{root}/addons/self/restart"
|
||||
out = {}
|
||||
try:
|
||||
async with ClientSession() as session:
|
||||
cfg = selfconfig.SelfConfig(session, "TOKEN-123")
|
||||
out["info"] = await cfg.info()
|
||||
await cfg.write_options({"max_w": 2400})
|
||||
await cfg.restart(selfconfig.RESTART_CONFIRM)
|
||||
try:
|
||||
await cfg.restart("")
|
||||
out["unconfirmed"] = None
|
||||
except selfconfig.NotConfirmed as err:
|
||||
out["unconfirmed"] = str(err)
|
||||
try:
|
||||
await cfg._call("GET", f"{root}/denied")
|
||||
out["denied"] = None
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
out["denied"] = str(err)
|
||||
try:
|
||||
await cfg._call("GET", f"{root}/nothing-here")
|
||||
out["missing"] = None
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
out["missing"] = str(err)
|
||||
finally:
|
||||
(selfconfig.SELF_INFO, selfconfig.SELF_OPTIONS,
|
||||
selfconfig.SELF_RESTART) = saved
|
||||
await runner.cleanup()
|
||||
out["seen"] = seen
|
||||
return out
|
||||
|
||||
|
||||
T = run(_transport())
|
||||
check("`info()` unwraps the Supervisor's `{result, data}` envelope",
|
||||
T["info"] == {"options": {"max_w": 2000}})
|
||||
check("every request carries the add-on's own SUPERVISOR_TOKEN as a bearer",
|
||||
[auth for _what, auth, _b in T["seen"]] == ["Bearer TOKEN-123"] * 3)
|
||||
check("the options write goes to /addons/self/options as {'options': blob} - "
|
||||
"the shape `SCHEMA_OPTIONS` accepts, and the shape that REPLACES the "
|
||||
"stored user options",
|
||||
[(w, b) for w, _a, b in T["seen"] if w == "options"]
|
||||
== [("options", {"options": {"max_w": 2400}})])
|
||||
check("the restart is a POST to /addons/self/restart and reaches it only when "
|
||||
"confirmed",
|
||||
[w for w, _a, _b in T["seen"]] == ["info", "options", "restart"]
|
||||
and T["unconfirmed"])
|
||||
check("a Supervisor refusal is carried through with ITS message and its status "
|
||||
"rather than rewritten - the page shows what the Supervisor said",
|
||||
"403" in T["denied"] and "Missing API permission" in T["denied"])
|
||||
check("...and so is a 404, so a path this add-on is not permitted is "
|
||||
"distinguishable from one that does not exist",
|
||||
"404" in T["missing"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -74,7 +74,7 @@ check(f"the option surface was actually read - {len(ORDER)} options, not zero",
|
||||
check("...and the repeatable `inverters:` section's per-entry keys were read "
|
||||
"too, which is the half a top-level-only reader would miss",
|
||||
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
|
||||
and len(NESTED["inverters"]) == 7)
|
||||
and len(NESTED["inverters"]) == 8)
|
||||
|
||||
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
|
||||
"languages")
|
||||
@@ -130,6 +130,11 @@ DERIVED = {
|
||||
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
|
||||
"circulation_w": ("SAFETY-31", "924"),
|
||||
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
|
||||
# SAFETY-42. Two needles rather than one: the campaign it was derived from,
|
||||
# and the word that says what it does NOT do. An operator who reads
|
||||
# "not-delivering report" and assumes the add-on has handled it is the
|
||||
# failure this alarm's whole wording is written against.
|
||||
"delivery_hold_s": ("SAFETY-42", "DERIVED FROM", "REPORTS ONLY"),
|
||||
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
|
||||
"bms_soc_floor": ("0x0560", "MEASURED"),
|
||||
"integrator_max_w": ("MEASURED", "Do not raise it"),
|
||||
@@ -243,10 +248,17 @@ for what, text in BAD:
|
||||
check(f"every shape outside the accepted subset RAISES rather than being "
|
||||
f"skipped. Silently accepted: {_accepted}", not _accepted)
|
||||
check("...and the accepted subset really does round-trip the shipped files - "
|
||||
"70 options and 7 per-entry keys came back out of each, which is what "
|
||||
"says the strictness above is not just refusing everything",
|
||||
# ⚠️ COUNTED, NOT TYPED. UX-02 corrected this sentence's option count
|
||||
# 70 -> 71 while OPT-09 independently added an eighth per-entry key
|
||||
# (`capacity_kwh`), and the two edits collided here on the rebase - each
|
||||
# restating a number the other had moved. The counts now come from the
|
||||
# same parse the assertion uses, so the sentence cannot drift from it
|
||||
# again. PROC-01.
|
||||
f"{len(ORDER)} options and {sum(len(v) for v in NESTED.values())} "
|
||||
f"per-entry keys came back out of each, which is what says the "
|
||||
f"strictness above is not just refusing everything",
|
||||
all(len(TR[lg]["configuration"]) == len(ORDER)
|
||||
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
|
||||
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 8
|
||||
for lg in LANGS))
|
||||
print(" ⚠️ the licence for this hand parser is NOT this file. "
|
||||
"tools/supervisor_shape_probe.py section F loads the same two files with "
|
||||
@@ -350,7 +362,7 @@ print("\n7. no behaviour and no value moved")
|
||||
# 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",
|
||||
"halves of config.yaml hold the same 71 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 "
|
||||
|
||||
@@ -264,6 +264,53 @@ check("...but the ENTRY is refused all the same, by rule 9 and not by the "
|
||||
"setpoint_entity": " "}]),
|
||||
-5000, 5000), "has no setpoint_entity"))
|
||||
|
||||
# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL ---------------
|
||||
# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE
|
||||
# RUN, which is the bar every clause of this rule is held to. A capacity that is
|
||||
# not a usable number is a pack the split would quietly stop preferring while
|
||||
# the operator can see a value in the box; a capacity on SOME entries is worse,
|
||||
# because `packshare.weights()` answers "no opinion" for the WHOLE roster in
|
||||
# that case - so every key the operator filled in does exactly nothing and
|
||||
# nothing says so.
|
||||
def _with_cap(*caps):
|
||||
o = {k: v for k, v in _PER_INVERTER_OK.items()}
|
||||
o["inverters"] = [dict(e) for e in _PER_INVERTER_OK["inverters"]]
|
||||
for entry, cap in zip(o["inverters"], caps):
|
||||
if cap is not None:
|
||||
entry["capacity_kwh"] = cap
|
||||
return o
|
||||
|
||||
|
||||
check("a roster stating a capacity on EVERY entry is accepted - and the two "
|
||||
"differ (15.36 / 20.48, FROM DATASHEET for B), because a fixture whose "
|
||||
"two per-unit numbers are equal cannot show a check reading the wrong "
|
||||
"unit's",
|
||||
validate_startup_config(_with_cap(15.36, 20.48), -5000, 5000) == [])
|
||||
check("...and a roster stating NONE is accepted too, which is what OPTIONAL "
|
||||
"means: the add-on has no opinion about the split and every unit weighs "
|
||||
"1.0, exactly as it did before this card",
|
||||
validate_startup_config(_with_cap(None, None), -5000, 5000) == [])
|
||||
check("a capacity that is not a usable number of kWh is REFUSED, and the "
|
||||
"refusal names the entry - zero, negative and a word alike",
|
||||
all(why(validate_startup_config(_with_cap(15.36, bad), -5000, 5000),
|
||||
"capacity_kwh") for bad in (0, -3, "twenty", 0.0)))
|
||||
check("⚠️ AND A PARTIAL ROSTER IS REFUSED - stated on one entry and left empty "
|
||||
"on the other. It is the clause that costs an operator something, and it "
|
||||
"is the one worth having: a pack measured in kWh beside a sibling "
|
||||
"weighing a dimensionless 1.0 is not a comparison, so the key would "
|
||||
"silently change nothing at all",
|
||||
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
||||
"not PARTIAL")
|
||||
and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000),
|
||||
"not PARTIAL"))
|
||||
check("...and the refusal names WHICH entry is which, because an operator with "
|
||||
"three inverters told 'one of them is wrong' is sent to the wrong "
|
||||
"cupboard",
|
||||
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
||||
"inverter-a")
|
||||
and why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
||||
"inverter-b"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("3. a meter that can never read")
|
||||
# ⚠️ These predicates belong to p1.validate_meter_options(), which build_source()
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""PKG-09 - grade the check that refuses a no-op deploy. `python3 test_version_gate.py`
|
||||
|
||||
⚠️ THE POINT OF THIS FILE IS THE REFUSALS, NOT THE CLEARANCES. A pre-deploy
|
||||
check that only ever says "CLEARED" is the defect `MUTATION.md` habit 4 names -
|
||||
"a check whose DATA cannot reach the failure it names" - which this repository
|
||||
has recorded twelve times. So every refusal below is DRIVEN: a repository is
|
||||
built in which the failure is really present, and the check is required to say
|
||||
so. A clearance is only meaningful next to a refusal from the same harness.
|
||||
|
||||
The third property is the one that is easy to lose: a SKIPPED check must not
|
||||
look like a PASSED one. Omitting `--options` cannot exit 0.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
GATE = os.path.join(HERE, "tools", "predeploy_check.py")
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
fails = []
|
||||
tmpdirs = []
|
||||
|
||||
|
||||
def check(name, ok):
|
||||
print(f" {'ok ' if ok else 'FAIL'} {name}")
|
||||
if not ok:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def git(repo, *args):
|
||||
return subprocess.run(
|
||||
["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t", *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def make_repo(version="0.7.0"):
|
||||
"""A repo shaped like this one: <root>/goodwe_controller/{config.yaml,app,tools}."""
|
||||
root = tempfile.mkdtemp(prefix="test_version_gate.")
|
||||
tmpdirs.append(root)
|
||||
addon = os.path.join(root, "goodwe_controller")
|
||||
os.makedirs(os.path.join(addon, "app"))
|
||||
write(addon, "config.yaml", f'name: t\nversion: "{version}"\n')
|
||||
write(addon, "app/control.py", "X = 1\n")
|
||||
write(addon, "CHANGELOG.md", "# Changelog\n")
|
||||
git(root, "init", "-q", ".")
|
||||
git(root, "add", "-A")
|
||||
git(root, "commit", "-q", "-m", f"initial at {version}")
|
||||
return root, addon
|
||||
|
||||
|
||||
def write(addon, rel, text):
|
||||
p = os.path.join(addon, rel)
|
||||
with open(p, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
|
||||
|
||||
def run_gate(addon, root, deployed, options=None):
|
||||
"""Run the REAL gate against a throwaway tree.
|
||||
|
||||
⚠️ The script is invoked where it SHIPS, not copied into the fixture. It
|
||||
imports the validator from its own sibling `app/`, which is the behaviour
|
||||
that matters: the gate must check a config against the validator it ships
|
||||
beside, not against whatever happens to sit next to a copy. Copying it into
|
||||
the fixture made every validator assertion silently SKIP - the first draft
|
||||
of this file did exactly that and reported four green skips as passes.
|
||||
"""
|
||||
cmd = [sys.executable, GATE,
|
||||
"--deployed-version", deployed, "--tree", addon, "--repo", root]
|
||||
if options:
|
||||
cmd += ["--options", options]
|
||||
return subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
|
||||
VALID = {"max_w": 2000, "slew_w": 0, "target_grid_w": -10, "gain": 0.6,
|
||||
"inverters": [], "setpoint_entity": "number.goodwe_setpoint_w"}
|
||||
|
||||
|
||||
def opts_file(d):
|
||||
fd, p = tempfile.mkstemp(suffix=".json")
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
json.dump(d, fh)
|
||||
return p
|
||||
|
||||
|
||||
print("classification - what forces a rebuild and what does not")
|
||||
from predeploy_check import is_runtime, shipped_version # noqa: E402
|
||||
|
||||
for p in ("goodwe_controller/app/control.py", "goodwe_controller/config.yaml",
|
||||
"goodwe_controller/translations/en.yaml", "goodwe_controller/run.sh"):
|
||||
check(f"runtime: {p}", is_runtime(p))
|
||||
for p in ("goodwe_controller/CHANGELOG.md", "goodwe_controller/DOCS.md",
|
||||
"goodwe_controller/test_control.py", "goodwe_controller/tools/x.py",
|
||||
"goodwe_controller/DECISIONS/a.md", "README.md"):
|
||||
check(f"NOT runtime: {p}", not is_runtime(p))
|
||||
|
||||
print("\nTHE REFUSAL - a runtime change behind an unchanged version")
|
||||
root, addon = make_repo("0.7.0")
|
||||
write(addon, "app/control.py", "X = 2\n")
|
||||
git(root, "commit", "-qam", "SAFETY-99: change behaviour, bump nothing")
|
||||
r = run_gate(addon, root, "0.7.0", opts_file(VALID))
|
||||
check("refuses (exit 1)", r.returncode == 1)
|
||||
check("...and says the deploy would be a NO-OP", "NO-OP" in r.stdout)
|
||||
check("...and names the offending commit", "SAFETY-99" in r.stdout)
|
||||
check("...and does NOT say cleared", "CLEARED for deploy" not in r.stdout)
|
||||
|
||||
print("\nPOSITIVE CONTROL - the same check must NOT fire on a docs-only change")
|
||||
root2, addon2 = make_repo("0.7.0")
|
||||
write(addon2, "CHANGELOG.md", "# Changelog\n\n## notes\n")
|
||||
git(root2, "commit", "-qam", "PKG-99: documentation only")
|
||||
r2 = run_gate(addon2, root2, "0.7.0", opts_file(VALID))
|
||||
check("does not refuse a docs-only tree", r2.returncode == 0)
|
||||
check("...and says there is nothing to deploy",
|
||||
"nothing to deploy" in r2.stdout)
|
||||
|
||||
print("\nTHE CLEARANCE - a bumped version with a valid config")
|
||||
root3, addon3 = make_repo("0.7.0")
|
||||
write(addon3, "app/control.py", "X = 3\n")
|
||||
write(addon3, "config.yaml", 'name: t\nversion: "0.8.0"\n')
|
||||
git(root3, "commit", "-qam", "PKG-99: change and bump together")
|
||||
r3 = run_gate(addon3, root3, "0.7.0", opts_file(VALID))
|
||||
check("clears (exit 0)", r3.returncode == 0)
|
||||
check("...and says so", "CLEARED for deploy" in r3.stdout)
|
||||
|
||||
print("\n⭐ A SKIPPED CHECK MUST NOT LOOK LIKE A PASSED ONE")
|
||||
r4 = run_gate(addon3, root3, "0.7.0") # no --options
|
||||
check("omitting --options cannot exit 0", r4.returncode != 0)
|
||||
check("...says SKIPPED", "SKIPPED" in r4.stdout)
|
||||
check("...says the config was NEVER CHECKED", "NEVER CHECKED" in r4.stdout)
|
||||
check("...and does NOT claim clearance", "CLEARED for deploy" not in r4.stdout)
|
||||
|
||||
print("\nTHE OTHER REFUSAL - a config this version's validator rejects")
|
||||
bad = dict(VALID, slew_w=1000) # rule 10: > 2/18 x 2000 = 222 W
|
||||
r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad))
|
||||
check("refuses a config the validator rejects", r5.returncode == 1)
|
||||
check("...and warns it is exit 78", "78" in r5.stdout)
|
||||
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
|
||||
check("...and prints the validator's own words", "slew_w is 1000 W" in r5.stdout)
|
||||
|
||||
print()
|
||||
print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED")
|
||||
# ⚠️ EVERY FIXTURE ABOVE BUILDS A FRESH REPOSITORY WHOSE `version:` LINE IS
|
||||
# INTRODUCED ONCE AND NEVER EDITED. That is precisely the shape in which the
|
||||
# original `git log -S 'version: "'` looked correct - the pickaxe finds commits
|
||||
# that change a string's COUNT, and introducing the line changes it from 0 to 1.
|
||||
# A real repository edits the line IN PLACE, the count never moves again, and
|
||||
# the lookup silently returns the commit that first added it.
|
||||
#
|
||||
# MEASURED on admin/goodwe-addon before the fix: `-S` named `0a1e61d`
|
||||
# (2026-08-23, the original packaging commit) as "the last bump", so a correctly
|
||||
# bumped tree was reported as 87 commits behind an unbumped version - with
|
||||
# PKG-06's and PKG-07's own bump commits listed among the offenders. The tool
|
||||
# cried wolf on every run, and none of the fixtures above could see it, because
|
||||
# a test that builds its own one-bump history is a test asserting against a
|
||||
# structure it built itself. MUTATION.md habit 4, in the file written to
|
||||
# prevent a recurrence.
|
||||
from predeploy_check import last_version_change # noqa: E402
|
||||
|
||||
root4 = tempfile.mkdtemp(prefix="test_version_gate.hist.")
|
||||
tmpdirs.append(root4)
|
||||
addon4 = os.path.join(root4, "goodwe_controller")
|
||||
os.makedirs(os.path.join(addon4, "app"))
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.5.0"\n')
|
||||
write(addon4, "app/control.py", "X = 1\n")
|
||||
git(root4, "init", "-q", ".")
|
||||
git(root4, "add", "-A")
|
||||
git(root4, "commit", "-q", "-m", "initial at 0.5.0")
|
||||
_first = git(root4, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
write(addon4, "app/control.py", "X = 2\n")
|
||||
git(root4, "commit", "-qam", "runtime change between bumps")
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.6.0"\n')
|
||||
git(root4, "commit", "-qam", "bump to 0.6.0")
|
||||
write(addon4, "app/control.py", "X = 3\n")
|
||||
git(root4, "commit", "-qam", "another runtime change")
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0"\n')
|
||||
git(root4, "commit", "-qam", "bump to 0.7.0")
|
||||
_last_bump = git(root4, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
check("⭐ THE LAST BUMP IS FOUND, NOT THE FIRST. An in-place edit of the "
|
||||
"version line does not change how many times the string occurs, so a "
|
||||
"pickaxe search returns the commit that introduced it and every bump "
|
||||
"after that is invisible",
|
||||
last_version_change(root4) == _last_bump)
|
||||
check("...and it is emphatically not the commit that introduced the line",
|
||||
last_version_change(root4) != _first)
|
||||
|
||||
r6 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
|
||||
check("a tree bumped with nothing after it clears, even with bumps in history",
|
||||
r6.returncode == 0 and "CLEARED for deploy" in r6.stdout)
|
||||
|
||||
write(addon4, "app/control.py", "X = 4\n")
|
||||
git(root4, "commit", "-qam", "SAFETY-98: runtime change after the last bump")
|
||||
r7 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
|
||||
check("and one runtime commit after that last bump is refused", r7.returncode == 1)
|
||||
check("...naming only the commit since the LAST bump, not since the first",
|
||||
"SAFETY-98" in r7.stdout and "runtime change between bumps" not in r7.stdout)
|
||||
|
||||
|
||||
# ⚠️ AND THE FIXTURE ABOVE STILL COULD NOT TELL TWO IMPLEMENTATIONS APART.
|
||||
# Review swapped `last_version_change` for "-G's newest raw hit, skip the value
|
||||
# comparison entirely" and THE WHOLE SUITE STAYED GREEN, including the section
|
||||
# above - because a linear history of real bumps never produces a commit that
|
||||
# touches the version LINE without changing the version VALUE. So the docstring's
|
||||
# central claim ("the VALUE is compared, and that is the only thing that answers
|
||||
# it") was not pinned by anything, and `-G` alone would have shipped as if it
|
||||
# were the fix. That is the third distinct shape of habit 4 in this one file.
|
||||
#
|
||||
# A cosmetic touch of the version line is the discriminator: `-G '^version: "'`
|
||||
# matches it, and the value comparison does not.
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0" # pinned deliberately\n')
|
||||
git(root4, "commit", "-qam", "COSMETIC: annotate the version line, same value")
|
||||
_cosmetic = git(root4, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
check("⭐ A COMMIT THAT TOUCHES THE VERSION LINE WITHOUT CHANGING THE VERSION "
|
||||
"IS NOT A BUMP. `-G` matches it and a filter-only lookup would name it; "
|
||||
"only comparing the VALUE against the parent's answers the question the "
|
||||
"tool is actually asking",
|
||||
last_version_change(root4) == _last_bump)
|
||||
check("...and it is emphatically not the cosmetic commit",
|
||||
last_version_change(root4) != _cosmetic)
|
||||
|
||||
r8 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
|
||||
check("...and because config.yaml IS a runtime path, that cosmetic commit is "
|
||||
"still counted as something unshipped - conservative, and correct: the "
|
||||
"Supervisor rebuilds on the version string alone, so a changed file "
|
||||
"behind an unchanged version is a no-op whatever changed in it",
|
||||
r8.returncode == 1 and "COSMETIC" in r8.stdout)
|
||||
|
||||
|
||||
print()
|
||||
print("⭐ A GIT LOOKUP THAT GENUINELY FAILS - THE SHAPE NO FIXTURE COULD REACH")
|
||||
# ⚠️ EVERY FIXTURE ABOVE CREATES ITS OWN REPOSITORY AS ITS OWN UID, so none of
|
||||
# them could ever meet the failure that actually happened: the documented
|
||||
# invocation runs the container as uid 0 against a checkout owned by the
|
||||
# operator, git refuses with "detected dubious ownership", `_git` returns
|
||||
# non-zero, and the no-op half reports SKIPPED. It degraded honestly - exit 2,
|
||||
# never a false clearance - but the check this file exists for had never once
|
||||
# run in its documented form, and nothing here could see that.
|
||||
#
|
||||
# `_git` now sets `safe.directory`, so ownership no longer breaks it. This
|
||||
# section grades the REMAINING failure mode: git answering not at all.
|
||||
root5 = tempfile.mkdtemp(prefix="test_version_gate.nogit.")
|
||||
tmpdirs.append(root5)
|
||||
addon5 = os.path.join(root5, "goodwe_controller")
|
||||
os.makedirs(addon5)
|
||||
write(addon5, "config.yaml", 'name: t\nversion: "0.7.0"\n') # NOT a git repo
|
||||
|
||||
check("a repository git cannot answer for reports no last-bump commit",
|
||||
last_version_change(root5) is None)
|
||||
|
||||
r9 = run_gate(addon5, root5, "0.7.0", opts_file(VALID))
|
||||
check("⭐ AND THE GATE DOES NOT CLEAR ON IT. An unanswerable git lookup is "
|
||||
"INCONCLUSIVE, not clean - the one thing it must never do is call a "
|
||||
"tree it could not inspect deployable",
|
||||
r9.returncode != 0 and "CLEARED for deploy" not in r9.stdout)
|
||||
check("...it says INCONCLUSIVE in those words", "INCONCLUSIVE" in r9.stdout)
|
||||
|
||||
# ⚠️ AND THE EXIT CODES ARE PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0`
|
||||
# is satisfied by collapsing NOT-CLEARED into REFUSED, which review found the
|
||||
# earlier draft could not tell apart. 1 and 2 mean different things to an
|
||||
# operator: 1 says "this deploy is wrong", 2 says "nobody checked".
|
||||
check("NOT CLEARED is exactly 2, not merely non-zero", r9.returncode == 2)
|
||||
r10 = run_gate(addon3, root3, "0.7.0", opts_file(dict(VALID, slew_w=1000)))
|
||||
check("REFUSED is exactly 1, not merely non-zero",
|
||||
r10.returncode == 1 and "REFUSED" in r10.stdout)
|
||||
|
||||
|
||||
print("\nthe shipped tree's own version is readable")
|
||||
check("shipped_version() finds a version", bool(shipped_version(HERE)))
|
||||
|
||||
for d in tmpdirs:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -722,18 +722,41 @@ print(f" e.g. {_sustained.reason!r}")
|
||||
|
||||
print()
|
||||
print(" ⚠️ AND NOT ON THE TRANSIENT, WHICH IS THE FALSE-ALARM CASE")
|
||||
# ⚠️ THE SHAPES BELOW ARE THE RIG'S, NOT INVENTED. Measured on TEST-19 over 924
|
||||
# closed-loop runs in which every unit executed the share it was given: the
|
||||
# worst continuous opposition above 700 W is 5.0 s - ONE control cycle - at a
|
||||
# peak of 1700 W. That is the serialised write path: on a reversing cycle B's
|
||||
# new number lands up to a whole cycle after A's, both slew through zero at
|
||||
# 800 W/s behind a 10 s lag, and between the two crossings they genuinely
|
||||
# oppose. A guard that trips on that is a false-alarm generator, and this
|
||||
# project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19).
|
||||
# THE SHAPE BELOW IS THE RIG'S: the serialised write path means that on a
|
||||
# reversing cycle B's new number lands up to a whole control cycle after A's,
|
||||
# both slew through zero behind a real ramp and a real lag, and between the two
|
||||
# crossings they genuinely oppose. A guard that trips on that is a false-alarm
|
||||
# generator, and this project's doctrine on crying wolf is explicit (SAFETY-15,
|
||||
# SAFETY-19).
|
||||
#
|
||||
# ⚠️ THE MAGNITUDE IS A FIXTURE INPUT, NOT A MEASUREMENT, AND THIS COMMENT USED
|
||||
# TO SAY OTHERWISE. It read "the worst single transient the rig can produce -
|
||||
# 1700 W", sourced to a 924-run campaign whose harness IS NOT COMMITTED IN
|
||||
# EITHER REPOSITORY (DECISIONS/SAFETY-31-circulating-power.md §3 says so in its
|
||||
# own words). Three sweeps have now been run against that claim and no two agree:
|
||||
#
|
||||
# 924-run campaign, harness uncommitted peak 1700 W, 505.0 s at 100 W
|
||||
# independent reviewer sweep, 2026-09-07 peak 2750 W, 15.0 s above 700 W
|
||||
# goodwe `two_master_circulation`, 840 runs peak 1048 W, 5.0 s above 700 W
|
||||
#
|
||||
# ⚠️ THAT IS NOT EVIDENCE THAT ANY OF THEM IS WRONG. The three swept different
|
||||
# grids, and the last one measured WHY the spread is so wide: on its own grid the
|
||||
# worst legitimate peak is 1048 W on the modelled plant, 2250 W on `INSTANT`, and
|
||||
# 0 W on `INSTANT` with no write stagger reaching a whole cycle. The plant model
|
||||
# and the stagger set are TERMS of the number, and a worst case quoted without
|
||||
# them is not a measurement. It is evidence that the claim cannot be adjudicated
|
||||
# while the instrument is missing.
|
||||
#
|
||||
# So what this check now says is what it can support: 1700 W is LARGER than
|
||||
# anything any of the three sweeps produced legitimately, and one control cycle
|
||||
# of it does not trip. The committed, re-runnable version of the claim is
|
||||
# `admin/goodwe`'s `sim/inject.py two_master_circulation`, which runs on every
|
||||
# pull request there and requires ZERO of ~840 non-faulty runs to trip.
|
||||
_one_cycle = [0.0] * 5 + [1700.0] + [0.0] * 30
|
||||
_t1, _ = circ_drive(_one_cycle)
|
||||
check("the worst single transient the rig can produce - 1700 W for ONE control "
|
||||
"cycle - does not trip", not _t1.tripped)
|
||||
check("a single transient of 1700 W - larger than the worst peak any committed "
|
||||
"sweep of the legitimate family has produced - lasting ONE control cycle "
|
||||
"does not trip", not _t1.tripped)
|
||||
_repeated = ([0.0] * 3 + [1700.0]) * 20
|
||||
_t2, _ = circ_drive(_repeated)
|
||||
check("...and neither does it REPEATED every fourth cycle for 400 s, which is "
|
||||
@@ -1091,6 +1114,158 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
|
||||
"this' a different question from 'is this site tripped'",
|
||||
len(_notes2) == 1)
|
||||
|
||||
|
||||
print()
|
||||
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
|
||||
# ⚠️ THIS BLOCK EXISTS BECAUSE A MUTATION SURVIVED ALL 26 FILES. Replacing
|
||||
#
|
||||
# pos, neg = opposing([inv.batt for inv in self.inverters],
|
||||
# min_w=self.circ_wd.cfg.min_w / 2.0)
|
||||
#
|
||||
# in `check_circulating()` with `pos, neg = ([], [])` (-102 B) silently deletes
|
||||
# the "Right now X is discharging while Y charges" clause from the notification,
|
||||
# and the whole suite stayed GREEN. The block above asserts three tokens -
|
||||
# METER CANNOT SEE, CONTROL HAS BEEN STOPPED, isolator - and NEVER an inverter
|
||||
# name, so the one sentence `circulation.py` calls "the first thing somebody
|
||||
# woken by this alarm needs" and "impossible to reconstruct from the number
|
||||
# alone" was graded by nothing at all.
|
||||
#
|
||||
# ⚠️ IT IS NOT DEAD CODE, WHICH IS THE PART THAT MAKES IT A CHECK GAP RATHER
|
||||
# THAN A REACHABILITY ONE. Ledger M18 - the `await self.check_circulating()`
|
||||
# call removed - goes red on four checks above, so this function runs. What
|
||||
# nothing asserted was the ATTRIBUTION.
|
||||
#
|
||||
# ⚠️ AND THE ROSTER IS ["garage", "shed", "cellar"], WHICH IS THE OTHER HALF OF
|
||||
# THE FIX. Every fixture above runs on `inverter-a`/`inverter-b`;
|
||||
# test_maintenance.py section 11 records what that costs - on a fixture whose
|
||||
# names are the module's own constants, a check cannot tell the roster from the
|
||||
# constant, and `UNIT_A` is not even what a nameless entry falls back to. Here
|
||||
# the names are an operator's, the two participants pull OPPOSITE ways so the
|
||||
# sentence is not symmetric, and a third pack rests at 5 W. That makes three
|
||||
# DIFFERENT mutations - the clause dropped, the two directions swapped, and the
|
||||
# `min_w` floor dropped - into three DIFFERENT red checks. A fixture naming both
|
||||
# units the same, or one in which the direction did not matter, would see the
|
||||
# first and neither of the others.
|
||||
|
||||
_CIRC_ROSTER = [
|
||||
dict(name="garage", setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||
batt_entity="sensor.batt_a", max_w=5000),
|
||||
# ⚠️ B_RATING_HYPOTHESIS, and part 4's note above says why it is not 4600:
|
||||
# inverter B does not physically exist and a datasheet is not a measurement.
|
||||
dict(name="shed", setpoint_entity="number.sp_b", soc_entity="sensor.soc_b",
|
||||
batt_entity="sensor.batt_b", max_w=B_RATING_HYPOTHESIS),
|
||||
# THE BYSTANDER. A third pack resting at standby draw, on the roster, read
|
||||
# every cycle, and NOT a participant in anybody's loop.
|
||||
dict(name="cellar", setpoint_entity="number.sp_c", soc_entity="sensor.soc_c",
|
||||
batt_entity="sensor.batt_c", max_w=B_RATING_HYPOTHESIS),
|
||||
]
|
||||
_CIRC_NAMES = [e["name"] for e in _CIRC_ROSTER]
|
||||
_REST_W = 5.0
|
||||
|
||||
|
||||
class _NamedCircHass(_Hass):
|
||||
"""Three named packs; the meter still reads exactly zero.
|
||||
|
||||
Same house as `_CircHass` - grid = load - sum(B) with two units cancelling -
|
||||
with one extra pack idling at standby draw so that "who is named" and "who
|
||||
is merely on the roster" are different questions.
|
||||
"""
|
||||
|
||||
def __init__(self, a_w, b_w, c_w=_REST_W):
|
||||
super().__init__()
|
||||
self.vals = {"sensor.batt_a": a_w, "sensor.batt_b": b_w,
|
||||
"sensor.batt_c": c_w}
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
return {"sensor.grid": 0.0, "sensor.soc_a": 50.0,
|
||||
"sensor.soc_b": 50.0, "sensor.soc_c": 50.0,
|
||||
**self.vals}.get(entity)
|
||||
|
||||
|
||||
def _named_trio(hass, store, *, hold_s=1, min_w=700):
|
||||
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
|
||||
"max_w": 5000, "gain": 0.6, "auto_start": True,
|
||||
"circulation_w": min_w, "circulation_hold_s": hold_s,
|
||||
"inverters": _CIRC_ROSTER}
|
||||
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
return c
|
||||
|
||||
|
||||
async def _circ_named(a_w, b_w):
|
||||
hass = _NamedCircHass(a_w, b_w)
|
||||
c = _named_trio(hass, _MemStore())
|
||||
for _ in range(14):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await asyncio.sleep(0.25)
|
||||
notes = [x for x in hass.calls
|
||||
if x[0] == "persistent_notification"
|
||||
and x[2].get("notification_id") == "goodwe_circulating_power"]
|
||||
return c, notes
|
||||
|
||||
|
||||
_cg, _notes_g = asyncio.run(_circ_named(+3000.0, -3000.0))
|
||||
_cs, _notes_s = asyncio.run(_circ_named(-3000.0, +3000.0))
|
||||
_msg_g = _notes_g[0][2]["message"] if _notes_g else ""
|
||||
_msg_s = _notes_s[0][2]["message"] if _notes_s else ""
|
||||
print(f" roster : {_cg.units}")
|
||||
print(f" garage discharging, measured: "
|
||||
f"{dict(zip(_cg.units, [i.batt for i in _cg.inverters]))}")
|
||||
print(f" -> {_msg_g.split(chr(10))[2] if _msg_g.count(chr(10)) > 2 else _msg_g!r}")
|
||||
print(f" shed discharging, measured: "
|
||||
f"{dict(zip(_cs.units, [i.batt for i in _cs.inverters]))}")
|
||||
print(f" -> {_msg_s.split(chr(10))[2] if _msg_s.count(chr(10)) > 2 else _msg_s!r}")
|
||||
|
||||
# --- THE PRECONDITIONS, AS THEIR OWN CHECKS (MUTATION.md rule 1) ------------
|
||||
check("PRECONDITION: the roster carries three DISTINCT operator-chosen names, "
|
||||
f"none of them `UNIT_A` ({UNIT_A!r}) - so every claim below is a claim "
|
||||
"about the roster and not about a constant that happens to equal it",
|
||||
_cg.units == tuple(_CIRC_NAMES) and len(set(_CIRC_NAMES)) == 3
|
||||
and UNIT_A not in _CIRC_NAMES)
|
||||
check("PRECONDITION: the two runs are MIRROR IMAGES - same house, same meter at "
|
||||
"0 W, same magnitudes, and the two participants swapped. Without this the "
|
||||
"direction claim below could be satisfied by a sentence that ignores the "
|
||||
"measurement entirely",
|
||||
[i.batt for i in _cg.inverters] == [+3000.0, -3000.0, _REST_W]
|
||||
and [i.batt for i in _cs.inverters] == [-3000.0, +3000.0, _REST_W]
|
||||
and _cg.grid == 0.0 and _cs.grid == 0.0)
|
||||
check("PRECONDITION: both runs actually tripped and actually raised the card, "
|
||||
"so an absent sentence below is an absent sentence and not an absent alarm",
|
||||
_cg.circ_wd.tripped and _cs.circ_wd.tripped
|
||||
and len(_notes_g) == 1 and len(_notes_s) == 1)
|
||||
|
||||
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
|
||||
_WHO_G = "Right now garage is discharging while shed charges."
|
||||
_WHO_S = "Right now shed is discharging while garage charges."
|
||||
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
|
||||
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
|
||||
"needs and impossible to reconstruct from the watts alone, and until this "
|
||||
"line the sentence could be deleted with all 26 files green",
|
||||
_WHO_G in _msg_g)
|
||||
check("⚠️ AND WITH THE TWO SWAPPED, THE SENTENCE SWAPS. This is the half a "
|
||||
"single fixture cannot have: `pos` and `neg` exchanged, or a hard-coded "
|
||||
"sentence, satisfies the check above and fails this one. The operator is "
|
||||
"being told which pack to walk to",
|
||||
_WHO_S in _msg_s)
|
||||
check("...and each run carries ONLY its own sentence - the mirrored alarm does "
|
||||
"not also contain the other direction, so the clause is derived from this "
|
||||
"cycle's measurement rather than assembled from the roster",
|
||||
_WHO_S not in _msg_g and _WHO_G not in _msg_s)
|
||||
check("...and the RESTING pack is not named. `cellar` is on the roster, is read "
|
||||
"every cycle and is measured at 5 W of standby draw - `opposing()` is "
|
||||
"called with a floor of half the trip threshold precisely so a pack idling "
|
||||
"beside a 6 kW loop is not reported as one of its two participants",
|
||||
"cellar" not in _msg_g and "cellar" not in _msg_s
|
||||
and _cg.inverters[2].batt == _REST_W)
|
||||
check("...and the sentence sits with the figure, not instead of it: the same "
|
||||
"card still carries the watts, the window and the three things only this "
|
||||
"alarm can say",
|
||||
all("CIRCULATING_POWER" in m and "METER CANNOT SEE" in m
|
||||
and "CONTROL HAS BEEN STOPPED" in m and "isolator" in m
|
||||
for m in (_msg_g, _msg_s)))
|
||||
print()
|
||||
print(" the operator's press is the only way out, and it clears BOTH halves")
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject.
|
||||
|
||||
python3 tools/predeploy_check.py --deployed-version 0.7.0 --options live-options.json
|
||||
|
||||
⚠️ RUN IT WHERE THE ADD-ON'S DEPENDENCIES EXIST. `app/validate.py` imports its
|
||||
siblings and those need aiohttp, so on a bare host the validator half SKIPS -
|
||||
loudly, and exit 2, never a silent pass. To get both halves:
|
||||
|
||||
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
|
||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||
--deployed-version 0.7.0 --options /w/live-options.json
|
||||
|
||||
EXIT CODES. 0 cleared; 1 REFUSED, do not deploy; 2 NOT CLEARED - a check did not
|
||||
run, and that is not the same as passing.
|
||||
|
||||
TWO FAILURES THIS ANSWERS, BOTH OF WHICH HAVE HAPPENED.
|
||||
|
||||
1. THE SILENT NO-OP. The Supervisor keys the built image by `version:` in
|
||||
config.yaml. `ha apps update` against an unchanged version does nothing and
|
||||
REPORTS SUCCESS. PKG-06 recorded this on 2026-09-05 with SIX merges behind
|
||||
an unbumped version - its own commit message says so, and this line said
|
||||
"four" through two review rounds; PKG-07 recorded it again on 2026-09-06 with six. Both
|
||||
times a human noticed. Nothing else was ever going to.
|
||||
|
||||
2. THE REFUSED CONFIG. A config the shipped validator rejects is
|
||||
`SystemExit(78)` - the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES
|
||||
WITH IT, because the add-on serving it is not running, so recovery means the
|
||||
Supervisor UI. SAFETY-39's rule 10 made this reachable by a config that
|
||||
started fine on the previous version: it refuses an explicit `slew_w` above
|
||||
SLEW_RAIL_FRAC x the site rail, and 0.7.0's own shipped default of 1000
|
||||
exceeds the 555.6 W ceiling on a 5000 W rail.
|
||||
|
||||
⚠️ WHY THIS IS NOT A CI CHECK. The obvious version gate - "a pull request that
|
||||
touches runtime code must bump the version" - is the WRONG shape for this
|
||||
project, which deliberately batches many merges into one deploy. It would demand
|
||||
a bump per pull request, put every branch in conflict over one line of
|
||||
config.yaml, and still not prevent the actual failure, which happens at DEPLOY
|
||||
and not at merge. The check belongs where the mistake is made.
|
||||
|
||||
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
|
||||
reachable only through the Supervisor API, which needs a supervisor token, so
|
||||
the operator exports it and passes `--options`. Without that flag the validator
|
||||
half is SKIPPED and says so - a skipped check must never look like a passed one.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ADDON = os.path.dirname(HERE)
|
||||
sys.path.insert(0, ADDON)
|
||||
|
||||
# Files that end up in the built image and can change what it DOES. A change to
|
||||
# any of these needs a new `version:` or the Supervisor will not rebuild.
|
||||
# CHANGELOG.md, DOCS.md, MUTATION.md, DECISIONS/, test_*.py, tools/ and
|
||||
# scenarios/ are deliberately absent: they ship in the repo but cannot change
|
||||
# runtime behaviour, and demanding a bump for a typo fix trains people to bump
|
||||
# without reading, which is the habit this file exists to prevent.
|
||||
RUNTIME = (
|
||||
"goodwe_controller/app/",
|
||||
"goodwe_controller/translations/",
|
||||
"goodwe_controller/config.yaml",
|
||||
"goodwe_controller/Dockerfile",
|
||||
"goodwe_controller/run.sh",
|
||||
"goodwe_controller/requirements.txt",
|
||||
"goodwe_controller/build.yaml",
|
||||
)
|
||||
|
||||
CONFIG_PATH = "goodwe_controller/config.yaml"
|
||||
_VER = re.compile(r'^version:\s*"([^"]+)"', re.M)
|
||||
|
||||
|
||||
def shipped_version(tree=ADDON):
|
||||
with open(os.path.join(tree, "config.yaml"), encoding="utf-8") as fh:
|
||||
m = _VER.search(fh.read())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def is_runtime(path):
|
||||
return any(path == p or path.startswith(p) for p in RUNTIME)
|
||||
|
||||
|
||||
def _git(*args, repo=None):
|
||||
"""git, run so it answers regardless of who owns the checkout.
|
||||
|
||||
⚠️ WITHOUT `safe.directory` THIS TOOL SILENTLY DID HALF ITS JOB. The
|
||||
invocation this file's own docstring documents runs the container as uid 0
|
||||
against a checkout owned by the operator, and git refuses that:
|
||||
|
||||
fatal: detected dubious ownership in repository at '/w/goodwe-addon'
|
||||
|
||||
`_git` then returned non-zero, `last_version_change()` returned None, and
|
||||
the no-op check reported SKIPPED - so **the silent-no-op half, which is the
|
||||
reason this file exists, had never once run in its documented form.**
|
||||
|
||||
It degraded honestly: exit 2, INCONCLUSIVE, never a false clearance. But a
|
||||
check that cannot fire is not a check, and the two documented environments
|
||||
were mutually exclusive - in the container the validator ran and git
|
||||
skipped; on a bare host git ran and the validator skipped on aiohttp.
|
||||
Neither documented command ran both halves.
|
||||
|
||||
⚠️ AND IT WAS INVISIBLE IN THE ONLY CASE ANYONE HAD RUN. `main()` consults
|
||||
the git result only when `shipped == deployed`; every real run so far
|
||||
compared 0.7.0 against 0.9.0 and printed its verdict without needing git.
|
||||
|
||||
`test_version_gate.py` could not see it either: it invokes the gate with
|
||||
`sys.executable` against fixtures it creates as its own uid, so no fixture
|
||||
ever met the ownership mismatch. MUTATION.md habit 4, in a fourth shape,
|
||||
inside the file written to prevent it.
|
||||
|
||||
`safe.directory` is the right fix rather than `--user`: it makes the tool
|
||||
correct however it is invoked, instead of making every future caller
|
||||
remember a flag. The read is read-only, on a repository the operator is
|
||||
about to deploy from.
|
||||
"""
|
||||
where = repo or os.path.dirname(ADDON)
|
||||
return subprocess.run(
|
||||
["git", "-c", f"safe.directory={where}", "-C", where, *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def _version_at(sha, repo=None, path=CONFIG_PATH):
|
||||
"""`version:` as of one commit, or None if the file or the key is absent."""
|
||||
r = _git("show", f"{sha}:{path}", repo=repo)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
m = _VER.search(r.stdout)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def last_version_change(repo=None):
|
||||
"""The newest commit whose `version:` VALUE differs from its parent's.
|
||||
|
||||
⚠️ THIS USED TO BE `git log -S 'version: "'` AND IT WAS WRONG ON EVERY REAL
|
||||
REPOSITORY. `-S` is the pickaxe: it finds commits that change the COUNT of
|
||||
a literal string. A bump edits the line in place - `version: "0.7.0"` ->
|
||||
`version: "0.8.0"` - so the substring still occurs exactly once before and
|
||||
after, and `-S` never sees it. It found only the commit that first
|
||||
INTRODUCED the line.
|
||||
|
||||
Measured on this repository: `-S` returned `0a1e61d` (2026-08-23, the
|
||||
original packaging commit) as "the last bump", so the script reported the
|
||||
correctly-bumped tree as 87 commits behind an unbumped version - listing
|
||||
PKG-06's and PKG-07's own bump commits among the offenders. A tool whose
|
||||
entire purpose is to be trusted before a deploy cried wolf on every run.
|
||||
|
||||
⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER. `-G'^version: "'`
|
||||
matches a commit whose diff touches a line with that shape - which includes
|
||||
a reformat, a quote change, or a comment moved onto the line, none of which
|
||||
are bumps. So `-G` narrows the candidates and then the VALUE is compared
|
||||
against the parent's. The question is "did the version change", and that is
|
||||
the only thing that answers it.
|
||||
|
||||
⚠️ TWO GIT SHAPES THIS STILL GETS WRONG, NEITHER OF WHICH HAS EVER OCCURRED
|
||||
IN THIS REPOSITORY - every bump here has been a clean, linear, forward-only
|
||||
edit. Both were constructed in review rather than found:
|
||||
|
||||
1. A BUMP RESOLVED INSIDE A MERGE COMMIT IS INVISIBLE. `git log -G -- path`
|
||||
does not diff merge commits at all without `-m`, so a version line whose
|
||||
final value is decided by a conflict resolution is never a candidate.
|
||||
The lookup then names the last ordinary bump, and the merge itself counts
|
||||
as a runtime change since it - a FALSE REFUSAL of a tree that is in fact
|
||||
correctly bumped.
|
||||
2. REVERTING A BUMP WHILE KEEPING THE WORK MASKS THE WORK. Bump, change
|
||||
`app/`, then revert only `config.yaml`: the version is back where it
|
||||
started, the runtime change is still in the tree, and this reports
|
||||
"nothing to deploy" - a FALSE CLEARANCE, which is the dangerous
|
||||
direction.
|
||||
|
||||
Card PKG-11 carries both. They are recorded here rather than fixed because
|
||||
a lookup that handles them costs more than the failure they represent, and
|
||||
a caveat a reader can check beats a guarantee that is not true.
|
||||
"""
|
||||
r = _git("log", "--format=%H", "-G", r'^version: "', "--", CONFIG_PATH,
|
||||
repo=repo)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
shas = r.stdout.split()
|
||||
for sha in shas: # newest first
|
||||
here = _version_at(sha, repo)
|
||||
there = _version_at(sha + "^", repo)
|
||||
if here is not None and here != there:
|
||||
return sha
|
||||
return shas[-1] if shas else None
|
||||
|
||||
|
||||
def runtime_commits_since_version_change(repo=None):
|
||||
"""Commits touching RUNTIME since `version:` last changed. ([], None) if unknown.
|
||||
|
||||
Returns (commits, last_bump_sha). An empty list with a sha means the tree is
|
||||
clean; an empty list with None means git could not answer and the caller
|
||||
must NOT read that as a pass.
|
||||
"""
|
||||
last = last_version_change(repo)
|
||||
if not last:
|
||||
return [], None
|
||||
r = _git("log", "--format=%H %s", f"{last}..HEAD", repo=repo)
|
||||
if r.returncode != 0:
|
||||
return [], None
|
||||
out = []
|
||||
for line in r.stdout.splitlines():
|
||||
sha, _, subject = line.partition(" ")
|
||||
f = _git("show", "--name-only", "--format=", sha, repo=repo)
|
||||
if any(is_runtime(p) for p in f.stdout.split()):
|
||||
out.append((sha[:10], subject))
|
||||
return out, last[:10]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--deployed-version", required=True,
|
||||
help="the version currently running ON THE BOX")
|
||||
ap.add_argument("--options", help="path to the live options blob, exported "
|
||||
"from the Supervisor. Omitted = that half is SKIPPED.")
|
||||
ap.add_argument("--tree", default=ADDON)
|
||||
ap.add_argument("--repo", default=None)
|
||||
a = ap.parse_args()
|
||||
|
||||
problems, skipped = [], []
|
||||
shipped = shipped_version(a.tree)
|
||||
print(f" deployed on the box : {a.deployed_version}")
|
||||
print(f" shipped in this tree: {shipped}")
|
||||
|
||||
commits, last = runtime_commits_since_version_change(a.repo)
|
||||
if shipped is None:
|
||||
problems.append("could not read `version:` out of config.yaml")
|
||||
elif shipped == a.deployed_version:
|
||||
if last is None:
|
||||
skipped.append("could not ask git what changed since the last bump; "
|
||||
"the no-op check is INCONCLUSIVE, not clean")
|
||||
if commits:
|
||||
problems.append(
|
||||
f"THE DEPLOY WOULD BE A NO-OP. Version is {shipped} on both sides, and "
|
||||
f"{len(commits)} commit(s) touching runtime files have landed since the "
|
||||
f"version last changed ({last}). `ha apps update` will report success "
|
||||
f"and install NOTHING. Bump `version:` first.")
|
||||
for sha, subj in commits[:12]:
|
||||
print(f" {sha} {subj[:76]}")
|
||||
elif last is not None:
|
||||
print(" no runtime change since the last bump - nothing to deploy")
|
||||
|
||||
if a.options:
|
||||
# ⚠️ A TOOL RUN IN A HURRY BEFORE A DEPLOY MUST NOT ANSWER WITH A
|
||||
# TRACEBACK. A missing or malformed export is the likeliest operator
|
||||
# error here, and it is a REFUSAL, not a skip - the config was not
|
||||
# checked and the script must not be re-run past it.
|
||||
opts = None
|
||||
try:
|
||||
with open(a.options, encoding="utf-8") as fh:
|
||||
opts = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
problems.append(f"--options file not found: {a.options}")
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
||||
problems.append(f"--options file could not be read as JSON "
|
||||
f"({type(exc).__name__}: {exc}); the config was NOT checked")
|
||||
if a.options and opts is not None:
|
||||
try:
|
||||
from app.validate import validate_startup_config
|
||||
except Exception as exc: # pragma: no cover
|
||||
skipped.append(
|
||||
f"could not import the shipped validator ({exc}); the config check "
|
||||
f"did NOT run. `app/validate.py` imports its siblings, and those need "
|
||||
f"the add-on's runtime dependencies - so THIS SCRIPT ONLY CHECKS A "
|
||||
f"CONFIG WHERE THOSE DEPENDENCIES EXIST: inside the add-on container, "
|
||||
f"or in the CI image. Re-run it as:\n"
|
||||
f" docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \\\n"
|
||||
f" python3 /w/goodwe_controller/tools/predeploy_check.py \\\n"
|
||||
f" --deployed-version <ver> --options /w/<options.json>")
|
||||
else:
|
||||
found = validate_startup_config(opts)
|
||||
if found:
|
||||
problems.append(
|
||||
f"THE LIVE CONFIG IS REFUSED BY THIS VERSION'S VALIDATOR "
|
||||
f"({len(found)} problem(s)). Deploying it is SystemExit(78): the "
|
||||
f"add-on does not start, and the ingress panel goes with it because "
|
||||
f"the add-on serving it is not running. Recovery is the Supervisor UI.")
|
||||
for p in found:
|
||||
print(f" - {p}")
|
||||
else:
|
||||
print(" live options pass this version's validator")
|
||||
else:
|
||||
skipped.append("--options was not given, so the live config was NEVER CHECKED "
|
||||
"against this version's validator. A config refused at startup is "
|
||||
"exit 78. Export the options and re-run before deploying.")
|
||||
|
||||
print()
|
||||
for s in skipped:
|
||||
print(f" ⚠️ SKIPPED: {s}")
|
||||
if problems:
|
||||
for p in problems:
|
||||
print(f" ❌ {p}")
|
||||
print("\n REFUSED - do not deploy.")
|
||||
return 1
|
||||
if skipped:
|
||||
print(" NOT CLEARED - checks above did not run. Deploying now is a decision "
|
||||
"made without them.")
|
||||
return 2
|
||||
print(" CLEARED for deploy.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,48 @@
|
||||
# WHAT THIS BUILD IS SUPPOSED TO ANNOUNCE, recorded so that a change to the
|
||||
# composition has something to disagree with. THREE COLUMNS:
|
||||
#
|
||||
# <entity_id> <unique_id> <device identifier>
|
||||
#
|
||||
# ⚠️ ALL THREE, AND THE SECOND AND THIRD ARE THE HALF THE FIRST DRAFT MISSED.
|
||||
# A reference that carries only entity_ids cannot see the two changes that
|
||||
# orphan an install without changing a single composed id:
|
||||
#
|
||||
# `BASE` renamed (`goodwe_ctl` -> anything) every unique_id changes
|
||||
# `DEVICE["identifiers"]` changed HA sees a NEW device
|
||||
#
|
||||
# Home Assistant's MQTT integration keys its entity registry on `unique_id` and
|
||||
# its device registry on the device identifier. Change either and HA creates a
|
||||
# NEW entity beside the old one - and because discovery is published with
|
||||
# `retain=True` and nothing publishes a removal payload, the OLD config stays on
|
||||
# the broker and the dead entity stays in the registry, still named by every
|
||||
# automation that referenced it, never updating again. The composed entity_id is
|
||||
# identical on both, so an entity_id-only comparison reports a clean transition.
|
||||
# That is the same shape as the habit-4 this file's own card already caught
|
||||
# once: a reference that does not carry the thing that moved.
|
||||
#
|
||||
# ⚠️ AND `object_id` IS DELIBERATELY NOT A COLUMN. `app/mqtt.py` lines 36-42 and
|
||||
# 61-76 record that HA 2026.8 does NOT honour `object_id` in a discovery
|
||||
# payload - the id is composed from device name plus entity name - so renaming
|
||||
# one is a true no-op and a column for it would make this file refuse a change
|
||||
# that costs nothing. The check is graded on what HA actually keys on.
|
||||
#
|
||||
# PROVENANCE, stated exactly:
|
||||
# - recorded 2026-09-08 from `release/1.0` at `meter_source: firmware`,
|
||||
# roster of one, by reading `mqtt.discovery_payloads(units=[])`. It is what
|
||||
# the code announces.
|
||||
# - `sensor.goodwe_p1_sample_age` is additionally recorded in `app/mqtt.py` as
|
||||
# VERIFIED AGAINST THE LIVE SYSTEM on 2026-08-26, and the ESP32's ingest
|
||||
# guard subscribes to that exact string.
|
||||
# - ⚠️ IT IS NOT A DUMP OF THE LIVE INSTALL'S ENTITY REGISTRY. That is what
|
||||
# `--live-entities` is for. This file is about THE BUILD.
|
||||
#
|
||||
# Editing this file is how you say "the announcement changed on purpose". Do not
|
||||
# edit it to make a check pass.
|
||||
sensor.goodwe_setpoint goodwe_ctl_setpoint goodwe_rs485_controller
|
||||
sensor.goodwe_grid_power goodwe_ctl_grid goodwe_rs485_controller
|
||||
sensor.goodwe_battery_power goodwe_ctl_battery goodwe_rs485_controller
|
||||
sensor.goodwe_battery_soc goodwe_ctl_soc goodwe_rs485_controller
|
||||
sensor.goodwe_circulating_power goodwe_ctl_circulation goodwe_rs485_controller
|
||||
sensor.goodwe_maintenance_phase goodwe_ctl_phase goodwe_rs485_controller
|
||||
sensor.goodwe_controller_status goodwe_ctl_status goodwe_rs485_controller
|
||||
sensor.goodwe_p1_sample_age goodwe_ctl_p1_age goodwe_rs485_controller
|
||||
@@ -0,0 +1,22 @@
|
||||
# ARCH-02 fixture: THE SHAPE THE LIVE BOX IS IN TODAY (a flat, single-inverter
|
||||
# config). ⚠️ ILLUSTRATIVE ENTITY NAMES, NOT THE LIVE ONES - the owner runs the
|
||||
# rehearsal against a copy of the real options blob, and this file exists so
|
||||
# that the harness has a synthetic transition it can be graded on.
|
||||
setpoint_entity: number.goodwe_setpoint
|
||||
soc_entity: sensor.goodwe_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_battery_power_w
|
||||
batt_invert: false
|
||||
max_w: 5000
|
||||
bms_soc_floor: 10
|
||||
meter_source: 'off'
|
||||
meter_entity: sensor.p1_active_power
|
||||
inverters: []
|
||||
gain: 0.6
|
||||
slew_w: 0
|
||||
deadband_w: 15
|
||||
target_grid_w: 0
|
||||
step_w: 10
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
@@ -0,0 +1,39 @@
|
||||
# THE ENTITY IDS THE INSTALL HAS — a stand-in for the owner's real export.
|
||||
#
|
||||
# ⚠️ THIS FILE AND `announced-reference.txt` ANSWER DIFFERENT QUESTIONS AND ARE
|
||||
# NOT INTERCHANGEABLE:
|
||||
#
|
||||
# announced-reference.txt what the BUILD announces. Loaded on EVERY run.
|
||||
# Three columns, because HA keys on `unique_id` and
|
||||
# on the device identifier as well as on the
|
||||
# composed entity_id.
|
||||
# this file (--live-entities)
|
||||
# what the INSTALL has. One id per line. Orphans
|
||||
# are graded against it: an id the install has and
|
||||
# the AFTER shape does not announce is an orphan
|
||||
# whatever the build's own history says.
|
||||
#
|
||||
# ⚠️ OMITTING `--live-entities` IS A REFUSAL (exit 2), NOT A QUIETER RUN. It
|
||||
# used to be the other way round - an unreadable file refused, a missing flag
|
||||
# passed with exit 0 and said nothing about the half it had not checked - and
|
||||
# the owner runs this ONCE, at commissioning, under time pressure.
|
||||
# `--no-live-entities` is the deliberate opt-out and it prints a standing
|
||||
# banner in the report header and again on the exit line.
|
||||
#
|
||||
# PROVENANCE: these eight are what `release/1.0` announces at
|
||||
# `meter_source: firmware`, roster of one, recorded 2026-09-08. They stand in
|
||||
# for the export so the checks run in CI. ⚠️ THE REAL INSTALL CARRIES ROUGHLY 49
|
||||
# `goodwe*` ENTITIES (card ARCH-02, read 2026-09-06); the other ~41 belong to
|
||||
# the ESPHome firmware, to template sensors and to helpers, and this add-on
|
||||
# announces none of them. The tool separates the ids it owns from the ids it
|
||||
# does not rather than claiming orphans it cannot cause - which is the half of
|
||||
# this file the eight-line stand-in cannot exercise, and the reason DOCS.md's
|
||||
# owner step asks for the real export.
|
||||
sensor.goodwe_battery_power
|
||||
sensor.goodwe_battery_soc
|
||||
sensor.goodwe_circulating_power
|
||||
sensor.goodwe_controller_status
|
||||
sensor.goodwe_grid_power
|
||||
sensor.goodwe_maintenance_phase
|
||||
sensor.goodwe_p1_sample_age
|
||||
sensor.goodwe_setpoint
|
||||
@@ -0,0 +1,42 @@
|
||||
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
|
||||
#
|
||||
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
|
||||
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
|
||||
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
|
||||
# every two-pack fixture in the suite named its first entry `inverter-a`, so
|
||||
# `UNIT_A` and `inverters[0].name` were the same string in every check that
|
||||
# existed and the defect was invisible. Names that are not `inverter-a` are what
|
||||
# make a misdirected namespace something a rehearsal can SEE.
|
||||
#
|
||||
# `roster-inverter-a.yaml` beside this file is the same roster with the first
|
||||
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
|
||||
setpoint_entity: number.goodwe_setpoint
|
||||
soc_entity: sensor.goodwe_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_battery_power_w
|
||||
batt_invert: false
|
||||
max_w: 5000
|
||||
bms_soc_floor: 10
|
||||
meter_source: 'off'
|
||||
meter_entity: sensor.p1_active_power
|
||||
gain: 0.6
|
||||
slew_w: 0
|
||||
deadband_w: 15
|
||||
target_grid_w: 0
|
||||
step_w: 10
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
inverters:
|
||||
- name: inverter-a
|
||||
setpoint_entity: number.goodwe_setpoint
|
||||
soc_entity: sensor.goodwe_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_battery_power_w
|
||||
max_w: 5000
|
||||
bms_soc_floor: 10
|
||||
- name: inverter-b
|
||||
setpoint_entity: number.goodwe_b_setpoint
|
||||
soc_entity: sensor.goodwe_b_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_b_battery_power_w
|
||||
max_w: 4600
|
||||
bms_soc_floor: 10
|
||||
@@ -0,0 +1,42 @@
|
||||
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
|
||||
#
|
||||
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
|
||||
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
|
||||
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
|
||||
# every two-pack fixture in the suite named its first entry `inverter-a`, so
|
||||
# `UNIT_A` and `inverters[0].name` were the same string in every check that
|
||||
# existed and the defect was invisible. Names that are not `inverter-a` are what
|
||||
# make a misdirected namespace something a rehearsal can SEE.
|
||||
#
|
||||
# `roster-inverter-a.yaml` beside this file is the same roster with the first
|
||||
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
|
||||
setpoint_entity: number.goodwe_setpoint
|
||||
soc_entity: sensor.goodwe_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_battery_power_w
|
||||
batt_invert: false
|
||||
max_w: 5000
|
||||
bms_soc_floor: 10
|
||||
meter_source: 'off'
|
||||
meter_entity: sensor.p1_active_power
|
||||
gain: 0.6
|
||||
slew_w: 0
|
||||
deadband_w: 15
|
||||
target_grid_w: 0
|
||||
step_w: 10
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
inverters:
|
||||
- name: garage
|
||||
setpoint_entity: number.goodwe_setpoint
|
||||
soc_entity: sensor.goodwe_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_battery_power_w
|
||||
max_w: 5000
|
||||
bms_soc_floor: 10
|
||||
- name: shed
|
||||
setpoint_entity: number.goodwe_b_setpoint
|
||||
soc_entity: sensor.goodwe_b_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_b_battery_power_w
|
||||
max_w: 4600
|
||||
bms_soc_floor: 10
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a store that has ALREADY run the flat config under 0.7.0+, so DEV-02's `schema_version` is stamped and the SAFETY-02 latch is not tripped. NOT a copy of the live box's /data/state.json. This is the CLEAN CONTROL for the MAINT-05 half of the rehearsal: the flat->roster transition over it must come out with NO findings, which is what makes each deliberate mutation's RED mean something. A harness that is red on every input has not measured anything.",
|
||||
"schema_version": 1,
|
||||
"phase": "idle",
|
||||
"phase_started": "2026-08-07T10:00:00+00:00",
|
||||
"last_completed": "2026-08-07T18:42:00+00:00",
|
||||
"last_start_attempt": "2026-08-07T10:00:00+00:00",
|
||||
"sign_tripped": false,
|
||||
"sign_reason": "",
|
||||
"auto": true
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a FRESH INSTALL. No maintenance history, no latch, no schema markers: what /data/state.json looks like before the add-on has ever completed a cycle. It is a clean control, and it earned its place: the first draft of the harness reported this transition RED, because the DEV-02 migration writes both SAFETY-02 defaults under `inverter-a` before a roster exists and the tool called them stranded. Losing a default loses nothing."
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a PRE-DEV-02 flat state file. NOT a copy of the live box's /data/state.json; this session cannot read that one and did not try. It is built to carry, deliberately, the two shapes that state built by the happy path does not have: a LATCHED SAFETY-02 failsafe under the flat spelling, and a real maintenance history under the flat spelling. Both are LEGACY-SHAPED: there is no schema_version and no maint_schema_version, so this file was last written by a controller that had never heard of unit namespaces. Losing either one is silent.",
|
||||
"phase": "idle",
|
||||
"phase_started": "2026-08-07T10:00:00+00:00",
|
||||
"last_completed": "2026-08-07T18:42:00+00:00",
|
||||
"last_start_attempt": "2026-08-07T10:00:00+00:00",
|
||||
"sign_tripped": true,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
|
||||
"auto": true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
"""UX-02: can this add-on read and write its OWN options, and what comes back?
|
||||
|
||||
⚠️ WHY THIS SCRIPT EXISTS. The UX-02 card asserts that `GET /addons/self/info`,
|
||||
`POST /addons/self/options` and `POST /addons/self/restart` are permitted to an
|
||||
add-on with `hassio_api: true` and no special `hassio_role`, and it says - in
|
||||
its own words - "confirm this again in the PR by calling it, rather than citing
|
||||
this card". A permission read off a regex in somebody else's repository is a
|
||||
hypothesis; the whole configuration surface rests on it, so it is RUN here,
|
||||
against the compiled patterns out of the release image this site runs.
|
||||
|
||||
It also answers the question the panel's design depends on next: WHAT the info
|
||||
response carries. app/configui.py builds the entire form from `schema` and
|
||||
`translations` and owns no copy of either, which is only sound if those two
|
||||
really are in the response and really are the loader's own output. Both are
|
||||
constructed here, out of the image, and this repository's own model is built
|
||||
from them.
|
||||
|
||||
HOW TO RUN IT (the version is the one `ha info` reports on the target install -
|
||||
2026.08.0 is what 192.168.2.158 ran when UX-02 landed, and it is the same image
|
||||
tools/supervisor_shape_probe.py pins):
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/w:ro" -v "$PWD/app:/app:ro" -v "$PWD/tools:/probe:ro" \
|
||||
--entrypoint python3 \
|
||||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||||
/probe/supervisor_selfapi_probe.py
|
||||
|
||||
⚠️ IT TOUCHES NOTHING, AND IT TOUCHES THE LIVE SITE LEAST OF ALL. It reads
|
||||
config.yaml and translations/, imports Supervisor's own modules and runs them in
|
||||
a throwaway container. It opens no socket, contacts no Supervisor, POSTs no
|
||||
options and restarts nothing. The add-on is live in a real house; the only
|
||||
honest way to measure this is against a copy of the software, not against it.
|
||||
|
||||
⚠️ WHAT IT CANNOT MEASURE, SAID OUT LOUD. It does not execute an HTTP request
|
||||
against a running Supervisor, because doing that would mean pointing it at the
|
||||
live one. So §A grades the ACCESS DECISION (the compiled patterns, run against
|
||||
the exact paths) and §B grades the RESPONSE KEYS (the constants `info_data`
|
||||
builds its dict from), rather than a captured response body. What is NOT
|
||||
verified here is the wiring between the two - that the handler which builds that
|
||||
dict is the one mounted at that path. That is read, not run, and this is the
|
||||
sentence saying so.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/usr/src/supervisor")
|
||||
sys.path.insert(0, "/") # app/ is mounted at /app
|
||||
|
||||
import voluptuous as vol # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
from supervisor.utils.yaml import read_yaml_file # noqa: E402
|
||||
from supervisor.apps.validate import SCHEMA_APP_CONFIG # noqa: E402
|
||||
from supervisor.apps.options import AppOptions, UiOptions # noqa: E402
|
||||
from supervisor.apps.app import _OPTIONS_MERGER # noqa: E402
|
||||
from supervisor.store.data import _read_app_translations # noqa: E402
|
||||
from supervisor.const import (ATTR_OPTIONS, ATTR_SCHEMA, # noqa: E402
|
||||
ATTR_TRANSLATIONS)
|
||||
from supervisor.api.middleware.security import _V1_PATTERNS # noqa: E402
|
||||
|
||||
from app import configui # noqa: E402
|
||||
|
||||
FAILED = []
|
||||
|
||||
|
||||
def show(title, ok, detail=""):
|
||||
print(f" {'PASS' if ok else 'FAIL'} {title}")
|
||||
if detail:
|
||||
print(" " + str(detail)[:400])
|
||||
if not ok:
|
||||
FAILED.append(title)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("== A. THE PERMISSION, RUN RATHER THAN READ ==")
|
||||
# ⚠️ THE PATTERNS ARE SUPERVISOR'S OWN COMPILED OBJECTS, imported, not retyped.
|
||||
# `token_validation()` tests `api_bypass` against `request.path` for an
|
||||
# installed app BEFORE it consults `access_hassio_api` or any role, so a match
|
||||
# here is the whole permission question for these paths.
|
||||
for path, want in (("/addons/self/info", True),
|
||||
("/addons/self/options", True),
|
||||
("/addons/self/restart", True),
|
||||
("/addons/self/options/config", True),
|
||||
# The two the pattern explicitly excludes, asserted so that
|
||||
# a future widening of it does not go unnoticed here.
|
||||
("/addons/self/security", False),
|
||||
("/addons/self/update", False)):
|
||||
got = bool(_V1_PATTERNS.api_bypass.match(path))
|
||||
show(f"{path:32s} bypass={got}", got == want)
|
||||
|
||||
# ⚠️ THE ONE THE CARD DID NOT PREDICT, AND IT IS A NEGATIVE RESULT WORTH HAVING.
|
||||
# Supervisor has its OWN options validator at `/addons/{app}/options/validate`.
|
||||
# It is two path segments after `self`, so `[^/]+` cannot match it, and it is
|
||||
# not one of the listed literals either. An add-on cannot call it. That does
|
||||
# not hurt this card - the Supervisor validator knows only types and ranges, and
|
||||
# the whole reason this panel exists is the cross-field rules it structurally
|
||||
# cannot express - but "there is exactly one validator reachable from inside the
|
||||
# add-on, and it is ours" is a fact the design should rest on knowingly.
|
||||
_val = bool(_V1_PATTERNS.api_bypass.match("/addons/self/options/validate"))
|
||||
show("/addons/self/options/validate is NOT reachable from an add-on - so the "
|
||||
"ONLY validator this panel can call is app/validate.py, which is the one "
|
||||
"with the cross-field rules", not _val, f"bypass={_val}")
|
||||
|
||||
# ⚠️ AND WHICH OF THE TWO MECHANISMS IS CARRYING EACH PATH - MEASURED, BECAUSE
|
||||
# THE FIRST DRAFT OF THIS SECTION GOT IT WRONG. It asserted that the `default`
|
||||
# role admits none of the three, and that FAILED: the default role's pattern is
|
||||
# `^(?:|/.+/info)$`, which matches `/addons/self/info` perfectly well. So the
|
||||
# split is:
|
||||
#
|
||||
# /addons/self/info - admitted by BOTH the bypass and the default role
|
||||
# /addons/self/options - the bypass ONLY
|
||||
# /addons/self/restart - the bypass ONLY
|
||||
#
|
||||
# i.e. the two WRITING paths rest entirely on the bypass list, and if it were
|
||||
# narrowed this panel could still read its own options and could no longer save
|
||||
# them. That is a more useful thing to know than the tidier claim that was
|
||||
# asserted first, and it is only known because the claim was run.
|
||||
_default = _V1_PATTERNS.role_access["default"]
|
||||
show("`/addons/self/info` is admitted by the DEFAULT role as well as by the "
|
||||
"bypass (`/.+/info`) - so reading has two independent licences",
|
||||
bool(_default.match("/addons/self/info")))
|
||||
show("...while `/addons/self/options` and `/addons/self/restart` are admitted "
|
||||
"by the BYPASS ONLY. The two writing paths rest on that list alone",
|
||||
not _default.match("/addons/self/options")
|
||||
and not _default.match("/addons/self/restart"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== B. WHAT `GET /addons/self/info` IS BUILT FROM ==")
|
||||
# `AppsAPI.info_data()` builds its dict with these constants as keys. Their
|
||||
# VALUES are what the panel reads off the JSON, so they are asserted here
|
||||
# rather than typed into app/selfconfig.py as a guess.
|
||||
show("the response keys the panel reads are named `options`, `schema` and "
|
||||
"`translations`",
|
||||
(ATTR_OPTIONS, ATTR_SCHEMA, ATTR_TRANSLATIONS)
|
||||
== ("options", "schema", "translations"),
|
||||
f"{ATTR_OPTIONS} / {ATTR_SCHEMA} / {ATTR_TRANSLATIONS}")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== C. `schema` - what App.schema_ui actually is, for THIS config.yaml ==")
|
||||
raw = read_yaml_file(Path("/w/config.yaml"))
|
||||
cfg = SCHEMA_APP_CONFIG(raw)
|
||||
schema, defaults = cfg["schema"], cfg["options"]
|
||||
ui = UiOptions(None)(schema)
|
||||
show(f"UiOptions renders every key config.yaml declares ({len(ui)})",
|
||||
len(ui) == len(schema), f"schema keys {len(schema)}, ui nodes {len(ui)}")
|
||||
|
||||
# ⚠️ THE NAME IS THE MEASUREMENT. A NUMERIC range comes back as `lengthMin` /
|
||||
# `lengthMax` - which reads like a string length and is not - and app/configui.py
|
||||
# copies those two names straight through into the form's `min`/`max`. Guessing
|
||||
# `valueMin` would have produced a form with no bounds at all and no error
|
||||
# anywhere.
|
||||
_maxw = [n for n in ui if n["name"] == "max_w"][0]
|
||||
show("a numeric range arrives as lengthMin/lengthMax (NOT valueMin/valueMax), "
|
||||
"which is why app/configui.py reads those two names",
|
||||
_maxw.get("lengthMin") == 100 and _maxw.get("lengthMax") == 5000,
|
||||
json.dumps(_maxw))
|
||||
_sel = [n for n in ui if n["name"] == "meter_source"][0]
|
||||
show("an enum arrives as type `select` with its `options` listed",
|
||||
_sel.get("type") == "select" and "homewizard_local" in _sel.get("options", []),
|
||||
json.dumps(_sel))
|
||||
_inv = [n for n in ui if n["name"] == "inverters"][0]
|
||||
show("the roster arrives as type `schema` + `multiple`, carrying its per-entry "
|
||||
"fields - which is what the panel's roster editor draws cards from",
|
||||
_inv.get("type") == "schema" and _inv.get("multiple") is True
|
||||
and [s["name"] for s in _inv["schema"]][:3] == ["name", "setpoint_entity",
|
||||
"max_w"],
|
||||
json.dumps([s["name"] for s in _inv["schema"]]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== D. `options` - and what a POST to /addons/self/options REPLACES ==")
|
||||
# ⚠️ THE HAZARD THE COMPARE-AND-SWAP EXISTS FOR, DEMONSTRATED. `App.options` is
|
||||
# `deepmerge(config.yaml defaults, the STORED user options)` and the setter is
|
||||
# `persist[options] = deepcopy(value)` - a REPLACEMENT. So a key left out of a
|
||||
# POST does not keep its previous value; it falls back to the config.yaml
|
||||
# DEFAULT. Both halves are run below on real objects.
|
||||
_persist_before = {"max_w": 4200, "gain": 0.42}
|
||||
_effective_before = _OPTIONS_MERGER.merge(copy.deepcopy(defaults),
|
||||
copy.deepcopy(_persist_before))
|
||||
show("stored user options merge OVER the config.yaml defaults, so every key is "
|
||||
"present in what an add-on reads back",
|
||||
_effective_before["max_w"] == 4200
|
||||
and set(_effective_before) == set(defaults),
|
||||
f"max_w={_effective_before['max_w']} gain={_effective_before['gain']}")
|
||||
|
||||
_persist_after = {"gain": 0.42} # a POST that simply omits max_w
|
||||
_effective_after = _OPTIONS_MERGER.merge(copy.deepcopy(defaults),
|
||||
copy.deepcopy(_persist_after))
|
||||
show("...and a POST that OMITS a key does not preserve it - the key reverts to "
|
||||
"config.yaml's default. That is why this panel posts the WHOLE blob it "
|
||||
"read, and why it refuses to post one built on a stale read",
|
||||
_effective_after["max_w"] == defaults["max_w"] != 4200,
|
||||
f"omitted max_w -> {_effective_after['max_w']} "
|
||||
f"(default {defaults['max_w']}, was 4200)")
|
||||
|
||||
# The blob the panel would actually POST: the full merged options, unchanged.
|
||||
# If Supervisor refused that shape the read-modify-write would be unusable.
|
||||
_v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"],
|
||||
"local_goodwe_controller")))
|
||||
try:
|
||||
_validated = _v(copy.deepcopy(_effective_before))
|
||||
show("the WHOLE merged blob - what this panel sends back - is accepted by "
|
||||
"Supervisor's own options validator", True)
|
||||
except vol.Invalid as err:
|
||||
_validated = None
|
||||
show("the WHOLE merged blob is accepted", False, str(err))
|
||||
|
||||
# ⚠️ AND WHETHER IT COMES BACK BYTE-IDENTICAL, because the panel tells the
|
||||
# operator which settings are "stored but not running" by comparing the stored
|
||||
# blob with `/data/options.json`. Supervisor writes that file through
|
||||
# `schema.validate(self.options)`, so any COERCION in that step would show up
|
||||
# as a spurious difference on every page load.
|
||||
if _validated is not None:
|
||||
_drift = sorted(k for k in set(_validated) | set(_effective_before)
|
||||
if _validated.get(k) != _effective_before.get(k))
|
||||
show("...and validation does not COERCE it, so `stored vs running` compares "
|
||||
"like with like and shows a difference only when there is one. "
|
||||
f"Keys changed by validation: {_drift}", not _drift)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== E. `translations` - UX-01's labels, reaching a SECOND surface ==")
|
||||
translations = _read_app_translations(Path("/w"))
|
||||
show("Supervisor's own loader accepts both files, so `info['translations']` "
|
||||
"carries them", set(translations) == {"en", "nl"}, sorted(translations))
|
||||
|
||||
for lang, groups_expected in (("en", 7), ("nl", 7)):
|
||||
model = configui.field_model(ui, translations, lang)
|
||||
names = [g["name"] for g in model["groups"]]
|
||||
show(f"[{lang}] this repository's own field_model() turns those into "
|
||||
f"{len(names)} real section headings out of UX-01's `Group · ` prefix "
|
||||
f"- which the Supervisor's flat list cannot render at all",
|
||||
len(names) == groups_expected and not model["ungrouped"],
|
||||
f"{names} ungrouped={model['ungrouped']}")
|
||||
_raw = [f["key"] for g in model["groups"] for f in g["fields"]
|
||||
if f["label_lang"] == "raw"]
|
||||
show(f"[{lang}] ...and not one field falls back to its raw key name. "
|
||||
f"Raw: {_raw}", not _raw)
|
||||
_nohelp = [f["key"] for g in model["groups"] for f in g["fields"]
|
||||
if not f["help"]]
|
||||
show(f"[{lang}] ...and not one is left without the sentence that says "
|
||||
f"whether to touch it. Missing: {_nohelp}", not _nohelp)
|
||||
|
||||
# ⚠️ THE ONE PLACE THIS PANEL IS STRICTLY BETTER THAN THE CONFIGURATION TAB ON
|
||||
# THE SAME DATA, and shape_probe §F is the measurement that makes it a claim
|
||||
# rather than a boast: on frontend 20250925.1 the roster's per-entry keys go to
|
||||
# `ha-selector-object`, which labels them `fields[name].label || name`, and the
|
||||
# converter sets no label - so they render RAW there, however well UX-01
|
||||
# translated them. Here they are read out of the same `fields:` block and shown.
|
||||
_model_en = configui.field_model(ui, translations, "en")
|
||||
_roster = [f for g in _model_en["groups"] for f in g["fields"]
|
||||
if f["key"] == "inverters"][0]
|
||||
_sub = [(s["key"], s["label"], s["label_lang"]) for s in _roster["fields"]]
|
||||
show("the roster's PER-ENTRY keys resolve to their UX-01 labels here, which is "
|
||||
"exactly what the Supervisor's Configuration tab renders raw on frontend "
|
||||
"20250925.1 (shape_probe §F)",
|
||||
all(lang == "en" for _, _, lang in _sub) and len(_sub) == 7,
|
||||
json.dumps([(k, l) for k, l, _ in _sub]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== F. the anchor list is the SCHEMA's keys, and collisions are real ==")
|
||||
# app/configui.py files each validator sentence under every option key it
|
||||
# NAMES, with word boundaries. Three of this schema's keys are suffixes of one
|
||||
# another, so a naive `in` test would file every per-unit refusal under the site
|
||||
# key as well. Run against the real key list rather than a hand-made one.
|
||||
_keys = sorted(defaults)
|
||||
_collide = [k for k in _keys if any(o != k and o.endswith("_" + k)
|
||||
for o in _keys)]
|
||||
show(f"config.yaml really does contain keys that are suffixes of other keys, "
|
||||
f"so the boundary matching is load-bearing: {_collide}", bool(_collide))
|
||||
_by, _un = configui.problems_by_field(
|
||||
["unit_sign_disagree_w is 3000 W but unit-0's max_w is 2000 W"], _keys)
|
||||
show("...and a sentence naming `unit_sign_disagree_w` is NOT also filed under "
|
||||
"`sign_disagree_w`",
|
||||
sorted(_by) == ["max_w", "unit_sign_disagree_w"] and not _un,
|
||||
json.dumps(sorted(_by)))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== G. COULD THE SUPERVISOR'S CONFIGURATION TAB BE REMOVED? ==")
|
||||
# ⚠️ THE CARD SAYS IT CANNOT BE. THAT TURNS OUT TO BE THE RIGHT DECISION FOR
|
||||
# THE WRONG REASON, AND THE DIFFERENCE MATTERS - a constraint you believe is
|
||||
# imposed is one nobody re-examines, while a choice is something a decision
|
||||
# record has to justify. So it was RUN.
|
||||
#
|
||||
# The tab renders `App.schema_ui`, which is `UiOptions(...)(data["schema"])`.
|
||||
# Delete `schema:` from config.yaml and there is nothing to render. Supervisor
|
||||
# ACCEPTS that manifest - the key is optional and defaults to `{}` - so the tab
|
||||
# really would go away.
|
||||
#
|
||||
# What goes away with it is the add-on. `AppOptions` DROPS every option that
|
||||
# the schema does not declare (one "Option 'x' does not exist in the schema"
|
||||
# warning per key, and nothing else), so `App.options` validates down to the
|
||||
# EMPTY DICT, `write_options()` writes `{}` to /data/options.json, and
|
||||
# `load_options()` hands the controller nothing. The startup gate then refuses
|
||||
# it for having no actuator - exit 78 - and the ingress panel this card builds
|
||||
# is not running either, because the add-on serving it is not.
|
||||
#
|
||||
# So: removable, at the cost of every setting on the site and the add-on with
|
||||
# them. That is the measured reason DECISIONS/UX-02-two-config-surfaces.md
|
||||
# keeps the tab, and it is a much better reason than "the platform will not
|
||||
# let us".
|
||||
_noschema = {k: v for k, v in raw.items() if k != "schema"}
|
||||
try:
|
||||
_cfg2 = SCHEMA_APP_CONFIG(_noschema)
|
||||
_accepted, _detail = True, repr(_cfg2.get("schema"))
|
||||
except vol.Invalid as err:
|
||||
_cfg2, _accepted, _detail = None, False, str(err)
|
||||
show("a manifest with NO `schema:` block is ACCEPTED by Supervisor, and its "
|
||||
"`schema_ui` is empty - so the Configuration tab is removable after all",
|
||||
_accepted and _cfg2.get("schema") == {}
|
||||
and UiOptions(None)(_cfg2["schema"]) == [], _detail)
|
||||
if _accepted:
|
||||
_v2 = vol.Schema(vol.All(dict, AppOptions(None, _cfg2["schema"],
|
||||
_cfg2["name"], "local_x")))
|
||||
try:
|
||||
_out2 = _v2(copy.deepcopy(_cfg2["options"]))
|
||||
_kept = len(_out2)
|
||||
except vol.Invalid as err:
|
||||
_kept = f"refused: {err}"
|
||||
show("⚠️ ...and EVERY OPTION IS DROPPED WITH IT. Options not declared in "
|
||||
"the schema are discarded with one log warning each, so the add-on "
|
||||
"would start on an empty configuration, be refused by its own startup "
|
||||
"gate for having no actuator, and take the ingress panel down with "
|
||||
"it. That is why the tab stays",
|
||||
_kept == 0, f"options surviving an empty schema: {_kept} "
|
||||
f"of {len(defaults)}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if FAILED:
|
||||
print(f"== {len(FAILED)} CASE(S) FAILED ==")
|
||||
for title in FAILED:
|
||||
print(" - " + title)
|
||||
sys.exit(1)
|
||||
print("== all cases passed ==")
|
||||
@@ -315,6 +315,114 @@ 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)")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ UX-04: THAT COUNT IS NOW GRADED, AND IT IS WHY UX-01's AC 3 IS WITHDRAWN.
|
||||
#
|
||||
# UX-01 AC 3 said the visible count would drop to ~15, with the rest behind
|
||||
# HA's "show unused optional configuration options" toggle. The line above has
|
||||
# printed the refutation since the day it was written - 71, not 15 - and PRINTED
|
||||
# IS NOT GRADED: this probe exited 0 through the whole of UX-01's review while
|
||||
# displaying the number that contradicted the criterion it was evidence for.
|
||||
# That is the defect, not the count.
|
||||
#
|
||||
# THE FRONTEND RULE, transcribed from the bundle in THIS image at the top of
|
||||
# this section and replayed rather than paraphrased:
|
||||
#
|
||||
# _filteredSchema = (opts, schema) => schema.filter(e => e.name in opts
|
||||
# || e.required)
|
||||
#
|
||||
# `opts` is the add-on's OPTIONS AS SUPERVISOR SERVES THEM - the operator's
|
||||
# stored blob after `options:` has been deep-merged into it - so a key is
|
||||
# hideable only when it is BOTH absent from `options:` AND not required.
|
||||
#
|
||||
# WHY THE CRITERION IS WITHDRAWN RATHER THAN IMPLEMENTED, in two measurements:
|
||||
#
|
||||
# 1. IT IS ARITHMETICALLY UNREACHABLE. Supervisor's own UiOptions marks
|
||||
# `required` on every non-`?` key, and the filter above shows a required
|
||||
# key UNCONDITIONALLY. So the floor - what the tab renders with `options:`
|
||||
# EMPTIED ALTOGETHER, which is the most hiding that is even expressible -
|
||||
# is the required count below. It is nowhere near 15. No arrangement of
|
||||
# `options:` can reach the target; the criterion was never satisfiable.
|
||||
#
|
||||
# 2. THE 12 KEYS THAT *COULD* BE HIDDEN COST THE DEEP-MERGE DEFAULT. A key
|
||||
# absent from `options:` has nothing for the Supervisor to merge into an
|
||||
# existing box's stored blob. That is exactly the `delivery_hold_s`
|
||||
# mechanism the 0.9.0 upgrade rests on: a key that 0.7.0's stored blob
|
||||
# cannot contain resolves to its `options:` default instead of failing
|
||||
# validation, and moving it out of `options:` is precisely how you get the
|
||||
# exit 78 that mechanism exists to prevent. Worse, it would promote the 27
|
||||
# unpinned in-code literal fallbacks that `test_defaults_pin.py` part 4
|
||||
# ledgers into the LOAD-BEARING source of those defaults on a live install
|
||||
# - which is the CFG-03 defect the same card's AC 4 exists to police.
|
||||
#
|
||||
# So: four fewer clicks, bought with the upgrade path and with the defaults
|
||||
# pin. The decision is written up in
|
||||
# DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md
|
||||
# and the three assertions below are what keeps it a decision rather than an
|
||||
# omission - if anyone empties `options:` to shorten the form, this goes red.
|
||||
_hideable = sorted(set(schema) - set(defaults))
|
||||
_required = [n["name"] for n in ui if n.get("required")]
|
||||
|
||||
|
||||
def _filtered(opts):
|
||||
"""The frontend's own `_filteredSchema`, replayed on this manifest."""
|
||||
return [n for n in ui if n["name"] in opts or n.get("required")]
|
||||
|
||||
|
||||
show(f"every one of the {len(schema)} `schema:` keys is ALSO in `options:`, so "
|
||||
f"schema-only - the only set HA's toggle can hide - is EMPTY. UX-01 AC 3 "
|
||||
f"is not partially met, it is unstarted. Hideable: {_hideable}",
|
||||
not _hideable and len(schema) == len(defaults))
|
||||
show(f"...so replaying the frontend's own _filteredSchema on the served options "
|
||||
f"hides NOTHING: {len(_filtered(defaults))} of {len(ui)} fields render",
|
||||
len(_filtered(defaults)) == len(ui))
|
||||
# ⚠️ DERIVED, NOT TYPED, AND NOT A FLOOR. The assertion above read
|
||||
# `len(_required) > 15` while the prose said 59 - so a manifest change dropping
|
||||
# the required count to 20 would have passed while the sentence beside it stayed
|
||||
# wrong. That is the same shape as the count this repo already fixed in
|
||||
# `test_translations.py`: a number displayed but not graded.
|
||||
#
|
||||
# So the expected value is computed from config.yaml by Supervisor's OWN rule -
|
||||
# a key is optional iff its schema string ends in `?`, plus `inverters`, which
|
||||
# `UiOptions` marks optional because it is a list - and the assertion is an
|
||||
# EQUALITY against that. Typing 59 here would rot the first time a key is added.
|
||||
_OPTIONAL = {k for k, v in schema.items()
|
||||
if isinstance(v, str) and v.rstrip().endswith("?")} | {"inverters"}
|
||||
_EXPECT_REQ = len(schema) - len(_OPTIONAL)
|
||||
show(f"...and that floor is DERIVED: {len(schema)} schema keys minus "
|
||||
f"{len(_OPTIONAL)} optional ones (the `?` suffix, plus `inverters`) is "
|
||||
f"{_EXPECT_REQ}, and Supervisor's own required flag agrees",
|
||||
_EXPECT_REQ == len(_required))
|
||||
|
||||
# ⚠️ AND THE WITHDRAWAL'S OWN PREMISE, GRADED SEPARATELY. The equality above is
|
||||
# deliberately NOT enough on its own: both sides read the same `?` convention,
|
||||
# so marking a key optional moves BOTH and it stays green. That is correct - it
|
||||
# makes the printed number impossible to leave stale - but it grades Supervisor
|
||||
# agreeing with us, not the thing AC 3 was withdrawn for.
|
||||
#
|
||||
# AC 3 was withdrawn because the floor is FAR above the ~15 it asked for. If
|
||||
# enough keys ever became optional that the floor approached 15, the withdrawal
|
||||
# stops being true and this file should say so rather than keep printing a
|
||||
# sentence that has quietly become wrong.
|
||||
_AC3_ASKED = 15
|
||||
show(f"...and the floor of {_EXPECT_REQ} is still far above the ~{_AC3_ASKED} "
|
||||
f"AC 3 asked for, which is the whole reason it was withdrawn. If this ever "
|
||||
f"goes red the withdrawal needs re-arguing, not re-asserting",
|
||||
_EXPECT_REQ > 2 * _AC3_ASKED)
|
||||
|
||||
|
||||
# ⚠️ AN ISOLATED COUNTERFACTUAL, and the number is Supervisor's own `required`
|
||||
# flag rather than a count typed here. `options:` is emptied and NOTHING else
|
||||
# varies, so the answer cannot be about anything but the merge.
|
||||
show(f"...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is "
|
||||
f"expressible - the tab still renders {len(_filtered({}))} required "
|
||||
f"fields. THAT is the floor, and AC 3 asked for ~15. The criterion is "
|
||||
f"withdrawn as unsatisfiable, not deferred.",
|
||||
len(_filtered({})) == len(_required) and len(_required) == _EXPECT_REQ)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _entry(lang, name, path=()):
|
||||
node = TRANSLATIONS.get(lang, {}).get("configuration")
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# ⚠️ 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
|
||||
# The frontend keeps a field when `e.name in options || e.required`, and all 71
|
||||
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY-ONE are
|
||||
# rendered, always, in `schema:` order, with no description. This file is what
|
||||
# turns that into sentences.
|
||||
#
|
||||
@@ -78,6 +78,9 @@ configuration:
|
||||
batt_invert:
|
||||
name: "Battery power sign is reversed (optional)"
|
||||
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
|
||||
capacity_kwh:
|
||||
name: "Usable battery capacity (kWh) (optional)"
|
||||
description: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or its label. It is read for one purpose: dividing the site's target between the inverters by how much ENERGY each pack has left rather than by how many watts it is rated for. The two packs at the reference site are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so those two ways of dividing the work hand it to different batteries, and the energy one is the question worth asking. Optional: leave it empty and the add-on has no opinion, dividing the target evenly exactly as it did before. State it on EVERY inverter or on none of them - a pack measured in kWh next to one that is not is not a comparison, and the add-on refuses to start rather than ignore half of what you filled in."
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485 emergency stop is fitted"
|
||||
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
|
||||
@@ -234,6 +237,9 @@ configuration:
|
||||
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."
|
||||
delivery_hold_s:
|
||||
name: "Safety · Not-delivering report after (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
|
||||
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."
|
||||
|
||||
@@ -58,6 +58,9 @@ configuration:
|
||||
batt_invert:
|
||||
name: "Teken van het batterijvermogen omkeren (optioneel)"
|
||||
description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft."
|
||||
capacity_kwh:
|
||||
name: "Bruikbare capaciteit van de batterij (kWh) (optioneel)"
|
||||
description: "De bruikbare capaciteit van DEZE batterij in kilowattuur, van het datablad of het typeplaatje. Ze wordt voor één ding gelezen: het verdelen van de doelwaarde van de installatie over de omvormers volgens hoeveel ENERGIE elke batterij nog heeft, in plaats van volgens het vermogen waarvoor ze gebouwd is. De twee batterijen op de referentiesite zijn 5000 W met 15,36 kWh en 4600 W met 20,48 kWh, dus die twee manieren van verdelen wijzen het werk aan verschillende batterijen toe, en de energieverdeling is de juiste vraag. Optioneel: laat leeg en de add-on heeft geen voorkeur en verdeelt gelijk, precies zoals voordien. Vul ze in bij ELKE omvormer of bij geen enkele - een batterij in kWh naast een batterij zonder waarde is geen vergelijking, en de add-on start liever niet op dan de helft van wat u invulde te negeren."
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485-noodstop is geplaatst"
|
||||
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
|
||||
@@ -214,6 +217,9 @@ configuration:
|
||||
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."
|
||||
delivery_hold_s:
|
||||
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
|
||||
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."
|
||||
|
||||
Reference in New Issue
Block a user