Compare commits

...
9 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 b6c6da31d8 TEL-10: two sentences that named assertions this file does not make
checks / unit (pull_request) Successful in 7m57s
Both are review findings on the merged-in-spirit branch, and neither is in
executable code.

R6 - test_mqtt.py part 1's headline said "every one of release/1.0's NINE
messages is still byte-identical and still first". The assertion under it is
`announce(...)[:len(BASELINE) - 1] == BASELINE[:-1]`, which covers EIGHT: the
ninth, `goodwe_ctl/availability`, is byte-identical but deliberately moves to
last, so a prefix comparison cannot include it. Nothing was unguarded - the
`== SITE_NOW` on the next check pins it in its new position - but the sentence
was not the check. Part 3 carried the same mis-statement two lines from a check
that IS about all nine, and is corrected the same way.

R5 - M7's red count was decoration. Re-driven in the CI image on a `cp -a` of
the tree, purged, `:ro`, one file at a time, it is 10 reds in test_mqtt.py and
not 8, on this tree and on SAFETY-48's alike. The direction was right; the
number was inherited rather than re-measured, which is MUTATION.md's reporting
rule 3. A note beside the table now says that a red count is a property of the
SUITE as much as of the mutation and therefore has to name a tree.

Verified: the full suite in the CI image, purged tree, `:ro` mount, one file at
a time, exactly the command in .gitea/workflows/checks.yml - 30/30, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 02:36:23 +02:00
glenn schrooyenandClaude Opus 5 b548525d10 TEL-10: the loop's integrator stops being invisible
`self.i_w` has been a separate quantity from the command since SAFETY-04 and
has left the process nowhere at all - no entity, no MQTT attribute, no log
line. The investigation behind SAFETY-48 could only RECONSTRUCT it from the
grid trace plus a replay; the reconstruction reproduced about a third of the
engagements it was checked against and was discarded, so the card it was
gathered for had to be argued from a measurement nobody could stand behind.
It could not have been made to work either: `i_w` depends on every cycle
before it, including the ones where the arbiter overrode the loop and
`main.py` re-seeded `self.i_w = self.target`, and that re-seed is invisible in
the grid trace.

THREE SITE ROWS, APPENDED AFTER release/1.0's EIGHT. `loop_i` is
`Decision.i_w` - the accumulator after the cycle, before the output clamp, the
slew limit and quantisation. `loop_lead` is `decision.i_w - prev_w` against the
command `compute()` was HANDED, which is not a subtraction of the other two
published rows: `Setpoint` is the arbiter's output and is this cycle's, so on
any cycle the arbiter overrode the loop the two differ - and that is exactly
the cycle on which the integrator was re-seeded. `loop_reason` is
`compute()`'s own word; the arbiter's `explain()` prints the winning claim's
NAME and has never carried the claim's reason, so `slew-limited` appears in
neither the `Why` row nor `Controller status`. Measured on the same cycle:
arbiter `'loop -> -5000 W'`, loop `'i-clamped'`.

NOTHING IN THE CONTROL PATH MOVED. `app/control.py` is not in this diff.
`compute()`, its returned `Decision` and every constant it reads are identical
to release/1.0, and all nine of release/1.0's retained discovery messages are
byte-identical and still FIRST in the announcement.

Evidence: 30/30 suites green in the CI image. Nine mutations, ONE survivor,
reported as one: `decision.i_w - self.target` for the lead is +2 B and green
because `cycle()` has not reassigned `self.target` at that line - the name is
kept for the coupling, not for a behaviour it buys, and M2b (+8 B) is the
mutation that does change the value and is red.

AND TWO OF THIS CARD'S OWN CHECKS COULD NOT REACH THE FAILURE THEY NAMED.
"the published integrator reaches its bound" was GREEN under the card's named
mutation (publish `prev_w`, -3 B), because the command reaches the bound too -
twelve cycles later. It asserts the LEAD TIME now and is red on 5. "a stopped
controller publishes not-running" was GREEN under deleting that branch's
assignments (-45 B, -67 B), because the fixture STARTED stopped and `__init__`
writes the same values; it stops the controller part-way through a run that
has already wound the integrator to -5000 W. Both are MUTATION.md habit 4,
found by the battery rather than by review.

No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## `
heading that is not config.yaml's `version:`, and opening one would mean
taking a packaging decision this card does not get to take. The operator
account is in DOCS.md and the release note belongs to the next PKG ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-18 23:36:23 +02:00
admin 68c5ede7b9 Merge pull request 'PKG-14: cut 0.10.0, so fifteen merged tickets can reach an install' (#91) from PKG-14 into release/1.0
config.yaml said 0.9.0 while the live box runs 0.7.0 with 0.7.0's files, so it has never received 0.8.0 or 0.9.0 either. 28 runtime commits had landed since the 0.9.0 bump at 86bdf49 with no version to carry them.

MINOR, AND NOT BECAUSE OF THE NUMBER OF TICKETS. A config that is legal on 0.9.0 is REFUSED on 0.10.0: TARIFF-07 retires three price_provider ids and refuses them by name at startup (the ids stay in the schema enum precisely so validate.py's message is reachable rather than an opaque Supervisor rejection); TARIFF-18 moves price_taxes_eur_kwh 0.06 -> 0.11736, and the non-obvious half is the refusal that deliberately does NOT happen, because SUPERSEDED_TAXES_DEFAULTS_EUR_KWH keeps a taxes period table beside a stored 0.06 from becoming exit 78 on upgrade, with the rung measured un-weakened against a genuinely custom 0.1198; SAFETY-10 adds shadow_mode shipped `auto`, where an existing controlling install does not change by one watt and a fresh install comes up observing.

AND A 0.7.0 INSTALL CROSSES 0.8.0 ON THE WAY. `ha addons update` does not walk versions, so 0.8.0's rule 10 lands at the same moment: an explicit slew_w above SLEW_RAIL_FRAC x rail is refused, and 0.7.0's OWN shipped default of 1000 is refused against a 555.6 W ceiling. Measured red on this tree, with slew_w 400 measured green - which is what the live box carries. Someone reading only the newest section would otherwise miss the one release that can stop the add-on starting.

THE BOUNDARY WAS DRAWN HONESTLY RATHER THAN TIDILY. All 18 table rows in the top block merged after 86bdf49 - verified per row with git merge-base --is-ancestor - so the block is genuinely this run's work and was retitled as-is with its 18 `_this branch_` placeholders resolved to merge shas. But 0.10.0 ships MORE than that table lists: SAFETY-43 and TEST-21 are documented in a SECOND `## Unreleased` block sitting below 0.9.0, and SAFETY-44 - which changed app/main.py and app/store.py - is in CHANGELOG.md NOWHERE AT ALL. All three are named in the 0.10.0 preamble rather than moved, because re-filing is PKG-08. The second block is annotated, not rewritten or emptied: UX-02 in it shipped in 0.9.0 and is already in 0.9.0's own contents table, while the other three shipped here. Verified independently before merge: all 30 changelog deletions fall inside `## Unreleased` blocks and none in a released section.

THE BLOCK'S OWN STATED REASON FOR THE VERSION CHOICE WAS WRONG AND THE SECTION SAYS SO. It said the number depended on whether 0.9.0's image had been built and pulled, and that if it had not, this work belonged inside 0.9.0. That was written before 0.9.0's section was cut. It has been cut since 86bdf49 with 28 runtime commits behind it, so reusing 0.9.0 would make one version string name two different trees - and the Supervisor keys the built image on exactly that string, which is the PKG-06/07 no-op in its worst form.

Evidence: 29/29 green. predeploy_check.py --deployed-version 0.7.0 --options <live blob> clears at exit 0 both before and after, unchanged but for the shipped version line. No default was touched, so test_defaults_pin, test_price's mirror and test_version_gate stay green for the right reason. Eight mutations, no survivors, one at zero bytes. One was a SURVIVOR on the first draft - the slicer control was a sentinel only an EOF-running slicer could reach, so deleting a heading never got there; it asserts an invariant now, exactly one contents table per section.

Card numbers that did not reproduce, corrected in the report: the block is 1541 lines citing 31 ids rather than 5487 citing ~70 (those figures fit the whole file and the Detail notes block, which is PKG-08's subject); 28 runtime commits rather than 30; 18 tickets rather than fifteen. FCST-04 has a table row but no section body - pre-existing, left alone.
2026-09-16 02:58:28 +02:00
adminandClaude Opus 5 cb3f686555 PKG-14: cut 0.10.0, and say what an upgrade from 0.7.0 crosses
checks / unit (pull_request) Successful in 7m51s
`config.yaml` 0.9.0 -> 0.10.0, and the top `## Unreleased` block is closed
into `## 0.10.0` with a preamble that names the backward-incompatible
changes and who they reach.

MINOR, and NOT because eighteen tickets landed. A stored options blob the
previous version started on is REFUSED on this one:

  * TARIFF-07 retires three `price_provider` ids; a site set to one is
    refused BY NAME at startup. Measured on the reference blob, with a
    control on an accepted id so the refusals are about the ID.
  * TARIFF-18 moves `price_taxes_eur_kwh` 0.06 -> 0.11736. A site that
    STORED 0.06 is untouched, and - the non-obvious half - a taxes period
    table beside that stored 0.06 does NOT become a startup refusal on
    upgrade, because `SUPERSEDED_TAXES_DEFAULTS_EUR_KWH` reads it as
    untouched. Measured, with the counterfactual that the rung is not
    weakened (ORES's 0.1198 is still refused).
  * SAFETY-10 adds `shadow_mode`, shipped `auto`. An existing CONTROLLING
    install does not change by one watt and a FRESH one comes up observing.
    Measured cross-revision, 8f82dd4 -> e6da478, same stimulus through the
    shipped `Controller.cycle()`: byte-identical write streams, 13/13/25
    writes. Positive control: the same driver at `shadow_mode: on` gets 13
    writes to 5000 W on the older tree and one 0 W write on the newer.

And the one a reader of the newest section alone would miss: `ha addons
update` does not walk the versions, so a 0.7.0 install meets 0.8.0's and
0.9.0's rules at the same moment as 0.10.0's. Of those, SAFETY-39's rule 10
refuses 0.7.0's OWN shipped default `slew_w: 1000` against a 555.6 W
ceiling on a 5000 W rail. Measured red on this tree; the reference
install's `slew_w: 400` is measured green.

SCOPE, and it is drawn honestly rather than tidily. The top block's 18
entries all merged after the 0.9.0 bump `86bdf49`, so the block is closed
as-is and its `_this branch_` placeholders are resolved to merge shas.
⚠️ 0.10.0 SHIPS THREE MORE RUNTIME TICKETS THAT ARE NOT IN THAT TABLE -
SAFETY-43, TEST-21 and SAFETY-44 - and they are NAMED in the preamble
rather than moved, because re-filing this file's misplaced entries is
PKG-08. SAFETY-44 (`app/main.py`, `app/store.py`) is in the changelog
nowhere at all; that is said in both places. The second `## Unreleased`
block below `## 0.9.0` is ANNOTATED, not rewritten or emptied - UX-02 in it
shipped in 0.9.0, the other three in 0.10.0. No released section is
rewritten.

`test_version_gate.py` gains part 11: the bump and the closed section are
one act. PKG-09 refuses a deploy whose version did not move; nothing graded
the mirror failure, in which the version moves and the block is never
closed. Two sides from two files, a slicer with a synthetic positive
control run on every invocation, the retired ids read off `app/price.py` by
AST rather than transcribed, and the count of still-misfiled blocks frozen
so it can only shrink.

Mutations, all purged, tree bind-mounted :ro, backups at mktemp paths and
the revert fingerprinted:

  M1  `## 0.10.0` -> `## Unreleased`       +4 B    RED 2
  M2  version 0.10.0 -> 0.10.1             +0 B    RED 1   (ZERO-BYTE)
  M3  an id stripped from the NEWEST       +10 B   RED 1
      section only, left below
  M4  a 4th retired provider in price.py   +62 B   RED 1 here, 4 in
                                                   test_price.py
  M5  `## 0.9.0` heading lost              -3 B    RED 1
  M6  a 2nd `## Unreleased` left standing  +27 B   RED 1
  M7  the slicer never closes              -32 B   RED 3
  M8  SUPERSEDED_TAXES... emptied          -5 B    RED 2 on the evidence
                                                   instrument, plus
                                                   test_validate/test_price

⚠️ M5 WAS A SURVIVOR ON THE FIRST DRAFT. The slicer control was a sentinel
(`## 0.8.0`) that only a slicer running to EOF could reach, and deleting one
heading never gets there - ARCH-02's fifteenth instance, in a fresh costume.
11a now asserts the invariant instead: exactly one contents table per
section, which is what a lost heading breaks.

29/29 test files green. `tools/predeploy_check.py --deployed-version 0.7.0
--options <live blob>` CLEARED, exit 0, before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-16 02:48:27 +02:00
admin 587495e143 Merge pull request 'SAFETY-47: say what a shadow run can and cannot show' (#90) from SAFETY-47 into release/1.0
DOCS.md, app/shadow.py's header and DECISIONS/SAFETY-10 all claimed the strategies ask for a fixed power from inputs that do not depend on what the battery did, so a shadow run shows you the real trades. That was true when shadow mode was designed and false by the time it merged: SAFETY-45 landed hours earlier and made the buy leg min(asked, headroom), where headroom = (900/remaining) x (ceiling - projected) and `projected` reads the open quarter's accumulator - the meter, which is exactly a function of what the battery did. The strategy became closed-loop and the exemption went stale.

Measured at a binding cap, 1500/1200/2000, the same hour twice: live grid 1224-1506 W held at the ceiling against a flat 1200 W in shadow; SoC 50.000019 -> 51.856789 % against 50.000000 -> 50.000000; the two runs differ on 716 of 720 cycles; the final recorded figure is -302.4 W live against -2000.0 W in shadow, 6.61x. On a SAFETY-45-reverted tree the limit cycle is 28 onsets live against 1, guard engaged 410/720 against 0/720. Two of the three defects the card cites as motivation are closed-loop phenomena shadow cannot exhibit; TARIFF-14's half genuinely is visible, hour and price and spread identical 720/720.

THE CAVEAT'S DIRECTION WAS MEASURED RATHER THAN WRITTEN, and the ticket had it wrong. A draft saying a live run reaches a SMALLER number was checked pairwise over 3600 charging cycles in five configurations: the shadow record is never below live - zero cycles below and 730 EXACTLY EQUAL, at the start of each quarter where the amplification is 1.0. `Smaller` would have been false on 730 rows. The shipped sentence says UPPER BOUND and the two-point arithmetic behind it is graded.

Caveated rather than re-computed, with the rejections recorded: re-computing would make the recorded decision no longer the decision the shipped path reached while arbiter, clamp, the SAFETY-28 gate, the log and MQTT all ran on the real one - a second controller everywhere except the wire; recording nothing blanks the row exactly when arbitrage trades, and the log's edge key IS the watts; recording the unsized ask beside it adds a second number with no warning, and at the shipped 3500/2000 both read 2000 W. The trap avoided: keying the caveat on `capped` would fire at the quarter's START, where the record is right, and go SILENT at the end, where it is 6.61x out. It keys on the headroom having been MEASURED.

AND THE TICKET'S OWN ANALYSIS WAS INCOMPLETE. I said the committed evidence table could not fail for two reasons. They are independent in the FIX direction too: widening the fixture to a binding cap still leaves the comparison passing, because it reads index zero and the first charging cycle is -300.0000 W in both runs. Fixing only the fixture closes nothing. Recorded in MUTATION.md as a new habit-4 shape - an INDEX, not a fixture - and the agreement at index zero is now graded as an expectation so nobody rebuilds on it.

Evidence: 29/29 green. Fourteen mutations, no survivors. One was a REAL survivor first - deleting the caveat from the action log left all 29 files green, because the sentence was an f-string in _shadow_settle() and the only suite that drives it runs with meter_source: off; it is shadow.log_line() now. One CORRECTLY survived and is reported as such: min -> max makes the record larger, which is still an upper bound, so it is not the inversion that matters. And the new section nearly shipped FCST-06's shape - a mutation made it raise on its first min() and report zero reds - so every check now carries the section's precondition.

Two design limits stated rather than smoothed: the caveat discloses but does not help, since an engineer still cannot obtain the real number without arming the battery - the fix for that is a rig mode replaying the site's own captured meter through the shipped decision path, filed separately and deliberately NOT by giving shadow mode a plant model, which is the one change that would break its safety argument. And SAFETY-10's takeaway still oversells `auto`, which this card did not rewrite.
2026-09-15 01:36:41 +02:00
glenn schrooyenandClaude Opus 5 e3ccd9f6ea SAFETY-47: what a shadow run can show, and the watts on its status row
checks / unit (pull_request) Successful in 7m48s
Not a leak and not a write-path defect. Shadow mode's safety is intact -
the substitution, the re-seed, the authorisation gate and the clamp are
untouched. What was wrong is what three merged documents SAY a shadow run
can show you, and a number on the status row that can be 6.61x what a live
controller would command.

SHADOW MODE CANNOT SURFACE A CLOSED-LOOP DEFECT, AND THE DOCUMENTS NOW SAY
SO. The battery is held at 0 W and the carried command is re-seeded to it,
so every measured input - the meter, the billed-quarter accumulator behind
it, the battery power, the SoC - describes a plant that did not respond. A
closed-loop defect is one whose stimulus IS that response. This is the same
property that makes shadow mode safe, so it is not a gap to close later:
the closed-loop answer comes from the closed-loop rig or from a live run.
SAFETY-10 §5 already conceded this for the grid-following loop. It was
never the loop leg's limitation; it is the general case.

THE "STRATEGIES ARE UNAFFECTED" EXEMPTION WAS TRUE UNTIL SAFETY-45 MERGED,
ONE COMMIT EARLIER. That card sizes the buy leg as min(asked, headroom)
with headroom = (900/remaining) x (ceiling - projected), and `projected`
reads the open quarter's accumulator - the meter - which is a function of
what the battery did earlier in that quarter. Measured on TEST-23 stage 3
at peak_cap_w 1500 / house 1200 / max_w 2000, one hour at 5 s:

  SAFETY-45's limit cycle  NOT visible. 28 onsets live vs 1; grid
                           -455.5..+3200.0 W vs flat at the 1200 W house;
                           guard engaged 410/720 cycles vs 0/720.
  SAFETY-46's SoC churn    NOT visible. The SoC moves 0.000000000 pp.
  TARIFF-14's wrong hour   VISIBLE, and it is the only one: which hour, at
                           what price, clearing what spread - identical on
                           720 of 720 charging cycles at a binding cap.

The exemption narrows to exactly what survives: WHICH hour a strategy picks
and WHY are unaffected; HOW HARD it trades is not. The peak guard's ceiling
and reasons are worth reading from a shadow run; its ENGAGEMENTS are not.

THE NUMBER IS CAVEATED, NOT RE-COMPUTED, AND THAT IS THE DECISION. The
arithmetic is not wrong - 900/remaining is correct for a meter genuinely
under the ceiling, and in shadow the meter genuinely is. It is the right
answer to a question nobody asked. Both surfaces now introduce the watts as
what would have been commanded ON THIS CYCLE, in every state, and carry
shadow.SIZED_FROM_METER whenever a measured billed quarter sized them.
Rejected, and recorded in the decision doc: re-computing it (the recorded
decision would no longer be the decision the shipped path reached, while the
arbiter, clamp, SAFETY-28 gate, log and MQTT all ran on the real one - a
second controller everywhere except the wire); recording nothing (blanks the
row exactly when arbitrage is trading, and the log's edge key is the watts);
and recording the unsized ask beside it (a second number, no warning, and at
the shipped 3500/2000 both figures read 2000 W).

"UPPER BOUND" AND NOT "SMALLER", AND THE DIFFERENCE WAS MEASURED. Charging
only adds offtake, so the shadow size is >= the live size with EQUALITY at
the start of every quarter. Pairwise over 3600 charging cycles in five
configurations: 0 below, 730 exactly equal. The first draft said "smaller",
which would have been false on every one of those 730.

AND THE PREDICATE IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED. arbitrage's
own sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" only when the
headroom came out below the ask - in a shadow run, the START of the quarter,
where the two runs agree to the float. By the end, 900/remaining has
multiplied the headroom past the ask, the trade is not capped, the sentence
is silent, and the record is at its most wrong. A caveat keyed on it would
be quietest exactly where it is needed loudest.

SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED, FOR TWO
INDEPENDENT REASONS, AND CLOSING EITHER ALONE CLOSES NOTHING. Its fixture is
peak_cap_w 15000 against a 350 W house, so the guard never engages and the
sizing is inert - the harness's own comment says so. AND every identity row
reads the FIRST charging sample, where at elapsed=0 the projection IS the
held level and the two runs agree to the float even at a cap that does bind.
Measured: widening the fixture alone leaves that comparison passing. That
second reason is a new shape for MUTATION.md's habit-4 list - an INDEX, not
a fixture - and it is recorded there as the twelfth instance. §6 is re-taken
at a binding cap across the whole run: 716 of 720 charging cycles differ,
headroom 300.0..54000.0 W in shadow against 299.9..307.8 W live, recorded
-2000.0 W against a live -302.4 W.

THE GRADER IS test_arbitrage.py PART 3d, beside SAFETY-45's own closed-loop
regression and built on the same s45_loop() harness, so the runs differ in
one argument. Three lines stand in for _write_unit()'s substitution and
_shadow_settle()'s record and re-seed, marked as a transcription;
test_clamp.py part 8 remains the check on the shipped placement. The section
carries its own precondition into every check: a mutation that stopped
either run trading made it raise on its first min(), kill the file and
report ZERO reds - FCST-06's shape, driven rather than imagined.

AND THE FIX'S OWN FIRST DRAFT CARRIED THE SAME DEFECT. Deleting the caveat
from the action-log line left all 29 files green, because the sentence was
an f-string inside _shadow_settle() and the only suite that drives it uses a
meter_source:off fixture where no arbitrage decision exists. It is
shadow.log_line() now, for the reason words() is a function.

14 mutations, no survivors; M13 and M14 are +0 B and were confirmed on disk
by sha256 with every run purged. M14's first form was aimed at the wrong
direction and correctly survived - min->max makes the shadow record larger,
which is still an upper bound. All 29 check files green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-15 01:26:57 +02:00
admin e6da478f87 Merge pull request 'SAFETY-10: shadow mode, and 0 W is a number rather than a silence' (#89) from SAFETY-10 into release/1.0
The closed-loop rig made the case for this: with arbitrage armed and a binding cap the loop ran 28 charge onsets an hour (SAFETY-45); the price lookup returned the wrong quarter-hour for 45 minutes in every 60 (TARIFF-14); the SoC ceiling churns 598 Wh to gain 310 (SAFETY-46). Every one was invisible from outside, and there was no way to watch what the optimiser WOULD do without letting it do it.

THE TICKET SAID DEFAULT-ON AND THE AGENT REFUSED IT, correctly. The Supervisor resolves options as {**config.yaml defaults, **stored user options}, so a NEW key with a new default reaches every EXISTING install on the next update - including the one regulating the author's battery. `shadow_mode: true` would have stopped it silently, and the only symptom would be a battery that stopped doing anything. There is no mechanism by which one key defaults differently for a new install and an existing one - `options:` IS the default and there is one of it.

So the default is a value the add-on RESOLVES: shadow_mode: list(auto|on|off), shipped `auto` = shadow while control is STOPPED, live once STARTED. A fresh install ships auto_start: false, so it comes up in shadow and observes before it ever commands - default-on delivered where it is safe. An existing install that is controlling does not change by one watt; verified before merge, `auto` resolves to `not control_started`.

SECOND PUSHBACK, ALSO RIGHT: commands nothing is a NUMBER, not a silence. The inverter holds its last command forever, so a shadow mode that stopped writing would leave whatever was standing STANDING, under a page saying nothing is commanded - the exact looks-healthy-but-is-inert failure this repo keeps finding - and would hold the ESP32's 30 s stale-input watchdog tripped for the whole run, an alarm used as a mode. The actuator is held at 0.0 W, refreshed on the ordinary heartbeat, by the ordinary single writer, as a substitution inside the existing single choke point. Not a second write path. It is the LAST substitution, below SAFETY-28's gate and SAFETY-03's clamp, so both still grade the number the decision produced.

Every failsafe's behaviour is decided and recorded rather than inherited. The sign watchdogs are INERT because they grade standing_w(), held at 0 W - feeding them the decision would have them disagree with a meter that never saw it and LATCH A TRIP SURVIVING INTO LIVE OPERATION AND A RESTART. Clamp, write gate, meter freeze and peak guard OBSERVE the real decision. Circulation and delivery may legitimately latch, because their evidence is measured power. The operator stop is enforced at the wire rather than as a P_SAFETY claim, which would make every recorded decision read `stopped`. A standing latch is named in the shadow banner and turns it red.

Graded on the writes and the rendered surface, never on the flag, with the precondition asserted first: the same stimulus at `off` produces 30 non-zero writes with a largest of 5000 W; at `on`, set(writes) == {0.0} and 26 of them. Closed loop over the same hour twice: live -2000 W throughout, SoC 50.000 -> 62.331 %, grid to 2350 W; shadow 0 W throughout, SoC 50.0 -> 50.0 exactly, grid = the house exactly - with the arbitrage decision identical in action, instant, signed power, reason verbatim and arbiter winner across 3600/3600 samples.

Evidence: 29/29 green, CI green. Twenty-two mutations, no survivors, including a write escaping, silence instead of 0 W, observers blinded, default-on-for-everyone, the stop claim refiled, and a re-seed moved one line earlier which blinds SAFETY-18. Two mutation findings recorded: one first read as a survivor and was not, because a TypeError killed the section and reported zero reds - FCST-06's shape; and one had to become a PAIR because two fields both hold the detector at 0 W - TEST-16's shape.

Recorded limitation rather than glossed: the re-seed makes the exit safe by construction, and the price is that the loop leg's shadow decision is one slew step rather than a trajectory. Graded by name. The strategies are unaffected, which is why all three motivating defects remain visible.
2026-09-14 17:44:26 +02:00
Claude 8fa4febfef SAFETY-10: the whole decision path runs and nothing reaches the inverter
checks / unit (pull_request) Successful in 7m58s
Every optimiser defect this project has found was invisible from outside
while it was happening, and each was paid for at the meter first: SAFETY-45's
28 charge onsets an hour against a binding peak_cap_w, TARIFF-14's wrong
quarter-hour for 45 minutes in every 60, SAFETY-46's 598 Wh spent to gain
310 Wh. There was no way to watch what the optimiser WOULD do without letting
it do it.

`shadow_mode` runs strategies, arbiter, clamps and guards, records the
decision with its reason, and commands nothing.

⚠️ IT WRITES. It writes 0 W, on the ordinary heartbeat, through the ordinary
single writer. The inverter holds its last command forever - it has no meter
timeout - so "commands nothing" implemented as "stops writing" would leave
whatever was standing when shadow mode came on standing for as long as it was
left on, under a page saying nothing is being commanded. That is the failure
this repository keeps finding. Refreshing 0 W is also what
firmware/goodwe-master.yaml's 30 s stale-input watchdog requires of us; going
quiet would keep that board tripped for the whole run, which is an alarm being
used as a mode.

⚠️ IT IS NOT A SECOND WRITE PATH. No claim, no transport, no arithmetic of its
own. It is a substitution inside Controller._write_unit() - the single choke
point every write already reaches - in the same form as the three above it,
and it is the LAST of them, so SAFETY-28's gate and SAFETY-03's clamp still
grade the number the decision path produced.

⚠️ THE DEFAULT IS `auto`, NOT `true`, AND THAT IS THE CARD'S REAL CONTENT. The
Supervisor composes options as {**config.yaml defaults, **stored user
options}, so a new key with a new default reaches EVERY EXISTING INSTALL on
the next update - including the one regulating a real battery. A boolean
default-on would have stopped it silently. There is no mechanism by which one
key can default differently for a new install and an existing one, so
"default-on for new" is a value the add-on RESOLVES: `auto` is shadow while
control is STOPPED and live once it is STARTED. A fresh install ships
`auto_start: false` and comes up observing; an install that is controlling is
started and does not change by one watt.

THE FAILSAFES. Sign watchdogs (SAFETY-02/30): INERT - they grade
Inverter.standing_w(), which shadow mode holds at 0 W. Fed the shadow decision
they would disagree with a meter that never saw it and LATCH A TRIP THAT
SURVIVES INTO LIVE OPERATION AND A RESTART. Clamp (SAFETY-03), write gate
(SAFETY-28), meter freeze (SAFETY-18), peak guard: OBSERVE. Circulation
(SAFETY-31) and delivery (SAFETY-42): observe and may legitimately latch,
because their evidence is measured power. The operator stop is the one
deliberate change: enforced at the wire rather than as a P_SAFETY claim, which
would otherwise make every recorded decision read "stopped".

⚠️ RECORDED LIMITATION: _shadow_settle() re-seeds the carried command to what
stands on the wire, which makes the exit safe by construction - and therefore
the loop leg's shadow decision is ONE SLEW STEP, not a trajectory. A shadow
run answers "what would it command now", not "what would it have done next".
The strategies are unaffected; all three defects above are visible from one.

Graded on the writes and on the rendered surface, never on the flag: 22
mutations, no survivors, including one that lets a write escape and one that
goes silent instead of commanding 0 W. Closed loop on TEST-23 stage 3's rig,
same hour twice: live drives the pack to -2000 W and SoC 50.000 -> 62.331 %;
shadow commands 0 W throughout, SoC stays 50.0 exactly, and the arbitrage
decision is identical in action, instant, signed power, reason and arbiter
winner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-14 17:25:43 +02:00
admin 8f82dd4e06 Merge pull request 'SAFETY-45: size the trade to the headroom instead of interlocking against it' (#88) from SAFETY-45 into release/1.0
With arbitrage armed and a binding peak_cap_w, the loop did not settle: 28 charge onsets an hour, period 131 s, grid swinging -455 to +3200 W, and a billed quarter 59 W OVER the cap it had just spent fifteen minutes holding. Section 10.2 is explicit that an EMS which oscillates is worse than no EMS, for hardware wear and for the peak it is trying to avoid. Not reachable by any unit test in the repo - it needed the closed loop.

THE TICKET'S MECHANISM WAS WRONG AND THE AGENT DISPROVED IT. I wrote that the quarter boundary drives the cycle. Of the 27 gaps between the 28 onsets, 23 are 90-120 s entirely INSIDE one quarter; the four long gaps are exactly the four that SPAN a boundary, so the boundary lengthens the interval rather than causing it. A fix aimed at boundary handling would have removed 4 of 28.

The real cycle is a binary interlock keyed on a continuous quantity: the trade charges at max_w while `wanted = batt_w + excess` is still negative and the guard files nothing; `wanted` crosses zero; the guard engages on a FIVE WATT floor; that flips guard_state(), which cancels the WHOLE trade; the grid-following loop - which targets grid = 0, not grid = ceiling - takes the actuator, discharges to +1870 W and exports 420 W; the projection collapses; the trade re-arms at max_w.

So the fix sizes the buy leg rather than interlocking it. control.maintenance_charge_floor() already caps MAINTENANCE charging at the headroom under the site's cap, because a maintenance charge was the only thing big enough and long enough to set the peak - and that stopped being true when OPT-04 landed. peakguard.charge_headroom_w() publishes the same quantity off the MEASURED guard rather than the optional forecast entity, arbitrage.decide() takes it keyword-only and sizes the buy, main.py passes it. It is wanted_w read in the other direction: no new arithmetic and no new constant.

THE GUARD'S DECISION FUNCTION IS UNTOUCHED, and that is the proof 2026-08-20's bang-bang did not come back. peakguard.py is +139/-0 - zero deleted lines - and sha256(decide) is identical on both trees at 7252 bytes; replay_ac3.py reproduces 190/190 records, 110 engaged, 878 Wh identically. Verified independently before merge. Reverting that rung is red on eight named checks.

Rejected and recorded: a negative `lo`, because it creates a lo > hi contradiction against main.py's cheap-window maintenance shaping that resolves to 0 W - the battery parked in the billed quarter - and closing that would mean suspending maintenance shaping permanently on every affected site. And a backoff timer in arbitrage, because both modules' notes forbid inventing timers; the trade now re-arms at `ceiling - load`, which IS learning from the refusal.

After, on the same rig and the same command: onsets 28 -> 1, grid swing 3655 -> 282 W, and the billed quarters land at exactly 1500.0 W three times over. At the shipped defaults the two affected rows buy ~2% less energy and the worst quarter goes from 59 W over the cap to zero - which is section 10.1's ordering, since the tariff bills the month's single worst quarter. On an aggressive cap the sized trade buys 58% MORE. The non-binding control row is bit-identical.

Evidence: 29/29 green, CI green. Fourteen mutations, all red, two of them zero-byte. The regression closes the loop over the shipped guard, strategy, arbiter, control law and the real QuarterAverager, and asserts BOTH directions from one changed argument plus an inert-where-inert control.

TWO DEFECTS FOUND BY THE DISCIPLINE RATHER THAN BY REVIEW. The agent's own harness restored two edits to one file in list order and left one on disk - caught by the post-revert fingerprint, habit 18 biting a tool written the same afternoon. And test_peakguard.py part 1 formatted floor_w with a bare :.0f one line after the check that reverting the 2026-08-20 rung turns red, so the file ABORTED and parts 2-13 went ungraded: that mutation's coverage was one check and is now eight.
2026-09-14 16:20:39 +02:00
23 changed files with 4319 additions and 75 deletions
+310 -28
View File
@@ -1,33 +1,297 @@
# Changelog
## Unreleased
## 0.10.0
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`,
so `ha apps update` is a silent no-op on an unbumped change. This branch
deliberately does **not** pick the number, because the right one depends on a
fact the branch cannot observe - whether 0.9.0's image has already been built
and pulled. If it has not, this belongs inside 0.9.0's section before it ships;
if it has, it needs 0.10.0. Whoever deploys decides.
⚠️ **A CONFIG THAT IS LEGAL ON 0.9.0 IS REFUSED ON 0.10.0, AND THAT - NOT THE
NUMBER OF TICKETS - IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.** Same
shape as 0.8.0's `slew_w` rule and the same test: is there a stored options
blob the previous version started on and this one will not? There is. A refusal
is `SystemExit(78)` (`EX_CONFIG`) - 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.
Measured on the reference install's own exported blob (70 keys), merged over
this version's 75 defaults the way the Supervisor merges it, inside the CI
image. The three below are what a reader who checks only one thing should
check.
* ⚠️ **[[TARIFF-07]] - THREE `price_provider` IDS ARE RETIRED, AND A SITE SET
TO ONE OF THEM IS REFUSED BY NAME AT STARTUP.** `engie_dynamic`,
`totalenergies_dynamic` and `eneco_eddy` are gone from `PROVIDERS`. Each was
accepted, silently, at every startup on 0.9.0 and earlier; on 0.10.0 each is
a refusal that names the id, the endpoint, what that endpoint actually
answers - a 404 from a gateway with no route, a TLS handshake against a
hostname the certificate does not cover, a 404 with an empty body - which
tariff the site has really been running since the id was entered, and what to
set instead. **The refusal IS the migration.** Every fetch any of the three
ever made ended in `SourceUnavailable`, and §11.4 turns that into a SILENT
fall back to the static profile, so there is no working behaviour to preserve
and nothing on the status page ever differed from a provider that simply
never found a cheap hour. ⚠️ **All three ids stay in `config.yaml`'s
`price_provider` enum on purpose**: drop one and the Supervisor rejects the
options file *before the add-on runs*, the operator gets an opaque `expected
one of ...`, and `validate.py`'s message is never seen - on exactly the sites
that need it. `PROVIDERS` is three; the enum is seven.
⚠️ **The reference install is on `price_provider: off` and is unaffected.**
* **[[TARIFF-18]] - `price_taxes_eur_kwh` moves from `0.06` to `0.11736`, and
the non-obvious half is the refusal that deliberately does NOT happen.** A
site that *stored* `0.06` is unaffected in both directions. It keeps `0.06`,
because `App.options` is `deepmerge(config.yaml defaults, stored options)`
and a moved default reaches only keys the blob does not carry - so the new
figure arrives at a **fresh** install, and at an existing one only when its
options are re-saved. ⚠️ **AND IT DOES NOT BECOME A STARTUP REFUSAL EITHER,
WHICH TOOK A MECHANISM.** [[TARIFF-17]] made a `price_taxes_profile` period
table beside a **non-default** `price_taxes_eur_kwh` a refusal, comparing
against the shipped default precisely because the Supervisor writes the key
whether the operator typed it or not. Moving the default turns every stored
`0.06` into "non-default" **with nobody editing anything**, so a working
Walloon or Brussels site with a period table would have stopped starting on
this upgrade, over an inert number the table overrides anyway.
`price.py` carries `SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,)` and
`validate.py` reads a superseded default as *untouched*. **The rung is not
weakened**: the same table beside a figure that is neither the shipped nor a
superseded default - ORES's `0.1198` - is still refused, by name, and the
refusal now says which two figures it will accept.
* **[[SAFETY-10]] - a new `shadow_mode` option, shipped `auto`, and BOTH halves
of what that does matter.** ⚠️ **AN EXISTING INSTALL THAT IS CONTROLLING DOES
NOT CHANGE BY ONE WATT.** `auto` is not a constant the Supervisor merges; it
is a value the add-on **resolves**, and it resolves to `not control_started`
- `shadow.engaged()` reads `Controller.auto`, the persisted start/stop state.
An install with `auto_start: true`, or whose operator has pressed *Start /
stop control*, is started, so `auto` resolves to live. An install that is
**stopped** keeps its actuator at 0 W exactly as before, from the same writer
on the same heartbeat; what changes is what the status page says and that the
decision path now runs and is recorded. ⚠️ **A FRESH INSTALL COMES UP
OBSERVING**: it ships `auto_start: false`, has no stored `auto` key, and
therefore runs its whole decision path in shadow before it is ever allowed to
command anything. That is the "default-on" the card asked for, delivered
where it is safe and withheld where it is not - there is no mechanism in the
add-on options system by which one key can default differently for a new
install and an existing one, so a literal `shadow_mode: true` default would
have stopped the battery at the reference site on the next update.
**Measured cross-revision** - the same stimulus through the shipped
`Controller.cycle()` on `8f82dd4` (the merge before SAFETY-10) and on
`e6da478` (SAFETY-10 itself), start button ON: the write streams are
**byte-identical**, 13 / 13 / 25 writes on a 6000 W import, a 4000 W export
and a 300 W trickle, same values in the same order. An install that is
**stopped** is byte-identical across the same pair too, at one 0 W write.
⚠️ The positive control is what makes that readable: the same driver with
`shadow_mode: on` and the button ON gets 13 writes up to 5000 W on the older
tree and **one 0 W write** on the newer, so the driver does reach shadow mode
and an identical pair is a result rather than a silence. The shadow case is
also graded in `test_clamp.py` part 8 on what reached `set_number()`, against
a precondition run that puts 30 non-zero writes on the wire. ⚠️ An
unrecognised `shadow_mode` fails toward inaction
**and** is refused by name (rule 11), so the reinterpretation is never
silent; no blob written before 0.10.0 can carry one.
⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL IS -
THE REFUSAL THAT CAN STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha
addons update` does not walk the versions; it installs 0.10.0 in one step, so
**every validator rule added in 0.8.0 and 0.9.0 lands at the same moment as
0.10.0's own**, and a config that has not been looked at since 0.7.0 meets all
of them at once. Of those, exactly one can refuse a config nobody has edited:
> [[SAFETY-39]]'s **rule 10**, added in 0.8.0, refuses an explicit `slew_w`
> above `SLEW_RAIL_FRAC x` the site rail, where `SLEW_RAIL_FRAC` is
> `LOOP_TICK_S / PLANT_SETTLE_S` = 2/18. On a 5000 W rail that ceiling is
> **555.6 W** - and ⚠️ **0.7.0's own shipped default was `slew_w: 1000`**, so
> an install that never touched the key and simply took the default does not
> start on 0.10.0 until the value is lowered.
Measured on this tree: the reference blob with `slew_w: 1000` is refused, by
name, and the same blob at its actual `slew_w: 400` is accepted. 0.9.0's own
additions cannot reach a pre-0.9.0 config at all - [[OPT-09]]'s two refusals
fire only on a roster entry that USES `capacity_kwh`, a key that did not exist
- so 0.8.0's is the whole of the crossing. **Read the live options and check
`slew_w` against the ceiling and `price_provider` against the withdrawn list
BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both.
✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.**
docker run --rm -v <repo>:/w:ro -v <dir>:/opt:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.7.0 --options /opt/live-options.json
deployed on the box : 0.7.0
shipped in this tree: 0.10.0
live options pass this version's validator
CLEARED for deploy.
**The option surface: 71 keys -> 75, `options:` and `schema:` in step.** Four
new keys, and none of them can refuse a blob written before it existed:
`price_formula`, `price_export_formula` and `price_taxes_profile` are `str?` -
**note the trailing `?`, they are OPTIONAL** - defaulting to `""`;
`shadow_mode` is `list(auto|on|off)` with no `?`, which is the shape that
becomes exit 78, and it is safe for the same reason 0.9.0's `delivery_hold_s`
was: it carries an `options:` default, so the Supervisor's merge supplies it to
every stored blob that lacks it. Nothing was removed. One default moved
(`price_taxes_eur_kwh`, above) and one enum widened (`price_provider` gains
`custom_formula`).
⚠️ **WHY 0.10.0 AND NOT 0.9.0, SINCE 0.9.0'S IMAGE NEVER REACHED THE BOX.**
The block this section closes used to say the choice depended on whether
0.9.0 had been built and pulled, and that if it had not, this work belonged
*inside* 0.9.0's section. That was written before 0.9.0's section was cut. It
has been cut since `86bdf49`, on 2026-09-07, and **28 runtime commits** have
landed behind it; folding them in would make the string `0.9.0` name two
different trees, and the Supervisor keys the built image on exactly that
string. Reusing it is the PKG-06/PKG-07 no-op in its worst form - a cached
image the Supervisor is entitled to serve while reporting success. The version
a tree has already shipped under is not available to be re-cut.
⚠️ **AND THIS SECTION IS NOT THE WHOLE OF WHAT 0.10.0 SHIPS. THREE TICKETS
WITH RUNTIME CHANGES LANDED AFTER `86bdf49` AND ARE NOT IN THE TABLE BELOW.**
Saying so is cheaper than moving them, and moving them is [[PKG-08]]'s card,
not this one:
| ticket | runtime files | where it is written down |
|---|---|---|
| [[SAFETY-43]] | `app/delivery.py`, `app/packshare.py`, `config.yaml` | the `## Unreleased` block **below `## 0.9.0`**, which is misfiled |
| [[TEST-21]] | `app/delivery.py`, `config.yaml`, `translations/` | the same block |
| [[SAFETY-44]] | `app/main.py`, `app/store.py` | ⚠️ **nowhere in this file** |
Five more landed that touch no runtime file and so cannot change what the
image does - PKG-13, UX-04, SAFETY-31, ARCH-02, INSTR-01. The count that
matters for the no-op check is `tools/predeploy_check.py`'s own: **28 runtime
commits since `86bdf49`**, of which the table below accounts for 21.
Contents of this release, in merge order since 0.9.0 (`86bdf49`):
| | |
|---|---|
| _this branch_ | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
| _this branch_ | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard |
| _this branch_ | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
| _this branch_ | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
| _this branch_ | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
| _this branch_ | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch |
| _this branch_ | TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing |
| _this branch_ | TARIFF-14 - the price a trade is decided against is the price of the interval being traded |
| _this branch_ | TARIFF-07 - three plugins that never fetched a price are withdrawn, and a site configured on one is told so **⚠️ BACKWARD-INCOMPATIBLE** |
| _this branch_ | TARIFF-15 - `custom_formula`: your own tariff card's two coefficients, graded against the card's own printed price |
| _this branch_ | TARIFF-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving |
| _this branch_ | TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface |
| _this branch_ | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest |
| _this branch_ | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** |
| _this branch_ | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero |
| _this branch_ | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** |
| `c2787da` | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
| `5059e28` | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard |
| `958f5cd` | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
| `4257e38` | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
| `88c59a9` | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
| `559f0eb` | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch |
| `a6e64fb` | TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing |
| `8d09b30` | TARIFF-14 - the price a trade is decided against is the price of the interval being traded |
| `aade2c7` | TARIFF-07 - three plugins that never fetched a price are withdrawn, and a site configured on one is told so **⚠️ BACKWARD-INCOMPATIBLE** |
| `068b83f` | TARIFF-15 - `custom_formula`: your own tariff card's two coefficients, graded against the card's own printed price |
| `b4a3801` | TARIFF-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving |
| `c518007` | TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface |
| `7796f16` | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest |
| `4dcd9b0` | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** |
| `478ba93` | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero |
| `8f82dd4` | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** |
| `e6da478` | SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter **⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL** |
| `587495e` | SAFETY-47 - what a shadow run can show you, and the watts on its status row **⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED** |
### SAFETY-47 — what a shadow run can show you, and the watts on its status row
⚠️ **Nothing about the write path changed, and nothing needed to.** Shadow
mode still holds every inverter at 0 W through the one writer. What was wrong
is what three merged documents **said** a shadow run can show you, and a number
on the status page that could be **6.61×** what a live controller would
command.
**A shadow run cannot show you a closed-loop defect. Not partially — not at
all.** The battery is held at 0 W, so every measured input — the meter, the
billed-quarter accumulator behind it, the battery power, the state of charge —
describes a plant that did not respond, and a closed-loop defect is one whose
stimulus *is* the plant's response. That is the same property that makes shadow
mode safe, so it is not a gap to be closed later: **the closed-loop answer
comes from the closed-loop rig or from a live run.**
SAFETY-10 promised otherwise, because of a sentence that was true when it was
written and false one commit later:
> the **strategies** are unaffected — they ask for a fixed power from inputs
> that do not depend on what the battery did — so a shadow run does show you
> the trades, the peak-guard engagements and the SoC-limit behaviour.
SAFETY-45 (above) sizes the buy leg to the billed quarter's charging headroom,
and that headroom is `(900 / seconds remaining) × (ceiling − projected)` with
the projection read off the **open quarter's meter**. The strategy became
closed-loop the day that landed. Measured on the rig, one hour at `peak_cap_w`
1500 under a 1200 W house:
| | live | shadow |
|---|---|---|
| charge onsets | 28 | **1** |
| grid | −455.5 … +3200.0 W | **1200.0 W flat — the house** |
| capacity guard engaged | 410 / 720 cycles | **0 / 720** |
| state of charge | 50.000019 → 51.856789 % | **50.000000 → 50.000000 %** |
**What the documents now say.** The exemption narrows to exactly what is still
true — **which** hour a strategy picks and **why** are unaffected, because the
clock and the price series are not functions of the battery; **how hard** it
trades is not. Of the three defects shadow mode was written for, one —
TARIFF-14's wrong quarter-hour — is genuinely visible from a shadow run, and it
is graded that way: identical live and shadow on 720 of 720 charging cycles at
a binding cap.
**What you will see on the status page.** The `Shadow mode` row and the action
log now introduce the watts as what the controller would have commanded **on
this cycle** — in every state, because no number a shadow run records is a
forecast of a live run. And when a **measured** billed quarter sized those
watts, both surfaces carry:
> ⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE HOUSE
> IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same trade
> against a meter the battery is moving, so this is an UPPER BOUND and not a
> prediction; how far under it a live run lands is a closed-loop question and a
> shadow run cannot answer it.
⚠️ **The number is caveated, not re-computed, and that was the decision.**
Re-computing it to "look right" would mean the recorded decision was no longer
the decision the shipped path reached — while the arbiter, the clamp, the
write-authorisation gate, the action log and MQTT all ran on the real one. A
shadow run's whole worth is that what it records is what the controller
actually decided. `DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md` §3
records the three alternatives that were rejected and why.
⚠️ **On a site whose cap does not bind, nothing changes by one watt.** At
`peak_cap_w` 15000 against a 350 W house the recorded figure is the live one
exactly, and no caveat is a lie there either — the row still says the size came
off a meter with no battery in it, which is true and is the point.
**And SAFETY-10's own committed evidence could never have failed.** Its
comparison table ran at a cap the guard never reaches, *and* read only the
first charging sample — the one instant at which the two runs agree even at a
cap that does bind. Both had to be closed; closing either alone closes nothing.
The table is re-taken across the whole run at a binding cap, and
`test_arbitrage.py` part 3d is the CI-side grader so this class cannot recur.
14 mutations, no survivors; all 29 check files green.
### SAFETY-10 — shadow mode, and what its default does to YOUR install
A new option, `shadow_mode`, with three values and a shipped default of
`auto`. In shadow mode the add-on runs its entire decision path — strategies,
arbiter, clamps, guards — and **sends nothing to the inverter**: every inverter
is held at **0 W**, refreshed on the ordinary heartbeat by the ordinary single
writer, and the status page says plainly that the battery is not being
controlled. What it *would* have commanded, and why, is on the status page and
in the action log.
⚠️ **WHAT HAPPENS TO AN EXISTING INSTALL: NOTHING.** `auto` resolves to shadow
mode only while control is **stopped**. An install that is controlling —
`auto_start: true`, or *Start / stop control* pressed — is started, so `auto`
resolves to ordinary control and not one watt changes. An install that is
**stopped** keeps its actuator at 0 W exactly as it did before, from the same
writer on the same heartbeat; what changes for it is what the status page and
the action log **say**, and that the decision path now runs and is recorded.
⚠️ **AND THAT IS WHY IT IS NOT A BOOLEAN.** The card is titled "default-on".
Taken literally — `shadow_mode: true` in `config.yaml` — it would be the worst
change in this repository's history: the Supervisor composes an add-on's
options as *its defaults, overlaid with the stored user options*, so a new key
with a new default reaches **every existing install** on the next update,
including the one regulating a real battery at the reference site. It would
have silently stopped it, and the only symptom would have been a battery that
stopped doing anything. There is no mechanism in the add-on options system that
lets one key default differently for a new install and an existing one, so
"default-on for new" is expressed as a value the add-on **resolves** rather
than a constant the Supervisor merges.
A **new** install ships `auto_start: false`, so it comes up in shadow mode and
observes a day of decisions before it is allowed to command anything. That is
the default-on the card asked for, delivered where it is safe and withheld
where it is not.
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
@@ -1521,12 +1785,30 @@ and pulled. If it has not, this belongs inside 0.8.0's section before it ships;
if it has, it needs 0.8.1. Whoever deploys decides, and PKG-07 is the record of
what happens when nobody does.
⚠️ **ANNOTATED BY PKG-14, 2026-09-16 - EVERY ROW BELOW HAS SHIPPED, AND THE
PARAGRAPH ABOVE IS NO LONGER TRUE OF ANY OF THEM.** It is annotated rather
than rewritten, and nothing is moved out of it, because re-filing this file's
misplaced entries is [[PKG-08]]'s card and guessing a boundary is what that
card exists to stop. What is measurable, and what PKG-14 measured:
* **UX-02 shipped in 0.9.0.** Its merge `3d29993` is an ancestor of the 0.9.0
bump `86bdf49` and it is already listed in `## 0.9.0`'s own contents table,
so this row is a duplicate of a released one.
* **UX-04, TEST-21 and SAFETY-43 shipped in 0.10.0.** All three merged after
`86bdf49`. TEST-21 (`app/delivery.py`, `config.yaml`, `translations/`) and
SAFETY-43 (`app/delivery.py`, `app/packshare.py`, `config.yaml`) are runtime
changes and are named in `## 0.10.0`'s preamble as shipping there and being
written down here; UX-04 touches no runtime file.
* ⚠️ **[[SAFETY-44]] also shipped in 0.10.0 and is nowhere in this file** -
`app/main.py` and `app/store.py`, three commits. It is named in
`## 0.10.0`'s preamble for the same reason.
| | |
|---|---|
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
| _this branch_ | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression |
| `3d29993` | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen **⚠️ SHIPPED IN 0.9.0, see that section** |
| `bb46916` | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed **⚠️ SHIPPED IN 0.10.0** |
| `6719bd8` | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce **⚠️ SHIPPED IN 0.10.0** |
| `37dd0f0` | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression **⚠️ SHIPPED IN 0.10.0** |
⚠️ **SAFETY-43 CLOSES THE DETECTION REGRESSION OPT-09 DISCLOSED, AND IT IS A
TWO-INVERTER FIX. N=1 IS BYTE-IDENTICAL.** `delivery.delinquent()` named a unit
@@ -0,0 +1,406 @@
# SAFETY-10 — shadow mode, and why "default-on" is not `true`
**Status:** decided and implemented on branch `SAFETY-10`.
⚠️ **AMENDED BY SAFETY-47, and three of the claims below were false when this
was merged.** §5's "the strategies are unaffected" exemption, §4's peak-guard
row and §6's evidence table are corrected in place and each correction is
marked. The amendments are marked rather than rewritten silently, because what
this card believed is part of why the defect shipped. See
`DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md`.
**Card:** SAFETY-10 — *"Shadow mode (default-on, log-only decisions)"*.
**Nothing here reached the inverter.** No deployment, no `ha addons update`, no
contact with 192.168.2.158 or 192.168.2.221. Everything below was measured in
the CI image and on TEST-23 stage 3's closed-loop rig.
---
## 0. The four things a reader should take away
1. **"Default-on" is delivered as `auto`, not as `true`, and the difference is
an install that is regulating a real battery today.** The Supervisor
composes an add-on's options as *its defaults, overlaid with the stored user
options*, so a new key with a new default reaches **every existing install**
on the next update. A boolean `shadow_mode: true` would silently stop the
reference site. §1.
2. **Shadow mode WRITES. It writes 0 W, on the ordinary heartbeat, through the
ordinary single writer.** "Commands nothing" implemented as "stops writing"
would leave the inverter latched on whatever was standing — it has no meter
timeout — and would put the ESP32 into a tripped stale-input watchdog for
the whole run. §2.
3. **The sign watchdogs are inert, and that was the decision that had to be got
right.** Fed the shadow decision they would trip within `sign_disagree_s`
and **latch a trip that survives into live operation and a restart**. §4.
4. **The loop leg's shadow decision is one slew step, not a trajectory, and
that is a real limitation of this feature.** §5.
5. ⚠️ **SAFETY-47: and neither is anything else that depends on the plant
answering back. A shadow run cannot surface a closed-loop defect at all**,
by the same construction that makes it safe — the battery is held at 0 W, so
every measured input describes a plant that did not respond. Of the three
defects §"WHAT DEFECT THIS PREVENTS" was written for, exactly one —
TARIFF-14's wrong quarter-hour — is visible from a shadow run. §5, §6.
---
## 1. The default
### The literal reading, and what it costs
`config.yaml`'s `options:` block **is** the default. There is exactly one of it,
and HA's Supervisor resolves an add-on's effective options as
```
{**config.yaml options, **the stored user options}
```
A key that has never been stored therefore takes the file's value — on a fresh
install and on an install that has been running for months, identically. There
is **no mechanism in the add-on options system** by which one key can default
differently for the two. So the card's title, taken at face value, is:
> the next `ha addons update` stops the battery at the reference site, and the
> only symptom is a battery that has stopped doing anything.
That is strictly worse than every defect shadow mode exists to catch. It is
refused.
### What was shipped instead
`shadow_mode` is a three-valued `list(auto|on|off)` defaulting to `"auto"`:
| value | resolves to |
|---|---|
| `off` | live |
| `on` | shadow, always |
| `auto` | shadow **while control is stopped**; live once it is started |
`auto` reads `Controller.auto` — the persisted start/stop state, which a fresh
install takes from `auto_start` and which the *Start / stop control* button
moves. No new persisted state, no migration, no guess:
* a **fresh** install ships `auto_start: false`, has no stored `auto` key, and
therefore comes up **in shadow mode**. It observes a day of decisions before
it is ever allowed to command anything. That is the card's intent, delivered
where it is safe;
* an install that is **controlling** is started, so `auto` resolves to live and
**not one watt changes**;
* an install that is **stopped** keeps its actuator at 0 W exactly as before —
the same number, from the same writer, on the same heartbeat. What changes is
what the status page and the action log **say**, and that the decision path
now runs and is recorded.
⚠️ **Under `auto`, pressing Start leaves shadow mode.** That is the point of
`auto`: the button means "control the battery". An operator who wants the
decisions recorded *while the loop runs* — the configuration this card exists
to make possible — sets `on` explicitly, and cannot reach it by accident.
⚠️ **An unrecognised value resolves to SHADOW, and is REFUSED at startup.**
`shadow.mode()` fails toward inaction; `validate.py` rule 11 refuses the value
by name so the reinterpretation is never silent. A typo is not a decision, and
an install silently left in shadow mode commands nothing indefinitely while its
status page calmly explains that this is intentional.
---
## 2. "Commands nothing" is a number, not a silence
The inverter holds its last command **forever** — `HANDOVER.md` §6 says so, and
`firmware/goodwe-master.yaml`'s header records what it cost when a controller
stopped refreshing: *"Illusmart went silent mid-command and the inverter held
+5000 W for 113 s"*. `firmware/goodwe-master.yaml` states the contract that follows — *"HA
must now REWRITE the setpoint every ~10 s even when the value has not
changed"* — because without a refresh, **"stale" and "steady" are the same
thing on the wire**.
So shadow mode holds the actuator at `shadow.WIRE_W` = **0.0 W**, refreshed on
the ordinary heartbeat. Two consequences were weighed and are recorded:
* **it is not silence.** A shadow mode that stopped writing would leave the
standing command up until the ESP32's 30 s watchdog noticed — and at a site
with no such board, forever;
* **it does not use an alarm as a mode.** Letting that watchdog trip for the
duration of a shadow run teaches the operator that `wd_tripped` is normal,
which is exactly the training that makes it useless when the add-on has
actually died.
### It is not a second writer
SAFETY-28's rule is one actuator, one writer. Shadow mode files **no claim**,
owns **no transport**, and calls nothing in `hass.py`. It is a **substitution**
inside `Controller._write_unit()` — the single choke point every write in this
add-on already reaches, including the heartbeat, the stop button, `shutdown()`
and `maint_abort()` — in exactly the form of the three substitutions above it
(non-finite → 0 W, unauthorised → 0 W, clamped → the rating).
It is the **last** of them. Below SAFETY-28's gate and below SAFETY-03's clamp,
so both still grade the number the decision path actually produced, and a
shadow run can report *"it would have hit the rating here"*.
---
## 3. What the run records
| surface | what it carries |
|---|---|
| status banner | `SHADOW MODE - the battery is NOT being controlled...`, **first in the cascade**, amber — red, with the latch named, if one is standing |
| status row `Shadow mode` | which rung decided, the watts it **would have commanded on this cycle**, where the inverter actually is, and `Resolution.explain()` — plus, when a measured billed quarter sized those watts, SAFETY-47's caveat saying so (`shadow.SIZED_FROM_METER`) |
| status row `Commanded` | `0 W` — the truth, which the row above reconciles with the `Why` row below it |
| action log | one line when shadow mode comes on; one line per **change of decision** (winner, rounded watts, or SAFETY-47's caveat state), carrying the arbiter's own sentence and the same caveat the row carries — `shadow.log_line()` is the one sentence both read |
| MQTT | `status: shadow`, its own value, below `failsafe` |
The action log is **edge**-logged, not level-logged: the log holds 40 rows, and
a line per cycle would flush the context of the run's own findings inside four
minutes at the 15 s cycle floor.
---
## 4. The failsafes, one by one
| failsafe | in shadow mode | why |
|---|---|---|
| SAFETY-02/30 sign watchdogs | **INERT** | They grade `Inverter.standing_w()` — what this controller has standing on the wire — and shadow mode holds that at 0 W, so they cannot arm. ⚠️ Fed the shadow decision instead they would disagree with a meter that never saw it and **latch a trip that survives into live operation and a restart**: shadow mode would manufacture the failsafe it exists to help debug. ⚠️ **Two rungs hold it there** — `last_write` (the wire gate) and `inv.target` (the re-seed) — which is MUTATION.md's masking shape, so the mutation that reproduces the hazard defeats both as a pair (M6). Driven singly, dropping the re-seed does not reproduce it at all — measured |
| SAFETY-03 clamp | **OBSERVES** | Runs on the decision, sets `clamp_last`, logs every engagement. Changes no command |
| SAFETY-28 write gate | **OBSERVES** | Same placement, same reason: the question is who chose the number |
| SAFETY-18 meter freeze | **OBSERVES**, unchanged | It takes no action live either; both halves of its conjunction are as true in shadow |
| SAFETY-07/24/25 peak guard | **OBSERVES, AND SEES ONLY THE HOUSE** | Level-triggered, unlatched, files its claim as usual, so the recorded decision is the real one *for this meter*. ⚠️ **AMENDED BY SAFETY-47.** This row used to end "SAFETY-45's limit cycle is visible from a shadow run, which is the point". It is not: every term of `wanted = batt_w + excess` is measured, and in shadow mode the battery contributes nothing to either. Measured on the rig, same hour, same unsized code — **410 of 720 cycles engaged live, 0 of 720 in shadow** |
| SAFETY-31 circulation | **OBSERVES AND MAY LATCH** | Its evidence is *measured* power. Packs pushing against each other while all are told 0 W is a real fault |
| SAFETY-42 delivery | **OBSERVES AND MAY LATCH** | Same, against a share that is 0 W |
| operator stop | **SUPPRESSED AS A CLAIM, ENFORCED AT THE WIRE** | The one deliberate change. Its `P_SAFETY` 0 W claim out-ranks everything, so leaving it in makes the recorded decision `"stopped"` on every cycle and the run answers the wrong question. The actuator ends at the same number either way, and the wire substitution is **strictly stronger**: a claim can be out-ranked, and it cannot |
| inputs missing, non-finite, allocator refusal, arbiter contradiction | **UNCHANGED** | All resolve to 0 W, which is what the wire gets anyway |
⚠️ **A latch that is already up stays up.** Nothing here clears one. The shadow
banner **names** any standing latch and goes red for it, because a latch that is
up while the controller is inert is up the moment it stops being inert.
⚠️ **Maintenance is not in that table because shadow mode does not touch it.**
The state machines are ticked on every cycle whether control is started or not,
so a stopped install already runs them against a battery that does not move.
Shadow mode inherits that, unchanged and unimproved. Recorded here rather than
quietly fixed under another card's name.
---
## 5. The limitation, stated rather than discovered later
`Controller._shadow_settle()` re-seeds the carried command (`self.target` and
each `inv.target`) to `WIRE_W` at the **end** of every shadow cycle — after
every in-cycle guard has read the decision. Three machines read that value as a
statement about the **hardware**: the loop's `prev_w` and integrator,
`standing_w()`, and SAFETY-42's share comparison. Re-seeded, they track reality,
and **the exit is safe by construction** — the carried command already equals
the standing one, so the first live cycle slews from 0 W like any other.
⚠️ **The cost is real.** The loop's `prev_w` is therefore 0 W on every shadow
cycle, so the loop leg's recorded decision is **one slew step from the standing
command** and stays there. A shadow run answers *"what would this controller
command NOW, from where the plant really is"* — a true statement about the
present — and **not** *"what trajectory would it have followed"*. The trajectory
is a closed-loop property, it did not happen, and reporting it would be a
simulation of a plant that does not exist; three cycles in, such a number
describes nothing.
⚠️ **AMENDED BY SAFETY-47 — THE PARAGRAPH THAT STOOD HERE WAS THE DEFECT.**
It read:
> The strategies are unaffected: arbitrage, the peak guard and maintenance all
> file `SET` claims at a fixed power from level-triggered inputs. That is why
> the three defects this card was written for — SAFETY-45's 28 onsets an hour,
> TARIFF-14's wrong quarter-hour, SAFETY-46's ceiling churn — are all visible
> from a shadow run, and the loop's ramp is not.
It was true until **SAFETY-45 merged, one commit before this card**. That
change makes the buy leg `min(asked, headroom)` with
```
headroom = (900 / remaining) x (ceiling - projected)
```
and `projected` reads the **open quarter's accumulator** — the meter — which is
a function of what the battery did earlier in that same quarter. The strategy
is closed-loop, and the loop's one-slew-step limitation above is not a special
case of anything: **it is the general case**, and the limitation is the whole
feature's, not the loop leg's.
**The correct statement, and it is now graded rather than asserted:**
> A shadow run cannot surface a **closed-loop** defect — one whose stimulus is
> the plant's response to the controller — because the battery is held at 0 W
> and there is no response to be the stimulus. **Which** hour a strategy picks
> and **why** are unaffected, because the clock and the price series are not
> functions of the battery. **How hard** it trades is not.
Of the three defects this card was written for:
| | visible from a shadow run? | measured |
|---|---|---|
| SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow; grid −455.5 … +3200.0 W live, **flat at the 1200 W house** in shadow; guard engaged 410/720 live, **0/720** in shadow |
| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. No SoC limit can be *reached* in one |
| TARIFF-14's wrong quarter-hour | **yes**, and it is the one | which hour, at what price, clearing what spread: identical live and shadow on **720 of 720** charging cycles, at a binding cap |
Graded in `test_arbitrage.py` part **3d** — the same closed loop part 3c uses,
run once live and once in shadow — and the loop leg's own limitation stays
named in `test_watchdog.py` part 10 as `RECORDED LIMITATION`.
---
## 6. How "nothing reaches the inverter" is graded
**Not on a flag.** A check that asserted `Controller.shadow` would pass on a
build in which the flag is set and the substitution is deleted — which is
exactly mutation **M1**.
`test_clamp.py` part 8 drives the **shipped** `Controller.cycle()` and the
shipped heartbeat against a recording Home Assistant stub, and asserts on what
reached `set_number()`:
* **the precondition first** — the same stimulus with `shadow_mode: off` puts
30 non-zero writes on the wire, the largest 5000 W. MUTATION.md habit 4: a
check whose data cannot reach the failure it names is decoration;
* then `set(writes) == {0.0}`, **and** that there were 26 of them — a check
that only said "no non-zero write" would pass on the silent build too;
* entry and exit are measured on `inv.last_write` — what the inverter is
latched on, updated only on an **accepted** write — either side of the
boundary, against the same run that never entered shadow mode.
The closed-loop half is `/home/homelab/s10-mut/shadowloop.py` on TEST-23 stage
3's rig: the same hour, twice, against `plantsim.Plant` through the firmware's
own frame encoder.
⚠️ **SUPERSEDED BY SAFETY-47 — THE TABLE THAT STOOD HERE COULD NEVER HAVE GONE
RED, FOR TWO INDEPENDENT REASONS.** It read `arb_power_w` −2000 W *"identical"*,
`arb_reason` *"identical, verbatim"*, arbiter winner *"identical"*, and
concluded *"the decision happened, at the same instant, for the same reason, at
the same signed power — and the battery did not move."* The battery half is
true and is unchanged. The **decision** half was decoration:
1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the
capacity guard never engages and SAFETY-45's sizing is in the run and inert.
The harness's own comment says so — *"at this scenario's `peak_cap_w: 15000`
it is not binding anyway"* — which is MUTATION.md habit 4 written down
beside the check it disables;
2. **every identity row reads the FIRST charging sample.** At `elapsed = 0`
the projection *is* the held level, so the two runs agree to the float there
**even at a cap that does bind**. Measured: closing reason (1) alone leaves
that comparison passing.
Both had to be closed. Re-taken below at **`peak_cap_w` 1500 / house 1200 /
`max_w` 2000**, one hour at a 5 s cycle, **across the whole run** — harness
`/home/homelab/s47-mut/shadowloop47.py`, same rig, same three transcribed
lines.
| | live | shadow |
|---|---|---|
| grid | 1224.1 … 1506.0 W — **held up at the ceiling** | **1200.0 W flat = the house, exactly** |
| SoC | 50.000019 → **51.856789 %** | 50.000000 → **50.000000 %** |
| `ARBITRAGE_CHARGE` cycles | 720 / 720 | **720 / 720** |
| first charging cycle, `arb_power_w` | −300.0000 W | **identical — and this is why the old table passed** |
| `arb_power_w`, **whole run** | −307.799 … −299.930 W | **−2000.000 … −300.000 W** |
| …cycles where the two differ | — | **716 of 720** |
| `arb_reason`, whole run | the strategy's own sentence | **differs on 719 of 720** |
| measured charging headroom | 299.93 … 307.80 W | **300.0 … 54 000.0 W** |
| final recorded command | **−302.4 W** | **−2000.0 W — 6.61× the live figure** |
| price / spread / which hour | | **identical on 720 of 720** |
| arbiter winner | `arbitrage` | **identical** |
**What the re-taken table says.** The decision still *happens*, at the same
instant, on the same hour, at the same price, for the same *kind* of reason,
and the battery still does not move — those were the card's real claims and
they hold. What does **not** hold is that the decision is the *same decision*:
its **size** is a function of a meter the battery never moved, and at this cap
the recorded figure is 6.61× what a live controller commands. 54 000 W of
"headroom" under a 1500 W ceiling is `900/remaining` multiplying a gap the
battery never closed.
⚠️ **And the SoC row is the second half of it.** 0.000000000 pp of movement is
not a curiosity: it is the proof that **no SoC-limit behaviour is reachable
from a shadow run at all**, which §5 used to promise.
SAFETY-47 puts the caveat on both surfaces that carry the number and narrows
the three documents' claims; `test_arbitrage.py` part 3d is the CI-side grader
so this class cannot recur.
⚠️ **Three lines of each harness are a TRANSCRIPTION and are marked as one.**
`priceloop.Rig` has no `_write_unit()`, no clamp and no failsafe machine
(divergence 2 of its README), so the substitution's **placement** is
transcribed there. The shipped placement is graded by `test_clamp.py` part 8,
which drives the real `Controller`. ⚠️ **And the rig's transcribed cycle
predates SAFETY-45**: `arbitrage.decide()` gained a required
`charge_headroom_w` after `478ba93`, and the harness supplies it from the
**shipped** `peakguard.charge_headroom_w()` reading the **shipped** peak
decision rather than from a constant, so SAFETY-45 stays in the run.
---
## 7. The mutation battery — 22 mutations, no survivors
Harness: `/home/homelab/s10-mut/run.py`, backups beside it. Each run restores
from backup, purges `__pycache__` **on the host**, and grades through the CI
image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache
into the tree under test (TEST-15). The full table is in the branch report;
one per new behaviour, including **M1** (the substitution deleted — a write
escapes), **M2** (it returns instead of substituting — the inverter is left
latched), **M3** (it moves above SAFETY-28 and SAFETY-03 — the observers go
blind), **M8** (`auto` resolves to shadow unconditionally — the literal
default-on that stops the live install), **M12** (the stop claim is filed in
shadow — every recorded decision reads `stopped`), **M21** (the substitution
and the re-seed together) and **M22** (the re-seed moved one line earlier —
SAFETY-18 grades a 0 W no decision produced and goes permanently blind).
⚠️ **Two findings from driving it, both of the kind MUTATION.md says to expect.**
* **M2 first came back a SURVIVOR and was not one.** The "stop writing
altogether" build left `inv.last_write` as `None`, the check did `float()` on
it, and the section died with a `TypeError` — grading none of the checks
after it and reporting **zero reds**, which reads exactly like a survivor.
That is FCST-06's shape. The fixture now hands `None` back and names it as
its own check; M2 is red on three.
* **M6 had to become a PAIR, and only the battery said so.** `standing_w()`
prefers `last_write` — the 0.0 W the wire gate accepted — and falls back to
`inv.target`, which the re-seed also holds at 0.0. Either rung alone keeps
the detector inert, so dropping the re-seed **singly** came back red on the
limitation check and on **nothing about the latch**: the hazard it names was
not reproduced. TEST-16's rule with a new name on it.
* **M7/M8/M9/M19 had to MOVE SUITES.** They defeat `shadow.engaged()`, and a
defeated default stops `test_clamp.py` part 3's wired run reaching the wire
at all — aborting the file with an `IndexError` before part 8 is graded. The
red count there was a lower bound naming the saturation freeze. The mode
table now lives in `test_validate.py`, which drives no Controller.
* **`shadow_would_w = float(self.target)` is an EQUIVALENT mutation and was
measured as one.** `_shadow_settle()` runs before the re-seed, so at that
line `self.target` **is** `resolution.target_w`. It is recorded as equivalent
rather than reported as a survivor, and the non-equivalent form of the same
defect — the record becoming the **wire** — is driven instead.
### The table
Each row: the mutation, the file, the byte delta, the suite it was graded in,
and how many **named** checks went red. `⚠️` marks a row whose run did not
reach the end of its file, so its count is a **lower bound** (MUTATION.md).
| # | mutation | file | Δ | suite | reds |
|---|---|---|---|---|---|
| M1 | **a write escapes** — the substitution deleted | `main.py` | −6 B | clamp | 5 |
| M2 | **silence instead of 0 W** — it returns rather than substituting | `main.py` | −15 B | clamp | 3 |
| M3 | **the observers go blind** — it moves above SAFETY-28 and SAFETY-03 | `main.py` | +58 B | clamp | 1 |
| M4 | the site re-seed dropped — the exit resumes from a fiction | `main.py` | −36 B | clamp | 3 |
| M5 | the per-unit re-seed dropped | `main.py` | −22 B | clamp | 1 |
| M6 | **PAIRED**: `standing_w()` reads the target **and** the re-seed dropped — the sign latch that survives into live operation | `inverter.py` + `main.py` | −170 B | watchdog | 3 |
| M7 | `auto` inverted — shadow while started, live while stopped | `shadow.py` | +2 B | validate | 4 |
| M8 | **default-on for everyone** — `auto` always shadow; the literal reading that stops the live install | `shadow.py` | −15 B | validate | 2 |
| M9 | an unreadable value resolves to LIVE instead of shadow | `shadow.py` | +1 B | validate | 1 |
| M10 | `validate.py` stops refusing an unknown value | `shadow.py` | −9 B | validate | 9 |
| M11 | the loop leg gated on `auto` alone again — the decision path stops running | `main.py` | −17 B | clamp | 1 |
| M12 | the stop claim filed in shadow — every decision reads `stopped` | `main.py` | −20 B | clamp | 1 |
| M13 | the banner goes green while nothing is commanded | `shadow.py` | −2 B | clamp | 1 |
| M14 | the banner loses the standing latch | `shadow.py` | −2 B | clamp | 1 |
| M15 | the row renders the standing command instead of the decision | `shadow.py` | −8 B | clamp | 1 |
| M16 ⚠️ | the row disappears when live | `main.py` | +43 B | clamp | 1 |
| M17 | the record becomes the wire | `main.py` | −6 B | clamp | 7 |
| M18 | level-logged — the 40-row action log floods | `main.py` | −23 B | clamp | 1 |
| M19 | the two rungs render identically (masking) | `shadow.py` | +22 B | validate | 1 |
| M20 | the shipped default moves to `off` in `config.yaml` alone | `config.yaml` | −1 B | defaults_pin | 1 |
| M21 | **PAIRED**: the substitution **and** the re-seed deleted together | `main.py` | −103 B | clamp | 10 |
| M22 | the re-seed moved one line earlier — SAFETY-18 grades a 0 W no decision produced and goes permanently blind | `main.py` | +40 B | clamp | 1 |
**Survivors: none.** ⚠️ **M17's `+0 B`-adjacent sibling** —
`shadow_would_w = float(self.target)` — is recorded in §7 as **equivalent**
rather than counted here.
@@ -0,0 +1,319 @@
# SAFETY-47 — what a shadow run can show, and what it cannot
**Status:** decided and implemented on branch `SAFETY-47`.
**Card:** SAFETY-47 — *"shadow mode's claims, and the number on the status
row"*.
**Nothing here reached the inverter.** No deployment, no `ha addons update`, no
contact with 192.168.2.158 or 192.168.2.221. Everything below was measured in
the CI image and on TEST-23 stage 3's closed-loop rig.
⚠️ **This is not a write-path defect and nothing in the write path was
touched.** SAFETY-10's substitution, re-seed, authorisation gate and clamp are
unchanged. What was wrong is what three merged documents **say** a shadow run
can show you, and a number on the status row that can be **6.61×** what a live
controller would command.
---
## 0. The four things a reader should take away
1. **Shadow mode cannot surface a closed-loop defect. Not partially — not at
all**, and by the same construction that makes it safe. §1.
2. **The "strategies are unaffected" exemption was true until SAFETY-45 merged,
one commit before SAFETY-10.** Sizing the buy leg to the billed-quarter
headroom made the strategy closed-loop. §2.
3. **The recorded watts are the right answer to a question nobody asked, and
they are CAVEATED rather than re-computed.** Re-computing would mean the
recorded decision was no longer the decision the shipped path reached, which
is the one thing shadow mode has to be able to say. §3 records what was
rejected.
4. **SAFETY-10 §6's committed evidence could never have failed, for two
independent reasons, and closing either one alone closes nothing.** §4.
---
## 1. Why the answer is "not at all", and not "not very well"
Shadow mode holds every actuator at `shadow.WIRE_W` and re-seeds the carried
command to it at the end of each cycle. So every **measured** input — the
meter, the billed-quarter accumulator behind it, the battery power, the state
of charge — describes a plant that **did not respond**.
A closed-loop defect is, by definition, one whose stimulus **is** the plant's
response to the controller. No response, no stimulus, nothing to see. This is
not an implementation gap:
* feeding the guards a **simulated** plant instead is exactly what SAFETY-10 §4
refuses for the sign watchdogs, and the reason generalises — a controller
reacting to a plant that does not exist drifts further from reality every
tick, and three cycles in its numbers describe nothing;
* and SAFETY-10 §5 already conceded the same fact for the grid-following loop
("one slew step, not a trajectory"). That was never a property of the loop.
**It is the general case**, and the loop leg was simply where it had already
been noticed.
**So the closed-loop answer comes from a closed-loop rig (TEST-23 stage 3) or
from a live run. There is no third place, and saying that plainly is worth more
than the three sentences it replaces.**
### Measured, on the rig, at a cap that binds
`peak_cap_w` 1500 / house 1200 / `max_w` 2000, one hour at a 5 s cycle. Of the
three defects SAFETY-10 was written for:
| | visible? | measured |
|---|---|---|
| SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow. Grid −455.5 … +3200.0 W live, **flat at the 1200 W house** in shadow. Guard engaged 410/720 cycles live, **0/720** in shadow. (Taken with SAFETY-45 reverted — that is the defect being looked for.) |
| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. The SoC is the pack's integral of what the pack was commanded, and it was commanded 0 W. No SoC limit can be *reached* in a shadow run at all |
| TARIFF-14's wrong quarter-hour | **yes**, and it is the only one | the price lookup's inputs are the clock and the price series, neither a function of the battery. Which hour, at what price, clearing what spread: **identical live and shadow on 720 of 720 charging cycles, at a binding cap** |
### What a shadow run genuinely is for
*Which* strategy wins, on *which* hour, at *which* price, for *which* reason,
against which limits — the wrong-quarter-hour class, a trade armed on a day it
should not be, a refusal whose sentence names the wrong subsystem, a clamp that
would have engaged, a standing failsafe latch. That is a real and useful set,
and it is the set the documents now claim.
---
## 2. The exemption, and exactly how much of it survives
All three documents carried a version of:
> The **strategies** are unaffected — price arbitrage, the capacity guard and
> maintenance all ask for a fixed power **from inputs that do not depend on
> what the battery did**.
**True before SAFETY-45, false after it.** SAFETY-45 makes the buy leg
`min(asked, headroom)`, and
```
headroom = (900 / remaining) x (ceiling - projected)
```
where `projected` reads the **open quarter's accumulator** — the meter — which
is a function of what the battery did earlier in that same quarter. SAFETY-45
merged one commit before SAFETY-10, so the exemption was stale on the day it
was written.
**What survives, and it is now graded rather than asserted:**
> **Which** hour a strategy picks, and **why**, are unaffected — the clock and
> the price series are not functions of the battery.
> **How hard** it trades is not.
⚠️ **The peak guard is not in the surviving half either.** Every term of
`wanted = batt_w + excess` is measured, so in a shadow run the guard engages
only if the *house alone* would put the quarter over the ceiling. Its
**ceiling** and its **reasons** are worth reading from a shadow run; its
**engagements** are not evidence of anything.
---
## 3. The number on the status row — decided, and what was rejected
At `peak_cap_w` 1500 under a 1200 W house the status row read **"WOULD HAVE
COMMANDED −2 000 W"**. A live controller commands **−302.4 W**. Measured on the
shipped build:
| cap / house | live commanded | shadow recorded | over by |
|---|---|---|---|
| 1500 / 1200 | −302.4 W | **−2000.0 W** | **6.61×** |
| 3500 / 3000 | −502.6 W | −2000.0 W | 3.98× |
| 3500 / 2000 *(shipped cap)* | −1502.6 W | −2000.0 W | 1.33× |
| 3500 / 1800 | −1702.6 W | −2000.0 W | 1.17× |
| 15000 / 350 | −2000.0 W | −2000.0 W | 1.00× |
⚠️ **The arithmetic is not wrong.** `900/remaining` is correct for a meter that
is genuinely under the ceiling, and in a shadow run the meter genuinely *is*
under it, because the battery is not charging. **The recorded number is the
right answer to a question nobody asked** — and the last row of that table is
the fixture SAFETY-10's own evidence was taken on, which is why nothing
reported it.
### What shipped
1. **Unconditionally**, the row and the log line stop forecasting: the watts
are introduced as what would have been commanded **ON THIS CYCLE**. No
number a shadow run records is a prediction of a live run, so no state
should read like one.
2. **Conditionally**, whenever a **measured** billed quarter sized the number
being recorded, both surfaces carry `shadow.SIZED_FROM_METER`:
> ⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE
> HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same
> trade against a meter the battery is moving, so this is an UPPER BOUND and
> not a prediction; how far under it a live run lands is a closed-loop
> question and a shadow run cannot answer it.
⚠️ **"Upper bound", and not "smaller", and the difference was measured.**
Charging only *adds* offtake, so a live `projected` is never below a shadow one
and the headroom never above it — with **equality at the start of every
quarter**, where `900/remaining` is 1.0 and neither plant has moved. Measured
pairwise over 3600 charging cycles in five configurations: **0 cycles below,
730 exactly equal.** A sentence reading "smaller" would have been false on
every one of those 730, and the first draft of this card said "smaller".
⚠️ **And it names no factor.** How far under the bound a live run lands is a
property of the site. A number invented for the row would be a second helping
of the defect the sentence exists to disclose.
⚠️ **The predicate is NOT keyed on whether the trade was capped, and that is
the trap this card nearly walked into.** `arbitrage`'s own sentence says *"SIZED
BY THE CAPACITY-TARIFF GUARD"* only when the headroom came out **below** the
ask — which in a shadow run is the **start** of the quarter, where the two runs
still agree to the float. By the end, `900/remaining` has multiplied the
headroom **past** the ask, the trade is **not** capped, the sentence is silent,
and the recorded number is at its **most** wrong. A caveat keyed on it would be
quietest exactly where it is needed loudest. `arbitrage.sized_from_meter()` is
keyed on the headroom having been **measured**, so it fires on both.
`test_arbitrage.py` part 3d-8 drives the inversion.
### Rejected, and why
* **Re-compute the number so it does not amplify** — e.g. record
`ceiling − projected` (gain 1.0) instead of the sized figure. It matches the
live answer almost exactly at this cap, and it is the **worst** of the
options. It would be a **shadow-only sizing**: the recorded decision would no
longer be the decision the shipped path reached, while the arbiter, the
clamp, SAFETY-28's gate, the action log and MQTT all ran on the real one. A
second copy of the sizing arithmetic living in the display layer is
MUTATION.md's transcribed-predicate defect on an operator-facing surface, and
it generalises to no other strategy. SAFETY-10's whole safety argument is
that shadow mode is a **substitution at the wire**, not a second controller;
this would have made it a second controller everywhere except the wire.
* **Record nothing where the sizing depends on measured state.** Tempting —
"no number beats a number 6.61× out". Rejected on three counts: it blanks the
row exactly when arbitrage is trading, which is when a commissioning engineer
most wants it; the action log's edge key is the rounded watts, so blanking
them either floods or silences the log; and a surface that flickers between a
number and a dash is harder to read than a number with a standing caveat.
The narrow version of this — dropping the watts only — was the closest
runner-up.
* **Record the unsized ask alongside the sized one.** It adds a number without
adding a warning. The unsized ask is `max_w`, a constant the operator
configured; on the shipped 3500/2000 row both figures are 2000 W, so the pair
is exactly as misleading as the single figure was.
* **A caveat on every cycle in every state.** Rejected as the *whole* answer,
accepted as half of it: the unconditional part is a two-word narrowing of the
verb ("on this cycle"), not a warning, and the warning proper is conditional
so that it still means something when it appears.
---
## 4. SAFETY-10 §6's evidence, and why it could not fail
§6 tabulated `arb_power_w` −2000 W *"identical"*, `arb_reason` *"identical,
verbatim"*, arbiter winner *"identical"*. Two independent reasons it could
never have gone red:
1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the guard
never engages and SAFETY-45's sizing is in the run and inert. ⚠️ **The
harness's own comment says so** — *"at this scenario's `peak_cap_w: 15000`
it is not binding anyway"* — which is MUTATION.md habit 4 written down
beside the check it disables;
2. **every identity row reads the FIRST charging sample.** At `elapsed = 0` the
projection *is* the held level, so the two runs agree to the float there
**even at a cap that does bind**.
⚠️ **AND THAT SECOND REASON IS THE ONE A REVIEWER CANNOT SEE FROM A DIFF.**
Widening the fixture alone — the obvious fix — leaves the `[0]` comparison
passing. Measured, not reasoned: at 1500/1200 the first charging cycle is
−300.0000 W in **both** runs while 716 of the 720 cycles differ. Part 3d-2
grades that agreement **as an expectation**, so nobody rebuilds a check on
`[0]` believing it covers something.
This is habit 4's **twelfth** instance in this repository, its **second**
inside a committed evidence artifact, and — like the seventh and the eleventh —
it was in a **measuring instrument** rather than in a check.
§6 is re-taken at `peak_cap_w` 1500 / house 1200 / `max_w` 2000, across the
whole run. Harness `/home/homelab/s47-mut/shadowloop47.py`.
---
## 5. The grader, so this class cannot recur
`test_arbitrage.py` part **3d** — placed beside part 3c, which is SAFETY-45's
own closed-loop regression, and built on the **same** `s45_loop()` harness so
the two runs share every line of shipped code and differ in **one argument**:
```
s45_loop(sized=True) the live hour
s45_loop(sized=True, shadow=True) the same hour, battery held at WIRE_W
s45_loop(sized=False, shadow=True) SAFETY-45's own defect, watched from
inside shadow mode
```
Three lines of `s45_loop()` stand in for `Controller._write_unit()`'s
substitution and `_shadow_settle()`'s record and re-seed. They are marked as a
**transcription**; `test_clamp.py` part 8 is the check on the shipped
placement, and it drives the real `Controller`. Everything that produces a
number — the averager, the projection, the guard, the headroom, the strategy,
the arbiter, the row and the log sentence — is the shipped one.
What 3d grades, in order: that the cap **binds** (the precondition the old
evidence lacked); that the first charging cycle agrees **anyway**; that the
whole run diverges, and by how much; that the record is an **upper bound**, and
the two-point arithmetic that makes it one; what survives of the exemption;
that SAFETY-45's limit cycle and the guard's engagements are **not** visible;
the four rungs of `sized_from_meter()`, each reached with the other three clear
(TEST-16); both operator surfaces; the `capped`-keying trap; and main.py's
three call sites, pinned on the AST.
⚠️ **The section carries its own precondition into every check.** A mutation
that stops either run trading left the lists empty, the first `min()` raised,
the file died before part 4 and the run reported **zero reds — which reads
exactly like a survivor**. That is FCST-06's shape and it was **driven**, not
imagined. `s47check()` now fails every check in the section when the stimulus
was not produced, and the one division in it answers `nan` rather than raising.
---
## 6. The mutation battery — 14 mutations, no survivors
Harness `/home/homelab/s47-mut/run.py`, backups inside it. Each run restores
from backup, purges `__pycache__` **on the host**, and grades through the CI
image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache
into the tree under test (TEST-15). Fingerprints are printed before the first
run and re-checked after the last restore.
| # | mutation | file | Δ | reds |
|---|---|---|---|---|
| M1 | the row drops the caveat branch | `shadow.py` | −63 B | 2 |
| M2 | **the trap**: `sized_from_meter()` keyed on the trade being CAPPED — silent where the record is multiples out | `arbitrage.py` | +33 B | 5 |
| M3 | the CHARGE rung dropped — a sell carries a caveat §9.1 makes false | `arbitrage.py` | −30 B | 1 |
| M4 | the measured-headroom rung dropped | `arbitrage.py` | −18 B | 1 |
| M5 | the arbiter-winner rung dropped — a caveat on somebody else's number | `arbitrage.py` | −33 B | 1 |
| M6 | `main.py` renders the row with `sized_from_meter=False` | `main.py` | −23 B | 1 |
| M7 | `main.py` drops `winner=` — the rung exists and is never reached | `main.py` | −1 B | 1 |
| M8 | `main.py` sizes the trade from a literal — SAFETY-45 out of the shipped path, which the hoist put at risk | `main.py` | −41 B | 2 |
| M9 | the headroom assignment moves out of `cycle()` | `main.py` | −39 B | 1 |
| M10 | `sized_from_meter` gains a `False` default (TEST-14) | `shadow.py` | +8 B | 1 |
| M11 | `ON THIS CYCLE` deleted — the row forecasts again | `shadow.py` | −14 B | 1 |
| M12 | `log_line()` drops the caveat — the page discloses, the record does not | `shadow.py` | −63 B | 2 |
| M13 ⚠️ | `main.py` re-assembles the log sentence at the call site | `main.py` | **+0 B** | 1 |
| M14 ⚠️ | `wanted = batt_w − excess` — the headroom stops being monotone in the battery and the row's UPPER BOUND sentence becomes false | `peakguard.py` | **+0 B** | 38 |
**Survivors: none.**
⚠️ **M13 and M14 are `+0 B`, which MUTATION.md names as the risky delta.** Both
were confirmed on disk by `sha256` before the run started — the harness prints
the fingerprint of every graded file and the hash moved for each — and every
run is purged, so neither is at risk of the stale-`.pyc` failure.
⚠️ **Two findings from driving it, both reported rather than tidied away.**
* **M14's first form was aimed at the wrong direction and correctly survived.**
`min` → `max` in the buy leg's sizing makes the shadow record *larger*, which
is still an upper bound, so 3d-3b was right not to fail. The mutation that
actually inverts the bound is the sign of the excess term in `wanted`. A
survivor that is right is worth more than a red aimed at the wrong claim.
* **M12 was a survivor before the fix it forced.** Deleting the caveat from the
action-log line left **all 29 files green**: the only check that could see
that line drives a fixture with `meter_source: off`, where no arbitrage
decision — and therefore no caveat — is reachable. The sentence was an
f-string inside `Controller._shadow_settle()`, i.e. a surface no check could
grade. It is `shadow.log_line()` now, for the reason `words()` is a function,
and M13 is the mutation that puts it back at the call site.
@@ -0,0 +1,182 @@
# TEL-10 — the loop's integrator is published, as three site rows
**Status:** decided and implemented on branch `TEL-10`.
**Card:** TEL-10 — *"`self.i_w` is carried between cycles and published nowhere"*.
**Nothing here reached the inverter, and nothing here can.** `app/control.py`
is not touched by this branch; `compute()`, its returned `Decision` and every
constant it reads are byte-identical to `release/1.0`. The diff is a controller
recording three values it already had, and a publisher announcing three rows.
---
## 0. The three things a reader should take away
1. **The row that matters is the one that is usually indistinguishable from
`Setpoint`.** With nothing binding, the command *is* the integrator rounded
to 10 W, so on a quiet day publishing the wrong one looks correct. §2 is
the fixture that separates them by kilowatts, and §4 is the mutation that
proves the separation is what the check reads.
2. **`Loop reason` is a genuinely new sentence, not a second copy of one.** The
status page's `Why` row and `sensor.goodwe_controller_status` both carry the
*arbiter's* line (`loop -> -5000 W`), which prints the winning claim's NAME.
`compute()`'s own reason has never left the process, and `slew-limited` — the
value SAFETY-48 is about — appears in neither of the other two. §3.
3. ⚠️ **Three entities are three entities.** This is an addition to a live
install's entity list, which TEL-07 refused to make for four per-unit rows
at a roster of one. The argument for making it here is in §5, and so is the
argument that keeps it to three rather than to one per inverter.
---
## 1. Why this is a card at all
`self.i_w` has been a separate quantity from the command since SAFETY-04, which
split the accumulator out of the velocity form precisely so the two could be
bounded apart. Since then it has been:
* carried between cycles by `Controller.cycle()`,
* read by nothing outside `compute()`,
* and written to no entity, no MQTT topic, no attribute and no log line.
The consequence is not theoretical. The investigation behind SAFETY-48 needed to
know what the integrator was doing during the export episodes and could only
**reconstruct** it, from the grid trace plus a replay of the real `compute()`.
The reconstruction reproduced about a third of the engagements it was checked
against and was **discarded entirely** — so the card it was gathered for had to
be argued from a measurement nobody could stand behind.
⚠️ **And the reconstruction could not have been made to work.** `i_w` at a given
instant depends on every cycle before it, including every one in which the
arbiter overrode the loop and `main.py` re-seeded `self.i_w = self.target`. That
re-seed is invisible in the grid trace. A replay can only recover the integrator
on a run where the loop won every arbitration, which is not the run anybody is
investigating.
## 2. What is published, and against what
Three rows, on the SITE device, appended after `release/1.0`'s eight sensors:
| key | entity | what it is |
|---|---|---|
| `loop_i` | `sensor.goodwe_loop_integrator` | `Decision.i_w` — the accumulator AFTER this cycle, BEFORE the output clamp, the slew limit and quantisation, which is what its own docstring says it is |
| `loop_lead` | `sensor.goodwe_loop_integrator_lead` | `decision.i_w - prev_w`, where `prev_w` is the command `compute()` was handed |
| `loop_reason` | `sensor.goodwe_loop_reason` | `Decision.reason`, unmodified |
⚠️ **`loop_lead` IS NOT A SUBTRACTION OF THE OTHER TWO ROWS, WHICH IS WHY IT IS
A ROW.** `Setpoint` is the ARBITER's output, after every other strategy has had
its say, and it is THIS cycle's; the lead is measured against the command the
PREVIOUS cycle left standing. On any cycle where the arbiter overrode the loop,
subtracting the two published rows gives a different number — and that is
exactly the cycle on which `main.py` re-seeds the integrator, i.e. the cycle a
reader most needs the real figure for.
`cycle()` therefore records all three from the one `compute()` call, and names
the command it passes (`loop_prev`) rather than re-reading `self.target` at
publish time. `self.target` is reassigned later in the same method, so a lead
computed at publish time would subtract a different cycle's command and call the
answer a lead. ⚠️ **At the line as written the two expressions are the same
value, and the mutation that swaps them is a recorded SURVIVOR** — see §4 M2.
The name is kept for the coupling, not for a behaviour it currently buys.
### The not-running branch
`cycle()`'s `else` arm — stopped AND shadow mode off — already zeroes
`self.i_w`, because no accumulation may survive the outage. The three rows are
zeroed and `loop_reason` becomes `not-running` on the same branch. A row that
kept repeating the last integrator there would say the loop was winding while
nothing is being controlled.
⚠️ **`auto: false` ALONE IS NOT THAT BRANCH.** The shipped `shadow_mode: auto`
means a stopped controller still runs the whole decision path and records it
(SAFETY-10), so the loop is live and the integrator is real. The first draft of
the check asserted `not-running` about a loop that was running; it reads the
shadow case as its own claim now.
## 3. Why `loop_reason` is not the row that already exists
`self.reason` is `Resolution.explain()`:
```
"loop -> -5000 W" # winner, watts
"loop -> -5000 W, limited by soc" # ...and which limit bit
"CONTRADICTION between limits - commanding 0 W (loop)"
```
`Claim.set("loop", P_LOOP, decision.target_w, decision.reason, ...)` does carry
the loop's reason INTO the arbiter — and `explain()` prints the winner's
**source name**, never the claim's reason. So the loop's own word has always
been built, handed over, and dropped one line later.
Measured on the fixture in `test_telemetry.py` part 2, same cycle:
```
the arbiter's sentence: 'loop -> -5000 W'
the loop's own word : 'i-clamped'
```
## 4. Verified by breaking it
Nine mutations, run in the CI image on a purged tree, each fingerprinted before,
after, and again after the revert. The table with byte deltas and red counts is
in `test_telemetry.py`'s header; two entries are worth repeating here.
**M1 — `self.loop_i_w = loop_prev` (−3 B), the card's own named mutation:
publish the COMMAND under the integrator's name.** The first draft of the
acceptance check was *"the published integrator reaches its bound"*, and that
check was **GREEN** under M1 — because the command reaches the bound too, twelve
cycles later. MUTATION.md habit 4 in the check written to close the card. It
asserts the **lead time** now (the integrator is at the bound at least ten
cycles before the command is), and is red on 5 named checks.
**M2 — `decision.i_w - self.target` (+2 B) is a SURVIVOR, and is reported as
one.** At that line `cycle()` has not yet executed `self.target =
resolution.target_w`, so the two expressions denote the same value and the
mutation changes nothing that runs. The run was purged, which is not the
direction stale bytecode fabricates. M2b (`decision.i_w - decision.target_w`,
+8 B) is the mutation that does change the value and is red.
## 5. What was rejected
**Publishing as an MQTT attribute on an existing entity.** There is no
`json_attributes_topic` anywhere in this add-on; adding one would be a second
convention for one card, and HA's recorder keeps long-term statistics for a
state with `state_class`, not for an attribute. The reason this card exists is
that nobody had history to read.
**A status-page row instead.** The page is not recorded. `Why` already carries
the arbiter's sentence and stays that way; DOCS.md gains the section that says
how to read the three entities.
**One integrator row per inverter.** §6.2's architecture is ONE integrator on
the P1 meter whose output is then SPLIT. A per-unit copy would invent N of a
thing there is one of, and the two copies would disagree the moment the
allocator gave the units different shares. `test_mqtt.py` part 5 grades this at
every roster length from 1 to 6, and the mutation that moves `loop_i` into
`UNIT_SENSORS` is red on 8 named checks.
**Adding the rows at N≥2 only, the way TEL-07 gates its per-unit rows.**
TEL-07's gate exists because at a roster of one its four rows carry information
the site rows already carry exactly. These three carry information NOTHING
carries, at every roster length, including one.
**A `## Unreleased` block in `CHANGELOG.md`.** `test_version_gate.py` part 11b
refuses a changelog whose newest `## ` heading is not `config.yaml`'s
`version:` — PKG-14's rule that the bump and the closed section are one act.
Opening a section here would mean bumping the version, which is a packaging
decision this card does not get to take. The operator-facing account is in
DOCS.md; the release note belongs to whichever PKG ticket cuts the next
version, and this file is what it should be written from.
## 6. What this card does NOT establish
* **It says nothing about whether the integrator's behaviour is right.** That is
SAFETY-48. This branch adds an observable and changes no decision; the
15.7-day replay that grades the behaviour is in that card.
* **The three rows do not exist on the live install until the add-on is
updated**, and this branch does not update it. `tools/rehearsal/announced-
reference.txt` (what the BUILD announces) records them; `live-entities-
recorded.txt` (what the INSTALL has) deliberately does not, because an id the
build announces and the install lacks is a new entity, not an orphan.
* **`loop_lead` is signed and is not a distance.** Where the integrator and the
command have opposite signs it is the sum of two magnitudes. A reader wanting
"how far behind is the command" should take the magnitude and say so.
+213 -3
View File
@@ -287,8 +287,10 @@ it always has.
published — `Setpoint`, `Grid power`, `Battery power`, `Battery SoC`,
`Circulating power`, `Maintenance phase`, `Controller status`, `P1 sample age` —
keep their device, their `unique_id`s and therefore their **entity ids and their
whole recorded history**. At a roster of one, nothing new is announced at all:
no second device, no renamed entity, not one added topic.
whole recorded history**. At a roster of one, nothing new is announced by the
per-unit machinery at all: no second device, no renamed entity, not one added
topic. (TEL-10's three loop rows, below, are site rows and are added at every
roster length, including one.)
Declare a second inverter and **four rows per unit** appear beside them:
@@ -321,7 +323,53 @@ arrives, and it fails silently and looks correct.
There is one P1 meter and one grid connection; a per-inverter copy would invent
two of a thing there is one of, and `P1 sample age` is a safety input. Same for
`Circulating power` (it is a property of the *set* of inverters), the controller
status, the maintenance phase and the capacity-tariff peak.
status, the maintenance phase, the capacity-tariff peak and the three loop rows
below.
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
The control law is a bounded integrator: an accumulator that is **not** the
command. Each cycle it moves by `gain × error`; the command is then derived
from it and passed through the output clamp, the slew limit and a 10 W
quantisation before anything is written. Three entities publish what the
accumulator is doing:
| entity | unit | what it is |
|---|---|---|
| `Loop integrator` | W | the accumulator **after** this cycle — the value *before* the clamp, the slew limit and quantisation |
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
| `Loop reason` | — | the law's own word for this cycle |
`Loop reason` takes one of eight values:
| value | what happened |
|---|---|
| `tracking` | ordinary regulation |
| `deadband` | the error is inside `deadband_w`; the loop is holding |
| `i-clamped` | the integrator hit `integrator_max_w` (which follows `max_w` by default) |
| `clamped` | the *command* came out over `max_w` — only reachable if `integrator_max_w` is deliberately set above the rail |
| `slew-limited` | the command could not move as far as the integrator asked for; `slew_w` is what stopped it |
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
often disagree.** `Why` is the *arbiter's* sentence — `loop -> -1200 W` — which
names the strategy that won and the watts it won with. It has never carried the
strategy's own reason, so a loop that is slew-limited or frozen says so **only**
here.
⚠️ **`Loop integrator` is not `Setpoint`, even though it usually looks like
it.** With nothing binding, the command *is* the integrator rounded to 10 W, so
on a quiet day the two rows track each other and the difference is a rounding
step. They separate exactly when something is wrong: a large disturbance sends
the integrator to its bound in one or two cycles while the command may only
travel `slew_w` per cycle, and `Loop integrator lead` is the gap. A standing,
large lead means the loop has wound up further than the command can follow, and
the number of cycles needed to unwind it is that lead divided by `slew_w`.
Both numbers are `state_class: measurement`, so Home Assistant's recorder keeps
long-term statistics for them.
⚠️ **Two roster names that differ only in punctuation or case** — `inverter-b`
and `Inverter B` — are two names to the validator and one id once slugged. The
@@ -601,6 +649,168 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
| `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below |
#### Shadow mode (`shadow_mode`, SAFETY-10)
Shadow mode runs the **whole** decision path — the price strategy, the capacity
guard, the maintenance shaping, the grid-following loop, the arbiter that ranks
them, SAFETY-28's authorisation gate and SAFETY-03's clamp — and **sends nothing
to the inverter**. It is how you find out what this add-on *would* do on a site
before you let it do it.
Every defect in the optimiser that this project has found was invisible from
outside while it was happening: a price lookup answering for the wrong
quarter-hour, a trade against a binding capacity cap that ran 28 charge onsets
in an hour, an SoC ceiling churning 598 Wh to gain 310 Wh. Each one was paid for
at the meter first and noticed afterwards. Shadow mode is the surface that
would have shown all three on the day.
**What "commands nothing" means, exactly.** Every inverter is held at **0 W**,
written by the ordinary single writer and refreshed on the ordinary heartbeat.
It is not silence. ⚠️ **The inverter holds its last command forever** — it has
no meter timeout — so a mode that simply stopped writing would leave whatever
was standing when you switched it on standing for as long as you left it on,
under a page saying nothing was being commanded. Refreshing 0 W is also what
the ESP32's 30 s stale-input watchdog requires of us: going quiet would put
that board into a *tripped watchdog* state for the whole run, which is an alarm
being used as a mode.
**The three values.**
| value | what happens |
|---|---|
| `auto` | **the shipped default.** Shadow mode while control is **stopped**; ordinary control once control is **started**. A new install ships `auto_start: false`, so it comes up in shadow mode and observes before it ever commands anything. An install that is already controlling is started, so nothing about it changes |
| `on` | shadow mode **always**, whatever the *Start / stop control* button says. This is the one to pick to watch an armed site for a day — the loop runs, arbitrage decides, the peak guard files its claims, and the battery does not move |
| `off` | ordinary control |
⚠️ **Existing installs are not changed.** The default is `auto` rather than a
boolean `true` precisely because a boolean would have changed them: the
Supervisor composes an add-on's options as *its defaults, overlaid with your
stored options*, so a new key with a new default reaches every install on the
next update. `shadow_mode: true` would have silently stopped a battery that is
regulating today, and the only symptom would be a battery that stopped doing
anything.
⚠️ **Under `auto`, pressing *Start* leaves shadow mode.** That is the point of
`auto` — the button means "control the battery". Set `on` if you want the
decisions recorded while the loop runs.
**What you get out of it.** The status page carries a red-amber banner saying
the battery is not being controlled, a `Shadow mode` row saying which rung
decided, what it **would have commanded on that cycle** and why — with the
caveat below, where the number was sized from a meter the battery never
moved — and the `Why` row with the arbiter's own sentence. The action log gets one line each time the decision
changes — the winner or the watts — rather than one per cycle, so a 40-row log
still contains the context of what it found. MQTT reports `status: shadow`.
**The failsafes.** The sign watchdogs (SAFETY-02/30) are **inert**: they grade
what this controller has standing on the wire, and that is 0 W, so they cannot
arm. That is deliberate and it is the single most important decision in this
feature — fed the shadow decision instead they would disagree with a meter that
never saw it, and **latch a trip that survives into live operation and a
restart**. The clamp (SAFETY-03), the write-authorisation gate (SAFETY-28), the
frozen-meter alarm (SAFETY-18) and the capacity guard all **observe**: they run
on the number the decision path produced, so a shadow run can tell you the
clamp would have engaged. ⚠️ **The capacity guard observes a meter with no
battery in it.** Every term of the quantity it thresholds is measured, so it
engages in a shadow run only if the *house alone* would have put the quarter
over the ceiling — 410 engaged cycles live against 0 in shadow, on the hour
tabulated below. Its *ceiling* and its *reasons* are worth reading from a
shadow run; its *engagements* are not evidence of anything. The circulation (SAFETY-31) and delivery (SAFETY-42)
watchdogs observe and may legitimately latch, because their evidence is
*measured* power, not a command. The operator stop is enforced at the wire
instead of as a claim, so that the recorded decision is the real one.
⚠️ **A latch that was already up stays up.** Shadow mode does not clear
anything. The banner names any standing latch and goes red for it, because a
latch that is up while the controller is inert is up the moment it stops being
inert.
⚠️ **Maintenance is unchanged.** The maintenance state machines are ticked on
every cycle whether control is started or not, so they already run against a
battery that does not move on a stopped install. Shadow mode inherits that
exactly; it neither improves it nor makes it worse.
⚠️ **What a shadow run will NOT tell you — and it is a whole category, not a
footnote.** The controller's carried command is re-seeded to the 0 W standing
on the wire at the end of every shadow cycle — that is what makes turning
shadow mode off safe, because the loop then resumes from where the battery
actually is. Everything below follows from the same fact.
**A shadow run cannot show you a closed-loop defect. Not partially — not at
all.** The battery is held at 0 W, so every *measured* input — the meter, the
billed-quarter accumulator behind it, the battery power, the state of charge —
describes a plant that did not respond. A closed-loop defect is one whose
stimulus **is** the plant's response, and there is no response to be the
stimulus. This is not a gap waiting to be closed: feeding the guards a
simulated plant instead is exactly what the sign-watchdog decision below
refuses, and for the same reason — a controller reacting to a plant that does
not exist drifts further from reality every tick. **The closed-loop answer
comes from the closed-loop rig or from a live run, and there is no third
place.**
Concretely, measured on the rig at `peak_cap_w` 1500 under a 1200 W house,
`max_w` 2000, one hour at a 5 s cycle — the same hour run twice, live and in
shadow, on the same code:
| | live | shadow |
|---|---|---|
| charge onsets in the hour | **28** | **1** |
| grid | **−455.5 … +3200.0 W** | **1200.0 W flat — the house** |
| capacity guard engaged | **410 / 720 cycles** | **0 / 720** |
| state of charge | 50.000019 → 51.856789 % | **50.000000 → 50.000000 %** |
(The onset and engagement figures are taken with the trade-sizing fix reverted,
because that is the defect being looked for; the SoC and grid figures are the
shipped build.)
⚠️ **The "strategies are unaffected" exemption that used to stand here was true
until the trade-sizing fix landed, and is now false by hours.** That fix makes
the buy leg `min(asked, headroom)`, where the headroom is
`(900 / seconds remaining) × (ceiling − projected quarter average)` — and the
projection reads the **open quarter's accumulator**, i.e. the meter, i.e. a
function of what the battery did earlier in that same quarter. What survives
of the exemption is exactly this much:
> **Which** hour a strategy picks, and **why**, are unaffected — the clock and
> the price series are not functions of the battery. Measured: identical live
> and shadow on 720 of 720 charging cycles at a binding cap. **How hard** it
> trades is not.
⚠️ **So the watts on the `Shadow mode` row are the right answer to a question
nobody asked, and the row now says so.** In a shadow run the billed quarter
really *is* under its ceiling, because the battery really *is* not charging, so
`900 / remaining` multiplies a gap a live run would have closed. On the same
rig, shipped code:
| cap / house | live commanded | shadow recorded | over by |
|---|---|---|---|
| 1500 / 1200 | −302.4 W | **−2000.0 W** | **6.61×** |
| 3500 / 3000 | −502.6 W | −2000.0 W | 3.98× |
| 3500 / 2000 *(shipped cap)* | −1502.6 W | −2000.0 W | 1.33× |
| 3500 / 1800 | −1702.6 W | −2000.0 W | 1.17× |
| 15000 / 350 | −2000.0 W | −2000.0 W | 1.00× |
Whenever a measured billed quarter sized the number, the row and the action log
carry **⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE
HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W** — and the watts are introduced
as what it would have commanded **on this cycle**, in every state, because no
number a shadow run records is a forecast of a live one. The figure is **not**
re-computed to look right: a shadow-only sizing would mean the recorded
decision was no longer the decision the shipped path reached, which is the one
thing this feature has to be able to say.
The **grid-following loop** is the same story told first: its recorded decision
is **one slew step** and stays there, because a trajectory depends on a battery
responding and none did.
**What a shadow run genuinely is for**, then: *which* strategy wins, on *which*
hour, at *which* price, for *which* reason, against which limits — the
wrong-quarter-hour class of defect, a trade armed on a day it should not be, a
refusal whose sentence names the wrong subsystem, a clamp that would have
engaged, a standing failsafe latch. That is a real and useful set, and it is
the set this feature covers.
#### The sign-disagreement failsafe
+39 -1
View File
@@ -172,7 +172,7 @@ manufactures them from a **fixture**: the check runs, the assertion is
evaluated, the sentence above it is true — and the numbers it compares are
arranged so that the failure it describes could not have changed the answer.
It is not hypothetical and it is not rare. ELEVEN instances in two weeks; four
It is not hypothetical and it is not rare. TWELVE instances in two weeks; four
were found by review, two - CFG-04's fixture pair - by an author's own mutation
battery, which is the cheaper place, one by review again, in the one
place nobody had thought to point the rule at: not a check in the suite, but a
@@ -414,6 +414,44 @@ same place twice, the check is decoration.
The general habits are **14** and **15** below.
* ⚠️ **SAFETY-47 IS THE TWELFTH, AND IT IS THE SECOND INSIDE A COMMITTED
EVIDENCE ARTIFACT.** `DECISIONS/SAFETY-10-shadow-mode.md` §6 tabulated a live
run and a shadow run agreeing on `arb_power_w`, on the reason **verbatim**
and on the arbiter's winner, and concluded that shadow mode reaches the same
decision. **Two independent reasons it could not have gone red, and closing
either one alone closes nothing:**
* its fixture is `peak_cap_w: 15000` against a 350 W house, so the capacity
guard never engages and the thing whose invariance is being claimed —
SAFETY-45's sizing of the buy leg from the billed-quarter meter — is in the
run and **inert**. ⚠️ **The harness's own comment says so**: *"at this
scenario's `peak_cap_w: 15000` it is not binding anyway"*. The rule was
written down beside the check it disabled and nobody read it as a
disclosure;
* and every identity row reads `_sa[0]`/`_la[0]` — **the first charging
sample**. At `elapsed = 0` the projection **is** the held level, so the two
runs agree **to the float even at a cap that does bind**. Measured:
widening the fixture alone — the obvious fix — leaves that comparison
passing.
⚠️ **THE SECOND REASON IS A NEW SHAPE FOR THIS LIST: AN INDEX, NOT A
FIXTURE.** Ask of every identity claim not only *"could this data differ"*
but *"**at the point I sampled it**, could it differ yet"*. A comparison
taken at `t = 0` between two runs that diverge **because one of them acts**
is a comparison of a plant with itself, and no amount of widening the fixture
changes that. The replacement, `test_arbitrage.py` part 3d, grades the whole
run — 716 of 720 charging cycles differ — and grades the `[0]` agreement **as
an expectation**, so nobody rebuilds a check on it believing it covers
something.
⚠️ **AND THE FIX'S OWN FIRST DRAFT CARRIED A THIRTEENTH.** The new part's
action-log check could not reach the log line at all: the sentence was an
f-string inside `Controller._shadow_settle()`, and the only suite that drives
that method uses a `meter_source: off` fixture where no arbitrage decision —
and therefore no caveat — exists. Deleting the caveat from it left **all 29
files green**. It is `shadow.log_line()` now. *A sentence assembled at a call
site is a sentence no check can grade*, which is CFG-04's substring lesson
arriving from the other direction.
Note what these have in common with a masked rung: the mutation *runs*, and the
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
can see it, because the code under test really did behave identically on the
+61
View File
@@ -471,6 +471,67 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
price_now_eur_kwh=price)
def sized_from_meter(decision: ArbitrageDecision, headroom_w, *,
winner: str | None) -> bool:
"""SAFETY-47. Was the number a shadow run just recorded SIZED FROM THE
BILLED QUARTER - i.e. from a measurement of what the battery itself did?
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
`peakguard.charge_headroom_w()`, `discharge_permitted()` and
`freeze.looks_frozen()` are: the check that grades it has to assert on THE
SHIPPED EXPRESSION. A predicate transcribed into its own test is one copy
plus a future disagreement about what the check means.
------------------------------------------------------------------------
THE DEFECT IT PREVENTS
`shadow.words()` and the action log carry "would have commanded N W". In
SHADOW MODE the battery is held at `shadow.WIRE_W`, so the billed quarter
the buy leg was sized against contains THE HOUSE AND NOTHING ELSE - while
a live run's meter rises as the trade charges. The same code, the same
hour, `peak_cap_w` 1500 under a 1200 W house: live commands -302.4 W and a
shadow run records -2000.0 W. 6.61x, on the status row, with no caveat.
This is the flag that puts the caveat there.
⚠️ IT IS TRUE WHENEVER A MEASURED HEADROOM ENTERED THE ARITHMETIC, NOT
ONLY WHEN IT BOUND. `_act()` sizes the buy leg as `min(asked, headroom)`,
and "the headroom did not bind" is ITSELF a measured fact about a quarter
the battery never entered. Keying this on `capped` - on whether the
shipped sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" - would answer
True at the START of the quarter, where a shadow run and a live run still
agree to the watt, and False at the END, where `(900/remaining)` has
multiplied the headroom past the ask and the recorded number is at its
most wrong. The quietest caveat exactly where it is needed loudest.
⚠️ THE BUY LEG ONLY. §9.1: "Discharge and export are never capped by the
peak guard; only charging and import are." A sell is sized from
`discharge_w`, which is the roster's own rating and not a measurement of
the quarter, so a sell's recorded watts are NOT subject to this and must
not carry a caveat that would then be false.
⚠️ AND THE ARBITER'S WINNER IS THE THIRD RUNG, NOT AN AFTERTHOUGHT. A trade
that was sized from the meter and then OUT-RANKED is not the number being
recorded - `Resolution.target_w` belongs to somebody else - and a caveat
filed against somebody else's number is a false sentence on the status row,
which is the defect class this whole card is about. It is in HERE and not
in `main.py` for the reason the two rungs above are: all three answer one
question, a check has to be able to reach each of them with the other two
clear (TEST-16), and a rung that lives at a call site is a rung no check
can drive.
`headroom_w` is whatever `peakguard.charge_headroom_w()` answered for the
cycle this decision was taken on. ⚠️ None IS "NOTHING MEASURED IT", and
`decide()` REFUSES the trade there (IDLE_HEADROOM_UNMEASURED), so a None
can only ever arrive here beside a non-trading decision - but it is tested
rather than assumed, because the caller is `main.py` and not this module.
`winner` is `Resolution.winner`: the source that actually set the watts.
"""
return (winner == SOURCE
and decision is not None and decision.action == CHARGE
and headroom_w is not None)
def claim(decision: ArbitrageDecision) -> Claim | None:
"""The decision as one arbiter claim, or None when nothing is to be traded.
+368 -5
View File
@@ -66,6 +66,7 @@ from .mqtt import (MqttPublisher, announced_units, unit_slugs,
unit_state_key)
from . import peakguard
from . import packshare
from . import shadow
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option, provider_configured
from .price_history import PriceHistory
@@ -830,9 +831,46 @@ class Controller:
# live state
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
# --- SAFETY-10 -------------------------------------------------------
# What the decision path reached on the last shadow cycle, in W, or
# None before the first one. ⚠️ IT IS KEPT SEPARATELY FROM
# `self.target` BECAUSE THEY ARE DIFFERENT FACTS: `self.target` is what
# is STANDING ON THE WIRE - held at `shadow.WIRE_W` for the whole of a
# shadow run, and re-seeded to it at the end of every shadow cycle so
# the loop and the sign watchdogs track reality - and this is what a
# live controller WOULD have put there. Folding them into one field is
# how a shadow run starts telling an operator a number nothing is
# doing.
self.shadow_would_w = None
# SAFETY-47. Was the number above SIZED FROM THE BILLED-QUARTER METER
# on the cycle it was recorded? ⚠️ IT IS RECORDED WHERE THE DECISION
# WAS TAKEN, NOT RE-DERIVED WHERE THE PAGE IS RENDERED. The status page
# runs on its own task and `self.peak_guard` has moved on by then, so a
# page that asked the question again would be answering it about a
# different cycle from the one whose watts it is displaying - which is
# the same two-facts-one-field defect the comment above this one is
# about. ⚠️ False AND NOT None BEFORE THE FIRST SHADOW CYCLE, unlike
# the field above: this one is a claim ABOUT a number, and there is no
# number yet, so the honest value is "nothing to disclose". The row's
# `would_w is None` branch renders no watts at all there.
self.shadow_sized_from_meter = False
# Edge state for the action log, both of them. `_shadow_was` is None
# until the first cycle, so an install that comes up IN shadow mode
# says so in the log it will be read from rather than leaving the
# operator to infer it from a page nobody is looking at yet. Coming up
# LIVE is silent - see `_shadow_settle()`, which says why.
self._shadow_was = None
self._shadow_winner = None
self.target = 0.0
self.sat_count = 0
self.i_w = 0.0 # the loop's integrator, carried between cycles
# TEL-10. What the last cycle's loop did, kept for publication. Seeded
# to the SAME values the "stopped or blind" branch of `cycle()` writes,
# because before the first cycle the loop has not run - which is the
# thing that branch is for.
self.loop_i_w = 0.0
self.loop_i_lead = 0.0
self.loop_reason = "not-running"
self.loop_w = None # what the loop asked for last cycle, or None
# Did the grid-following loop actually win the LAST arbitration? That is
# the question SAFETY-02 has to ask about the command standing on the
@@ -904,6 +942,12 @@ class Controller:
# None (startup) or the PREVIOUS cycle's decision (an input hold), and
# the status row rendered all three identically.
self.arb_at = None
# SAFETY-45/47. The billed-quarter charging headroom the arbitrage step
# was handed on the cycle above, or None when nothing measured it. It
# is CARRIED rather than recomputed for `arbitrage.sized_from_meter()`,
# because `self.peak_guard` is a different cycle's decision by the time
# the status page renders.
self.arb_headroom_w = None
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
# is the exact, derived statement of "the latest cycle did not get as
# far as the arbitrage step", which is the fact the *Arbitrage* row
@@ -1739,6 +1783,42 @@ class Controller:
"reports a different value.",
inv.name, inv.batt_fresh.reason)
@property
def shadow(self) -> bool:
"""SAFETY-10. Is the battery being left alone right now?
⚠️ A PROPERTY, NOT A FIELD, AND FOR THE REASON `self.units` IS ONE.
Three surfaces ask this question at three different moments - the
cycle, the write, and the status page rendered between two cycles - and
a cached flag is a fourth answer that can disagree with the option and
the button it is derived from. Derived, they cannot.
⚠️ AND THE WRITE GATE ASKS THIS, NOT A PARAMETER PASSED DOWN TO IT. A
suppression that had to be threaded through `write_setpoint()` would be
absent from every call site that forgot it - the heartbeat, the stop
button, `shutdown()` - which is exactly the shape of hole this add-on's
single choke point exists to close.
"""
return shadow.engaged(self.o, control_started=self.auto)
def shadow_latches(self) -> tuple:
"""The failsafe latches that are UP, named, for the shadow banner.
A latch that is standing while the controller is inert is standing when
it stops being inert. `shadow.banner()` carries them so an operator who
turns shadow mode off is not handed a battery that will not move and a
page that never mentioned why.
"""
out = []
if self.circ_wd.tripped:
out.append("circulating power")
if self.site_sign_wd.tripped and len(self.inverters) > 1:
out.append("site sign watchdog")
for inv in self.inverters:
if inv.sign_wd.tripped:
out.append(f"{inv.name} sign watchdog")
return tuple(out)
def write_refusal(self, value: float, inv=None) -> str | None:
"""Why `value` may NOT be written to `inv` right now, or None if it may.
@@ -1930,6 +2010,40 @@ class Controller:
self.log_event(f"{inv.name}: {result.describe()}")
value = result.applied_w
# --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE -----------
# Shadow mode runs the whole decision path and commands nothing. The
# suppression is HERE, in the one method every write in this add-on
# reaches - the cycle, the heartbeat, the stop button, shutdown() and
# maint_abort() alike - so there is no call site that can route round
# it. app/shadow.py carries the whole argument; three things about the
# placement belong here:
#
# ⚠️ IT IS A SUBSTITUTION, NOT A RETURN, exactly like the two refusals
# above it. Returning would leave the inverter LATCHED ON ITS LAST
# COMMAND with the ESP32's heartbeat still being fed - a battery held
# at whatever it was doing when shadow mode was switched on, under a
# status page saying nothing is being commanded. That is the failure
# this add-on keeps finding, and shadow mode must not be a new way to
# reach it. `shadow.WIRE_W` is 0.0 W and it is REFRESHED on the
# ordinary heartbeat, which is also what firmware/goodwe-master.yaml's
# 30 s stale-input watchdog requires of us.
#
# ⚠️ IT IS BELOW SAFETY-28's GATE AND BELOW SAFETY-03's CLAMP, so both
# still grade the number the decision path actually produced. A shadow
# run that could not say "it would have hit the rating here" would be
# answering a different question from the one it is for.
#
# ⚠️ IT IS NOT A SECOND WRITER. No claim is filed, no transport is
# opened and no arithmetic of its own reaches the wire: one actuator,
# one writer, and this is that writer emitting the value it emits for
# every other refusal.
if self.shadow and value != shadow.WIRE_W:
_LOG.info("SHADOW MODE: not commanding %+.0f W to %s - holding "
"%+.0f W. The decision was reached and recorded; "
"nothing was sent to the inverter.",
value, inv.name, shadow.WIRE_W)
value = shadow.WIRE_W
changed = (inv.last_write is None or value != inv.last_write)
if not (changed or force):
return
@@ -2549,7 +2663,16 @@ class Controller:
# operator switching control off for the whole site; "inputs missing" is
# the SITE's own telemetry gone - the meter this controller regulates
# against. Neither is about a pack, and both must stop every inverter.
if not self.auto:
# ⚠️ SAFETY-10: `and not self.shadow`, AND IT IS THE ONE FAILSAFE THIS
# CARD CHANGES. `P_SAFETY` out-ranks every other claim, so leaving the
# stop claim in while shadow mode is engaged would make the recorded
# decision "stopped" on every cycle - a shadow run that answers the
# wrong question, which is the same as no shadow run. The actuator ends
# at the same number either way, and the substitution in `_write_unit()`
# that replaces it is STRICTLY STRONGER than this claim: a claim can be
# out-ranked by a higher priority, and the wire gate cannot be
# out-ranked by anything.
if not self.auto and not self.shadow:
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped",
actuator=self.actuator_of("safety")))
elif not self.inputs_ok:
@@ -2720,9 +2843,16 @@ class Controller:
# `arb_at` is then the age the status row reports rather than a
# decision presented as current.
self.arb, self.arb_at = None, now
self.arb_headroom_w = 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)
# SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED. One evaluation of
# the shipped expression feeds both the sizing below and
# `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the
# caveat on the recorded watts can never be answered from a
# different cycle's guard than the watts were.
self.arb_headroom_w = peakguard.charge_headroom_w(self.peak_guard)
self.arb = arbitrage.decide(
self.fresh_tariff_plan(now), now,
peak_guard_active=arbitrage.guard_state(self.peak_guard),
@@ -2741,7 +2871,7 @@ class Controller:
# peak block's conclusion, ~50 lines up, so it must stay after
# it. Evaluated earlier it would size this quarter's trade from
# the previous cycle's quarter.
charge_headroom_w=peakguard.charge_headroom_w(self.peak_guard),
charge_headroom_w=self.arb_headroom_w,
# The emptiest pack again, and for the sell side that is the
# right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there
# is one `soc` argument - `pack_floor_soc` names that residual
@@ -2893,7 +3023,14 @@ class Controller:
inv)))
# --- the grid-following loop -----------------------------------------
if self.auto and self.inputs_ok:
# ⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most
# needs and the one that is switched off by `auto_start: false`, so
# without this an install in `auto` shadow mode would record every
# strategy's decision and a permanent silence where the grid-following
# loop should be. It is safe because the loop's `prev_w` is
# `self.target`, which the end of this method re-seeds to what is
# actually standing on the wire.
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware
# actually did, and the controller must track reality or it jumps
@@ -2924,8 +3061,15 @@ class Controller:
# level: the two halves of §6.2's identity break in opposite
# directions. See `site_batt_w` and test_alloc.py part 8.
site_batt = self.site_batt_w
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
# AGAINST. `self.target` is reassigned further down this method, so
# a `decision.i_w - self.target` written at publish time would
# subtract a DIFFERENT cycle's command and call the answer a lead.
# The published lead is built from the value `compute()` was
# actually handed, on the line that hands it over.
loop_prev = self.target
decision = compute(
prev_w=self.target,
prev_w=loop_prev,
grid_w=self.grid,
actual_w=site_batt,
tuning=self.tuning,
@@ -2938,6 +3082,41 @@ class Controller:
self.sat_count = decision.sat_count
self.i_w = decision.i_w
self.loop_w = decision.target_w
# --- TEL-10 ------------------------------------------------------
# ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT
# THE CARD IS. `self.i_w` is carried between cycles and published
# nowhere - no entity, no attribute, no log line - so the only way
# anyone has ever asked what it was doing is to RECONSTRUCT it from
# the grid trace and a replay. That reconstruction reproduced about
# a third of the engagements it was checked against and was thrown
# away; a card that has to be argued from a discarded reconstruction
# is a card that cannot be argued.
#
# Three rows, and the third is not derivable from the first two by
# anyone reading the dashboard afterwards:
#
# `loop_i` the integrator AFTER this cycle - `Decision.i_w`,
# which its own docstring says is the value BEFORE
# the output clamp, the slew limit and quantisation.
# It is therefore NOT `setpoint` and must never be
# published from it.
# `loop_lead` how far the integrator stands AHEAD of the command
# it was computed against. This is the quantity
# SAFETY-48 bounds, and `integrator_max_w / slew_w`
# is the number of cycles it takes to unwind.
# `loop_reason` `compute()`'s OWN reason string - `deadband`,
# `i-clamped`, `clamped`, `slew-limited`,
# `saturated-freeze`, `freeze-released`, `tracking`.
#
# ⚠️ AND `loop_reason` IS NOT `self.reason`. `self.reason` is the
# ARBITER's sentence (`resolution.explain()`, e.g. "loop -> 1200 W")
# and it does not carry the loop's own word at all: the Claim filed
# below takes `decision.reason` and `Resolution.explain()` prints the
# winner's NAME, not the winner's reason. The status page's "Why"
# row is the arbiter's sentence and stays that way.
self.loop_i_w = decision.i_w
self.loop_i_lead = decision.i_w - loop_prev
self.loop_reason = decision.reason
# --- SAFETY-03 ---------------------------------------------------
# ⚠️ READ THE BOOLEANS, NOT `decision.reason`. On a default install
# the integrator bound is what bites and the output clamp is
@@ -2962,6 +3141,15 @@ class Controller:
# Stopped or blind: no accumulation may survive the outage.
self.i_w = 0.0
self.loop_w = None
# TEL-10, and it is the same zero the line above writes rather than
# a second opinion about it. ⚠️ `not-running` rather than a stale
# `tracking`: a reason row that keeps repeating the last word the
# loop said while the loop is not running is a row that reads as
# health, which is the failure `publish()`'s own None-dropping note
# is about one field over.
self.loop_i_w = 0.0
self.loop_i_lead = 0.0
self.loop_reason = "not-running"
# A loop that is not running cannot be winding. Clearing this here
# rather than letting the last value stand is what makes the alarm
# de-assert when control is stopped.
@@ -3227,6 +3415,133 @@ class Controller:
# reads only `self.p1`, which no line in this method writes.
self.peak.sync(self.p1)
# --- SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS -----
# Every guard above has now read `self.target` - the resolved intent -
# and SAFETY-18 in particular grades exactly that. Re-seeding before
# them would hand each one a 0 W that no decision produced. Re-seeding
# AFTER them hands the NEXT cycle, and the sign watchdogs, what is
# actually standing on the wire. See `_shadow_settle()`.
self._shadow_settle(resolution)
def _shadow_settle(self, resolution) -> None:
"""SAFETY-10. Record the decision, then re-seed the carried command.
TWO JOBS, AND THEY ARE IN THIS ORDER BECAUSE THE SECOND DESTROYS THE
FIRST'S EVIDENCE.
1. THE RECORD. A shadow run is only worth running if it answers "what
would it have done, and why". `self.shadow_would_w` and
`Resolution.explain()` are that answer, and the action log gets one
line per CHANGE OF DECISION rather than one per cycle - the log
holds 40 rows, and a level-logged shadow run would flush the context
of its own findings inside four minutes at a 15 s cycle floor. The
same edge rule the peak guard and the arbitrage strategy already
use, for the same reason.
2. THE RE-SEED. `self.target` and each `inv.target` are set back to
`shadow.WIRE_W` - what the write gate actually put on the wire.
⚠️ WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING. Three
machines read the carried command as a statement about the HARDWARE:
* `compute(prev_w=self.target, ...)` - the loop's slew limiter and
its integrator. Left holding a shadow decision, the first cycle
after shadow mode is turned off would slew from a base that never
existed: up to a full `slew_w` step onto a battery sitting at 0 W.
Re-seeded, the exit is safe BY CONSTRUCTION and needs no separate
transition code - the carried command already equals the standing
one.
* `Inverter.standing_w()`, which is what SAFETY-02/30's sign
watchdogs grade. Fed a shadow decision they would compare a number
against a meter that never saw it, disagree for as long as the
decision asked for power, and LATCH A TRIP THAT SURVIVES INTO LIVE
OPERATION AND A RESTART. Shadow mode would manufacture the failsafe
it exists to help debug. ⚠️ THIS ONE IS ALREADY HELD BY THE WIRE
GATE - `standing_w()` prefers `last_write`, and that is the 0.0 W
the gate accepted - so the re-seed is the SECOND rung, covering
the fallback before any write has been accepted. Two rungs
answering one question is MUTATION.md's masking shape, and the
mutation that reproduces the hazard has to defeat BOTH: driven
singly, dropping this line leaves the detector inert and names
nothing about the latch. Measured.
* SAFETY-42's delivery watchdog compares `inv.target` against what
the pack is producing. A share nothing was told to produce is a
delinquency report on every cycle of every shadow run.
⚠️ AND EVERY CYCLE'S DECISION IS THEREFORE COMPUTED FROM WHERE THE
PLANT REALLY IS, not from a counterfactual plant that drifts further
from reality every tick. "What would this controller command NOW,
given this meter and a battery at 0 W" is a true statement about the
present; "what would the eleventh cycle of a run that never happened
have commanded" is not a statement about anything.
"""
engaged = self.shadow
if engaged != self._shadow_was:
# ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. The
# edge is against `None` on cycle one, and an install that comes up
# CONTROLLING has nothing to report - a "shadow mode off" line at
# every start is one more row of boilerplate in a 40-row log, and
# it was measured pushing a real clamp episode's context out of
# test_clamp.py part 2's ring. Coming up IN shadow mode is the
# other direction and it is always worth a line: it is the log an
# operator reads to find out why nothing is being commanded.
first = self._shadow_was is None
self._shadow_was = engaged
if engaged:
self.log_event(
"SHADOW MODE ON - " + shadow.why(
self.o, control_started=self.auto)
+ f"; inverter held at {shadow.WIRE_W:.0f} W")
_LOG.warning(
"SAFETY-10 SHADOW MODE IS ON. The full decision path runs "
"and NOTHING is commanded: every inverter is held at "
"%+.0f W on the ordinary heartbeat. %s", shadow.WIRE_W,
shadow.why(self.o, control_started=self.auto))
elif not first:
self.log_event(
"SHADOW MODE OFF - the battery is being controlled again, "
f"resuming from {shadow.WIRE_W:.0f} W")
_LOG.warning(
"SAFETY-10 shadow mode is OFF. This controller is "
"commanding the battery again, starting from the "
"%+.0f W that was standing on the wire.", shadow.WIRE_W)
if not engaged:
self.shadow_would_w = None
self.shadow_sized_from_meter = False
self._shadow_winner = None
return
self.shadow_would_w = float(resolution.target_w)
# SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY. All three rungs - the
# arbiter's winner, the leg being a CHARGE, and the headroom having
# been MEASURED - live inside `arbitrage.sized_from_meter()`, which
# says why, including why it is NOT keyed on whether the trade was
# capped. A rung written at a call site is a rung no check can reach
# with the other two clear, which is TEST-16's rule.
self.shadow_sized_from_meter = arbitrage.sized_from_meter(
self.arb, self.arb_headroom_w, winner=resolution.winner)
# ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE. A decision
# that moves from the loop to the peak guard at the same number is a
# different decision and the operator needs the line; a loop target
# that wanders by a watt is not, and 15 s of those would be the whole
# log.
# ⚠️ SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE. The same winner at the
# same watts with and without the caveat are two different claims about
# the same number, and an edge key blind to that would print the first
# one and silently keep it standing. It cannot flood: the flag only
# moves when arbitrage starts or stops owning the resolution, or when
# the guard starts or stops measuring the quarter, and both of those
# move the winner or the watts as well on every cycle measured.
edge = (resolution.winner, round(float(resolution.target_w)),
self.shadow_sized_from_meter)
if edge != self._shadow_winner:
self._shadow_winner = edge
self.log_event(shadow.log_line(
would_w=resolution.target_w, reason=resolution.explain(),
sized_from_meter=self.shadow_sized_from_meter))
self.target = shadow.WIRE_W
for inv in self.inverters:
inv.target = shadow.WIRE_W
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
@@ -4016,6 +4331,14 @@ class Controller:
or (self.site_sign_wd.tripped
and len(self.inverters) > 1)
or self.circ_wd.tripped
# SAFETY-10: "shadow" is its own value and not a
# flavour of "stopped". A fleet dashboard reporting a
# shadow install as `running` would be calling a
# battery nobody is controlling healthy; one reporting
# it as `stopped` would lose the fact that the decision
# path is live and being recorded. ⚠️ BELOW `failsafe`:
# a latch that is up is up, and shadow does not clear it.
else "shadow" if self.shadow
else "running" if self.auto else "stopped"),
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
# `site_circulating_w`. `publish()` drops None values, so a cycle
@@ -4023,6 +4346,14 @@ class Controller:
# rather than publishing a zero that would read as health.
"circulation": (None if self.site_circulating_w is None
else round(self.site_circulating_w)),
# TEL-10. The loop's own three, written from the fields `cycle()`
# recorded and not re-derived here - the page and the publisher run
# on their own tasks and `self.target` has moved on by the time
# either of them looks (SAFETY-47's note on `sized_from_meter`, one
# field up, is the same hazard).
"loop_i": round(self.loop_i_w, 1),
"loop_lead": round(self.loop_i_lead, 1),
"loop_reason": self.loop_reason,
}
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
@@ -4516,7 +4847,24 @@ class Controller:
def status_payload(self) -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
if self.circ_wd.tripped:
# --- SAFETY-10, AND IT IS FIRST IN THE CASCADE -----------------------
# "The battery is NOT being controlled" is the one sentence on this
# page that may never be displaced by another condition. A banner that
# can be out-ranked does not STATE a thing plainly; it states it
# whenever nothing else is happening, which is the half of the time
# nobody needs telling.
#
# ⚠️ AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. The three
# `bad` branches below all say some version of "held at 0 W", which is
# still true in shadow mode - but an operator who turns shadow mode off
# with a latch up gets a battery that will not move and a page that
# never mentioned it. `shadow.banner()` names the latch and goes red
# for it, so nothing this cascade would have said is lost.
_shadow = shadow.banner(self.o, control_started=self.auto,
latched=self.shadow_latches())
if _shadow is not None:
level, banner = _shadow
elif self.circ_wd.tripped:
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are
# latched P_SAFETY stops, so the site is at 0 W either way and the
# banner can only carry one sentence. This one goes first because it
@@ -4616,6 +4964,21 @@ class Controller:
("State of charge", "—" if self.pack_floor_soc is None
else f"{self.pack_floor_soc:g} %"),
("Commanded", w(self.target)),
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
# "Commanded 0 W" above and a "Why" below that explains a trade is
# the exact pair an operator would otherwise have to guess at; this
# row is what makes the two of them consistent, and it is rendered
# in EVERY state - including `off`, where it says the battery IS
# being controlled - because a surface whose ABSENCE is how you
# learn something is a surface you have to already know about.
("Shadow mode", shadow.words(
self.o, control_started=self.auto,
would_w=self.shadow_would_w,
# SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE
# - the page runs on its own task and `self.peak_guard` has
# moved on. See `Controller.__init__`.
sized_from_meter=self.shadow_sized_from_meter,
reason=self.reason)),
("Why", self.reason),
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
else f"{self.maint.phase} ({self.maint_unit.name})"),
+30
View File
@@ -85,6 +85,36 @@ SENSORS = [
# sensor's VALUE rather than its arrival, so a dead publisher freezes the
# number and reads permanently fresh. Filed as TEL-06.
("p1_age", "p1_sample_age_s", "P1 sample age", "s", "duration", "measurement", None),
# --- TEL-10: the loop's integrator, published for the first time ---------
#
# ⚠️ APPENDED, AND THE POSITION IS THE POINT. `discovery_payloads()` walks
# this list in order and `_announce()` publishes what it returns, so a row
# inserted ABOVE would move every later entity's place in the announcement.
# Nothing existing is renamed, re-typed or re-ordered by these three; they
# are three NEW retained topics after the nine that are already there, and
# test_mqtt.py part 1 grades exactly that claim.
#
# ⚠️ AND THEY ARE SITE ROWS, NOT PER-UNIT ONES. There is one integrator -
# §6.2's architecture is ONE integrator on the P1 meter whose output is then
# SPLIT - so a per-unit copy would invent N of a thing there is one of, the
# same argument `grid` and `p1_age` are site rows for.
#
# `state_class: measurement` on the two numbers is what puts them in the
# recorder's statistics, which is the whole point: the reason this card
# exists is that the last investigation had no history to read.
("loop_i", "goodwe_loop_integrator", "Loop integrator", "W", "power", "measurement", None),
# ⚠️ NOT DERIVABLE FROM THE TWO ROWS EITHER SIDE OF IT, which is why it is
# its own row rather than a subtraction a dashboard could do. `setpoint` is
# the ARBITER's output after every other strategy has had its say, and it is
# also THIS cycle's; the lead is measured against the command `compute()`
# was handed, which is the PREVIOUS one. Subtracting the published rows
# gives a different number on every cycle the arbiter overrode the loop.
("loop_lead", "goodwe_loop_integrator_lead", "Loop integrator lead", "W", "power", "measurement", None),
# ⚠️ NOT `sensor.goodwe_controller_status` AND NOT THE "Why" ROW. Both of
# those carry the ARBITER's sentence; this is `compute()`'s own word for
# what the control law did with this cycle - `slew-limited` is the value
# SAFETY-48 is about and it appears in neither of the other two.
("loop_reason", "goodwe_loop_reason", "Loop reason", None, None, None, "mdi:sigma"),
]
BASE = "goodwe_ctl"
+518
View File
@@ -0,0 +1,518 @@
"""SAFETY-10 - shadow mode: the whole decision path, and no command.
WHAT DEFECT THIS PREVENTS
=========================
Not one defect - the CLASS of them. Every optimiser fault this repository has
found was invisible from outside while it was happening, and each one was paid
for at the meter before anybody could see it:
* SAFETY-45 arbitrage against a binding `peak_cap_w` ran 28 charge onsets an
hour, the grid swinging -455 .. +3200 W, and put a billed
quarter 59 W over the cap;
* TARIFF-14 the price lookup answered for the wrong quarter-hour 45 minutes
in every 60, and traded on it;
* SAFETY-46 the SoC ceiling churns 598 Wh to gain 310 Wh, no hysteresis.
There was no way to WATCH what the optimiser would do without letting it do it.
A commissioning engineer could arm arbitrage and find out, or not arm it and
learn nothing. Shadow mode is the third option: strategies, arbiter, clamps and
guards all run, the decision is recorded with its reason, and NOTHING THE
CONTROLLER DECIDES REACHES THE INVERTER.
⚠️ AND OF THOSE THREE, A SHADOW RUN CAN SHOW YOU EXACTLY ONE - SEE THE NEXT
SECTION. This header used to name SAFETY-45's limit cycle as the example
of what shadow mode surfaces. That was false, and SAFETY-47 measured it.
⚠️ WHAT A SHADOW RUN CAN AND CANNOT SHOW (SAFETY-47)
=====================================================
SHADOW MODE CANNOT SURFACE A CLOSED-LOOP DEFECT. Not badly, not partially -
NOT AT ALL, and by the same construction that makes it safe. The actuator is
held at WIRE_W and the carried command is re-seeded to WIRE_W, so every
MEASURED input - the meter, the quarter accumulator behind it, the battery
power, the state of charge - describes a plant that DID NOT RESPOND. A
closed-loop defect is by definition one whose stimulus is the plant's response
to the controller. No response, no stimulus, no defect to see.
That is not a gap to be closed later. Feeding the guards a simulated plant
instead is what SAFETY-10 §4 refuses for the sign watchdogs, for a reason that
applies to all of this: a controller reacting to a plant that does not exist
drifts further from reality every tick, and three cycles in its numbers
describe nothing. THE CLOSED-LOOP ANSWER COMES FROM A CLOSED-LOOP RIG
(TEST-23 stage 3) OR FROM A LIVE RUN. There is no third place.
So, of the three defects above, measured on that rig at `peak_cap_w` 1500 /
house 1200 / `max_w` 2000, one hour at a 5 s cycle:
* SAFETY-45's limit cycle NOT VISIBLE. Live: 28 charge onsets, the grid
swinging -455.5 .. +3200.0 W, the guard engaged
on 410 of 720 cycles. Shadow, same hour, same
code: 1 onset, the grid flat at the 1200 W
house, the guard engaged on 0 of 720. Every
term of `wanted = batt_w + excess` is measured.
* SAFETY-46's SoC churn NOT VISIBLE. The SoC moves 0.000000000 pp in a
shadow run, because it is the pack's integral of
what the pack was commanded and the pack was
commanded 0 W. No SoC limit can be REACHED in a
shadow run; the ones already crossed when it
started are reported, and that is all.
* TARIFF-14's wrong quarter VISIBLE, and it is the one. The price lookup's
inputs are the CLOCK and the PRICE SERIES, and
neither is a function of what the battery did.
Measured: which hour the strategy picked, its
price and the spread it cleared are identical
live and shadow on 720 of 720 charging cycles,
AT A BINDING CAP.
⚠️ THE EXEMPTION THAT USED TO BE HERE WAS TRUE UNTIL SAFETY-45 MERGED. This
module, DOCS.md and DECISIONS/SAFETY-10-shadow-mode.md all said "the strategies
are unaffected - they ask for a fixed power from inputs that do not depend on
what the battery did". SAFETY-45 made the buy leg `min(asked, headroom)` with
headroom = (900 / remaining) x (ceiling - projected)
and `projected` reads the OPEN QUARTER'S ACCUMULATOR - the meter - which is a
function of what the battery did earlier in that same quarter. The strategy
became closed-loop the day that landed. What survives of the exemption, and
only this:
WHICH hour a strategy picks, and WHY, are unaffected.
HOW HARD it trades is not.
⚠️ AND THE SIZE A SHADOW RUN RECORDS IS THE RIGHT ANSWER TO A QUESTION NOBODY
ASKED. `900/remaining` is correct arithmetic for a meter that is genuinely
under the ceiling - and in shadow the meter genuinely IS under it, because the
battery is not charging. So the number is not a bug; it is an answer computed
for a plant that did not exist. Measured on the same rig, shipped code:
cap / house live commanded shadow recorded over
1500 / 1200 -302.4 W -2000.0 W 6.61x
3500 / 3000 -502.6 W -2000.0 W 3.98x
3500 / 2000 -1502.6 W -2000.0 W 1.33x
3500 / 1800 -1702.6 W -2000.0 W 1.17x
15000 / 350 -2000.0 W -2000.0 W 1.00x
The last row is the fixture SAFETY-10's own evidence was taken on, which is why
nothing reported this. `words()` carries the caveat rather than a re-computed
number, and `DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md` §3 records the
three alternatives that were rejected and why.
⚠️ "REACHES NOTHING" IS NOT "WRITES NOTHING", AND THE DIFFERENCE IS THE WHOLE
SAFETY ARGUMENT
=======================================================================
The inverter holds its last command FOREVER - it has no meter timeout
(HANDOVER.md §6), and firmware/goodwe-master.yaml's header records what that
cost when a controller stopped refreshing: Illusmart went silent mid-command
and the inverter held +5000 W for 113 s. main.py's own header carries the same
measurement. So a shadow mode that
simply stopped writing would leave whatever was standing when it was switched
on standing for as long as it was left on. That is the exact failure this
repository keeps finding - LOOKS HEALTHY BUT IS INERT - with the status page
calmly reporting that nothing is being commanded.
So shadow mode does write, and it writes ONE number:
the actuator is held at WIRE_W (0.0 W), refreshed on the ordinary
heartbeat, by the ordinary single writer.
That is a DEFINED, SAFE actuator state, it is the same state the operator's
stop button reaches, and it honours the contract firmware/goodwe-master.yaml
§"CONTRACT CHANGE" imposes on us - HA must rewrite the setpoint every ~10 s or
the ESP32's 30 s stale-input watchdog cannot tell "steady" from "dead". Going
silent would put that board into a tripped-watchdog state for the whole shadow
run, which is an ALARM being used as a MODE: the operator who learns that
`wd_tripped` is normal during shadow runs is the operator who ignores it when
the add-on has actually died.
⚠️ AND IT IS NOT A SECOND WRITE PATH. SAFETY-28's rule is one actuator, one
writer. Shadow mode files no claim, owns no transport and calls nothing in
hass.py. It is a SUBSTITUTION inside `Controller._write_unit()` - the single
choke point every write in this add-on already goes through - in exactly the
form the three substitutions above it take (non-finite -> 0 W, unauthorised ->
0 W, clamped -> the rating). It is the LAST of them, so SAFETY-28's
authorisation gate and SAFETY-03's clamp still grade the number the decision
path actually produced, and a shadow run therefore records that the clamp WOULD
have engaged.
THE DEFAULT, AND WHY IT IS NOT A PLAIN `true` (§ the ticket's own warning)
==========================================================================
The card is titled "default-on". Taken literally - `shadow_mode: true` in
`config.yaml`'s `options:` block - it is the worst change in this repository's
history. Home Assistant's Supervisor composes an add-on's effective options as
`{**config.yaml defaults, **the stored user options}`, so a NEW key with a new
default reaches EVERY install on the next update, including the one regulating
a real battery in the author's house today. Literal default-on silently stops
an existing install from controlling, and the only symptom is a battery that
has stopped doing anything.
There is no mechanism in the add-on options system that lets one key default
differently for a new install and an existing one: `options:` IS the default,
and there is exactly one of it. So "default-on for new" has to be expressed as
a value the add-on RESOLVES, not as a constant the Supervisor merges.
Hence three values rather than two, defaulting to `auto`:
off never shadow. The controller commands the battery.
on always shadow, whatever the start/stop button says.
auto shadow WHILE CONTROL IS STOPPED; live once it is started.
`auto` is default-on for a new install and a no-op for an existing one, and it
is that without any persisted marker, any migration, and any guess:
* a FRESH install ships `auto_start: false` and has no stored `auto` key, so
it comes up stopped -> shadow. It observes a day of decisions before it is
ever allowed to command anything. That is the card's intent, delivered.
* an install that is CONTROLLING - `auto_start: true`, or the operator has
pressed *Start / stop control* and the store carries `auto: true` - is
started, so `auto` resolves to LIVE and its behaviour does not change by
one watt.
* an install that is STOPPED keeps its actuator at 0 W exactly as before -
the same number, from the same writer, on the same heartbeat - and gains
the recording. The only thing that changes for it is what the status page
and the action log SAY.
⚠️ AND `auto` MEANS THE START BUTTON LEAVES SHADOW MODE. That is deliberate:
under `auto` the button reads "Start / stop control" and pressing Start is the
operator saying "control the battery". An operator who wants the decisions
recorded WHILE the loop runs sets `shadow_mode: on` explicitly - that is the
configuration this card exists to make possible, and it is the one that cannot
be reached by accident.
⚠️ AN UNRECOGNISED VALUE RESOLVES TO SHADOW, NOT TO LIVE. `validate.py` refuses
it at startup so this is not meant to be reachable, but the fail-safe direction
for "the operator's intent is unreadable" is the one that does not move energy.
THE FAILSAFES, ONE BY ONE (the card asks for each to be decided and recorded)
============================================================================
| failsafe | in shadow mode |
|--------------------------------|-------------------------------------------|
| SAFETY-02/30 sign watchdogs | INERT, and by construction rather than by |
| (per-unit and site) | a special case. They grade |
| | `Inverter.standing_w()` - what this |
| | controller has standing on the wire - and |
| | shadow mode holds that at 0 W. A 0 W |
| | command cannot oppose a meter, so the |
| | detector never arms. ⚠️ THIS IS THE ONE |
| | THAT HAD TO BE GOT RIGHT: fed the SHADOW |
| | decision instead, every shadow run would |
| | latch a trip within `sign_disagree_s` - |
| | the decision opposes a meter that never |
| | saw it - and that latch SURVIVES INTO |
| | LIVE OPERATION and a restart. Shadow mode |
| | would manufacture the failsafe it is |
| | meant to help debug. |
| SAFETY-03 clamp | OBSERVES. It runs on the number the |
| | decision path produced, `clamp_last` is |
| | set and every engagement goes to the |
| | action log, because "it would have hit |
| | the rating" is part of what a shadow run |
| | is for. It changes no command: the value |
| | it narrows is replaced by 0 W below it. |
| SAFETY-28 write authorisation | OBSERVES, for the same reason and in the |
| | same place - above the substitution, so |
| | it still asks who chose the number. |
| SAFETY-18 meter freeze | OBSERVES, unchanged. It takes no action |
| | in live operation either; it grades the |
| | resolved intent against a meter that has |
| | stopped moving, and both halves are as |
| | true in shadow as out of it. |
| SAFETY-07/24/25 peak guard | OBSERVES, AND SEES ONLY THE HOUSE. It is |
| | level-triggered and unlatched and it files|
| | its claim as usual, so the recorded |
| | decision is the real one FOR THIS METER - |
| | but every term of `wanted = batt_w + |
| | excess` is measured, and in shadow mode |
| | the battery contributes nothing to either.|
| | ⚠️ SAFETY-47: this row used to claim |
| | SAFETY-45's limit cycle was visible from a|
| | shadow run. It is not - 410/720 engaged |
| | cycles live against 0/720 in shadow, on |
| | the same hour and the same code. |
| SAFETY-31 circulation | OBSERVES AND MAY LATCH, legitimately. Its |
| SAFETY-42 delivery | evidence is MEASURED power, not a |
| | command: packs pushing against each other |
| | while every one of them is told 0 W is a |
| | real fault and a latch is the right |
| | answer to it. |
| operator stop (`auto` False) | SUPPRESSED AS A CLAIM, ENFORCED AT THE |
| | WIRE. It is the one deliberate change. |
| | Its `P_SAFETY` 0 W claim out-ranks |
| | everything, so leaving it in would make |
| | the recorded decision "stopped" on every |
| | cycle - the shadow run would answer the |
| | wrong question. The actuator state is |
| | identical either way, and the wire |
| | substitution is STRICTLY STRONGER than |
| | the claim: a claim can be out-ranked, and |
| | this cannot. |
| inputs missing, non-finite, | UNCHANGED. All of them resolve to 0 W, |
| allocator refusal, arbiter | which is what the wire gets in shadow |
| contradiction | mode anyway, so they act redundantly and |
| | are recorded as they always were. |
⚠️ MAINTENANCE IS NOT IN THAT TABLE BECAUSE SHADOW MODE DOES NOT TOUCH IT. The
state machines are ticked on every cycle today whether control is started or
not, so a stopped install already runs them against a battery that does not
move. Shadow mode inherits that, unchanged and unimproved; it is a pre-existing
property of `auto_start: false` and it is recorded here rather than quietly
fixed under another card's name.
LEAVING SHADOW MODE
===================
`Controller.cycle()` re-seeds its carried command (`self.target` and each
`inv.target`) to WIRE_W at the END of every shadow cycle, AFTER every in-cycle
guard has read the decision. Two things follow, and both are safety properties:
* the loop's `prev_w` and its integrator track WHAT IS ACTUALLY STANDING ON
THE WIRE, so every shadow cycle answers "from where the plant really is,
what would this controller do NOW" - a true statement about the present,
rather than a simulation of a counterfactual plant that drifts further from
reality every tick;
* the exit is safe BY CONSTRUCTION. The carried command already equals the
standing command, so the first live cycle slews from 0 W like any other
cycle. Carrying the shadow decision instead would let the slew limiter
depart from a base that never existed - up to a full `slew_w` step onto a
battery sitting at zero.
The entry is safe for the ordinary reason: the substitution happens on the very
first write of the very first shadow cycle, and `last_write` was not 0 W, so
that write is issued rather than skipped as unchanged.
"""
from __future__ import annotations
# ⚠️ CFG-03's mirror. `test_defaults_pin.py` part 2 discovers every
# module-level name ending in OPTION_DEFAULTS and asserts each value
# reproduces config.yaml EXACTLY, so this table cannot drift from the shipped
# default. Nothing in the add-on may carry a literal fallback for a key a
# mirror answers for - part 3 scans for that - which is why `main.py` asks the
# functions below and never reads `shadow_mode` itself.
SHADOW_OPTION_DEFAULTS = {
"shadow_mode": "auto",
}
#: The three values `shadow_mode` may take, in the order config.yaml's schema
#: lists them. `validate.py` refuses anything else by name.
MODES = ("auto", "on", "off")
#: What stands on the wire while shadow mode is engaged. ⚠️ A NUMBER, NOT A
#: SILENCE - see this module's header. It is 0.0 W for the same reason every
#: other refusal in this add-on is 0.0 W: fail toward inaction, and keep
#: commanding it so the inverter cannot be left latched on anything else.
WIRE_W = 0.0
#: SAFETY-47. What the recorded watts mean when a MEASURED billed quarter sized
#: them.
#:
#: ⚠️ "UPPER BOUND" AND NOT "SMALLER", AND THE DIFFERENCE WAS MEASURED RATHER
#: THAN ARGUED. Charging only ADDS offtake, so a live meter's `projected` is
#: never below a shadow meter's and the headroom is never above it: the shadow
#: size is >= the live size, with EQUALITY at the start of every quarter, where
#: `900/remaining` is 1.0 and neither plant has moved. Measured pairwise over
#: 3600 charging cycles in five configurations - 0 cycles below, 730 exactly
#: equal. A sentence reading "smaller" would have been false on every one of
#: those 730.
#:
#: ⚠️ AND IT NAMES NO FACTOR. How far under the bound a live run lands is a
#: property of the site - 6.61x at a 1500 W cap under a 1200 W house, 1.00x at
#: a cap that does not bind - and a number invented for the row would be a
#: second helping of the defect this constant exists to disclose.
SIZED_FROM_METER = (
"⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE "
"HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same "
"trade against a meter the battery is moving, so this is an UPPER BOUND "
"and not a prediction; how far under it a live run lands is a closed-loop "
"question and a shadow run cannot answer it")
def mode(opts: dict) -> str:
"""The configured `shadow_mode`, normalised to one of `MODES`.
⚠️ AN UNRECOGNISED VALUE ANSWERS `"on"`, NOT `"auto"` AND NOT `"off"`.
`validate.py` refuses such a value at startup, so this branch is not meant
to be reachable at all; it exists because the fail-safe direction for "the
operator's intent cannot be read" is the one that does not move energy. A
typo that silently resolved to `off` would put a battery under a controller
whose configuration nobody could parse.
"""
raw = opts.get("shadow_mode", SHADOW_OPTION_DEFAULTS["shadow_mode"])
text = str("" if raw is None else raw).strip().lower()
if text in MODES:
return text
return "on"
def engaged(opts: dict, *, control_started: bool) -> bool:
"""Is the battery being left alone right now?
`control_started` is `Controller.auto` - the persisted start/stop state,
which a fresh install gets from `auto_start` and an operator moves with the
*Start / stop control* button. See the header on why `auto` reads it.
"""
m = mode(opts)
if m == "off":
return False
if m == "on":
return True
return not control_started
def why(opts: dict, *, control_started: bool) -> str:
"""Which rung decided, in the words an operator can act on.
⚠️ THE REASON, NOT ONLY THE VERDICT - MUTATION.md's masking rule. `on` and
`auto`-while-stopped produce the same boolean and want different operator
actions: one is turned off by editing an option, the other by pressing a
button. A surface that showed only "shadow" would make a mutation that
swapped the two rungs invisible.
"""
# ⚠️ THE LIVE SENTENCES SAY "NOT IN SHADOW MODE", NOT "THE BATTERY IS
# BEING CONTROLLED". The second is FALSE on a stopped install with
# `shadow_mode: off` - nothing is regulating there either - and a row that
# is false in one of the four live states is a row an operator learns to
# discount. What is true in every live state is that what this add-on
# decides reaches the inverter; whether control is RUNNING is the
# `Stopped` banner's question and it is answered there.
m = mode(opts)
if m == "off":
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
"add-on decides DOES reach the inverter")
if m == "on":
return ("on (shadow_mode: on) - decisions are recorded and nothing "
"is commanded")
if control_started:
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
"mode: what this add-on decides DOES reach the inverter")
return ("on (shadow_mode: auto, control is stopped) - decisions are "
"recorded and nothing is commanded")
def words(opts: dict, *, control_started: bool, would_w: float | None,
sized_from_meter: bool, reason: str = "") -> str:
"""The status page's `Shadow mode` row.
⚠️ THE ROW IS RENDERED IN EVERY STATE, INCLUDING `off`. A surface whose
ABSENCE is how you learn which mode you are in is a surface an operator
has to already know about; TARIFF-11's rule is that the rendered text is
what gets graded, so there has to be text in both states.
`would_w` is the decision the path reached this cycle - what a live
controller would have put on the wire GIVEN THIS METER AND A BATTERY AT
0 W - and `reason` is `Resolution.explain()`, the same sentence the `Why`
row carries. Together they are the whole of "what would it have done, and
why"; without them the row would say a shadow run happened and nothing
about what it found.
⚠️ `sized_from_meter` HAS NO DEFAULT, AND THAT IS SAFETY-47's WHOLE POINT.
It is True when a MEASURED billed-quarter headroom sized the number being
recorded - `arbitrage.sized_from_meter()` answers it, and it is the one
place the question is asked. A default of False here would be a caller
that forgot the caveat rendering a row that reads exactly like a caller
that decided it did not apply, which is the shape of defect this card is
about. TEST-14's rule with a new name on it.
⚠️ AND THE PHRASE IS "ON THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
CAVEAT FIRES. Every number a shadow run records was reached from a plant
that did not respond; "WOULD HAVE COMMANDED -2 000 W" reads as a forecast
of a live run and no number here is one. The ⚠️ below is the STRONGER,
conditional statement about the cycles where the answer can be multiples
out - see this module's header for the measured table.
⚠️ AND IT IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED. `arbitrage`'s own
sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" only when the headroom
came out BELOW the ask - which in a shadow run is the START of the
quarter, where the two runs still agree. By the end of the quarter
`900/remaining` has multiplied the headroom past the ask, the trade is
NOT capped, the sentence is silent and the recorded number is at its most
wrong. A caveat keyed on that sentence would be quietest exactly where it
is needed loudest. `test_arbitrage.py` part 3d-8 drives that.
"""
head = why(opts, control_started=control_started)
if not engaged(opts, control_started=control_started):
return head
if would_w is None:
return (f"{head}; no decision has been reached yet - the inverter is "
f"held at {WIRE_W:.0f} W")
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
# the whole sentence it ate the comma out of `head` - and `head` is the
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
# masking rule says must survive.
num = f"{float(would_w):+,.0f}".replace(",", " ")
out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter "
f"held at {WIRE_W:.0f} W")
if sized_from_meter:
out += f" {SIZED_FROM_METER}"
if reason:
out += f" - {reason}"
return out
def log_line(*, would_w: float, reason: str, sized_from_meter: bool) -> str:
"""The action-log line for one change of shadow decision.
⚠️ IT IS A FUNCTION FOR THE REASON `words()` IS ONE, AND SAFETY-47 LEARNED
IT THE EXPENSIVE WAY. This sentence used to be an f-string inside
`Controller._shadow_settle()`. Deleting the caveat from it there left all
29 files GREEN - the only check that could see the line drove a fixture
with `meter_source: off`, where no arbitrage decision and therefore no
caveat can ever be reached. A sentence assembled at a call site is a
sentence no check can grade, and the page would have said one thing while
the record a later analysis reads said another.
⚠️ AND IT IS THE SAME `SIZED_FROM_METER` CONSTANT THE ROW CARRIES, not a
second wording of it. Two sentences meaning one thing is how the two
surfaces start disagreeing about which cycles are caveated.
"""
out = (f"SHADOW: would have commanded {float(would_w):+.0f} W ON THIS "
f"CYCLE - {reason}")
if sized_from_meter:
out += f" {SIZED_FROM_METER}"
return out
def banner(opts: dict, *, control_started: bool, latched: tuple = ()) -> tuple:
"""`(level, text)` for the top of the status page, or `None` when live.
⚠️ IT NAMES A STANDING LATCH AND GOES RED FOR ONE. The shadow banner sits
ABOVE every other branch of the cascade, because "the battery is not being
controlled" is the one sentence that may never be displaced - but a
failsafe latch that is up while the controller is inert is up when it
stops being inert, and an operator who turns shadow mode off without
clearing it gets a battery that will not move and a green page that
never mentioned why. So the latch is carried INTO the shadow banner
rather than losing its own.
"""
if not engaged(opts, control_started=control_started):
return None
text = ("SHADOW MODE - the battery is NOT being controlled. Every "
f"inverter is held at {WIRE_W:.0f} W and nothing this add-on "
"decides reaches the inverter.")
if latched:
return ("bad", text + " ⚠️ A FAILSAFE LATCH IS STILL UP ("
+ ", ".join(latched) + ") and it will still be up when shadow "
"mode is turned off.")
return ("warn", text)
def problems(opts: dict) -> list:
"""`validate.py`'s rule: `shadow_mode` must be one of the three values.
⚠️ THE SENTENCE SPELLS `shadow_mode`. `configui.problems_by_field()` files
a refusal under the field whose key its text contains; a rule that never
names a key lands in the page's top-level block instead, where the operator
cannot see which control is wrong. UX-02's whole point is that the shipped
validator is the only rule surface, so the rule has to be readable there.
⚠️ AND IT IS A REFUSAL RATHER THAN A COERCION. `mode()` resolves an
unreadable value to shadow, which is the safe direction but is also a
SILENT reinterpretation of what the operator wrote - and an install left in
shadow mode by a typo commands nothing at all, indefinitely.
"""
raw = opts.get("shadow_mode", SHADOW_OPTION_DEFAULTS["shadow_mode"])
text = str("" if raw is None else raw).strip().lower()
if text in MODES:
return []
return [f"shadow_mode is {raw!r}, which is not one of "
+ ", ".join(f"'{m}'" for m in MODES)
+ ". 'auto' leaves the battery alone while control is stopped and "
"commands it once control is started; 'on' records every "
"decision and commands nothing at all; 'off' is ordinary "
"control."]
+13
View File
@@ -334,6 +334,7 @@ from .peakguard import PEAK_OPTION_DEFAULTS
from .price import (
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
from .shadow import problems as shadow_problems
from .static_tariff import (
ProfileInvalid, TAXES_PROFILE_OPTION, parse_static_profile,
parse_taxes_profile)
@@ -840,6 +841,18 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# missing rating one line up.
problems.extend(_roster_problems(opts))
# --- 11. SAFETY-10's shadow mode, and it is a REFUSAL ---------------------
# `shadow_mode` decides whether this controller commands the battery at all,
# so an unreadable value is not a detail to log and carry on past.
# `shadow.mode()` resolves an unrecognised one to SHADOW - the direction
# that does not move energy - and an install silently reinterpreted that way
# commands nothing, indefinitely, while its status page calmly explains
# that this is intentional. UX-02 made this validator the ONE rule surface
# both editors read, so the refusal belongs here rather than in a coercion
# nobody can see. The sentence spells `shadow_mode` so
# `configui.problems_by_field()` can file it under the field.
problems.extend(shadow_problems(opts))
# --- 2. the configured ceiling against the device's own rating -----------
# ⚠️ THE FLAT `max_w`, AND ONLY THE FLAT ONE. `dev_min`/`dev_max` are ONE
# device's range - the flat `setpoint_entity`'s - so this rule can only ever
+42 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.9.0"
version: "0.10.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -746,6 +746,38 @@ options:
# and not 150 or 200. 37 control cycles.
delivery_hold_s: 185
# SAFETY-10. Run the whole decision path and command NOTHING - strategies,
# arbiter, clamps and guards all run, the decision and its reason are
# recorded, and every inverter is held at 0 W. `app/shadow.py` carries the
# argument; the three things to know before changing this line are:
#
# auto shadow WHILE CONTROL IS STOPPED, live once it is started. This is
# the shipped value and it is what "default-on" means here.
# on always shadow, whatever the Start / stop button says. This is the
# one an operator picks to watch a day of decisions on an armed site
# before letting it trade.
# off ordinary control.
#
# ⚠️ IT IS NOT A BOOLEAN DEFAULTING TO `true`, AND THAT IS THE WHOLE CARD.
# The Supervisor composes an add-on's options as `{**these defaults, **the
# stored user options}`, so a NEW key with a new default reaches every
# EXISTING install on the next update - including one regulating a real
# battery. `shadow_mode: true` would silently stop it, and the only symptom
# would be a battery that has stopped doing anything. There is no mechanism
# in this options system that lets one key default differently for a new
# install and an existing one, so "default-on for new" has to be a value the
# add-on RESOLVES. `auto` is that value: a fresh install ships
# `auto_start: false` and therefore comes up in shadow mode; an install that
# is controlling is started, so `auto` resolves to live and NOTHING about it
# changes.
#
# ⚠️ AND UNDER `auto` THE START BUTTON LEAVES SHADOW MODE. Pressing *Start /
# stop control* is the operator saying "control the battery". Somebody who
# wants the decisions recorded WHILE the loop runs sets `on` explicitly -
# which is the configuration this option exists for, and the one that cannot
# be reached by accident.
shadow_mode: "auto"
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
@@ -1065,6 +1097,15 @@ schema:
# running short of one unit's share must be reported well inside one.
delivery_hold_s: int(30,900)
# SAFETY-10. ⚠️ A THREE-VALUED LIST AND NOT A `bool`, for the reason written
# against the default in the options block above: a boolean has one default
# and the Supervisor gives it to every install, new and existing alike.
# `app/shadow.MODES` is this tuple and `validate.py` rule 11 refuses anything
# else BY NAME, because `shadow.mode()` resolves an unreadable value to
# SHADOW - safe, but a silent reinterpretation that commands nothing for as
# long as nobody notices.
shadow_mode: list(auto|on|off)
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
+521 -7
View File
@@ -94,6 +94,8 @@ from app.arbitrage import (
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
threshold_eur_kwh,
)
# SAFETY-47 section 3d. The shipped shadow-mode row, not a transcription of it.
from app import shadow as _shadow
from app.price import (
PRICE_OPTION_DEFAULTS, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, WIDEN_FACTOR, brussels, required_spread, widen_factor,
@@ -890,18 +892,61 @@ check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
# caller fills is a TypeError only on the one path a suite does not run. Same
# pin, same reason, as `weights=` in test_packshare.py part 10.
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
_s45_calls = [n for n in ast.walk(ast.parse(_S45_MAIN))
_S45_MAIN_TREE = ast.parse(_S45_MAIN)
_s45_calls = [n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, ast.Call)
and ast.unparse(n.func).endswith("arbitrage.decide")]
_s45_arg = [ast.unparse(k.value) for c in _s45_calls for k in c.keywords
if k.arg == "charge_headroom_w"]
def _s45_assigned(name, clear=("None",)):
"""Every expression main.py assigns to `self.<name>` that is not a CLEAR.
⚠️ SAFETY-47 HOISTED THE HEADROOM OUT OF THE CALL, so pinning the call
argument alone stopped covering anything: the literal a mutation would put
there now goes one line up instead. Both ends are pinned.
⚠️ `clear` NAMES THE CONSTANTS THAT MEAN "NOTHING HAS ANSWERED THIS YET" -
`__init__`'s value and the per-cycle reset - and they are not a second
source for the number. Excluding them does NOT soften the pin: a mutation
that replaces the real assignment with one of those constants leaves this
list EMPTY, which matches no expected value and is red.
"""
return [ast.unparse(n.value) for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, ast.Assign)
and [ast.unparse(t) for t in n.targets] == [f"self.{name}"]
and ast.unparse(n.value) not in clear]
_s45_head_src = _s45_assigned("arb_headroom_w")
print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); "
f"charge_headroom_w= is {_s45_arg}")
f"charge_headroom_w= is {_s45_arg}, and self.arb_headroom_w is assigned "
f"from {_s45_head_src}")
check("main.py passes the guard's OWN measured headroom at its one "
"arbitrage.decide() call site - the shipped expression, not a "
"transcription of it, so a mutation back to a literal or to max_w is red "
"rather than silent",
_s45_arg == ["peakguard.charge_headroom_w(self.peak_guard)"])
_s45_arg == ["self.arb_headroom_w"]
and _s45_head_src == ["peakguard.charge_headroom_w(self.peak_guard)"])
# ⚠️ AND THE HOIST DID NOT MOVE IT OUT OF THE CYCLE. An `arb_headroom_w` set
# once in `__init__` and never refreshed would satisfy the pin above and would
# size every trade from the guard decision of whatever cycle happened to run
# last - which is the defect the hoist's own comment warns about, one level up.
_s45_cycle = next(n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
and n.name == "cycle"
and any(isinstance(c, ast.Call)
and ast.unparse(c.func).endswith("arbitrage.decide")
for c in ast.walk(n)))
check("...and the assignment is INSIDE the same `cycle()` that makes the call, "
"so the headroom the trade is sized from is this cycle's guard decision "
"and not one carried from an arbitrary earlier one",
any(isinstance(n, ast.Assign)
and [ast.unparse(t) for t in n.targets] == ["self.arb_headroom_w"]
and ast.unparse(n.value)
== "peakguard.charge_headroom_w(self.peak_guard)"
for n in ast.walk(_s45_cycle)))
# --------------------------------------------------------------------------- #
@@ -946,14 +991,26 @@ S45_T0 = D0 + timedelta(hours=CHEAP_H)
def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
seconds=S45_RUN_S):
seconds=S45_RUN_S, shadow=False):
"""One closed hour. `sized=False` is the behaviour this card removes.
⚠️ THE COUNTERFACTUAL IS ONE ARGUMENT, NOT A SECOND CODE PATH. `sized=False`
hands `decide()` a headroom nothing can bind - which is exactly what the
tree did before this card, because nothing was passing one - so the two runs
below differ in one value and share every line of shipped code. Habit 7.
⚠️ SAFETY-47's `shadow=True` IS THE SAME DISCIPLINE, ONE ARGUMENT AGAIN. It
reproduces the THREE things `Controller._write_unit()` and
`_shadow_settle()` do and nothing else: the resolved command is RECORDED,
the plant is driven from `shadow.WIRE_W` instead of from it, and the
carried command is RE-SEEDED to `shadow.WIRE_W` after the record is taken.
Those three lines are a TRANSCRIPTION of main.py's placement and are marked
as one - `test_clamp.py` part 8 is the check on the shipped placement, and
it drives the real `Controller`. What is NOT transcribed is every number
that matters here: the averager, the projection, the guard, the headroom,
the strategy, the arbiter and the row's own words are all the shipped ones.
"""
avg = QuarterAverager(phases=1, max_hold_s=900.0)
tuning = Tuning(gain=0.6, max_w=MAX_W, slew_w=0.0)
t0 = S45_T0
@@ -962,6 +1019,7 @@ def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
loop_w = None
batt_cmd = batt = 0.0 # + discharge, as main.py's site_batt_w
onsets = blocks = 0
rec = []
prev_action = "<start>"
grid_lo, grid_hi = None, None
billed = []
@@ -1008,7 +1066,32 @@ def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
claims.append(Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W,
"max_w"))
claims.append(Claim.set("loop", P_LOOP, d.target_w, d.reason))
target = resolve(claims).target_w
res = resolve(claims)
target = res.target_w
head_w = _pg.charge_headroom_w(pk) if sized else HEADROOM_CLEAR
rec.append(dict(
t_s=t, action=arb.action, power_w=arb.power_w,
reason=arb.reason, price=arb.price_now_eur_kwh,
spread=arb.spread_eur_kwh, winner=res.winner,
target_w=float(res.target_w), headroom_w=head_w,
engaged=bool(pk.engaged),
sized_from_meter=_arb.sized_from_meter(
arb, head_w, winner=res.winner)))
if shadow:
# --- TRANSCRIBED 1/3: `_shadow_settle()`'s record, taken
# BEFORE the re-seed destroys its evidence.
rec[-1]["would_w"] = float(res.target_w)
rec[-1]["row"] = _shadow.words(
{"shadow_mode": "on"}, control_started=False,
would_w=float(res.target_w),
sized_from_meter=rec[-1]["sized_from_meter"],
reason=res.explain())
# --- TRANSCRIBED 2/3: `_write_unit()`'s substitution - the
# plant below is driven from WIRE_W, not from the decision.
# --- TRANSCRIBED 3/3: `_shadow_settle()`'s re-seed of the
# carried command, which is what `compute(prev_w=...)`,
# `standing_w()` and SAFETY-42 read next cycle.
target = _shadow.WIRE_W
# THE PLANT, MODELLED: an 800 W/s ramp into a 10 s first-order lag.
step = min(1.0, acc)
delta = target - batt_cmd
@@ -1020,7 +1103,9 @@ def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
grid_hi = grid if grid_hi is None else max(grid_hi, grid)
return dict(onsets=onsets, grid_lo=round(grid_lo, 1),
grid_hi=round(grid_hi, 1), swing=round(grid_hi - grid_lo, 1),
billed=billed, blocks=blocks)
billed=billed, blocks=blocks, rec=rec,
engaged=sum(1 for r in rec if r["engaged"]),
cycles=len(rec))
S45_BEFORE = derived(lambda: s45_loop(sized=False))
S45_AFTER = derived(lambda: s45_loop(sized=True))
@@ -1076,11 +1161,440 @@ S45_WIDE_S = derived(lambda: s45_loop(sized=True, cap_w=5000.0, load_w=1200.0,
print(f" peak_cap_w 5000 W, 1200 W house (load + max_w = "
f"{S45_LOAD_W + MAX_W:.0f} W, under the cap): unsized "
f"{S45_WIDE['billed']} W, sized {S45_WIDE_S['billed']} W")
def _s45_outcome(r):
"""Everything the two runs put on the PLANT and the METER.
⚠️ `rec` IS EXCLUDED AND THE EXCLUSION IS NAMED. SAFETY-47 added a
per-cycle record to this harness, and it carries `headroom_w` and
`sized_from_meter` - which are THE ONE ARGUMENT the two runs differ in, by
construction. Comparing them would make this check fail for the reason it
exists to rule out. The resolved command per cycle IS compared, below, and
that is a stronger statement than the six aggregates ever were.
"""
return {k: v for k, v in r.items() if k != "rec"}
check("on a site where the trade cannot reach the cap the two runs are "
"IDENTICAL to the watt - so this card costs nothing on the installs it "
"is not about, and the check above is not passing because the cap "
"dominates everything",
S45_WIDE == S45_WIDE_S and S45_WIDE["onsets"] == 1)
_s45_outcome(S45_WIDE) == _s45_outcome(S45_WIDE_S)
and S45_WIDE["onsets"] == 1)
check("...and identical CYCLE BY CYCLE, not merely in the six aggregates: "
"every resolved command and every traded size matches to the float",
[(r["target_w"], r["power_w"]) for r in S45_WIDE["rec"]]
== [(r["target_w"], r["power_w"]) for r in S45_WIDE_S["rec"]])
# --------------------------------------------------------------------------- #
print()
print("3d. SAFETY-47 - the SHADOW record against a LIVE run, at a BINDING cap")
# ---------------------------------------------------------------------------
# WHAT THIS PART EXISTS FOR, AND IT IS NOT A SECOND SAFETY-45 CHECK.
#
# SAFETY-10 shipped shadow mode saying, in three merged documents, that "the
# strategies are unaffected - they ask for a fixed power from inputs that do
# not depend on what the battery did - so a shadow run does show you the
# trades, the peak-guard engagements and the SoC-limit behaviour". That was
# true until 3c's own fix merged. SAFETY-45 makes the buy leg
# `min(asked, headroom)`, and the headroom is
#
# (900 / remaining) x (ceiling - projected)
#
# where `projected` reads the OPEN QUARTER'S ACCUMULATOR - the meter - which is
# a function of what the battery did earlier in that same quarter. The strategy
# became closed-loop the day 3c landed, and shadow mode holds the battery at
# 0 W, so in a shadow run that accumulator contains THE HOUSE AND NOTHING ELSE.
#
# ⚠️ AND SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED - TWO
# INDEPENDENT REASONS, AND CLOSING EITHER ONE ALONE CLOSES NOTHING.
# (1) its fixture is `peak_cap_w: 15000` against a 350 W house, so the guard
# never engages and the sizing is inert - MUTATION.md habit 4, inside a
# merged card's own proof;
# (2) every identity row reads THE FIRST CHARGING SAMPLE, and at `elapsed=0`
# the projection IS the held level, so the two runs agree to the float
# there even AT a binding cap. 3d-2 drives that and says so.
#
# So this part is the grader for the class: a shadow run and a live run, the
# same hour, the same code, at a cap that BINDS. What is REAL and what is
# TRANSCRIBED is in `s45_loop()`'s own docstring - the three lines that stand
# in for `Controller._write_unit()` and `_shadow_settle()` are named there, and
# `test_clamp.py` part 8 is the check on the shipped placement.
# ---------------------------------------------------------------------------
S47_LIVE = derived(lambda: s45_loop(sized=True))
S47_SHAD = derived(lambda: s45_loop(sized=True, shadow=True))
# The SAFETY-45 defect itself, watched from inside shadow mode - which is what
# SAFETY-10's header claimed a shadow run would surface.
S47_UNSIZED_SHAD = derived(lambda: s45_loop(sized=False, shadow=True))
_s47_lc = derived(lambda: [r for r in S47_LIVE["rec"] if r["action"] == CHARGE])
_s47_sc = derived(lambda: [r for r in S47_SHAD["rec"] if r["action"] == CHARGE])
# ⚠️ THE SECTION CARRIES ITS OWN PRECONDITION INTO EVERY CHECK, AND THIS IS
# FCST-06's SHAPE RATHER THAN TIDINESS. A mutation that stops either run
# trading at all leaves these lists EMPTY; the first `min()` below then raises,
# the file dies before part 4, and the run reports ZERO reds - which reads
# exactly like a survivor. Measured: driving `wanted = batt_w - ex` through
# this section did precisely that. So the lists are replaced by a sentinel that
# cannot satisfy anything, and `s47check()` fails every check in the section
# when the stimulus was not produced.
_S47_DEAD = dict(action=None, power_w=0.0, reason="<no charging cycle>",
price=None, spread=None, winner=None, target_w=0.0,
headroom_w=0.0, engaged=False, sized_from_meter=False,
would_w=0.0, row="", t_s=0.0)
_S47_OK = bool(_s47_lc) and bool(_s47_sc)
if not _S47_OK:
_s47_lc = _s47_sc = [dict(_S47_DEAD)]
_s47_n = min(len(_s47_lc), len(_s47_sc))
def s47check(name, cond):
"""A 3d check, AND the section's own precondition, in one verdict."""
check(name, _S47_OK and bool(cond))
check("PRECONDITION: both runs actually reached a charging decision - without "
"this the section raises on its first `min()`, the file dies before "
"part 4 and the run reports ZERO reds, which reads exactly like a "
"survivor (MUTATION.md, FCST-06's shape). Measured, not imagined",
_S47_OK)
print(f" live : {len(_s47_lc)} charging cycles, traded "
f"{min(r['power_w'] for r in _s47_lc):+.1f} .. "
f"{max(r['power_w'] for r in _s47_lc):+.1f} W, "
f"headroom {min(r['headroom_w'] for r in _s47_lc):.1f} .. "
f"{max(r['headroom_w'] for r in _s47_lc):.1f} W, "
f"grid {S47_LIVE['grid_lo']:.0f} .. {S47_LIVE['grid_hi']:.0f} W")
print(f" shadow: {len(_s47_sc)} charging cycles, RECORDED "
f"{min(r['power_w'] for r in _s47_sc):+.1f} .. "
f"{max(r['power_w'] for r in _s47_sc):+.1f} W, "
f"headroom {min(r['headroom_w'] for r in _s47_sc):.1f} .. "
f"{max(r['headroom_w'] for r in _s47_sc):.1f} W, "
f"grid {S47_SHAD['grid_lo']:.0f} .. {S47_SHAD['grid_hi']:.0f} W")
# --- 3d-1. THE PRECONDITION: the cap binds, and the meter proves it --------
# ⚠️ WITHOUT THIS EVERY CHECK BELOW IS SAFETY-10 §6's DEFECT IN A NEW FILE. At
# a cap the trade cannot reach, live and shadow agree exactly and nothing here
# could ever go red - which is precisely what the committed evidence did.
s47check("PRECONDITION: the cap BINDS - the live run's trade is sized by the "
f"guard on every charging cycle, never reaching the {MAX_W:.0f} W ask",
_s47_lc and all(abs(r["power_w"]) < MAX_W - 1.0 for r in _s47_lc))
s47check("...and the live METER is what closed the gap: it is held up at the "
f"{S45_CAP_W:.0f} W ceiling rather than sitting at the "
f"{S45_LOAD_W:.0f} W house, which is the feedback a shadow run does not "
f"have",
S47_LIVE["grid_hi"] > S45_LOAD_W + 200.0)
s47check("...and the SHADOW meter sees the house and nothing else for the whole "
"run, which is what makes its billed quarter a different quarter",
near(S47_SHAD["grid_lo"], S45_LOAD_W, 0.001)
and near(S47_SHAD["grid_hi"], S45_LOAD_W, 0.001))
# --- 3d-2. WHY THE OLD SHAPE COULD NOT FAIL --------------------------------
# Graded as an EXPECTATION rather than left as a footnote, so that nobody
# rebuilds a check on `[0]` and believes it covers something.
print(f" first charging cycle: live {_s47_lc[0]['power_w']:+.4f} W, "
f"shadow {_s47_sc[0]['power_w']:+.4f} W")
s47check("⚠️ THE FIRST CHARGING CYCLE IS IDENTICAL IN BOTH RUNS EVEN HERE, so "
"widening SAFETY-10 §6's fixture ALONE would not have made its table go "
"red: at elapsed=0 the projection IS the held level and neither plant "
"has moved yet. Both reasons had to be closed, and this is the one a "
"reviewer cannot see from a diff",
_s47_lc[0]["power_w"] == _s47_sc[0]["power_w"]
and _s47_lc[0]["reason"] == _s47_sc[0]["reason"])
# --- 3d-3. AND ACROSS THE WHOLE RUN THEY DIVERGE ---------------------------
_s47_mis = sum(1 for i in range(_s47_n)
if _s47_lc[i]["power_w"] != _s47_sc[i]["power_w"])
_s47_worst = min(_s47_sc, key=lambda r: r["power_w"])
_s47_live_worst = min(_s47_lc, key=lambda r: r["power_w"])
# ⚠️ NaN AND NOT AN EXCEPTION when the sentinel above is in play: `nan > 2.0`
# is False, so the check goes RED, while a ZeroDivisionError here would kill
# the file and report zero reds. Same defect, one line further down - it was
# driven, and it is why this is not a plain division.
_s47_over = (abs(_s47_worst["power_w"]) / abs(_s47_live_worst["power_w"])
if _s47_live_worst["power_w"] else float("nan"))
print(f" size mismatches {_s47_mis}/{_s47_n} charging cycles; the shadow "
f"run records {_s47_worst['power_w']:+.0f} W where the live run's "
f"hardest trade is {_s47_live_worst['power_w']:+.1f} W - "
f"{_s47_over:.2f}x")
s47check("ACROSS THE WHOLE RUN the recorded trade size diverges on all but the "
"first few cycles - the check SAFETY-10 §6 should have been",
_s47_mis > 0.9 * _s47_n)
s47check(f"...and the shadow run RECORDS a trade the live run never commanded, "
f"over by {_s47_over:.2f}x. `900/remaining` amplifies a "
f"(ceiling - projected) gap the battery never closed, and the row "
f"carried that number to a commissioning engineer with no caveat",
_s47_over > 2.0)
s47check("...and the amplification is IN THE HEADROOM, which is where the "
f"measurement enters: the shadow guard offers up to "
f"{max(r['headroom_w'] for r in _s47_sc):.0f} W of charging headroom "
f"against a {S45_CAP_W:.0f} W ceiling and a {S45_LOAD_W:.0f} W house, "
f"while the live guard never exceeds "
f"{max(r['headroom_w'] for r in _s47_lc):.0f} W",
max(r["headroom_w"] for r in _s47_sc) > MAX_W
and max(r["headroom_w"] for r in _s47_lc) < MAX_W)
# --- 3d-3b. THE SENTENCE THE ROW PRINTS, GRADED AS ARITHMETIC --------------
# ⚠️ THE ROW CLAIMS THE RECORDED NUMBER IS AN **UPPER BOUND**, and a claim on
# an operator-facing surface is a claim this file has to be able to fail.
# Charging only ADDS offtake, so a live `projected` is never below a shadow
# one and the headroom is never above it. The earlier wording said "smaller",
# which is FALSE on every cycle where the two are equal - the start of each
# quarter, where `900/remaining` is 1.0 and neither plant has moved.
_s47_below = [i for i in range(_s47_n)
if abs(_s47_sc[i]["power_w"]) < abs(_s47_lc[i]["power_w"]) - 1e-9]
_s47_equal = sum(1 for i in range(_s47_n)
if abs(_s47_sc[i]["power_w"]) == abs(_s47_lc[i]["power_w"]))
print(f" the shadow record is below the live command on "
f"{len(_s47_below)}/{_s47_n} cycles and exactly equal on {_s47_equal}")
s47check("the recorded size is an UPPER BOUND on the live one, on every compared "
"cycle - which is the sentence `shadow.SIZED_FROM_METER` puts on the "
"status row, so the row is making a claim this file can fail",
not _s47_below)
s47check("...and it is NOT strictly greater: the two are equal at the start of "
f"each quarter ({_s47_equal} cycles here), which is why the row says "
f"'upper bound' and not 'smaller' - the second would be a false "
f"sentence on exactly those cycles",
_s47_equal > 0)
# ⚠️ AND THE MECHANISM BEHIND THE BOUND, AS TWO POINTS OF THE SHIPPED
# ARITHMETIC RATHER THAN AS ONE PROPERTY OF A WHOLE RUN. Same ceiling, same
# elapsed, same house: one quarter that carried the battery's charge at the
# meter (LIVE) and one that did not (SHADOW). The closed-loop version above
# cannot be driven red by any mutation that ALSO stops the fixture trading -
# measured - so the relation it rests on is graded directly here.
def _s47_headroom(*, meter_w, batt_w, elapsed_s=450.0):
"""`charge_headroom_w()` for a quarter that banked `meter_w` for
`elapsed_s`, with `batt_w` measured at the pack. All shipped."""
avg = QuarterAverager(phases=1, max_hold_s=900.0)
t = 0.0
while t <= elapsed_s:
imp, exp = split_signed(meter_w)
avg.add(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=S45_T0 + timedelta(seconds=t),
ingest_mono=(S45_T0.timestamp() + t)))
t += 5.0
pk = _pg.decide(
PeakTracker.project(avg, S45_T0 + timedelta(seconds=elapsed_s)),
batt_w=batt_w, cap_w=S45_CAP_W, reading=None, achievable_w=MAX_W,
soc=50.0, soc_floor=20.0, max_hold_s=900.0, frozen=False)
return _pg.charge_headroom_w(pk)
_S47_TRADE_W = 300.0
_s47_h_shadow = derived(lambda: _s47_headroom(meter_w=S45_LOAD_W, batt_w=0.0))
_s47_h_live = derived(lambda: _s47_headroom(
meter_w=S45_LOAD_W + _S47_TRADE_W, batt_w=-_S47_TRADE_W))
print(f" half-way through one quarter, {S45_LOAD_W:.0f} W house under a "
f"{S45_CAP_W:.0f} W ceiling: the SHADOW meter (battery at 0 W) offers "
f"{_s47_h_shadow:.0f} W of headroom, the LIVE meter (the same house "
f"plus a {_S47_TRADE_W:.0f} W charge) offers {_s47_h_live:.0f} W")
s47check("the SHADOW meter offers STRICTLY MORE charging headroom than the "
"live one on the same house, same ceiling and same elapsed - which "
"is the whole mechanism, in two points of the shipped arithmetic "
"rather than as a property of a whole run",
_s47_h_shadow > _s47_h_live)
s47check("...and the live answer is the one that lands the block ON the "
"ceiling, so the shadow answer is the one that is wrong about the "
"bill rather than the other way round",
near(_s47_h_live, S45_CAP_W - S45_LOAD_W, 1.0))
# --- 3d-4. WHAT SURVIVES OF THE EXEMPTION, GRADED --------------------------
# The narrowed claim the three documents now make, as a check rather than as a
# sentence: WHICH hour and WHY are unaffected; HOW HARD is not.
s47check("WHICH hour the strategy picked and at WHAT price are identical on "
f"every one of the {_s47_n} compared cycles - the clock and the price "
f"series are not functions of what the battery did, which is the half "
f"of the old exemption that is still true",
all(_s47_lc[i]["price"] == _s47_sc[i]["price"] for i in range(_s47_n)))
s47check("...and so is the spread it had to clear",
all(_s47_lc[i]["spread"] == _s47_sc[i]["spread"]
for i in range(_s47_n)))
# --- 3d-5. THE CLOSED-LOOP DEFECT THE DOCUMENTS SAID WAS VISIBLE -----------
# ⚠️ SAFETY-10's HEADER NAMED SAFETY-45's LIMIT CYCLE AS EXAMPLE #1 OF WHAT A
# SHADOW RUN SURFACES. 3c's own PRECONDITION establishes that the unsized loop
# really does run away; the same stimulus watched from inside shadow mode is
# a settled, uneventful hour.
print(f" SAFETY-45's own defect, unsized: live {S45_BEFORE['onsets']} "
f"onsets/swing {S45_BEFORE['swing']:.0f} W, guard engaged "
f"{S45_BEFORE['engaged']}/{S45_BEFORE['cycles']} -> in shadow mode "
f"{S47_UNSIZED_SHAD['onsets']} onsets/swing "
f"{S47_UNSIZED_SHAD['swing']:.0f} W, guard engaged "
f"{S47_UNSIZED_SHAD['engaged']}/{S47_UNSIZED_SHAD['cycles']}")
s47check("⚠️ SAFETY-45's LIMIT CYCLE IS NOT VISIBLE FROM A SHADOW RUN, and three "
"merged documents said it was. The same hour, the same unsized code: "
f"{S45_BEFORE['onsets']} charge onsets and a "
f"{S45_BEFORE['swing']:.0f} W grid swing live, "
f"{S47_UNSIZED_SHAD['onsets']} onsets and "
f"{S47_UNSIZED_SHAD['swing']:.0f} W in shadow",
S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0
and S47_UNSIZED_SHAD["onsets"] <= 2
and S47_UNSIZED_SHAD["swing"] < 1.0)
s47check("...and neither are the guard ENGAGEMENTS the same documents promised: "
f"{S45_BEFORE['engaged']} engaged cycles live against "
f"{S47_UNSIZED_SHAD['engaged']} in shadow. Every term of "
f"`wanted = batt_w + excess` is MEASURED, and in shadow mode the "
f"battery contributes nothing to either term",
S45_BEFORE["engaged"] > 100 and S47_UNSIZED_SHAD["engaged"] == 0)
# --- 3d-6. THE THREE RUNGS OF `sized_from_meter()`, SEPARATED --------------
# TEST-16's rule: three rungs answer one question, so each is reached with the
# other two clear. Any two of them left standing would mask the third.
_s47_chg = _s47_worst
_s47_sell = derived(lambda: act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER,
headroom=0.0))
s47check("the CHARGE rung: a metered headroom that sized a buy leg answers True, "
"which is the state the caveat exists for",
_arb.sized_from_meter(
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
spread_eur_kwh=1.0, required_eur_kwh=0.1,
price_now_eur_kwh=0.05),
300.0, winner=_arb.SOURCE) is True)
s47check("...the SELL rung: a DISCHARGE answers False however the headroom reads. "
"§9.1 - discharge and export are never capped by the peak guard - so a "
"caveat on a sell would be a FALSE sentence on the status row",
_s47_sell.action == DISCHARGE
and _arb.sized_from_meter(_s47_sell, 300.0, winner=_arb.SOURCE) is False)
s47check("...the UNMEASURED rung: a None headroom answers False. Nothing sized "
"anything from a meter, so there is nothing to disclose - and `decide()` "
"refuses the trade there anyway",
_arb.sized_from_meter(
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
spread_eur_kwh=1.0, required_eur_kwh=0.1,
price_now_eur_kwh=0.05),
None, winner=_arb.SOURCE) is False)
s47check("...the WINNER rung: the same sized charge answers False once something "
"else owns the resolution. `Resolution.target_w` is then somebody "
"else's number and a caveat filed against it would be false",
_arb.sized_from_meter(
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
spread_eur_kwh=1.0, required_eur_kwh=0.1,
price_now_eur_kwh=0.05),
300.0, winner="loop") is False)
# --- 3d-7. THE ROW, AT THE WORST CYCLE -------------------------------------
_s47_row = _s47_chg["row"]
_s47_first_row = _s47_sc[0]["row"]
print(f" worst cycle row: {_s47_row[:230]}")
s47check("the shipped row carries the caveat at the worst cycle: the operator is "
"told the number was sized from a meter with only the house in it",
_shadow.SIZED_FROM_METER in _s47_row)
s47check("...and it still carries the watts, in their own slot, and the standing "
"0 W beside them - the caveat NARROWS the claim rather than deleting "
"the record a shadow run exists to produce",
"WOULD HAVE COMMANDED" in _s47_row and "inverter held at 0 W" in _s47_row
and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "")
s47check("...and the watts are introduced as what it would have commanded ON "
"THIS CYCLE, in every shadow state and not only the caveated ones - no "
"number a shadow run records is a forecast of a live run",
"ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_row
and "ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_first_row)
# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS -----------
# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY.
# Driven as one mutation, deleting the caveat from the log line alone left all
# 29 files green: the only check that could see that line drives a fixture with
# `meter_source: off`, where no arbitrage decision - and therefore no caveat -
# is reachable. The sentence is `shadow.log_line()` now, so it can be graded.
_s47_log = derived(lambda: _shadow.log_line(
would_w=_s47_chg["would_w"], reason="arbitrage -> -2000 W",
sized_from_meter=_s47_chg["sized_from_meter"]))
_s47_log_plain = derived(lambda: _shadow.log_line(
would_w=_s47_chg["would_w"], reason="loop -> -300 W",
sized_from_meter=False))
print(f" action log: {_s47_log[:190]}")
s47check("the ACTION LOG line carries the same caveat the row does - a page that "
"discloses and a 40-row record that does not is the worse half of the "
"two, because the record is what is read after the run",
_shadow.SIZED_FROM_METER in _s47_log)
s47check("...and it is the SAME constant, not a second wording of it, so the two "
"surfaces cannot start disagreeing about which cycles are caveated",
_shadow.SIZED_FROM_METER in _s47_row and _shadow.SIZED_FROM_METER
in _s47_log)
s47check("...and an uncaveated cycle gets the plain sentence, so the caveat is "
"not simply printed on everything",
_shadow.SIZED_FROM_METER not in _s47_log_plain
and "would have commanded" in _s47_log_plain
and "ON THIS CYCLE" in _s47_log_plain)
# --- 3d-8. ⚠️ AND IT IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED ----------
# THE TRAP THIS PART EXISTS TO SPRING. `arbitrage`'s own sentence says "SIZED
# BY THE CAPACITY-TARIFF GUARD" only when the headroom came out BELOW the ask.
# In a shadow run that is the START of the quarter - where the two runs still
# agree to the float. By the end `900/remaining` has multiplied the headroom
# PAST the ask, the trade is NOT capped, the sentence is silent, and the
# recorded number is at its most wrong. A caveat keyed on that sentence would
# be quietest exactly where it is needed loudest.
_S47_CAPPED = "SIZED BY THE CAPACITY-TARIFF GUARD"
print(f" at the worst cycle the shipped trade sentence says "
f"{'' if _S47_CAPPED in _s47_chg['reason'] else 'NOT '}capped; at the "
f"first it says "
f"{'' if _S47_CAPPED in _s47_sc[0]['reason'] else 'NOT '}capped")
s47check("PRECONDITION: the shipped 'capped' sentence IS present at the first "
"charging cycle, where the shadow number is RIGHT - so the inverse "
"below is a real inversion and not an absence everywhere",
_S47_CAPPED in _s47_sc[0]["reason"])
s47check("⚠️ ...and ABSENT at the worst cycle, where the shadow number is "
f"{_s47_over:.2f}x out. A caveat keyed on `capped` would fire only "
f"where the record is accurate and go silent where it is multiples "
f"wrong; `sized_from_meter()` is keyed on the headroom having been "
f"MEASURED, so it fires on both",
_S47_CAPPED not in _s47_chg["reason"]
and _s47_chg["sized_from_meter"] is True
and _s47_sc[0]["sized_from_meter"] is True)
# --- 3d-9. main.py's CALL SITES, PINNED ------------------------------------
# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (habit 15), same as 3b's. Nothing
# behavioural in this file can see main.py render the row with a hard-coded
# False, because every check above calls `shadow.words()` itself.
_s47_words = [n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, ast.Call)
and ast.unparse(n.func).endswith("shadow.words")]
_s47_flag = [ast.unparse(k.value) for c in _s47_words for k in c.keywords
if k.arg == "sized_from_meter"]
_s47_flag_src = _s45_assigned("shadow_sized_from_meter",
clear=("None", "False"))
print(f" main.py: shadow.words(sized_from_meter={_s47_flag}), and "
f"self.shadow_sized_from_meter is assigned from {_s47_flag_src}")
s47check("main.py renders the row from the flag THE CYCLE RECORDED, at its one "
"shadow.words() call site - not from a literal and not re-derived on "
"the status task, where `self.peak_guard` is a different cycle's",
_s47_flag == ["self.shadow_sized_from_meter"])
s47check("...and that flag comes from the SHIPPED predicate with all three of its "
"arguments - the decision, the headroom the trade was sized from, and "
"the arbiter's winner - so a mutation to `False`, to a literal or to a "
"two-argument transcription is red rather than silent",
_s47_flag_src == ["arbitrage.sized_from_meter("
"self.arb, self.arb_headroom_w, "
"winner=resolution.winner)"])
_s47_logcalls = [n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, ast.Call)
and ast.unparse(n.func).endswith("shadow.log_line")]
_s47_logflag = [ast.unparse(k.value) for c in _s47_logcalls for k in c.keywords
if k.arg == "sized_from_meter"]
print(f" main.py: shadow.log_line(sized_from_meter={_s47_logflag})")
s47check("...and the ACTION LOG line comes from the shipped sentence with the "
"same flag, at main.py's one call site - not re-assembled as an "
"f-string where no check can reach it, which is how it survived a whole "
"mutation battery once already",
_s47_logflag == ["self.shadow_sized_from_meter"])
# ⚠️ AND THE PARAMETER HAS NO DEFAULT. TEST-14's rule, and this card is the
# reason it matters here: a caller that FORGOT the caveat and a caller that
# DECIDED it did not apply would render the identical row.
_s47_sig = inspect.signature(_shadow.words)
_s47_logsig = inspect.signature(_shadow.log_line)
s47check("`shadow.words()` and `shadow.log_line()` BOTH require "
"`sized_from_meter` - no default on either, so a caller cannot omit the "
"caveat by accident and get the un-caveated surface",
"sized_from_meter" in _s47_sig.parameters
and _s47_sig.parameters["sized_from_meter"].default
is inspect.Parameter.empty
and "sized_from_meter" in _s47_logsig.parameters
and _s47_logsig.parameters["sized_from_meter"].default
is inspect.Parameter.empty)
# --------------------------------------------------------------------------- #
+444 -3
View File
@@ -74,6 +74,7 @@ Read MUTATION.md before you report a survivor.
import asyncio
import io
import math
import re
import sys
from datetime import datetime, timedelta, timezone
@@ -87,6 +88,7 @@ from app.arbiter import P_LOOP, Claim, UnitBounds, allocate, resolve
from app.control import Tuning, compute
from app.inverter import Inverter
from app.main import Controller
from app import shadow
fails = []
T0 = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
@@ -670,8 +672,15 @@ def arm(c, value):
async def _direct():
hass = _Hass(0.0)
# ⚠️ SAFETY-10: `shadow_mode: "off"` EXPLICITLY, BECAUSE THIS FIXTURE
# NEVER PRESSES START. The shipped default is `auto`, which is SHADOW
# MODE while control is stopped - so without this line the probe below
# would be graded against a controller that is correctly refusing to
# command anything, and the clamp it is here to exercise would never be
# reached. The shipped write path is unaffected: a stopped controller
# only ever writes 0 W in production, which shadow mode also writes.
c = Controller({"setpoint_entity": "number.sp", "max_w": 2000,
"meter_source": "off"},
"meter_source": "off", "shadow_mode": "off"},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
# The shape the heartbeat, shutdown() and handle_action() all use - each one
@@ -1121,10 +1130,16 @@ A_UNIT, B_HYPO = "inverter-a", "inverter-b"
def fresh(**over):
"""A Controller with a stub HA, no cycle run, and therefore NO record."""
"""A Controller with a stub HA, no cycle run, and therefore NO record.
⚠️ SAFETY-10: `shadow_mode: "off"` for `_direct()`'s reason - this fixture
never presses Start, and the shipped default is shadow mode while control
is stopped. Part 7 grades who is allowed to reach the WIRE; a controller
that is correctly commanding nothing cannot answer that question.
"""
hass = _Hass(0.0)
c = Controller(dict({"setpoint_entity": "number.sp", "max_w": 2000,
"meter_source": "off"}, **over),
"meter_source": "off", "shadow_mode": "off"}, **over),
hass, _Store(), _Pub(), stop=asyncio.Event())
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
return hass, c
@@ -1414,6 +1429,432 @@ check("...and the numbers those cycles put on the wire are the arbiter's own "
hass_r.writes and all(abs(w) <= LIVE_MAX_W for w in hass_r.writes))
# --------------------------------------------------------------------------- #
# 8. SAFETY-10 - SHADOW MODE: THE DECISION PATH RUNS AND NOTHING REACHES THE
# INVERTER
# --------------------------------------------------------------------------- #
# This part belongs in THIS file and not in a new one: shadow mode is a
# substitution inside `_write_unit()`, one line below SAFETY-03's clamp and two
# below SAFETY-28's gate, and the property it claims - "nothing reaches the
# inverter" - is the same property parts 5 and 7 already grade from the other
# side. The fake `set_number()` in this file IS the wire.
#
# ⚠️ EVERY CHECK BELOW IS GRADED ON THE WRITES OR ON THE RENDERED SURFACE, NOT
# ON `Controller.shadow`. A check that asserted the flag would pass on a build
# in which the flag is set and the substitution is deleted, which is precisely
# the build this section exists to refuse.
#
# ⚠️ AND EVERY ONE OF THEM RUNS THE SAME STIMULUS IN BOTH MODES. MUTATION.md's
# rule: a per-thing check that has never seen the things DISAGREE is a total.
# `_s10_live` and `_s10_shadow` are the same 6000 W import through the same
# `Controller.cycle()`; the only difference is one option.
print("\n8. SAFETY-10 - shadow mode\n")
def _s10(shadow_mode, *, auto=True, cycles=25, grid=6000.0, dev=None, **over):
"""The same wired run as part 3, with `shadow_mode` and the start button.
⚠️ `auto` IS A PARAMETER BECAUSE `shadow_mode: auto` READS IT. The three
values and the two button positions are six states and they are not all
the same state; the pure-function table over all six is in
`test_validate.py`, and 8f says why it is not here.
"""
async def go():
hass = _Hass(grid, track=True)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": shadow_mode},
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = auto
# `dev` narrows what the DEVICE says it will accept, which is the
# clamp's second stage - the only way to make the clamp engage on a
# target the loop has already bounded at `max_w`.
c.dev_min, c.dev_max = ((-LIVE_MAX_W, LIVE_MAX_W) if dev is None
else (-dev, dev))
# ⚠️ CAPTURED PER CYCLE, BECAUSE THE HEARTBEAT OVERWRITES IT.
# `clamp_last` is assigned by every write, and the heartbeat below
# writes `inv.target` - 0 W in shadow mode - so the clamp's verdict on
# the DECISION is gone by the end of the run. Looking for it afterwards
# would grade the refresh instead of the cycle.
c.clamp_seen = []
for n in range(cycles):
await c.read_inputs()
await c.cycle()
if c.clamp_last is not None:
c.clamp_seen.append(c.clamp_last)
# ⚠️ THE HEARTBEAT IS DRIVEN HERE AND NOT LEFT OUT, BECAUSE IT IS
# THE HALF THAT PROVES SHADOW MODE IS NOT SILENCE. `_write_unit()`
# skips an unchanged value unless `force` is set, so a cycles-only
# fixture sees exactly ONE 0 W write and could not tell "holds the
# inverter at 0 W" from "wrote 0 W once and went quiet" - and the
# second of those leaves a real install's ESP32 in a tripped
# stale-input watchdog for the whole run. `heartbeat_s=0` makes
# every tick due, which is the shipped path with its clock wound
# forward rather than a second implementation of it.
await c.send_heartbeats(float(n), 0.0)
return hass, c
return asyncio.run(go())
_live_h, _live_c = _s10("off")
_shad_h, _shad_c = _s10("on")
# --- 8a. THE PRECONDITION, WHICH IS THE HALF THAT MAKES THE REST MEAN ANYTHING
# MUTATION.md habit 4: a check whose DATA cannot reach the failure it names is
# decoration. "No non-zero value was written" is trivially true of a stimulus
# that would not have produced one, so the same stimulus is first shown to
# produce a large one with the option off.
check("PRECONDITION: with shadow_mode off this stimulus really does drive the "
f"wire - {len(_live_h.writes)} writes, "
f"{len([w for w in _live_h.writes if w != 0.0])} of them non-zero, "
f"largest {max((abs(w) for w in _live_h.writes), default=0):.0f} W",
len([w for w in _live_h.writes if w != 0.0]) >= 10
and max((abs(w) for w in _live_h.writes), default=0) > 1000.0
and 0.0 not in _live_h.writes)
# --- 8b. NOTHING THE DECISION PATH PRODUCED REACHES THE INVERTER -------------
check("AC 1: in shadow mode EVERY value that reached set_number() is 0 W. "
f"Distinct values written: {sorted(set(_shad_h.writes))}",
_shad_h.writes and set(_shad_h.writes) == {0.0})
# ⚠️ AND IT IS NOT SILENCE. The inverter holds its last command forever and the
# ESP32's stale-input watchdog needs a refresh; a shadow mode that stopped
# writing would leave a standing command up under a page saying nothing is
# commanded. A check that only asserted "no non-zero write" would pass on that
# build too - this is the half that separates them.
check("...and it WROTE that 0 W rather than going silent, which is what keeps "
f"the inverter from holding its last command ({len(_shad_h.writes)} "
"writes)",
len(_shad_h.writes) >= 2)
# --- 8c. THE DECISION PATH ACTUALLY RAN ------------------------------------
# "Nothing reached the inverter" is also true of a controller that decided
# nothing. Both halves, or the feature is an off switch with a long name.
check("the full decision path still ran: the loop reached a target and the "
f"arbiter resolved one ({_shad_c.loop_w!r} W, "
f"would have commanded {_shad_c.shadow_would_w!r} W)",
_shad_c.loop_w is not None and abs(_shad_c.loop_w) > 500.0
and _shad_c.shadow_would_w is not None
and abs(_shad_c.shadow_would_w) > 500.0)
check("...and the decision it recorded is a number a live controller would "
"have written - it is inside the rail and it is not the 0 W that went "
"on the wire",
_shad_c.shadow_would_w != 0.0
and abs(_shad_c.shadow_would_w) <= LIVE_MAX_W)
# --- 8d. THE CARRIED COMMAND TRACKS THE WIRE, WHICH IS WHAT MAKES THE EXIT
# SAFE
check("the carried site command is what is STANDING ON THE WIRE, not the "
f"decision ({_shad_c.target!r} W standing, "
f"{_shad_c.shadow_would_w!r} W decided)",
_shad_c.target == 0.0 and _shad_c.shadow_would_w != 0.0)
check("...and every unit's share is too, so SAFETY-42's delivery watchdog is "
"not handed a share nothing was told to produce",
all(i.target == 0.0 for i in _shad_c.inverters))
def _s10_exit(shadow_first):
"""Run twelve cycles, force shadow mode off, take ONE more cycle.
Answers with `inv.last_write` either side of that boundary - WHAT STANDS ON
THE WIRE, updated only on an accepted write, which is the number the
inverter is actually latched on. Reading `hass.writes` instead would be
blind to the converged case, where the controller correctly re-sends
nothing at all.
⚠️ THE COMPARISON IS AGAINST THE SAME RUN THAT NEVER ENTERED SHADOW MODE,
not against a constant. `slew_w` bounds a step from the standing command,
so "did it resume from the wire or from a fiction" is a question about a
DIFFERENCE, and a hard-coded expectation would be the number the code
produces compared with itself.
"""
async def go():
hass = _Hass(6000.0, track=True)
c = Controller(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": "on" if shadow_first else "off"},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
for _ in range(12):
await c.read_inputs()
await c.cycle()
before = c.inverters[0].last_write
c.o["shadow_mode"] = "off"
await c.read_inputs()
await c.cycle()
# ⚠️ NOT `float()`. `last_write` is None until something is ACCEPTED,
# and a build in which shadow mode writes nothing at all would turn
# this fixture into a TypeError - which MUTATION.md's FCST-06 section
# is about: a traceback grades none of the checks after it AND reads
# like a survivor. It is handed back as None and named below instead.
return before, c.inverters[0].last_write, c
return asyncio.run(go())
_exit_before, _exit_after, _exit_c = _s10_exit(True)
_never_before, _never_after, _never_c = _s10_exit(False)
print(f" shadow run: standing {_exit_before!r} W -> {_exit_after!r} W on "
"the first live cycle")
print(f" never in shadow: standing {_never_before!r} W -> "
f"{_never_after!r} W on the same cycle")
check("SOMETHING REACHED THE WIRE IN SHADOW MODE. `last_write` is set only on "
"an ACCEPTED write, so None here is the build that stops writing "
"altogether and leaves the inverter latched on its last command - the "
"failure shadow mode must not be a new way to reach",
_exit_before is not None and _exit_after is not None
and _never_before is not None and _never_after is not None)
_exit_before = -1.0 if _exit_before is None else float(_exit_before)
_exit_after = -1.0 if _exit_after is None else float(_exit_after)
_never_before = 0.0 if _never_before is None else float(_never_before)
_never_after = 0.0 if _never_after is None else float(_never_after)
check("PRECONDITION: the two arrangements DISAGREE - the run that was never in "
"shadow mode is standing somewhere else entirely at that boundary, so "
"the comparison below is not a number against itself",
_never_before != _exit_before and abs(_never_before) > 1000.0)
check("PRECONDITION: and the shadow run really was standing at 0 W, so the "
"step below is measured from the wire and not from a coincidence",
_exit_before == 0.0)
check("AC 2, leaving: the first live command after shadow mode is one slew "
"step from the 0 W that was actually standing on the wire - NOT one step "
"from the decision shadow mode reached, which never existed at the "
f"battery. |{_exit_after:.0f}| <= slew 1000 W",
abs(_exit_after) <= 1000.0)
check("...and the run that was never in shadow mode is NOT inside that bound "
"at the same cycle, which is what says the bound is a real constraint "
"here and not a property of the stimulus",
abs(_never_after) > 1000.0)
def _s10_enter():
"""Live first, then shadow mode, on a battery standing somewhere else."""
async def go():
hass = _Hass(6000.0, track=True)
c = Controller(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": "off"}, hass, _Store(), _Pub(),
stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
for _ in range(12):
await c.read_inputs()
await c.cycle()
standing = hass.writes[-1]
c.o["shadow_mode"] = "on"
await c.read_inputs()
await c.cycle()
# ⚠️ `inverters[0].last_write`, NOT `hass.writes[-1]`. The question is
# WHAT THE INVERTER IS LATCHED ON after the first shadow cycle, and a
# build that writes NOTHING leaves it latched on the live value while
# `hass.writes[-1]` still reads that same number - the two look
# identical from the write list and are opposite at the battery.
return standing, float(c.inverters[0].last_write), c
return asyncio.run(go())
_stood, _first_shadow, _enter_c = _s10_enter()
print(f" standing when shadow mode came on: {_stood:.0f} W")
check("PRECONDITION: the battery was standing at a real, non-zero command when "
"shadow mode was switched on - otherwise 'it reached 0 W' says nothing",
abs(_stood) > 500.0)
check("AC 2, entering: after ONE shadow cycle the inverter is latched on 0 W, "
"not on what it was doing a cycle earlier. The inverter holds its last "
"command FOREVER, so a shadow mode that merely stopped writing would "
"leave that command standing under a page saying nothing is commanded "
f"({_stood:+.0f} W -> {_first_shadow:+.0f} W)",
_first_shadow == 0.0)
# --- 8e. THE OTHER GUARDS ON THE WRITE PATH STILL OBSERVE --------------------
# The substitution is the LAST thing before the wire, below SAFETY-28's gate and
# below SAFETY-03's clamp, so both still grade the number the decision path
# produced. A shadow run that could not say "it would have hit the rating here"
# is answering a different question from the one it is for.
_cl_h, _cl_c = _s10("on", cycles=12, grid=9000.0, dev=400.0)
_cl_req = [r.requested_w for r in _cl_c.clamp_seen]
print(f" decision {_cl_c.shadow_would_w!r} W; what the clamp was asked "
f"about at each cycle boundary: {sorted(set(_cl_req))}")
check("PRECONDITION: the decision is a real, non-zero number and the wire "
"carried only 0 W on this run, so the two are distinguishable",
_cl_c.shadow_would_w is not None and _cl_c.shadow_would_w != 0.0
and set(_cl_h.writes) == {0.0})
check("SAFETY-03 still OBSERVES in shadow mode: the clamp was asked about the "
"number THE DECISION PATH PRODUCED, not about the 0 W that went on the "
"wire. That is what puts the substitution BELOW the clamp, and it is "
"what lets a shadow run report that the rating would have been hit",
_cl_req and all(r == _cl_c.shadow_would_w for r in _cl_req))
check("...and the clamp's own alarm still ran on it - the winding-loop "
"engagement reached the action log on a run where nothing was commanded",
any("clamp" in e.lower() or "CLAMP" in e for e in _cl_c.events))
check("SAFETY-28's gate still OBSERVES too - it refused nothing on this run, "
"which is the same answer it gives live, so shadow mode has not been "
"implemented by making every write unauthorised",
not [e for e in _cl_c.events if "WRITE REFUSED" in e])
# ⚠️ AND SAFETY-18 IS GRADED ON WHAT IT WAS HANDED, WHICH IS AN ORDERING
# CLAIM. `check_meter_frozen()` takes `self.target` - the arbiter's RESOLVED
# INTENT - and `_shadow_settle()` re-seeds that to 0 W at the very end of the
# cycle, AFTER it. Re-seed one line earlier and the freeze detector grades a
# 0 W that no decision produced: its conjunction needs a command at the rail,
# so it goes permanently blind and the whole suite stays green. The detector's
# own `evaluate` is wrapped here because the arguments are the only place that
# ordering is observable.
_fz_seen = []
_fz_real = _shad_c.freeze_wd.evaluate
def _fz_spy(unchanged, target, *a, **k):
_fz_seen.append(target)
return _fz_real(unchanged, target, *a, **k)
_shad_c.freeze_wd.evaluate = _fz_spy
async def _one_more():
await _shad_c.read_inputs()
await _shad_c.cycle()
asyncio.run(_one_more())
_shad_c.freeze_wd.evaluate = _fz_real
print(f" SAFETY-18 was handed: {_fz_seen}")
check("PRECONDITION: the freeze detector was consulted at all on that cycle",
len(_fz_seen) == 1)
check("SAFETY-18 still OBSERVES the DECISION: it is handed the arbiter's "
"resolved intent, not the 0 W the wire gate substituted. Re-seeding one "
"line earlier would hand it a command no decision produced and its "
"conjunction - a command at the rail against a meter that has stopped "
"moving - could never be satisfied again",
_fz_seen and _fz_seen[0] == _shad_c.shadow_would_w
and _fz_seen[0] != shadow.WIRE_W)
# --- 8f. THE MODE TABLE LIVES IN test_validate.py ---------------------------
# `shadow.engaged()` is a pure function of the option and the start button, and
# the six states it answers for are graded beside the rule that validates the
# option - not here, where a defeat of the DEFAULT stops part 3's wired run
# reaching the wire at all and takes this whole file down before part 8 runs.
# ⚠️ THAT IS MEASURED, NOT ANTICIPATED: the mutation that makes `auto` resolve
# to shadow unconditionally aborts part 3 with an IndexError, so its red count
# here is a LOWER BOUND and none of part 8 is graded (MUTATION.md, FCST-06).
# --- 8g. THE OPERATOR STOP, WHICH IS THE ONE FAILSAFE THIS CARD CHANGES ------
_stop_live_h, _stop_live_c = _s10("off", auto=False, cycles=6)
_stop_shad_h, _stop_shad_c = _s10("auto", auto=False, cycles=6)
print(f" stopped, shadow off: {_stop_live_c.reason!r}")
print(f" stopped, shadow on : {_stop_shad_c.reason!r}")
check("PRECONDITION: with shadow mode off, a stopped controller files the "
"P_SAFETY stop claim and the arbiter resolves to THAT source, at 0 W",
_stop_live_c.reason.startswith("safety")
and _stop_live_c.target == 0.0)
check("in shadow mode that claim is NOT filed, so the arbiter resolves to the "
"REAL winner and the recorded decision is the real one. A P_SAFETY claim "
"out-ranks everything - left in, every shadow run would answer the wrong "
f"question ({_stop_shad_c.reason!r})",
_stop_shad_c.reason.startswith("loop")
and _stop_shad_c.shadow_would_w is not None
and abs(_stop_shad_c.shadow_would_w) > 0.0)
check("...and the actuator ends at exactly the same place either way, because "
"the substitution that replaced the claim is STRICTLY STRONGER: a claim "
"can be out-ranked and a wire gate cannot",
set(_stop_shad_h.writes) == {0.0} == set(_stop_live_h.writes))
# --- 8h. THE RENDERED SURFACE (TARIFF-11's pattern) -------------------------
# Graded on `status_payload()`, which IS the status page: web.py's `PAGE` is a
# static shell whose banner and rows are filled from this JSON. Named slots, not
# substring tests, so a transposition moves a slot.
_ROW = "Shadow mode"
_shad_pay = _shad_c.status_payload()
_live_pay = _live_c.status_payload()
_shad_rows = dict(_shad_pay["rows"])
_live_rows = dict(_live_pay["rows"])
print(f" banner: {_shad_pay['banner']!r}")
print(f" row : {_shad_rows.get(_ROW)!r}")
check("the status page has a Shadow mode row in BOTH states, so its ABSENCE is "
"never how an operator learns which one they are in",
_ROW in _shad_rows and _ROW in _live_rows)
check("AC 3: the banner states plainly that the battery is not being "
"controlled",
"SHADOW MODE" in _shad_pay["banner"]
and "NOT being controlled" in _shad_pay["banner"])
check("...and it is not green. A page that reads `ok` while nothing is being "
"commanded is the 'looks healthy but is inert' failure itself",
_shad_pay["level"] == "warn")
check("...and it is FIRST in the cascade: the same controller has other "
"conditions to report and none of them displaces this sentence",
_live_pay["banner"] != _shad_pay["banner"]
and "SHADOW MODE" not in _live_pay["banner"])
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at "
r"([-\d]+) W", _shad_rows[_ROW])
check("the row answers 'what would it have done' with a NUMBER in its own "
"named slot, and says where the inverter actually is in a second one - "
"two slots in one sentence, so a transposition moves them",
_slot is not None)
if _slot is not None:
_would = float(_slot.group(1).replace(" ", ""))
check("...and that number is the decision the path reached, not the 0 W "
f"standing on the wire ({_would:.0f} vs {_shad_c.target:.0f})",
_would == round(float(_shad_c.shadow_would_w))
and _would != _shad_c.target)
check("...and the second slot is the STANDING command, so the two cannot "
"be read as one number",
float(_slot.group(2)) == 0.0)
check("the row answers 'and why' by carrying the arbiter's own sentence, "
"rather than a re-derivation of it",
_shad_c.reason in _shad_rows[_ROW])
check("with shadow mode off the row says so, and says what follows from it - "
"which is the arrangement that makes the one above a claim about this "
"controller and not about the string",
"NOT in shadow mode" in _live_rows[_ROW]
and "DOES reach the inverter" in _live_rows[_ROW]
and "WOULD HAVE COMMANDED" not in _live_rows[_ROW])
# ⚠️ A STANDING LATCH IS NAMED RATHER THAN HIDDEN. The shadow banner sits above
# the three `bad` branches, so it has to carry what they would have said - a
# latch that is up while the controller is inert is up the moment it stops
# being inert.
_shad_c.inverters[0].sign_wd.tripped = True
_latched = _shad_c.status_payload()
print(f" banner with a latch up: {_latched['banner']!r}")
check("a failsafe latch that is standing is NAMED in the shadow banner and "
"turns it red, so nobody turns shadow mode off into a battery that will "
"not move and a page that never mentioned why",
_latched["level"] == "bad"
and "LATCH IS STILL UP" in _latched["banner"]
and _shad_c.inverters[0].name in _latched["banner"])
check("...and it still says the battery is not being controlled, so the latch "
"has not displaced the sentence that may never be displaced",
"NOT being controlled" in _latched["banner"])
_shad_c.inverters[0].sign_wd.tripped = False
# --- 8i. THE RECORD A LATER ANALYSIS READS ----------------------------------
# A shadow run is only worth running if it answers "what would it have done,
# and why" AFTERWARDS, from the 40-row action log, and not only on a page
# somebody happened to be looking at.
_shadow_lines = [e for e in _shad_c.events if "SHADOW" in e]
print(f" action log, shadow lines: {len(_shadow_lines)} of "
f"{len(_shad_c.events)}")
check("the action log records that shadow mode came on, in words",
any("SHADOW MODE ON" in e for e in _shadow_lines))
check("...and what it would have commanded, with the arbiter's reason beside "
"it",
any("would have commanded" in e and "loop" in e for e in _shadow_lines))
check("...EDGE-logged, not level-logged: 25 cycles did not flush the 40-row "
"log that has to carry the context of what the run found",
len(_shadow_lines) < 25 and len(_shad_c.events) <= 40)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
+10 -1
View File
@@ -224,9 +224,18 @@ A_RATING = 5000.0
# had become so; validate.py rule 10 refused it the day it existed, which is
# what a rule about a relationship is for. 0 means "follow the rail"
# (`control.SLEW_RAIL_FRAC * max_w`) and is what config.yaml ships.
# ⚠️ SAFETY-10: `shadow_mode="off"` EXPLICITLY, BECAUSE NOTHING HERE PRESSES
# START. The shipped default is `auto`, which is SHADOW MODE while control is
# stopped - and in shadow mode every write is substituted with 0 W at the one
# choke point these parts grade. Without this line the per-unit clamp, the
# per-unit write budget and the per-unit heartbeat would all be measured on a
# controller that is correctly refusing to command anything, which is a
# different (and vacuous) claim. The shipped write path is unaffected: a
# stopped controller only ever writes 0 W in production anyway.
BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid",
max_w=A_RATING, gain=0.6, slew_w=0, deadband_w=15,
target_grid_w=0, step_w=10, heartbeat_s=10, stale_input_s=15)
target_grid_w=0, step_w=10, heartbeat_s=10, stale_input_s=15,
shadow_mode="off")
def built(opts=None, store=None, hass=None, second=None):
+123 -26
View File
@@ -151,6 +151,47 @@ BASELINE = [
('goodwe_ctl/availability', 'online', True),
]
# --- TEL-10's three, and they are DELIBERATELY NOT IN `BASELINE` -------------
#
# ⚠️ `BASELINE` IS WHAT release/1.0 PUBLISHES AND NOTHING MAY BE ADDED TO IT.
# Every strong claim in this file rests on that list being a record of another
# tree; append a row this branch invented and "byte-identical to release/1.0"
# quietly becomes "identical to itself", which is the defect MUTATION.md's
# habit 4 is about - a check whose data cannot reach the failure it names.
#
# So TEL-10's arrival is TWO claims and gets two checks: the nine release/1.0
# messages did not move, and EXACTLY these three arrived after them.
#
# ⚠️ AND THIS LIST IS A REGRESSION PIN, NOT EVIDENCE OF CORRECTNESS - the same
# status as part 3's sha256 over the per-unit payloads, for the same reason:
# there is no released version of these bytes to diff against, so all it can
# say is "these three payloads have not changed since the PR was reviewed".
_TEL10_NUM = ('"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV
+ ', "unit_of_measurement": "W", "device_class": "power", '
'"state_class": "measurement"}')
TEL10 = [
('homeassistant/sensor/goodwe_ctl_loop_i/config',
'{"name": "Loop integrator", "object_id": "goodwe_loop_integrator", '
'"unique_id": "goodwe_ctl_loop_i", "state_topic": "goodwe_ctl/loop_i", '
+ _TEL10_NUM, True),
('homeassistant/sensor/goodwe_ctl_loop_lead/config',
'{"name": "Loop integrator lead", "object_id": "goodwe_loop_integrator_lead", '
'"unique_id": "goodwe_ctl_loop_lead", "state_topic": "goodwe_ctl/loop_lead", '
+ _TEL10_NUM, True),
('homeassistant/sensor/goodwe_ctl_loop_reason/config',
'{"name": "Loop reason", "object_id": "goodwe_loop_reason", '
'"unique_id": "goodwe_ctl_loop_reason", "state_topic": "goodwe_ctl/loop_reason", '
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
'"icon": "mdi:sigma"}', True),
]
# What THIS branch announces at a roster of one: release/1.0's eight sensor
# configs, then TEL-10's three, then the availability message - which stays
# LAST, because `_announce()` publishes it after every config and a broker
# replaying the retained set must see availability arrive after the entities it
# is the availability OF.
SITE_NOW = BASELINE[:-1] + TEL10 + BASELINE[-1:]
class FakeClient:
"""Captures what `_announce()` actually hands paho."""
@@ -177,7 +218,7 @@ def announce(units=(), omit=(), module=mqtt):
return pub.client.msgs
def diff(got, want=BASELINE):
def diff(got, want=SITE_NOW):
"""`(missing, changed, added)` between two announcement lists, by topic."""
g, w = dict((t, (p, r)) for t, p, r in got), dict((t, (p, r)) for t, p, r in want)
missing = [t for t in w if t not in g]
@@ -186,7 +227,7 @@ def diff(got, want=BASELINE):
return sorted(missing), sorted(changed), sorted(added)
def report(label, got, want=BASELINE):
def report(label, got, want=SITE_NOW):
m, c, a = diff(got, want)
print(f" {label}: {len(want)} topics, {len(m)} missing, {len(c)} changed, "
f"{len(a)} added")
@@ -231,11 +272,33 @@ print("\n1. the recorded baseline, and a roster of one")
# entity on a live install.
m, c, a = report("roster of one (`inverters:` empty, i.e. every install today)",
announce(units=roster_names({})))
check("⭐ AT THE SHIPPED ROSTER OF ONE THE ANNOUNCEMENT IS BYTE-IDENTICAL TO "
"release/1.0: same topics, same unique_ids, same payload bytes, same "
"retain flags, in the same order - no second device, no renamed entity, "
"no added topic",
(m, c, a) == ([], [], []) and announce(units=roster_names({})) == BASELINE)
# ⚠️ THE HEADLINE SAYS EIGHT, NOT NINE, AND THE SLICE UNDER IT IS WHY.
# release/1.0 announces NINE messages; the ninth is `goodwe_ctl/availability`,
# which `_announce()` publishes LAST and which TEL-10 therefore MOVES - it is
# byte-identical but no longer at index 8. So what is asserted here is the eight
# entity CONFIGS, `[:len(BASELINE) - 1] == BASELINE[:-1]`, and the word "nine"
# above it named an assertion this file does not make. The ninth is not
# unguarded: the `== SITE_NOW` on the next check pins it byte-for-byte in its
# new last position. Two sentences, two checks.
check("⭐ AT THE SHIPPED ROSTER OF ONE EVERY ONE OF release/1.0's EIGHT ENTITY "
"CONFIGS IS STILL BYTE-IDENTICAL AND STILL FIRST: same topics, same "
"unique_ids, same payload bytes, same retain flags, in the same order - "
"no second device, no renamed entity, nothing re-ordered. TEL-10 adds "
"rows AFTER them; it may not move one of them. (release/1.0's NINTH "
"message, `availability`, is byte-identical too and deliberately moves "
"to LAST; the `== SITE_NOW` below is what pins it)",
announce(units=roster_names({}))[:len(BASELINE) - 1] == BASELINE[:-1])
# ⚠️ AND THE ADDITION IS ITS OWN CLAIM, WITH A COUNT. "Nothing moved" and
# "exactly three arrived" are different sentences and a single `== SITE_NOW`
# would let a fourth row hide inside whichever of the two a reader had in mind.
_m1, _c1, _a1 = diff(announce(units=roster_names({})), BASELINE)
check("⭐ TEL-10 ADDS EXACTLY THREE TOPICS AND CHANGES NONE: the integrator, "
"the lead and the loop's own reason string, all NEW, all on the SITE "
"device - nothing of release/1.0's is missing and nothing of it changed",
(_m1, _c1) == ([], [])
and sorted(_a1) == sorted(t for t, _p, _r in TEL10)
and announce(units=roster_names({})) == SITE_NOW)
check("...and the roster-of-one path is the one every install actually reads: "
"an empty `inverters:` names exactly one unit",
@@ -244,11 +307,11 @@ check("...and the roster-of-one path is the one every install actually reads: "
check("...and a publisher told NOTHING about the roster announces the same "
"thing, so a caller that has not been taught about units cannot change "
"what a live install sees",
announce(units=()) == BASELINE)
announce(units=()) == SITE_NOW)
m, c, a = report("roster of one, P1 off (`omit=('p1_age',)`)",
announce(units=roster_names({}), omit=("p1_age",)),
[row for row in BASELINE if "p1_age" not in row[0]])
[row for row in SITE_NOW if "p1_age" not in row[0]])
check("...and the `omit` path is unchanged too: with P1 ingestion off the "
"watchdog input is still not announced, and nothing else moved",
(m, c, a) == ([], [], []))
@@ -280,11 +343,18 @@ check("⭐ AC 1: AT A ROSTER OF TWO, EVERY ONE OF A's NINE MESSAGES IS STILL "
(m, c) == ([], []))
check("...and the eight additions are all B's and A's own per-unit rows, and "
"all of them are NEW topics", len(a) == 8)
check("...and A's nine messages are still the FIRST nine, in the same order, "
"so a broker replaying retained messages sees them unchanged",
check("...and A's eight entity configs are still the FIRST eight, in the same "
"order, so a broker replaying retained messages sees them unchanged "
"(the ninth, `availability`, is byte-identical and last - the check "
"above is the one that covers it, as a diff rather than a prefix)",
TWO[:len(BASELINE) - 1] == BASELINE[:-1])
site_ids = {c_["unique_id"] for c_ in cfgs(BASELINE)}
# ⚠️ THE SITE SET IS `SITE_NOW`, NOT `BASELINE`. Read from `BASELINE` it would
# be short by TEL-10's three, and those three would then be counted as PER-UNIT
# rows by every line below that says "not a site id" - which silently changes
# what the sha256 pin, the uniqueness checks and part 7's fed/announced set are
# taken over.
site_ids = {c_["unique_id"] for c_ in cfgs(SITE_NOW)}
unit_cfgs = [c_ for c_ in cfgs(TWO) if c_["unique_id"] not in site_ids]
a_cfgs = [c_ for c_ in unit_cfgs if "inverter_a" in c_["unique_id"]]
b_cfgs = [c_ for c_ in unit_cfgs if "inverter_b" in c_["unique_id"]]
@@ -330,7 +400,7 @@ def field(rows, key):
for key in ("unique_id", "object_id", "state_topic"):
av, bv = set(field(a_cfgs, key)), set(field(b_cfgs, key))
sv = set(field(cfgs(BASELINE), key))
sv = set(field(cfgs(SITE_NOW), key))
check(f"⭐ AC 2: no `{key}` is shared between A's entities and B's, and "
f"none of B's collides with a SITE entity that already exists",
len(av) == 4 and len(bv) == 4 and not (av & bv) and not (bv & sv)
@@ -340,10 +410,10 @@ all_ids = field(cfgs(TWO), "unique_id")
check("...and across the WHOLE announcement every unique_id is distinct - an "
"HA discovery collision is not an error, it DROPS the second entity, so "
"one inverter's telemetry would silently stop existing",
len(all_ids) == len(set(all_ids)) == 16)
len(all_ids) == len(set(all_ids)) == 19)
all_topics = [t for t, _, _ in TWO]
check("...and so is every topic", len(all_topics) == len(set(all_topics)) == 17)
check("...and so is every topic", len(all_topics) == len(set(all_topics)) == 20)
# ⚠️ THE SLUG COLLISION rule 9(c) DOES NOT REFUSE. `inverter-b` and `Inverter B`
# are two different names by the validator's test and one string once slugged.
@@ -363,8 +433,14 @@ print("\n5. site-scoped sensors stay site-scoped")
# firmware's ingest guard). A second copy of it is a second answer to "is the
# meter fresh?", one of which is always the one nobody is looking at. `grid` is
# the same argument without the safety edge: there is ONE grid connection.
# ⚠️ TEL-10's THREE ARE IN HERE, AND THE ARGUMENT IS `grid`'s. There is ONE
# integrator - §6.2's architecture is one integrator on the P1 meter whose
# output is then SPLIT - so a per-inverter copy of it would invent N of a thing
# there is one of, and two of them would disagree the moment the allocator gave
# the units different shares.
SITE_ONLY = ("goodwe_ctl_grid", "goodwe_ctl_p1_age", "goodwe_ctl_circulation",
"goodwe_ctl_phase", "goodwe_ctl_status")
"goodwe_ctl_phase", "goodwe_ctl_status",
"goodwe_ctl_loop_i", "goodwe_ctl_loop_lead", "goodwe_ctl_loop_reason")
rows = []
for n in range(1, 7):
names = [f"inverter-{chr(ord('a') + i)}" for i in range(n)]
@@ -377,8 +453,8 @@ check("⭐ AC 3: `grid` and `p1_sample_age_s` are announced EXACTLY ONCE at ever
"two of a thing there is one of, and would duplicate a safety signal",
all(counts == [1] * len(SITE_ONLY) for _n, counts, _t in rows))
check("...and the site set does not grow with the roster while the per-unit "
"set does: 8 site entities always, 4 more per unit past the first",
[t for _n, _c, t in rows] == [8, 16, 20, 24, 28, 32])
"set does: 11 site entities always, 4 more per unit past the first",
[t for _n, _c, t in rows] == [11, 19, 23, 27, 31, 35])
site_devices = {json.dumps(c_["device"], sort_keys=True)
for c_ in cfgs(announce(units=NAMES2))
@@ -420,7 +496,7 @@ for c_, eid in zip(cfgs(TWO), composed):
f"name {c_['name']!r}")
check("⭐ every composed entity_id at a roster of two is DISTINCT: no per-unit "
"entity can land on top of a site entity that already has history",
len(composed) == len(set(composed)) == 16)
len(composed) == len(set(composed)) == 19)
base_composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(BASELINE)]
check("...and the eight ids that exist today are unchanged by adding B, which "
@@ -620,9 +696,9 @@ def properties(mod):
bc = [c_ for c_ in uc if "inverter_b" in c_["unique_id"]]
ac = [c_ for c_ in uc if "inverter_a" in c_["unique_id"]]
comp = [compose(c_["device"]["name"], c_["name"]) for c_ in tc]
out["A byte-identical at N=1"] = base == BASELINE
out["A byte-identical at N=1"] = base == SITE_NOW
out["A byte-identical at N=2"] = diff(two)[:2] == ([], [])
out["N=1 adds no topic"] = len(base) == len(none) == len(BASELINE)
out["N=1 adds no topic"] = len(base) == len(none) == len(SITE_NOW)
out["unique_ids all distinct"] = (
len(set(c_["unique_id"] for c_ in tc)) == len(tc))
out["slug-alike names still distinct"] = (
@@ -703,6 +779,25 @@ MUTATIONS = [
' ("grid", "grid_power", "Grid power", "W", "power", "measurement", None),\n')]),
("B's device drops `via_device`",
[(' "via_device": DEVICE["identifiers"][0],\n', "")]),
# --- TEL-10 ------------------------------------------------------------
("TEL-10's integrator row is DROPPED (published to a topic nothing "
"announced, which AC 4 forbids in the other direction)",
[(' ("loop_i", "goodwe_loop_integrator", "Loop integrator", "W", "power", "measurement", None),\n', "")]),
("TEL-10's integrator is announced PER INVERTER - two answers to a "
"question §6.2 says there is one integrator for",
[(' ("sign_latch", "sign_latch", "Sign latch", None, None, None, "mdi:flash-alert"),\n',
' ("sign_latch", "sign_latch", "Sign latch", None, None, None, "mdi:flash-alert"),\n'
' ("loop_i", "loop_integrator", "Loop integrator", "W", "power", "measurement", None),\n')]),
# ⚠️ THE ONE THAT CHANGES NO BYTE OF ANY PAYLOAD. Every id, name, unit and
# topic is identical; only the POSITION of the integrator row moves. A
# broker replaying the retained set then hands HA a different sequence, and
# part 1's claim is about order as much as about bytes - which is why it is
# asserted as a LIST and not as a set.
("TEL-10's integrator row is announced BEFORE release/1.0's nine",
[(' ("loop_i", "goodwe_loop_integrator", "Loop integrator", "W", "power", "measurement", None),\n', ""),
(' ("setpoint", "goodwe_setpoint", "Setpoint", "W", "power", "measurement", None),\n',
' ("loop_i", "goodwe_loop_integrator", "Loop integrator", "W", "power", "measurement", None),\n'
' ("setpoint", "goodwe_setpoint", "Setpoint", "W", "power", "measurement", None),\n')]),
]
survivors = []
@@ -757,11 +852,13 @@ for line in [
"resolve it, because it renames no device and no entity, and the byte-diff "
"protects both spellings equally. SIZE: unresolved, 8 live entity ids; it "
"wants one look at the live registry, which this branch must not take.",
"3. THE PER-UNIT PAYLOADS HAVE NO RELEASED BASELINE. A's are diffed against "
"release/1.0; B's can only be diffed against this branch (the sha256 pin in "
"part 3). A wrong `device_class` or unit on a per-unit sensor is therefore "
"caught as a CHANGE from review, not as an error. SIZE: 4 sensors x 4 "
"optional fields.",
"3. THE PER-UNIT PAYLOADS HAVE NO RELEASED BASELINE, AND NEITHER DO "
"TEL-10's THREE. A's are diffed against release/1.0; B's can only be "
"diffed against this branch (the sha256 pin in part 3), and `TEL10` above "
"is the same kind of pin for the three site rows this branch adds. A wrong "
"`device_class` or unit on any of them is therefore caught as a CHANGE "
"from review, not as an error. SIZE: 4 per-unit sensors x 4 optional "
"fields, plus 3 site sensors x 4.",
"4. NO CHECK HERE RUNS A BROKER. `_announce()` is graded through a fake "
"paho client, so QoS, retain semantics on a real broker, and what HA does "
"with a retained config for a device it has never seen are all out of "
+370
View File
@@ -0,0 +1,370 @@
"""Runnable check for TEL-10: the loop's integrator, published.
`self.i_w` is carried between cycles of `Controller.cycle()` and, before this
card, left the process nowhere at all - no entity, no MQTT attribute, no log
line. The last investigation into what it does had to RECONSTRUCT it from the
grid trace; the reconstruction reproduced about a third of the engagements it
was checked against and was discarded.
⚠️ WHAT THIS FILE REFUSES TO DO, SAID FIRST. It does not assert that a row
EXISTS. `publish()` handing a dict with `loop_i` in it is satisfied by
`loop_i: 0.0` forever, by `loop_i: setpoint`, and by a row that reports the
command under the integrator's name - which is exactly the confusion the card
is about, because on a default install the two are within a quantisation step
of each other for most of every day. Every check below therefore drives REAL
CYCLES through the real `Controller.cycle()` until the two quantities are
MEASURABLY DIFFERENT, and grades the published number at a tick where reading
the wrong one gives the wrong answer. MUTATION.md habit 4.
VERIFIED BY BREAKING IT. Every check below was driven by the mutation that
defeats the claim it names, in the CI image, on a tree purged of `__pycache__`
before each run, with the file's sha256 taken before, after, and again after
the revert (MUTATION.md's reporting rules, and habit 18's unique backup path).
# mutation bytes reds file
-- ---------------------------------------------- ----- ---- -----------
M1 `self.loop_i_w = loop_prev` - publish the -3 B 5 this file
COMMAND under the integrator's name
M2 `decision.i_w - self.target` for the lead +2 B 0 SURVIVOR
M2b `decision.i_w - decision.target_w` for the lead +8 B 1 this file
M3 `"loop_reason": self.reason` - the ARBITER's -5 B 5 this file
sentence in the loop's row
M4 delete `self.loop_reason = "not-running"` -45 B 1 this file
M5 delete the not-running branch's two zeroes -67 B 1 this file
M6 `"loop_i": round(self.target, 1)` -2 B 4 this file
M7 the `loop_i` row deleted from `SENSORS` -96 B 10+1 test_mqtt
+ this file
M8 `loop_i` announced PER INVERTER +89 B 8 test_mqtt
M9 `loop_i` announced BEFORE release/1.0's nine +0 B 7 test_mqtt
⚠️ A RED COUNT IS A PROPERTY OF THE SUITE AS MUCH AS OF THE MUTATION, SO EVERY
ONE ABOVE NAMES A TREE - this one, at this commit. A later card that adds a
check, re-baselines one or deletes one moves the number with the mutation
untouched. Re-measure rather than inherit: `tools/mutation_run.sh` on a `cp -a`
of the tree, one file at a time, and say which tree. M7's count was written as
8 and is 10 on a re-run, on both trees - the direction was right and the count
was decoration, which is the half of MUTATION.md's reporting rule 3 that is
easiest to let rot.
⚠️ M9 IS ZERO BYTES AND CHANGES NO PAYLOAD. Every id, name, unit and topic is
identical; only the ORDER of the retained messages moves. No fingerprint and no
byte delta can see it, which is why part 1 asserts the announcement as an
ordered LIST and not as a set.
⚠️ M2 IS A REAL SURVIVOR AND IS REPORTED AS ONE. At the line it edits,
`loop_prev` and `self.target` are the same value: `cycle()` does not reassign
`self.target` until `self.target = resolution.target_w`, further down. The
mutation therefore changes nothing that runs, on a purged tree, which is not
the direction stale bytecode fabricates. `loop_prev` is kept anyway - it is the
name the `compute()` call is made with, so the lead cannot be measured against
a different cycle's command by anybody moving a line - and M2b is the mutation
that DOES change the value, aimed at exactly that.
⚠️ AND THE FIRST DRAFT OF THIS FILE HAD TWO CHECKS OF ITS OWN THAT COULD NOT
REACH THE FAILURE THEY NAMED. Both are MUTATION.md habit 4:
* "the published integrator reaches its bound" was GREEN under M1, because
the command reaches the bound too - twelve cycles later. The check now
asserts the LEAD TIME, not the value.
* "a stopped controller publishes `not-running`" was GREEN under M4 and M5,
because the fixture STARTED stopped and `__init__` writes the same values.
It stops the controller part-way through a run that has already wound the
integrator to -5000 W, so a row left standing is a WRONG row.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
edit reverted inside one second is invisible to CPython's staleness test. That
is TEST-15, and it has already turned one live mutation into a false survivor.
Read MUTATION.md before you report a survivor.
"""
import asyncio
import sys
from app import mqtt
from app.control import Tuning, slew_limit_w
from app.main import Controller
fails = []
# The live install's shape, and every number in it is measured rather than
# chosen: `max_w` and `slew_w` are read straight off the recorder (the setpoint
# sensor's extremes are exactly +-5000 and 11 226 recorded steps are exactly
# 400 W), `gain` and `target_grid_w` are what the options carry.
LIVE = {"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "target_grid_w": -10.0,
"deadband_w": 15, "step_w": 10}
# The integrator's bound on a default install: `integrator_max_w` is None, which
# control.py resolves to `max_w`. Written as a literal on purpose (habit 2) -
# `== c.tuning.max_w` would compare the thing under test against itself.
BOUND_W = 5000.0
# `slew_w / gain`: the error above which the integrator moves further in one
# cycle than the command is allowed to. 400 / 0.6.
OUTRUN_W = 666.67
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
class _Store:
def __init__(self):
self.data = {}
def set(self, *a):
pass
def get_time(self, *a):
return None
class _Pub:
"""Captures what `Controller.publish()` hands the publisher, every cycle."""
enabled = False
def __init__(self):
self.frames = []
def publish(self, values):
self.frames.append(dict(values))
def close(self):
pass
class _Hass:
"""A scripted meter and an inverter that follows what it is told.
⚠️ THE PLANT FOLLOWS EXACTLY AND WITH NO LAG, WHICH IS DELIBERATE AND IS
THE HARDER CASE FOR EVERY CHECK HERE. A lagging inverter trips the
SATURATION freeze (`|prev_w - actual_w| > saturation_w`), and the
saturation freeze already holds the integrator - so a laggy fixture would
let SAFETY-13's mechanism pass for SAFETY-48's. Nothing below may be
explained by saturation, and part 4 asserts `frozen` is False throughout.
"""
def __init__(self, grid, soc=50.0):
self.grid_seq = grid if callable(grid) else (lambda _n: grid)
self.soc = soc
self.batt = 0.0
self.n = 0
self.writes = []
self.calls = []
async def limits(self, entity):
return (-LIVE["max_w"], LIVE["max_w"], 1.0)
async def number(self, entity, invert=False):
return {"sensor.grid": self.grid_seq(self.n), "sensor.soc": self.soc,
"sensor.batt": self.batt}.get(entity)
async def set_number(self, entity, value):
self.writes.append(float(value))
self.batt = float(value)
return True
async def call(self, domain, service, data):
self.calls.append((domain, service, data))
return True
def run(grid, cycles=30, auto=True, stop_after=None, **over):
"""`cycles` real `Controller.cycle()`s, and every published frame.
`stop_after` presses stop part-way through - which is not the same run as
starting stopped, and is the only one that can tell a row that was WRITTEN
on the not-running branch from a row that was never written at all.
"""
async def go():
hass = _Hass(grid)
pub = _Pub()
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15},
**LIVE, **over),
hass, _Store(), pub, stop=asyncio.Event())
c.auto = auto
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
for k in range(cycles):
if stop_after is not None and k == stop_after:
c.auto = False
await c.read_inputs()
await c.cycle()
c.publish()
hass.n += 1
return c, hass, pub.frames
return asyncio.run(go())
# ==============================================================================
print("1. TEL-10: the integrator is published, and it is NOT the command")
# ==============================================================================
# A standing 6 kW export against a 5 kW rail and a 400 W slew step. The
# integrator moves `gain * error` = 3.6 kW on the first cycle and reaches its
# 5000 W bound on the second; the command may only move 400 W a cycle, so it is
# at 800 W when the integrator is already railed and needs twelve more cycles to
# get there. That gap is the fixture: for ten cycles the two numbers differ by
# kilowatts, and a row published from the wrong one is wrong by kilowatts.
ctl, hass, frames = run(-6000.0, cycles=30)
i_row = [f["loop_i"] for f in frames]
sp_row = [f["setpoint"] for f in frames]
lead_row = [f["loop_lead"] for f in frames]
why_row = [f["loop_reason"] for f in frames]
print(" cycle loop_i setpoint loop_lead loop_reason")
for k in range(8):
print(f" {k:<5d}{i_row[k]:8.0f} {sp_row[k]:9.0f} {lead_row[k]:10.0f}"
f" {why_row[k]}")
first_at_bound = next((k for k, v in enumerate(i_row) if abs(v) >= BOUND_W), None)
first_cmd_at_bound = next((k for k, v in enumerate(sp_row) if abs(v) >= BOUND_W),
len(sp_row))
print(f" the integrator reaches {-BOUND_W:.0f} W at cycle "
f"{first_at_bound}; the command reaches it at cycle "
f"{first_cmd_at_bound}")
# ⚠️ THE LEAD TIME IS PART OF THE CLAIM, NOT A NICETY. "The published number
# reaches the bound" is satisfied by publishing the COMMAND too - it gets there
# as well, twelve cycles later - so a check written on the value alone grades
# nothing and the mutation that publishes `prev_w` walks straight through it.
# Measured: with `self.loop_i_w = loop_prev` this line is red and the value-only
# form was green.
check("⭐ THE PUBLISHED INTEGRATOR REACHES ITS BOUND, AND REACHES IT TEN CYCLES "
f"BEFORE THE COMMAND DOES. A 6 kW export winds it to exactly "
f"{BOUND_W:.0f} W - `integrator_max_w` is None, which control.py resolves "
"to `max_w` - while the command is still travelling 400 W a cycle",
first_at_bound is not None and i_row[first_at_bound] == -BOUND_W
and first_cmd_at_bound - first_at_bound >= 10)
# ⚠️ THE PRECONDITION, AS ITS OWN CHECK (habit 1). If the command happened to be
# at the bound too, the line above would be satisfied by publishing EITHER
# number and would be grading nothing.
check("...and the command is NOWHERE NEAR it at that tick, so the two are "
f"separable: setpoint {sp_row[first_at_bound]:.0f} W against an "
f"integrator of {i_row[first_at_bound]:.0f} W",
abs(sp_row[first_at_bound]) <= BOUND_W / 2)
check("...and the gap is the slew limit and not an accident: the command is "
"still travelling 400 W a cycle while the integrator stands still",
abs(sp_row[first_at_bound + 1] - sp_row[first_at_bound])
== slew_limit_w(Tuning(**LIVE)))
# The lead is `i_w - prev_w`, and `prev_w` is the command the LAST cycle left
# standing - which is the setpoint this file captured one frame earlier. Graded
# as that identity rather than as a magnitude, because a magnitude cannot tell
# a lead from the integrator itself.
ident = [k for k in range(1, 12)
if round(lead_row[k], 1) != round(i_row[k] - sp_row[k - 1], 1)]
check("⭐ AND `loop_lead` IS THE INTEGRATOR MINUS THE COMMAND IT WAS COMPUTED "
"AGAINST - the PREVIOUS cycle's setpoint, not this one's. Checked as an "
f"identity over cycles 1..11 ({len(ident)} disagreements)", not ident)
check("...and it is large while the command is catching up, so the row is not "
f"a permanent zero: worst lead {min(lead_row):.0f} W",
min(lead_row) <= -2000.0)
# ==============================================================================
print()
print("2. TEL-10: the reason row is compute()'s OWN word, not the arbiter's")
# ==============================================================================
# ⚠️ THE TWO SENTENCES EXIST SIDE BY SIDE AND ONE OF THEM ALREADY HAD A ROW.
# `self.reason` is `Resolution.explain()` - "loop -> -800 W" - and it prints the
# winning claim's NAME, never the claim's reason. So a publisher wired to
# `self.reason` would produce a row that is never wrong-looking and never once
# says `slew-limited`, which is the value SAFETY-48 is about.
print(f" the arbiter's sentence: {ctl.reason!r}")
print(f" the loop's own word : {ctl.loop_reason!r}")
check("⭐ THE LOOP'S REASON REACHES THE WIRE, and on a slew-limited cycle it is "
"the string `slew-limited` - a value the arbiter's sentence does not "
"contain at all",
"slew-limited" in why_row and "slew-limited" not in ctl.reason)
check("...and the two rows are DIFFERENT STRINGS on the same cycle, so one "
"cannot have been published from the other",
frames[-1]["loop_reason"] != ctl.reason)
check("...and every published reason is one `compute()` can actually produce",
set(why_row) <= {"tracking", "deadband", "i-clamped", "clamped",
"slew-limited", "saturated-freeze", "freeze-released",
"not-running"})
# A converged site: the error is inside the deadband and the law says so.
calm = run(-10.0, cycles=6)[2]
check("...and a site resting inside the deadband publishes `deadband`, not the "
"last exciting word the loop said", calm[-1]["loop_reason"] == "deadband")
# ⚠️ THE NOT-RUNNING PATH IS A DIFFERENT BRANCH OF `cycle()` AND IS GRADED AS
# ONE (habit 9). That branch zeroes `self.i_w`, so a row that kept repeating the
# last integrator there would say the loop was winding while nothing is being
# controlled at all.
#
# ⚠️ AND `auto: False` IS NOT THAT BRANCH, WHICH IS WHY BOTH ARE HERE. The
# shipped `shadow_mode: auto` means a STOPPED controller still runs the whole
# decision path and records it - SAFETY-10 - so the loop is live, the
# integrator is real, and a check that assumed otherwise would have been
# asserting `not-running` about a loop that was running. It took `auto: False`
# AND `shadow_mode: off` to reach the arm that zeroes it.
shadowed = run(-6000.0, cycles=6, auto=False)[2]
print(f" stopped, shadow_mode auto: status {shadowed[-1]['status']!r}, "
f"loop_i {shadowed[-1]['loop_i']:+.0f} W, "
f"reason {shadowed[-1]['loop_reason']!r}")
check("a STOPPED controller in shadow mode still publishes a live integrator - "
"the decision path runs, so the row must report it rather than a zero "
"that would read as 'nothing is winding'",
shadowed[-1]["status"] == "shadow"
and shadowed[-1]["loop_i"] < -1000.0
and shadowed[-1]["loop_reason"] != "not-running")
# ⚠️ AND IT IS STOPPED PART-WAY, NOT STARTED STOPPED, WHICH IS THE WHOLE
# DIFFERENCE. A controller that never ran carries `__init__`'s values, so a run
# that begins stopped is satisfied by a branch that writes NOTHING - measured:
# deleting `self.loop_reason = "not-running"` from `cycle()` outright was a
# -45 B SURVIVOR of the first draft of this check. The loop has to wind first,
# so that a row left standing is a row that says the wrong thing.
stopped = run(-6000.0, cycles=12, stop_after=6, shadow_mode="off")[2]
print(f" while running : loop_i {stopped[5]['loop_i']:+.0f} W, "
f"reason {stopped[5]['loop_reason']!r}, status {stopped[5]['status']!r}")
print(f" after stopping : loop_i {stopped[-1]['loop_i']:+.0f} W, "
f"reason {stopped[-1]['loop_reason']!r}, status {stopped[-1]['status']!r}")
check("...and the precondition: the loop really was winding before stop was "
"pressed, so a stale row afterwards would be a WRONG row and not merely "
"an unset one",
stopped[5]["loop_i"] <= -1000.0
and stopped[5]["loop_reason"] != "not-running")
check("⭐ AND ONCE THE DECISION PATH IS GENUINELY OFF THE ROWS SAY SO: "
"`not-running` and a zero integrator - the same zero `cycle()` writes to "
"`self.i_w` on that branch, not the number it was carrying a cycle ago",
stopped[-1]["status"] == "stopped"
and stopped[-1]["loop_reason"] == "not-running"
and stopped[-1]["loop_i"] == 0.0 and stopped[-1]["loop_lead"] == 0.0)
# ==============================================================================
print()
print("3. TEL-10: announced and published are the same three topics")
# ==============================================================================
# mqtt.py's `omit` note: an entity that exists and is never fed is a worse
# signal than one that does not exist. This is that claim for TEL-10's rows,
# taken off the SHIPPED announcement and the SHIPPED `publish()`.
announced = {t.rsplit("/", 2)[-2].replace(f"{mqtt.BASE}_", "")
for t, _ in mqtt.discovery_payloads()}
published = set(frames[-1])
TEL10 = {"loop_i", "loop_lead", "loop_reason"}
print(f" announced keys: {sorted(announced & TEL10)}")
print(f" published keys: {sorted(published & TEL10)}")
check("all three rows are announced AND published - no entity nothing feeds, "
"no topic nothing declared",
TEL10 <= announced and TEL10 <= published)
check("...and they are on the SITE device, not per unit - there is one "
"integrator",
all(c["device"] == mqtt.DEVICE
for t, c in mqtt.discovery_payloads(["inverter-a", "inverter-b"])
if c["unique_id"].replace(f"{mqtt.BASE}_", "") in TEL10))
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+85
View File
@@ -21,6 +21,7 @@ from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
from app.price import (
PRICE_OPTION_DEFAULTS, SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC
from app import shadow
from app.validate import (
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
actuator_writers, refuse, site_rail_w, validate_startup_config,
@@ -1722,6 +1723,90 @@ check("and the 6% asymmetry is reported as an ORDERED PAIR: the transposed "
"vat_pct 6" not in _T16_PAIR[0])
# --------------------------------------------------------------------------- #
print("\nSAFETY-10 - shadow_mode is validated like every other option")
# ⚠️ WHY THIS IS A REFUSAL AND NOT A COERCION. `shadow.mode()` resolves an
# unrecognised value to SHADOW - the direction that does not move energy - and
# an install silently reinterpreted that way COMMANDS NOTHING, indefinitely,
# while its status page calmly explains that this is intentional. A typo is not
# a decision, and UX-02 made this validator the one rule surface both editors
# read, so the rule has to be here rather than in a quiet fallback.
_S10_BAD = ["true", "yes", "1", "shadow", "", "autoo", None, 1]
for _v in _S10_BAD:
_p = validate_startup_config(base(shadow_mode=_v), -5000, 5000)
check(f"shadow_mode={_v!r} is REFUSED, and the refusal SPELLS the key so "
"configui.problems_by_field() can file it under the right control",
why(_p, "shadow_mode is") and why(_p, "'auto'")
and why(_p, "'on'") and why(_p, "'off'"))
for _v in ("auto", "on", "off", "AUTO", " on ", "Auto "):
check(f"shadow_mode={_v!r} starts - the three values and the tolerant "
"spellings of them are accepted, so the rule above refuses garbage "
"and not case",
validate_startup_config(base(shadow_mode=_v), -5000, 5000) == [])
check("PRECONDITION: and the key being ABSENT starts too, which is what every "
"install that upgrades into this version has. A rule that refused the "
"absent key would refuse every existing install at once",
validate_startup_config(base(), -5000, 5000) == []
and "shadow_mode" not in base())
# The two arrangements have to DISAGREE somewhere, or `why()` is grading a
# sentence that is in the list for another reason entirely.
check("...and a refused shadow_mode is the ONLY thing wrong with that config, "
"so the refusal above is this rule's and not some other rule's",
len(validate_startup_config(base(shadow_mode="maybe"), -5000, 5000)) == 1)
# --- THE SIX STATES, AND THE ONE THAT PROTECTS THE EXISTING INSTALL ---------
# ⚠️ GRADED HERE RATHER THAN IN test_clamp.py, WHERE IT STARTED, AND THE REASON
# IS MEASURED. `shadow.engaged()` is a pure function of the option and the
# start button; a mutation that defeats the DEFAULT stops test_clamp.py's
# part 3 reaching the wire at all, which aborts that file with an IndexError
# before part 8 runs - so the mutation's red count there is a lower bound and
# the table itself is never graded (MUTATION.md, FCST-06's shape). Nothing in
# this file drives a Controller, so nothing here can be taken down that way.
# Six states. The one that matters most is the LAST row: an install that is
# controlling today must not be changed by this option arriving with a default.
_TABLE = [
("off", True, False), ("off", False, False),
("on", True, True), ("on", False, True),
("auto", False, True), ("auto", True, False),
]
for _m, _started, _want in _TABLE:
check(f"shadow_mode {_m!r} with control "
f"{'started' if _started else 'stopped'} -> "
f"{'SHADOW' if _want else 'LIVE'}",
shadow.engaged({"shadow_mode": _m}, control_started=_started)
is _want)
check("THE EXISTING INSTALL: the SHIPPED DEFAULT, on an install that is "
"controlling, is LIVE. This is the whole reason the option is not a "
"boolean defaulting to true - the Supervisor merges a new default into "
"every existing install, and a boolean would have stopped a battery that "
"is regulating a real house today",
shadow.engaged({}, control_started=True) is False)
check("...and the SAME shipped default on a FRESH install - `auto_start` is "
"false, so nothing has started - is SHADOW. That is what 'default-on' "
"means here, delivered where it is safe",
shadow.engaged({}, control_started=False) is True)
# ⚠️ THE REASON, NOT THE VERDICT (MUTATION.md's masking rule). `on` and
# `auto`-while-stopped are the same boolean and want DIFFERENT operator
# actions: one is an option, the other is a button. A surface showing only the
# verdict would be blind to the two rungs being swapped.
check("the two ways of being in shadow mode are told apart in words, so a "
"build that swapped the rungs could not render identically",
shadow.why({"shadow_mode": "on"}, control_started=False)
!= shadow.why({"shadow_mode": "auto"}, control_started=False))
# ⚠️ AND THE FAIL-SAFE DIRECTION OF AN UNREADABLE VALUE IS GRADED, THOUGH THE
# rule above means a running install can never hold one. It survived the first
# mutation battery as M9 for exactly that reason: everything else refused the
# value before `mode()` ever saw it, so the direction it fails in was ungraded
# and could have been flipped to "live" with the whole suite green.
check("an UNREADABLE shadow_mode resolves to SHADOW, not to live - the "
"direction that does not move energy. It is reachable only if this "
"file's rule is ever weakened, which is the case it exists for",
shadow.engaged({"shadow_mode": "garbage"}, control_started=True) is True
and shadow.engaged({"shadow_mode": ""}, control_started=True) is True
and shadow.mode({"shadow_mode": "garbage"}) == "on")
# --------------------------------------------------------------------------- #
print()
if fails:
+130
View File
@@ -272,6 +272,136 @@ check("REFUSED is exactly 1, not merely non-zero",
print("\nthe shipped tree's own version is readable")
check("shipped_version() finds a version", bool(shipped_version(HERE)))
# --------------------------------------------------------------------------- #
# PKG-14 - THE OTHER HALF OF THE NO-OP, AND NOTHING GRADED IT.
#
# PKG-09 refuses a deploy whose `version:` did not move. It cannot see the
# mirror failure, which this repository has now produced twice: the version
# DOES move and the changelog's `## Unreleased` block is never closed, so the
# operator who opens the file to find out what they are about to install reads
# "THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE" about a
# tree that is being installed. `## 0.8.0` records one such block that
# accumulated across three releases; `## 0.9.0` was cut with a second one left
# standing underneath it.
#
# ⚠️ THE TWO SIDES COME FROM DIFFERENT FILES ON PURPOSE (MUTATION.md habit 4).
# config.yaml's `version:` and CHANGELOG.md's newest heading are written by
# different hands at different times, and the whole defect is that they drift.
# A check that read the number once and compared it with itself would be the
# DEV-02 fixture again: a number against itself.
#
# ⚠️ AND IT IS NOT A SUBSTRING SCAN OVER THE WHOLE FILE. Every id this section
# looks for occurs dozens of times lower down, so a scan of the file would pass
# on any tree whatever the newest section said - CFG-04's defect, which asserted
# against the TEXT of the thing under test. The section is SLICED first, and
# part 11a grades the slicer against a sentinel that exists only below it.
print("\n11. PKG-14 - the bump and the closed section are one act")
_CHANGELOG = os.path.join(HERE, "CHANGELOG.md")
with open(_CHANGELOG, encoding="utf-8") as fh:
_cl_lines = fh.read().splitlines()
_heads = [(i, l[3:].strip()) for i, l in enumerate(_cl_lines)
if l.startswith("## ")]
check(f"precondition: the file has {len(_heads)} `## ` headings, so the "
f"quantifiers below are not over an empty set", len(_heads) >= 8)
# 11a. THE SLICE, AND TWO CONTROLS ON THE SLICER.
# ARCH-02's fifteenth instance: a splitter that never closes attributes
# everything to the last thing it opened, and every per-section check
# downstream silently becomes a total over the whole file. Both controls below
# exist because 11b/11c would go on passing for a tree in which nothing they
# name is true.
def slice_newest(lines):
"""(heading text, the lines of the FIRST `## ` section) - closing at the
next `## `, which is the half that has to be graded rather than assumed."""
heads = [i for i, l in enumerate(lines) if l.startswith("## ")]
if not heads:
return None, []
start = heads[0]
end = heads[1] if len(heads) > 1 else len(lines)
return lines[start][3:].strip(), lines[start:end]
# CONTROL ONE, run on every invocation rather than attested in a commit
# message: a synthetic document whose token appears ONLY below the first
# section. A slicer that runs to the end of the file finds it; this one must
# not. test_defaults_pin.py part 5 plants a literal for the same reason.
_SYNTH = ["# Changelog", "", "## 9.9.9", "", "the newest section.", "",
"## 9.9.8", "", "PLANTED-ONLY-BELOW", ""]
_syn_name, _syn_body = slice_newest(_SYNTH)
check("11a. the slicer CLOSES - on a synthetic document it excludes a token "
"that exists only in the section BELOW the newest one",
_syn_name == "9.9.9" and "PLANTED-ONLY-BELOW" in "\n".join(_SYNTH)
and "PLANTED-ONLY-BELOW" not in "\n".join(_syn_body))
_top_name, _top_body = slice_newest(_cl_lines)
_newest = "\n".join(_top_body)
_SENTINEL = "## 0.8.0"
check(f"...and on the shipped file too: it does not contain {_SENTINEL!r}, "
f"which the file does", _SENTINEL in "\n".join(_cl_lines)
and _SENTINEL not in _newest)
check(f"...and it is not empty: {len(_top_body)} lines under {_top_name!r}",
len(_top_body) > 20)
# CONTROL TWO - and this is the one that catches a LOST HEADING rather than a
# loose slicer. Every version section in this file carries exactly one
# "in merge order since" contents table. Delete a released section's `## `
# heading and its table is swallowed by the section above, which is a released
# record disappearing into an unreleased one - the shape `## 0.7.0` already
# records for 0.6.0, and the one PKG-08 is left holding.
_tables = [l for l in _top_body if "in merge order since" in l]
check(f"...and it carries EXACTLY ONE contents table, not {len(_tables)} - a "
f"second one means a released section below lost its `## ` heading and "
f"was absorbed into this one", len(_tables) == 1)
# 11b. THE BLOCK IS CLOSED, AND IT CARRIES THE SHIPPED NUMBER.
check("11b. the newest `## ` heading is a VERSION and not `Unreleased` - the "
"block was closed by whoever bumped the version",
_top_name.lower() != "unreleased")
_shipped = shipped_version(HERE)
check(f"...and it is exactly config.yaml's `version:` ({_shipped!r} in "
f"config.yaml, {_top_name!r} in CHANGELOG.md) - two files, so a bump "
f"without a section, or a section without a bump, is red",
_top_name == _shipped)
# 11c. A WITHDRAWN price_provider IS NAMED IN THE SECTION THAT WITHDRAWS IT.
# ⚠️ READ OFF app/price.py BY AST, NOT TYPED HERE. A list transcribed into this
# file would go on passing after a fourth id was retired and documented
# nowhere, which is the exact failure TARIFF-07 exists to prevent: an id the
# add-on refuses at startup and no release note mentions.
import ast # noqa: E402
_price_src = open(os.path.join(HERE, "app", "price.py"),
encoding="utf-8").read()
_retired = []
for _node in ast.walk(ast.parse(_price_src)):
if isinstance(_node, ast.AnnAssign) and getattr(
_node.target, "id", None) == "RETIRED_PROVIDERS":
_retired = [k.value for k in _node.value.keys]
elif isinstance(_node, ast.Assign) and any(
getattr(t, "id", None) == "RETIRED_PROVIDERS" for t in _node.targets):
_retired = [k.value for k in _node.value.keys]
check(f"11c. precondition: RETIRED_PROVIDERS was read off app/price.py and is "
f"not empty - {sorted(_retired)}", len(_retired) > 0)
_unnamed = sorted(p for p in _retired if p not in _newest)
check(f"...and every one of the {len(_retired)} withdrawn id(s) is named in "
f"`## {_top_name}` - an id the validator refuses at startup that no "
f"release note mentions is the TARIFF-07 defect with the sign flipped",
_unnamed == [])
# 11d. THE LEDGER OF BLOCKS STILL MISFILED, FROZEN RATHER THAN FIXED.
# PKG-08 owns splitting them; this file owns the promise that the number can
# only go DOWN. A release that cuts its section and leaves a second
# `## Unreleased` standing is red here on the commit that does it.
_stale = [n for _, n in _heads[1:] if n.lower() == "unreleased"]
check(f"11d. exactly {len(_stale)} `## Unreleased` block(s) remain BELOW the "
f"newest section, and the count is pinned EXACTLY so the ledger can "
f"only shrink - splitting them is PKG-08, not this file",
len(_stale) == 1)
for d in tmpdirs:
shutil.rmtree(d, ignore_errors=True)
+118
View File
@@ -2858,6 +2858,124 @@ check("...and the SITE sign detector is still absent at a roster of one - part "
not _c38one.site_sign_wd.tripped
and _c38one.site_sign_wd.disagree_since is None)
# =========================================================================== #
# 10. SAFETY-10 - THE SIGN WATCHDOG IN SHADOW MODE
# =========================================================================== #
# THE QUESTION THE CARD ASKS OF EVERY FAILSAFE: does it observe, does it latch,
# or is it inert in shadow mode? For this one the answer had to be INERT, and
# the reason is the worst outcome in the feature:
#
# SAFETY-02 grades the command STANDING ON THE WIRE against the meter. Shadow
# mode holds the wire at 0 W, so the detector cannot arm. Fed the SHADOW
# DECISION instead it would compare a number against a meter that never saw
# it - guaranteed disagreement for as long as the decision asked for power -
# and TRIP. That latch survives into live operation AND across a restart
# (part 3 of this file is that property), so a shadow run would hand the
# operator a battery that will not move, from a run in which nothing moved.
# Shadow mode would manufacture the failsafe it exists to help debug.
#
# ⚠️ GRADED ON THE SAME STIMULUS THAT TRIPS IT LIVE, NOT ON A QUIET ONE.
# MUTATION.md: a per-thing check that has never seen the things DISAGREE is a
# total. `_s10_sign()` is `_e2e()` above with one option changed - the same
# inverted loop, the same steady 2000 W import, the same 1 s window.
print("\n10. SAFETY-10 - the sign watchdog in shadow mode\n")
async def _s10_sign(shadow_mode):
hass = _Hass()
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": -0.6,
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True,
# ⚠️ AN EXPLICIT SLEW, AND THE REASON IS THE LIMITATION GRADED
# BELOW. At `slew_w: 0` the rail is `max_w / 9` = 222 W, and in
# shadow mode the loop starts from the 0 W standing on the wire
# EVERY cycle - so its decision never leaves 222 W and never
# reaches the 300 W gate at all. The detector would then be
# "inert" for a reason that has nothing to do with this card.
"slew_w": 1000,
"shadow_mode": shadow_mode}
c = Controller(opts, hass, _S(), _P(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -5000.0, 5000.0
wanted = []
for _ in range(12):
await c.read_inputs()
await c.cycle()
wanted.append(c.shadow_would_w if c.shadow else c.target)
await asyncio.sleep(0.25)
return c, hass, wanted
_s10_live, _s10_lh, _s10_lw = asyncio.run(_s10_sign("off"))
_s10_shad, _s10_sh, _s10_sw = asyncio.run(_s10_sign("on"))
print(f" live : tripped={_s10_live.sign_wd.tripped} "
f"wanted={[round(w) for w in _s10_lw[:4]]}")
print(f" shadow: tripped={_s10_shad.sign_wd.tripped} "
f"wanted={[round(w) for w in _s10_sw[:4]]}")
check("PRECONDITION: the same stimulus TRIPS the watchdog with shadow mode "
"off, so what follows is a claim about shadow mode and not about a "
"stimulus that could never have tripped anything",
_s10_live.sign_wd.tripped)
check("PRECONDITION: and in shadow mode the decision path still asked for "
"charge into that import - well past the 300 W gate - so the detector "
"had a wrong-way number available to it and did not use it",
any(w is not None and w < -300.0 for w in _s10_sw))
check("SAFETY-10: the sign watchdog is INERT in shadow mode. It grades what "
"this controller has standing on the wire, and that is 0 W - so a latch "
"that would survive into live operation and across a restart is never "
"armed by a run in which nothing was commanded",
not _s10_shad.sign_wd.tripped
and _s10_shad.sign_wd.disagree_since is None)
check("...and it is inert BY CONSTRUCTION, not by a special case: the value it "
"was handed is `Inverter.standing_w()`, which is the 0 W shadow mode "
"actually wrote and not the decision it recorded",
_s10_shad.inverters[0].standing_w() == 0.0
and set(_s10_sh.writes) == {0.0})
# ⚠️ AND THE LIMITATION IS RECORDED HERE RATHER THAN LEFT FOR SOMEBODY TO
# DISCOVER. `_shadow_settle()` re-seeds the loop's `prev_w` to what is standing
# on the wire, so in shadow mode the loop leg answers "what would this
# controller command NOW, from a battery at 0 W" - ONE STEP - and not "what
# trajectory would it have followed". The trajectory is a closed-loop property
# and it did not happen; reporting it would be a simulation of a plant that
# does not exist.
#
# ⚠️ AMENDED BY SAFETY-47. The three lines that stood here said "the strategies
# are unaffected ... which is why the three defects this card was written for
# are all visible from a shadow run and this one is not." That was true until
# SAFETY-45 merged, one commit earlier: the buy leg is now sized from the
# BILLED-QUARTER METER, which is a function of what the battery did. So this is
# NOT the loop leg's own limitation - it is the GENERAL case, and the loop leg
# is merely where it had already been noticed. A shadow run cannot surface any
# closed-loop defect, and of the three this card was written for exactly one -
# TARIFF-14's wrong quarter-hour - is visible from one. `test_arbitrage.py`
# part 3d grades that; this check keeps grading the loop leg, which is what it
# has always been for.
check("RECORDED LIMITATION: the loop leg's shadow decision is ONE slew step "
"from the standing command and stays there, while the live run RAMPS. A "
"shadow run answers 'what would it do now', not 'what trajectory would "
f"it have followed' (shadow {[round(w) for w in _s10_sw[:4]]} vs live "
f"{[round(w) for w in _s10_lw[:4]]})",
len({round(w) for w in _s10_sw if w is not None}) == 1
and len({round(w) for w in _s10_lw[:4]}) > 1)
# ⚠️ AND "INERT" HAD TO BE SHOWN TO MEAN STARVED, NOT DISABLED. A detector
# that had been switched off would satisfy every check above and would still
# be off the moment shadow mode ends. So the SAME object, off the SAME
# controller, is handed the disagreement the run never let it see.
_s10_n0 = datetime.now(timezone.utc)
_s10_wd = _s10_shad.inverters[0].sign_wd
check("PRECONDITION: it is not tripped going in, so what follows is this "
"stimulus and not a latch left over from the run",
not _s10_wd.tripped)
_s10_wd.evaluate(-1500.0, 2000.0, True, _s10_n0)
_s10_fired = _s10_wd.evaluate(-1500.0, 2000.0, True,
_s10_n0 + timedelta(seconds=5))
check("...and the failsafe is STARVED, not DISABLED: handed -1500 W standing "
"against a +2000 W import - the evidence shadow mode never lets it see - "
"the very same detector object trips. It is armed for the moment shadow "
"mode ends",
bool(_s10_fired) and _s10_wd.tripped)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
@@ -38,6 +38,14 @@
#
# Editing this file is how you say "the announcement changed on purpose". Do not
# edit it to make a check pass.
#
# ⚠️ TEL-10 ADDED THE LAST THREE ROWS, AND THEY ARE ADDITIONS RATHER THAN A
# RE-COMPOSITION. Nothing above them moved: same entity_ids, same unique_ids,
# same device. They are recorded here - the BUILD's reference - and deliberately
# NOT in `live-entities-recorded.txt`, because the install does not have them
# yet and an id the build announces that the install lacks is a NEW entity, not
# an orphan. `test_mqtt.py` part 1 grades the same two claims on the payload
# bytes.
sensor.goodwe_setpoint goodwe_ctl_setpoint goodwe_rs485_controller
sensor.goodwe_grid_power goodwe_ctl_grid goodwe_rs485_controller
sensor.goodwe_battery_power goodwe_ctl_battery goodwe_rs485_controller
@@ -46,3 +54,6 @@ sensor.goodwe_circulating_power goodwe_ctl_circulation goodwe_rs485_controlle
sensor.goodwe_maintenance_phase goodwe_ctl_phase goodwe_rs485_controller
sensor.goodwe_controller_status goodwe_ctl_status goodwe_rs485_controller
sensor.goodwe_p1_sample_age goodwe_ctl_p1_age goodwe_rs485_controller
sensor.goodwe_loop_integrator goodwe_ctl_loop_i goodwe_rs485_controller
sensor.goodwe_loop_integrator_lead goodwe_ctl_loop_lead goodwe_rs485_controller
sensor.goodwe_loop_reason goodwe_ctl_loop_reason goodwe_rs485_controller
+3
View File
@@ -249,6 +249,9 @@ configuration:
delivery_hold_s:
name: "Safety · Not-delivering report after (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
shadow_mode:
name: "Safety · Shadow mode — run every decision, command nothing"
description: "Runs the whole decision path — strategies, arbiter, clamps and guards — and sends NOTHING to the inverter. Every inverter is held at 0 W, refreshed on the ordinary heartbeat, and the status page says plainly that the battery is not being controlled; the decision it WOULD have commanded, and why, is on the status page and in the action log. This is how you watch a site for a day before letting it trade. 'auto' (the shipped value) means shadow mode while control is stopped and ordinary control once you press Start — so a NEW install observes before it ever commands anything, and an install that is already controlling is not changed by one watt. 'on' means shadow mode always, whatever the Start / stop button says: that is the setting to pick when you want the loop and the strategies running on a live site with nothing reaching the battery. 'off' is ordinary control. ⚠️ While this is on the battery is NOT being regulated — the grid is not held at zero, no arbitrage trade is executed and no capacity peak is shaved. ⚠️ A failsafe latch that was already up stays up: shadow mode does not clear it, and the banner names it."
auto_start:
name: "Advanced · Start controlling automatically on boot"
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
+3
View File
@@ -229,6 +229,9 @@ configuration:
delivery_hold_s:
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
shadow_mode:
name: "Veiligheid · Schaduwmodus — alles beslissen, niets sturen"
description: "Doorloopt het volledige beslispad — strategieën, arbiter, begrenzers en bewakingen — en stuurt NIETS naar de omvormer. Elke omvormer wordt op 0 W gehouden, ververst op de gewone hartslag, en de statuspagina zegt onomwonden dat de batterij niet wordt aangestuurd; wat er gestuurd ZOU zijn, en waarom, staat op de statuspagina en in het actielogboek. Zo bekijkt u een installatie een dag lang voordat u hem laat handelen. 'auto' (de meegeleverde waarde) betekent schaduwmodus zolang de regeling gestopt is en gewone regeling zodra u op Start drukt — een NIEUWE installatie kijkt dus eerst toe voordat ze ooit iets stuurt, en een installatie die al regelt verandert geen watt. 'on' betekent altijd schaduwmodus, wat de Start/stop-knop ook zegt: die stand kiest u als u de lus en de strategieën wilt laten draaien op een werkende installatie zonder dat er iets bij de batterij aankomt. 'off' is gewone regeling. ⚠️ Zolang dit aanstaat wordt de batterij NIET geregeld — het net wordt niet op nul gehouden, er wordt geen prijshandel uitgevoerd en er wordt geen capaciteitspiek afgevlakt. ⚠️ Een vergrendeling die al aanstond blijft aanstaan: schaduwmodus heft die niet op, en de banner noemt hem."
auto_start:
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."