Compare commits

...
15 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 477911afa1 UX-04: the 59 was computed and then graded against "> 15"
checks / unit (pull_request) Successful in 6m22s
Review approved and found four things worth fixing. Three are cosmetic; the
fourth is the same defect this card is about, one level up.

⚠️ THE FLOOR WAS PRINTED AS 59 AND ASSERTED AS `> 15`. So a manifest change
dropping the required count to 20 would have passed while the sentence beside it
still said 59 - a number displayed but not graded, which is exactly what let
UX-01's AC 3 survive review in the first place, and exactly the shape
`test_translations.py` was already fixed for.

Now DERIVED and graded in two separate assertions, because they are two
different claims:

  1. `_EXPECT_REQ = len(schema) - len(_OPTIONAL)`, where optional is Supervisor's
     own rule - a `?` suffix, plus `inverters` because `UiOptions` marks a list
     optional - asserted EQUAL to Supervisor's required count. The printed
     number is computed, so it cannot go stale.
  2. ⚠️ AND THE WITHDRAWAL'S PREMISE, SEPARATELY. The equality alone is not
     enough and I checked why: both sides read the same `?` convention, so
     marking a key optional moves BOTH and it stays green - measured, by making
     `slew_w` optional and watching it pass. That grades Supervisor agreeing
     with us, NOT the thing AC 3 was withdrawn for. The second assertion grades
     the premise: the floor is still far above the ~15 AC 3 asked for, and if it
     ever is not, the withdrawal needs re-arguing rather than re-asserting.

Driven, both directions, against the real 2026.08.0 image:

    clean                         PASS  "floor is DERIVED: 71 schema keys minus
                                         12 optional ones is 59, and
                                         Supervisor's own required flag agrees"
    premise threshold 15 -> 40    FAIL  "the floor of 59 is still far above
                                         the ~40 AC 3 asked for"

Also: a dangling reference to `DECISIONS/UX-04-hiding-and-the-deepmerge.md`,
which was never the filename shipped, and `DOCS.md` still saying section F
"prints" what it now grades.

Not changed, and review agreed both are disclosed rather than defects: the
pair-keyed ledger cannot see a SECOND site for a pair it already holds (the
site count can grow while the pair set cannot), and the probe's assertions run
by hand rather than in CI because they need the Supervisor image - the same
status as before this card, not a regression.

Suite: 27 files, all pass. Probe: all cases passed on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, --network none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:56:55 +02:00
Claude Opus 5 21f22b393a UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02
checks / unit (pull_request) Successful in 6m20s
UX-01's three unmet acceptance criteria, closed out. It shipped in 0.7.0 and is
LIVE, so all three describe the running install. No behaviour, no schema and no
default moves here: config.yaml, translations/ and every module under app/ are
byte-identical to 6e7b233.

⚠️ AC 3 - "the visible count drops to ~15" - IS WITHDRAWN AS UNSATISFIABLE, and
the arithmetic is the reason rather than the effort. options: and schema: both
declare all 71 keys, so the set HA's toggle can hide is EMPTY - it hides a key
only when it is ?-optional AND absent from options:, and nothing is absent. But
the deeper fact is that no manifest could have met it: Supervisor's own
UiOptions marks 59 of the 71 REQUIRED, and the frontend's own filter -
`schema.filter(e => e.name in opts || e.required)`, transcribed out of the
bundle in the release image - shows a required field unconditionally. So 59 is
the floor with options: EMPTIED ALTOGETHER. The target was ~4x below anything
reachable.

⚠️ AND THE 12 HIDES THAT ARE EXPRESSIBLE WOULD BE BOUGHT WITH THE UPGRADE PATH.
A key absent from options: has no default for the Supervisor to deep-merge into
an existing box's stored blob. That is exactly the delivery_hold_s mechanism
this release rests on: a REQUIRED key a 0.7.0 blob has never stored resolves to
its options: default of 185 instead of becoming exit 78. Hiding a key is
precisely the edit that removes that. It would also promote the 27 unpinned
in-code literal fallbacks the new check ledgers into 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 its AC 3.

⚠️ THE COUNT WAS PRINTED AND NEVER GRADED, WHICH IS HOW THIS PASSED REVIEW.
supervisor_shape_probe.py section F has printed "the Configuration tab renders
71 fields" since the day it was written and exited 0 through UX-01's whole
review while displaying the number that contradicted the criterion it was
evidence for. Three assertions replace the bare print, every number computed
from the manifest and from Supervisor's own `required` flag rather than typed,
including an ISOLATED COUNTERFACTUAL that empties options: and nothing else.
MEASURED RED: delete one key (price_feed_url) from options: and two of them
fail - `Hideable: ['price_feed_url']`, `70 of 71 fields render` - exit 1.

--- AC 4: the AST check, built --------------------------------------------

AC 4's first clause is VACUOUS over an empty set (see above) and its second -
"the table changed goes red" - already works six times over. The third, the one
with the content, was never written: the only literal scan in the repository is
test_validate.py's regex over eight maintenance_* keys, which is a grep and
predates the card.

test_defaults_pin.py walks ast.Call nodes over app/*.py. It:

  - DISCOVERS the six *_OPTION_DEFAULTS mirrors from the tree rather than
    naming them. Every existing pin names its own table by hand, so a SEVENTH
    table added tomorrow is pinned by nobody and nothing says so;
  - grades all 30 mirrored keys against config.yaml, and requires that no
    module carries a literal fallback for any of them;
  - FREEZES the CFG-03 backlog at exactly today's 27 (module, key) pairs, by
    pair and not by count - a count goes green again the moment one site is
    deleted and another added. Line numbers are deliberately out of the key: a
    ledger that reddens on an unrelated reformat is one people re-bless without
    reading. It can only shrink;
  - grades every remaining literal AGAINST config.yaml, which is the half that
    catches CFG-04's actual failure - two copies of one number, one moves.

⭐ ONE PREVIOUSLY UNREPORTED DIVERGENCE, and it is what that last check is for:
main.py:290 answers a missing target_grid_w with 0 where config.yaml ships -10.
It cannot reach an operator - the deep-merge guarantees the key is present - and
the only paths that hand over a partial dict are sim/inject.py and this repo's
fixtures. Recorded as the single permitted divergence with its 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.

⭐ THE RED DIRECTION WAS RUN, TWICE OVER. Part 5 feeds the scanner a synthetic
module containing a deliberate violation of each rule and REQUIRES it to be
caught, in-process, through the same function the graded parts call - so a
scanner that goes blind stops working there too - and the site total is graded
against a floor. And a literal was planted in the REAL tree:

    main.py:290  float(opts.get("maintenance_charge_w", 2000))
    -> FAIL no literal fallback survives for any of the 30 keys a mirror
       already answers for                                          exit 1

    main.py:290  opts.get("p1_host", "x"), opts.get("slew_w", 999)
    -> FAIL no NEW module/key pair has started carrying a literal fallback
            [('main.py', 'p1_host')]
       FAIL every remaining literal still AGREES with config.yaml
            main.py  slew_w: code 999 vs config.yaml 0               exit 1

Both plants removed.

--- AC 5: CFG-04 is closed, and not by this card's predecessor -------------

fef6ef2 touched config.yaml, translations/, test_translations.py and the probe -
no defaults code at all - and on 0.7.0 the max_w 2000 -> 2500 triple mutation
survives 21 files, 0 RED. CONFIRMED CLOSED on release/1.0 by re-running the same
triple mutation on a throwaway copy of 6e7b233: 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

git log -L 310,325:test_selfconfig.py attributes that check to bcee235,
"UX-02: pin both halves of the max_w fix, because neither was graded". The AC is
met BY UX-02 and ships with the pending 0.9.0 deploy. Nothing was re-done.

--- the 8 per-entry translations: they stay --------------------------------

Measured negative and not a defect in them: UiOptions gives inverters type
schema WITH multiple, the frontend sends that case to ha-selector-object, which
labels fields `fields[name].label || name`, and the converter sets neither label
nor translation_key. The Supervisor tab CANNOT be made to render them. They stay
because UX-02's ingress panel is the surface that can, because they cost
nothing, and because deleting them removes the only thing that would catch a
per-entry key added without a translation - which is how SAFETY-32's REQUIRED
bms_soc_floor would have joined the roster unlabelled. A written decision, not a
change; translations/ is untouched.

tools/supervisor_selfapi_probe.py stays red on its hardcoded len(_sub) == 7.
That is TOOL-04, already filed, and it is not re-filed or fixed here.

--- evidence ---------------------------------------------------------------

  positive control  test_defaults_pin.py's mirror floor moved 6 -> 7 inside the
                    harness container: "### RED: test_defaults_pin.py", exit 1
  suite             27 files, ALL GREEN, exit 0 (goodwe-addon-checks:1, the
                    checkout mounted read-only)
  shape probe       all cases passed, exit 0, on
                    ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0

Nothing here touched hardware, the live Home Assistant at 192.168.2.158, or
sim/. Both probe runs were throwaway containers over a read-only mount.

DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md carries all four
decisions, the frontend transcription, and the two counterfactual transcripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:29:48 +02:00
admin 6e7b23388c Merge pull request 'PKG-13: the no-op half of the pre-deploy check has never run as documented' (#65) from PKG-13 into release/1.0 2026-09-07 17:34:18 +02:00
glenn schrooyenandClaude Opus 5 1ba35f9c99 PKG-13: one safe.directory flag, not two, and the tighter of the pair
checks / unit (pull_request) Successful in 6m19s
Review found `-c safe.directory={where}` and `-c safe.directory=*` set on the
same invocation, the specific value redundant once the wildcard is present.

Dropped the WILDCARD rather than the specific path, which is the opposite of
the suggestion and the tighter end of it: `_git()` always runs `-C where`, so
the only repository it ever reads is the one it was pointed at, and naming that
one path grants exactly the trust the tool needs. `*` granted trust to every
repository on the machine for the lifetime of each subprocess - harmless for
read-only `log`/`show` with no tty, as review measured, but broader than the
job.

Verified after the change: the documented container invocation still runs BOTH
halves and reports `no runtime change since the last bump` / `CLEARED for
deploy`, so the fix this branch exists for is intact.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 17:27:36 +02:00
glenn schrooyenandClaude Opus 5 0b653c35a6 PKG-13: the no-op half of the pre-deploy check has never run as documented
checks / unit (pull_request) Successful in 6m19s
⚠️ THE INVOCATION ON PKG-09's CARD AND IN THE TOOL'S OWN DOCSTRING RUNS ONLY
HALF THE CHECK. The container runs 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` returned non-zero, `last_version_change()` returned None, and the
silent-no-op check - THE REASON PKG-09 EXISTS - reported SKIPPED. Measured, on
the documented command against release/1.0:

    ⚠️  SKIPPED: could not ask git what changed since the last bump;
                 the no-op check is INCONCLUSIVE, not clean
    NOT CLEARED

and with `--user $(id -u):$(id -g)`, unchanged otherwise:

    no runtime change since the last bump - nothing to deploy
    CLEARED for deploy.

⚠️ THE TWO HALVES WERE MUTUALLY EXCLUSIVE ACROSS THE TWO DOCUMENTED
ENVIRONMENTS. 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. The pre-0.9.0 clearance was sound only because it happened to be driven as
two separate invocations.

⚠️ 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 ever needing git.

FIXED IN `_git()` WITH `safe.directory`, NOT BY DOCUMENTING `--user`. The tool
should be correct however it is invoked rather than requiring every future
caller to remember a flag; the read is read-only, on a repository the operator
is about to deploy from.

⚠️ WHY THE TESTS COULD NOT SEE IT, WHICH IS THE FINDING BEHIND THE FINDING.
`test_version_gate.py` invokes the gate with `sys.executable` against fixtures
it creates as its own uid, so no fixture could ever meet an ownership mismatch.
MUTATION.md habit 4 in a fourth shape, inside the file written to prevent it -
after the pickaxe bug, after the one-bump history, after the filter-only
survivor. Two fixtures added:

  - a directory that is NOT a git repository, so the lookup genuinely fails:
    the gate must exit 2 saying INCONCLUSIVE and must NOT say CLEARED.
  - ⚠️ THE EXIT CODES 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 says "this deploy is wrong"; 2 says
    "nobody checked". Driven: collapsing 2 into 1 now goes red on exactly that
    check.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 17:02:01 +02:00
admin d0633665c9 Merge pull request 'PKG-12: bump 0.8.0 -> 0.9.0, so the five merges behind it can actually ship' (#64) from PKG-12 into release/1.0
PKG-12: bump 0.8.0 -> 0.9.0, so the five merges behind it can actually ship
2026-09-07 00:53:44 +02:00
glenn schrooyenandClaude Opus 5 8e3734f5ea PKG-12: the live options WERE checked, and the changelog said they were not
checks / unit (pull_request) Successful in 6m17s
Review approved the bump and, in doing so, dated one of its own paragraphs.
When this section was written the pre-deploy check had not been run and the
changelog said so, correctly. The owner then exported the reference install's
options and the check cleared, which makes the shipped sentence false in the
release notes an operator reads at the hardware.

Replaced with what was measured, twice:

  stored blob as-is                          exit 0, CLEARED
  effective post-upgrade config              exit 0, CLEARED
    (the 70 stored keys merged OVER 0.9.0's
     71 config.yaml defaults)

  max_w 5000, slew_w 400, rule-10 ceiling 555.6 W.

⚠️ AND THE SECOND CHECK IS NOT CEREMONY. The stored blob has 70 keys, not 71:
`delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so a box running 0.7.0 has
never stored it, and it is REQUIRED in the schema with no trailing `?` - the
exact shape that becomes exit 78. It resolves to its `options:` default of 185
because the Supervisor sets `App.options = deepmerge(defaults, stored)`, which
`tools/supervisor_selfapi_probe.py` §D pins against a real Supervisor image.
Review re-derived that independently rather than taking the reasoning on trust.

"Your saved settings are valid" and "what the box will actually run is valid"
are different questions, and only the second one is the one being asked before
a deploy.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 00:47:11 +02:00
glenn schrooyenandClaude Opus 5 86bdf49332 PKG-12: bump 0.8.0 -> 0.9.0, so the five merges behind it can actually ship
checks / unit (pull_request) Successful in 6m14s
⚠️ MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK, AND THAT DISTINCTION IS
CHECKED RATHER THAN ASSERTED. PKG-07 was minor because a config that started on
0.7.0 could be REFUSED on 0.8.0. Nothing of that kind is true here:

  - `options:` and `schema:` hold 71 top-level keys on both 06661c5 and HEAD.
    The one schema addition is a PER-ENTRY key, `capacity_kwh: float(0.1,200)?`,
    and the trailing `?` makes it OPTIONAL.
  - An unmodified 0.8.0-era config validates clean on 0.9.0. Measured through
    the shipped validator 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.
  - OPT-09's two new refusals fire only on configs that USE the new key - an
    unparseable value, or the key stated on one entry and left empty on another.
    Neither can reach a config written before the key existed.

PKG-07's first draft justified its bump with an invented compatibility break and
review caught it. This one states what was measured, and the measurement says
existing installs upgrade untouched.

Contents since 0.8.0 (`06661c5`): UX-02 (`3d29993`), OPT-09 (`fdb3b69`),
PKG-09 (`fcaa724`).

⚠️ THE VERSION WENT UNBUMPED FOR THE THIRD TIME IN THREE DAYS - PKG-06 six
merges, PKG-07 six, this one five - and this time PKG-09's own tool caught it
during PKG-09's review rather than a person. The changelog carries OPT-09's
disclosed N=2 detection regression and points at the pre-deploy check.

⚠️ STILL NOT DONE, AND STILL NOT DOABLE FROM HERE: the live options have not
been checked against this version's validator. The gate reports exit 2 - NOT
CLEARED - rather than clearing, which is correct. Export the options from the
Supervisor and re-run before `ha apps update`.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 00:18:36 +02:00
admin fcaa72418d Merge pull request 'PKG-09: refuse a deploy that would be a no-op, or that the add-on would reject' (#63) from PKG-09 into release/1.0
PKG-09: refuse a deploy that would be a no-op, or that the add-on would reject
2026-09-07 00:04:32 +02:00
glenn schrooyenandClaude Opus 5 04b3330def PKG-09: refuse a deploy that would be a no-op, or that the add-on would reject
checks / unit (pull_request) Successful in 6m16s
Two ways a deploy of this add-on has gone wrong, and the Supervisor reports
both as success.

THE SILENT NO-OP. The Supervisor keys the built image by `version:`, so
`ha apps update` against an unchanged version installs nothing and says it
worked. PKG-06 recorded this on 2026-09-05 with six merges behind an unbumped
version; PKG-07 recorded it again on 2026-09-06 with six. Both times a human
noticed. Nothing else was going to.

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.

⚠️ IT CAUGHT A LIVE ONE ON ITS OWN PULL REQUEST. Run against release/1.0 while
this branch was in review: version 0.8.0 on both sides with UX-02's and
OPT-09's runtime commits already merged behind it. The class this exists to
catch had recurred, the same day, unnoticed - which is the argument for PKG-10,
a non-required post-merge check, since this script only helps someone who
remembers to run it.

⚠️ THE FIRST VERSION WAS BROKEN ON EVERY REAL REPOSITORY AND ITS TESTS COULD NOT
SEE IT. The "when did the version last change" lookup was `git log -S`, the
pickaxe, which finds commits that change the COUNT of a literal string. A bump
edits the line IN PLACE, so the count never moves and it returned only the
commit that first INTRODUCED the line. Measured here:

    -S  ->  0a1e61d  2026-08-23  Packaged as a Home Assistant add-on
    -G  ->  06661c5  2026-09-06  PKG-07: bump 0.7.0 -> 0.8.0

so a correctly bumped tree was reported as 87 COMMITS BEHIND AN UNBUMPED
VERSION, PKG-06's and PKG-07's own bump commits among the offenders. A tool
whose whole purpose is to be trusted cried wolf on every run.

⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER - it matches a reformat,
a quote change, a comment moved onto the line. `-G` narrows the candidates and
the VALUE is then compared against the parent's.

⚠️ THREE DISTINCT SHAPES OF HABIT 4 IN ONE FILE, each found by a different
means, which is the honest record of how this landed:
  1. Fixtures built fresh repositories whose version line is introduced once and
     never edited - the one shape in which `-S` looks correct. Found by review.
     Answered with a three-state history; the old lookup goes red on 4 checks.
  2. That three-state history still could not tell "value-compared" from
     "filtered-only": review swapped in `-G`'s newest raw hit and THE WHOLE
     SUITE STAYED GREEN, so the docstring's central claim was pinned by nothing.
     Answered with a cosmetic touch of the version line - `-G` matches it, the
     value comparison does not. That mutant now goes red on 3 checks.
  3. The counts below were claimed fixed and two of them had not landed at all.

Two git shapes it still gets wrong are recorded in the docstring rather than
fixed - a bump resolved inside a merge commit (false refusal) and a bump
reverted while the work is kept (false clearance, the dangerous direction).
Neither has ever occurred here; both were constructed in review. Card PKG-11.

⚠️ AND THE PROSE COUNTS, WHICH I REPORTED AS FIXED WHEN THEY WERE NOT. Round 1
found "PKG-06 ... four merges" (its own commit message says six) and "recorded
eleven times" (MUTATION.md:550 says TWELFTH). I replaced them without asserting
the replacement matched, said "all applied", and round 2 found both still in the
tree - one fixed in DOCS.md but not in the script, one not fixed anywhere. A
claim that a correction landed is itself a claim that needs checking, and this
is PROC-01's tenth instance.

WHAT IT DOES NOT DO. It does not read the live box: the options blob needs the
Supervisor API. Without --options the validator half SKIPS - loudly, exit 2 -
and never pretends. On a bare host `app/validate.py` needs aiohttp, so that half
skips too and names the container to re-run in. A missing or malformed --options
file is a REFUSAL with a sentence, not a traceback.

EXIT CODES: 0 cleared, 1 REFUSED, 2 NOT CLEARED. ⚠️ 2 IS NOT A PASS.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 23:57:51 +02:00
admin fdb3b69397 Merge pull request 'OPT-09: the weights parameter nothing ever passed, and what it cannot do' (#62) from OPT-09 into release/1.0
OPT-09: the weights parameter nothing ever passed, and what it costs SAFETY-42
2026-09-06 23:33:17 +02:00
glenn schrooyenandClaude Opus 5 cab267c4f3 OPT-09: the 6500 W window is a bound the ratings never let the site reach
checks / unit (pull_request) Successful in 6m10s
Round-2 non-blocking findings, both fixed.

⚠️ THE WINDOW FIGURE WAS ARITHMETIC NOBODY HAD RUN. `min_w * (w_A + w_B) / w_B`
= 6500 W is correct about `_fill()`'s PROPORTIONAL regime and is not a
reachable steady state: the healthy pack's own 5000 W rating binds first.

SWEPT on part 8b's own fixture, reproduced rather than transcribed:

    load   equal   OPT-09   site target   A      B
    1000   199/200   0/200       1080    997     83
    2000   199/200   0/200       2170   2003    167
    3000   199/200   0/200       3250   3000    250
    4000   199/200   4/200       4340   4006    334
    4500   199/200   8/200       4890   4514    376
    4900   199/200  41/200       5320   4911    409   <- largest suppressed
    5000   199/200 195/200       8000   5000   3000   <- A at its rating
    6000   199/200 195/200       8000   5000   3000

So the alarm returns BELOW the formula bound, not above it, and the largest
site target reached with the wedged unit still under the 500 W threshold is
5320 W - not 6500. The equal-weights "suppressed below 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.

The formula and its check are kept, because the mechanism they state is right
and it is the mechanism that generalises to other ratings. What was wrong was
the sentence around them, in four places, implying the site passes through the
bound. Each now carries the sweep and says the rating binds first.

⚠️ THIS NUMBER CAME FROM THE REVIEW, NOT FROM THE BRANCH, and was adopted in
good faith by the implementer. The reviewer found it in round 2 and said so.
That is worth recording: a figure arriving from a trusted reviewer got less
scrutiny than one written by the author, and PROC-01's eight fabricated numbers
in two days is now nine. The rule that catches it is the same either way - run
it before you ship it.

Also: the DECISIONS header's base sha, stale for the third time (464d398 ->
3368231 -> 3d29993, moved by two rebases and caught by review each time). It now
records that history, and the header is why `test_translations.py`'s counts are
computed rather than typed.

SAFETY-43 is now named by id where the follow-up is referenced, so a reader can
follow the thread from the disclosure to its closure.

Suite: 25 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 23:26:22 +02:00
glenn schrooyenandClaude Opus 5 8d83ef0dfb OPT-09: the weights DO move on a wedged unit, and that narrows SAFETY-42
checks / unit (pull_request) Successful in 6m11s
B1, the blocking review finding. §5 of the decision said:

    "A wedged unit reads a healthy SoC, so no envelope closes and no weight
     moves - OPT-09 cannot see it at all."

The first clause is true and is about the ENVELOPE. The second clause is
FALSE, and it was load-bearing for the claim that OPT-09 and SAFETY-42 meet
without overlapping. 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 - the ordinary case this card exists to
exploit. And delivery.delinquent() (app/delivery.py:484, threshold at :531)
names a unit only while its short_by() gap exceeds min_w = Tuning.saturation_w
= 500 W in ABSOLUTE WATTS. Shrinking the share shrinks the gap below the
threshold, and the alarm stops firing on a unit still delivering nothing.

So the seam, stated accurately: OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit. That is a DETECTION REGRESSION at
N=2. It is disclosed here, not fixed: making delinquent() relative to the ask
rather than absolute is SAFETY-42's code and a design change, filed as its own
card.

1. THE CORRECTION, in the three places that carried the claim

   * DECISIONS/OPT-09-soc-aware-weights.md §5 - rewritten. States the seam
     accurately, quotes the sentence it is replacing, carries the measured
     rows and the window arithmetic, and names the residual in the same voice
     as SAFETY-13's "2 of 272" and SAFETY-42's "the discriminator does not
     exist". Also drops "they meet and do not overlap" from the opening.
   * app/packshare.py - a new section in the module docstring, immediately
     after "weights are a comfort choice for stability and not for delivery",
     which is where the over-read starts.
   * CHANGELOG.md - a new paragraph in the SAFETY-42 entry, ending on the
     operator-facing sentence: a wedged inverter with less energy headroom
     than its sibling can now go unreported at ordinary house loads.

   test_packshare.py part 8's closing check and its "what this file cannot
   see" list repeated the same false clause; both now say the ENVELOPE cannot
   reach a wedged unit and point at part 8b for what the WEIGHTS do to it.

2. THE GRADED CHECK - test_packshare.py part 8b, 6 named checks

   The reviewer's probe, reproduced: real compute(), real plan(), real
   packshare.weights(), real delivery.account(), real delivery.delinquent();
   the only model is the plant (A obeys within one cycle inside its rating,
   B accepts its share and delivers 0 W). Unit B is WEDGED at 15 % against
   its own 10 % floor, so envelope() does not close and account() explains
   nothing - a SAFETY-42 true positive by that card's own definition, which
   a PRECONDITION check asserts before anything is concluded from it.

       load 1500 W, B wedged at 15 %, equal weights  named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 1500 W, B wedged at 15 %, OPT-09 weights named on   0/200 cycles,
         site target +1630 W, shares a+1505, b+125,  grid -5 W, named=(nobody)
       load 6000 W, B wedged at 15 %, equal weights  named on 199/200 cycles
       load 6000 W, B wedged at 15 %, OPT-09 weights named on 195/200 cycles

       suppression window: equal weights, site target < 1000 W; OPT-09
       weights, site target < 6500 W of a 9600 W site

   THE CHECKS ASSERT THE CURRENT, UNFLATTERING BEHAVIOUR ON PURPOSE - that
   the weighted arm names NOBODY where the equal arm names the wedged unit.
   A check asserting the alarm still fires would fail, and papering over it
   would be worse than the original defect. The 6000 W row is there so the
   part is not pinning "the weighted arm never names anybody": past the
   healthy pack's own rating, _fill()'s last-resort pass hands the wedged
   unit 3000 W and both arms name it. The window is real and it has an edge.

   MUTATION - packshare.weights() keeps its shape and forces every weight to
   1.0 (M11 in part 11's table, which now has 11 rows). RUN on a purged tree
   with python3 -B, not reasoned about:

       load 1500 W, B wedged at 15 %, equal weights  named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 1500 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 6000 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
         site target +4000 W, shares a+2000, b+2000, grid +4000 W, named=[b]
       suppression window: equal weights < 1000 W; OPT-09 weights < 1000 W

     FAIL  AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200 [...]
     FAIL  ...and the arithmetic behind it, stated as a window [...]
     FAIL  AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY [...]

   test_packshare.py exits 1. Restored: exits 0.

3. THREE PROC-01 PROSE DEFECTS

   * DECISIONS/OPT-09-soc-aware-weights.md:3 said "Base: release/1.0 @
     464d398". The branch was rebased; git merge-base HEAD 3368231 is
     3368231. Corrected, and it says why the old number was there.
   * Same file:13 cited main.py:2272 for "resolution = resolve(...)".
     git show 3368231:goodwe_controller/app/main.py | grep -n says 2422.
     Corrected. The sibling citation arbiter.py:535 is right and is left.
   * DOCS.md:174 said "Since 0.7.x the split is driven by two separate
     things", which tells an operator the fix already shipped. config.yaml
     is 0.8.0 and the live install is 0.7.0, where the split is 50/50 - this
     card's own premise. Now "from 0.8.0 onwards", with an explicit warning
     that 0.7.0 and earlier are a fixed 50/50.

NOT TOUCHED, deliberately: delinquent()'s absolute threshold (SAFETY-42's
code, its own card), and app/main.py:2699's double-logged scope refusal (N4,
filed separately).

INVERTER B DOES NOT EXIST (expected ~2026-09-12). Every two-unit figure above
is TWO COPIES OF A through a plant modelled in test_packshare.py; B's 4600 W /
20.48 kWh / 10 % floor are from the datasheet and the serial. Nothing here was
measured on hardware, and the added disclaimers say so in each place.

Verified with the real harness per .gitea/workflows/checks.yml
(goodwe-addon-checks:1): COLLECTED 24 files, ALLGREEN. Harness positive
control run first - test_validate.py deliberately broken, harness reported
"FAILED: test_validate.py" and exited 1; restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:55:20 +02:00
glenn schrooyenandClaude Opus 5 f8e501393f OPT-09: the weights parameter nothing ever passed, and what it cannot do
`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:

  * a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
    `bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
    pack is not asked to discharge and its share goes to the sibling;
  * SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
    `_fill()`'s last-resort pass will override the moment the sibling saturates.

Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.

Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.

SAFETY-42 and this card 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 its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.

Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.

⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:55:20 +02:00
admin 3d2999337d Merge pull request 'UX-02: the validator's refusals, next to the field, before the save' (#61) from UX-02 into release/1.0
UX-02: the validator refusals, next to the field, before the save
2026-09-06 22:45:49 +02:00
25 changed files with 4234 additions and 83 deletions
+217
View File
@@ -1,5 +1,86 @@
# 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. The
closure is card SAFETY-43.
**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.**
@@ -14,6 +95,7 @@ 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 |
⚠️ **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
@@ -50,6 +132,47 @@ already declared, and the panel was already live.
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
@@ -206,6 +329,100 @@ the bound from 10 to 13. That ceiling change also removes a degenerate setting:
the release bound, so **no accepted configuration can stop the block engaging**.
At the old bound of 9 a setting of 9 or 10 deleted it silently.
⚠️ **THE SITE TARGET IS NO LONGER SPLIT 50/50 BETWEEN TWO PACKS ([[OPT-09]]) -
AND THIS ADDS A PER-ENTRY OPTION, SO IT IS A SCHEMA CHANGE.**
`capacity_kwh: float(0.1,200)?` joins the `inverters` roster as an **optional**
per-entry key. Optional, where SAFETY-32 made `bms_soc_floor` required, and for
that card's own reason: a missing cut-out would have to be *defaulted* and one
default for two batteries protects the shallower pack with the deeper pack's
number, whereas a missing capacity has an honest answer - no opinion about the
split, every unit weighs 1.0, which is exactly what shipped. It is **all or
nothing across the roster** and rule 9 refuses a partial one. There is no flat
`capacity_kwh`: at one inverter there is no split to weight.
The allocator has had a `weights` argument since [[ALLOC-01]] and **nothing had
ever passed one** - `main.py` called `resolve()` without it and `arbiter.py`
defaulted it to 1.0 per unit - so the split was fixed 50/50 regardless of what
either pack could do. Two things now feed it, and they are not the same kind of
thing:
- **an ENVELOPE, which is a permission.** A new `pack` UNIT claim at `P_SAFETY`
closes a unit's `hi` at 0 W when its own SoC is at or under its own
`bms_soc_floor`, and its `lo` at 0 W at 100 %. The allocator obeys it
absolutely, so an empty pack is not asked to discharge and its share moves to
the sibling. ⚠️ **Not filed at all at a roster of one** - every source routes
to the site there, so it would bound the SITE TARGET, and [[SAFETY-25]]'s
Option A (no loop-side SoC floor) stands untouched on the shipped install.
- **WEIGHTS, which are a preference.** Between those limits each pack is asked
for the fraction matching its **energy** headroom in kWh - not its power
rating. The two packs are asymmetric in opposite directions (5000 W /
15.36 kWh against 4600 W / 20.48 kWh **from datasheet**), so the two ways of
dividing hand the work to different packs.
⚠️ **AND WEIGHTS ARE A COMFORT CHOICE FOR STABILITY, NOT FOR DELIVERY.**
[[ARCH-01]]'s convex combination guarantees a bad split cannot destabilise the
site; it says nothing about whether the site can regulate. Measured (two 2000 W
units, one at its floor, modelled plant, real `compute()`): at a 3000 W load the
weighted arm and the shipped arm command the **identical** +2000 W into the empty
pack and deliver the **identical** 2000 W, because `_fill()`'s last-resort pass
hands the remainder to a zero-weighted unit rather than leave watts unplaced.
Closing the envelope does not recover those watts either - it reports them:
`unmet_w` becomes +2000 W and the action log says so, where before the record
read impeccable. A unit that cannot deliver still parks the site short by its
share, and that is now visible instead of silent.
Also: `arbitrage.decide()` is sized per pack rather than at the site rating on
both sides, so a two-pack site cannot ask to buy 9600 W with one pack able to
absorb it; and a 0 W sizing is refused by name, because a 0 W arbitrage claim is
a park order that outranks the loop. ⚠️ The sell side is a **no-op on every
roster today** - the sell gate reads the emptiest pack, so it refuses outright in
exactly the states where the sum could be smaller - and moving that gate is
deliberately not this card's; `DECISIONS/OPT-09-soc-aware-weights.md` §6 says
why.
⚠️ **[[SAFETY-42]]'s end-to-end fixture moved, and its finding did not.** That
card's evidence was that a wedged unit and a pack at its floor are byte-identical
in the power domain. After this card the cold pack is no longer *asked*, so the
shares differ - but the two quantities SAFETY-42's rejected discriminator reads
(saturation persistence and |grid error|) are still inseparable on both arms, so
the conclusion is unchanged and is now stated on the predicate's own inputs.
`test_delivery.py` part 4 carries the whole argument. The two cards meet at one
hazard from opposite sides: **OPT-09 stops the site ASKING a pack whose SoC says
it cannot deliver; SAFETY-42 alarms when a pack that said it could does not.**
⚠️ **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
@@ -0,0 +1,306 @@
# 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 NARROWS SAFETY-42's DETECTION WINDOW. THIS
IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE.** 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()` (`app/delivery.py:484`)
names a unit only while its `short_by()` gap exceeds `min_w =
Tuning.saturation_w` - the comparison is `app/delivery.py:531`, and it is
**500 W in absolute watts**. 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 grades all of it and **pins the current,
unflattering behaviour**, so that the day it changes a named check moves. The
closure - `delinquent()` measured against the ask rather than a flat 500 W - is
card **SAFETY-43**.
So the seam, stated accurately: **OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit.** The two cards do not merely meet from
opposite sides; this one takes something away from the other one. Closing the gap
means making `delinquent()` relative to the ask rather than absolute, which is
SAFETY-42's code and a design change - it is filed as its own card, deliberately
not done here, and this paragraph is its input. Until then this is a **disclosed
residual**, in the same voice as SAFETY-13's "2 of 272" and SAFETY-42's own "the
discriminator does not exist": at two inverters, a wedged unit with materially
less energy headroom than its sibling can go unreported at ordinary house loads.
⚠️ **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,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.
+96 -1
View File
@@ -110,7 +110,8 @@ 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.
| | |
@@ -136,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:
@@ -161,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
@@ -229,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
@@ -1521,3 +1573,46 @@ 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 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.
+8
View File
@@ -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
+46 -1
View File
@@ -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,
+15
View File
@@ -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 "
+55 -3
View File
@@ -192,8 +192,8 @@ class Inverter:
def __init__(self, name, *, setpoint_entity="", soc_entity="",
batt_entity="", batt_invert=False, max_w=2000.0,
bms_soc_floor=None, store=None, sign_cfg=None,
delivery_cfg=None):
bms_soc_floor=None, capacity_kwh=None, store=None,
sign_cfg=None, delivery_cfg=None):
self.name = str(name)
self.setpoint_entity = str(setpoint_entity or "")
self.soc_entity = str(soc_entity or "")
@@ -215,6 +215,20 @@ class Inverter:
# the config by name long before that, at startup.
self.bms_soc_floor = (None if bms_soc_floor is None
else float(bms_soc_floor))
# OPT-09. THIS PACK'S USABLE CAPACITY IN kWh, or None when the roster
# entry did not state one.
#
# ⚠️ OPTIONAL WHERE `bms_soc_floor` IS REQUIRED, AND THE ASYMMETRY IS
# THE POINT RATHER THAN AN INCONSISTENCY. A missing cut-out would have
# to be defaulted, and one default for two batteries protects the
# shallower pack with the deeper pack's number - SAFETY-32's whole card.
# A missing capacity has an honest answer that costs nothing: this pack
# states no energy headroom, so `packshare.weights()` has NO OPINION
# about the split and every unit weighs 1.0, which is exactly the
# behaviour that shipped. None, never 0.0: a zero-capacity pack is one
# that may never be used, and nobody decides that by omitting a key.
self.capacity_kwh = (None if capacity_kwh is None
else float(capacity_kwh))
# --- the device's own reported range, read from its own entity -------
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
@@ -482,7 +496,9 @@ def build_roster(opts: dict, store, sign_cfg=None,
it - see the options-block note on `inverters:`.
NON-EMPTY each entry describes one inverter and the flat keys describe
none of them. ⚠️ NOTHING IS INHERITED. An entry that omits
none of them. OPT-09's `capacity_kwh` is an entry key too, and an
OPTIONAL one - see `_entry_capacity_kwh()` and `Inverter.__init__`
on why that absence is answerable where a missing cut-out is not. ⚠️ NOTHING IS INHERITED. An entry that omits
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
because "this unit's number came from somewhere else" is the
defect DEV-02 spent a card removing and a fallback here would
@@ -519,6 +535,13 @@ def build_roster(opts: dict, store, sign_cfg=None,
# mirror exactly as validate.py rule 8 defaults it, so an install
# that never set the key reads the same 10 % in both places.
bms_soc_floor=_flat_bms_floor(opts),
# ⚠️ OPT-09 ADDS NO FLAT `capacity_kwh`, AND THE ABSENCE IS THE
# DECISION. Capacity is read for ONE purpose - weighting a split
# between two packs - and at the single-inverter shape there is no
# split: `allocator.plan()` is a pass-through at N<=1 and a weight
# is arithmetic nobody reads. A flat key would be an option an
# operator can set, that the add-on reads, and that changes nothing,
# which is the worst of the three.
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)]
roster = []
@@ -546,6 +569,11 @@ def build_roster(opts: dict, store, sign_cfg=None,
# SAFETY-32's own defect written in three lines - the shallower pack
# graded against the deeper pack's BMS.
bms_soc_floor=_entry_bms_floor(entry),
# OPT-09, and NOT inherited either, for the reason above it: a
# capacity borrowed from a sibling would weight a 15.36 kWh pack as
# though it were a 20.48 kWh one, which is the split this card
# exists to stop being wrong.
capacity_kwh=_entry_capacity_kwh(entry),
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg))
return roster
@@ -617,6 +645,30 @@ def _flat_max_w(opts: dict) -> float:
except (TypeError, ValueError):
return 0.0
def _entry_capacity_kwh(entry: dict):
"""One entry's OWN usable capacity in kWh, or None when it stated none.
None rather than 0.0 and rather than a borrowed number: `_entry_max_w()` can
answer 0.0 because a zero rating commands nothing and rule 9 refuses it by
name, but a zero CAPACITY is a pack with no energy headroom in either
direction, i.e. a pack the weighting would silently stop preferring. "Not
stated" and "empty" must not be the same answer - `packshare.weights()`
turns the first into "no opinion at all" and the second into a weight of
zero, and those are different splits.
A NON-POSITIVE or unparseable value also answers None. Rule 9 refuses it at
startup with a message; until it does, withholding is the direction that
cannot invent a preference.
"""
raw = entry.get("capacity_kwh")
if raw is None or raw == "":
return None
try:
val = float(raw)
except (TypeError, ValueError):
return None
return val if val > 0.0 else None
def _entry_max_w(entry: dict) -> float:
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
+197 -2
View File
@@ -65,6 +65,7 @@ from .maintenance import (
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
unit_state_key)
from . import peakguard
from . import packshare
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
from .static_tariff import resolve_tariff, static_status
@@ -145,6 +146,23 @@ def load_options() -> dict:
return {}
def pack_energy_of(inv):
"""OPT-09. ONE roster entry as the view `packshare` consumes.
⚠️ ONE BUILDER, TWO CALLERS, AND THAT IS THE POINT. `Controller.pack_energy`
needs the whole roster (for the weights and the trade sizing) and
`Controller.cycle()` needs one entry at a time (for that unit's `pack`
claim). Written out twice, the two would drift - and the direction they
would drift in is a unit whose envelope is computed from a different set of
numbers than its weight, which is precisely the class of defect DEV-02 spent
a card removing. It is a module function rather than a method on `Inverter`
so that `inverter.py` does not have to import `packshare`.
"""
return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor,
inv.capacity_kwh, inv.max_w)
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db"):
@@ -628,6 +646,12 @@ class Controller:
# edge-triggered. Starts as the empty tuple - nothing has been dropped
# yet and nothing has been said about it.
self._dropped_last = ()
# OPT-09. The last cycle's `unmet_w`, so the line that reports it is
# edge-triggered for `_dropped_last`'s reason: a site that cannot place
# every watt stays that way for as long as the pack is empty, and a line
# a second would flush the 40-row action log of the context explaining
# it. 0 is "every watt was placed", which is the state at boot.
self._unmet_last = 0
# ⚠️ THE SITE MEASUREMENT, AND THE ONLY ONE LEFT ON THIS OBJECT. `soc`
# and `batt` were per-device scalars and now live on each `Inverter`;
# `grid` is P1, one meter, one connection, and belongs here.
@@ -1015,6 +1039,55 @@ class Controller:
"""
return float(sum(inv.max_w for inv in self.inverters))
def trade_power_w(self, floor_pct, ceiling_pct) -> tuple:
"""OPT-09. `(charge_w, discharge_w)` - how hard a trade may be SIZED.
The site rating is what the site can push; this is what the site can
push AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` used to be
handed `site_max_w` on both sides, so on a roster of two the trade was
sized at the sum of the ratings whatever either pack's state of charge
was - the site would buy at 9600 W with only one pack able to absorb it,
and the watts that could not be placed would come back as `unmet_w` for
the site integrator to chase for the rest of the hour.
⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` /
`arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, because SIZING
A TRADE IS THE TRADE'S BUSINESS. This method answers "how much may this
strategy ask for"; the ENVELOPE - `packshare.envelope()`, filed as a
`pack` claim - answers "what may this pack be commanded at all", and
that one is the pack's own physical cut-out for the reason SAFETY-25
gives. Sizing with the physical floor would let the strategy ask for
watts its own reserve forbids; bounding the envelope with the trade's
reserve would let a trade preference stop the grid-following loop.
THEY ARE DIFFERENT QUESTIONS AND THEY GET DIFFERENT NUMBERS.
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack ends up
carrying the trade is the allocator's answer, and the only per-pack
instrument it has for a TRADE reserve is the weights - which are a
preference, not a permission (`allocator._fill()` will hand watts to a
zero-weighted unit rather than leave them unmet). So a pack between its
arbitrage floor and its BMS cut-out can still take part of a sell. That
is stated rather than fixed: fixing it needs a per-pack envelope that
depends on WHICH claim won the tick, which the arbiter's partition does
not express, and is a card of its own.
A pack whose SoC did not read is counted on NEITHER side. It is not
reachable with control running - `inputs_ok` stops the site when any
pack's SoC is missing - and withholding is the direction that cannot
over-size.
⚠️ NEITHER SIDE CAN BE ZERO ON A TICK THAT ACTUALLY TRADES, and it is
not left to that coincidence: `arbitrage.decide()` refuses a 0 W sizing
by name (`IDLE_NO_SIZE`), because a 0 W arbitrage claim is a park order
that outranks the loop.
"""
# ⚠️ THE REDUCTION ITSELF LIVES IN `packshare`, NOT HERE, and this
# method is the roster view it is applied to. A transcription of the
# arithmetic into a check is one copy plus a future disagreement about
# what it means, and the copy that drifts is always the grading one -
# so `test_packshare.py` part 9 calls the SAME function this does.
return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct)
@property
def site_rating_w(self) -> float:
"""The largest magnitude the SITE can actually be commanded, in W.
@@ -1132,6 +1205,44 @@ class Controller:
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
for inv in self.inverters]
@property
def pack_energy(self) -> list:
"""OPT-09. Each pack as the FOUR numbers a split needs, in roster order.
The fourth is `capacity_kwh`, and it is why this is not `packs` above.
That list answers the FLOOR question - may this pack be spent - and its
consumers (`pack_permissions`, `binding_soc_floor`) have no use for a
capacity and would have to default one. This list answers the SPLIT
question - which pack should carry more of the site target - and cannot
be asked without one.
⚠️ EVERY unit, live or not, for `packs`'s reason. The allocator drops
what `live_units()` does not list and `plan()` ignores a weight for a
unit it dropped, so filtering here would only move the same decision to
a worse place - and `packshare.weights()` needs to see a pack that did
not answer in order to withhold its opinion about the whole roster.
"""
return [pack_energy_of(inv) for inv in self.inverters]
def pack_weights(self, target_w):
"""OPT-09. The comfort weights for a site target of `target_w`.
⚠️ IT IS A METHOD HANDED TO `resolve()`, NOT A DICT COMPUTED BEFORE IT,
and that is not indirection for its own sake. An energy-headroom
weighting is direction-dependent - room above the floor when the site is
discharging, room below full when it is charging - and the two give
opposite answers on the same roster. The direction is the SIGN OF THE
SITE TARGET, which is stage 2's output, so nothing here can know it
until `resolve()` has run stage 2. `arbiter.resolve()` calls this once,
between stage 2 and stage 3, with the number it just chose.
⚠️ AND IT IS READ FRESH ON EVERY CALL, NEVER CACHED, for the reason the
allocator refuses to cache an envelope: a pack's SoC moves for reasons
that have nothing to do with the allocator, and a weighting held from
construction prefers a pack that has since emptied.
"""
return packshare.weights(self.pack_energy, target_w)
@property
def inputs_ok(self) -> bool:
"""The site meter, and EVERY pack's own two readings.
@@ -1930,6 +2041,39 @@ class Controller:
# setpoint entity (§11.3), not a bound on the grid connection.
claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w",
actuator=self.actuator_of("supervised", inv)))
# --- OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS -------------
# ⚠️ THE POINT OF THE CARD, AND IT IS AN ENVELOPE RATHER THAN A
# WEIGHT. Weighting an empty pack down does NOT stop it being
# commanded: `allocator._fill()` runs a deliberate last-resort pass
# over the zero-weight units when the preferred ones saturate, so
# that a preference can never manufacture a standing error at the
# meter. Only a closed envelope stops the ask, and `cap` is obeyed
# absolutely. So a pack at or under its own BMS cut-out gets
# `hi = 0` and a pack at 100 % gets `lo = 0`, and the site target
# goes to the sibling instead of being asked of a battery that
# cannot answer it.
#
# ⚠️ AT A ROSTER OF ONE THIS IS NEVER FILED, AND THAT IS THE SAME
# IDENTITY `sign_site` IS GUARDED BY RATHER THAN A SPECIAL CASE.
# `actuator_for()` routes every source to the SITE at N=1 - with one
# inverter the fabricated grid reading and the battery setpoint are
# the same number - so this interval would bound THE SITE TARGET
# there, not a share. That is a behaviour change on the live
# single-inverter install and it is the shape SAFETY-25 decided
# against on 2026-09-02 (Option A): ordinary self-consumption is not
# given an SoC floor by this card. There is also nothing for it to
# do at N=1 - no sibling to move the share to - so the whole of AC 4
# ("at N=1 allocation is byte-identical to today") is this `if`.
#
# WHAT IT COSTS, SAID PLAINLY: at N=1 nothing here stops the loop
# discharging into the BMS cut-out. That is unchanged, it is
# SAFETY-25's deferred Option B, and the BMS itself is what stops it.
if n_units > 1:
_pe = packshare.envelope(pack_energy_of(inv))
if _pe.binding:
claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi,
_pe.why,
actuator=self.actuator_of("pack", inv)))
# The SITE bound, for the site-level consumers below. It is the roster
# sum: what this site can deliver, not what one of its inverters can.
max_w = self.site_max_w
@@ -2202,6 +2346,7 @@ class Controller:
self.arb = None
if arbitrage.enabled(self.o):
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
self.arb = arbitrage.decide(
self.fresh_tariff_plan(now), now,
peak_guard_active=arbitrage.guard_state(self.peak_guard),
@@ -2214,7 +2359,22 @@ class Controller:
soc=self.pack_floor_soc,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
charge_w=max_w, discharge_w=max_w,
# OPT-09. PER-PACK SIZING. Not `max_w` - the site rating - on
# both sides any more: the trade is sized to the packs whose own
# state of charge permits that direction, so it can never ask
# for more than the roster can take. `trade_power_w()` carries
# the argument, including which of the two SoC bounds belongs
# here and which belongs on the envelope. ⚠️ AT N=1 BOTH SIDES
# ARE `max_w` EXACTLY, whenever the trade is permitted at all:
# the one pack that passed the gate is the whole roster.
# ⚠️ AND ON THE SELL SIDE THIS IS A NO-OP TODAY on any roster,
# because `soc=` below is the EMPTIEST pack, so a sell is
# refused outright whenever any pack is at or under the floor -
# i.e. whenever this sum could be smaller than the site rating.
# It becomes reachable the day that gate becomes per-pack;
# `test_packshare.py` part 9 grades it directly rather than
# letting an unreachable branch go ungraded.
charge_w=charge_w, discharge_w=discharge_w,
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
# SITE, and arbitrage.claim() says so on the object it builds. It
# buys and sells at the grid against a day-ahead price: the price is
@@ -2419,8 +2579,24 @@ class Controller:
# stale from this line onwards. Incremented BEFORE resolve() so that the
# record resolve() builds is stamped with the tick it is for.
self.alloc_tick += 1
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. ARCH-01 left the
# allocator one free parameter and ALLOC-01 built the machinery that
# consumes it (`allocator._fill`); this line is the first thing that has
# ever passed one. Without it every unit weighed 1.0 and the split was
# a fixed 50/50 regardless of what either pack could do.
#
# ⚠️ A METHOD, NOT A DICT. The weighting is by ENERGY HEADROOM and is
# therefore direction-dependent, and the direction is the sign of the
# site target - which stage 2 chooses inside `resolve()`. See
# `pack_weights()` and `packshare.weights()`; the arbiter calls it once,
# between stage 2 and stage 3.
#
# ⚠️ AND IT IS A PREFERENCE, NOT A PERMISSION. Nothing about the site's
# ability to DELIVER rests on this argument - a weight cannot stop a
# unit being commanded and cannot move a failing unit's load to its
# sibling. The `pack` claim filed above is the half that constrains.
resolution = resolve(claims, units=self.units, live=self.live_units(),
tick=self.alloc_tick)
weights=self.pack_weights, tick=self.alloc_tick)
self.allocation = resolution.allocation
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
@@ -2514,6 +2690,25 @@ class Controller:
if self._dropped_last and not _dropped:
self.log_event("ALLOCATOR: every inverter is back in the split")
self._dropped_last = _dropped
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
# `unmet_w` is the site target the envelopes could not absorb. It is
# NOT a broken allocation - `Allocation`'s own docstring says why
# fusing it with `ok` would let one saturated inverter park the whole
# site - but it IS the site failing to deliver what the loop asked
# for, and before this card nothing anywhere reported it. It is the
# state a closed envelope produces when BOTH packs are at their
# limits: the ask has nowhere left to go, and an operator watching a
# standing grid error needs the reason rather than the symptom.
# Edge-triggered on the VALUE, for `_dropped` reason.
_unmet = int(self.allocation.unmet_w if self.allocation else 0)
if _unmet != self._unmet_last:
if _unmet:
self.log_event(
f"ALLOCATOR: {_unmet:+d} W of the site target could not be "
f"placed on any inverter - {self.allocation.explain()}")
else:
self.log_event("ALLOCATOR: the whole site target fits again")
self._unmet_last = _unmet
if resolution.scope_errors:
# A claim reached the arbiter on an actuator its source's scope does
# not permit. It is a BUG in the filing site, not a state to survive:
+451
View File
@@ -0,0 +1,451 @@
"""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 IS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROW SAFETY-42's
DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2
=======================================================================
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()` names a unit only while its `short_by()` gap exceeds
`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 % floor, so `envelope()` does not close and `account()` explains
nothing): at a 1500 W load the healthy pack can carry alone, equal weights name
the wedged unit on 199 of 200 cycles and these weights name it on NONE. The
suppression window widens 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 has
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 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.
⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK
**AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That
is a disclosed residual, not a fixed thing. Closing it means making
`delinquent()` relative to the ask rather than absolute - SAFETY-42's code
and a design change, filed as its own card and deliberately NOT done here.
Do not "fix" part 8b by making it assert the alarm still fires; it does not,
and a green check that says otherwise would be worse than the gap.
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)))
+49 -1
View File
@@ -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(
+27 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.8.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
@@ -621,6 +640,12 @@ schema:
# enforced per entry, a missing required key names the key, a scalar where the
# list belongs is refused, and the UI renders it as a repeatable section.
#
# ⚠️ AND OPT-09's `capacity_kwh` IS DECLARED OPTIONAL (`?`), UNLIKE
# `bms_soc_floor`, WHICH IS THE ARGUMENT IN THE OPTIONS BLOCK ABOVE: an
# optional cut-out is a defaulted one and a default is one number for two
# batteries, whereas an absent capacity has an honest answer - no opinion
# about the split, every unit weighs 1.0, which is what shipped.
#
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
# no `name` has no Store namespace and would share another unit's SAFETY-02
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
@@ -642,6 +667,7 @@ schema:
soc_entity: str?
batt_entity: str?
batt_invert: bool?
capacity_kwh: float(0.1,200)?
estop_fitted: bool
+13 -4
View File
@@ -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}
+372
View File
@@ -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")
+104 -36
View File
@@ -475,13 +475,42 @@ def _power_domain(rows):
_pw, _pf = _power_domain(_sw), _power_domain(_sf)
check(f"THE TWO ARMS ARE BYTE-IDENTICAL IN THE POWER DOMAIN on all "
f"{len(_pw)} cycles - grid, site target, every share, every measured "
f"watt. No threshold on any of those numbers, at any N and any X, can "
f"separate a wedged unit from a pack at its floor, and the card's own "
f"proposed discriminator reads only those numbers",
len(_pw) == len(_pf) and _pw == _pf)
check("...and the ONLY thing that differs is the state of charge",
# ⚠️ OPT-09 CHANGED WHAT THIS PAIR OF ARMS LOOKS LIKE, AND THE CARD'S FINDING
# SURVIVES IT INTACT. UNTIL OPT-09 the two traces were byte-identical in every
# power number - the cold pack was ASKED for the same +2000 W the wedged one was
# asked for, and both produced 0 W - and that identity was this file's headline.
# OPT-09 closes a pack's `hi` at 0 W once its own state of charge is at or under
# its own `bms_soc_floor`, so the cold arm's B is now commanded 0 W and the two
# arms' SHARES differ. ⚠️ THAT IS A CHANGE IN WHAT THE SITE ASKS FOR, NOT IN
# WHAT ANY DETECTOR CAN SEE, and the card's argument is about the latter: the
# proposed discriminator reads SATURATION-PERSISTENCE and |GRID ERROR|, and on
# BOTH arms the loop is saturation-frozen on every cycle while ~1000 W stands at
# the meter. No (N, X) separates them, exactly as measured on the rig - so the
# claim is now stated on the two quantities the predicate actually reads rather
# than on the whole trace, which is the stronger form of the same sentence.
_ERR_W = 900.0
_frozen = [[r["reason"] for r in rows] for rows in (_sw, _sf)]
_settled = [[r for r in rows if abs(r["grid"]) > _ERR_W] for rows in (_sw, _sf)]
print(f" the two quantities the card's predicate reads, last 8 cycles: "
f"wedged grid {[round(r['grid']) for r in _sw[-8:]]}, "
f"cold grid {[round(r['grid']) for r in _sf[-8:]]}")
check(f"THE TWO ARMS ARE INSEPARABLE IN THE TWO QUANTITIES THE CARD'S PROPOSED "
f"DISCRIMINATOR READS. On both, the loop reports the SAME reason on the "
f"last {len(_sw[-8:])} cycles and |grid error| stands over "
f"{_ERR_W:.0f} W on every one of them. No threshold on saturation "
f"persistence and no threshold on grid error, at any N and any X, can "
f"separate a wedged unit from a pack at its floor",
[r["reason"] for r in _sw[-8:]] == [r["reason"] for r in _sf[-8:]]
and len(_settled[0]) >= 8 and len(_settled[1]) >= 8)
check("...and the SHARES now differ, which is OPT-09 and not a weakening of the "
"line above: the cold pack is commanded 0 W because its own SoC closed "
"its own envelope, while the wedged pack - whose SoC is healthy - is "
"still asked for its full share and still delivers nothing. The two "
"cards meet here from opposite sides: OPT-09 stops the ASK, this file "
"grades the ALARM, and neither can do the other's half",
_pw != _pf
and _sf[-1]["shares"][1] == 0.0 and _sw[-1]["shares"][1] == 2000.0)
check("...and the state of charge is still what tells them apart",
[r["soc"] for r in _sw] != [r["soc"] for r in _sf])
print(f" both arms, last cycle: grid {_sw[-1]['grid']:.0f} W, shares "
f"{tuple(round(v) for v in _sw[-1]['shares'])}, measured "
@@ -490,27 +519,48 @@ print(f" SoC: wedged arm {_sw[-1]['soc']} cold arm {_sf[-1]['soc']}")
check("AC 1: the WEDGED unit is NAMED, and only it",
_sw[-1]["named"] == ("inverter-b",))
check("AC 2: the COLD PACK AT ITS FLOOR IS NOT - on the identical trace",
check("AC 2: the COLD PACK AT ITS FLOOR IS NOT",
_sf[-1]["named"] == ())
print(f" {_cw.inverters[1].delivery_wd.reason}")
# ⚠️ THE VACUOUS-PASS GUARD, AND IT IS NOT DECORATION. MUTATION.md's third
# failure mode is a check whose data cannot reach the failure it names: "the
# cold arm named nobody" reads exactly the same against a fixture where nothing
# was short, nothing was asked, or the loop never ran. So the cold arm is
# asserted to have REACHED the branch and been turned away by the account, not
# to have missed it.
# was short, nothing was asked, or the loop never ran.
#
# ⚠️ AND OPT-09 MOVED WHICH OF THOSE IS TRUE, WHICH IS WORTH MORE THAN THE
# CHECK IT REPLACES. Until OPT-09 the cold pack WAS asked for its full share,
# was short of it, and was turned away by `account()` alone - the account was the
# only thing standing between an empty battery and an alarm. It is not any more:
# the pack's own envelope is closed, so it is never asked, `short_by()` answers
# None on a zero share, and AC 2 is delivered STRUCTURALLY - by the site not
# asking - rather than by a discriminator applied after the fact.
#
# BOTH HALVES ARE ASSERTED, because the second is the one that survives OPT-09
# being reverted or a pack whose numbers are absent: `account()` STILL turns this
# pack away when the ask IS present, and that is asked of it directly here on
# this arm's own measured SoC and configured cut-out. Parts 2 and 3 grade the
# predicate over its whole input space; this line pins it to THIS fixture's
# numbers so the end-to-end arm cannot pass for a reason unrelated to the pack.
_cold_b = _cf.inverters[1]
check("...and the cold arm REACHED that decision rather than falling short of "
"it: B was asked for a discharge share, was short of it by more than "
"saturation_w, and was turned away by `account()` alone",
float(_cold_b.standing_w()) > 0
and short_by(_cold_b.standing_w(), _cold_b.batt) > _cf.tuning.saturation_w
and account(live=True, parked=False, soc=_cold_b.soc,
bms_floor=_cold_b.bms_soc_floor) is not None)
check("...and the WEDGED arm's B differs from it in exactly that one place",
check("...and the cold arm reaches AC 2 the way OPT-09 makes it reach it: B's "
"own state of charge closed B's own envelope, so B is commanded 0 W and "
"`short_by()` has no discharge share to be short of. The ask never "
"arrives, which is a better answer than an alarm suppressed after it does",
float(_cold_b.standing_w()) == 0.0
and short_by(_cold_b.standing_w(), _cold_b.batt) is None)
check("...and `account()` STILL turns this pack away if the ask does arrive - "
"asked directly, on this arm's own measured SoC and its own configured "
"cut-out. That is the half that must not depend on OPT-09: an unreadable "
"SoC or an unstated floor is a case OPT-09 deliberately does NOT close on, "
"and the account is what keeps those quiet",
account(live=True, parked=False, soc=_cold_b.soc,
bms_floor=_cold_b.bms_soc_floor) is not None)
check("...and the WEDGED arm's B differs from it in exactly that one place - "
"which is why it is still asked for its share, and still named",
account(live=True, parked=False, soc=_cw.inverters[1].soc,
bms_floor=_cw.inverters[1].bms_soc_floor) is None)
bms_floor=_cw.inverters[1].bms_soc_floor) is None
and float(_cw.inverters[1].standing_w()) > 0)
# ⚠️ WHICH FLOOR, AND THIS IS A WIRING CHECK THAT THE PURE ONES ABOVE CANNOT
# MAKE. `peakguard.pack_floor_pct()` conjoins a pack's own cut-out with the
@@ -694,11 +744,17 @@ check("...and each arm is a config validate.py rule 8 accepts: its "
"peak_soc_floor is above EVERY unit's own cut-out",
all(float(c.o.get("peak_soc_floor")) > float(i.bms_soc_floor)
for c in (_c_deep, _c_shal) for i in c.inverters))
check("...and the two arms are IDENTICAL IN THE POWER DOMAIN on every cycle - "
"grid, site target, every share, every measured watt. The configured "
"cut-out is the only thing that differs, which is part 4's claim made "
"again at two cut-outs that are not the shipped one",
_power_domain(_r_deep) == _power_domain(_r_shal))
check("...and the two arms are INSEPARABLE IN THE QUANTITIES THE CARD'S "
"PROPOSED DISCRIMINATOR READS - the same loop reason and over 900 W of "
"grid error on every one of the last eight cycles - which is part 4's "
"claim made again at two cut-outs that are not the shipped one. ⚠️ THEIR "
"SHARES DIFFER, because OPT-09 closes the DEEP arm's envelope (15 % "
"against a 25 % cut-out) and leaves the SHALLOW arm's open (8 % against "
"5 %); that is the site declining to ask, not a detector telling them "
"apart, and it is why the pin below still needs the pack's OWN number",
[r["reason"] for r in _r_deep[-8:]] == [r["reason"] for r in _r_shal[-8:]]
and all(abs(r["grid"]) > 900.0
for rows in (_r_deep, _r_shal) for r in rows[-8:]))
check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is "
f"cold, and is NOT named - even though it is far above the "
@@ -706,11 +762,14 @@ check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is "
f"against, which would have called an empty pack a fault",
_r_deep[-1]["named"] == () and float(_bd.bms_soc_floor) == _FLOOR_DEEP
and _bd.soc == _DEEP_SOC)
check("...and it REACHED that decision: it was asked for a discharge share, "
"was short of it by more than saturation_w, and was turned away by "
"`account()` alone",
float(_bd.standing_w()) > 0
and short_by(_bd.standing_w(), _bd.batt) > _c_deep.tuning.saturation_w
check("...and it reaches that decision the way OPT-09 makes it reach it - the "
"pack's own 25 % cut-out closed its own envelope, so it is commanded 0 W "
"and has no discharge share to be short of - while `account()`, asked "
"directly on the same two numbers, STILL turns it away. Both halves, for "
"the reason part 4 gives: the second is what survives OPT-09 being "
"reverted, and it is what covers the absences OPT-09 will not close on",
float(_bd.standing_w()) == 0.0
and short_by(_bd.standing_w(), _bd.batt) is None
and account(live=True, parked=False, soc=_bd.soc,
bms_floor=_bd.bms_soc_floor) is not None)
check(f"a pack at {_SHAL_SOC:.0f} % against its OWN {_FLOOR_SHALLOW} % cut-out "
@@ -801,12 +860,21 @@ check("the saturation freeze: FIRES - and that is the point. It is the one "
print(f" saturation counter {_cw.sat_count} (freezes at "
f"{_cw.tuning.saturation_cycles}); site commanded "
f"{_cw.target:.0f} W against {_cw.site_batt_w:.0f} W measured")
check("...and it fires IDENTICALLY on the benign arm, on the identical trace, "
"at the same counter. A saturation-shaped alarm cannot tell these two "
"apart at any threshold and any duration",
_cf.sat_count == _cw.sat_count
and _cf.site_batt_w == _cw.site_batt_w
and _cf.target == _cw.target)
print(f" ...and on the benign arm: counter {_cf.sat_count}, site "
f"commanded {_cf.target:.0f} W against {_cf.site_batt_w:.0f} W measured")
check("...and it fires ON THE BENIGN ARM TOO, from the same site numbers. The "
"site is commanded the same watts against the same measured watts and is "
"frozen on both, many times over the configured cycle count. ⚠️ THE "
"COUNTERS DIFFER BY ONE CYCLE SINCE OPT-09, and that rescues nothing: the "
"cold arm reaches its steady state one cycle sooner because its healthy "
"sibling gets the whole share immediately instead of half of it. A "
"duration threshold that both arms exceed by an order of magnitude "
"cannot be set between them, which is the claim - and the two settled "
"states are identical to the watt",
_cf.site_batt_w == _cw.site_batt_w
and _cf.target == _cw.target
and min(_cf.sat_count, _cw.sat_count) >= _cw.tuning.saturation_cycles
and abs(_cf.sat_count - _cw.sat_count) <= 1)
print()
print(" ...and a unit the ALLOCATOR HAS ALREADY DROPPED is not reported twice")
+12 -3
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+78 -27
View File
@@ -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 ---
+11 -4
View File
@@ -74,7 +74,7 @@ check(f"the option surface was actually read - {len(ORDER)} options, not zero",
check("...and the repeatable `inverters:` section's per-entry keys were read "
"too, which is the half a top-level-only reader would miss",
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
and len(NESTED["inverters"]) == 7)
and len(NESTED["inverters"]) == 8)
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
"languages")
@@ -248,10 +248,17 @@ for what, text in BAD:
check(f"every shape outside the accepted subset RAISES rather than being "
f"skipped. Silently accepted: {_accepted}", not _accepted)
check("...and the accepted subset really does round-trip the shipped files - "
"71 options and 7 per-entry keys came back out of each, which is what "
"says the strictness above is not just refusing everything",
# ⚠️ COUNTED, NOT TYPED. UX-02 corrected this sentence's option count
# 70 -> 71 while OPT-09 independently added an eighth per-entry key
# (`capacity_kwh`), and the two edits collided here on the rebase - each
# restating a number the other had moved. The counts now come from the
# same parse the assertion uses, so the sentence cannot drift from it
# again. PROC-01.
f"{len(ORDER)} options and {sum(len(v) for v in NESTED.values())} "
f"per-entry keys came back out of each, which is what says the "
f"strictness above is not just refusing everything",
all(len(TR[lg]["configuration"]) == len(ORDER)
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 8
for lg in LANGS))
print(" ⚠️ the licence for this hand parser is NOT this file. "
"tools/supervisor_shape_probe.py section F loads the same two files with "
+47
View File
@@ -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()
+282
View File
@@ -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")
+306
View File
@@ -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())
@@ -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
View File
@@ -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."
+3
View File
@@ -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."