Compare commits

...
10 Commits
Author SHA1 Message Date
adminandClaude Opus 5 ca8fd01819 CI-9: correct the guard comment's grading claim, and name the third blind spot
checks / unit (pull_request) Successful in 7m54s
Documentation only - no change to the guard, the trigger, or any step.

The guard's comment block contradicted itself. It said the pull request that
reintroduces a filter "is still graded by the unfiltered workflow which is on
the base today", while the checkout step's note a few lines below says grading
uses the HEAD's workflow. The head's is correct, and the review measured it on
PR #101: head carrying a filter that excludes the base, base carrying the
unfiltered workflow -> no run, context posted `skipped`, rollup `success`, gate
cleared (409). The wrong version made the step sound like an unconditional
ratchet. What is actually true is narrower: it bites whenever the reintroduced
filter still names the pull request's own base, which is the ordinary case.

That correction exposes a third blind spot, worse than the two already listed.
A reintroduced `branches:` filter that simply does not name the base escapes
the guard entirely, because the pull request carrying it is itself skipped. It
needs no craft - `branches: [main]` from a template, or a tidy-up to `[master]`,
which nothing in this repository's history has ever targeted - and it lands as a
skipped, laundered, untested merge into release/1.0. After that every subsequent
pull request is skipped as well: the gate off completely, not merely the stacked
case leaking.

It is documented, not fixed, and deliberately so. No in-repo guard can close it,
because any such guard lives inside the workflow that gets skipped. Closing it
needs a second permanently unfiltered workflow whose context is added to
release/1.0's `status_check_contexts` - a forge change, the owner's decision,
being put to them separately, and explicitly not a blocker on CI-9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 14:44:16 +02:00
adminandClaude Opus 5 d2ad080e45 CI-9: guard the trigger mechanically, and re-derive two figures I made up
checks / unit (pull_request) Successful in 7m55s
Review findings on PR #95.

1. Two figures in the header were written under the word "measured" and were
   not measured. "of the 50 pull requests in this repository's history, 49..."
   came from a /pulls call I did not notice was truncated at Gitea's 50-item
   page cap; and "#93 was stacked on TEL-10 five days ago" was invented - #93
   was created 2026-09-19T10:05:00 and merged 11:05:26, the same morning, under
   three hours before CI-9 was opened. Both re-derived from a paged API query
   and now stated with their denominator: 93 pull requests predate this ticket
   (#1..#93, excluding CI-9's own #94..#98), 92 against release/1.0, exactly one
   stacked (#2, TEL-01 -> SAFETY-04). The 92 is flagged as an upper bound rather
   than a census, because the API reports a pull request's CURRENT base and so
   hides any that was opened stacked and later retargeted - which is #93 itself.
   The ratio, and so the cost argument, is unchanged.

2. The fix rested on an invariant - checks.yml has no event filter - that was
   defended only by a comment. Prose has already failed in this exact file:
   9c1a23328a shipped "NO `paths:` FILTER, AND ITS ABSENCE IS LOAD-BEARING" and
   `branches: [release/1.0, master]` in the same commit, eighteen lines apart,
   and that survived 20 days and 84 merges. Adds a step that reads the `on:`
   block of the MERGED checks.yml and fails if it has regained a `branches:`,
   `paths:`, `tags:`, `types:` or any `-ignore` variant. This is not the card's
   option 3 - it does not try to detect its own skip, which is impossible; it
   bites on the pull request that reintroduces a filter, which the unfiltered
   workflow still grades. Two blind spots are named in the step's comment, both
   needing a protection-rule change that is the owner's call.

Also softens "uncloseable from here", which the review judged overstated:
Gitea's acceptance of a `skipped` context is unreachable from this repository,
but the consequence is substantially closeable, and the guard is what closes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 14:25:22 +02:00
adminandClaude Opus 5 074157c4c2 CI-9: drop the pull_request branches filter, because a skipped check clears the gate
checks / unit (pull_request) Successful in 7m57s
`branches: [release/1.0, master]` meant a stacked pull request - one based on
another feature branch - never matched. Gitea does not stay silent about a
workflow it sits out: it posts the required context itself with state
`skipped`, the combined-status rollup reports that as `success`, and the merge
gate accepts it. Retargeting does not repair it, because the skip is a commit
status on the head sha and so survives the base change.

Measured on Gitea 1.27.3 with throwaway PR #94 against a branch carrying a copy
of release/1.0's protection rule:

  only status is `skipped`   ->  POST .../merge  409 head out of date  (CLEARED)
  no status at all           ->  POST .../merge  405 not all required  (refused)
  status `pending`           ->  POST .../merge  405 not all required  (refused)
  status `success`           ->  POST .../merge  409 head out of date  (CLEARED)

The 409s come from a deliberately wrong head_commit_id in the probe, which
Gitea checks only after branch protection - so a 409 means the request had
already cleared the gate, and nothing was ever merged. `skipped` is the only
state that leaks.

Fixing this at the gate is not possible. Gitea branch protection can require
that a context is PRESENT, never that it is SUCCESS: its whole status-check
vocabulary is `enable_status_check` and `status_check_contexts`, per this
server's own /swagger.v1.json. So the fix has to be upstream - never emit a
skip - which means no event filter.

Cost: every pull request now runs the full ~8 minute suite, including the
intermediate steps of a stack. 49 of the 50 pull requests in this repository's
history were opened straight against release/1.0 and would have run anyway.

Also corrects two things CI-01's header stated as fact. A filter miss is
fail-OPEN, not the fail-closed pending-forever it described; and it is a
skip-ci keyword, not a filter miss, that actually withholds the context and
leaves it pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 13:58:58 +02:00
adminandClaude Opus 5 186a044a3a Merge pull request 'SAFETY-48: the slew step ceiling reaches the integrator, and what it bounds is the LEAD' (#93) from SAFETY-48 into release/1.0
Reviewed on the pull request and merged there. Retargeted from TEL-10 to release/1.0 after #92 merged, and NOT merged on the inherited green: retargeting alone left the old `skipped` conclusion attached to a6af332 and the combined status still read success, so the required context was satisfied by a workflow that had never run. Run 799 is a genuine pull_request execution on the merge into release/1.0 - all five steps, the add-on's own tests occupying 08:56:58 to 09:04:47 UTC.

THE CAP MOVING A SATURATION-FROZEN INTEGRATOR IS ACCEPTABLE, NOT MERELY DISCLOSED. The disclosed figures reproduce exactly against the committed capture - 79 160 frozen ticks, 1095 moved, 679 further out on the side they were already on, worst 2424 W - and the worked tick in the comment is present with exactly the numbers written down and is the worst of that subset. What decides it is where the integrator lands rather than which way it moves: on the shipped configuration integrator_max_w is unset, so target == i_w after the bounds and the cap holds |i_w - slewed| to one slew step on every slew-limited cycle, frozen ones included. Measured over the whole 256 251-tick recording the carried integrator never stands more than two slew steps from the command on the wire - 800 W against release/1.0's 9200 W. On the frozen path this replaces an integrator bounded only by integrator_max_w with one bounded at two steps, so the loop is less wound up on those ticks and not more. The same-cycle command cannot be affected at all, the cap moving i_w after slewed is decided.

THE ALTERNATIVE IS REFUSED FOR A MEASURED REASON. Gating the cap with `if frozen: _lead_cap = float("inf")` reproduces all four of section 5's corrected figures - export 34.71 kWh, wasted 3.49 kWh, worst quarter 3434 W, worst observed lead 4961 W. The gate does leave the bound with a hole large enough to make the card pointless.

NOTHING FALSE FOUND. Section 3a's lag table reproduces cell for cell, including the rows that say the peak improvement is absent at every lag at or below 5.0 s; the card does not claim a capacity-tariff benefit and says so in four places. Form A is committed nowhere, so it was rebuilt from section 3's prose alone and reproduces all seven figures - 34.43 / 110.62 / 2656 / 2.99 / 3934.59 / +2.73 / +991.53 - which makes section 3 a specification rather than a summary. The one-sided cap was swept against the two-sided band over 136 080 states on an independent grid: 0 differences, corroborating habit 22's retraction.

MUTATION EVIDENCE RE-DRIVEN, EVERY COUNT EXACT, on purged cp -a trees one file at a time: L2 zero bytes 4+3, L4 one red in test_telemetry with test_control GREEN as claimed, L5a zero bytes 5+5, L6 zero bytes 6+3.

SUITE RE-RUN BY THE REVIEWER on the merge rather than the branch tip: release/1.0 29/29, this merge 30/30, exit 0.

One non-blocking note left on the pull request: "worst case 2424 W" is scoped to the 679 further-out ticks, while the largest move over all 1095 is 4220 W. That case is benign - the cap pulling a stale positive integrator down to follow a charging command, magnitude falling - which is why it is a note and not a finding.

Merging is not deploying. The live install still runs release/1.0's control law and slew_w 400 applied by hand; this merge authorises no deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 11:05:25 +02:00
adminandClaude Opus 5 660d079bf3 Merge pull request 'TEL-10: the loop's integrator stops being invisible' (#92) from TEL-10 into release/1.0
Reviewed on the pull request and merged there. `checks / unit (pull_request)` run 798 is a real execution, not a skip: all five steps ran and "the add-on's own tests" occupied 08:04:51 to 08:12:38 UTC.

CONTROL BEHAVIOUR IS UNCHANGED, VERIFIED MECHANICALLY RATHER THAN READ. goodwe_controller/app/control.py is blob 180f58a on release/1.0 and on TEL-10 alike, same sha256 of contents, and release/1.0 is a direct ancestor of TEL-10 so there is no merge-base divergence for a change to hide behind. The only executable edit inside cycle() is prev_w=self.target becoming a named local, loop_prev, passed to the same call. Three write sites for self.i_w exist and only two have a telemetry counterpart; the third is the arbiter re-seed at main.py:3049, inside the same branch and immediately followed by compute(), so it cannot leave loop_i_w stale. app/mqtt.py appends to SENSORS and discovery_payloads() walks it in order, so every existing entity keeps its index.

SUITE RE-RUN BY THE REVIEWER, on the MERGE rather than the branch tip, in the CI image on a purged tree, :ro, one file at a time: release/1.0 29/29, this merge 30/30, and the stacked SAFETY-48 30/30 against both bases. All exit 0.

MUTATION EVIDENCE RE-DRIVEN RATHER THAN READ, because the previous round's counts were irreproducible. M1 is 5 reds on TEL-10's tree and 4 on SAFETY-48's, exactly as the header says and for the reason it gives; M2 is confirmed GREEN and is reported as a survivor; M7 is -96 B and 10 reds in test_mqtt.py plus 1 in test_telemetry.py; M9 is a zero-byte reordering and 7 reds. Every claimed count exact.

R6's correction is true and not merely made: part 1's headline now says EIGHT and the slice under it asserts eight entity configs, part 3's NINE headline is backed by a full missing/changed diff that does cover all nine, and the unasked-for fix two lines down reads eight against the eight-prefix slice.

Merging is not deploying. Nothing here reached the inverter and no deploy is authorised by this merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 10:54:02 +02:00
glenn schrooyenandClaude Opus 5 a6af332048 SAFETY-48: what the lead cap actually does to a frozen integrator, and what the peak figure depends on
checks / unit (pull_request) Successful in 7m55s
Four review findings, none of them in executable code. The control law, the
tests and the instruments' arithmetic are untouched; every number below was
re-measured against the committed 16-day capture before it was written down.

R1 - app/control.py carried a justification the decision record and MUTATION.md
habit 22 had already retracted, and it was false about the code it sits in. It
said an unconditional placement "would also pull the integrator toward a
command the SATURATION freeze is pinning", priced at 118 of 256 250 ticks. The
118 was measured against a different pair; on the pair the sentence names the
two forms are bit-identical - 0 of 256 246 ticks, re-measured. And the narrow
form that shipped DOES move a saturation-frozen integrator: it runs before the
freeze block and is not gated on `frozen`. Of 79 160 frozen ticks the cap
changes `i_w` on 1095, and on 679 of those it moves it further out on the side
it was already on - putting back magnitude the freeze had just let fall, which
the freeze's own rule forbids. Worst case 2424 W, at prev_w -3400 W, grid
+4697 W, actual -4144 W, sat_count 2 -> 3: the freeze leaves the integrator at
-176 W and the cap drags it to -2600 W. It never overrides a same-cycle refusal
(the freeze clipped on 75 512 ticks, the slew bound on none of them), and that
is stated too. It is also where SAFETY-13's settle values went: gating the cap
on `not frozen` puts test_control.py part 5 back at -1190 W exactly, while part
4(b) does not come back - that one moved for the winding reason.

R3 - the record read its own sensitivity table backwards. `fit_plant()` picks
lag 7.5 s on battery-power RMS, which separates it from 2.5 s by 0.3 %, while
the tool's own printed fidelity measure (export against the recorded 29.43 kWh)
prefers 2.5 s by the whole 35 % gap. Swept properly: at every lag <= 5.0 s
release/1.0's worst quarter is ALREADY 3434 W and this card changes it by
exactly zero. The 3739 -> 3434 W headline exists at one plant. New section 3a
carries the table; the export reduction now leads every summary and the peak is
presented as lag-dependent. The form-A rejection is strengthened rather than
weakened: form A is worse on the billed quarter at every lag where release/1.0
produces a peak at all, and at every top-N cut of the quarters.

R2 - DOCS.md stated modelled watts to the owner as measurement. It now leads
with the export reduction, says the figures are simulated, and says plainly
that no capacity-tariff improvement is promised.

R4 - tools/slew_sim.py --slew narrated "the OVER-ABSORPTION does not move, and
at the slowest slews it is slightly WORSE" while printing a monotonically
falling column. That sentence is TRUE on release/1.0 (reproduced:
958/1107/1133/1058/987/1029/1150/1143/1081) and false under this branch's law
(777/734/663/563/553/507/435/358/321), so SAFETY-39 is not retracted: its
record gets a forward reference, and the script now reads SLEW_LEAD_STEPS out
of the law it imported and narrates from the column it just printed. Both
branches of that narration were driven.

R5 - three counts in the L table were decoration and are re-driven in the CI
image on a `cp -a`, purged, `:ro`, one file at a time: L5 is 5+5 with the
function names exchanged and 5+4 with the branch bodies exchanged (two
mutations wearing one name, now listed separately), L6 is 6+3. The separable-
state count is grid-dependent and three sweeps disagree, so all three are
quoted with their grids and the load-bearing claim is WHERE they separate.

R7 - "a large import answered too weakly" is not what the aggregate shows. Form
A cuts import by 5.45 kWh and export by 5.18 kWh, and paired per tick delivers
8.56 kWh less offtake energy on the ticks release/1.0 was importing. The harm
is a tail redistribution inside quarters that already net to offtake. The
conclusion stands; the sentence is narrowed to it.

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:37:01 +02:00
glenn schrooyenandClaude Opus 5 03ef61fa3b SAFETY-48: the slew step ceiling reaches the integrator, and what it bounds is the LEAD
`compute()` freezes the integrator with an asymmetric rule and the comment
beside it states the requirement outright - "the requirement is that the
INTEGRATOR stop accumulating, not only the command" - but `frozen` is computed
from ONE signal, `abs(prev_w - actual_w) > saturation_w`, and slew-limiting
never enters it. The integrator moves `gain * error` a cycle and the command
moves at most `slew_w`, so the integrator outruns the command whenever
`|error| > slew_w / gain`: 667 W at the live shape, against a measured median
grid error of 1114 W at the onset of a recorded episode. SAFETY-39's retune
made it MORE frequent, not less - a smaller step is a lower threshold.

⚠️ THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, WHICH IS WHY IT IS
NOT WHAT SHIPPED. Reusing the asymmetric freeze on a carried slew flag was
built from release/1.0's own source and measured over 16.00 days / 256 252
meter instants of the live install's recorder: export 39.61 -> 34.43 kWh, and
the worst quarter-hour average OFFTAKE 3739 -> 3935 W with the mean quarter
+2.73 W, in five of six plant and tuning variants. A frozen integrator stops at
`prev + gain*error`, which undershoots by (1 - gain); for an export that is
free and for an import it is offtake, for as long as the ramp lasts. The card
said "do not invent a second rule"; the measurement says to, and both sentences
are recorded.

WHAT SHIPS BOUNDS THE LEAD AT ONE SLEW STEP, inside the branch where the slew
actually bound. Same 16 days, same ticks, same disturbance, same plant, same
seed: export 39.61 -> 34.07 kWh, wasted throughput 4.67 -> 3.19 kWh, battery
sign reversals 2992 -> 2770, median recovery 232 -> 199 s, p99 849 -> 850 s,
max 901 -> 898 s, unrecovered 53 -> 31, worst |i_w - prev_w| 9200 -> 800 W -
and the worst 15-minute average offtake 3739 -> 3434 W, i.e. the billed
quantity IMPROVES. `i_w = slewed` (a cap of zero) is better again at 32.02 kWh
and is refused: it is the velocity form this module's docstring says `i_w=None`
collapses to.

TWO COMMITTED NUMBERS MOVED, DELIBERATELY, WITH THE REASON BESIDE THEM.
SAFETY-13's released command settles at -800 W and -1200 W where release/1.0
settled at -1190 W and -2390 W, because there is less wound integrator to
settle onto; AC 4's property - it stops at the frozen integrator, not the rail
- is what is asserted and the second is now graded against `i_w` rather than a
literal. And SAFETY-39's "the slew does not own the over-absorption" is now
FALSE - INVERTED, on the independent first-order plant test_control.py writes
itself: 1005/1109/1285/1330 W rising with a slower slew becomes 729/550/424/326
falling. Two plant models that share nothing agree on the sign.

Evidence: `tools/replay_recorded_loop.py` plus the read-only capture it made
(scenarios/loop-2026-09-02_2026-09-17). The "before" law is the shipped file
with the block removed by a named substitution - never a transcription - and a
missing anchor is a REFUSAL carrying "NOTHING HAS BEEN MEASURED". The
instrument prints its own fidelity first: it reproduces 39.6 kWh of export
where the install exported 29.4, i.e. it is ~35 % pessimistic about the loop's
tracking, so only the delta is claimed.

30/30 suites green, including test_control.py's exhaustive i_w=None
equivalence sweep against the transcribed release/1.0 law, untouched: the cap
moves `i_w` and not `target`, and that sweep compares (target_w, sat_count,
frozen, reason).

Six law mutations, one instrument battery. L4 - the cap applied
unconditionally, -92 B - was a SURVIVOR of all thirty files: it is
bit-identical on the shipped configuration (0 of 256 246 ticks) and separable
on 2050 of 257 400 swept states, all at `integrator_max_w` above `max_w`, which
part 4 now drives. L5 and L6 are ZERO BYTES.

AND TWO COMMENTS IN THIS CARD WERE INVENTED AND ARE CORRECTED IN PLACE. The
one-sided spelling was justified by a two-sided form that would "drag a
trailing integrator" - it does not, and the two agree on 205 920 of 205 920
swept states; and a "118 of 256 252 ticks" figure had been measured against a
different pair. MUTATION.md gains habits 21 and 22 for both shapes.

NOT DEPLOYED, NOT VERSIONED. The live install still runs release/1.0's law.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 02:36:28 +02:00
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
80 changed files with 3638 additions and 60 deletions
+238 -5
View File
@@ -3,12 +3,35 @@
# of real coverage including a wall-clock cadence test, and not one machine ever
# ran them. Every merge here was a human reading a diff.
#
# ⚠️ NO `paths:` FILTER, AND ITS ABSENCE IS LOAD-BEARING. Branch protection on
# release/1.0 requires the context `checks / unit (pull_request)`. Gitea leaves a
# required context it never receives PENDING forever, so the moment this workflow
# learns to sit a PR out, that PR becomes unmergeable. Condition a STEP if you
# ⚠️ NO EVENT FILTER OF ANY KIND - NO `paths:`, AND SINCE CI-9 NO `branches:`
# EITHER - AND THAT ABSENCE IS LOAD-BEARING. Branch protection on release/1.0
# requires the context `checks / unit (pull_request)`. Condition a STEP if you
# must; never the workflow.
#
# ⚠️ CI-01 WROTE THE REASON DOWN WRONG, AND THE WRONG REASON WAS THE SAFE-
# SOUNDING ONE. It said an unreceived required context sits PENDING forever, so
# a sat-out pull request becomes unmergeable - annoying, but fail-safe. That is
# not what this Gitea does. MEASURED on Gitea 1.27.3, 2026-09-19, throwaway
# PR #94: when a workflow exists but its event filter does not match, Gitea does
# not stay silent. It POSTS the required context itself, with state `skipped`:
#
# GET /api/v1/repos/admin/goodwe-addon/commits/<head sha>/statuses
# [{"status":"skipped","context":"checks / unit (pull_request)",
# "description":"Skipped","target_url":""}] <- no run, no job, no url
#
# GET /api/v1/repos/admin/goodwe-addon/commits/<head sha>/status
# {"state":"success","total_count":1} <- the rollup LAUNDERS it
#
# and Gitea's merge gate accepts it. Same PR, same head sha, one status on it
# and that status a skip, POST .../pulls/94/merge answered 409 `head out of
# date` - the 409 being a deliberately wrong `head_commit_id` in the probe, i.e.
# the request had ALREADY CLEARED BRANCH PROTECTION. The control, a required
# context with no status at all, answered 405 `Not all required status checks
# successful`. So the true failure mode is the exact inverse of the one written
# here before: sitting a pull request out does not make it unmergeable, it makes
# it MERGEABLE WITHOUT EVER BEING TESTED, and the badge says success while it
# happens. Fail-open, not fail-closed. Do not reintroduce a filter here.
#
# ⚠️ THE RUNNER IS SHARED WITH THE OTHER REPO'S RIG. `goodwe-runner` on
# 192.168.2.147 carries the labels `self-hosted` and `goodwe-rig` and is
# registered `:host`, and it is the only runner there is - so this job wears the
@@ -23,9 +46,85 @@
# that step; a green from this workflow is a claim about content that can land.
name: checks
# ⚠️ CI-9: `pull_request:` TAKES NO `branches:` LIST, ON PURPOSE. It used to
# read `branches: [release/1.0, master]`, which meant a STACKED pull request -
# one whose base is another feature branch rather than a release branch - did
# not match, and so was skipped, laundered to `success`, and mergeable untested
# by the mechanism described at the top of this file.
#
# ⚠️ AND RETARGETING DID NOT REPAIR IT, WHICH IS WHAT MADE THIS P1. The skip is
# a COMMIT STATUS ON THE HEAD SHA, not a property of the pull request, so moving
# the base afterwards leaves it exactly where it was. Observed live on PR #93
# (2026-09-19): retargeted TEL-10 -> release/1.0, and the 10:05:00 `skipped`
# stayed attached to head a6af332 with the rollup still reading success. It was
# caught by a human who forced a real execution by reopening the pull request
# (run 799, 10:56). Reproduced deliberately on throwaway PR #94: retargeted onto
# a branch carrying a byte-identical copy of release/1.0's protection rule, and
# the single `skipped` status survived the retarget unchanged - no new run, no
# new status - and still cleared the gate.
#
# ⚠️ WHY NOT FIX THIS AT THE GATE INSTEAD, which would be the better place. It
# cannot be done. Gitea's branch protection cannot express "this context must be
# SUCCESS" as opposed to "this context must be PRESENT AND NOT FAILING". The
# whole of its status-check vocabulary is two fields - `enable_status_check`
# (bool) and `status_check_contexts` (a list of globs MATCHING CONTEXT NAMES) -
# confirmed against this server's own schema at /swagger.v1.json, definitions
# EditBranchProtectionOption and CreateBranchProtectionOption. There is no field
# for the required STATE anywhere in either. The success/skip comparison lives
# in Gitea's compiled code, so the only lever left is upstream of it: make sure
# the context is never a skip in the first place. Hence this block.
#
# ⚠️ WHAT THIS COSTS, stated honestly, because it is a real trade. Every pull
# request now runs the full suite, including the intermediate steps of a stack
# that nobody intends to merge on its own. A run is ~8 minutes wall clock
# (run 799: 7m55s) on a runner that is `capacity: 1` and shared with the other
# repo's rig, so each extra run is ~8 minutes of slot that a rig campaign then
# queues behind.
#
# SCALE OF THE NEW WORK, re-derived from the API and stated with its denominator,
# because the first version of this paragraph had a made-up one. Counting the 93
# pull requests that predate this ticket (#1..#93; CI-9's own #94..#98 excluded,
# and note /pulls caps a page at 50, so this needs paging to see at all): 92 were
# opened against release/1.0 and would have run anyway, and exactly ONE was
# stacked - #2, TEL-01 -> SAFETY-04, closed unmerged, 2026-08-24. No pull request
# has ever targeted `master`, so half the old filter was dead from the start.
#
# ⚠️ THAT 92 IS AN UPPER BOUND, NOT A CENSUS, and the reason is this very bug.
# The API reports a pull request's CURRENT base, so one that was opened stacked
# and later retargeted is recorded against its final base and is invisible here.
# #93 is precisely that case - opened 2026-09-19T10:05:00 on TEL-10, retargeted,
# merged into release/1.0 at 11:05:26 the same morning, under three hours before
# CI-9 was opened - and it counts in the 92. So the true historical stacking rate
# is somewhere above 1/93, and unmeasurable from here.
#
# The bill is therefore small today and grows with however much stacking the team
# adopts. It is worth paying: the alternative is that the cheap case stays
# untested and indistinguishable from the tested one.
#
# ⚠️ THIS DOES NOT CHANGE GITEA'S ACCEPTANCE OF A `skipped` REQUIRED CONTEXT -
# that half is in its compiled code and is not reachable from this repository.
# The CONSEQUENCE, though, is very largely closeable from here, and is closed:
# after this edit there is nothing in this repository that produces a skip -
# there is exactly one workflow file, it now has no event filter of any kind,
# and the other candidate cause was MEASURED and is not one. The `guard the
# trigger` step below then keeps it that way mechanically rather than by asking
# the next editor to read this comment.
#
# THE MEASUREMENT, because the obvious guess is wrong and was nearly written
# here as fact. A skip-ci keyword in a commit message (`[skip ci]`, `[ci skip]`,
# `[no ci]`, `[skip actions]`, `[actions skip]`) does NOT behave like a filter
# miss. On throwaway PR #94, head b36effb, commit subject ending `[skip ci]`:
#
# .../commits/b36effb/statuses -> [] <- NO status, not a skip
# .../commits/b36effb/status -> {"state":"pending"} <- so the rollup is pending
# POST .../pulls/94/merge -> 405 Not all required status checks successful
#
# i.e. skip-ci is FAIL-CLOSED - it withholds the context entirely, which is the
# pending-forever behaviour CI-01's header wrongly attributed to a filter miss.
# It blocks the merge rather than laundering it, so it needs no fix and no
# app.ini change. Only the filter miss ever posted a `skipped`.
on:
pull_request:
branches: [release/1.0, master]
workflow_dispatch:
# ⚠️ NO `concurrency:` BLOCK, AND ITS ABSENCE IS AS LOAD-BEARING AS THE MISSING
@@ -224,6 +323,140 @@ jobs:
exit 1
fi
# ⚠️ THE GUARD ON THE TRIGGER. THIS STEP EXISTS BECAUSE PROSE ALREADY
# FAILED IN THIS FILE, ONCE, FOR TWENTY DAYS.
#
# `git show 9c1a23328a` is CI-01's first commit. It shipped the header
# "⚠️ NO `paths:` FILTER, AND ITS ABSENCE IS LOAD-BEARING" and the line
# `branches: [release/1.0, master]` IN THE SAME COMMIT, EIGHTEEN LINES
# APART. The comment forbade one kind of event filter while the code
# directly above the job carried another, and nobody caught it through 84
# merges until the gate let a stacked pull request through. So the whole of
# CI-9's fix rests on an invariant - "checks.yml has no event filter" -
# that has already been silently violated in this exact file by the person
# who wrote the warning against violating it. A louder comment is not a
# remedy for that. A red step is.
#
# WHY THIS CAN WORK AT ALL, given CI-9 rejected the card's option 3 (a step
# that detects its own workflow having been skipped) as impossible. That
# option is impossible because a skipped workflow runs no steps, so there
# is nothing alive to notice. This is the different, tractable case: it
# fires on the pull request that REINTRODUCES a filter, and it does so
# whenever the filter being reintroduced STILL NAMES THAT PULL REQUEST'S
# OWN BASE - the ordinary case, because a filter put back by hand names
# the release branch people are actually targeting. The edit then has to
# get past this step before it can start causing skips. It is a ratchet,
# not a skip detector.
#
# ⚠️ AND IT IS THE HEAD'S WORKFLOW THAT DECIDES WHETHER ANYTHING RUNS, NOT
# THE BASE'S. An earlier draft of this comment claimed the reintroducing
# pull request "is still graded by the unfiltered workflow which is on the
# base today". That was wrong, it contradicted the checkout step's own note
# a few lines below, and the error was not cosmetic: it made this step
# sound like an unconditional ratchet, which is exactly the false comfort
# CI-9 exists to stamp out. MEASURED on PR #101 - head carrying a filter
# that excludes the base, base carrying the unfiltered workflow - and the
# result was no run at all, the context posted `skipped`, the rollup
# reading `success`, and the gate CLEARED (409). Gitea takes the workflow
# file from refs/pull/<n>/head, so a head that filters itself out is simply
# never graded, and nothing on the base can reach out and stop it. That is
# what blind spot 3 below is made of.
#
# WHAT IT INSPECTS is the MERGED tree in $WS - the content that would
# actually land on the base - not the branch tip and not the copy of this
# file that Gitea used to decide whether to run. Those are three different
# trees here (see the long note on the checkout step) and only the merged
# one is the thing being authorised.
#
# ⚠️ THREE ACKNOWLEDGED BLIND SPOTS. NOT ONE OF THEM IS CLOSEABLE FROM
# INSIDE THIS FILE, and the reason is the same in all three: each is a case
# where THIS WORKFLOW IS ITSELF THE THING THAT GETS SKIPPED, and a guard
# that lives inside a skipped workflow does not run. Closing them needs a
# SECOND, permanently unfiltered workflow whose own context is added to
# release/1.0's `status_check_contexts`, so that something survives to
# report when this file is sat out. That is a forge change and the
# repository owner's decision; it is being put to them separately and is
# deliberately NOT held as a blocker on CI-9.
#
# 1. A reintroduced `paths:` filter crafted to exclude the reintroducing
# pull request's OWN diff would skip that pull request, so this step
# would not run to object to it.
#
# 2. An edit that lands on a feature branch through a stacked pull
# request is graded by the HEAD's workflow, so a filter already
# present in the head hides the edit from this step - until that
# branch is proposed for release/1.0, where (unless it is case 3) the
# base matches the reintroduced filter and this step runs and bites.
# Caught at the release boundary, which is the boundary that matters.
#
# 3. ⚠️ THE WORST OF THE THREE, AND WORSE THAN BOTH OF THE OTHERS PUT
# TOGETHER: a reintroduced `branches:` filter that simply DOES NOT
# NAME THE BASE escapes this step entirely, because the pull request
# carrying it is itself skipped. It takes no craft and no malice to
# write - `branches: [main]` copied out of a GitHub template, or a
# tidy-up to `[master]`, a branch that NO pull request in this
# repository's history has ever targeted - and it arrives as a
# skipped, laundered, untested merge into release/1.0.
#
# What makes it categorically worse is what happens the moment after.
# Once that file is ON release/1.0, every subsequent pull request is
# graded against it and every one of them is skipped too. This is not
# the stacked case leaking through a crack; it is the gate off
# completely, for everything, silently, with every badge green. The
# only thing standing between the repository and that state today is
# a human reading this file - which is precisely the kind of defence
# this step exists because it has already failed once (9c1a23328a).
#
# It is deliberately a FAILURE and not a warning: a warning would leave
# the required context green, which is the precise defect CI-9 is about.
- name: guard the trigger - refuse a checks.yml that has regained an event filter
run: |
set -eu
f="$WS/goodwe-addon/.gitea/workflows/checks.yml"
test -f "$f" || { echo "checks.yml is missing from the merged tree."; exit 1; }
# Walk the top-level `on:` block of the MERGED file. Full-line comments
# at column 0 are skipped outright - this file's own prose quotes
# `paths:` and `branches:` many times and must not trip its own guard -
# and an inline `#` is trimmed from any line that is kept.
bad=$(awk '
function scan(t) {
sub(/#.*/, "", t)
if (t ~ /(^|[^A-Za-z0-9_-])(branches|branches-ignore|paths|paths-ignore|tags|tags-ignore|types)[[:space:]]*:/)
return 1
return 0
}
/^[[:space:]]*#/ { next }
/^("on"|'"'"'on'"'"'|on)[[:space:]]*:/ {
inon = 1
rest = $0; sub(/^[^:]*:/, "", rest)
if (scan(rest)) print NR ": " $0
next
}
inon == 1 {
if ($0 ~ /^[[:space:]]*$/) next
if ($0 ~ /^[[:space:]]/) { if (scan($0)) print NR ": " $0; next }
inon = 0
}
' "$f")
if [ -n "$bad" ]; then
echo "REFUSED: checks.yml has regained an event filter in its \`on:\` block."
echo "$bad"
echo
echo " A filter means some pull request stops matching this workflow. Gitea does"
echo " not stay silent about a workflow it sits out - it posts the required context"
echo " \`checks / unit (pull_request)\` itself with state \`skipped\`, the combined"
echo " status rolls that up as \`success\`, and the merge gate ACCEPTS it. That is"
echo " CI-9: a pull request merged on a workflow that never ran a step. It was"
echo " reproduced by merging PR #96 for real through a protected branch."
echo
echo " Branch protection cannot save you here - it can require that a context is"
echo " PRESENT, never that it is SUCCESS - so the filter is the whole defence."
echo " If you genuinely need to narrow what runs, condition a STEP, never the"
echo " workflow. See the \`on:\` block's comments for the measurements."
exit 1
fi
echo "OK: the \`on:\` block of the merged checks.yml carries no event filter."
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
run: |
set -eu
@@ -187,6 +187,23 @@ over-absorption. So the rig does not choose the number — **the constraint
chooses it, and the rig says what the constraint does and does not buy.** That
is the same relationship the 2026-08-22 table had to its own three columns.
⚠️ **FORWARD REFERENCE, ADDED BY SAFETY-48 — THIS TABLE AND THIS SENTENCE ARE
TRUE OF release/1.0's LAW AND FALSE OF THE ONE THAT REPLACED IT.** Nothing above
is retracted: re-run on release/1.0 today the same rig prints the same column
(958 / 1107 / 1133 / 1058 / 987 / 1029 / 1150 / 1143 / 1081 W), flat,
non-monotone, 800 better than 556. Under SAFETY-48's lead cap the **same script
on the same plant** prints 777 / 734 / 663 / 563 / 553 / 507 / 435 / 358 / 321 W
— falling monotonically, so a slower slew is now *better* and the sentence above
inverts. The mechanism SAFETY-39 identified is exactly why: `slew_w` bounds the
OUTPUT while the integrator goes on accumulating `gain · error`, so slowing the
slew used to buy nothing; bound the integrator's lead and it starts to.
`tools/slew_sim.py --slew` now names which of the two laws it is running under
before it narrates the column, and
`DECISIONS/SAFETY-48-bounding-the-integrator-lead.md` §6 carries both tables and
the independent confirmation from `test_control.py`'s own plant model. ⚠️ **This
does NOT re-open the choice of 556**: the constraint picks the number, the rig
does not, and that is unchanged in both laws.
⚠️ **A single step is not evidence here.** The deadband, the 10 W quantisation
and the phase of the step against the tick move the result by hundreds of watts,
non-monotonically. The first cut of this derivation was a single step and ranked
@@ -0,0 +1,551 @@
# SAFETY-48 — the slew step ceiling reaches the integrator, and what it bounds is the LEAD
**Status:** decided and implemented on branch `SAFETY-48`, on top of `TEL-10`.
**Card:** SAFETY-48 — *"the integrator goes on winding while the command is
slew-limited"*.
**Nothing here reached the inverter.** The live install still runs `slew_w: 400`
applied by hand on 2026-09-05 12:00 and `release/1.0`'s control law. This branch
does not deploy and does not bump the version.
---
## 0. The four things a reader should take away
1. **It is a missing case, not a wrong constant, and the arithmetic says so.**
The integrator moves `gain * error` a cycle and the command moves at most
`slew_w`, so the integrator outruns the command whenever
`|error| > slew_w / gain` — 667 W at the live shape. SAFETY-39's retune made
`slew_w` **smaller**, which makes that threshold **lower**. §1.
2. ⚠️ **THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, AND THAT IS WHY
IT IS NOT WHAT SHIPPED.** Reusing the asymmetric freeze on a carried slew
flag cuts export 13 % and moves the worst quarter-hour average **offtake**
from 3739 W to **3935 W**, with the mean quarter +2.7 W — and on the billed
quarter it is worse at **every plant lag where release/1.0 produces a peak
at all** (a tie at lag 0, worse at 2.5 / 5.0 / 7.5 / 10.0 s) and at **every
top-N cut of the quarters**, which makes this the least lag-sensitive result
in the card. The harm is a tail redistribution inside quarters that already
net to offtake, not a weaker answer to import in aggregate. §3, §3a.
3. **What ships bounds the LEAD at one slew step, only where the slew is what
stopped the command, and only in the leading direction.** The robust result
is the **export reduction — 39.6 → 34.1 kWh, and it falls at every plant and
tuning tried** — with wasted throughput 4.67 → 3.19 kWh, sign reversals
2992 → 2770 and median recovery 232 → 199 s. ⚠️ **The billed quarter is not
something this card can claim.** It reads 3739 → **3434 W** at the fitted
plant lag of 7.5 s; at every lag ≤ 5.0 s — including the one that best
reproduces the recorded export — release/1.0 is *already* at 3434 W and this
card moves it by exactly zero. The lag is not identified by the data. §4,
§3a.
4. **Two committed numbers moved and neither is a silent edit.** SAFETY-13's
released command settles lower, because there is less wound integrator to
settle onto; and SAFETY-39's *"the slew does not own the over-absorption"* is
now **false — inverted, on its own independent plant model.** §5, §6.
---
## 1. The defect, in the terms the code uses
`compute()` freezes the integrator with an asymmetric rule, and the comment
beside it states the requirement outright:
> the requirement is that the INTEGRATOR stop accumulating, not only the command
But `frozen` is computed from **one** signal:
```python
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w # not tracking
```
Slew-limiting never enters it. When the command hits the step ceiling the
integrator keeps winding at `gain * error` until it reaches `integrator_max_w`,
and the unwind afterwards is `integrator_max_w / slew_w` cycles — 12.5 at the
live shape, about a minute at the meter's measured tick.
⚠️ **AND THE RETUNE MADE IT MORE FREQUENT, NOT LESS.** `|error| > slew_w / gain`
is 1667 W at `slew_w: 1000` and **667 W** at `slew_w: 400`. The measured median
grid error at the onset of a recorded episode is 1114 W — above the second
threshold and below the first. This is why SAFETY-39 did not fix it, and the
mechanism is the same one §6 shows inverting.
## 2. How it was measured, and what the measurement cannot say
`tools/replay_recorded_loop.py`, over **16.00 days / 256 252 meter instants** of
the live install's own recorder, exported read-only.
**Real:** the disturbance (`D = grid + batt` at every recorded meter instant —
the house with this loop's own battery taken back out, which is exogenous and
therefore valid to drive a counterfactual with); the tick (the meter's own
recorded instants, median **5.01 s**); and the control law, imported out of the
checkout. The "before" law is the **same file** with the SAFETY-48 block removed
by a named substitution and exec'd into a fresh namespace — never a
transcription — and a missing or duplicated anchor is a **refusal** with
`NOTHING HAS BEEN MEASURED` on it, because an instrument that quietly compared
the shipped law with itself would report the card as changing nothing.
**Modelled:** the inverter (pure delay 7.5 s + first-order lag 2.0 s +
`batt = 0.973 × cmd − 22.4 W`, fitted on this same recording above the SoC
floor, residual **375 W RMS**); the pack (SoC integrated, discharge refused at
or below the floor); and **everything else the arbiter does** — the recorded
setpoint is the arbiter's output after the peak guard, the SoC clamps,
maintenance and arbitrage, while the replay runs `compute()` alone.
⚠️ **THE ABSOLUTE FIGURES ARE PESSIMISTIC BY ABOUT A THIRD AND THE TOOL PRINTS
IT ON EVERY RUN.** Replaying the before-law reproduces 39.6 kWh of export where
the install actually exported **29.4 kWh** over the same window. The delta is
what this instrument is for: both laws run on the same ticks, the same
disturbance, the same plant and the same seed.
⚠️ **THE SoC FILTER ON THE PLANT FIT IS THE WHOLE FIT, AND IT IS A FINDING IN
ITS OWN RIGHT.** This install spends **36 % of its ticks at or below a 10 % SoC
with the command standing near 2 kW and the battery delivering nothing.** Fitted
over all ticks the recording says `batt = 0.50 × cmd − 396 W, r = 0.70` — a
plant that follows half the command, which no inverter does. Above the floor the
same data says `batt = 1.00 × cmd − 28 W, r = 0.977`. The floor is the PACK and
is modelled separately.
## 3. The form the card asked for, and the number that refused it
The card asked for the asymmetric rule already in service for saturation, on a
flag carried across cycles the way `sat_count` is. It was built — from
release/1.0's own source, one substitution per change — and measured on the same
16 days through the same pipeline:
| | release/1.0 | **form A** — carried slew flag + the asymmetric freeze | **SAFETY-48** — the lead cap |
|---|---:|---:|---:|
| export kWh | 39.61 | 34.43 | **34.07** |
| import kWh | 116.07 | 110.62 | 111.20 |
| battery sign reversals | 2992 | **2656** | 2770 |
| wasted throughput kWh | 4.67 | **2.99** | 3.19 |
| **worst 15-min offtake W** | 3739 | ⚠️ **3935** | **3434** |
| mean quarter-hour offtake delta | — | ⚠️ **+2.73 W** | **−2.39 W** |
| worst quarter-hour delta | — | +992 W | +532 W |
| recovery median s | 232 | **184** | 199 |
| recovery p99 s | 849 | **781** | 850 |
| recovery max s | 901 | 890 | 898 |
| unrecovered (> 900 s) | 53 | **26** | 31 |
| \|i_w − prev_w\| p99 W | 3361 | 1534 | **800** |
| \|i_w − prev_w\| max W | 9200 | 6912 | **800** |
**Form A wins on four rows and loses on the one that is billed.** The capacity
tariff (*capaciteitstarief*) bills the month's worst quarter-hour average
offtake, and form A raises it by **196 W** — and raises the MEAN quarter by
2.7 W, so this is systematic and not one unlucky quarter. It does so in **five
of the six** plant and tuning variants in `--sensitivity`; the sixth is a tie.
The per-cycle mechanism is that a frozen integrator stops at
`prev + gain·error(first cycle)`, which is `(1 − gain)` short of what the site
needs — 40 % short at `gain: 0.6`.
⚠️ **AND THE SENTENCE THAT USED TO FOLLOW THAT ONE WAS TOO BIG FOR THE DATA.**
It read *"for an IMPORT it is offtake, so a large import is answered too weakly
for as long as the ramp lasts"*. The aggregate does not show a weaker answer to
import: form A cuts total import by **5.45 kWh** and export by **5.18 kWh**
against release/1.0, and paired tick by tick it delivers **8.56 kWh LESS**
offtake energy on the ticks release/1.0 spent importing (lower on 38 229 such
ticks, higher on 19 669). What form A moves is the **TAIL, within quarters that
already net to offtake** — the worst quarter goes 3739 → 3935 W, the top five
3260 → 3300 W, the top fifty 1880.0 → 1882.7 W. The capacity tariff bills the
worst quarter and nothing else, so a redistribution that leaves the mean alone
and lifts the maximum is exactly the trade that is refused here. The conclusion
is unchanged; the causal sentence is now the one the data supports.
⚠️ **THE CARD SAID "do not invent a second rule", AND THE MEASUREMENT SAYS TO.**
That instruction is recorded here rather than quietly dropped. What ships is a
different rule; the paragraph above is why.
## 3a. ⚠️ The plant lag is NOT identified by this recording, and the billed quarter is the claim that depends on it
`fit_plant()` picks `lag 7.5 s` because it minimises **battery-power RMS**. That
objective barely distinguishes the candidates — 375.3 W at 7.5 s against 376.4 W
at 2.5 s, **0.3 %** — while the tool's *own* printed fidelity measure, replayed
export against the **29.43 kWh this install actually exported**, prefers 2.5 s by
the whole of the 35 % gap the header warns about. Two measures on the same data
disagree, and nothing here breaks the tie.
So the sweep has to be read as a sensitivity, not as a confirmation:
| plant lag | fit RMS W | replayed export kWh (release/1.0) | **worst 15-min offtake W:** release/1.0 | form A | SAFETY-48 | mean quarter ΔW |
|---:|---:|---:|---:|---:|---:|---:|
| 0.0 | 392.3 | 23.5 | **3434** | 3434 | **3434** | +0.53 |
| 2.5 | **376.4** | **28.9** ← nearest 29.43 | **3434** | ⚠️ 3732 | **3434** | −0.59 |
| 5.0 | 378.0 | 32.1 | **3434** | ⚠️ 3711 | **3434** | −1.02 |
| 7.5 | **375.3** ← chosen | 39.6 | 3739 | ⚠️ 3935 | **3434** | −2.39 |
| 10.0 | 384.2 | 75.4 | 3954 | ⚠️ 4326 | **3434** | −4.65 |
(The RMS column is `fit_plant()`'s own inner sweep pinned to one lag — best `tau`
and a re-fitted gain/offset at that lag. The export and quarter columns follow
`--sensitivity`'s convention instead: only the lag is substituted into the
global fit, everything else held. That is why the lag 5.0 and 10.0 export
figures are the ones already printed in §7b.)
⚠️ **AT EVERY LAG ≤ 5.0 s THE IMPROVEMENT IS NOT AN IMPROVEMENT — IT IS AN
ABSENCE.** release/1.0's worst quarter is *already* 3434 W there and SAFETY-48
changes it by **exactly zero**. §7b's paragraph used to read the `3434 → 3434`
rows as "the direction does not move"; that is the wrong reading. The direction
has nothing to move: at those plants release/1.0 never produces a quarter worse
than 3434 W in the first place, and the 3739 → 3434 W headline exists only at
the single longest lag `fit_plant()` happens to select. **The peak claim is a
property of the plant model, not of the card.** What the card can claim on the
billed quantity is that it does not make it worse, at any lag.
⚠️ **AND THE ONE RESULT HERE THAT IS NOT LAG-SENSITIVE IS THE FORM-A
REJECTION.** Form A's worst quarter is the worst of the three at **every lag
where release/1.0 produces a peak at all** — 3732, 3711, 3935, 4326 against
release/1.0's 3434, 3434, 3739, 3954 at lags 2.5 / 5.0 / 7.5 / 10.0, and a tie
at lag 0.0 where both sit at 3434. It is worse at every top-N cut of the
quarters at the fitted plant (top-1 3934.6 against 3738.7, top-5 3299.7 against
3260.5, top-10 2890.6 against 2871.0, top-50 1882.7 against 1880.0, top-100
1481.7 against 1479.0), and it raises the mean quarter. §3 understated this: the rejection does not rest on one unlucky
quarter at one plant, which is exactly the objection a lag-sensitive result
invites.
⚠️ **WHAT WOULD SETTLE IT IS NOT IN THIS RECORDING.** Both objectives above are
computed from the same four recorded entities, and they disagree; nothing else
in the capture breaks the tie, and this card did not go looking for a third
objective after seeing which lag each of the first two preferred. A driven step
response — the command timestamped where it is written rather than where Home
Assistant recorded it — is what identifies a lag. That is a rig measurement and
it is not this card. Until it exists, the export reduction is what this card
delivers and the peak figure is a plant-model artefact.
## 4. What ships
```python
slew = slew_limit_w(tuning)
slewed = max(prev_w - slew, min(prev_w + slew, target))
if slewed != target:
reason = "slew-limited"
_lead_cap = SLEW_LEAD_STEPS * slew
if target > slewed:
i_w = min(i_w, slewed + _lead_cap)
elif target < slewed:
i_w = max(i_w, slewed - _lead_cap)
target = slewed
```
Four properties, and each of them is a decision:
* **It is inside the branch**, and ⚠️ **that placement was a SURVIVOR of all
thirty files until a check was written for it.** Applying it to every cycle
instead — `i_w = max(target - cap, min(target + cap, i_w))` after
`target = slewed`, −92 B — is **bit-identical on the shipped configuration**:
0 of 256 246 ticks differ over the 16-day recording. The two separate **only**
where `integrator_max_w` is set ABOVE `max_w` — the configuration `control.py`
says is the only way its `clamped` reason is reachable. There the integrator
is *deliberately* allowed above the rail, and an unconditional cap would drag
it back to the command every cycle and make the option inert.
`test_telemetry.py` part 4 drives that configuration, and the unconditional
form is red on it.
⚠️ **THE COUNT OF SEPARABLE STATES IS DECORATION AND THREE SWEEPS DISAGREE
ABOUT IT.** This card's grid gave **2050 of 257 400**; an independent review
measured **1220 of 246 064**; a third, smaller grid (four tunings × 7 `prev_w`
× 9 `grid_w` × 5 `actual_w` × 8 `i_w` including `None` × 4 `sat_count`) gives
**776 of 40 320** — and that third one is spelled out here precisely because
the first two are not, which is the whole reason they cannot be reconciled.
No count here should be read as exact without the grid beside it. All three
agree on the load-bearing part, which is **where**: every separable state sits
in the `integrator_max_w > max_w` tuning and nowhere else.
⚠️ **AND AN EARLIER DRAFT OF THIS BULLET GAVE THE WRONG REASON AND THE WRONG
NUMBER.** It said the two forms differ on 118 ticks because an unconditional
cap would pull the integrator toward a command the SATURATION freeze is
pinning. That 118 was measured against a *different* pair — a band applied
after quantisation, at the very end of `compute()` — and the saturation story
does not apply to the placement actually being compared, which sits before
the freeze. The figures above are the ones that were measured on the pair the
sentence names.
⚠️ **AND THE SATURATION STORY IS NOT MERELY INAPPLICABLE — IT IS BACKWARDS.
THE NARROW CAP THAT SHIPPED *DOES* MOVE A SATURATION-FROZEN INTEGRATOR.** It
sits before the freeze block and is not gated on `frozen`. Measured on the
same 16 days at the live shape: of **79 160** frozen ticks the cap changed
`i_w` on **1095**, and on **679** of those it moved it further out on the side
it was already on — putting back magnitude the freeze had just let fall, which
is the one direction the freeze's rule ("may fall, never rise") exists to
forbid. Worst case **2424 W**, at
`prev_w −3400 W, grid +4697 W, actual −4144 W, sat_count 2 → 3`: the freeze
leaves the integrator at **−176 W**, the slew binds, and the cap drags it to
**−2600 W**. It never overrides a *same-cycle* refusal — the freeze clipped
`moved` on 75 512 ticks and the slew bound on none of them — but the claim
that this placement leaves the frozen path alone is false, and §5 is where
that shows up in a committed number.
* **It is written one-sided, and that is a CLARITY choice, not a correctness
one.** ⚠️ **An earlier draft of this file and of the comment in `control.py`
said the two-sided band `max(slewed - cap, min(slewed + cap, i_w))` was a bug
that would drag a TRAILING integrator up toward the command. That was
invented, and the sweep said so: the two spellings agree on 205 920 of
205 920 cases** across four tunings, every sign of `prev_w`, `grid_w`,
`actual_w` and `i_w`, and the `i_w=None` path. `target` is monotone in `i_w`,
so when the slew binds `i_w` lies on the same side of `slewed` as `target`
does and the other clamp cannot reach. The one-sided form stays because it
NAMES the side the lead is on; the equivalence is recorded so the next reader
finds a measurement instead of an argument.
* **It moves `i_w` and not `target`.** This cycle's command is already decided;
what is bounded is what gets CARRIED. That is also why the exhaustive
`i_w=None` equivalence sweep in `test_control.py` is untouched — it compares
`(target_w, sat_count, frozen, reason)`, none of which this moves.
* **The cap is in STEPS, not watts**, for SAFETY-39's reason: a watt figure is a
constant that outlives the `slew_w` it was derived against. `SLEW_LEAD_STEPS`
moves with `slew_limit_w()` and therefore with the rail.
⚠️ **ONE STEP, AND NOT ZERO.** `i_w = slewed` is full back-calculation, and
`control.py`'s own docstring says what that costs: passing `i_w=None` *"reduces
this function to the exact velocity form it replaced"*. A zero cap makes `i_w`
identically the command on every cycle, which is that collapse written from the
other end. Measured, it is **better** — export 32.02 kWh against 34.07, mean
quarter −3.69 W against −2.39 — and it is still refused, because it throws away
the reason the accumulator was split out at all. That 2 kWh is the price of
keeping the bounded form, and it is stated rather than hidden.
⚠️ **TWO LEADS, AND CONFUSING THEM IS HOW A CHECK GETS WRITTEN WRONG.** The cap
bounds `i_w − target`, the command this cycle EMITS, at one step — which is what
makes the unwind one cycle. `Decision.i_w − prev_w`, which is what TEL-10's
`loop_lead` publishes, is measured against the command the PREVIOUS cycle left
standing, and the emitted command is itself one step further on: the published
figure is bounded by **two** steps. Both bounds are asserted, each against its
own number.
### The two recorded deadlocks, driven rather than argued
`control.py` names two encodings that produce a permanently stuck loop, and a
gate written on the integrator is exactly the edit that brings them back.
Neither is reachable here and both are test cases in `test_telemetry.py` part 4:
* **the shrink-only encoding** — *"only corrections that shrink |i_w|"* is
unsatisfiable for both signs whenever the correction is larger than twice the
integrator, and the loop then sits at its last value forever (measured before
the fix: 0 W held indefinitely into a 2 kW import). SAFETY-48 is not a shrink
rule; it caps the lead beyond one slew step and only while the slew binds.
* **`i_w == 0.0`** — `main.py` resets the integrator to exactly 0.0 on every
stop and every reseed, and *"may not wind further in the direction it is
already pushing"* has no referent there. SAFETY-48 does not touch that gate;
the first push from zero is driven and asserted to move.
And a third, specific to this card: **the cap cannot latch.** The flag is not
carried, and after the cap `|i_w − slewed| = cap`, so the next cycle's command
covers it in one step and the slew stops binding — after which the integrator
accumulates freely again. The driven check asserts the command still walks all
the way to the rail on a railed export.
## 5. What this card COSTS, stated rather than smoothed
**SAFETY-13's release buys back less.** That card's own scenario — a second
writer holding the pack at +700 W, the command pinned, then released — settles
at **−800 W** where release/1.0 settled at −1190 W against an integrator of
−1194 W; and its sibling run settles at **−1200 W** where release/1.0 settled at
−2390 W. The property AC 4 asserts is unchanged: the released command stops at
the frozen integrator and not at the rail.
⚠️ **AND THE TWO MOVED FOR TWO DIFFERENT REASONS, WHICH THE ONE-LINE EXPLANATION
"how far that integrator had been allowed to wind" COVERS ONLY HALF OF.** Gating
the cap on `not frozen` — one line, `if frozen: _lead_cap = float("inf")`, so
the cap becomes a no-op while frozen — puts **part 5 back at release/1.0's
−1190 W exactly**. That run moved because the cap **fires on frozen cycles**
(§4). **Part 4(b) does not come back**: it is −1200 W gated and −1200 W shipped,
against release/1.0's −2390 W, because *that* one moved for the winding reason.
Both literals in `test_control.py` were re-baselined **with the reason beside
them**, and the second is now asserted against `i_w` rather than against a
number.
**The alternative that avoids it was measured and rejected.** Gating the cap on
`not frozen` as above gives export **34.71 kWh** (against 34.07), worst quarter
**3434 W** (the same as shipped), wasted **3.49 kWh** (against 3.19) — and a
worst observed lead of **4961 W**, i.e. no bound at all on the quantity this
card exists to bound. A bound with an exception large enough to drive a truck
through is not a bound. ⚠️ These four figures replace an earlier set (34.9,
3461 W, 3.5 kWh, 4966 W) that does not reproduce on this tree with the gate
spelled as above; the gate spelling is written out here so the next reader can
re-run it rather than re-derive it.
**One quarter-hour in 1534 is 532 W worse.** The billed figure is a MAXIMUM, and
at the fitted plant lag the maximum improves (3739 → 3434 W); no quarter under
SAFETY-48 reaches release/1.0's own worst. The mean moves the right way. The
tail is reported because a maximum over 1534 quarters is decided by one of them
— and §3a is the reason that improvement is not claimed as a benefit of the
card: at every shorter plant lag release/1.0's own maximum is already 3434 W.
## 6. The independent confirmation, which arrived as a failing check
`test_control.py`'s SAFETY-39 section drives a family of 51 synthetic export
steps through a **first-order plant this file writes itself** — no relation to
the replay, no recorded data in it at all. It carried this assertion:
> ⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION … Quartering the slew from 1000
> to 250 does not reduce the over-absorption at all
and it was right about the mechanism and therefore wrong about the conclusion:
`slew_w` bounds the OUTPUT while the integrator accumulates `gain · error`, so a
slower slew left the integrator winding for longer. Worst case over the family:
| `slew_w` | release/1.0 | this branch |
|---|---:|---:|
| 1000 | 1005 W | **729 W** |
| 0 (the rail, 556) | 1109 W | **550 W** |
| 400 (the live interim) | 1285 W | **424 W** |
| 250 | 1330 W | **326 W** |
**The ranking inverts.** Before: slower is worse, monotonically. After: slower is
better, monotonically. Two plant models that share nothing agree on the sign,
which is worth more than either alone — and it means SAFETY-39's *"gain is the
next constant to re-derive"* is now only half true: with the lead bounded, the
slew owns a share of the over-absorption it did not own before. `gain` is still
not touched here, for SAFETY-39's own reason: two live control-law constants
moving in one release makes the next measurement uninterpretable.
⚠️ **AND THE RIG SAYS THE SAME THING, WHICH MEANS TWO COMMITTED TRANSCRIPTS
CHANGED MEANING UNDER THIS CARD.** `tools/slew_sim.py --slew` runs the same
derivation on admin/goodwe's `plantsim.Plant`, a third model again. Worst-case
over-absorption over its 51-step family, `slew_w` from 1000 down to 250:
| `slew_w` | 1000 | 800 | 700 | 600 | 556 | 500 | 400 | 300 | 250 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| release/1.0 | 958 | 1107 | 1133 | 1058 | 987 | 1029 | 1150 | 1143 | 1081 |
| **this branch** | 777 | 734 | 663 | 563 | 553 | 507 | 435 | 358 | 321 |
release/1.0's row is flat and non-monotone and **800 really is better than 556**
on that column — SAFETY-39 measured it correctly and its §3 sentence is true of
the law it was written against. This branch's row falls monotonically. So
SAFETY-39's record is not retracted; it is given a **forward reference**, and
`slew_sim.py`'s own narration now reads `SLEW_LEAD_STEPS` out of the control law
it imported and says which of the two tables it is printing. MUTATION.md habit 8
— a committed transcript is a check, and a transcript whose prose contradicts
its own table is a failing one.
## 7. Verified by breaking it
**The law.** `test_telemetry.py` part 4 and `test_control.py`'s SAFETY-39
section. The mutation table is in `test_telemetry.py`'s header.
**The instrument** (MUTATION.md habit 8 — anything under `tools/` that a
document quotes is subject to the battery):
| # | mutation to `app/control.py` | what the instrument did |
|---|---|---|
| I2 | the whole SAFETY-48 block deleted | **REFUSED**, exit 1, `NOTHING HAS BEEN MEASURED` — not a zero delta |
| I3 | the anchor line itself edited | **REFUSED**, same message |
| I3b | `SLEW_LEAD_STEPS = 1000` — the cap present but inert | **every row +0.00**, which is the honest report that the block is doing nothing |
| I4 | `SLEW_LEAD_STEPS = 0` — full back-calculation | every row moves further; this is the rejected form's number in §4 |
I3b is the one that matters: it proves the delta in §3 is caused by the block
and not by the two runs differing for some other reason.
**The law**, driven in the CI image on a purged tree, fingerprinted before,
after and after the revert:
| # | mutation to `app/control.py` | bytes | reds |
|---|---|---:|---|
| L1 | the whole SAFETY-48 block deleted | −195 B | 5 in `test_telemetry.py`, 4 in `test_control.py` |
| L2 | `SLEW_LEAD_STEPS = 0` — the velocity form | +0 B | 4 + 3 |
| L3 | `SLEW_LEAD_STEPS = 12` — a cap above the rail | +1 B | 4 + 3 |
| L4 | the cap applied unconditionally, outside the slew branch | −92 B | 1 + 0 — **and it was a SURVIVOR of all thirty files before that check existed** |
| L5a | the `min`/`max` FUNCTION names exchanged | +0 B | 5 + 5, one of them `test_control.py`'s own "frozen integrator never deadlocks" over 13 041 states |
| L5b | the two BRANCH BODIES exchanged — the other reading of "the cap on the wrong side" | +0 B | 5 + 4, and it does **not** reach the deadlock sweep |
| L6 | the lead measured against `prev_w` rather than the command emitted | +0 B | 6 + 3 |
⚠️ **L5 AND L6 ARE BOTH ZERO BYTES** — `min`/`max` and `slewed`/`prev_w` are
each the same length — so no fingerprint and no byte delta can see either, and
one of L5a's reds is `test_control.py`'s own *"frozen integrator never
deadlocks"* sweep over 13 041 states, written by SAFETY-13 for a different
reason.
⚠️ **THREE OF THESE COUNTS WERE WRONG AND ARE RE-MEASURED ABOVE, WHICH IS
MUTATION.md's REPORTING RULE 3 APPLIED TO THIS CARD'S OWN TABLE.** The earlier
row gave L5 as 2 + 5 and L6 as 4 + 3; driven again in the CI image on a `cp -a`
of this tree, purged, `:ro`, one file at a time, they are **5 + 5** and
**6 + 3**. ⚠️ **AND "L5" NAMED TWO DIFFERENT MUTATIONS.** "`min`/`max` swapped"
can mean the function names exchanged or the branch bodies exchanged; they are
both red, they are both the cap on the wrong side, and they give different
counts (5 + 5 against 5 + 4) and reach different checks. A row that writes one
number for a mutation a reader cannot re-derive from its own description is a
row that cannot be re-run, so both spellings are listed. The directions in the
original table were all correct; the counts were decoration, and decoration in
a mutation table reads as evidence.
## 7b. The transcript, quoted rather than summarised
`python3 tools/replay_recorded_loop.py --sensitivity`, on this branch, 58 s:
```
THE RECORDING: 256252 meter instants over 16.00 days, loop-2026-09-02_2026-09-17
tick p5 4.77 s median 5.01 s p95 7.86 s (control.LOOP_TICK_S says 2.0)
the recorded command: extremes -5000 / +5000 W -> max_w 5000; most common step above 100 W is 400 W, 9924 times -> slew_w 400
the fitted inverter: lag 7.5 s, tau 2.0 s, batt = 0.9731 x cmd -22.4 W, residual 375 W RMS above the SoC floor
FIDELITY - the BEFORE law replayed against the recording it came from
setpoint r=0.887 mean +601 W simulated vs +739 W recorded
grid r=0.682 mean +195 W simulated vs +239 W recorded
export 39.61 kWh simulated vs 29.43 kWh recorded -> the model is +35 % PESSIMISTIC about the loop's tracking
THE A/B, same ticks, same disturbance, same plant, same seed
release/1.0 SAFETY-48 delta
export kWh 39.61 34.07 -5.54 the cost
import kWh 116.07 111.20 -4.86
battery sign reversals 2992.00 2770.00 -222.00
wasted throughput kWh 4.67 3.19 -1.48
worst 15-min offtake W 3738.67 3433.94 -304.74 ⚠️ THE BILLED ONE
recovery events 549.00 612.00 +63.00
recovery median s 232.06 199.04 -33.01
recovery p99 s 849.00 849.94 +0.94
recovery max s 901.06 898.40 -2.66
unrecovered (>900 s) 53.00 31.00 -22.00
|i_w - prev_w| p99 W 3361.12 800.00 -2561.12
|i_w - prev_w| max W 9200.00 800.00 -8400.00
slew-limited ticks 16392.00 11654.00 -4738.00
mean quarter delta W -2.39 over 1534 quarters
worst quarter delta W +532.27
SENSITIVITY - the ranking, not the watts
variant export kWh worst quarter W mean quarter dW
plant lag 5.0 s 32.1 -> 27.2 3434 -> 3434 -1.02
plant lag 10.0 s 75.4 -> 68.3 3954 -> 3434 -4.65
plant tau 8 s 56.5 -> 49.8 3995 -> 3605 -3.30
gain 1.0135, offset -26 46.8 -> 39.3 3713 -> 3434 -3.27
slew_w 555.6 (the rail) 36.5 -> 34.4 3434 -> 3434 -0.17
gain 0.3 28.1 -> 27.0 3434 -> 3434 +0.16
(58 s)
```
⚠️ **`--sensitivity` IS THE RANKING AND NOT THE WATTS.** The export figure moves
by a factor of two across these plants; what does not move is the direction —
**on export**. ⚠️ **THE `worst quarter W` COLUMN IS NOT THE SAME KIND OF
EVIDENCE AND THIS PARAGRAPH USED TO TREAT IT AS IF IT WERE.** Three of its six
rows read `3434 -> 3434`, and that is not "the direction holds": it is
release/1.0 *already sitting at 3434 W* on those plants, with nothing for the
card to improve. §3a sweeps the lag properly and finds the same thing at every
lag ≤ 5.0 s. The billed-quarter improvement in the A/B table above exists at one
plant, the one `fit_plant()` selects by an objective that separates it from its
neighbour by 0.3 %.
Two rows deserve naming rather than rounding: at `slew_w: 555.6` (the rail) and
at `gain: 0.3` the mean quarter-hour delta is −0.17 W and **+0.16 W** — ties,
not improvements. Both are regimes where `|error| > slew_w / gain` is rarely
satisfied (1333 W at `gain: 0.3`, 926 W at the rail against a measured median
onset error of 1114 W), so there is little for the card to bite on. That is the
mechanism agreeing with itself, and it is also the honest answer to "would this
still be worth shipping if the tuning moved": at `gain: 0.3` it would not be.
## 8. What this card does NOT establish
* **Nothing here drives an inverter.** The replay is a model with a 375 W
residual and no arbiter in it. The closed-loop answer comes from the rig or
from a live run — SAFETY-47 records why a shadow run cannot give it.
* **The live tick is 5.0 s, not `LOOP_TICK_S`'s 2.0 s**, measured off 9924
recorded 400 W steps and off the meter's own cadence. It is wrong in the SAFE
direction (a slower meter makes `validate.py` rule 10 conservative) and is NOT
changed here — re-deriving `SLEW_RAIL_FRAC` is a refusal boundary on a live
install and belongs to its own card. It is recorded in the tool's header and
printed on every run.
* **The brief's plant figure was close but not exact.** It quoted
`delivered = 1.0135 × command − 26 W, r = 0.992` at a 4–6 s publish lag. On
this 16-day window, above the SoC floor, the same relation measures
`1.00 × command − 28 W, r = 0.977` at a best-fit pure delay of **7.5 s**. The
conclusion — the inverter is a pure follower and the plant is not the problem
— survives; the coefficients are the ones in §2.
* **The brief's cost figure is larger than this measurement.** It quoted ~14 kWh
of avoidable export and 65 % of site export inside 7.9 % of the time. Measured
here: **5.5 kWh** avoidable of 39.6 kWh modelled (29.4 kWh recorded), with
42 % of export inside the 6.3 % of ticks that are slew-limited and 69 % within
±60 s of one, over 15.3 % of the time. The concentration claim holds; the
avoidable-kWh figure does not, and the difference is not reconcilable from
here because the reconstruction it came from was discarded.
@@ -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.
+88 -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,90 @@ 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.
⚠️ **`Loop integrator lead` is BOUNDED, and the bound is two slew steps.** While
the slew limit is what is holding the command back, the integrator may not stand
more than **one** `slew_w` ahead of the command it emits — so the command always
covers the gap in one cycle, and a reversal costs one cycle of unwind rather
than `integrator_max_w / slew_w` (12.5 at `max_w: 5000`, `slew_w: 400`).
Measured against the *previous* cycle's command, which is what this row
publishes, that is two steps: 800 W at the live shape. **A published lead
persistently above two slew steps means something is wrong** — either the
saturation freeze is holding the command (`Loop reason` says
`saturated-freeze`), or the bound is not doing its job.
Before this bound existed, the same 6 kW export drove the lead to 4600 W and the
integrator to its 5000 W rail, and the command then needed twelve cycles — about
a minute at this install's meter rate — to walk back down once the disturbance
cleared.
⚠️ **What the bound buys — and how much of it is a model rather than a meter
reading.** Sixteen days of the reference install's own recorded disturbances
were replayed through both laws (`tools/replay_recorded_loop.py`). The result
that survives every plant and tuning tried is the **export reduction: about an
eighth of the modelled export** — 5.5 kWh of 39.6 at the plant lag the replay
fits, 3.8 kWh of 28.9 at the shorter lag that best reproduces what this install
actually exported — with wasted battery throughput falling alongside it, 4.67 to
3.19 kWh at the fitted plant. ⚠️ **Those are simulated watt-hours.** The
replay's fitted inverter carries a ~375 W RMS residual and prints `+35 %
PESSIMISTIC` beside its own export figure on every run; the *direction* is what
it establishes, not the absolute kWh.
⚠️ **And it does NOT promise you a lower capacity-tariff peak.** The worst
quarter-hour average offtake — the billed quantity — improves from 3739 W to
3434 W **only at the longest plant lag the replay fits**. At every shorter lag,
including the one that best reproduces the recorded export, release/1.0 already
sits at 3434 W and this bound changes it by exactly zero. The plant lag is not
identified by the recording, so treat the peak as unchanged and the export as
the reason this exists. `DECISIONS/SAFETY-48-bounding-the-integrator-lead.md`
§3a has the table.
⚠️ **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
+41
View File
@@ -811,6 +811,47 @@ Cheap habits fall out of it, and all of them are now used in this repository:
retrofitted: guarding another card's section is a change to what its
checks report, and it belongs to whoever next touches it.
21. ⚠️ **A CHECK CAN BE GREEN UNDER ITS OWN CARD'S NAMED MUTATION, AND TWICE
IN ONE FILE THE REASON WAS THAT THE WRONG QUANTITY EVENTUALLY REACHES THE
RIGHT VALUE.** TEL-10's acceptance was *"a run that winds the integrator
into the bound must show the published number reaching the bound"*, and its
named mutation was *"publish `prev_w` instead of `i_w`"*. The check was
**GREEN** on the mutant: the command reaches the same bound too, twelve
cycles later, and a check that only reads the VALUE cannot tell the two
apart. The same file's *"a stopped controller publishes `not-running`"* was
green under deleting that branch's assignments outright (−45 B and −67 B),
because the fixture STARTED stopped and `__init__` writes the same values.
Both are habit 4 - the data could not reach the failure - and both were
found by the card's own battery. The fixes are the shape to copy:
* where two quantities CONVERGE, grade the **order or the interval**, not
the value: "the integrator reaches the bound BEFORE the command does",
and a PLATEAU (the lead sits at exactly −800 W for eight consecutive
cycles) rather than a maximum, because a maximum is one tick and a
plateau is the shape;
* where a branch WRITES a value the constructor also writes, the fixture
has to pass THROUGH the other state first. Stop 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 and not merely an unset one.
22. ⚠️ **AN EXPLANATION IN A COMMENT IS A CLAIM, AND SAFETY-48 SHIPPED TWO THAT
WERE INVENTED.** A gate was written one-sided (`if target > slewed: min(...)
elif target < slewed: max(...)`) under a comment saying the two-sided band
`max(lo, min(hi, i_w))` was a BUG that would drag a trailing integrator
toward the command, with a worked example. It is not a bug and the example
was wrong: `target` is monotone in `i_w`, so the other clamp cannot reach,
and the two spellings agree on **205 920 of 205 920** swept states. A second
bullet said two placements of the same cap *"differ on 118 of 256 252
ticks"*; that 118 had been measured against a **different pair** and the
placement actually being compared is bit-identical on the shipped
configuration - 0 of 256 246 ticks. Both sentences were plausible, both were
load-bearing for a design choice, and neither had been run.
**The rule: a comment that says "X would be wrong" is subject to the same
battery as a check - BUILD X AND MEASURE IT.** Where the answer is
"equivalent", say so with the sweep size and keep the spelling for what it
NAMES; where the answer is a number, quote the run it came from. An
invented justification for a correct decision is still a defect, and it is
the kind a reviewer cannot see because the code is right.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
+151
View File
@@ -191,6 +191,80 @@ SETTLE_CYCLES = math.ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) # 12
# edge rather than in prose, because it is a property of two files agreeing.
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
# ---------------------------------------------------------------------------
# SAFETY-48. HOW FAR THE INTEGRATOR MAY STAND AHEAD OF THE COMMAND.
#
# ⚠️ THE MISSING CASE, AND IT IS A CASE AND NOT A CONSTANT. The freeze below
# stops the integrator accumulating, and the comment beside it says what the
# requirement is: "the INTEGRATOR stop accumulating, not only the command". But
# `frozen` is computed from ONE signal - `abs(prev_w - actual_w) >
# saturation_w`, the inverter not tracking - and SLEW-LIMITING NEVER ENTERS IT.
# When the command hits the step ceiling, the integrator goes on winding at
# `gain * error` until it reaches `integrator_max_w`.
#
# The arithmetic, and it is the whole card:
#
# the integrator moves gain * error per cycle
# the command moves at most slew per cycle
# so the integrator outruns the command whenever
#
# |error| > slew / gain
#
# At the live install's shape (gain 0.6, slew_w 400) that is 667 W. The median
# grid error at the onset of a recorded episode is 1114 W. This is why
# SAFETY-39's `slew_w` retune did not fix it: a smaller step makes the
# threshold LOWER, so the case fires more often, not less.
#
# ⚠️ WHAT IS BOUNDED IS THE LEAD, NOT THE INTEGRATOR. Two forms were measured
# over 16.0 days of the live install's own recorded disturbances
# (`tools/replay_recorded_loop.py`, and DECISIONS/SAFETY-48 has the table):
#
# (a) reuse the asymmetric freeze on a carried slew flag - "may not wind
# further in the direction it is already pushing", exactly as saturation
# does. It cuts export 13 % AND RAISES THE BILLED QUANTITY: the worst
# quarter-hour average OFFTAKE goes 3739 -> 3935 W and the mean quarter
# moves +2.7 W, in five of six plant and tuning variants, and on the
# billed quarter it is worse at every plant lag where release/1.0
# produces a peak at all (a tie at lag 0, worse at 2.5/5/7.5/10 s)
# and at every top-N cut of the quarters.
#
# ⚠️ AND THE MECHANISM IS A REDISTRIBUTION, NOT A WEAKER ANSWER. An
# earlier draft of this line said a frozen integrator undershoots by
# (1 - gain) "so a large import is answered too weakly". The aggregate
# does not show that: form A cuts total import by 5.45 kWh, and paired
# tick by tick against release/1.0 it delivers 8.56 kWh LESS offtake
# energy on the ticks release/1.0 was importing. What it moves is the
# TAIL - within quarters that already net to offtake, the worst of them
# gets worse - and the capacity tariff bills the worst quarter and
# nothing else. That is the wrong trade here and the reason this is not
# the fix that ships.
#
# (b) cap how far the integrator may LEAD the command, one slew step. Export
# 39.6 -> 34.1 kWh, wasted throughput 4.7 -> 3.2 kWh, sign reversals
# 2992 -> 2770, worst quarter-hour offtake 3739 -> 3434 W at the fitted
# plant lag (⚠️ AND AT NO SHORTER ONE: at every lag <= 5.0 s release/1.0
# is already at 3434 W and this changes it by zero - DECISIONS/SAFETY-48
# §3a), median recovery 232 -> 199 s. It is what ships.
#
# ⚠️ AND THE CAP IS NOT ZERO. `i_w = slewed` is full back-calculation, and this
# module's own docstring says what that costs: passing `i_w=None` "reduces this
# function to the exact velocity form it replaced". A zero cap makes `i_w`
# identically the command on every cycle, which is that same collapse written
# from the other end - it measures 32.0 kWh, better than (b) by 2 kWh, and it
# throws away the reason the accumulator was split out at all. One step is the
# smallest cap that is not that.
#
# WHAT THE NUMBER MEANS: the command can cover a one-step lead in ONE cycle, so
# the worst-case unwind after a reversal is one cycle instead of
# `integrator_max_w / slew_w` - 12.5 cycles at the live shape, ~60 s at the
# meter's measured 5.0 s tick.
#
# ⚠️ IT IS IN STEPS RATHER THAN IN WATTS FOR SAFETY-39's REASON, WHICH IS THE
# REASON THIS FILE KEEPS RE-LEARNING. A watt figure is a constant that outlives
# the `slew_w` it was derived against; expressed in steps it moves with
# `slew_limit_w()` and therefore with the rail.
SLEW_LEAD_STEPS = 1
@dataclass(frozen=True)
class Tuning:
@@ -507,6 +581,83 @@ def compute(
slewed = max(prev_w - slew, min(prev_w + slew, target))
if slewed != target:
reason = "slew-limited"
# --- SAFETY-48: the step ceiling reaches the integrator too ---------
# ⚠️ INSIDE THIS BRANCH AND NOWHERE ELSE - AND THE REASON IS NOT THE
# SATURATION FREEZE. An earlier draft of this comment said that applied
# unconditionally the cap "would also pull the integrator toward a
# command the SATURATION freeze is pinning", and priced that at 118 of
# 256 250 ticks. Both halves were invented, and MUTATION.md habit 22 is
# what fell out of it. The 118 had been measured against a DIFFERENT
# pair - a band applied after quantisation, at the very end of
# `compute()`. On the pair the sentence actually names - this same cap
# moved out of this branch to just after `target = slewed` - the two
# forms are BIT-IDENTICAL on the shipped configuration: 0 of 256 246
# ticks differ over the 16-day recording. They separate only where
# `integrator_max_w` is deliberately set ABOVE `max_w`, the one
# configuration this file says can reach its `clamped` reason; there
# the integrator is meant to run above the rail and an unconditional
# cap would drag it back to the command every cycle and make the option
# inert. THAT is why the cap stays in the branch, and
# `test_telemetry.py` part 4 drives it. (The separable-state COUNT is a
# property of whatever grid was swept - 2050 of 257 400 on this card's,
# 1220 of 246 064 on the review's, 776 of 40 320 on a third - so the
# claim to carry forward is WHERE they separate, not how many.)
#
# ⚠️ AND THE HALF A READER WILL OTHERWISE ASSUME AWAY: THIS CAP DOES
# MOVE A SATURATION-FROZEN INTEGRATOR. It runs BEFORE the freeze block
# below and is not gated on `frozen`. Measured over the same recording
# at the live shape: of 79 160 frozen ticks the cap changed `i_w` on
# 1095 of them, and on 679 of those it moved it FURTHER OUT on the side
# it was already on - putting back magnitude the freeze had just let
# fall, which is the one direction the freeze's own rule ("may fall,
# never rise") exists to forbid. Worst case 2424 W, and the worked tick
# is `prev_w -3400 W, grid +4697 W, actual -4144 W, sat_count 2 -> 3`:
# the freeze leaves the integrator at -176 W, the slew binds, and the
# cap drags it to -2600 W. It never overrides a SAME-CYCLE refusal -
# the freeze clipped `moved` on 75 512 ticks and the slew bound on none
# of them - but it does not leave the frozen path alone either.
#
# ⚠️ AND THAT IS WHERE SAFETY-13's SETTLE VALUES WENT. Gating this cap
# on `not frozen` puts `test_control.py` part 5 back at release/1.0's
# -1190 W exactly; part 4(b)'s -2390 -> -1200 W does NOT come back,
# because that one moved for the other reason - the winding BEFORE the
# freeze is bounded now. The gate is refused anyway: measured the same
# way it leaves a worst observed lead of 4961 W, i.e. no bound at all
# on the quantity this card exists to bound. DECISIONS/SAFETY-48 §5
# carries the rest of the price.
#
# ⚠️ WRITTEN ONE-SIDED, AND IT IS **EQUIVALENT** TO THE TWO-SIDED BAND
# `max(slewed - cap, min(slewed + cap, i_w))` - MEASURED, NOT REASONED.
# An earlier draft of this comment claimed the two-sided spelling was a
# bug that would drag a TRAILING integrator up toward the command. That
# is false and the sweep said so: `target` is monotone in `i_w`
# (`target = clamp(clamp(i_w, +-limit), +-max_w)`) and `slewed` is
# `target` pulled toward `prev_w`, so whenever the slew binds, `i_w`
# lies on the same side of `slewed` as `target` does and the other
# clamp cannot reach. The two spellings agree on **205 920 of 205 920**
# cases across four tunings, every sign of `prev_w`, `grid_w`,
# `actual_w` and `i_w`, and the `i_w=None` path.
#
# It stays one-sided because it SAYS which side the lead is on - the
# side the command wanted to move and could not - and because a reader
# reaching for the band spelling should find the sweep rather than an
# argument. ⚠️ The "descending" case is NOT an exception to the rule: a
# command falling from +2400 W toward an integrator at +100 W has an
# integrator 2300 W ahead of it IN THE DIRECTION OF TRAVEL, and it is
# pulled back to one step beyond the emitted command exactly as a
# rising one is.
#
# ⚠️ `i_w` IS CHANGED AND `target` IS NOT. This cycle's command is
# already decided; what is bounded is what gets CARRIED. That is also
# why the exhaustive `i_w=None` equivalence sweep in test_control.py is
# untouched by this card: it compares `(target_w, sat_count, frozen,
# reason)`, none of which this moves, and the seeded path re-seeds from
# `prev_w` next cycle anyway.
_lead_cap = SLEW_LEAD_STEPS * slew
if target > slewed:
i_w = min(i_w, slewed + _lead_cap)
elif target < slewed:
i_w = max(i_w, slewed - _lead_cap)
target = slewed
if frozen:
+67 -1
View File
@@ -864,6 +864,13 @@ class Controller:
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
@@ -3054,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,
@@ -3068,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
@@ -3092,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.
@@ -4288,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
+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"
@@ -0,0 +1,799 @@
{
"generated": "2026-09-18T23:52:32.622227+02:00",
"generator": "tools/replay_recorded_loop.py --export",
"source": "http://192.168.2.158:8123/api/history/period",
"timezone": "Europe/Brussels",
"entities": {
"grid": "sensor.p1_meter_active_power",
"batt": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"setpoint": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"soc": "sensor.goodwe_rs485_controller_goodwe_battery_soc"
},
"format": "{\"t\": <ISO-8601 with offset>, \"v\": <state string>} per line, gzip, mtime 0",
"days": [
{
"date": "2026-09-02",
"window": [
"2026-09-02T00:00:00+02:00",
"2026-09-03T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16515,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-02-grid.jsonl.gz",
"bytes_raw": 922104,
"sha256_raw": "8e61b964fd9ebe047dfcc70f32f08ce2bebe7ac3ffe6f97000e3526a5637e9c4"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3299,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-02-batt.jsonl.gz",
"bytes_raw": 187888,
"sha256_raw": "0e2f392158a7fd2dc77287d3f94de1edab81c6a9a7e16b0f59f525145fe6a61b"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 4683,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-02-setpoint.jsonl.gz",
"bytes_raw": 267407,
"sha256_raw": "2f486e27bffdce82f16d20104fcdbbe7057ebd4c025c9ac00eb59531a44d1ea1"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 130,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-02-soc.jsonl.gz",
"bytes_raw": 7141,
"sha256_raw": "090d14866552b4dc27cf0c0505cfb5c4d7e7223b25a5af504598618aeba317ed"
}
}
},
{
"date": "2026-09-03",
"window": [
"2026-09-03T00:00:00+02:00",
"2026-09-04T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16419,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-03-grid.jsonl.gz",
"bytes_raw": 919508,
"sha256_raw": "fd4230b864a2f789fcac6da7bbe87f0445fedbc81818d3c4a1c1a41655ed5b4f"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3434,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-03-batt.jsonl.gz",
"bytes_raw": 194783,
"sha256_raw": "3a12c37dfe33a571b68f27a68d88b38170f4acd39f4c9574f58de7a9c358fe8e"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5145,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-03-setpoint.jsonl.gz",
"bytes_raw": 292456,
"sha256_raw": "c8c70155151c44ef92cd7048dd5642838e2fcd5dfa4989db30d39f91e9031aa2"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 69,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-03-soc.jsonl.gz",
"bytes_raw": 3786,
"sha256_raw": "a34ccf6a175d0b60b0852a305be69457c0e0a42f3f9a1c777ab100039cc57c24"
}
}
},
{
"date": "2026-09-04",
"window": [
"2026-09-04T00:00:00+02:00",
"2026-09-05T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16098,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-04-grid.jsonl.gz",
"bytes_raw": 897558,
"sha256_raw": "4c1528faa35778a3fe6a7b613482af4ef854e6b1b92ba8a6c72aa231158cf93f"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3009,
"non_numeric_states": 1,
"attempts": 1,
"file": "2026-09-04-batt.jsonl.gz",
"bytes_raw": 170410,
"sha256_raw": "2ee54e9c6670ebc87bb120bb98a572dbe6b129c3a2e3aa7802c5993ecab03500"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 4300,
"non_numeric_states": 1,
"attempts": 1,
"file": "2026-09-04-setpoint.jsonl.gz",
"bytes_raw": 244058,
"sha256_raw": "7130b266efe61447276183a8da1b79e14e1e2a56b92b9884c5cb3c1d277cf002"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 47,
"non_numeric_states": 1,
"attempts": 1,
"file": "2026-09-04-soc.jsonl.gz",
"bytes_raw": 2583,
"sha256_raw": "833ed5f48b3fac83beb65cb1c1b00ed66597505bcab37813997c3f3f908b72bd"
}
}
},
{
"date": "2026-09-05",
"window": [
"2026-09-05T00:00:00+02:00",
"2026-09-06T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16448,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-05-grid.jsonl.gz",
"bytes_raw": 920613,
"sha256_raw": "03cb4e2b3e075b060a47bf5d3cb83d5c55d0055512dcec800a4aaf5bbae91b3e"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4409,
"non_numeric_states": 6,
"attempts": 1,
"file": "2026-09-05-batt.jsonl.gz",
"bytes_raw": 251332,
"sha256_raw": "a11c722ff106df1675e57e9d98af37ec22bf61d509fa847c30c31a50165c07a1"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 6339,
"non_numeric_states": 6,
"attempts": 1,
"file": "2026-09-05-setpoint.jsonl.gz",
"bytes_raw": 361644,
"sha256_raw": "7929161bfaf654dab097d02219b3a1c83d1530e78a0e38776115792afd45fb8d"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 132,
"non_numeric_states": 6,
"attempts": 1,
"file": "2026-09-05-soc.jsonl.gz",
"bytes_raw": 7289,
"sha256_raw": "139d883516de462655fe34902c30cc09a329ace6fbb248b06f8ffd8b1d6edaa2"
}
}
},
{
"date": "2026-09-06",
"window": [
"2026-09-06T00:00:00+02:00",
"2026-09-07T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16060,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-06-grid.jsonl.gz",
"bytes_raw": 899105,
"sha256_raw": "7d3e92c36834aeb1992d611cea6d256967585b45c3c9251b00dac676befebbea"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3569,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-06-batt.jsonl.gz",
"bytes_raw": 202858,
"sha256_raw": "5030a36a36b9ae6867c582c70fb7cf57b6fe762aa1e52993ab48d4f5c4269ef4"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5190,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-06-setpoint.jsonl.gz",
"bytes_raw": 295576,
"sha256_raw": "d361daab3fa61bae75779d574e3619d3cc0908501187f054b16643caa2e0f231"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 83,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-06-soc.jsonl.gz",
"bytes_raw": 4556,
"sha256_raw": "6ee9813016c7a1bdb62f6f4e892e8c3351491e1c5324c67862c068cebee8c2d1"
}
}
},
{
"date": "2026-09-07",
"window": [
"2026-09-07T00:00:00+02:00",
"2026-09-08T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15512,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-07-grid.jsonl.gz",
"bytes_raw": 868060,
"sha256_raw": "69ff04ecc0df7faa11824b3a4f2e4bd5a84167aa260386380d842da84da4f152"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3646,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-07-batt.jsonl.gz",
"bytes_raw": 207432,
"sha256_raw": "37a68612eadcecf80d6e796f653ae8e4ec9ff98a40a8d560ca18df995b47ad14"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 3521,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-07-setpoint.jsonl.gz",
"bytes_raw": 201333,
"sha256_raw": "1c0a93b59c46f7338e81a1a8f0609db7e140793bc28c9a66d2087eaf2360eb2b"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 138,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-07-soc.jsonl.gz",
"bytes_raw": 7584,
"sha256_raw": "bea3599b83e280a11a3a6b83b2626db15acaf04c2d7db16734a1acc76f691bf8"
}
}
},
{
"date": "2026-09-08",
"window": [
"2026-09-08T00:00:00+02:00",
"2026-09-09T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15691,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-08-grid.jsonl.gz",
"bytes_raw": 872562,
"sha256_raw": "632c58235dde62178358a72c445945829dfbb580cf9dbd1eef32c545ea29154b"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4451,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-08-batt.jsonl.gz",
"bytes_raw": 251748,
"sha256_raw": "0d94ce9c4e41e33094fb7a3ffafbec15c9d9ad1b741aaa0b77e39a9ca8b64572"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5145,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-08-setpoint.jsonl.gz",
"bytes_raw": 292178,
"sha256_raw": "d29a15925e1a87c57a120828dd6f138b0ee3738f0418f789e6b14451785dcab4"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 118,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-08-soc.jsonl.gz",
"bytes_raw": 6481,
"sha256_raw": "f07d9cd878763dcda6e8bf58653d260a5d897f4bc65efd446707e0af5a84c813"
}
}
},
{
"date": "2026-09-09",
"window": [
"2026-09-09T00:00:00+02:00",
"2026-09-10T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16017,
"non_numeric_states": 1,
"attempts": 1,
"file": "2026-09-09-grid.jsonl.gz",
"bytes_raw": 894526,
"sha256_raw": "50924035a087895b89b72f6acf5db24a3b9ed704362968854e1328493e0c7758"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4124,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-09-batt.jsonl.gz",
"bytes_raw": 235314,
"sha256_raw": "93e26b5f40b20d1478c457b7cae06f899c2b52f1ce2abba90fe08ca14ad4607e"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5855,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-09-setpoint.jsonl.gz",
"bytes_raw": 334751,
"sha256_raw": "d5286bc477b825343804e4fd8eb85e9a35c3a8e98e83abba9df5d2879637bab9"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 123,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-09-soc.jsonl.gz",
"bytes_raw": 6757,
"sha256_raw": "c4b6b1a3fc3de4e928db2f6ac7520a7556893e1b588d2f57711a4c7ded6646d4"
}
}
},
{
"date": "2026-09-10",
"window": [
"2026-09-10T00:00:00+02:00",
"2026-09-11T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16172,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-10-grid.jsonl.gz",
"bytes_raw": 902807,
"sha256_raw": "7a388548ce9b10374354afe17ba10567f7a592c38b7614b60e570d12a8c4cc87"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4322,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-10-batt.jsonl.gz",
"bytes_raw": 246291,
"sha256_raw": "e01417fb54e163f188feed3aab464cd7e125d1c8c6e2b5ed51066d86f84cc221"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5450,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-10-setpoint.jsonl.gz",
"bytes_raw": 310937,
"sha256_raw": "5fd7917fa89459a7f89eb838cc07d09f2dda85964f45df27a16d49a5608af797"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 142,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-10-soc.jsonl.gz",
"bytes_raw": 7802,
"sha256_raw": "ff94001915949d165377ad6b2ac8dd0f00c84a0cd1c8a83fbddf474543dc1871"
}
}
},
{
"date": "2026-09-11",
"window": [
"2026-09-11T00:00:00+02:00",
"2026-09-12T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15975,
"non_numeric_states": 1,
"attempts": 1,
"file": "2026-09-11-grid.jsonl.gz",
"bytes_raw": 889154,
"sha256_raw": "335847b7f757a1fa3682be000ef3e77dcf637ccb52b9ad3d739539d1a7fd1384"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4399,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-11-batt.jsonl.gz",
"bytes_raw": 250012,
"sha256_raw": "bd91e7a0e33cbec1737fb95019746c44bdf3ff71e71933ff99b45383b3bcef29"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5519,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-11-setpoint.jsonl.gz",
"bytes_raw": 315079,
"sha256_raw": "8f538f230ff2076a5a098d27a6d26390c9995e88a14b9b30e7b141bd216b211a"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 124,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-11-soc.jsonl.gz",
"bytes_raw": 6812,
"sha256_raw": "d090fe60687ce35498afa923089881d1eaec3b50be2604ffe4ac4261c6d7f8b5"
}
}
},
{
"date": "2026-09-12",
"window": [
"2026-09-12T00:00:00+02:00",
"2026-09-13T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16001,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-12-grid.jsonl.gz",
"bytes_raw": 893240,
"sha256_raw": "3ff17d8fa70133338cd4c54067b7c37d5f33fd971d8037e99b8f829703d42a2d"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3744,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-12-batt.jsonl.gz",
"bytes_raw": 213926,
"sha256_raw": "228b3498d915d6b96781dca039899816b4a9c45874af019095cede1128c2d2aa"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5012,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-12-setpoint.jsonl.gz",
"bytes_raw": 287190,
"sha256_raw": "bfb3389a021c2b01a005901c961c2c30bb6ca4a42e5e80c00855ea1144f3464a"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 126,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-12-soc.jsonl.gz",
"bytes_raw": 6922,
"sha256_raw": "ca17b220922550d9d00330f62742edf9b9cdf990cd3647a14a580f3427023092"
}
}
},
{
"date": "2026-09-13",
"window": [
"2026-09-13T00:00:00+02:00",
"2026-09-14T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15912,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-13-grid.jsonl.gz",
"bytes_raw": 888912,
"sha256_raw": "79a2f5462c396cc26c90c72e2b38390f1665bb41b77fc9120019595228133ca3"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3432,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-13-batt.jsonl.gz",
"bytes_raw": 193080,
"sha256_raw": "9410b9b80632c2ad68cd0632a5dd6513077eaecfc7a776986df867f847b19b39"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 3566,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-13-setpoint.jsonl.gz",
"bytes_raw": 201353,
"sha256_raw": "8173b590ed7a33d441cc0f2fb447ad88ce1c12aef6019155124132f44b11981c"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 80,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-13-soc.jsonl.gz",
"bytes_raw": 4391,
"sha256_raw": "15a8328cc07964216be4f3a6001fb640937e33f81b13c64e8da0872ab0da8a46"
}
}
},
{
"date": "2026-09-14",
"window": [
"2026-09-14T00:00:00+02:00",
"2026-09-15T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16086,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-14-grid.jsonl.gz",
"bytes_raw": 904274,
"sha256_raw": "26c255b793432474a45dc6f0fff156f358f8468f2381e8161f4d0812d732e754"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 2679,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-14-batt.jsonl.gz",
"bytes_raw": 152706,
"sha256_raw": "d2bec4260c3649db865744a93f20c38c8f76b4f09f2e0b2308e61280b415321d"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 4007,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-14-setpoint.jsonl.gz",
"bytes_raw": 229164,
"sha256_raw": "4b3feb9cf7349bf8d6b3c4b6daa7d8b598f5589e52dfe48c98791d89e5678d79"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 75,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-14-soc.jsonl.gz",
"bytes_raw": 4116,
"sha256_raw": "31b3d54b0b4ea4e2e5ee5fc0131c05bea5f920996699888ac764b66d19763d89"
}
}
},
{
"date": "2026-09-15",
"window": [
"2026-09-15T00:00:00+02:00",
"2026-09-16T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15947,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-15-grid.jsonl.gz",
"bytes_raw": 890223,
"sha256_raw": "6a6d39306d47b08dc190d576b3b7a2e486a755ad846fa721262a53002305bb2c"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3651,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-15-batt.jsonl.gz",
"bytes_raw": 208309,
"sha256_raw": "4b60ab3ab24b9fc6dbde7262d36c74b0ef164429e382448c48ceea709921ddf4"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 4641,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-15-setpoint.jsonl.gz",
"bytes_raw": 265543,
"sha256_raw": "308ab0767d0dfd1c78daf313aef292e4383cd78d155656f2e829b88ad8539ece"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 116,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-15-soc.jsonl.gz",
"bytes_raw": 6372,
"sha256_raw": "5a8d8bdca4c6eccc275edcc13ee1b1ec24092f86d209b605f4405d95f23107d6"
}
}
},
{
"date": "2026-09-16",
"window": [
"2026-09-16T00:00:00+02:00",
"2026-09-17T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15888,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-16-grid.jsonl.gz",
"bytes_raw": 886214,
"sha256_raw": "cc3a1fc8f892fd22bb27b64f9928dcbbea9849a9c26ef8e7b2221483c82e5c15"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4444,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-16-batt.jsonl.gz",
"bytes_raw": 252502,
"sha256_raw": "70189159b45c26f742dca92475d991f70210b5766a711e466b2fbd266968842b"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5424,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-16-setpoint.jsonl.gz",
"bytes_raw": 309293,
"sha256_raw": "9f8c00b6bdea1de540f7ade4bc31870f61d972dfcd58d48a1653b1e114320d75"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 155,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-16-soc.jsonl.gz",
"bytes_raw": 8517,
"sha256_raw": "1bf7f07c5b60eea5cadb9758a40d2b34463ecaf33627b117ea2bed6afa45fb57"
}
}
},
{
"date": "2026-09-17",
"window": [
"2026-09-17T00:00:00+02:00",
"2026-09-18T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15544,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-17-grid.jsonl.gz",
"bytes_raw": 867466,
"sha256_raw": "340bec5e35fed7a4f0964694bbca294fba21fce77ddd27af616720398c1cbbc4"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3868,
"non_numeric_states": 2,
"attempts": 1,
"file": "2026-09-17-batt.jsonl.gz",
"bytes_raw": 218642,
"sha256_raw": "4cb8e89baa89b4919d5e9b578585ab591eac4e94a636fa3aa88d293926f8401b"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 4599,
"non_numeric_states": 2,
"attempts": 1,
"file": "2026-09-17-setpoint.jsonl.gz",
"bytes_raw": 261144,
"sha256_raw": "ddebbc60d2d8b0b8e06d2bef6bc3b61ea7c39becb22f25b7b2219455a7398519"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 97,
"non_numeric_states": 2,
"attempts": 1,
"file": "2026-09-17-soc.jsonl.gz",
"bytes_raw": 5336,
"sha256_raw": "f1fade3d84a924447659fefa680f5b795266f3281bc2f5d5b6f86a1e434ddd4e"
}
}
}
]
}
+72 -21
View File
@@ -522,7 +522,20 @@ for _ in range(20):
pinned = [r for r in rows if r[3] == "saturated-freeze"]
check(f"non-zero prev_w, another writer: pinned at {pinned[0][1]:.0f} W...",
pinned and all(r[1] == -800.0 for r in pinned))
check(f"...then released, settling at {prev:.0f} W", prev == -2390.0)
# ⚠️ SAFETY-48 MOVED THIS NUMBER FROM -2390 W TO -1200 W, DELIBERATELY, AND
# THE PROPERTY IT IS HERE FOR IS UNCHANGED. The release lets the command walk
# to the FROZEN INTEGRATOR; SAFETY-48 bounds how far that integrator can have
# wound ahead of the command before the freeze engaged, so the value it walks
# to is smaller. What this run must still show is that the release happens and
# that it stops at the integrator - both asserted, the second against `i_w`
# rather than against a literal, because the literal is the half this card
# moves. The cost is stated rather than smoothed: on a persistent second writer
# this run buys back 1200 W of a 2000 W export where release/1.0 bought back
# 2390 W. DECISIONS/SAFETY-48 §5 records it as the price of the bound and the
# rejected alternative that avoids it.
check(f"...then released, settling at {prev:.0f} W, at the frozen integrator "
f"({i_w:.0f} W) - release/1.0 settled at -2390 W, at its own unbounded "
f"one", prev == -1200.0 and prev == i_w)
# --- 5. THE CARD'S OWN CASE, DRIVEN END TO END -----------------------------
# The live install's shape (max_w 5000, slew_w 400, target_grid_w -10). The site
@@ -546,9 +559,14 @@ check(f"the card's case: the charge starts at cycle {started[0]}"
# that divergence into windup. It cannot, because releasing the OUTPUT freeze
# does not release the INTEGRATOR freeze - so the command can only walk, one
# slew step per cycle, to a value the integrator stopped accumulating at.
# ⚠️ SAFETY-48 MOVED THIS ONE TOO, -1190 W -> -800 W against an integrator that
# stood at -1194 W and now stands at -800 W, for the reason above. AC 4's
# property - the release stops at the integrator and not at the rail - is what
# is asserted; the literal is the branch's own number and is printed.
check(f"the card's case: it settles at {prev:.0f} W, at the frozen integrator "
f"({i_w:.0f} W) and not the {TL.max_w:.0f} W rail",
prev == -1190.0 and abs(prev) <= abs(i_w) and abs(prev) < TL.max_w)
f"({i_w:.0f} W) and not the {TL.max_w:.0f} W rail - release/1.0 settled "
f"at -1190 W against an integrator of -1194 W",
prev == -800.0 and abs(prev) <= abs(i_w) and abs(prev) < TL.max_w)
steps = [abs(cmds[i] - cmds[i - 1]) for i in range(1, len(cmds))]
check(f"the card's case: no cycle moves more than the slew limit "
f"(worst {max(steps):.0f} W of {slew_limit_w(TL):.0f})",
@@ -1115,25 +1133,58 @@ check("⭐ FIXED, ON THE THING THE SLEW ACTUALLY OWNS: following the rail remove
_R["live"]["big"] > 0 and _R["rail"]["big"] == 0
and _R["interim"]["big"] == 0)
# ⚠️ AND WHAT IT DOES NOT BUY, MEASURED, ASSERTED, AND SAID BEFORE ANYBODY CAN
# READ MORE INTO THIS CARD THAN IT DELIVERED. `slew_w` bounds the OUTPUT; the
# integrator accumulates `gain * error` per cycle and is bounded only by
# `integrator_max_w`. At gain 0.6 into a 3.4 kW error the integrator moves
# ~2000 W per tick and reaches a 5000 W rail in under three ticks WHATEVER
# `slew_w` is. So the over-absorption is a GAIN term, and slowing the slew does
# not reduce it - on this model it gets slightly WORSE, because the command
# spends longer away from where it needs to be while the integrator keeps
# winding. admin/goodwe's rig, a different plant model, agrees: over-absorption
# 990 W at slew 1000 and 1086 W at slew 250, against 1010 W at gain 0.6 and
# 863 W at gain 0.3 (tools/slew_sim.py).
# --- SAFETY-48 RE-BASELINED THIS CHECK, AND THE INVERSION IS THE EVIDENCE ----
#
# `gain` is therefore the next constant to re-derive. SAFETY-39 measured it and
# CHANGED NOTHING: it is a live control-law constant on a running house, and two
# of them moving in one release makes the next measurement uninterpretable.
check("⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION, AND THIS CHECK IS HERE SO "
"THAT NOBODY READS SAFETY-39 AS 'THE OVERSHOOT IS FIXED'. Quartering the "
"slew from 1000 to 250 does not reduce the over-absorption at all",
_R["slower"]["over"] >= _R["live"]["over"])
# ⚠️ WHAT THIS LINE USED TO SAY, AND WHY IT IS NOT A SILENT EDIT. SAFETY-39
# shipped:
#
# "⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION ... Quartering the slew
# from 1000 to 250 does not reduce the over-absorption at all"
# asserted as _R["slower"]["over"] >= _R["live"]["over"]
#
# and the reasoning under it was right about the mechanism and therefore wrong
# about the conclusion: `slew_w` bounds the OUTPUT while the integrator
# accumulates `gain * error` and is bounded only by `integrator_max_w`, so
# slowing the slew leaves the integrator winding for LONGER and the
# over-absorption got WORSE. Measured on this model at release/1.0
# (`68c5ede`), worst case over the same 51-step family:
#
# slew_w 1000 over-absorbed 1005 W
# slew_w 0 (rail, 556) 1109 W <- SLOWER IS WORSE
# slew_w 400 1285 W
# slew_w 250 1330 W
#
# SAFETY-48 bounds the integrator's LEAD at one slew step, which is exactly the
# quantity that sentence describes. The same six runs on this branch:
#
# slew_w 1000 over-absorbed 729 W
# slew_w 0 (rail, 556) 550 W <- AND NOW SLOWER IS BETTER
# slew_w 400 424 W
# slew_w 250 326 W
#
# So the claim is inverted rather than weakened, on a plant model this file
# writes itself and SAFETY-48's 15.7-day replay of the live install's recorded
# disturbances has never seen. The two agree on the sign, which is worth more
# than either alone. ⚠️ The old direction is asserted as a NEGATIVE below so
# that a revert of SAFETY-48 cannot quietly restore it and leave this section
# green on the other law.
#
# `gain` is still the next constant to re-derive, and SAFETY-48 CHANGED NOTHING
# ABOUT IT: it is a live control-law constant on a running house, and two of
# them moving in one release makes the next measurement uninterpretable.
check("⭐ SAFETY-48: WITH THE INTEGRATOR'S LEAD BOUNDED, A SLOWER SLEW REDUCES "
"THE OVER-ABSORPTION - 1000 -> rail -> 400 -> 250 falls monotonically, "
f"{_R['live']['over']:.0f} -> {_R['rail']['over']:.0f} -> "
f"{_R['interim']['over']:.0f} -> {_R['slower']['over']:.0f} W. On "
"release/1.0 the same four runs RISE: 1005 -> 1109 -> 1285 -> 1330 W",
_R["rail"]["over"] < _R["live"]["over"]
and _R["interim"]["over"] < _R["rail"]["over"]
and _R["slower"]["over"] < _R["interim"]["over"])
check("...and every member of the family over-absorbs LESS than release/1.0's "
"own figure for that member - the recorded four are 1005, 1109, 1285 and "
"1330 W, taken on 68c5ede by running this file",
_R["live"]["over"] < 1005.0 and _R["rail"]["over"] < 1109.0
and _R["interim"]["over"] < 1285.0 and _R["slower"]["over"] < 1330.0)
check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at "
"the same slew. That is the measurement behind 'gain is the next constant "
"to re-derive', and it is a measurement rather than an opinion",
+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 "
+575
View File
@@ -0,0 +1,575 @@
"""Runnable check for TEL-10 and SAFETY-48. `python3 test_telemetry.py`
TEL-10 publishes the loop's integrator. SAFETY-48 bounds it. They are graded in
one file because they are one observable: the number SAFETY-48 bounds is the
number TEL-10 added, and a check on either that could not see the other would
be grading its own fixture.
`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.
⚠️ AND SAFETY-48 HAS ALREADY MOVED ONE OF THEM. M1 defeats FIVE checks on
TEL-10's own tree and FOUR on this one: this card replaced the "...the gap is
the slew limit and not an accident" check, because the gap it asserted is now
bounded and the assertion is written against the bound instead. The mutation
did not change; the suite did. Both counts are right, for different trees, and
neither is right without one.
⚠️ 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.
SAFETY-48's OWN BATTERY, same discipline, against `app/control.py`:
# mutation bytes reds
-- ---------------------------------------------- ----- --------------------
L1 the whole SAFETY-48 block deleted -195 B 5 here + 4 control
L2 `SLEW_LEAD_STEPS = 0` - the velocity form +0 B 4 here + 3 control
L3 `SLEW_LEAD_STEPS = 12` - a cap above the rail +1 B 4 here + 3 control
L4 the cap applied unconditionally, outside the -92 B 1 here ⚠️ SURVIVOR
slew branch of all 30 files
until part 4's
`integrator_max_w`
case existed
L5 `min`/`max` swapped - the cap on the wrong +0 B 5 here + 5 control
side (or 5 + 4; see below)
L6 the lead measured against `prev_w` rather than +0 B 6 here + 3 control
the command it emits
⚠️ L4, L5 AND L6 ARE ALL ZERO- OR NEGATIVE-BYTE EDITS THAT CHANGE NO PAYLOAD
AND NO OPTION, AND L4 WAS BIT-IDENTICAL ON THE SHIPPED CONFIGURATION - 0 of
256 246 ticks over the 16-day recording. It is separable only where
`integrator_max_w` is above `max_w`, which is the stimulus part 4 drives; the
COUNT of separable states is a property of whatever grid was swept and three
sweeps disagree about it, so DECISIONS/SAFETY-48 §4 carries all three rather
than one.
⚠️ AND L5 IS TWO MUTATIONS WEARING ONE NAME, WHICH IS WHY ITS COUNT NEEDS A
SPELLING BESIDE IT. "min/max swapped" can mean the FUNCTION names exchanged
(`max(i_w, slewed + cap)` / `min(i_w, slewed - cap)`) - 5 here + 5 in
test_control.py, one of the five being SAFETY-13's own "frozen integrator never
deadlocks" sweep over 13 041 states - or the whole BRANCH bodies exchanged
(`max(i_w, slewed - cap)` / `min(i_w, slewed + cap)`), which is 5 + 4 and does
NOT reach the deadlock sweep. Both are red, both are the cap on the wrong side,
and a table that writes one number for the pair is reporting a mutation nobody
can re-run. The first spelling is the one the row above quotes.
⚠️ 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 SLEW_LEAD_STEPS, Tuning, compute, 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.
#
# ⚠️ THIS FIXTURE WAS BUILT AGAINST THE UNBOUNDED INTEGRATOR AND SAFETY-48 TOOK
# ITS SEPARATION AWAY, WHICH IS WORTH SAYING RATHER THAN QUIETLY RE-TUNING. On
# the law TEL-10 shipped against, the integrator reached its 5000 W bound on
# cycle 1 and the command needed twelve more cycles to arrive, so the two rows
# differed by KILOWATTS and any check at all could see it. SAFETY-48 bounds the
# lead at one slew step, so the gap is now 800 W and the integrator arrives ONE
# cycle early instead of twelve. Both surviving separations are graded: the
# ARRIVAL ORDER, and the 800 W plateau that holds for the whole ramp.
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 ARRIVAL ORDER IS THE CLAIM, NOT THE VALUE. "The published number
# reaches the bound" is satisfied by publishing the COMMAND too - it gets there
# as well, a cycle 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` the value-only form was GREEN and this one is
# red.
check("⭐ THE PUBLISHED INTEGRATOR REACHES ITS BOUND, AND REACHES IT BEFORE THE "
f"COMMAND DOES. A 6 kW export winds it to exactly {BOUND_W:.0f} W - "
"`integrator_max_w` is None, which control.py resolves to `max_w` - while "
"the command is still a slew step short",
first_at_bound is not None and i_row[first_at_bound] == -BOUND_W
and first_cmd_at_bound > first_at_bound)
# ⚠️ 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 NOT at the bound 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(BOUND_W - abs(sp_row[first_at_bound])) >= 200.0)
# ⚠️ AND THE PLATEAU, WHICH IS THE SEPARATION THAT LASTS. Through the whole
# ramp the integrator stands exactly two slew steps ahead of the command the
# previous cycle left standing - one step is `SLEW_LEAD_STEPS` against the
# command this cycle EMITS, and the emitted command is itself one step ahead of
# `prev_w`. It is graded as a RUN of identical values rather than as a maximum,
# because a maximum is reached by one tick and a plateau is the shape.
_plateau = [k for k in range(1, 13) if lead_row[k] == -800.0]
print(f" the lead plateau: {lead_row[1:13]}")
check("⭐ AND THE TWO ROWS ARE SEPARATED FOR THE WHOLE RAMP, not at one tick: "
f"the lead sits at exactly -800 W (two slew steps) on "
f"{len(_plateau)} of cycles 1..12 while both numbers travel 400 W a "
"cycle", len(_plateau) >= 8)
check("...and the command really is travelling a full slew step a cycle "
"through that plateau, so the plateau is not a stalled loop",
all(round(abs(sp_row[k] - sp_row[k - 1]), 6)
== slew_limit_w(Tuning(**LIVE)) for k in _plateau))
# 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 never a permanent zero while the loop is working: worst "
f"lead {min(lead_row):.0f} W",
min(lead_row) <= -800.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"] < -100.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()
print("4. SAFETY-48: the slew step ceiling stops the integrator outrunning it")
# ==============================================================================
# ⚠️ THE ARITHMETIC THE CARD IS ABOUT, RE-DERIVED RATHER THAN QUOTED. The
# integrator moves `gain * error` a cycle and the command moves at most
# `slew_w`, so the integrator outruns the command whenever
# `|error| > slew_w / gain`. At the live shape that is 667 W, and the recorded
# median error at the onset of an episode is 1114 W.
T = Tuning(**LIVE)
check(f"the outrun threshold is `slew_w / gain` = {OUTRUN_W:.0f} W, and it is "
"the shipped pair that produces it, not a constant written here",
abs(T.slew_w / T.gain - OUTRUN_W) < 0.5)
# ⚠️ TWO LEADS, AND CONFUSING THEM IS HOW THIS CHECK WOULD BE WRITTEN WRONG.
# The bound is on `i_w - target`, the command this cycle EMITS - that is what
# makes the unwind one cycle, because the command can cover one slew step in
# one cycle. `loop_lead`, and `Decision.i_w - prev_w`, are measured against the
# command the PREVIOUS cycle left standing, and the emitted command is itself
# one slew step further on - so the published figure is bounded by TWO steps,
# not one. Both are asserted, against their own bound.
_SLEW = slew_limit_w(T)
d = compute(prev_w=0.0, grid_w=-6000.0, actual_w=0.0, tuning=T, i_w=-100.0)
print(f" one cycle at a 5990 W error: command {d.target_w:+.0f} W, "
f"integrator {d.i_w:+.0f} W, ahead of the EMITTED command by "
f"{d.i_w - d.target_w:+.0f} W and of `prev_w` by {d.i_w:+.0f} W, "
f"reason {d.reason!r}")
check(f"⭐ THE INTEGRATOR MAY LEAD THE COMMAND IT EMITS BY AT MOST "
f"{SLEW_LEAD_STEPS} x slew_w = {SLEW_LEAD_STEPS * _SLEW:.0f} W - so the "
"worst-case unwind is ONE cycle, not `integrator_max_w / slew_w` (12.5 "
"at this shape)",
abs(d.i_w - d.target_w) <= SLEW_LEAD_STEPS * _SLEW + 1e-9)
# ...and over a real run, which is where release/1.0 walks to the rail.
leads = [abs(f["loop_lead"]) for f in frames]
print(f" over {len(frames)} driven cycles: worst published lead "
f"{max(leads):.0f} W, worst integrator {min(i_row):.0f} W")
check(f"⭐ AND IT HOLDS ON EVERY DRIVEN CYCLE: the published lead - measured "
f"against the PREVIOUS command - never exceeds "
f"{(SLEW_LEAD_STEPS + 1) * _SLEW:.0f} W, one step for the bound and one "
"for the step the command took. On release/1.0 this same run reaches "
"4600 W",
max(leads) <= (SLEW_LEAD_STEPS + 1) * _SLEW + 1e-9)
check("...and release/1.0's figure is not merely larger, it is over the bound: "
"the run that produced 4600 W here would be red on the line above",
4600.0 > (SLEW_LEAD_STEPS + 1) * _SLEW)
check("...and nothing here is the SATURATION freeze doing SAFETY-48's work: "
"the inverter tracks exactly, so `frozen` is never true",
not any(f["loop_reason"] in ("saturated-freeze", "freeze-released")
for f in frames))
# ⚠️ AND THE CAP IS NOT ZERO, WHICH IS ITS OWN CHECK BECAUSE NOTHING ELSE
# NOTICES. `i_w = slewed` - a cap of zero - satisfies every bound above and is
# what control.py's docstring calls "the exact velocity form it replaced":
# the accumulator stops being a separate quantity at all. The claim is
# therefore that the integrator is STRICTLY ahead of the command it emits, by
# exactly one step, on a slew-limited cycle.
check(f"⭐ AND IT IS NOT A BACK-CALCULATION: on a slew-limited cycle the "
f"integrator stands EXACTLY {SLEW_LEAD_STEPS} slew step(s) ahead of the "
f"command it emits ({d.i_w - d.target_w:+.0f} W), not on top of it - "
"a cap of zero would collapse this function to the velocity form it "
"replaced",
d.reason == "slew-limited"
and round(abs(d.i_w - d.target_w), 6) == SLEW_LEAD_STEPS * _SLEW)
# ⚠️ AND IT BINDS IN BOTH DIRECTIONS OF TRAVEL, WHICH IS NOT OBVIOUS FROM THE
# SPELLING. `if target > slewed ... elif target < slewed` reads like two cases
# and is one: a command FALLING from +2400 W toward an integrator at +100 W has
# an integrator 2300 W ahead of it in the direction of travel, and it is capped
# exactly as a rising one is. A check written only on the rising case would be
# green for a gate that had lost half of itself.
_dn = compute(prev_w=2400.0, grid_w=-200.0, actual_w=2400.0, tuning=T,
sat_count=0, i_w=100.0)
print(f" descending: prev +2400 W, integrator +100 W -> command "
f"{_dn.target_w:+.0f} W, integrator {_dn.i_w:+.0f} W, "
f"reason {_dn.reason!r}")
check("⭐ THE CAP BINDS ON A FALLING COMMAND TOO: descending from +2400 W the "
f"integrator is pulled to exactly one slew step beyond the command it "
f"emits ({_dn.i_w - _dn.target_w:+.0f} W), not left 2300 W below it",
_dn.reason == "slew-limited"
and round(abs(_dn.i_w - _dn.target_w), 6) == SLEW_LEAD_STEPS * _SLEW)
# ⚠️ THE CAP IS INSIDE THE SLEW BRANCH, AND THIS IS THE ONLY STIMULUS THAT CAN
# TELL THAT FROM AN UNCONDITIONAL ONE. Applying it to every cycle - `i_w =
# max(target - cap, min(target + cap, i_w))` after `target = slewed` - is a
# -92 B edit that is BIT-IDENTICAL on the shipped configuration, and it was a
# SURVIVOR of all 30 files until this check existed. It is measured: 0 of
# 256 246 ticks differ over the 16-day recording, and 2050 of 257 400 swept
# states do - every one of them at `integrator_max_w` ABOVE `max_w`, the
# configuration control.py says is the only way its `clamped` reason is
# reachable. There the integrator is DELIBERATELY allowed above the rail, and a
# cap that fired on every cycle would drag it back to the command and make the
# option inert.
_HI = Tuning(gain=0.6, max_w=2000.0, slew_w=400.0, target_grid_w=-10.0,
integrator_max_w=5000.0)
_hi = compute(prev_w=-2400.0, grid_w=-8000.0, actual_w=-6000.0, tuning=_HI,
sat_count=0, i_w=-2400.0)
print(f" integrator_max_w 5000 above a max_w of 2000: command "
f"{_hi.target_w:+.0f} W, integrator {_hi.i_w:+.0f} W, "
f"reason {_hi.reason!r}")
check("⭐ THE CAP DOES NOT FIRE WHERE THE SLEW DID NOT BIND: with "
"`integrator_max_w` deliberately above `max_w` the integrator keeps its "
"own bound (-5000 W) instead of being dragged to the command - an "
"unconditional cap would put it at -2400 W and make the option inert",
_hi.i_w == -5000.0 and _hi.reason == "clamped")
# ⚠️ THE TWO RECORDED DEADLOCKS, AS TEST CASES. control.py names both at
# length; a gate written on the integrator is exactly the edit that brings them
# back, so each is driven rather than argued.
#
# (a) THE `i_w == 0.0` CASE. `main.py` resets the integrator to exactly 0.0 on
# every stop and every reseed, and "may not wind further in the direction it
# is already pushing" has no referent there. A gate that files zero under
# one sign blocks the first push toward the other one forever.
z = compute(prev_w=0.0, grid_w=-6000.0, actual_w=0.0, tuning=T, sat_count=0,
i_w=0.0)
check("at `i_w == 0.0` the first push toward charging is NOT blocked - the "
f"command leaves zero ({z.target_w:+.0f} W) and the integrator moves "
f"({z.i_w:+.0f} W)", z.target_w < 0.0 and z.i_w < 0.0)
# (b) THE SHRINK-ONLY ENCODING. "Only corrections that shrink |i_w|" is
# unsatisfiable for both signs whenever the correction is larger than twice
# the integrator, and the loop then sits at its last value forever. Driven
# as the measured case: 0 W held into a 2 kW import.
imp = run(2000.0, cycles=12)[2]
print(f" 2 kW import from rest: setpoints "
f"{[f['setpoint'] for f in imp[:6]]}")
check("a 2 kW import from rest recovers rather than sitting at its last value "
"- the shrink-only encoding held 0 W indefinitely here",
imp[-1]["setpoint"] >= 1000.0)
# ⚠️ AND THE GATE MUST NOT BE A DELAY. A loop that is slew-limited for a long
# ramp has to arrive, or the fix has bought export back with standing error.
far = run(-6000.0, cycles=40)[2]
print(f" a 6 kW export, 40 cycles: setpoint reaches "
f"{far[-1]['setpoint']:.0f} W of the {-BOUND_W:.0f} W rail")
check("⭐ THE COMMAND STILL ARRIVES: a railed export drives the command to the "
"rail, one slew step a cycle, with the integrator bounded the whole way "
"- the gate bounds the LEAD, it does not stop the loop",
far[-1]["setpoint"] == -BOUND_W)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
@@ -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
@@ -0,0 +1,649 @@
#!/usr/bin/env python3
"""SAFETY-48's evidence: the live install's OWN recorded disturbances, replayed
through the shipped control law with and without the card.
python3 tools/replay_recorded_loop.py # the A/B table
python3 tools/replay_recorded_loop.py --sensitivity # ...and does it survive
# a different plant?
python3 tools/replay_recorded_loop.py --export --days 16 --token $HASS_TOKEN
WHY A REPLAY AND NOT A STEP RESPONSE
====================================
`test_control.py`'s SAFETY-39 section drives a family of synthetic export steps
through a first-order plant, and it is a good instrument for the question it
asks. It cannot answer this one. The defect this card is about is a MISSING
CASE - the integrator outrunning the command whenever `|error| > slew_w / gain`
- and whether that case matters is a question about how often the house
actually produces such an error, how long it stands, and what else is happening
when it does. A step family answers "what would happen if"; only the recording
answers "how much did this cost".
WHAT IS REAL IN HERE
====================
* THE DISTURBANCE. `D = grid + batt` at every recorded meter instant: the
house's own net demand with THIS loop's battery power taken back out. It is
exogenous - the house does not know what the battery did - which is what
makes it valid to drive a counterfactual with, for a run of any length.
* THE TICK. The replay steps on the METER's own recorded sample instants, not
on a constant. Median 5.0 s over the window. ⚠️ `control.LOOP_TICK_S` says
2.0 s; that was measured on 2026-09-05 and is NOT what this install has been
running since. The figure matters to `SLEW_RAIL_FRAC`, and it is wrong in
the SAFE direction (a slower meter makes validate.py rule 10 conservative).
* THE CONTROL LAW. `app/control.py`, imported out of this checkout. The
"before" law is THE SAME FILE with the SAFETY-48 block removed by a named
substitution and exec'd into a fresh namespace - so it is release/1.0's
behaviour derived from the shipped bytes, never a transcription. The
substitution anchor must occur exactly once or this refuses to run.
MODELLED, AND THEREFORE NOT EVIDENCE ABOUT THE THING MODELLED
=============================================================
* THE INVERTER. A pure delay + first-order lag + affine gain, FITTED on this
same recording at SoC above the floor. Its residual is printed on every run
and is large (~375 W RMS): the replay tracks the meter WORSE than the real
install does, so the absolute export figures below are pessimistic by about
a third against the recording. ⚠️ THE DELTA IS WHAT THIS TOOL IS FOR - both
laws run against the same plant, on the same ticks, from the same seed.
* THE PACK. SoC integrated from the simulated battery power off the recorded
SoC, discharge refused at or below the floor, charge refused at 100 %.
* EVERYTHING ELSE THE ARBITER DOES. The recorded setpoint is the ARBITER's
output - the loop's claim after the peak guard, the SoC clamps, maintenance
and arbitrage have had their say. This replay runs `compute()` ALONE. That
is the largest single reason the fidelity section below is not tighter, and
it is why no absolute figure here should be quoted as "what the install
did"; the recording itself is that.
⚠️ WHAT THIS CANNOT SHOW, SAID BEFORE THE RESULTS RATHER THAN AFTER THEM
=======================================================================
* IT CANNOT SHOW THAT THE FIX IS SAFE ON THE REAL PLANT. Nothing here drives
an inverter. The rig and a live run are what answer that, and SAFETY-47
records why a shadow run cannot.
* IT CANNOT SEPARATE THE FIX FROM THE PLANT MODEL ON ONE RUN. `--sensitivity`
re-runs the whole table on five other plants and tunings; the ranking is the
claim, not the watt figures.
* THE BILLED QUANTITY IS A MAXIMUM OVER ~1500 QUARTERS, so it is a tail
statistic and one quarter decides it. The per-quarter MEAN delta is printed
beside it for exactly that reason.
"""
import argparse
import bisect
import gzip
import hashlib
import json
import math
import os
import pathlib
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
HERE = pathlib.Path(__file__).resolve().parent
ADDON = HERE.parent
sys.path.insert(0, str(ADDON))
SCENARIO = ADDON / "scenarios" / "loop-2026-09-02_2026-09-17"
DEFAULT_URL = "http://192.168.2.158:8123"
TZNAME = "Europe/Brussels"
# The entities, and what each is for. ⚠️ `setpoint` is NOT an input to the
# replay: it is the RECORDING the replay is graded against in §fidelity, and it
# is what `slew_w` and `max_w` are read off rather than assumed.
ENTITIES = {
"grid": "sensor.p1_meter_active_power",
"batt": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"setpoint": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"soc": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
}
# The live install's shape. `max_w` and `slew_w` are READ OFF THE RECORDING by
# `--export` and re-derived on every run below; `gain` and `target_grid_w` are
# the options and are not observable in the trace.
LIVE = dict(gain=0.6, max_w=5000.0, slew_w=400.0, target_grid_w=-10.0)
SOC_FLOOR = 10.0
CAP_WH = 17379.0 # measured: -dE/dSoC over 1604 segments of this record
GRID_HOLD_S = 30.0 # `meter_max_age_s`: past this a held meter is no reading
TICK_CAP_S = 60.0 # a longer gap is an outage, not a cycle
# ---------------------------------------------------------------------------
# the recording
def _rows(key):
out = []
for fn in sorted(os.listdir(SCENARIO)):
if not fn.endswith(f"-{key}.jsonl.gz"):
continue
with gzip.open(SCENARIO / fn, "rb") as fh:
for line in fh:
r = json.loads(line)
out.append((datetime.fromisoformat(r["t"]).timestamp(), r["v"]))
out.sort(key=lambda r: r[0])
ded = []
for t, v in out:
if ded and abs(ded[-1][0] - t) < 1e-9:
ded[-1] = (t, v) # the history endpoint's day-boundary repeat
continue
ded.append((t, v))
return ded
def _steps(key):
"""[(t, float|None)] - None OPENS A HOLE and nothing is held across it.
⚠️ A LONG SILENCE IS NOT A HOLE. Home Assistant's recorder stores a row only
when a state CHANGES, so eleven hours of `setpoint` silence means the
command held that value for eleven hours - which is exactly what this site
does overnight at its SoC floor. What IS a hole is a row whose state is
`unavailable`/`unknown`, and `grid` additionally stops being believed after
GRID_HOLD_S because that is the age the shipped controller stops believing
it at.
"""
out = []
for t, s in _rows(key):
try:
out.append((t, float(s)))
except (TypeError, ValueError):
out.append((t, None))
return out
def stream():
"""[(t, grid, batt, setpoint, soc)] at every recorded METER instant."""
g = [(t, v) for t, v in _steps("grid") if v is not None]
others = {}
for k in ("batt", "setpoint", "soc"):
s = _steps(k)
others[k] = ([r[0] for r in s], [r[1] for r in s])
prev_t = None
out = []
for t, v in g:
if prev_t is not None and t - prev_t > GRID_HOLD_S:
prev_t = t
continue
prev_t = t
row = [t, v]
for k in ("batt", "setpoint", "soc"):
tt, vv = others[k]
j = bisect.bisect_right(tt, t) - 1
row.append(None if j < 0 else vv[j])
if any(x is None for x in row):
continue
out.append(tuple(row))
return out
# ---------------------------------------------------------------------------
# the plant, fitted on this same recording
def _lsq(xs, ys):
n = len(xs)
mx, my = sum(xs) / n, sum(ys) / n
sxy = sum((a - mx) * (b - my) for a, b in zip(xs, ys))
sxx = sum((a - mx) ** 2 for a in xs)
syy = sum((b - my) ** 2 for b in ys)
return sxy / sxx, my - (sxy / sxx) * mx, sxy / math.sqrt(sxx * syy)
def fit_plant(rows, stride=1):
"""(lag_s, tau_s, gain, offset, rms_W), fitted where the pack can MOVE.
⚠️ THE SoC FILTER IS THE WHOLE FIT. This install spends 36 % of its ticks at
or below a 10 % SoC with the command standing near 2 kW and the battery
delivering nothing, and a fit taken over all ticks reads
`batt = 0.50 x cmd - 396 W, r = 0.70` - a plant that follows half the
command, which no inverter does. Above the floor the same data says
`batt = 1.00 x cmd - 28 W, r = 0.977`. The floor is the PACK and is modelled
separately.
"""
t = [r[0] for r in rows]
ba = [r[2] for r in rows]
sp = [r[3] for r in rows]
live = [r[4] > SOC_FLOOR + 2 for r in rows]
best = None
for lag in (0.0, 2.5, 5.0, 7.5, 10.0, 12.5):
j, u = 0, []
for i, ti in enumerate(t):
while j + 1 < len(t) and t[j + 1] <= ti - lag:
j += 1
u.append(sp[j])
idx = [i for i in range(0, len(t), stride)
if live[i] and abs(u[i]) > 50]
g, o, _ = _lsq([u[i] for i in idx], [ba[i] for i in idx])
for tau in (0.5, 1.0, 2.0, 4.0, 8.0, 16.0):
b, err, cnt = 0.0, 0.0, 0
for i in range(1, len(t)):
dt = min(t[i] - t[i - 1], TICK_CAP_S)
b += (1.0 - math.exp(-dt / tau)) * (g * u[i] + o - b)
if live[i]:
err += (b - ba[i]) ** 2
cnt += 1
rms = math.sqrt(err / cnt)
if best is None or rms < best[-1]:
best = (lag, tau, g, o, rms)
return best
class Plant:
def __init__(self, lag_s, tau_s, g, o, soc0):
self.lag_s, self.tau_s, self.g, self.o = lag_s, tau_s, g, o
self.b, self.soc, self.q = 0.0, soc0, []
def seed(self, t, b, cmd):
self.b, self.q = b, [(t - self.lag_s - 1e-6, cmd)]
def step(self, t, dt, cmd):
self.q.append((t, cmd))
want = self.q[0][1]
while len(self.q) > 1 and self.q[1][0] <= t - self.lag_s:
self.q.pop(0)
want = self.q[0][1]
tgt = self.g * want + self.o
if self.soc <= SOC_FLOOR:
tgt = min(tgt, 0.0) # an empty pack cannot discharge
if self.soc >= 100.0:
tgt = max(tgt, 0.0) # a full one cannot charge
self.b += (1.0 - math.exp(-dt / self.tau_s)) * (tgt - self.b)
self.soc = max(0.0, min(100.0, self.soc - self.b * dt / 36.0 / CAP_WH))
return self.b
# ---------------------------------------------------------------------------
# the two laws
SAFETY_48_BLOCK_ANCHOR = " _lead_cap = SLEW_LEAD_STEPS * slew\n"
def laws():
"""(before, after) - the shipped module, and the same SOURCE without the card.
⚠️ NOT A TRANSCRIPTION, AND NOT A FLAG. `test_mqtt.py` part 8's technique:
the shipped file's bytes with one named substitution, exec'd into a fresh
namespace. An anchor that does not occur exactly once is a refusal, because
a substitution that applied to nothing would be reported as "the card
changes nothing" - MUTATION.md's survivor, wearing an instrument's clothes.
"""
import app.control as after
src = (ADDON / "app" / "control.py").read_text()
if src.count(SAFETY_48_BLOCK_ANCHOR) != 1:
raise SystemExit(
"replay_recorded_loop: app/control.py does not contain exactly one\n"
f" {SAFETY_48_BLOCK_ANCHOR.strip()!r}\n"
"NOTHING HAS BEEN MEASURED. This is a REFUSAL, not a result: with no\n"
"anchor there is no `before` law to compare against, and a run that\n"
"quietly compared the shipped law with ITSELF would report the card\n"
"as changing nothing - which is the survivor MUTATION.md is about,\n"
"wearing an instrument's clothes.")
start = src.index(SAFETY_48_BLOCK_ANCHOR)
end = src.index(" target = slewed\n", start)
block = src[start:end]
stripped = src.replace(block, "")
if "SLEW_LEAD_STEPS * slew" in stripped:
raise SystemExit("replay_recorded_loop: the block was not removed - "
"NOTHING HAS BEEN MEASURED")
before = type(sys)("control_before")
exec(compile(stripped, "<release/1.0 law>", "exec"), before.__dict__)
return before, after
def replay(rows, law, fit, tuning):
"""[(t, dt, grid, batt, cmd, i_w, lead, reason, soc)] over the whole record."""
t0, g0, b0, sp0, soc0 = rows[0]
pl = Plant(*fit[:4], soc0)
pl.seed(t0, b0, sp0)
prev, i_w, sat = sp0, sp0, 0
out = []
for i in range(1, len(rows)):
t, gr, ba, sp, soc = rows[i]
dt = t - rows[i - 1][0]
if dt > TICK_CAP_S:
# An outage. Re-seed from the recording rather than inventing a
# cycle: nothing this loop did across it is knowable.
pl = Plant(*fit[:4], soc)
pl.seed(t, ba, sp)
prev, i_w, sat = sp, sp, 0
continue
D = gr + ba # the exogenous half of the meter
b = pl.step(t, dt, prev)
grid = D - b
d = law.compute(prev, grid, b, tuning, sat, i_w)
out.append((t, dt, grid, b, d.target_w, d.i_w, d.i_w - prev, d.reason,
pl.soc))
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
return out
# ---------------------------------------------------------------------------
# the five reported quantities - ONE implementation, run on both traces
def export_kwh(tr):
return sum(max(0.0, -r[2]) * r[1] for r in tr) / 3.6e6
def import_kwh(tr):
return sum(max(0.0, r[2]) * r[1] for r in tr) / 3.6e6
def reversals(tr, dead_w=50.0):
n, last = 0, 0
for r in tr:
s = 1 if r[3] > dead_w else (-1 if r[3] < -dead_w else 0)
if s and last and s != last:
n += 1
if s:
last = s
return n
def wasted_kwh(tr, bin_s=60.0):
"""Throughput that went round rather than anywhere, in FIXED clock bins.
Per bin, `sum |b|dt - |sum b dt|`. A bin spent charging, or discharging, or
idle contributes ZERO; only a bin that does both contributes. ⚠️ The bins
are wall-clock and identical for both laws on purpose: an episode-scoped
definition would MOVE with the fix and compare two different questions.
"""
tot, cur, absE, net = 0.0, None, 0.0, 0.0
for r in tr:
k = math.floor(r[0] / bin_s)
if cur is None:
cur = k
if k != cur:
tot += abs(absE) - abs(net)
cur, absE, net = k, 0.0, 0.0
absE += abs(r[3]) * r[1]
net += r[3] * r[1]
return (tot + abs(absE) - abs(net)) / 3.6e6
def quarters(tr):
"""{quarter -> mean OFFTAKE in W}. The billed quantity.
Calendar quarter-hours, the average of NET power, floored at 0 - a quarter
that nets to export is not offtake and is not billed. A quarter with less
than 80 % coverage is not a quarter and is dropped.
"""
acc = {}
for r in tr:
k = math.floor(r[0] / 900.0)
e, s = acc.get(k, (0.0, 0.0))
acc[k] = (e + r[2] * r[1], s + r[1])
return {k: max(0.0, e / s) for k, (e, s) in acc.items() if s > 720.0}
def recoveries(tr, bias, onset_w, cap_s=900.0):
"""Seconds from the error leaving `onset_w` to it back inside the deadband.
⚠️ AN EXCURSION THE PACK CANNOT ANSWER IS NOT A RECOVERY TIME. This install
spends 36 % of its ticks at the SoC floor with the command standing near
2 kW and the battery delivering nothing; the error there is not the loop's
to clear and every law scores the same non-answer. Leaving those in gave a
p99 of eight hours. An excursion counts only if the pack had headroom in the
needed direction for its whole length, and one still standing after `cap_s`
is counted as UNRECOVERED rather than as a large number.
"""
out, unrec, t_on, ok = [], 0, None, True
for r in tr:
err = r[2] - bias
head = (r[8] > SOC_FLOOR + 1) if err > 0 else (r[8] < 99.0)
if t_on is None:
if abs(err) > onset_w:
t_on, ok = r[0], head
else:
ok = ok and head
if abs(err) < 15.0:
if ok:
out.append(r[0] - t_on)
t_on = None
elif r[0] - t_on > cap_s:
unrec += 1 if ok else 0
t_on = None
return sorted(out), unrec
def pct(xs, p):
return xs[min(len(xs) - 1, int(round(p / 100.0 * (len(xs) - 1))))] if xs else float("nan")
def table(tr, tuning):
rec, unrec = recoveries(tr, tuning.target_grid_w, tuning.slew_w / tuning.gain)
leads = sorted(abs(r[6]) for r in tr)
q = quarters(tr)
return dict(export_kwh=export_kwh(tr), import_kwh=import_kwh(tr),
reversals=reversals(tr), wasted_kwh=wasted_kwh(tr),
worst_quarter_w=max(q.values()), rec_n=len(rec),
rec_med=pct(rec, 50), rec_p99=pct(rec, 99),
rec_max=rec[-1] if rec else float("nan"),
rec_unrecovered=unrec, lead_p99=pct(leads, 99),
lead_max=leads[-1], slew_ticks=sum(1 for r in tr if r[7] == "slew-limited"),
ticks=len(tr), _q=q)
ROWS = [("export_kwh", "export kWh", "the cost"),
("import_kwh", "import kWh", ""),
("reversals", "battery sign reversals", ""),
("wasted_kwh", "wasted throughput kWh", ""),
("worst_quarter_w", "worst 15-min offtake W", "⚠️ THE BILLED ONE"),
("rec_n", "recovery events", ""),
("rec_med", "recovery median s", ""),
("rec_p99", "recovery p99 s", ""),
("rec_max", "recovery max s", ""),
("rec_unrecovered", "unrecovered (>900 s)", ""),
("lead_p99", "|i_w - prev_w| p99 W", ""),
("lead_max", "|i_w - prev_w| max W", ""),
("slew_ticks", "slew-limited ticks", "")]
def show(a, b, name_a="release/1.0", name_b="SAFETY-48"):
print(f" {'':26} {name_a:>13} {name_b:>13} {'delta':>11}")
for k, lab, note in ROWS:
print(f" {lab:<26} {a[k]:13.2f} {b[k]:13.2f} {b[k] - a[k]:+11.2f}"
+ (f" {note}" if note else ""))
d = [b["_q"][k] - a["_q"][k] for k in a["_q"] if k in b["_q"]]
print(f" {'mean quarter delta W':<26} {'':13} {'':13} "
f"{sum(d) / len(d):+11.2f} over {len(d)} quarters")
print(f" {'worst quarter delta W':<26} {'':13} {'':13} {max(d):+11.2f}")
def fidelity(tr, rows):
"""What the BEFORE run gets wrong against the recording it came from.
⚠️ PRINTED BEFORE THE A/B TABLE IS READ, NOT AFTER. This is the number that
says how far any ABSOLUTE figure above may be trusted, and it is large.
"""
n = len(tr)
rec_g = [rows[i + 1][1] for i in range(n)]
rec_sp = [rows[i + 1][3] for i in range(n)]
sim_g = [r[2] for r in tr]
sim_c = [r[4] for r in tr]
dt = [r[1] for r in tr]
_, _, r_sp = _lsq(sim_c, rec_sp)
_, _, r_g = _lsq(sim_g, rec_g)
ex_sim = sum(max(0.0, -g) * d for g, d in zip(sim_g, dt)) / 3.6e6
ex_rec = sum(max(0.0, -g) * d for g, d in zip(rec_g, dt)) / 3.6e6
print(f" setpoint r={r_sp:.3f} mean {sum(sim_c) / n:+8.0f} W simulated "
f"vs {sum(rec_sp) / n:+8.0f} W recorded")
print(f" grid r={r_g:.3f} mean {sum(sim_g) / n:+8.0f} W simulated "
f"vs {sum(rec_g) / n:+8.0f} W recorded")
print(f" export {ex_sim:8.2f} kWh simulated vs {ex_rec:8.2f} kWh "
f"recorded -> the model is {100 * (ex_sim / ex_rec - 1):+.0f} % "
f"PESSIMISTIC about the loop's tracking")
# ---------------------------------------------------------------------------
# --export: the read-only capture
def _get(url, token, timeout=180.0):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
last = None
for attempt in range(1, 5):
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
raise RuntimeError(f"HTTP {resp.status}")
return json.loads(resp.read().decode()), attempt
except Exception as exc:
last = f"{type(exc).__name__}: {exc}"
if attempt < 4:
time.sleep(3 * attempt)
raise RuntimeError(last)
def export(base, token, days, out_dir):
"""One local day per request, per entity. Read-only: GET only, never a write.
⚠️ URL-ENCODE THE TIMESTAMPS. The history endpoint answers `Invalid
end_time` otherwise, and it answers it with a 400 that reads like a bad
entity id.
"""
from zoneinfo import ZoneInfo
tz = ZoneInfo(TZNAME)
out_dir = pathlib.Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now(tz).date()
man = {"generated": datetime.now(tz).isoformat(),
"generator": "tools/replay_recorded_loop.py --export",
"source": f"{base}/api/history/period", "timezone": TZNAME,
"entities": ENTITIES,
"format": '{"t": <ISO-8601 with offset>, "v": <state string>} per '
'line, gzip, mtime 0',
"days": []}
rc = 0
for d in range(days, 0, -1):
day = today - timedelta(days=d)
s = datetime(day.year, day.month, day.day, tzinfo=tz)
e = s + timedelta(days=1)
rec = {"date": str(day), "window": [s.isoformat(), e.isoformat()],
"files": {}}
for key, ent in ENTITIES.items():
q = urllib.parse.urlencode({"filter_entity_id": ent,
"end_time": e.isoformat(),
"minimal_response": ""})
url = (f"{base}/api/history/period/"
f"{urllib.parse.quote(s.isoformat())}?{q}")
try:
doc, attempts = _get(url, token)
except Exception as exc:
rec["files"][key] = {"read": "FAILED", "error": str(exc)}
print(f" {day} {key:9s} FAILED {exc}", flush=True)
rc = 1
continue
series = doc[0] if doc else []
body = b""
n_bad = 0
for row in series:
t = row.get("last_changed") or row.get("last_updated")
v = row.get("state")
if t is None or v is None:
continue
try:
float(v)
except (TypeError, ValueError):
n_bad += 1
body += (json.dumps({"t": t, "v": v}) + "\n").encode()
fn = f"{day}-{key}.jsonl.gz"
with gzip.GzipFile(out_dir / fn, "wb", mtime=0) as fh:
fh.write(body)
rec["files"][key] = {
"read": "OK", "entity": ent, "samples": len(series),
"non_numeric_states": n_bad, "attempts": attempts, "file": fn,
"bytes_raw": len(body),
"sha256_raw": hashlib.sha256(body).hexdigest()}
print(f" {day} {key:9s} {len(series):7d} rows, {n_bad} non-numeric",
flush=True)
man["days"].append(rec)
(out_dir / "manifest.json").write_text(json.dumps(man, indent=1) + "\n")
return rc
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--export", action="store_true")
ap.add_argument("--days", type=int, default=16)
ap.add_argument("--url", default=DEFAULT_URL)
ap.add_argument("--token", default=os.environ.get("HASS_TOKEN"))
ap.add_argument("--out-dir", default=str(SCENARIO))
ap.add_argument("--sensitivity", action="store_true")
args = ap.parse_args()
if args.export:
if not args.token:
raise SystemExit("--export needs --token or $HASS_TOKEN")
return export(args.url, args.token, args.days, args.out_dir)
from app.control import Tuning
t0 = time.time()
rows = stream()
span = (rows[-1][0] - rows[0][0]) / 86400.0
dts = sorted(rows[i + 1][0] - rows[i][0] for i in range(len(rows) - 1))
print(f"\nTHE RECORDING: {len(rows)} meter instants over {span:.2f} days, "
f"{SCENARIO.name}")
print(f" tick p5 {pct(dts, 5):.2f} s median {pct(dts, 50):.2f} s "
f"p95 {pct(dts, 95):.2f} s (control.LOOP_TICK_S says 2.0)")
# ⚠️ READ OFF THE RECORDING, NOT ASSUMED. `max_w` is the setpoint's own
# extreme and `slew_w` is the step the recorded command takes over and over.
sp = [r[3] for r in rows]
steps = {}
for i in range(1, len(rows)):
if rows[i][0] - rows[i - 1][0] < 8.0:
steps[round(abs(sp[i] - sp[i - 1]))] = \
steps.get(round(abs(sp[i] - sp[i - 1])), 0) + 1
top = max((c, w) for w, c in steps.items() if w > 100)
print(f" the recorded command: extremes {min(sp):+.0f} / {max(sp):+.0f} W"
f" -> max_w {LIVE['max_w']:.0f}; most common step above 100 W is "
f"{top[1]:.0f} W, {top[0]} times -> slew_w {LIVE['slew_w']:.0f}")
fit = fit_plant(rows, stride=7)
print(f" the fitted inverter: lag {fit[0]:.1f} s, tau {fit[1]:.1f} s, "
f"batt = {fit[2]:.4f} x cmd {fit[3]:+.1f} W, residual {fit[4]:.0f} W "
f"RMS above the SoC floor")
before, after = laws()
T = Tuning(**LIVE)
tr_b = replay(rows, before, fit, T)
tr_a = replay(rows, after, fit, T)
tab_b, tab_a = table(tr_b, T), table(tr_a, T)
print("\nFIDELITY - the BEFORE law replayed against the recording it came from")
fidelity(tr_b, rows)
print("\nTHE A/B, same ticks, same disturbance, same plant, same seed")
show(tab_b, tab_a)
if args.sensitivity:
print("\nSENSITIVITY - the ranking, not the watts")
variants = [
("plant lag 5.0 s", (5.0, fit[1], fit[2], fit[3], 0.0), LIVE),
("plant lag 10.0 s", (10.0, fit[1], fit[2], fit[3], 0.0), LIVE),
("plant tau 8 s", (fit[0], 8.0, fit[2], fit[3], 0.0), LIVE),
("gain 1.0135, offset -26", (fit[0], fit[1], 1.0135, -26.0, 0.0), LIVE),
("slew_w 555.6 (the rail)", fit, dict(LIVE, slew_w=555.6)),
("gain 0.3", fit, dict(LIVE, gain=0.3)),
]
print(f" {'variant':<26}{'export kWh':>22}{'worst quarter W':>26}"
f"{'mean quarter dW':>18}")
for name, f2, tune in variants:
T2 = Tuning(**tune)
b = table(replay(rows, before, f2, T2), T2)
a = table(replay(rows, after, f2, T2), T2)
d = [a["_q"][k] - b["_q"][k] for k in b["_q"] if k in a["_q"]]
print(f" {name:<26}{b['export_kwh']:9.1f} ->{a['export_kwh']:9.1f}"
f"{b['worst_quarter_w']:13.0f} ->{a['worst_quarter_w']:9.0f}"
f"{sum(d) / len(d):+18.2f}")
print(f"\n ({time.time() - t0:.0f} s)")
return 0
if __name__ == "__main__":
sys.exit(main())
+44 -4
View File
@@ -220,6 +220,7 @@ def slew_table(ps, ct, tick_s, rail):
f"faster than the")
print(f" plant settles. {rail:.0f} W / ({ct.PLANT_SETTLE_S:.0f} s / "
f"{tick_s:.0f} s per tick) = {frac * rail:.0f} W per tick.\n")
overs = []
for slew in (1000, 800, 700, 600, int(frac * rail), 500, 400, 300, 250):
w = family(ps, ct, gain=0.6, slew_w=float(slew), rail_w=rail,
tick_s=tick_s)
@@ -227,13 +228,52 @@ def slew_table(ps, ct, tick_s, rail):
" <- the 2026-09-05 live pair" if slew == 1000 else (
" <- the live interim" if slew == 400 else ""))
row(f"slew_w {slew}{tag}", w)
overs.append(w["over"])
print("\n READ IT THIS WAY. The >= 900 W step - the live symptom, which")
print(" escalated 2 -> 1 -> 13 -> 43 per 30 min that morning - is gone from")
print(" every run below 900 W of slew, and that is what the slew owns.")
print(" The OVER-ABSORPTION does not move, and at the slowest slews it is")
print(" slightly WORSE. The rig cannot pick the derived value out of that")
print(" column, and it is not asked to: the CONSTRAINT picks it, and this")
print(" table is what says what the constraint does and does not buy.")
# ⚠️ THE NARRATION BELOW IS DERIVED FROM THE COLUMN JUST PRINTED, NOT
# WRITTEN ABOUT IT. SAFETY-39 wrote "the over-absorption does not move, and
# at the slowest slews it is slightly WORSE" - true of release/1.0's law,
# which is what it was measured against, and FALSE under SAFETY-48's lead
# cap, where the same script on the same plant prints a monotonically
# FALLING column. A transcript whose prose contradicts its own table is a
# failing check (MUTATION.md habit 8), and a sentence that is right under
# one law and wrong under another has to be computed rather than typed. So:
# which law is imported is read off `control.py` itself, and whether the
# column falls is read off the column.
cap = getattr(ct, "SLEW_LEAD_STEPS", None)
falling = all(b < a for a, b in zip(overs, overs[1:]))
law = ("release/1.0's law - the integrator's lead over the command is "
"UNBOUNDED" if cap is None else
f"SAFETY-48's law - the integrator may lead the command by "
f"{cap} slew step(s)")
print(f"\n THE LAW THIS RAN AGAINST: {law}.")
if cap is None and not falling:
print(" The OVER-ABSORPTION does not move with the slew, and at the")
print(" slowest slews it is slightly WORSE - `slew_w` bounds the")
print(" OUTPUT while the integrator goes on winding, so a slower slew")
print(" leaves it winding for longer. SAFETY-48 bounds that lead and")
print(" INVERTS this column; see DECISIONS/SAFETY-48 §6.")
elif cap is not None and falling:
print(" The OVER-ABSORPTION FALLS MONOTONICALLY with the slew, which")
print(" is the OPPOSITE of what SAFETY-39 measured on release/1.0")
print(" (958/1107/1133/1058/987/1029/1150/1143/1081 W, flat and")
print(" non-monotone). Bounding the integrator's lead is what changed")
print(" it: the slew now owns a share of the over-absorption it did")
print(" not own before. DECISIONS/SAFETY-48 §6 has both tables and an")
print(" independent confirmation on test_control.py's own plant.")
else:
print(" ⚠️ NEITHER NARRATION FITS. The law above and the shape of the")
print(f" column ({'falling' if falling else 'not falling'}) are the")
print(" combination this script has no sentence for - which means the")
print(" measurement has moved and DECISIONS/SAFETY-39 §3 and")
print(" DECISIONS/SAFETY-48 §6 both need re-reading before this table")
print(" is quoted anywhere.")
print("\n EITHER WAY THE RIG DOES NOT PICK THE NUMBER, and it is not asked")
print(" to: the CONSTRAINT picks it, and this table is what says what the")
print(" constraint does and does not buy.")
def gain_table(ps, ct, tick_s, rail):