Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 5.5 29ca9b5157 DOC-1: operator docs name the real P1 entity and a roster that starts
checks / unit (pull_request) Successful in 8m8s
- DOCS.md / config.yaml: the watchdog's P1 age entity is
  sensor.goodwe_p1_sample_age (device "GoodWe" + "P1 sample age", as
  app/mqtt.py announces and the live install recorded), not
  sensor.p1_sample_age_s.
- DOCS.md: the inverters: example and per-entry table carry the required
  bms_soc_floor (and capacity_kwh); config.yaml's schema comment says four
  required keys, not three.
- Field guide: marked v0.1.0/outdated; slew_w row no longer recommends the
  1000 W that SAFETY-39 now refuses. HTML regenerated; the PDF is NOT
  rebuilt (no Chrome here). README/DOCS pointers fixed.
- test_docs.py: the documented roster must pass app/validate.py and the
  schema's per-entry keys; the documented entity id must equal the id
  composed from app/mqtt.py; the wrong id may appear in no shipped doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:24:50 +02:00
adminandClaude Opus 5.5 a45ed06c05 PKG-18: cut 0.12.0, so TEL-12's pv_entities can reach an installed 0.11.0 (#117)
checks / unit (pull_request) Successful in 8m0s
Reviewed at head 1ad4d3a. CI run 830 (attempt 1, pull_request, PR 117 -> release/1.0), 31/31 PASS. Review: #issuecomment-1588, #issuecomment-1589.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:09:28 +02:00
glenn schrooyenandClaude Opus 5.5 1ad4d3a5bb PKG-18: cut 0.12.0, so TEL-12's pv_entities can reach an installed 0.11.0
checks / unit (pull_request) Successful in 8m5s
One runtime merge since 0.11.0 (cccb2fa): TEL-12 (#116), a new optional
list option `pv_entities` beside `pv_entity`. Option surface 76 -> 77, so
a minor bump. config.yaml version and a closed `## 0.12.0` section only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 22:59:44 +02:00
admin f863ecaa8c TEL-12: several PV inverters, and one absent member is an absent TOTAL (#116)
pv_entity becomes a roster: pv_entities beside it, replacing it, with the SITE total over the roster. One absent member makes the TOTAL absent - never the partial sum of the ones that answered, because a smaller number indistinguishable from a real one reads a week later as 'there was less sun than there was', which is the conclusion OPT-10 exists to test rather than to manufacture. A member honestly reporting 0 W is still summed.

Reviewed on PR 116. Re-run independently: supervisor_shape_probe.py SSG (exit 0, transcript reproduces byte for byte), the owner's real 75-key options blob through Supervisor 2026.08.0's own AppOptions (ACCEPTED, zero drift on any stored value, pv_entities defaulted to []), tel11_pv_trace.py --compare across both trees (every hash reproduces, control 846a10a5b89e unmoved on unset and one), the rejected-design mutant (trace REFUSED 5/5 with the partial sums named), the planted leak N8 (52/26/10 red cells), and eight mutations on fresh purged copies including N5 sum->max at +0 bytes - no survivors. Habit 24's central claim measured: TEL-11's own --compare refuses on every honest run now that release/1.0 is 0.11.0.

Green traced to this PR's own run: 829/1954, attempt 1, event pull_request, path checks.yml@refs/pull/116/head, payload naming PR 116 with base release/1.0 @ cccb2fa, 31 suites PASS in 7m53s - a real execution, not a laundered skip.

Not deployable as merged: config.yaml stays 0.11.0 and the CHANGELOG entry is PKG's call.
2026-09-24 22:41:19 +02:00
adminandClaude Opus 5 0ef9565071 TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s
0.11.0 shipped `pv_entity` as a single string. The reference site's owner
reported within minutes of it going live that they have several PV inverters
and could configure one - which is ordinary for a multi-string roof, not a
property of one house. `pv_entities` is the roster and the row carries the
SITE total.

⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`,
and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL
ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no
irradiance" conclusion the channel exists to TEST. So `pv.total()` answers
`None` the moment any configured entity is absent: the topic carries HA's own
`None`, the entity goes to `unknown`, the recorder records a GAP. The
alternative (the partial sum beside a contributor count) was implemented and
run as mutation N1b; it loses because the MQTT row is read COLD a week later by
an attribution that would believe it, while the qualifying count would live in a
second entity with its own history. The partial information is not thrown away -
the STATUS PAGE names every entity that reported, with its watts, and every one
that did not, with its reason, because that surface is read LIVE by a human who
wants the diagnostic.

⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is
`is None`, never falsiness - a roster that blanked the site on a zero would
report an absence on every clear evening.

THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list:
tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an
0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and
deepmerge's list strategy is `override` so a `[]` default does not rescue it -
the live add-on would not start. `match(.*)?` does accept both shapes and is
rejected for a different, measured reason: it validates nothing (a string, a
list and the integer 417 all pass) and renders as a plain text box. So
`pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for
`inverters:`, with a status caution saying which key won - a union would
double-count the inverter the old key still names.

STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than
replaced and now grades five claims over three document sets. The control hash
is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the
roster control document is byte-identical between the two trees. 2/5 is a claim
TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly
as 0.11.0 does" is a real cross-tree identity over control AND telemetry.

⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit
24: a discriminator defined as "the baseline lacks this feature" dies on the
release that ships the feature, and dies as a false refusal.

Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B
(`sum` -> `max`, the same three characters) and was run on a fresh copy with
`cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO
contributions in two units with absence driven at EVERY position - a member
contributing 0 W could go absent without moving the sum of the rest, which is
habit 23 with a coinciding value in the arithmetic rather than in the plant.

31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0,
so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG
entry is wanted and deliberately not written - 11b forbids `## Unreleased` and
the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the
material it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 22:13:25 +02:00
adminandClaude Opus 5 cccb2fa07b PKG-17: cut 0.11.0, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1 (#114)
checks / unit (pull_request) Successful in 8m1s
Bumps config.yaml 0.10.1 -> 0.11.0 and adds the `## 0.11.0` section, so the Supervisor offers the update that carries SAFETY-56's wording fix and TEL-11's `pv_entity`.

Reviewed at head b11bea7. Green traced to this PR's own run (827, attempt 1, payload names PR 114 base release/1.0, 31/31 real). Version gate 11a-11d, SAFETY-56's behaviour hash and TEL-11's four trace rows all re-measured on this tree and matching. slug unchanged; option surface 75 -> 76 with no validator rule added.

Two non-blocking findings recorded on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 19:20:06 +02:00
adminandClaude Opus 5 b11bea70d3 PKG-17: the SAFETY-57 correction pointed at the wrong paragraph
checks / unit (pull_request) Successful in 8m13s
Self-review. The note added to `### SAFETY-57` said the reading rule it is
qualifying is "two paragraphs up"; it is in the paragraph directly above - the
same one the note follows. A changelog that misdirects a reader to a paragraph
that is not there is the small end of exactly the defect this cut is fixing
three sentences of elsewhere, so it is corrected rather than left.

Text only, one line pair, still two files in the branch's diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 19:01:35 +02:00
adminandClaude Opus 5 cfafdffaed PKG-17: cut 0.11.0, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1
checks / unit (pull_request) Successful in 8m0s
Two files, and no third. config.yaml 0.10.1 -> 0.11.0; CHANGELOG.md gains
`## 0.11.0` and three corrections to sections below it.

A MINOR BUMP, NOT A PATCH. TEL-11 adds an option - `pv_entity`, 75 keys -> 76 in
`options:` and `schema:` together - which is the same test 0.8.0, 0.9.0 and
0.10.0 were cut on. Nothing here can refuse a stored blob: the key is `str?`
with an `options:` default of "", validate.py does not mention it, no default
moved and no enum widened, so a blob that starts the add-on on 0.10.1 starts it
on 0.11.0.

THE TABLE HAS TWO ROWS AND THE WINDOW HAD FOUR MERGES. CI-9's follow-up
(9f6b9f1, `.gitea/workflows/checks.yml` only) and SEC-2 (239e120,
`docs/RELEASE.md` + README.md) are excluded under PKG-15's rule - AUDIENCE, NOT
IMPORTANCE. Neither file is under `goodwe_controller/`, neither is in the built
image, neither is in predeploy_check.py's RUNTIME tuple, and there is no version
of this add-on on which an operator can observe either. SEC-2 is the closer call
and the section argues it in full rather than asserting it: it is the more
consequential ticket of the two - it is why this release is a pull request
rather than a push to master - and it is still a procedure for whoever cuts a
release, not something the person deciding whether to press Update can act on.
Both are named in the section with pointers to PR #102 / checks.yml and PR #108
/ docs/RELEASE.md.

WHAT THE ENTRIES LET AN OPERATOR DECIDE. SAFETY-56: behaviour byte-identical
(6030175ed2ab..., reproduced in the CI image over 2390 capture reads, a 66-cell
grid stepped 120 times and six driven worlds), three surfaces named with the old
and new wording side by side - the STALE_READING line quoted onto the action log
and status page, the SAFETY-37 ERROR log line, and the readiness row that
asserted a false fact about a named entity. TEL-11: unset is byte-identical in
BOTH the control and telemetry documents, set is byte-identical in the control
document only; the three TEL-11 rows of that table were re-measured in the CI
image on this tree (control 846a10a5... twice, telemetry a443a6ce... / 4e6d1cb2...).
It is stated plainly that pv_entity is not a control input and does not change
what the loop does, and that absence publishes `unknown` rather than 0.

THREE CORRECTIONS BELOW THE NEW SECTION, WITHOUT REWRITING HISTORY. The 0.10.1
preamble said SAFETY-56 was open; the SAFETY-57 entry said its accusatory
diagnosis half was "deliberately untouched"; the 0.10.0 preamble left SAFETY-56
with no pointer while SAFETY-57 had one. All three quotations are what the
0.10.1 image actually prints and are left standing - a reader on 0.10.1 has to
recognise them - each now scoped to 0.10.1 and pointed at `## 0.11.0`.

VERIFIED. test_version_gate.py part 11 all four: 11a one contents table and the
slicer closed (302 lines under '0.11.0'), 11b 0.11.0 == config.yaml, 11c all
three RETIRED_PROVIDERS ids named - scoped to a pre-0.10.0 crossing and pointing
at `## 0.10.0`, which is where the evidence is - 11d still exactly one
`## Unreleased` below, PKG-08's, untouched. 31/31 in the CI image, checks.yml's
exact command, purged tree, `:ro`.

Not deployed and not installed. Shipping is a separate pull request from
release/1.0 into master, per docs/RELEASE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 18:50:13 +02:00
admin 94da37fc72 TEL-11: PV production stops being invisible, and stays out of the control law (#113)
Reviewed on the PR. Green traced to run 825 (PRNUM 113, BASEREF release/1.0, attempt 1, real 8m6s execution). Control-path identity independently reproduced on both trees and counter-tested with a planted leak (red on 26/41 cells); all three of the trace tool substituted refusals fire. M7 genuinely red. Three publication states distinct on the wire. 31/31 in the CI image.

Not deployable as merged: config.yaml stays at 0.10.1, so predeploy_check.py reports a NO-OP until PKG cuts a version.
2026-09-24 15:55:29 +02:00
glenn schrooyenandClaude Opus 5 07ba3433ed TEL-11: PV production stops being invisible, and stays out of the control law
checks / unit (pull_request) Successful in 8m6s
OPT-10 measured that this site's pack reaches 90 % SoC on 2 days of 16 and that
20.5 kWh of surplus left the site while the pack had room - 12.6 kWh of it with
the battery not charging at all - and could not say WHY, because PV production
is recorded NOWHERE. Three causes need three different fixes (no irradiance:
nothing to fix; a charge-power limit; the optimiser declining to charge, which
is the only software defect) and guessing between them is how an optimiser gets
tuned to solve a cloud problem. This card adds the channel. It does not do the
attribution.

AN HA ENTITY, NOT A REGISTER, AND THAT IS A PRODUCT DECISION. This add-on's
RS485 bus carries the BATTERY inverter it drives; a household's PV inverter is
normally a different box from a different vendor reporting into Home Assistant
through its own integration. `pv_entity` takes whatever `sensor.*` the site's
own PV integration provides, any brand HA can read.

POSITIVE IS PRODUCTION, AND THERE IS NO `pv_invert`. The convention is derived
rather than assumed: `meter_entity` is positive when IMPORTING and `batt_entity`
is positive when DISCHARGING (DOCS.md, Sources), both of which are "power
arriving at the house's AC bus", and production arrives there too. Those two
keys carry invert flags because they are BIPOLAR and are READ BY THE CONTROL
PATH - `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor, so a reversed sensor hands the site a plausible number
it acts on. Production is unipolar and is read by nothing: a reversed sensor
announces itself as -4 kW at noon in the very history this card creates, and a
flag would only let an installer hide it. No negative-value alarm either - a PV
inverter's own standby draw makes a small negative reading at night ordinary, so
any threshold would be a number nobody measured.

ABSENCE IS NEVER ZERO, AND "DROPPED" IS NOT ABSENT. `publish()` SKIPS a None,
which is right for `circulation` and exactly wrong here: a skipped row leaves
Home Assistant holding the last number forever and the recorder draws a FLAT
LINE through the outage, which is indistinguishable from a measurement and
manufactures the "there was no surplus" conclusion this card exists to TEST. So
the absence is published, with HA's own payload: `mqtt/const.py` declares
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric branch,
so the entity's state becomes `unknown` and the recorder stores a GAP. Read out
of home-assistant/core `dev` on 2026-09-24 and quoted at the constant.
`ABSENT_AS_UNKNOWN` is a one-key set and the suite asserts `circulation: None`
is STILL dropped.

THE UNIT IS READ OFF THE ENTITY. `mW`, `W`, `kW`, `MW`, `GW`, `TW` - HA's own
`UnitOfPower`, every factor an exact power of ten. A sensor with no unit, or a
unit outside that table, is REFUSED by name and the status page says which.
`read_inputs()`'s `abs(raw) < 50` kW-or-W guess is safe for a quarter-hour
demand forecast and is NOT safe here: 30 W of dusk is an ordinary reading and
that heuristic would publish it as 30 kW.

GENUINELY OPTIONAL, AND NOT A SECOND `price_now_entity`. validate.py does not
mention the key - it cannot conflict with anything, because nothing else reads
it. The MQTT row lives in a new `OPTIONAL_SENSORS` list whose `include` argument
DEFAULTS TO EMPTY, so every caller that has never heard of `pv_entity` -
test_mqtt.py's fixtures, tools/rehearse_roster.py, anything written next year -
announces exactly what it announced before. Unset: no state call at all, no
entity, no topic, no status row, no page row. Set and failing: a CAUTION
(`ok=False, warn=True`), never a `bad` row, so a telemetry channel can never put
a battery site into NOT READY.

IT MUST NOT BECOME A CONTROL INPUT, AND THAT IS GRADED TWICE. `test_pv.py`
part 6 drives 40 cycles of the real `cycle()` and asserts the commands are
bit-identical across unset / no reading / 0 W / 8 kW, with a COMPUTED census
(33 modules under app/) showing the material is mentioned in exactly three:
pv.py, main.py, mqtt.py. `tools/tel11_pv_trace.py` is the cross-tree half -
`safety56_behaviour_trace.py`'s pattern with a DIFFERENT control clause, because
this card's claim is that nothing differs rather than that only sentences do: it
REFUSES unless the two `app/` fingerprints differ and unless exactly one side
reacts to the option. Measured on 41 stimulus cells:

    control   release/1.0 unset  846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    control   TEL-11      unset  846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    control   TEL-11      set    846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    telemetry release/1.0 unset  a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
    telemetry TEL-11      unset  a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
    telemetry TEL-11      set    4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f

UNSET is byte-identical in BOTH documents, which is stronger than "the control
path is untouched": an install that does not set the option sees no change of
any kind, announcement and status rows included.

⚠️ AND THE CARD'S CENTRAL MUTATION WAS A SURVIVOR ON THE FIRST BATTERY.
`self.grid -= self.pv_w` in `read_inputs()` - +106 B, on disk, purged, genuinely
running - came back GREEN, because the fixture drove a 6 kW export over 12
cycles, which puts the loop AGAINST ITS RAIL: a command moving `slew_w` every
cycle produces the same ramp for -6000 W and for -14 000 W of apparent error.
MUTATION.md habit 23 with a saturated ACTUATOR in place of a coinciding value,
and it is in that file now as the habit's second instance. The fixture now
drives a 600 W import the loop SETTLES on, against a responsive meter, and asks
habit 23's own question as its own check: shift the METER by the same 8 kW and
confirm the commands really would have moved.

Evidence: 31/31 suites green in the CI image, checks.yml's exact command, purged
tree, `:ro`. Ten mutations, NO survivors, each on its own `cp -a` of the tree so
habit 18's revert hazard is closed by construction - table in
`DECISIONS/TEL-11-pv-production.md` §7 with byte deltas and sha256s.

⚠️ THIS IS A RUNTIME CHANGE BEHIND AN UNCHANGED VERSION, AND
`tools/predeploy_check.py` WILL REFUSE IT. `app/`, `config.yaml` and
`translations/` are all in its `RUNTIME` tuple and `config.yaml` still reads
0.10.1, so the gate reports a NO-OP deploy until a version is cut. That is PKG's
call and this card does not take it.

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 that
same packaging decision. The operator account is in DOCS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 15:35:07 +02:00
admin 9f6b9f14cd CI-9 follow-up: the summary said "mechanically", the blind spots say otherwise (#102)
Corrects the preamble's claim that the `guard the trigger` step keeps checks.yml filter-free "mechanically". It does not, for blind spots 1 and 3: the guard lives inside the workflow a filter makes Gitea skip, so for those the defence is the comment.

Names the filter that would actually get written - `branches: [release/1.0]`, not `[main]` - because it reads as tightening onto the protected branch, leaves every day-to-day pull request green, and switches off the `release/1.0` -> `master` release pull request that SEC-2 made the mandated path to an installed system. Points the forge fix at both protected branches, not just `release/1.0`.

Reviewed on the pull request. Verified from the branch's bytes: the guard's awk is unchanged and still refuses `branches:`, `paths:`, `paths-ignore:`, `branches-ignore:`, `tags-ignore:`, `types:` and the inline-flow form injected into the `on:` block of the edited file; comment-only at 0 non-comment changed lines, non-comment lines identical 139 == 139; the merged tree differs from c9aaf7f in this one file only. Green traced per CI-10 to run 824 / job 1949, payload PRNUM 102 / BASEREF release/1.0, base c9aaf7f, 30 PASS 0 FAIL.
2026-09-24 14:37:15 +02:00
glenn schrooyenandClaude Opus 5 8f9c2cad57 CI-9 follow-up: the skip direction, written the right way round
checks / unit (pull_request) Successful in 8m37s
`every subsequent pull request matching it is skipped too` is the inverse of
the behaviour. A pull request that MATCHES a `branches:` filter still RUNS;
the one the filter EXCLUDES is what Gitea sits out, reports `skipped`, and the
rollup launders into `success`.

The error arrived with the generalisation in the previous commit: `33e17db`
said "every subsequent pull request is skipped too", which was unqualified and
true for the `[main]` case it described. Naming the wider hazard made a
qualifier necessary, and the wrong one went in - contradicting the paragraph
directly below it, which describes `[release/1.0]` correctly as leaving every
day-to-day PR running and green.

Corrected, and the direction is now stated explicitly with the failed draft
recorded, because this file already carries one instance of the same shape:
"That was wrong, it contradicted the checkout step's own note a few lines
below, and the error was not cosmetic."

Guard re-verified on the edited bytes: passes here, still refuses
`branches: [release/1.0]` added to the `on:` block. Comment-only, 0
non-comment lines changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 14:27:19 +02:00
admin c9aaf7f04d SAFETY-56: the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can (#112)
Text only, no behaviour change. Three operator-facing sentences stop asserting a dead register where the gate cannot distinguish a dead register from a pack the BMS is refusing.

Reviewed on the PR: behaviour byte-identical (trace tool sha256 6030175ed2ab reproduced on both trees, plus AST identity with string constants normalised); five mutations re-applied on purged trees, all matching claimed named-red counts; SoC of the live episode independently measured at 11 percent; CI green traced to PRNUM 112 / BASEREF release/1.0.
2026-09-23 21:27:40 +02:00
adminandClaude Opus 5 8a6acd9fa2 SAFETY-56: the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can
checks / unit (pull_request) Successful in 8m0s
Text only. No behaviour change - same conjunction, same limits, same MISSING,
same 0 W, same NOT READY. `tools/safety56_behaviour_trace.py` says so: 2390
capture reads, a 66-cell swept grid, and six driven controller worlds produce
the byte-identical document `6030175e...` on origin/release/1.0 and on this
branch, while the sentences differ.

THE DEFECT. SAFETY-37's conjunction is satisfied by two disjoint worlds - the
register has stopped reporting reality, or the SOURCE cannot comply with the
command - and nothing `freshness.py` is handed separates them. Three sentences
asserted the first. On the live 2026-09-21 episode it was the second: the pack
read 11 % for the whole 850 s hold, every command in that hold was a discharge,
and the register answered the first CHARGE command three seconds later. The
readiness row was worse still, because it asserted a false fact about a NAMED
entity that was present, numeric and updating - so the operator opened
Developer Tools, found it working, and had every reason to conclude the
diagnostics were broken.

THE FIX. Each message states what was OBSERVED and names BOTH explanations.
Three wordings, no shared helper: two are a log line about a behaviour, the
third is a banner about an entity, and a helper serving both would drop
whichever half the other caller does not need. SoC stays STATED, NOT WIRED -
part 11b drives the two worlds at 50 % and 11 % and the sentence does not move
one byte. The row does not branch on `batt_fresh.stale` either: `stale_note()`
already rides `liveness_notes()` a few rows below, so the discriminator is "is
that row there?", and part 11d asserts it is present in one world and absent in
the other.

THE CHECKS. test_freshness.py part 11 builds the pair of worlds with ground
truth written down rather than derived from the published stream, asserts they
are really two (40 of 40 reads differ) while the published stream is
bit-identical, and only then grades the sentences. Eleven mutations, all red:
each of the three sentences reverted (3, 2 and 2 named reds), the two
explanations transposed at ZERO bytes (1), the second explanation deleted (1),
the admission of ambiguity replaced by a ranking (1), all three reverted at
once (5), and two aimed at this card's own check - collapsing the world pair
(2) and emptying the accusation list, which a positive control against the
three sentences as 0.10.1 shipped them catches (1).

AND TWO OF THE ELEVEN FOUND DEFECTS IN THE CHECK RATHER THAN IN THE CODE.
Transposing `label` and `entity` in the row made it unfindable and the run died
on an IndexError - a red with no name and every later check ungraded, FCST-06's
shape exactly; `_s56_row()` returns a named absence now, 0 named reds before, 3
after. And the first invariance run compared a tree with itself, because it
archived a branch whose edits were not committed yet; the tool now REFUSES to
report "behaviour unchanged" unless the sentences halves differ, and both of
its refusals are driven.

SAFETY-57's part 10h check was the handoff and is inverted rather than deleted:
it required the accusation to still be present so that removing it had to be a
deliberate act. This is that act. Its escape clause is untouched.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 21:02:40 +02:00
glenn schrooyenandClaude Opus 5 628279a68a CI-9 follow-up: name the filter that actually gets written, not the one that looks wrong
checks / unit (pull_request) Successful in 7m58s
Three sentences in the blind-spot clause went stale between writing and review.

`no pull request in 99 has ever targeted master` is no longer true: SEC-2 made
`release/1.0` -> `master` the mandated release path and protected `master`
with the same required context, and a PR against that base has run for real.
The census it came from also erases its own evidence - PR #102 was itself
opened against `master` before retargeting, which the /pulls listing no longer
shows, exactly as the caveat 25 lines above this clause warns.

The clause also named `branches: [main]` as the hazard. That is the version
that looks wrong. The one someone would actually write is
`branches: [release/1.0]` - it reads as tightening onto the protected branch,
every day-to-day PR keeps running green, and the only thing it switches off is
the release PR whose content reaches an installed system.

And the prescribed fix named only `release/1.0`'s status_check_contexts. Since
SEC-2 `master` carries the same rule, so a reader following it literally would
half-close the hole and believe it shut.

Guard re-verified against the edited bytes: passes on this file (whose new
prose quotes `branches: [release/1.0]` in a comment) and still refuses a real
filter added to the `on:` block. Comment-only: 0 non-comment lines changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 20:18:50 +02:00
admin 239e120bf6 SEC-2: write down the release path, now that master refuses a direct push (#108)
Reviewed on the PR (issuecomments 1480, 1485, 1488, 1489). Documentation only: docs/RELEASE.md (new, 116 lines) plus a 2-line pointer in README.md's file map. No code, no workflow change.

Verified against the forge rather than accepted from the document:
- Both branch_protections rules re-read field-for-field identical (enable_push false, enable_force_push false, status check `checks / unit (pull_request)`, required_approvals 0, block_admin_merge_override true, no whitelists, no file patterns); master's rule created 2026-09-23T03:09:21; default_branch still master. Section 2's After table is exact. No protection was changed by this review - both rules re-read with their original updated_at.
- The empty-PR 405 trap reproduced independently on my own control (throwaway PR #111 between two same-SHA unprotected branches): empty PR -> 405 "Please try again later"; same PR after one commit -> 409 "head out of date". Section 1's "read the message, not the code" is correct and both messages are quoted accurately. PR #111 closed, branches deleted.
- release/1.0 (b0b24e5) is an ancestor of master (0f14c2a), so a literal release PR is empty right now - PR #109 has 0 commits, 0 files, and Gitea opened it anyway. Section 1's closing note is right.
- A master-base PR gets a genuine CI run, not a laundered skip: PR #107 head cb7e45b3 shows Waiting -> In progress -> success, "Successful in 7m56s", run 814. This PR's own head 9d593429 likewise, run 818.
- All five required steps are present and correctly ordered: work merges to release/1.0 with a version bump in goodwe_controller/config.yaml; release via PR release/1.0 -> master; direct push refused; green check before merge; `ha store reload` after, without which the Supervisor keeps serving the cached version. required_approvals: 0 is stated twice, so no reader can believe the dev/review split is enforced.

One non-blocking defect left open (issuecomment 1485): section 1 calls 0f14c2a "the only commit on master that carries no (#NN) merge suffix". 274 of master's 297 commits carry no suffix. The true statement is stronger - no release has ever reached master through a pull request, and 6f5e8e3 (0.10.0) was promoted by the same direct push one release earlier, so 0.10.1 was the second, not a one-off. Background, not procedure; wants a follow-up line change.

A third comment of mine (issuecomment 1488, merge style) is withdrawn in issuecomment 1489 - it rested on a premise I had not checked and which is false.
2026-09-23 04:08:18 +02:00
glenn schrooyenandClaude Opus 5 9d593429d3 SEC-2: write down the release path, now that master refuses a direct push
checks / unit (pull_request) Successful in 7m56s
master is the default branch, which is what the Home Assistant Supervisor
clones - so whatever sits on it runs on a real 5 kW inverter. Until today it
had no branch protection at all, while every gate this repo built sat on
release/1.0, the branch that does not ship. 0.10.1 went straight onto master
on 2026-09-22 with no PR, no CI and no reviewer, and it succeeded.

master now carries the same rule as release/1.0 - enable_push false,
required context `checks / unit (pull_request)`, block_admin_merge_override,
required_approvals 0. That is a forge setting and cannot live in a commit, so
the part that can is this: the procedure it implies. Without it the next
release is attempted the old way and fails confusingly at the push.

docs/RELEASE.md records the path (merge to release/1.0, bump version, PR
release/1.0 -> master, wait for green, merge, then `ha store reload` before
the Supervisor offers anything), the before/after protection state of both
branches, and how the gate was proven to bite rather than merely badge green.

Documentation only - no code, no workflow change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 03:43:37 +02:00
admin b0b24e5d41 PKG-16: cut 0.10.1, so SAFETY-57's fix can reach an installed 0.10.0 (#105)
checks / unit (pull_request) Successful in 7m57s
Reviewed on the PR (issuecomments 1455-1462): identity verified unchanged (slug goodwe_controller, no image: key, installed slug 68285ac7_goodwe_controller corroborated from HA backup metadata); version-gate 11a-11d re-implemented and verified independently; the 16-of-93-at-790s pairing re-derived from the committed 16-day capture (>=710: 17, >=790: 16, >=815: 15); the SAFETY-57 entry verified as a faithful move carrying only the three declared #104 documentation fixes; corrected 0.10.0 preamble corroborated to the day against backup metadata; suite 30/30 reproduced with checks.yml's exact command. Two files, no control code. The 710 s/790 s split between test_freshness.py and this file is a known, stated gap and takes its own card.

NOTE: master (the Supervisor's default branch) is still 0.10.0 and still lacks SAFETY-57. This merge does not deliver; bringing master to release/1.0 is a fast-forward and is the remaining step.
2026-09-22 15:52:25 +02:00
glenn schrooyenandClaude Opus 5 eb88646899 PKG-16: cut 0.10.1, so SAFETY-57's fix can reach an installed 0.10.0
checks / unit (pull_request) Successful in 8m0s
The Supervisor decides an update exists by comparing config.yaml's version
string with the one the box reports. master and release/1.0 both said 0.10.0,
so SAFETY-57 - a deadlock that parked the reference battery for ~13 min on
2026-09-21 while the house exported 2.4 kW - had no way of reaching the box
that has it. A patch bump is the delivery mechanism, not bookkeeping.

- config.yaml: 0.10.0 -> 0.10.1. Nothing else in the option surface moves:
  no key added or removed, no default changed, no enum widened, no rule added
  to validate.py, so a blob that starts on 0.10.0 starts on 0.10.1.

- CHANGELOG.md: a new `## 0.10.1` section, holding SAFETY-57's entry moved out
  of `## 0.10.0`. The entry opened by saying it was misfiled and that whoever
  cut the next version owned moving it; this is that cut, so that paragraph is
  replaced by what an operator actually needs in order to decide - what the
  deadlock does (command parked at 0 W, batt reading gated to MISSING, escape
  condition unreachable from inside the state), how to recognise it (setpoint
  stuck at exactly 0 W with the battery register flat, a STALE line and no
  clearing line) and that a restart of the add-on clears it.

- PKG-16 proper: `## 0.10.0`'s preamble still claimed 0.10.0 "has never been
  built or pulled anywhere" and that the reference install was on 0.7.0. Both
  stopped being true on 2026-09-20 ~22:18 CEST, when 0.10.0 was installed as a
  repository add-on (slug 68285ac7_goodwe_controller) and began regulating a
  real battery. The section's own bump argument was explicitly conditional on
  that; the condition has fired, so the argument is retold as history and the
  section is closed. This text is what the Supervisor shows beside Update.

Also fixes, in this file only, the three non-blocking documentation defects
raised on PR #104 - issuecomments 1445, 1446 and 1447:

- 1446: the live episode is 790 s, not 710 s. Re-derived rather than rescaled:
  at >= 790 s the committed 16-day replay gives 16 of 93, not 17.
- 1447: the BMS keep-alive pulse was an inference stated as a measurement. No
  committed capture identifies one, and on this episode 0x0518 never moved at
  all. The measured claim replaces the firmware story.
- 1445: "neither consumer can reach a verdict inside one window" is universal
  as written and graded on a fixture that puts the obligation back on every
  release. It now carries the qualifier the reviewer asked for, and names the
  unbounded case and why the shipped one-inverter roster cannot reach it.

The same three defects also sit in app/freshness.py, test_freshness.py,
DOCS.md and the DECISIONS note. They are deliberately NOT touched here: this
PR is config.yaml and CHANGELOG.md only, and none of them is worth delaying a
live safety fix. SAFETY-56 and SAFETY-58 are likewise untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:22:03 +02:00
admin b9f7bd0506 SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested (#104)
Reviewed and merged by the PR review agent. Acceptance criterion re-driven independently off the committed RS-485 capture: the verdict is withdrawn with 0x0518 held constant at -3 W, no external change. Counterfactual release_s=inf verified byte-equivalent to the shipped-before module over 242336 reads, so the SAFETY-37 conjunction is untouched. 30/30 suite green in checks.yml's own image; 11 mutations re-applied on a purged tree; the argued-equivalent M7 proved equivalent over full internal state.

Three non-blocking documentation findings filed on the PR (comments 3/7, 4/7, 5/7) for a follow-up before the next version is cut.
2026-09-21 15:49:35 +02:00
adminandClaude Opus 5 43c144b8d9 SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
checks / unit (pull_request) Successful in 7m58s
SAFETY-37's freshness gate reports a battery-power register as MISSING when it
stops following its own command, and a missing input is "command 0 W and keep
commanding" (§11.1 rule 1). The gate's only exit was "an arrival carrying a
DIFFERENT number" - and at 0 W nothing is asking the register to be anywhere.
The state had no exit reachable from inside it, and the log line promised the
opposite in the same sentence that announced the 0 W.

REPRODUCED FROM THE WIRE, NOT MODELLED. scenarios/safety57-2026-09-21/ is 3840
verbatim frames off the RS-485 pair, read from the passive sniffer, decoded by
tools/decode_bus_capture.py (0 unparsed, 0 bad CRC, 0 unknown shapes; the
mode->sign convention is graded off the capture, not trusted). The box was
running 0.10.0, so the 0 W in the trace IS the add-on's own claim:

  10:51:24.7  0x0518 reaches -3 W. Pack at 11 %, the BMS refuses the discharge,
              the inverter idles. THE REGISTER IS HEALTHY AND TRUTHFUL.
  10:51:29.7  the command reaches +1060 W - 800 W of travel
  10:52:00    the conjunction arms. Inverter.batt becomes None
  10:52:21.7  the site commands 0 W, on the wire
  ...         0x0518 does not change value for 850.0 s. The setpoint does not
              change value for 790.0 s. The house exports 2.4 kW throughout
  ~11:04      a human restarts the add-on. That is what ends it

AND IT HAD BEEN ESCAPING BY LUCK. Replaying the committed 16-day loop capture
through the shipped conjunction finds 93 trips whose wait for the BMS
keep-alive pulse that cleared them has a median of 143.4 s, a worst case of
21440.1 s, and 17 of 93 at or beyond this episode's 710 s. That distribution
belongs to the battery's firmware, not to this code.

THE CARD'S OPTION 2 ALREADY HELD AND IS NOT THE BUG. _grade() has required
|standing| >= MOVE_W since SAFETY-37. The trip was at +1060 W and the stuck
state is at 0 W; they are different moments, and an entry-side rule cannot
reach a state entered legitimately and then persisted. Nothing in the
conjunction is weakened here.

THE RULE, SAFETY-13's SHAPE ONE MODULE OVER - which latches get an exit, not
how long the block lasts:

  the verdict is withdrawn when the obligation that produced it has been gone
  for RELEASE_S, and the register is then re-graded from scratch.

RELEASE_S is BATT_OBLIGED_S, 17.0 s - the obligation window already derived in
the file (6.0 plant + 10.0 publish + 1.0 read), taken at the FLOOR of its band
for the mirror of MOVE_W's reason. Which half is safe is the opposite of
SAFETY-13's: being wrong long is the failure being fixed, being wrong short
costs one bounded re-grading window. Below 17 s the release pre-empts the
cheaper exit - the register answering by itself - and that is graded
behaviourally, not asserted.

The whole parked interval is now stale_input_s + RELEASE_S = 32 s. On the live
episode: telemetry back at 10:52:39 instead of 11:05:35, with 0x0518 still
answering the same -3 W. RED BEFORE (release_s=inf, a graded counterfactual and
not a second implementation), GREEN AFTER.

WHAT IT COSTS, AND IT IS NOT SMALL. A register dead for good is re-graded for
ever: over 1201 reads the held number reaches a consumer on 630 (52.5 %)
against 35 (2.9 %) before. Payable only because one window is at most 35 s and
the two consumers hold for 75 s and 85 s, so neither can reach a verdict inside
one and the re-trip clears both windows. No detection SAFETY-37 had is given
back - a permanent None clears the same windows for ever too - and the command
is derived from the P1 meter, never from batt.

Two clocks, deliberately: changed_mono/unchanged_s() stay facts about the
REGISTER, graded_from/graded_s() are the VERDICT's window. Collapsing them
either lies to the operator or defeats the re-grading.

main.py's stale line had its ESCAPE CLAUSE corrected - it promised a clearance
the state could not produce - and gained a STALE WITHDRAWN edge that says in
its own words that nothing has been learned about the register. The DIAGNOSIS
half is SAFETY-56's and is deliberately untouched, with a named check asserting
it is still there so that rewrite has to be deliberate.

Battery: 22 mutations, 21 red, 1 argued equivalent, 0 unexplained survivors.
Two findings kept in DECISIONS: _release()'s move_w test swapped for
unchanged_s SURVIVED at +5 B because every fixture rested at exactly 0 W (habit
11, closed by a 0/100/300 W fixture); and the code shipped a habit-22 comment
claiming the _release/_grade order was load-bearing, which the battery
falsified - the two act on mutually exclusive predicates, so the order is not
observable. MUTATION.md gains habit 23 for a mutation that ran, fingerprinted
clean, and was a no-op on the fixture because `0.0 or x` is x.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro, one
file at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-21 12:33:56 +02:00
adminandClaude Opus 5 6f5e8e378a PKG-15: bring release/1.0 to master at 0.10.0, so the default branch is the add-on
`master` has been `5184a2c` / v0.2.1 since 2026-08-25 - the tree that drives the
house - while every ticket since has landed on `release/1.0`. Three consequences
were live, and all three are what this merge closes:

 * `master` is the DEFAULT branch, so Gitea indexes dispatchable workflows from
   it. `master` had no `.gitea/` at all, which is why `GET /actions/workflows`
   answered empty and the UI's dispatch button was missing.
 * new pull requests default to `master`. #102 was opened against it by
   accident and its first run graded the change merged into a stale `master` -
   an honest-looking green answering the wrong question.
 * AND THE ONE THAT NOW MATTERS MOST: the repository has not yet been added to
   Home Assistant, and adding it is part of this release. `master` is the
   branch the Supervisor will read the moment it is added, so whatever stands
   here is what it shows and offers to install. This merge is what makes that
   0.10.0 rather than 0.2.1.

NO CONFLICT WAS POSSIBLE AND THAT WAS CHECKED, NOT ASSUMED. `master` is a
direct ancestor of `release/1.0` (`git merge-base --is-ancestor` true), the
merge base IS `5184a2c`, and `origin/release/1.0..origin/master` is empty -
`master` carries nothing `release/1.0` lacks. `--no-ff` is used anyway, so the
crossing from 0.2.1 to 0.10.0 is one addressable commit rather than a pointer
move.

WHAT CROSSES: config.yaml 0.2.1 -> 0.10.0. The add-on the Supervisor will build
is a different program from v0.2.1 in the ways `goodwe_controller/CHANGELOG.md`
sections 0.3.0 through 0.10.0 describe - among them P1 meter ingestion, the
separate integrator the velocity form did not have, shadow mode, the tariff
plan and the multi-inverter roster. The ROLLBACK POINT does not move: tag
`v0.2.1` still names `5184a2c` and still names the tree that ran the house.

NOT A DEPLOY. Nothing is installed, no `ha addons update` is run, `/addons/` is
untouched and neither 192.168.2.158 nor 192.168.2.221 was contacted. Adding the
repository to Home Assistant, and pressing Install, remain the owner's call.

Verified before merging: the full suite in the CI image on `release/1.0`'s tree
- `cp -a`, purged, `:ro` mount, one file at a time, exactly the command in
.gitea/workflows/checks.yml - 30/30, exit 0; and PR #103's own
`checks / unit (pull_request)` was a real `success` on the merge, not a skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-20 12:34:30 +02:00
admin 8a5736136a PKG-15: fold TEL-10 and SAFETY-48 into 0.10.0, so the version names its contents (#103)
Reviewed and merged by the PKG-15 review agent.

Verified rather than accepted:
- test_version_gate.py 11d pins `## Unreleased` blocks below the newest section at exactly one; deleting the block goes red (reproduced on a copy of the tree).
- 32 runtime commits since 86bdf49 and 24 accounted by the contents table, re-derived with predeploy_check.py's own RUNTIME rule; PKG-14's superseded 28/21 were correct when taken.
- app/validate.py imports neither main nor mqtt (read by AST), and SLEW_RAIL_FRAC / LOOP_TICK_S / PLANT_SETTLE_S are byte-identical across cb3f686..HEAD, so PKG-14's CLEARED transcript still holds.
- CI-9 (16b1d6c) touches only .gitea/workflows/checks.yml, outside the built image and outside RUNTIME.
- The SAFETY-48 entry keeps the capacity-tariff misreading out: no worst-quarter row in the A/B table, and the 3739 -> 3434 W figure appears only inside the paragraph that disclaims it.

Three non-blocking defects posted on the PR: the gain:0.3 / slew-rail tie values are swapped against DECISIONS/SAFETY-48; "touch three files between them" should say three runtime files; and the stated reason cb3f686 enters the count ("last..HEAD excluded it") is wrong, though the count is right.
2026-09-19 19:08:05 +02:00
adminandClaude Opus 5 5aba220046 PKG-15: fold TEL-10 and SAFETY-48 into 0.10.0, so the version names its contents
checks / unit (pull_request) Successful in 7m53s
0.10.0 was cut by PKG-14 on 2026-09-16 and three tickets have merged into
release/1.0 since. Two of them change the built image - one is a CONTROL LAW
change and one adds three MQTT entities - and neither is described anywhere in
this file. Tagging that tree today would stamp a number on contents the
changelog does not account for.

FOLDED RATHER THAN CUT AS 0.11.0, AND THE ARGUMENT IS CONDITIONAL.
0.10.0 has never been built or pulled anywhere: the reference install is on
0.7.0 (PKG-14's own predeploy run records `deployed on the box : 0.7.0`), the
repository carries exactly one tag, `v0.2.1`, and no release. So the string
`0.10.0` does not yet name a tree anyone is running, and the PKG-06/PKG-07
no-op that forbade re-cutting `0.9.0` does not reach it. Cutting 0.11.0 would
burn a number on a release that never left the repository. The condition is
checkable and the section now says so: run against a box already reporting
0.10.0, `tools/predeploy_check.py` refuses with THE DEPLOY WOULD BE A NO-OP and
names the runtime commits since the bump. `config.yaml` stays at 0.10.0, so
`test_version_gate.py` 11b still finds the two sides equal.

SAFETY-48 IS THE ENTRY THAT MATTERED MOST TO GET RIGHT, AND WHAT IT PROMISES
IS LESS EXPORT - NOT A LOWER BILL. The measured result is export 39.61 -> 34.07
kWh over the committed 16-day replay, 14 % of the replayed figure, falling at
every plant and tuning variant swept (~4 % to ~16 %, median near an eighth).
The section says in its own heading and its first line that the control law
changed, and it says PLAINLY that the billed quarter is not a claim: at every
plant lag <= 5.0 s - including the one that best reproduces the recorded export
- release/1.0's worst quarter is ALREADY 3434 W and this changes it by exactly
zero. The 3739 -> 3434 W headline exists at the single longest fitted lag, and
the lag is not identified by the recording. The costs are in the entry too: the
cap moves a saturation-frozen integrator on 1095 of 79 160 frozen ticks, 679 of
them outward; SAFETY-13's settle values moved; SAFETY-39's "the slew does not
own the over-absorption" is now false.

TEL-10 gets the three site rows, what each one is, and the two things a reader
would otherwise get wrong: the lead is NOT a subtraction of two published rows,
and `Loop reason` is not the `Why` row. It repeats neither of the two sentences
TEL-10's own review retracted - EIGHT messages stay first and the ninth,
`goodwe_ctl/availability`, is byte-identical but deliberately moves to last.

CI-9 IS DELIBERATELY NOT IN THIS FILE, AND THE OMISSION IS WRITTEN DOWN RATHER
THAN SILENT. It changes one file, `.gitea/workflows/checks.yml`, outside
`goodwe_controller/` entirely: not in the built image, not in `is_runtime()`'s
list, and not observable on any version of the add-on. This file is the release
note the Supervisor shows beside the version number, and a row here has to be
something the person deciding whether to press Update could act on. The note
above SAFETY-48 says so and says the rule is about audience, not importance.

THE STRAY `## Unreleased` AT LINE 1777 IS NOT TOUCHED, AND THAT IS A DECISION.
It is the 0.8.0-era block PKG-14 annotated: every row in it has shipped, the
annotation says which release each went to, and re-filing it is PKG-08's card.
It is also load-bearing - `test_version_gate.py` 11d pins the count of
`## Unreleased` blocks below the newest section at EXACTLY one, so deleting it
is red on the commit that does it, and so is adding a second.

TWO MEASURED FIGURES IN PKG-14'S PROSE WENT STALE AND ARE RE-DERIVED, NOT
ADJUSTED. The runtime-commit count since `86bdf49` is now 32, not 28, of which
the contents table accounts for 24, not 21; both re-run through
`predeploy_check.is_runtime()` itself. The other eight are the three misfiled
tickets (SAFETY-43 x2, SAFETY-44 x3, TEST-21 x2) and `cb3f686`, PKG-14's own
bump, which edits `config.yaml` and so counts as runtime - it was HEAD when
PKG-14 measured and is inside the range now.

PKG-14's `CLEARED, exit 0` predeploy transcript was NOT re-run and now carries
the check that says why it still holds: `config.yaml` is untouched by both
tickets; `app/validate.py` imports neither `main` nor `mqtt`, and the three
names it takes from `control` - SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S -
are byte-identical across cb3f686..HEAD.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 18:49:09 +02:00
glenn schrooyenandClaude Opus 5 33e17dba0e CI-9 follow-up: the summary said "mechanically", the blind spots say otherwise
checks / unit (pull_request) Successful in 7m57s
The residual-risk paragraph at the top of checks.yml claims the `guard the
trigger` step "keeps it that way mechanically rather than by asking the next
editor to read this comment". For blind spots 1 and 3 that is not true: the
guard lives inside the workflow a filter makes Gitea skip, so for those cases
the defence IS the comment.

That paragraph is the preamble someone reads at the exact moment they are
editing the trigger, so a summary running ahead of its detail is worse here
than elsewhere. It now points at the blind spots and states spot 3 plainly:
a filter naming a branch nothing targets is itself skipped, lands green and
untested, and then skips every subsequent pull request - the gate off for
everything, silently.

Flagged by the reviewer on PR #95 (issuecomment-1424) as non-blocking.

This PR is also the first real test of CI-9's fix on release/1.0 itself. Run
806 was a workflow_dispatch and posts no commit status at all, so it verified
content but never the gate. This one has to post `checks / unit
(pull_request)` as success rather than skipped.

Verified before push: the guard passes against this edited file (whose new
prose quotes `branches: [main]` and `master`) and still refuses a genuine
filter added to the `on:` block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 15:53:01 +02:00
admin 16b1d6c422 CI-9: a stacked PR is skipped, and a skipped check clears the merge gate (#95) 2026-09-19 14:55:12 +02:00
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
adminandClaude Opus 5 cb3f686555 PKG-14: cut 0.10.0, and say what an upgrade from 0.7.0 crosses
checks / unit (pull_request) Successful in 7m51s
`config.yaml` 0.9.0 -> 0.10.0, and the top `## Unreleased` block is closed
into `## 0.10.0` with a preamble that names the backward-incompatible
changes and who they reach.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

TWO DEFECTS FOUND BY THE DISCIPLINE RATHER THAN BY REVIEW. The agent's own harness restored two edits to one file in list order and left one on disk - caught by the post-revert fingerprint, habit 18 biting a tool written the same afternoon. And test_peakguard.py part 1 formatted floor_w with a bare :.0f one line after the check that reverting the 2026-08-20 rung turns red, so the file ABORTED and parts 2-13 went ungraded: that mutation's coverage was one check and is now eight.
2026-09-14 16:20:39 +02:00
glenn schrooyenandClaude Opus 5 e1b9254626 SAFETY-45: a price-driven charge is SIZED by the capacity guard, not switched by it
checks / unit (pull_request) Successful in 7m42s
With arbitrage armed at the shipped defaults, arbitrage and the capacity-tariff
guard drove each other in a sustained limit cycle. Re-measured on TEST-23 stage
3's closed rig against 478ba93, metrics recomputed rather than taken from the
card: `peak_cap_w` 1500 / 1200 W house -> 28 charge onsets an hour, mean period
131.1 s, the grid swinging -455.5 to +3200.0 W; at the SHIPPED
`peak_cap_w: 3500` with an 1800 W house -> 8/h, with a 3000 W house -> 32/h.
The bite rule is `load + max_w > peak_cap_w`, i.e. any house over 1500 W in the
cheapest quarter. §10.2: "an EMS that oscillates is worse than no EMS, both for
hardware wear and - per §9.1 - for the peak it is trying to avoid." And it was
losing the argument it was having: EVERY billed quarter in the three affected
configurations finished OVER the cap.

⚠️ THE CARD'S STATED MECHANISM IS WRONG AND THE CORRECTION IS ON THE RECORD.
The card, and .cache/s45.md before it, say the loop is the quarter boundary
resetting the projection and arbitrage re-arming at full power. It is not. Of
the 27 gaps between the 28 onsets, 23 are 90-120 s (mean 111.5 s) and lie
ENTIRELY INSIDE one quarter; the four long gaps - 230, 240, 250, 255 s - are
exactly the four that SPAN a boundary, so the boundary LENGTHENS the interval
rather than causing an onset. The real cycle, off the recorded fields: the trade
charges at max_w while `wanted = batt_w + excess` is negative and the guard
files NOTHING; `wanted` crosses zero; the guard engages on a floor of 5.3 W;
that flips `arbitrage.guard_state()`, which cancels the WHOLE trade; the
grid-following loop - which targets grid = 0, not grid = ceiling - takes the
actuator and discharges to +1870 W, exporting 420 W; the projection collapses;
the guard releases; the trade re-arms at max_w. A binary interlock keyed on a
continuous quantity. A fix aimed at the boundary would have removed four of
twenty-eight onsets.

THE FIX IS A SHAPE THIS ADD-ON ALREADY HAD. `control.maintenance_charge_floor()`
caps a MAINTENANCE charge at "the headroom left under the site's cap", because
"a maintenance charge is the only thing this system does that is big enough and
long enough to set that peak". ⚠️ That sentence stopped being true when OPT-04
landed. `peakguard.charge_headroom_w()` publishes the same quantity off the
MEASURED guard rather than off the optional `peak_forecast_entity` - the hole
SAFETY-25 had to close in `shaping_suspended()` - and `arbitrage.decide()` sizes
its BUY leg to it, keyword-only and with no default.

NO NEW ARITHMETIC AND NO NEW CONSTANT. The number is `wanted_w` read in the
other direction: `decide()` already computes the DISCHARGE that lands the
quarter on the ceiling, and a negative one is a permitted CHARGE of `-wanted`.
⚠️ `peakguard.decide()` IS UNCHANGED - sha256(decide) = 200090d0835a3a55, 7252 B
on this branch and on 478ba93 - so 2026-08-20's anti-bang-bang rung is exactly
where it was, and tools/replay_ac3.py reproduces 190/190 guarded quarters, 110
engaged cycles, 5 blind and 878 Wh on both trees.

TWO PROPERTIES MAKE IT SAFE TO HAND A STRATEGY, BOTH SWEPT RATHER THAN ARGUED.
It is INVARIANT IN THE BATTERY - raise the charge by dW, the meter rises by dW,
so `excess` rises by dW while `batt_w` falls by dW and `wanted` does not move:
1350.0 W at every point across batt_w -5000..+2000 W on one banked quarter,
while the `excess` those runs see takes six distinct values. It is feed-forward,
so consuming it adds no feedback path around the plant. And HOLDING IMPLIES ZERO
OR UNKNOWN: across 1080 swept combinations not one ENGAGED decision answers a
positive headroom.

AFTER, same rig, same command. 1500/1200: 28 onsets -> 1, grid swing 3655.5 ->
282.0 W, energy bought 180.0 -> 285.2 Wh (+58%, because a continuous 300 W trade
beats a 2000 W trade cancelled for 57% of the hour), billed quarters
1490/1497/1509 -> 1500.0/1500.0/1500.0. 3500/1800: 8 -> 1, 3511/3536/3559 ->
3500/3500/3500. 3500/3000: 32 -> 1, 3519/3542/3557 -> 3500/3500/3500. The two
shipped-default rows buy 2.2% and 1.6% LESS energy and in exchange the worst
quarter goes from 59 W over the cap to zero - which is the ordering §10.1 asks
for, since the capacity tariff bills the month's single worst quarter.
⚠️ AND THE NEGATIVE CONTROL: at peak_cap_w 5000 with a 1200 W house, where
`load + max_w` never reaches the cap, the two runs are BIT-IDENTICAL.

⚠️ A NEGATIVE `lo` WAS CONSIDERED AND REJECTED, AND THE REASON IS IN THE CODE.
Deleting `decide()`'s `wanted <= 0` gate and filing `lo = wanted` is the tidier
answer arithmetically. It creates a new `lo > hi` CONTRADICTION against
main.py's cheap-window maintenance shaping, which files a NEGATIVE `hi`;
arbiter.py clause 2 has no notion of one limit out-ranking another, so it
resolves to 0 W - the battery PARKED in the quarter that sets the bill - and the
only way to close that suspends the maintenance charge shaping PERMANENTLY on
every site this card is about. A backoff or cooldown in arbitrage was rejected
too: both modules' design notes say no timer is invented, and the trade now
re-arms at `ceiling - load` rather than at max_w, which is what learning from a
refusal means when the refusal is a measurement.

THE REGRESSION IS THE LOOP SETTLING, NOT A UNIT TEST ON decide().
test_arbitrage.py 3c closes the loop over the shipped guard, strategy, arbiter,
control law and QuarterAverager with a modelled plant, and asserts BOTH
directions: unsized it must run away (24 onsets, -414..+3200 W, a quarter over
the cap) and sized it must settle (1 onset, 303 W swing, three quarters at
exactly 1500.0 W). The two runs differ in one argument. A third pair asserts the
card is INERT where the defect is inert.

Evidence: 29/29 green. 14 mutations, every one RED on a named check, no
survivors; two are ZERO BYTES - `min()` -> `max()` on the sizing, and the
sized-trade sentence's two operands transposed - and both are red. M13 reverts
the 2026-08-20 rung and is red on EIGHT named checks in test_peakguard and three
in test_arbitrage. Every revert verified by a tree fingerprint AFTER the revert,
which is how a defect in the battery's own harness was found: two edits to one
file, restored in list order, left the first one on disk (MUTATION.md habit 18).
test_peakguard.py part 1 also gained a None-safe format, because the mutation it
exists to catch made it raise TypeError one line later and ABORT the file -
parts 2-13 were ungraded and M13's recorded coverage was one check instead of
eight.

Not fixed, and named: TEST-23 finding 1's export overshoot at the END of a trade
(356-938 W, ~0.37 x max_w) is a different card; a house whose load ALONE exceeds
the cap still has the trade refused outright and the loop drive to grid = 0
rather than to the ceiling, and no swept pair reaches that regime; the container
leg (hadrive.py) was not re-run because test23s3-addon is deliberately stopped
as a second writer on the setpoint entity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-14 16:10:34 +02:00
admin 478ba93710 Merge pull request 'TARIFF-16: the injection leg gets its own coefficients' (#87) from TARIFF-16 into release/1.0
export_price_eur_kwh() modelled injection as one scalar. Eneco publishes 0,1 x BELPEX-H - 1,188 on the same card as its consumption formula, identical on the January and September 2026 cards; ENGIE publishes (B x eSpot_15) - A; Aspiravi uses 0,07 against ~0,10 on its own consumption leg. Reproduced against the shipped readers over one real BELPEX day: the published formula gives 24 distinct prices with a spread of 0.16710 EUR/kWh, the scalar gives 0.00000, and 5 of 24 hours are NEGATIVE - down to -0.03008 EUR/kWh - which are the midday hours a PV household exports in. The 6% VAT asymmetry is not wrong in the scalar, it is unstateable.

THE CARD'S BEFORE-STATE WAS WRONG AND THE AGENT CORRECTED IT. I wrote that this affects what the operator is shown. It did not: export_price_eur_kwh() had ZERO callers under app/ - not arbitrage, not the status page, not MQTT. Verified independently before merge; the only references are its own definition, the defaults entry, FRAMEWORK_KEYS and two comments. So the honest before-state is weaker than the card claimed: the scalar was not a misleading display, it was an UNREAD OPTION. DOCS, CHANGELOG and the startup line say so, and a check grades it. An operator actually seeing their injection price is a separate ticket.

Machinery reused rather than copied, which is TARIFF-17's move applied a second time: parse_export_formula() is parse_price_formula() with ONE argument moved, selecting a row of a _LEGS table, so everything that differs between the legs is a string and the units table, NaN rule, closed enumerations, unknown-key refusal, card comparison and DST handling are one implementation. Pinned by a check that parses every file under app/ and asserts all 32 grammar names are defined in exactly one module, plus an AST pin on the wrapper's body, plus twelve refusal sentences DERIVED from the consumption leg's own.

A NEGATIVE INJECTION PRICE IS ACCEPTED AND AN IMPLAUSIBLE ONE IS STILL REFUSED, and the bound had to move to achieve it: implausible_all_in() floors consumption at +0.02 EUR/kWh of energy, which is fatal on a leg that is legitimately negative. The bound is now off the PRICE and onto the COEFFICIENTS, where it is index-free and sign-free - a pass-through band (0.10-10x; Eneco 1.00x, Aspiravi 0.70x) and an offset-magnitude bound (0.20 against published 0.01188 and 0.02). One spec is put through both bands and the answers printed: the consumption band refuses -0.03008, the injection band accepts it.

The missing printed estimate is stated, not worked around. Eneco's injection line has no worked example beside it, so `card` is optional on this leg and unchanged on the other; where an estimate exists the self-check runs at the same tolerance, and where it does not validate.py logs that the coefficients are UNGRADED and names the two bounds that ran instead. A check MEASURES the resulting gap: a factor of 0.070 where the contract says 0.100 - 30% of every export payment - passes both bounds, because 0.70x is a pass-through somebody really publishes.

The scalar path is genuinely unchanged: the function body is pinned on the AST and seven option dicts are compared against the pre-TARIFF-16 expression with ==.

Evidence: 29/29 green, CI green. Twenty mutations, every one red on a named check. Giving the injection band TARIFF-15's floor is red in test_price.py and GREEN IN THE OTHER 28 FILES; so are the second-grammar and ignore-the-formula mutants. One is a single byte, one is ZERO bytes and moves no decision - legs transposed in the VAT banner, caught by an ordered-pair check. One is recorded for its SHAPE rather than its result: rerouting the scalar through the seam recurses, so the suite dies naming nothing, and a second mutation of the same property exists so a check can name it.

Two sharp edges flagged rather than smoothed: `index` and `resolution` on the injection leg name a series nobody fetches, which an operator could misread as a promise to fetch it; and the coefficient bands are materially weaker than a card check, catching the factor-of-10 class and nothing else.
2026-09-13 18:51:25 +02:00
glenn schrooyenandClaude Opus 5 995da777ce TARIFF-16: the injection leg stops being one number
checks / unit (pull_request) Successful in 7m42s
`price.py` modelled what a site is PAID for export as ONE scalar. The market
publishes a line, and the cards say so in their own words: Eneco Zon & Wind
Dynamisch `0,1 x BELPEX-H - 1,188` c EUR/kWh - "het injectietarief is dynamisch
en kan per uur wijzigen", identical on the January and the September 2026 cards;
ENGIE Dynamic `(B x eSpot_15) - A`; Aspiravi `0,07 x Belpex - 2`.
`price_export_formula` carries that line with its OWN coefficients.

⚠️ THIS IS NOT A WRONG-TRADE FIX, AND SAYING SO IS PART OF SHIPPING IT.
`arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment
says so, citing §9.3's near-zero Belgian injection price as the reason the
economically sound trade on this market is charge-cheap-to-displace-expensive-
IMPORT. Both hours it compares are all-in CONSUMPTION prices. No trade this
add-on takes today is decided by an injection price and nothing here changes
one. What it changes is what an operator is told, and what a future
export-aware strategy rests on: OPT-06 and any export logic would otherwise
inherit a scalar that cannot represent the market, and the 6% VAT asymmetry
would be invisible in the arithmetic. test_price.py 17h parses every module
under app/ and goes red the day something calls an injection reader, so that
sentence is graded rather than attested.

FOUR PROPERTIES A SCALAR CANNOT HOLD. Its own factor (Eneco 0,102 consumption
against 0,100 injection; Aspiravi 0,07 against ~0,10 - not the consumption B
with a sign flip). A negative offset, which `price_markup_eur_kwh: float(0,1)`
cannot express at all. VAT-exemption - "injectietarieven zijn vrijgesteld van
btw" - against 6% on consumption, representable only because `vat_pct` is PER
SPEC. And a price that goes below zero with no floor, under 11,88 EUR/MWh of
BELPEX-H, which is the middle of a sunny day.

⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, AND THAT NEEDED A DIFFERENT BAND
RATHER THAN A LOWER FLOOR. `implausible_all_in()` floors the consumption leg at
+0.02 EUR/kWh of energy; applied to injection that refuses the real market in
the hours it matters most. Measured rather than argued: 17c puts ONE spec
through BOTH bands and prints that the consumption band refuses -0.03008
EUR/kWh while the injection band accepts it. The floor was not decoration - it
is what catches a card read a hundred times too small, the one unit error the
card self-check cannot see because it cancels out of a comparison between the
two sides - so the bound moves OFF the price and ONTO the coefficients, where
it is index-free and sign-free: a pass-through band (Eneco's injection leg is
1.00x, Aspiravi's 0.70x; the band is 0.10-10x) and an offset magnitude bound
(0.20 EUR/kWh against published 0.01188 and 0.02). Two rungs that can both
answer a hundredfold error, so each is graded on its REASON, each gets a
stimulus the other cannot reach with the precondition asserted as its own
check, and they are mutated TOGETHER (TEST-16).

⚠️ THE CARD SELF-CHECK APPLIES WHERE THERE IS A CARD, AND WHERE THERE IS NOT
THAT IS STATED RATHER THAN WORKED AROUND. `price_formula` can require a `card`
block because a Belgian card prints the consumption formula, the reference
index AND the resulting price. Eneco's INJECTION formula is printed with no
price beside it. Requiring a block would make the published line inexpressible;
inventing the estimate would grade the coefficients against arithmetic done
with the same coefficients. So the block is OPTIONAL on this leg and unchanged
on the other, the self-check runs at the same tolerance wherever a printed
estimate exists, and validate.py says at startup - "its coefficients are
UNGRADED" - when it does not, naming the two bounds that ran instead. This is
TARIFF-15 demonstrating two tariffs end to end rather than three, one card
later. 17e also MEASURES the size of that gap: a factor of 0.070 where the
contract says 0.100 - 30% of every export payment - passes both bounds, because
0.70x is a pass-through somebody really publishes.

ONE PARSER, NOT TWO. `parse_export_formula()` is `parse_price_formula()` with
its OPTION NAME as an argument - TARIFF-17's move on `parse_static_profile()`,
exactly - and everything that differs between the legs is a string in one
`_LEGS` table. A second copy would be a second unit table, a second NaN rule, a
second unknown-key rule and a second `to_resolution()`, diverging first on the
23- and 25-hour days. test_price.py 17b pins it two ways: all 32 grammar names
are defined in exactly ONE module under app/, with the scope COMPUTED by
parsing every file rather than listed; and twelve refusal sentences for the new
option are DERIVED from the old option's own sentence rather than transcribed.
The four sentences that deliberately differ are named, with what differs, and
asserted to differ.

NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "";
`export_price_eur_kwh()`'s body is byte-identical, pinned on the AST rather
than on the source text; `export_price_at()` falls through to it for every
index value. 17f compares seven option dicts x five index values against the
pre-TARIFF-16 expression with `==`.

`price_export_formula` joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived
from that set, and `code_leaks()` refuses a plugin that names it with nothing
else edited.

ALSO CORRECTED HERE: config.yaml's comment beside `export_price_eur_kwh` still
read "no shipped supplier publishes a dynamic injection curve". TARIFF-07
struck that claim from app/price.py and DOCS.md and did not reach config.yaml,
where it has been shipping since.

20 mutations, every one red on a named check, ledger in test_price.py below
section 17 - including a ZERO-byte one (the two legs transposed in the
VAT-asymmetry banner: no decision moves, only the sentence) and a ONE-byte one
(`card_required=True` on the injection leg). Three run over all 29 files and
red on test_price.py alone. 29/29 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 18:41:06 +02:00
admin 4dcd9b0fc6 Merge pull request 'TARIFF-18: the taxes default is half the real Flemish figure' (#86) from TARIFF-18 into release/1.0
price_taxes_eur_kwh shipped at 0.06 against a real Flemish 0.11736 - Fluvius Antwerpen 5,35 + bijzondere accijns 4,876 + groene stroom/WKK 1,51 + energiebijdrage 0, incl. 6% VAT, from the Eneco residential card for September 2026. Across the eight Fluvius DSOs the range is 0.117-0.131, so the shipped default was roughly half. The components ship as rows in the code, and the exact unrounded sum is used so the check asserts an EQUALITY against them rather than a tolerance. The energiebijdrage is a ZERO ROW, not an absence - it went to 0 EUR/MWh on 2026-08-01 and was merged into the excise, so anyone hand-computing this from an older guide double-counts it; deleting the row changes no sum, so its PRESENCE is graded separately.

AN UPGRADE HAZARD THE TICKET DID NOT NAME, found and closed. App.options is a deepmerge of config.yaml's defaults over the stored options, so the moment the default moves, every site STORING 0.06 becomes non-default with nobody having edited anything. Combined with TARIFF-17's new rung - a taxes period table beside a non-default scalar is a startup refusal - a working Walloon or Brussels site with a table would have hit SystemExit(78) on the upgrade, over an inert number the table overrides anyway. Closed with SUPERSEDED_TAXES_DEFAULTS_EUR_KWH: validate.py reads a superseded default as untouched too. The rung is NOT weakened - a genuinely custom 0.1198 beside a table is still refused by name, and the refusal now names both figures.

AND THE TICKET'S FRAMING WAS WRONG. I wrote that a site which took the default silently gets a different number. That is not what the deepmerge does: an existing install keeps its stored 0.06 and sees nothing change. The new default reaches a FRESH install, and an existing one only when its options are re-saved. Said plainly in the CHANGELOG, flagged as not re-measured on this branch (the 70-of-71-keys figure is 0.9.0's), with predeploy_check.py --options named as what would confirm it.

THE FIGURE IS THE BOTTOM OF THE BAND, DELIBERATELY, and the argument is the agent's rather than the ticket's: the error directions are not symmetric. Set too high, a genuinely negative hour reads positive and the add-on exports into paid disposal - section 17's own regression case. Set too low, it only declines a trade that would have paid. One default standing in for eight DSOs belongs at the bottom, and Antwerpen's 0.11736 is a real card row there rather than a synthesised minimum.

A CHECK HAD TO BE REWRITTEN RATHER THAN RE-POINTED. test_price.py section 0 justified its own constants by '0.06 is exactly twice 0.03'. This card ends that coincidence, so the sentence is false and is gone; it now asserts per figure that neither shipped default equals the fixture, which is stronger than the tuple compare it replaced. Recorded rather than quietly dropped, along with three other now-false claims the change created in test_price.py's docstring, MUTATION.md and DECISIONS/TARIFF-15 - which had predicted this card and is annotated as shipped.

Evidence: baseline 29/29 before any edit, 29/29 on the committed tree. Ten mutations, all red, every revert sha256-verified and every grade taken in a container with the tree :ro so no .pyc can exist. Two are zero-byte. The interesting one moves the component rows AND the default AND config.yaml together into a coherent West-Vlaanderen card - caught only by the literal typed in the test file and the band check, which is why the sum is graded from two independent sides. What the suite cannot see is stated in the PR: it grades the default against the card as a number, never the card against Belgium.
2026-09-13 16:33:37 +02:00
124 changed files with 17889 additions and 313 deletions
+269 -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,116 @@
# 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.
#
# ⚠️ READ THE BLIND SPOTS ON THAT STEP BEFORE TRUSTING THE SENTENCE ABOVE.
# "Mechanically" holds only for a reintroduced filter that still names the
# PR's own base. For blind spots 1 and 3 the defence IS this comment, because
# the guard lives inside the workflow that a filter makes Gitea skip. Blind
# spot 3 is the one that matters: a filter naming a branch THIS pull request
# does not target is itself skipped, lands green and untested, and then every
# subsequent pull request the filter DOES NOT NAME is skipped too. That is the
# gate off, silently.
#
# ⚠️ THAT DIRECTION IS THE WHOLE POINT AND IT IS EASY TO WRITE BACKWARDS. A
# pull request that MATCHES the filter still runs; it is the one the filter
# excludes that Gitea sits out, reports `skipped`, and the rollup launders
# into `success`. An earlier draft of this very clause said "matching it is
# skipped", which is the inverse of the measurement and contradicted the
# paragraph directly below.
#
# ⚠️ AND THE DANGEROUS ONE IS NOT `branches: [main]`, WHICH AT LEAST LOOKS
# WRONG. It is `branches: [release/1.0]`, which looks like tightening onto the
# protected branch and is what a careful person would write. Every day-to-day
# pull request still runs and still goes green, so nothing appears broken -
# and the only thing switched off is the `release/1.0` -> `master` release
# PR, i.e. the one whose content reaches an installed system. SEC-2 made that
# PR the mandated release path (docs/RELEASE.md), so this is now a live route
# rather than a hypothetical one.
#
# Closing it needs a SECOND permanently unfiltered workflow posting its own
# context, with that context added to the `status_check_contexts` of BOTH
# `release/1.0` AND `master` - both carry the rule since SEC-2, and adding it
# to one alone leaves the hole open on the other. A forge change, with the
# owner.
#
# 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 +354,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
+6 -1
View File
@@ -4,6 +4,10 @@ The guide technicians carry is the PDF:
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
⚠️ **Outdated: it is v0.1.0.** Its `slew_w` row is corrected in `build_guide.py`
and `field-guide.html`, but the PDF has not been rebuilt (see below). Where the
guide disagrees with `goodwe_controller/DOCS.md`, DOCS.md wins.
It covers, in the order the work is done: pre-visit checks, hardware, firmware,
add-on install and configuration, how to read the Web UI, the six commissioning
gates, the e-stop (and the statement a site without one must sign), proving a
@@ -18,7 +22,8 @@ copy cannot drift from the PDF someone is holding in a cellar.
python3 docs/build_guide.py # regenerates field-guide.html and the PDF
```
Requires Chrome or Chromium (used headless for print-to-PDF). Screenshots live
Requires Chrome or Chromium (used headless for print-to-PDF); `CHROME` in the
script is a Windows path, so edit it on any other machine. Screenshots live
in `docs/img/` and are base64-embedded, so `field-guide.html` is standalone.
⚠️ **Every section must stay under the A4 printable height** (1039 px at 96 dpi
+5 -3
View File
@@ -30,9 +30,11 @@ goodwe_controller/ the add-on
returns, run against its own release image
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; outdated)
docs/build_guide.py its single source - rebuilds the HTML and PDF
FIELD-GUIDE.md how to edit and rebuild the guide
docs/RELEASE.md how a change reaches the battery - `master` is
protected, releases go via PR from `release/1.0`
```
**Picking this up after a break? Read `HANDOVER.md` first** — current state,
@@ -77,8 +79,8 @@ machine itself dying:
2. **Wind-down before firmware updates** — 0 W written before the update starts.
3. **RS485 e-stop (optional)** — writes 0 W after 30 s of total bus silence.
Sites sold without the e-stop must have the acknowledgement in `FIELD-GUIDE.md`
§10 signed.
Sites sold without the e-stop must have the client acknowledgement in the field
guide (Step 7b) signed.
## Development
+116
View File
@@ -0,0 +1,116 @@
# Releasing — how a change reaches the battery
`master` is the **default branch**, and the default branch is what the Home
Assistant Supervisor clones when it installs or updates this add-on. Whatever
sits on `master` is what runs on a real 5 kW inverter. Treat a push to `master`
as a deployment, because that is what it is.
---
## 1. `master` is protected — releases go through a pull request
Until 2026-09-23 `master` had **no protection of any kind**. Every gate this
repo built (CI-01, CI-9) sat on `release/1.0`, the branch that does *not* ship.
On 2026-09-22 version 0.10.1 went straight onto `master` — no pull request, no
CI run, no reviewer — and it succeeded (commit `0f14c2a`, the only commit on
`master` that carries no `(#NN)` merge suffix). SEC-2 closed that.
**The release path is now:**
1. Merge the work into `release/1.0` as usual, via a pull request that goes
green. Bump `version:` in `goodwe_controller/config.yaml` on that branch —
the Supervisor keys the built image by `version:`, so without a bump nothing
is offered and nothing changes.
2. Open a pull request **`release/1.0` → `master`**. Do not push to `master`;
it will be refused by the pre-receive hook:
```
remote: error: Not allowed to push to protected branch master
! [remote rejected] ... (pre-receive hook declined)
```
3. Wait for `checks / unit (pull_request)` to go **green** on that pull request.
The workflow in `.gitea/workflows/checks.yml` carries **no `branches:`
filter**, so a `master` base matches and a real run happens — verified on
throwaway PR #107 (base `master`), which queued a genuine run.
⚠️ Green means green. A `skipped` status is laundered into a `success`
rollup by this Gitea and the merge gate accepts it — see the long note at
the top of `.gitea/workflows/checks.yml`. Never add an event filter there.
4. Merge the pull request. `required_approvals` is **0**, so a green check is
the whole gate; there is no second pair of eyes unless someone asks for one.
5. **Reload the store.** Merging to `master` does not, by itself, offer the
update. The Supervisor only sees the new version after:
```bash
ssh root@192.168.2.158 "ha store reload && ha addons update local_goodwe_controller"
```
Until `ha store reload` runs, the add-on page still shows the old version
and the release looks like it did nothing.
Note that `release/1.0` is normally an *ancestor* of `master` between releases,
so a `release/1.0 → master` pull request is empty until new commits land on
`release/1.0`. That is expected; it means there is nothing to release yet.
---
## 2. Branch protection — before and after SEC-2
Forge is Gitea at `192.168.2.147:3000`, repo `admin/goodwe-addon`. Read it back
with `GET /api/v1/repos/admin/goodwe-addon/branch_protections`.
**Before (2026-09-23):**
| branch | protected | status check required | context | approvals |
|---|---|---|---|---|
| `master` (default) | **no rule at all** | — | — | — |
| `release/1.0` | yes | yes | `checks / unit (pull_request)` | 0 |
**After:**
| branch | protected | status check required | context | approvals |
|---|---|---|---|---|
| `master` (default) | yes | yes | `checks / unit (pull_request)` | 0 |
| `release/1.0` | yes (unchanged) | yes | `checks / unit (pull_request)` | 0 |
Both rules are otherwise identical: `enable_push: false`,
`enable_force_push: false`, `block_admin_merge_override: true`, no whitelists,
no file patterns. `required_approvals` stays at **0** on purpose — raising it
would block the review agent from merging, and nobody asked for it.
The default branch was **not** changed. Changing it would change what the
Supervisor clones, which is a different decision from this one.
### How the gate was proven to actually bite
A protection rule on this forge can *appear* satisfied while having checked
nothing, so the rule was tested rather than trusted. The discriminator is a
merge `POST` carrying a deliberately wrong `head_commit_id`: Gitea evaluates
branch protection *before* it checks the head, so
* `409 head out of date` → the request **cleared** protection;
* `405 Not all required status checks successful` → protection **refused** it.
Read the *message*, not just the code: `405` is also returned as
`Please try again later` for a pull request with **no commits**, which has
nothing to do with protection. An empty pull request on an unprotected base
gives that same `405` (verified, throwaway PR #110), so an empty
`release/1.0 → master` reports it too.
Validated against a control on an unprotected base (PR #106), which answered
`409` while its check was still pending — i.e. protection was no obstacle
there. On `master` (PR #107) the same probe answered `405` while pending, and
`409` once the check was genuinely green. A direct `git push` to `master` was
rejected by the pre-receive hook with `master`'s SHA unmoved.
---
## 3. Rolling back
There is no force-push escape hatch: `enable_force_push` is `false` on both
branches. To undo a bad release, land a revert on `release/1.0`, bump the
version again, and take it through §1. The `/addons/` rollback for the
reference install is in `HANDOVER.md`.
+2 -2
View File
@@ -95,7 +95,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="page" id="p1">
<div class="hdr">
<div><h1>GoodWe RS485 Controller</h1>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0</div></div>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
<small>Installer copy</small>
</div>
@@ -503,7 +503,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table>
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code>&nbsp;/&nbsp;9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
+2 -2
View File
@@ -60,7 +60,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="page" id="p1">
<div class="hdr">
<div><h1>GoodWe RS485 Controller</h1>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0</div></div>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
<small>Installer copy</small>
</div>
@@ -468,7 +468,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table>
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code>&nbsp;/&nbsp;9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,406 @@
# SAFETY-10 — shadow mode, and why "default-on" is not `true`
**Status:** decided and implemented on branch `SAFETY-10`.
⚠️ **AMENDED BY SAFETY-47, and three of the claims below were false when this
was merged.** §5's "the strategies are unaffected" exemption, §4's peak-guard
row and §6's evidence table are corrected in place and each correction is
marked. The amendments are marked rather than rewritten silently, because what
this card believed is part of why the defect shipped. See
`DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md`.
**Card:** SAFETY-10 — *"Shadow mode (default-on, log-only decisions)"*.
**Nothing here reached the inverter.** No deployment, no `ha addons update`, no
contact with 192.168.2.158 or 192.168.2.221. Everything below was measured in
the CI image and on TEST-23 stage 3's closed-loop rig.
---
## 0. The four things a reader should take away
1. **"Default-on" is delivered as `auto`, not as `true`, and the difference is
an install that is regulating a real battery today.** The Supervisor
composes an add-on's options as *its defaults, overlaid with the stored user
options*, so a new key with a new default reaches **every existing install**
on the next update. A boolean `shadow_mode: true` would silently stop the
reference site. §1.
2. **Shadow mode WRITES. It writes 0 W, on the ordinary heartbeat, through the
ordinary single writer.** "Commands nothing" implemented as "stops writing"
would leave the inverter latched on whatever was standing — it has no meter
timeout — and would put the ESP32 into a tripped stale-input watchdog for
the whole run. §2.
3. **The sign watchdogs are inert, and that was the decision that had to be got
right.** Fed the shadow decision they would trip within `sign_disagree_s`
and **latch a trip that survives into live operation and a restart**. §4.
4. **The loop leg's shadow decision is one slew step, not a trajectory, and
that is a real limitation of this feature.** §5.
5. ⚠️ **SAFETY-47: and neither is anything else that depends on the plant
answering back. A shadow run cannot surface a closed-loop defect at all**,
by the same construction that makes it safe — the battery is held at 0 W, so
every measured input describes a plant that did not respond. Of the three
defects §"WHAT DEFECT THIS PREVENTS" was written for, exactly one —
TARIFF-14's wrong quarter-hour — is visible from a shadow run. §5, §6.
---
## 1. The default
### The literal reading, and what it costs
`config.yaml`'s `options:` block **is** the default. There is exactly one of it,
and HA's Supervisor resolves an add-on's effective options as
```
{**config.yaml options, **the stored user options}
```
A key that has never been stored therefore takes the file's value — on a fresh
install and on an install that has been running for months, identically. There
is **no mechanism in the add-on options system** by which one key can default
differently for the two. So the card's title, taken at face value, is:
> the next `ha addons update` stops the battery at the reference site, and the
> only symptom is a battery that has stopped doing anything.
That is strictly worse than every defect shadow mode exists to catch. It is
refused.
### What was shipped instead
`shadow_mode` is a three-valued `list(auto|on|off)` defaulting to `"auto"`:
| value | resolves to |
|---|---|
| `off` | live |
| `on` | shadow, always |
| `auto` | shadow **while control is stopped**; live once it is started |
`auto` reads `Controller.auto` — the persisted start/stop state, which a fresh
install takes from `auto_start` and which the *Start / stop control* button
moves. No new persisted state, no migration, no guess:
* a **fresh** install ships `auto_start: false`, has no stored `auto` key, and
therefore comes up **in shadow mode**. It observes a day of decisions before
it is ever allowed to command anything. That is the card's intent, delivered
where it is safe;
* an install that is **controlling** is started, so `auto` resolves to live and
**not one watt changes**;
* an install that is **stopped** keeps its actuator at 0 W exactly as before —
the same number, from the same writer, on the same heartbeat. What changes is
what the status page and the action log **say**, and that the decision path
now runs and is recorded.
⚠️ **Under `auto`, pressing Start leaves shadow mode.** That is the point of
`auto`: the button means "control the battery". An operator who wants the
decisions recorded *while the loop runs* — the configuration this card exists
to make possible — sets `on` explicitly, and cannot reach it by accident.
⚠️ **An unrecognised value resolves to SHADOW, and is REFUSED at startup.**
`shadow.mode()` fails toward inaction; `validate.py` rule 11 refuses the value
by name so the reinterpretation is never silent. A typo is not a decision, and
an install silently left in shadow mode commands nothing indefinitely while its
status page calmly explains that this is intentional.
---
## 2. "Commands nothing" is a number, not a silence
The inverter holds its last command **forever** — `HANDOVER.md` §6 says so, and
`firmware/goodwe-master.yaml`'s header records what it cost when a controller
stopped refreshing: *"Illusmart went silent mid-command and the inverter held
+5000 W for 113 s"*. `firmware/goodwe-master.yaml` states the contract that follows — *"HA
must now REWRITE the setpoint every ~10 s even when the value has not
changed"* — because without a refresh, **"stale" and "steady" are the same
thing on the wire**.
So shadow mode holds the actuator at `shadow.WIRE_W` = **0.0 W**, refreshed on
the ordinary heartbeat. Two consequences were weighed and are recorded:
* **it is not silence.** A shadow mode that stopped writing would leave the
standing command up until the ESP32's 30 s watchdog noticed — and at a site
with no such board, forever;
* **it does not use an alarm as a mode.** Letting that watchdog trip for the
duration of a shadow run teaches the operator that `wd_tripped` is normal,
which is exactly the training that makes it useless when the add-on has
actually died.
### It is not a second writer
SAFETY-28's rule is one actuator, one writer. Shadow mode files **no claim**,
owns **no transport**, and calls nothing in `hass.py`. It is a **substitution**
inside `Controller._write_unit()` — the single choke point every write in this
add-on already reaches, including the heartbeat, the stop button, `shutdown()`
and `maint_abort()` — in exactly the form of the three substitutions above it
(non-finite → 0 W, unauthorised → 0 W, clamped → the rating).
It is the **last** of them. Below SAFETY-28's gate and below SAFETY-03's clamp,
so both still grade the number the decision path actually produced, and a
shadow run can report *"it would have hit the rating here"*.
---
## 3. What the run records
| surface | what it carries |
|---|---|
| status banner | `SHADOW MODE - the battery is NOT being controlled...`, **first in the cascade**, amber — red, with the latch named, if one is standing |
| status row `Shadow mode` | which rung decided, the watts it **would have commanded on this cycle**, where the inverter actually is, and `Resolution.explain()` — plus, when a measured billed quarter sized those watts, SAFETY-47's caveat saying so (`shadow.SIZED_FROM_METER`) |
| status row `Commanded` | `0 W` — the truth, which the row above reconciles with the `Why` row below it |
| action log | one line when shadow mode comes on; one line per **change of decision** (winner, rounded watts, or SAFETY-47's caveat state), carrying the arbiter's own sentence and the same caveat the row carries — `shadow.log_line()` is the one sentence both read |
| MQTT | `status: shadow`, its own value, below `failsafe` |
The action log is **edge**-logged, not level-logged: the log holds 40 rows, and
a line per cycle would flush the context of the run's own findings inside four
minutes at the 15 s cycle floor.
---
## 4. The failsafes, one by one
| failsafe | in shadow mode | why |
|---|---|---|
| SAFETY-02/30 sign watchdogs | **INERT** | They grade `Inverter.standing_w()` — what this controller has standing on the wire — and shadow mode holds that at 0 W, so they cannot arm. ⚠️ Fed the shadow decision instead they would disagree with a meter that never saw it and **latch a trip that survives into live operation and a restart**: shadow mode would manufacture the failsafe it exists to help debug. ⚠️ **Two rungs hold it there** — `last_write` (the wire gate) and `inv.target` (the re-seed) — which is MUTATION.md's masking shape, so the mutation that reproduces the hazard defeats both as a pair (M6). Driven singly, dropping the re-seed does not reproduce it at all — measured |
| SAFETY-03 clamp | **OBSERVES** | Runs on the decision, sets `clamp_last`, logs every engagement. Changes no command |
| SAFETY-28 write gate | **OBSERVES** | Same placement, same reason: the question is who chose the number |
| SAFETY-18 meter freeze | **OBSERVES**, unchanged | It takes no action live either; both halves of its conjunction are as true in shadow |
| SAFETY-07/24/25 peak guard | **OBSERVES, AND SEES ONLY THE HOUSE** | Level-triggered, unlatched, files its claim as usual, so the recorded decision is the real one *for this meter*. ⚠️ **AMENDED BY SAFETY-47.** This row used to end "SAFETY-45's limit cycle is visible from a shadow run, which is the point". It is not: every term of `wanted = batt_w + excess` is measured, and in shadow mode the battery contributes nothing to either. Measured on the rig, same hour, same unsized code — **410 of 720 cycles engaged live, 0 of 720 in shadow** |
| SAFETY-31 circulation | **OBSERVES AND MAY LATCH** | Its evidence is *measured* power. Packs pushing against each other while all are told 0 W is a real fault |
| SAFETY-42 delivery | **OBSERVES AND MAY LATCH** | Same, against a share that is 0 W |
| operator stop | **SUPPRESSED AS A CLAIM, ENFORCED AT THE WIRE** | The one deliberate change. Its `P_SAFETY` 0 W claim out-ranks everything, so leaving it in makes the recorded decision `"stopped"` on every cycle and the run answers the wrong question. The actuator ends at the same number either way, and the wire substitution is **strictly stronger**: a claim can be out-ranked, and it cannot |
| inputs missing, non-finite, allocator refusal, arbiter contradiction | **UNCHANGED** | All resolve to 0 W, which is what the wire gets anyway |
⚠️ **A latch that is already up stays up.** Nothing here clears one. The shadow
banner **names** any standing latch and goes red for it, because a latch that is
up while the controller is inert is up the moment it stops being inert.
⚠️ **Maintenance is not in that table because shadow mode does not touch it.**
The state machines are ticked on every cycle whether control is started or not,
so a stopped install already runs them against a battery that does not move.
Shadow mode inherits that, unchanged and unimproved. Recorded here rather than
quietly fixed under another card's name.
---
## 5. The limitation, stated rather than discovered later
`Controller._shadow_settle()` re-seeds the carried command (`self.target` and
each `inv.target`) to `WIRE_W` at the **end** of every shadow cycle — after
every in-cycle guard has read the decision. Three machines read that value as a
statement about the **hardware**: the loop's `prev_w` and integrator,
`standing_w()`, and SAFETY-42's share comparison. Re-seeded, they track reality,
and **the exit is safe by construction** — the carried command already equals
the standing one, so the first live cycle slews from 0 W like any other.
⚠️ **The cost is real.** The loop's `prev_w` is therefore 0 W on every shadow
cycle, so the loop leg's recorded decision is **one slew step from the standing
command** and stays there. A shadow run answers *"what would this controller
command NOW, from where the plant really is"* — a true statement about the
present — and **not** *"what trajectory would it have followed"*. The trajectory
is a closed-loop property, it did not happen, and reporting it would be a
simulation of a plant that does not exist; three cycles in, such a number
describes nothing.
⚠️ **AMENDED BY SAFETY-47 — THE PARAGRAPH THAT STOOD HERE WAS THE DEFECT.**
It read:
> The strategies are unaffected: arbitrage, the peak guard and maintenance all
> file `SET` claims at a fixed power from level-triggered inputs. That is why
> the three defects this card was written for — SAFETY-45's 28 onsets an hour,
> TARIFF-14's wrong quarter-hour, SAFETY-46's ceiling churn — are all visible
> from a shadow run, and the loop's ramp is not.
It was true until **SAFETY-45 merged, one commit before this card**. That
change makes the buy leg `min(asked, headroom)` with
```
headroom = (900 / remaining) x (ceiling - projected)
```
and `projected` reads the **open quarter's accumulator** — the meter — which is
a function of what the battery did earlier in that same quarter. The strategy
is closed-loop, and the loop's one-slew-step limitation above is not a special
case of anything: **it is the general case**, and the limitation is the whole
feature's, not the loop leg's.
**The correct statement, and it is now graded rather than asserted:**
> A shadow run cannot surface a **closed-loop** defect — one whose stimulus is
> the plant's response to the controller — because the battery is held at 0 W
> and there is no response to be the stimulus. **Which** hour a strategy picks
> and **why** are unaffected, because the clock and the price series are not
> functions of the battery. **How hard** it trades is not.
Of the three defects this card was written for:
| | visible from a shadow run? | measured |
|---|---|---|
| SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow; grid −455.5 … +3200.0 W live, **flat at the 1200 W house** in shadow; guard engaged 410/720 live, **0/720** in shadow |
| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. No SoC limit can be *reached* in one |
| TARIFF-14's wrong quarter-hour | **yes**, and it is the one | which hour, at what price, clearing what spread: identical live and shadow on **720 of 720** charging cycles, at a binding cap |
Graded in `test_arbitrage.py` part **3d** — the same closed loop part 3c uses,
run once live and once in shadow — and the loop leg's own limitation stays
named in `test_watchdog.py` part 10 as `RECORDED LIMITATION`.
---
## 6. How "nothing reaches the inverter" is graded
**Not on a flag.** A check that asserted `Controller.shadow` would pass on a
build in which the flag is set and the substitution is deleted — which is
exactly mutation **M1**.
`test_clamp.py` part 8 drives the **shipped** `Controller.cycle()` and the
shipped heartbeat against a recording Home Assistant stub, and asserts on what
reached `set_number()`:
* **the precondition first** — the same stimulus with `shadow_mode: off` puts
30 non-zero writes on the wire, the largest 5000 W. MUTATION.md habit 4: a
check whose data cannot reach the failure it names is decoration;
* then `set(writes) == {0.0}`, **and** that there were 26 of them — a check
that only said "no non-zero write" would pass on the silent build too;
* entry and exit are measured on `inv.last_write` — what the inverter is
latched on, updated only on an **accepted** write — either side of the
boundary, against the same run that never entered shadow mode.
The closed-loop half is `/home/homelab/s10-mut/shadowloop.py` on TEST-23 stage
3's rig: the same hour, twice, against `plantsim.Plant` through the firmware's
own frame encoder.
⚠️ **SUPERSEDED BY SAFETY-47 — THE TABLE THAT STOOD HERE COULD NEVER HAVE GONE
RED, FOR TWO INDEPENDENT REASONS.** It read `arb_power_w` −2000 W *"identical"*,
`arb_reason` *"identical, verbatim"*, arbiter winner *"identical"*, and
concluded *"the decision happened, at the same instant, for the same reason, at
the same signed power — and the battery did not move."* The battery half is
true and is unchanged. The **decision** half was decoration:
1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the
capacity guard never engages and SAFETY-45's sizing is in the run and inert.
The harness's own comment says so — *"at this scenario's `peak_cap_w: 15000`
it is not binding anyway"* — which is MUTATION.md habit 4 written down
beside the check it disables;
2. **every identity row reads the FIRST charging sample.** At `elapsed = 0`
the projection *is* the held level, so the two runs agree to the float there
**even at a cap that does bind**. Measured: closing reason (1) alone leaves
that comparison passing.
Both had to be closed. Re-taken below at **`peak_cap_w` 1500 / house 1200 /
`max_w` 2000**, one hour at a 5 s cycle, **across the whole run** — harness
`/home/homelab/s47-mut/shadowloop47.py`, same rig, same three transcribed
lines.
| | live | shadow |
|---|---|---|
| grid | 1224.1 … 1506.0 W — **held up at the ceiling** | **1200.0 W flat = the house, exactly** |
| SoC | 50.000019 → **51.856789 %** | 50.000000 → **50.000000 %** |
| `ARBITRAGE_CHARGE` cycles | 720 / 720 | **720 / 720** |
| first charging cycle, `arb_power_w` | −300.0000 W | **identical — and this is why the old table passed** |
| `arb_power_w`, **whole run** | −307.799 … −299.930 W | **−2000.000 … −300.000 W** |
| …cycles where the two differ | — | **716 of 720** |
| `arb_reason`, whole run | the strategy's own sentence | **differs on 719 of 720** |
| measured charging headroom | 299.93 … 307.80 W | **300.0 … 54 000.0 W** |
| final recorded command | **−302.4 W** | **−2000.0 W — 6.61× the live figure** |
| price / spread / which hour | | **identical on 720 of 720** |
| arbiter winner | `arbitrage` | **identical** |
**What the re-taken table says.** The decision still *happens*, at the same
instant, on the same hour, at the same price, for the same *kind* of reason,
and the battery still does not move — those were the card's real claims and
they hold. What does **not** hold is that the decision is the *same decision*:
its **size** is a function of a meter the battery never moved, and at this cap
the recorded figure is 6.61× what a live controller commands. 54 000 W of
"headroom" under a 1500 W ceiling is `900/remaining` multiplying a gap the
battery never closed.
⚠️ **And the SoC row is the second half of it.** 0.000000000 pp of movement is
not a curiosity: it is the proof that **no SoC-limit behaviour is reachable
from a shadow run at all**, which §5 used to promise.
SAFETY-47 puts the caveat on both surfaces that carry the number and narrows
the three documents' claims; `test_arbitrage.py` part 3d is the CI-side grader
so this class cannot recur.
⚠️ **Three lines of each harness are a TRANSCRIPTION and are marked as one.**
`priceloop.Rig` has no `_write_unit()`, no clamp and no failsafe machine
(divergence 2 of its README), so the substitution's **placement** is
transcribed there. The shipped placement is graded by `test_clamp.py` part 8,
which drives the real `Controller`. ⚠️ **And the rig's transcribed cycle
predates SAFETY-45**: `arbitrage.decide()` gained a required
`charge_headroom_w` after `478ba93`, and the harness supplies it from the
**shipped** `peakguard.charge_headroom_w()` reading the **shipped** peak
decision rather than from a constant, so SAFETY-45 stays in the run.
---
## 7. The mutation battery — 22 mutations, no survivors
Harness: `/home/homelab/s10-mut/run.py`, backups beside it. Each run restores
from backup, purges `__pycache__` **on the host**, and grades through the CI
image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache
into the tree under test (TEST-15). The full table is in the branch report;
one per new behaviour, including **M1** (the substitution deleted — a write
escapes), **M2** (it returns instead of substituting — the inverter is left
latched), **M3** (it moves above SAFETY-28 and SAFETY-03 — the observers go
blind), **M8** (`auto` resolves to shadow unconditionally — the literal
default-on that stops the live install), **M12** (the stop claim is filed in
shadow — every recorded decision reads `stopped`), **M21** (the substitution
and the re-seed together) and **M22** (the re-seed moved one line earlier —
SAFETY-18 grades a 0 W no decision produced and goes permanently blind).
⚠️ **Two findings from driving it, both of the kind MUTATION.md says to expect.**
* **M2 first came back a SURVIVOR and was not one.** The "stop writing
altogether" build left `inv.last_write` as `None`, the check did `float()` on
it, and the section died with a `TypeError` — grading none of the checks
after it and reporting **zero reds**, which reads exactly like a survivor.
That is FCST-06's shape. The fixture now hands `None` back and names it as
its own check; M2 is red on three.
* **M6 had to become a PAIR, and only the battery said so.** `standing_w()`
prefers `last_write` — the 0.0 W the wire gate accepted — and falls back to
`inv.target`, which the re-seed also holds at 0.0. Either rung alone keeps
the detector inert, so dropping the re-seed **singly** came back red on the
limitation check and on **nothing about the latch**: the hazard it names was
not reproduced. TEST-16's rule with a new name on it.
* **M7/M8/M9/M19 had to MOVE SUITES.** They defeat `shadow.engaged()`, and a
defeated default stops `test_clamp.py` part 3's wired run reaching the wire
at all — aborting the file with an `IndexError` before part 8 is graded. The
red count there was a lower bound naming the saturation freeze. The mode
table now lives in `test_validate.py`, which drives no Controller.
* **`shadow_would_w = float(self.target)` is an EQUIVALENT mutation and was
measured as one.** `_shadow_settle()` runs before the re-seed, so at that
line `self.target` **is** `resolution.target_w`. It is recorded as equivalent
rather than reported as a survivor, and the non-equivalent form of the same
defect — the record becoming the **wire** — is driven instead.
### The table
Each row: the mutation, the file, the byte delta, the suite it was graded in,
and how many **named** checks went red. `⚠️` marks a row whose run did not
reach the end of its file, so its count is a **lower bound** (MUTATION.md).
| # | mutation | file | Δ | suite | reds |
|---|---|---|---|---|---|
| M1 | **a write escapes** — the substitution deleted | `main.py` | −6 B | clamp | 5 |
| M2 | **silence instead of 0 W** — it returns rather than substituting | `main.py` | −15 B | clamp | 3 |
| M3 | **the observers go blind** — it moves above SAFETY-28 and SAFETY-03 | `main.py` | +58 B | clamp | 1 |
| M4 | the site re-seed dropped — the exit resumes from a fiction | `main.py` | −36 B | clamp | 3 |
| M5 | the per-unit re-seed dropped | `main.py` | −22 B | clamp | 1 |
| M6 | **PAIRED**: `standing_w()` reads the target **and** the re-seed dropped — the sign latch that survives into live operation | `inverter.py` + `main.py` | −170 B | watchdog | 3 |
| M7 | `auto` inverted — shadow while started, live while stopped | `shadow.py` | +2 B | validate | 4 |
| M8 | **default-on for everyone** — `auto` always shadow; the literal reading that stops the live install | `shadow.py` | −15 B | validate | 2 |
| M9 | an unreadable value resolves to LIVE instead of shadow | `shadow.py` | +1 B | validate | 1 |
| M10 | `validate.py` stops refusing an unknown value | `shadow.py` | −9 B | validate | 9 |
| M11 | the loop leg gated on `auto` alone again — the decision path stops running | `main.py` | −17 B | clamp | 1 |
| M12 | the stop claim filed in shadow — every decision reads `stopped` | `main.py` | −20 B | clamp | 1 |
| M13 | the banner goes green while nothing is commanded | `shadow.py` | −2 B | clamp | 1 |
| M14 | the banner loses the standing latch | `shadow.py` | −2 B | clamp | 1 |
| M15 | the row renders the standing command instead of the decision | `shadow.py` | −8 B | clamp | 1 |
| M16 ⚠️ | the row disappears when live | `main.py` | +43 B | clamp | 1 |
| M17 | the record becomes the wire | `main.py` | −6 B | clamp | 7 |
| M18 | level-logged — the 40-row action log floods | `main.py` | −23 B | clamp | 1 |
| M19 | the two rungs render identically (masking) | `shadow.py` | +22 B | validate | 1 |
| M20 | the shipped default moves to `off` in `config.yaml` alone | `config.yaml` | −1 B | defaults_pin | 1 |
| M21 | **PAIRED**: the substitution **and** the re-seed deleted together | `main.py` | −103 B | clamp | 10 |
| M22 | the re-seed moved one line earlier — SAFETY-18 grades a 0 W no decision produced and goes permanently blind | `main.py` | +40 B | clamp | 1 |
**Survivors: none.** ⚠️ **M17's `+0 B`-adjacent sibling** —
`shadow_would_w = float(self.target)` — is recorded in §7 as **equivalent**
rather than counted here.
@@ -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,221 @@
# SAFETY-45 — the trade is SIZED by the capacity guard, not switched by it
**Status: closed.** `peakguard.charge_headroom_w()` publishes the watts the open
billed quarter can still afford; `arbitrage.decide()` takes it as a required
keyword-only argument and sizes the BUY leg to it; `main.py` passes the measured
guard's own number at its one call site. `peakguard.decide()` is **not changed** —
byte-identical to `release/1.0` at `478ba93`, `sha256(decide) = 200090d0835a3a55`,
7252 B on both trees.
⚠️ **THE HOUSE LOAD IN EVERY FIGURE BELOW IS SYNTHETIC.** TEST-23 stage 3's
`priceloop.py` drives a flat modelled house against a modelled pack
(`plantsim.Plant`, 800 W/s ramp into a 10 s lag, 15 360 Wh). The PRICES are real,
the decision code is the shipped code, the billed-quarter accumulator is
`app/p1.py`'s own — the load is not. Nothing here is a measurement of the live
site at 192.168.2.158, which this work did not touch.
## 0. The defect, measured
TEST-23 stage 3 found a sustained limit cycle with arbitrage armed and a charge
decision active. Re-measured here on the same rig against the same checkout,
metrics recomputed rather than copied — a **charge onset** is a cycle at which
`arbitrage.decide()` transitions into `ARBITRAGE_CHARGE`, which is the edge the
rig already logs:
| `peak_cap_w` | house | onsets/h | mean period | grid swing | billed quarters (W) |
|---|---|---|---|---|---|
| 1500 | 1200 W | **28** | 131.1 s | **−455.5 … +3200.0 W** | 1490.1 / 1496.5 / **1508.8** |
| 3500 *(shipped)* | 1800 W | 8 | 509.3 s | +127.5 … +3800.0 W | **3511.3 / 3536.0 / 3559.1** |
| 3500 *(shipped)* | 3000 W | 32 | 114.8 s | +1202.0 … +5000.0 W | **3518.9 / 3542.2 / 3556.7** |
| 5000 | 1200 W | 1 | — | +1240.6 … +3200.0 W | 3169.5 / 3200.0 / 3200.0 |
The last row is the **negative control**: `load + max_w = 3200 W` never reaches
the cap, so there is no cycle to remove. The bite rule is `load + max_w >
peak_cap_w`, which at the shipped `peak_cap_w: 3500` / `max_w: 2000` is any house
drawing over 1500 W in the cheapest quarter.
§10.1's ordering did win on the money — the guard kept the quarters within tens
of watts of the cap — but **every billed quarter in the three biting rows
finished OVER it**, which is the thing §9.1 bills. §10.2: *"an EMS that
oscillates is worse than no EMS, both for hardware wear and — per §9.1 — for the
peak it is trying to avoid."*
## 1. ⚠️ The mechanism is NOT mainly the quarter boundary, and the card says it is
The card, and `.cache/s45.md` before it, describe the loop as *"at each quarter
boundary the projection resets to 0, the guard disengages, and arbitrage re-arms
at full power."* **That is not what the 28 onsets are.** Their timestamps, off
the run above:
1, 231, 351, 471, 581, 696, 806, 896, 1136, 1256, 1371, 1486, 1596,
1711, 1811, 2061, 2176, 2296, 2411, 2521, 2626, 2716, 2971, 3091,
3206, 3321, 3431, 3541 (s, quarter boundaries at 900/1800/2700)
**23 of the 27 gaps are 90–120 s, mean 111.5 s, entirely inside one quarter.**
The four long gaps — 230, 240, 250, 255 s — are precisely the four that SPAN a
boundary, so the boundary **lengthens** the interval rather than causing an
onset. Only three onsets land within 15 s of a boundary and one of those is
`t = 1`, the start of the run.
The real cycle, read off the recorded fields at t = 1000…1180 s:
1. the trade charges at `−2000 W`; the meter sits at 3200 W against a 1500 W
ceiling; `wanted = batt_w + excess` is **negative** (−132 → −8 W), so
`decide()` files **nothing** and arbitrage is not constrained at all;
2. `wanted` drifts up as the quarter's own history worsens and the `900 /
remaining` gain grows, and crosses zero at t = 1055 with **`floor_w = 5.3 W`**;
3. that 5 W floor flips `arbitrage.guard_state()` to `True`, which refuses the
**whole trade** — so the setpoint goes from −2000 W to +5 W and the
grid-following loop, which targets grid = 0 and not grid = ceiling, takes the
actuator and discharges to **+1870 W**, exporting 420 W;
4. the projection collapses, `wanted` goes negative, the guard releases, and
arbitrage re-arms at **`max_w`**. 115 s later, again.
So the defect is **a binary interlock keyed on a continuous quantity**, and the
quarter boundary is one of the ways it is crossed rather than the cause. The
card's other claim — *"projection 3092 W against a 1509 W ceiling, `engaged=False`,
for 170 consecutive seconds"* — reproduces, but it is the **first** such window
(`first_engaged_t_s = 151.0`); the steady-state silent window is ~40–65 s.
None of this changes what the fix has to do. It is recorded because a card that
says "the boundary is the trigger" invites a fix in the boundary handling, and
such a fix would have removed four of twenty-eight onsets.
## 2. What was rejected, and why
### 2a. Reverting `decide()`'s `wanted <= 0.0` rung — rejected, and it is a trap
That rung is 2026-08-20's anti-bang-bang. Its own comment records the cost of not
having it: 17 engage/release cycles in 900 s and a quarter finishing 49 W over
the limit it had spent a quarter of an hour holding. It is untouched, pinned on
the AST in `test_peakguard.py` 13g, and `tools/replay_ac3.py` reproduces the same
190/190, 110 engaged cycles, 5 blind, 878 Wh on this branch as on `478ba93`.
### 2b. Filing the affordable charge as a NEGATIVE `lo` — rejected
Arithmetically this is the prettiest answer: `lo = wanted` with no sign gate is
continuous, is exactly the control law `decide()` already computes, and would
have put the constraint on the arbiter where it binds every claimant rather than
only arbitrage. It was rejected on two grounds, in this order:
1. ⚠️ **IT CREATES A NEW `lo > hi` CONTRADICTION CLASS AGAINST THE MAINTENANCE
SHAPING.** `main.py`'s cheap-window maintenance charge files
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` — a NEGATIVE `hi`.
`arbiter.py` clause 2 applies every limit at or above the winner's priority
and takes the most restrictive, with **no notion of one limit out-ranking
another**; a peak `lo = −300 W` against a shaping `hi = −1000 W` is `lo > hi`,
which resolves to **0 W — the battery parked, in the one quarter of the month
that sets the bill**. That is the exact failure `peakguard.shaping_suspended()`
exists to prevent, and the only way to close it would be to key that
suppression on "a claim exists" — which, on every site this card is about,
means the maintenance charge-only shaping is suspended **permanently**. A
silent, large change to a different subsystem is not a price this card should
pay.
2. It changes the meaning of `PeakDecision.engaged` at five call sites outside
this module — `arbitrage.guard_state()`, `shaping_suspended()`,
`claim()`, `main.py`'s edge-logged action line and `main.py`'s status row —
each of which today means "the guard is HOLDING" and would silently start
meaning "a claim exists".
`test_peakguard.py` part 5's sweep asserts **no negative floor, ever**, and the
reason it gives ("a `lo` limit is incapable of capping discharge or export only
while it is non-negative") is, strictly, not the true reason — a `lo` of any sign
caps neither. The invariant is still right and is still worth holding; §2b above
is the argument that actually supports it, and it is recorded here rather than
left for the next person to re-derive.
### 2c. A backoff, cooldown or hysteresis constant in `arbitrage.py` — rejected
The card asks whether the second half of the fix belongs in arbitrage: *"a trade
refused at 14:59 and re-armed at 15:00 has learned nothing from the refusal."*
It does belong there, but as a **size**, not as a memory. `arbitrage.py`'s
hysteresis note is explicit that the switching decision is "which hour is it" and
that **no timer is needed and none is invented**; `peakguard.py`'s design note
spends a paragraph on why it carries no hysteresis constant. A cooldown would be
a second control loop compensating for a limit the first one declined to file,
and its constant would outlive whatever it was tuned against. The trade now
re-arms at `ceiling − load` instead of at `max_w`, which is what "learning from
the refusal" means when the refusal is a measurement.
## 3. What was done, and the precedent it follows
`control.maintenance_charge_floor()` has capped MAINTENANCE charging at *"the
headroom left under the site's cap"* since the loop was written, and its
docstring says why:
> "A maintenance charge is the only thing this system does that is big enough and
> long enough to set that peak, so it is capped by the headroom left under the
> site's cap."
⚠️ **THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED.** Price arbitrage is a
second thing that is big enough and long enough — it commands `max_w` for a whole
tariff interval, deliberately, against the meter — and it was given no such cap.
This card gives it the same shape, with one difference that matters:
`maintenance_charge_floor()` sizes itself from `peak_forecast_entity`, which is
OPTIONAL and empty on a default install — the exact hole SAFETY-25 had to close
in `shaping_suspended()`. `charge_headroom_w()` comes off `decide()`, which runs
wherever P1 does.
**The number is `wanted_w` read in the other direction.** `decide()` already
computes `wanted = batt_w + excess` as the DISCHARGE that lands the quarter on the
ceiling; a negative `wanted` is a permitted CHARGE of `−wanted`. No new
arithmetic, no new constant.
Two properties make it safe to hand a strategy, and both are swept rather than
argued (`test_peakguard.py` part 13):
* ⚠️ **IT IS INVARIANT IN THE BATTERY.** Raise the charge by dW and the meter
rises by dW, so `excess` rises by exactly dW while `batt_w` falls by dW:
`wanted` does not move. Measured across `batt_w` from −5000 W to +2000 W on one
banked quarter — the headroom is 1350.0 W at every one of the six points while
the `excess` those runs see moves through six distinct values. It is a
**feed-forward** number, so consuming it adds no feedback path around the plant.
The fixed point is `charge = ceiling − load`, with the meter ON the ceiling and
`wanted` parked a whole `ceiling − load` clear of the engagement boundary.
* **HOLDING IMPLIES ZERO OR UNKNOWN.** Across 1080 swept combinations, not one
ENGAGED decision answers a positive headroom, so the function can never hand a
caller permission the guard's own `lo` is at that instant refusing.
## 4. After, on the same rig, same command, same checkout
| `peak_cap_w` | house | onsets/h | grid swing | energy bought | billed quarters (W) |
|---|---|---|---|---|---|
| 1500 | 1200 W | 28 → **1** | 3655.5 → **282.0 W** | 180.0 → **285.2 Wh** | **1500.0 / 1500.0 / 1500.0** |
| 3500 | 1800 W | 8 → **1** | 3672.5 → 1687.6 W | 1652.9 → 1616.1 Wh | **3500.0 / 3500.0 / 3500.0** |
| 3500 | 3000 W | 32 → **1** | 3798.0 → **474.7 W** | 482.9 → 475.3 Wh | **3500.0 / 3500.0 / 3500.0** |
| 5000 | 1200 W | 1 → 1 | **identical** | **identical** | **identical** |
* **One onset** means the trade starts once and runs for the whole window. The
battery is trading 100 % of the hour where it used to trade 43 %, 92 % and 54 %.
* **Every billed quarter lands exactly on the cap and none over it**, where every
quarter in the three biting rows used to finish over.
* **The money.** At 1500/1200 the site buys **58 % MORE** energy, because a
continuous 300 W trade beats a 2000 W trade that is cancelled for 57 % of the
hour. At the two shipped-default rows it buys **2.2 % and 1.6 % less** — and in
exchange the worst quarter drops from 59 W over the cap to 0 W over it. The
capacity tariff bills the month's single worst quarter with no averaging and no
forgiveness (specs.md:583), so that is not a trade-off, it is the ordering
§10.1 asks for.
* The remaining 1687.6 W at 3500/1800 is the opening **ramp**, not a cycle: the
pack goes from 0 to 1700 W of charge in about ten seconds and the meter with
it. One onset, no export, no reversal.
* The last row is bit-identical before and after. **This card costs nothing on
the installs it is not about.**
## 5. What this does NOT fix, with sizes
* **TEST-23 finding 1 — the export overshoot at the END of a trade** (356–938 W,
≈0.37 × `max_w`) is untouched and is a different card. It happens when the
cheap interval ends and the grid-following loop takes back an actuator that was
holding a charge; nothing here changes the handover.
* **A house whose load ALONE exceeds the cap** (`load > peak_cap_w`) still puts
`wanted > 0`, still engages the guard, and still refuses the trade outright
through the interlock — correctly, since you cannot buy in a quarter you are
already over on. The loop then drives to grid = 0 rather than to grid =
ceiling, which is more discharge than the guard asked for. Not swept here: all
four (cap, load) pairs on the record have `load < cap`.
* **`arbitrage.claim()` still files the SIZED number, but the ARBITER can clamp
it further** and the strategy is not told. The reason line names the guard's
headroom, not the arbiter's `bound_by`. Pre-existing; not widened.
* **The container leg was not re-run.** `hadrive.py` needs `test23s3-addon`
started, and that container is deliberately stopped because it is a second
writer on the setpoint entity under measurement. The in-process rig's twelve
transcribed lines are pinned by `test_arbitrage.py` 3b's AST check on
`main.py`'s call site, which is the line this card adds to them.
@@ -0,0 +1,319 @@
# SAFETY-47 — what a shadow run can show, and what it cannot
**Status:** decided and implemented on branch `SAFETY-47`.
**Card:** SAFETY-47 — *"shadow mode's claims, and the number on the status
row"*.
**Nothing here reached the inverter.** No deployment, no `ha addons update`, no
contact with 192.168.2.158 or 192.168.2.221. Everything below was measured in
the CI image and on TEST-23 stage 3's closed-loop rig.
⚠️ **This is not a write-path defect and nothing in the write path was
touched.** SAFETY-10's substitution, re-seed, authorisation gate and clamp are
unchanged. What was wrong is what three merged documents **say** a shadow run
can show you, and a number on the status row that can be **6.61×** what a live
controller would command.
---
## 0. The four things a reader should take away
1. **Shadow mode cannot surface a closed-loop defect. Not partially — not at
all**, and by the same construction that makes it safe. §1.
2. **The "strategies are unaffected" exemption was true until SAFETY-45 merged,
one commit before SAFETY-10.** Sizing the buy leg to the billed-quarter
headroom made the strategy closed-loop. §2.
3. **The recorded watts are the right answer to a question nobody asked, and
they are CAVEATED rather than re-computed.** Re-computing would mean the
recorded decision was no longer the decision the shipped path reached, which
is the one thing shadow mode has to be able to say. §3 records what was
rejected.
4. **SAFETY-10 §6's committed evidence could never have failed, for two
independent reasons, and closing either one alone closes nothing.** §4.
---
## 1. Why the answer is "not at all", and not "not very well"
Shadow mode holds every actuator at `shadow.WIRE_W` and re-seeds the carried
command to it at the end of each cycle. So every **measured** input — the
meter, the billed-quarter accumulator behind it, the battery power, the state
of charge — describes a plant that **did not respond**.
A closed-loop defect is, by definition, one whose stimulus **is** the plant's
response to the controller. No response, no stimulus, nothing to see. This is
not an implementation gap:
* feeding the guards a **simulated** plant instead is exactly what SAFETY-10 §4
refuses for the sign watchdogs, and the reason generalises — a controller
reacting to a plant that does not exist drifts further from reality every
tick, and three cycles in its numbers describe nothing;
* and SAFETY-10 §5 already conceded the same fact for the grid-following loop
("one slew step, not a trajectory"). That was never a property of the loop.
**It is the general case**, and the loop leg was simply where it had already
been noticed.
**So the closed-loop answer comes from a closed-loop rig (TEST-23 stage 3) or
from a live run. There is no third place, and saying that plainly is worth more
than the three sentences it replaces.**
### Measured, on the rig, at a cap that binds
`peak_cap_w` 1500 / house 1200 / `max_w` 2000, one hour at a 5 s cycle. Of the
three defects SAFETY-10 was written for:
| | visible? | measured |
|---|---|---|
| SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow. Grid −455.5 … +3200.0 W live, **flat at the 1200 W house** in shadow. Guard engaged 410/720 cycles live, **0/720** in shadow. (Taken with SAFETY-45 reverted — that is the defect being looked for.) |
| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. The SoC is the pack's integral of what the pack was commanded, and it was commanded 0 W. No SoC limit can be *reached* in a shadow run at all |
| TARIFF-14's wrong quarter-hour | **yes**, and it is the only one | the price lookup's inputs are the clock and the price series, neither a function of the battery. Which hour, at what price, clearing what spread: **identical live and shadow on 720 of 720 charging cycles, at a binding cap** |
### What a shadow run genuinely is for
*Which* strategy wins, on *which* hour, at *which* price, for *which* reason,
against which limits — the wrong-quarter-hour class, a trade armed on a day it
should not be, a refusal whose sentence names the wrong subsystem, a clamp that
would have engaged, a standing failsafe latch. That is a real and useful set,
and it is the set the documents now claim.
---
## 2. The exemption, and exactly how much of it survives
All three documents carried a version of:
> The **strategies** are unaffected — price arbitrage, the capacity guard and
> maintenance all ask for a fixed power **from inputs that do not depend on
> what the battery did**.
**True before SAFETY-45, false after it.** SAFETY-45 makes the buy leg
`min(asked, headroom)`, and
```
headroom = (900 / remaining) x (ceiling - projected)
```
where `projected` reads the **open quarter's accumulator** — the meter — which
is a function of what the battery did earlier in that same quarter. SAFETY-45
merged one commit before SAFETY-10, so the exemption was stale on the day it
was written.
**What survives, and it is now graded rather than asserted:**
> **Which** hour a strategy picks, and **why**, are unaffected — the clock and
> the price series are not functions of the battery.
> **How hard** it trades is not.
⚠️ **The peak guard is not in the surviving half either.** Every term of
`wanted = batt_w + excess` is measured, so in a shadow run the guard engages
only if the *house alone* would put the quarter over the ceiling. Its
**ceiling** and its **reasons** are worth reading from a shadow run; its
**engagements** are not evidence of anything.
---
## 3. The number on the status row — decided, and what was rejected
At `peak_cap_w` 1500 under a 1200 W house the status row read **"WOULD HAVE
COMMANDED −2 000 W"**. A live controller commands **−302.4 W**. Measured on the
shipped build:
| cap / house | live commanded | shadow recorded | over by |
|---|---|---|---|
| 1500 / 1200 | −302.4 W | **−2000.0 W** | **6.61×** |
| 3500 / 3000 | −502.6 W | −2000.0 W | 3.98× |
| 3500 / 2000 *(shipped cap)* | −1502.6 W | −2000.0 W | 1.33× |
| 3500 / 1800 | −1702.6 W | −2000.0 W | 1.17× |
| 15000 / 350 | −2000.0 W | −2000.0 W | 1.00× |
⚠️ **The arithmetic is not wrong.** `900/remaining` is correct for a meter that
is genuinely under the ceiling, and in a shadow run the meter genuinely *is*
under it, because the battery is not charging. **The recorded number is the
right answer to a question nobody asked** — and the last row of that table is
the fixture SAFETY-10's own evidence was taken on, which is why nothing
reported it.
### What shipped
1. **Unconditionally**, the row and the log line stop forecasting: the watts
are introduced as what would have been commanded **ON THIS CYCLE**. No
number a shadow run records is a prediction of a live run, so no state
should read like one.
2. **Conditionally**, whenever a **measured** billed quarter sized the number
being recorded, both surfaces carry `shadow.SIZED_FROM_METER`:
> ⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE
> HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same
> trade against a meter the battery is moving, so this is an UPPER BOUND and
> not a prediction; how far under it a live run lands is a closed-loop
> question and a shadow run cannot answer it.
⚠️ **"Upper bound", and not "smaller", and the difference was measured.**
Charging only *adds* offtake, so a live `projected` is never below a shadow one
and the headroom never above it — with **equality at the start of every
quarter**, where `900/remaining` is 1.0 and neither plant has moved. Measured
pairwise over 3600 charging cycles in five configurations: **0 cycles below,
730 exactly equal.** A sentence reading "smaller" would have been false on
every one of those 730, and the first draft of this card said "smaller".
⚠️ **And it names no factor.** How far under the bound a live run lands is a
property of the site. A number invented for the row would be a second helping
of the defect the sentence exists to disclose.
⚠️ **The predicate is NOT keyed on whether the trade was capped, and that is
the trap this card nearly walked into.** `arbitrage`'s own sentence says *"SIZED
BY THE CAPACITY-TARIFF GUARD"* only when the headroom came out **below** the
ask — which in a shadow run is the **start** of the quarter, where the two runs
still agree to the float. By the end, `900/remaining` has multiplied the
headroom **past** the ask, the trade is **not** capped, the sentence is silent,
and the recorded number is at its **most** wrong. A caveat keyed on it would be
quietest exactly where it is needed loudest. `arbitrage.sized_from_meter()` is
keyed on the headroom having been **measured**, so it fires on both.
`test_arbitrage.py` part 3d-8 drives the inversion.
### Rejected, and why
* **Re-compute the number so it does not amplify** — e.g. record
`ceiling − projected` (gain 1.0) instead of the sized figure. It matches the
live answer almost exactly at this cap, and it is the **worst** of the
options. It would be a **shadow-only sizing**: the recorded decision would no
longer be the decision the shipped path reached, while the arbiter, the
clamp, SAFETY-28's gate, the action log and MQTT all ran on the real one. A
second copy of the sizing arithmetic living in the display layer is
MUTATION.md's transcribed-predicate defect on an operator-facing surface, and
it generalises to no other strategy. SAFETY-10's whole safety argument is
that shadow mode is a **substitution at the wire**, not a second controller;
this would have made it a second controller everywhere except the wire.
* **Record nothing where the sizing depends on measured state.** Tempting —
"no number beats a number 6.61× out". Rejected on three counts: it blanks the
row exactly when arbitrage is trading, which is when a commissioning engineer
most wants it; the action log's edge key is the rounded watts, so blanking
them either floods or silences the log; and a surface that flickers between a
number and a dash is harder to read than a number with a standing caveat.
The narrow version of this — dropping the watts only — was the closest
runner-up.
* **Record the unsized ask alongside the sized one.** It adds a number without
adding a warning. The unsized ask is `max_w`, a constant the operator
configured; on the shipped 3500/2000 row both figures are 2000 W, so the pair
is exactly as misleading as the single figure was.
* **A caveat on every cycle in every state.** Rejected as the *whole* answer,
accepted as half of it: the unconditional part is a two-word narrowing of the
verb ("on this cycle"), not a warning, and the warning proper is conditional
so that it still means something when it appears.
---
## 4. SAFETY-10 §6's evidence, and why it could not fail
§6 tabulated `arb_power_w` −2000 W *"identical"*, `arb_reason` *"identical,
verbatim"*, arbiter winner *"identical"*. Two independent reasons it could
never have gone red:
1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the guard
never engages and SAFETY-45's sizing is in the run and inert. ⚠️ **The
harness's own comment says so** — *"at this scenario's `peak_cap_w: 15000`
it is not binding anyway"* — which is MUTATION.md habit 4 written down
beside the check it disables;
2. **every identity row reads the FIRST charging sample.** At `elapsed = 0` the
projection *is* the held level, so the two runs agree to the float there
**even at a cap that does bind**.
⚠️ **AND THAT SECOND REASON IS THE ONE A REVIEWER CANNOT SEE FROM A DIFF.**
Widening the fixture alone — the obvious fix — leaves the `[0]` comparison
passing. Measured, not reasoned: at 1500/1200 the first charging cycle is
−300.0000 W in **both** runs while 716 of the 720 cycles differ. Part 3d-2
grades that agreement **as an expectation**, so nobody rebuilds a check on
`[0]` believing it covers something.
This is habit 4's **twelfth** instance in this repository, its **second**
inside a committed evidence artifact, and — like the seventh and the eleventh —
it was in a **measuring instrument** rather than in a check.
§6 is re-taken at `peak_cap_w` 1500 / house 1200 / `max_w` 2000, across the
whole run. Harness `/home/homelab/s47-mut/shadowloop47.py`.
---
## 5. The grader, so this class cannot recur
`test_arbitrage.py` part **3d** — placed beside part 3c, which is SAFETY-45's
own closed-loop regression, and built on the **same** `s45_loop()` harness so
the two runs share every line of shipped code and differ in **one argument**:
```
s45_loop(sized=True) the live hour
s45_loop(sized=True, shadow=True) the same hour, battery held at WIRE_W
s45_loop(sized=False, shadow=True) SAFETY-45's own defect, watched from
inside shadow mode
```
Three lines of `s45_loop()` stand in for `Controller._write_unit()`'s
substitution and `_shadow_settle()`'s record and re-seed. They are marked as a
**transcription**; `test_clamp.py` part 8 is the check on the shipped
placement, and it drives the real `Controller`. Everything that produces a
number — the averager, the projection, the guard, the headroom, the strategy,
the arbiter, the row and the log sentence — is the shipped one.
What 3d grades, in order: that the cap **binds** (the precondition the old
evidence lacked); that the first charging cycle agrees **anyway**; that the
whole run diverges, and by how much; that the record is an **upper bound**, and
the two-point arithmetic that makes it one; what survives of the exemption;
that SAFETY-45's limit cycle and the guard's engagements are **not** visible;
the four rungs of `sized_from_meter()`, each reached with the other three clear
(TEST-16); both operator surfaces; the `capped`-keying trap; and main.py's
three call sites, pinned on the AST.
⚠️ **The section carries its own precondition into every check.** A mutation
that stops either run trading left the lists empty, the first `min()` raised,
the file died before part 4 and the run reported **zero reds — which reads
exactly like a survivor**. That is FCST-06's shape and it was **driven**, not
imagined. `s47check()` now fails every check in the section when the stimulus
was not produced, and the one division in it answers `nan` rather than raising.
---
## 6. The mutation battery — 14 mutations, no survivors
Harness `/home/homelab/s47-mut/run.py`, backups inside it. Each run restores
from backup, purges `__pycache__` **on the host**, and grades through the CI
image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache
into the tree under test (TEST-15). Fingerprints are printed before the first
run and re-checked after the last restore.
| # | mutation | file | Δ | reds |
|---|---|---|---|---|
| M1 | the row drops the caveat branch | `shadow.py` | −63 B | 2 |
| M2 | **the trap**: `sized_from_meter()` keyed on the trade being CAPPED — silent where the record is multiples out | `arbitrage.py` | +33 B | 5 |
| M3 | the CHARGE rung dropped — a sell carries a caveat §9.1 makes false | `arbitrage.py` | −30 B | 1 |
| M4 | the measured-headroom rung dropped | `arbitrage.py` | −18 B | 1 |
| M5 | the arbiter-winner rung dropped — a caveat on somebody else's number | `arbitrage.py` | −33 B | 1 |
| M6 | `main.py` renders the row with `sized_from_meter=False` | `main.py` | −23 B | 1 |
| M7 | `main.py` drops `winner=` — the rung exists and is never reached | `main.py` | −1 B | 1 |
| M8 | `main.py` sizes the trade from a literal — SAFETY-45 out of the shipped path, which the hoist put at risk | `main.py` | −41 B | 2 |
| M9 | the headroom assignment moves out of `cycle()` | `main.py` | −39 B | 1 |
| M10 | `sized_from_meter` gains a `False` default (TEST-14) | `shadow.py` | +8 B | 1 |
| M11 | `ON THIS CYCLE` deleted — the row forecasts again | `shadow.py` | −14 B | 1 |
| M12 | `log_line()` drops the caveat — the page discloses, the record does not | `shadow.py` | −63 B | 2 |
| M13 ⚠️ | `main.py` re-assembles the log sentence at the call site | `main.py` | **+0 B** | 1 |
| M14 ⚠️ | `wanted = batt_w − excess` — the headroom stops being monotone in the battery and the row's UPPER BOUND sentence becomes false | `peakguard.py` | **+0 B** | 38 |
**Survivors: none.**
⚠️ **M13 and M14 are `+0 B`, which MUTATION.md names as the risky delta.** Both
were confirmed on disk by `sha256` before the run started — the harness prints
the fingerprint of every graded file and the hash moved for each — and every
run is purged, so neither is at risk of the stale-`.pyc` failure.
⚠️ **Two findings from driving it, both reported rather than tidied away.**
* **M14's first form was aimed at the wrong direction and correctly survived.**
`min` → `max` in the buy leg's sizing makes the shadow record *larger*, which
is still an upper bound, so 3d-3b was right not to fail. The mutation that
actually inverts the bound is the sign of the excess term in `wanted`. A
survivor that is right is worth more than a red aimed at the wrong claim.
* **M12 was a survivor before the fix it forced.** Deleting the caveat from the
action-log line left **all 29 files green**: the only check that could see
that line drives a fixture with `meter_source: off`, where no arbitrage
decision — and therefore no caveat — is reachable. The sentence was an
f-string inside `Controller._shadow_settle()`, i.e. a surface no check could
grade. It is `shadow.log_line()` now, for the reason `words()` is a function,
and M13 is the mutation that puts it back at the call site.
@@ -0,0 +1,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,181 @@
# SAFETY-56 — what the gate cannot tell apart, said in the sentence
**Text only. No behaviour changes in this card**, and §5 is the measurement that
says so: the same stimuli through the tree before and after produce a
**byte-identical** trace of every verdict, every `reading()`, every `inputs_ok`
and every readiness row's `(ok, warn)`.
---
## 1. The defect
0.10.1 is regulating a real GW5000S-BP. Two messages tell the operator that
healthy hardware has failed.
**The log line**, as emitted on the reference site on 2026-09-21:
> SAFETY-37: inverter-a battery-power register **is not following its own
> command** - STALE_READING: -3 unchanged for 36 s while the standing command
> moved 800 W to +1600 W - **the reading is not following**, so it is reported
> as MISSING rather than as a measurement … Treating it as MISSING, which
> commands 0 W.
**The readiness row**, at the same moment:
> NOT READY — battery power: `sensor.kelder_goodwe_master_stage1_goodwe_inverter_ac_power`
> **is missing or not numeric**
That entity was present, numeric and updating.
**SAFETY-37's conjunction is correct and is not touched by this card** —
`unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W`. A register
genuinely stuck against a real command is exactly what it should catch, and a
pack that cannot comply is a state the site must stop in too. What was wrong is
what the sentences **concluded** from it.
## 2. Two worlds satisfy the conjunction
| | the register | the pack |
|---|---|---|
| **DEAD** | has stopped reporting reality | is doing what it was told |
| **REFUSED** | is reporting reality correctly | cannot comply — the BMS is refusing at its floor |
A refused pack holds still under any setpoint, so an honest register publishes
the same unchanged number a dead one does. **Nothing `freshness.py` is handed
separates them.** All three sentences asserted DEAD.
## 3. The live episode was REFUSED, and it is measured, not argued
Off the committed capture `scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz`,
whose bytes and decoder are already graded by `test_freshness.py` part 10a:
| | |
|---|---|
| the register | answered **-3 W for 850 s**, 10:51:24 → 11:05:34 |
| the commands during that hold | **+260, +660, +1060 W** — discharge only, never a charge |
| pack SoC throughout | **11 %** |
| first **charge** command after the restart | 11:05:31 at **-400 W** |
| the register moves | **11:05:34** — **3 s**, inside one 17.0 s obligation window |
**A register that answers a reversed command inside one obligation window had
not stopped reporting.** What would not move was the pack. The sentence the
operator read named the one thing in the loop that was telling the truth.
⚠️ What this does **not** establish: that no register is ever intermittently
wrong. It excludes a register that had simply stopped; it cannot exclude one
that was wrong and then right. That is the point — the new sentence claims
neither.
## 4. The fix, and why it is three sentences and not one helper
Each message states **what was observed** and names **both** explanations.
| where | before | after |
|---|---|---|
| `app/freshness.py` `_grade()` | *"…+1600 W - the reading is not following, so it is reported as MISSING…"* | *"…+1600 W. **TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART**: the register may have stopped reporting reality, or the source may be unable to comply with the command - a pack the BMS is refusing holds still under any setpoint, and an honest register then reads exactly like a frozen one. Either way the number cannot be read as this command's effect, so it is reported as MISSING…"* |
| `app/main.py` `_read_unit()` | *"SAFETY-37: %s battery-power **register is not following its own command** - %s."* | *"SAFETY-37: %s battery-power reading **did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER** - %s."* |
| `app/main.py` `checks()` | *"{label}: {entity} **is missing or not numeric**"* | *"{label}: **no usable value this cycle from** {entity} - **and this row cannot tell which of two things that is**: the entity is missing or not numeric, or it is publishing a number that this controller is withholding (a freshness gate does exactly that, and names itself in a row of its own). Confirm the entity in Developer Tools before replacing it"* |
**Three sentences, three wordings, no shared helper.** The first two are a log
line about a **behaviour**; the third is a banner about a named **entity**, and
the two answer different questions ("what did the register do" against "can I
trust this entity"). A helper serving both would have to drop whichever half
the other caller does not need, and the half it dropped would be the
operator's next step. The log line names the pair **once**, inside the `%s` it
already interpolates, rather than in both of its own halves.
**No new input, and SoC is not wired.** `BOUNDS["soc"]` stays *STATED, NOT
WIRED*; telling the two worlds apart needs the pack floor (SAFETY-32's number)
and SoC (TEL-08's wiring), and a message honest about its own ambiguity needs
neither. Part 11b drives the two worlds at **50 %** and **11 %** SoC and
asserts the sentence does not move one byte.
**The readiness row does not branch.** `inv.batt_fresh.stale` is on the same
object at the same moment, and the row deliberately does not ask it: the same
loop renders the grid and SoC rows, which have no gate, and a branch per row
would be a second place where "why is this None" is decided. The gate already
has a surface — `stale_note()` rides `liveness_notes()` a few rows below with
the whole STALE_READING sentence in it — so the operator's discriminator is
*"is that row there?"*, and part 11d asserts that row is **present in REFUSED
and absent in ABSENT**.
## 5. Behaviour is byte-identical, measured
`tools/safety56_behaviour_trace.py` prints two documents from one stimulus set.
The `behaviour` half contains **no sentence**: only `stale`, `reading()`,
`releases`, `held`, the window bookkeeping, `inputs_ok`, and every readiness
row's `(ok, warn)`. Run in the CI image, purged, `:ro`, against
`origin/release/1.0` **archived out of git** and against this branch:
| | |
|---|---|
| the committed live capture | **2390 reads**, 7 verdict edges |
| a swept grid of command × register schedules | **66 cells × 120 reads** — 1060 stale reads, 1309 `MISSING` readings, 6 releases |
| six driven controller worlds through the shipped read path | DEAD, REFUSED, ABSENT, HEALTHY, RELEASE, RETRIP — every read's `(batt, inputs_ok, stale)` and every `checks()` row's `(ok, warn)` |
| **result** | `6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c` **on both trees** |
⚠️ **AND THE CONTROL IS INSIDE THE TOOL, BECAUSE AN IDENTITY BETWEEN TWO TREES
THAT ARE THE SAME TREE IS HABIT 4.** `--compare` refuses to report "behaviour
unchanged" unless the `sentences` halves **differ**. This card's first
invariance run was exactly that mistake — it archived a branch whose edits were
not committed yet, so `before` and `after` were the same code and the identity
was perfect and worthless.
Both of the tool's refusals are **driven**, not asserted:
| | |
|---|---|
| the same file compared with itself | `REFUSED: the sentences halves are IDENTICAL…`, exit **2** |
| `MOVE_W` 250.0 → 240.0 in the after tree | `BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'`, exit **1** |
| this card | `OK: sentences differ, behaviour is byte-identical.`, exit **0** |
## 6. The mutation battery
Each in its own `mktemp`'d copy (habit 18), purged on the host, mounted `:ro`,
graded by `tools/mutation_run.sh` in the CI image.
| | mutation | Δ bytes | named reds |
|---|---|---|---|
| M1 | `_grade()` reverted to *"the reading is not following"* | -402 | **3** |
| M2 | the log line reverted to *"register is not following its own command"* | -80 | **2** |
| M3 | the row reverted to *"{entity} is missing or not numeric"* | -619 | **2** |
| M4 | *register* and *source* transposed between the two explanations | **0** | **1** |
| M5 | the second explanation deleted, the first kept | -216 | **1** |
| M6 | *"CANNOT TELL THEM APART"* replaced by *"IN ORDER OF LIKELIHOOD"* | -13 | **1** |
| M7 | the REFUSED world's ground truth set to the DEAD world's | 0 | **2** |
| M8 | the row's second cause deleted | -309 | **1** |
| M9 | `label` and `entity` transposed in the row | **0** | **3** |
| M10 | M1 + M2 + M3 together, i.e. exactly what 0.10.1 ships | -1101 | **5** |
| M11 | the accusation literals replaced by one that matches nothing | 0 | **1** |
⚠️ **M9's first run was a defect in this card's own check, and it is the one
worth reading.** The row was fetched with `[...][0]`. Transposing `label` and
`entity` makes the row unfindable, so the run died on an `IndexError` — a red
with **no name attached**, with every check after that line **ungraded**. That
is FCST-06's shape exactly. `_s56_row()` now returns a named absence instead of
raising, and a precondition grades it: **0 named reds before, 3 after.**
⚠️ **M11 is the other one aimed inward.** `not any(...)` over an empty list is
True, and so is one over three mistyped literals. The positive control runs the
same predicate against the three sentences **as 0.10.1 shipped them** and
requires it to find an accusation in every one, and every literal to match at
least one of them (habit 7: vary one thing from an accepted baseline).
## 7. What part 11 does not grade
* It does not show the live episode was a BMS refusal beyond doubt — see §3.
* It does not grade English. A sentence that named both explanations and then
said something useless about them would pass.
* The `no entity configured` branch beside the rewritten row is untouched and
ungraded: a blank entity really is the only thing it can be.
* The **grid-power and battery-SoC rows** go through the same rewritten line and
have no freshness gate today, so for them the second cause is a hypothesis
about this controller rather than a live possibility. That is weaker than the
battery-power case and is the price of not branching per row. A gate on either
signal — TEL-08 would be one — makes it exact.
* **SAFETY-58 is not touched** (the first read after a long rest trips before a
healthy register could answer), and neither is the 710/790 disagreement
between `test_freshness.py` and the changelog. Both are separate cards; the
second has a **fourth site** at
`DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274` that a fix written
off the PR table alone would miss.
@@ -0,0 +1,388 @@
# SAFETY-57 — the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
**Status:** decided and implemented on branch `SAFETY-57`, off `origin/master` @ `6f5e8e3`
(content-identical to `release/1.0` @ `8a57361`).
**Card:** SAFETY-57 — *"the freshness gate has no exit reachable from inside the state it creates"*.
**Nothing here reached the inverter.** No contact was made with 192.168.2.158 or
192.168.2.221. The one live system touched is `sniffer@192.168.2.167`, a
**passive** RS-485 listener with no address on the bus, and it was read only.
---
## 0. The five things a reader should take away
1. **The live episode is reproduced from the wire, not modelled.** The capture
is committed, the decoder is committed, and the box that produced it was
running the defect — so the `0 W` in the trace *is* the add-on's own
`inputs missing → command 0 W`. §2.
2. **Option 2 from the card — "do not arm the gate when the command is 0 W" —
already holds, and it is not the bug.** `_grade()` has required
`|standing| >= MOVE_W` since SAFETY-37. The trip happened at **+1060 W** and
the stuck state is at **0 W**; they are different moments. §3.
3. **The decision is not "how long", it is "when".** The verdict is withdrawn
when the **obligation that produced it is no longer standing** — SAFETY-13's
shape one module over. The 17.0 s is the obligation window that was already
derived in the file, taken at the **floor** of its band, and which half is
safe is the **opposite** of SAFETY-13's. §4.
4. **It costs 52 % exposure on a permanently dead register, and that number is
in the suite so it cannot be forgotten.** It is payable only because one
re-grading window (35 s) is shorter than both consumer holds (75 s, 85 s).
**No detection SAFETY-37 had is given back.** §5.
5. **The battery had been rescuing this code by accident.** 93 trips in the
committed 16-day capture; median wait for the BMS keep-alive pulse that
cleared them **143 s**, worst **5 h 57 m**. §6.
---
## 1. What was there, in the terms the code uses
`freshness.py`, `Freshness.submit()` and `_grade()`:
```python
if self.changed_mono is None or value != self.held:
...
self.stale = False # the only place the latch is cleared
def _grade(self, standing_w, now_mono):
if self.stale or self.changed_mono is None:
return # sets the latch; never clears it
```
and `main.py:1777`, `inv.batt = inv.batt_fresh.reading()`, where `reading()`
answers `None` while `stale`. `inputs_ok` sees the `None`, the site holds for
`stale_input_s`, then files `Claim.set("safety", P_SAFETY, 0.0, "inputs
missing")` and keeps filing it.
The reason the latch exists is written in the class docstring and it is a good
one: without it the verdict oscillates at about 1 Hz, because declaring the
reading stale drops the command below `MOVE_W`, which un-arms the conjunction,
which hands the held number straight back.
**The defect is that the only thing that ends it is an arrival carrying a
different number — and the state's own output is what stops the register being
asked for one.** The log line promised the opposite, in the same sentence that
announced the 0 W:
> *Treating it as MISSING, which commands 0 W. It clears itself the moment the
> register reports a different value.*
---
## 2. Reproducing it, from the wire
`scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz` is 3840
verbatim frames off the RS-485 pair, 10:30–11:10 Europe/Brussels, decoded by
`tools/decode_bus_capture.py`. The decoder reports **0 unparsed, 0 bad CRC,
0 unknown shapes**, and its mode→sign convention is **graded off the capture**
rather than trusted: 298 commands at or under −1000 W are each answered one
obligation window later by a negative register reading, and not one positive.
| capture time | on the wire |
|---|---|
| 10:51:14 | `0x0518` = **−447 W**; the loop is charging |
| 10:51:19 | the command reverses: EMS **mode 3**, 260 W of discharge |
| 10:51:24.7 | `0x0518` = **−3 W**. The pack is at **11 %**, the BMS refuses the discharge, the inverter idles. **The register is healthy and truthful.** |
| 10:51:29.7 | the command reaches **+1060 W**. 800 W of travel |
| 10:52:21.7 | the setpoint is written **0 W** — this is the add-on's own §11.1 rule 1, recorded on the wire |
| …850.0 s | `0x0518` does not change value |
| …790.0 s | the setpoint does not change value |
| ~11:04 | **a person restarts the add-on** — not visible in this file, and the only thing that ends the episode |
| 11:05:31.7 | the restarted loop commands **−400 W of charge**, which the pack accepts at 11 % |
| 11:05:34.7 | `0x0518` = **−137 W** |
⚠️ **THE CAPTURE IS CLOSED-LOOP FOR THE WHOLE PARKED INTERVAL**, which is why
this is a reproduction and not a simulation: the box was running 0.10.0, so
every command in the trace is the shipped code's own. The only event inside the
window the file cannot account for is the human restart.
Replaying the shipped `_read_unit` loop at 1 Hz against those two series
(`test_freshness.py` part 10b):
| | stale at | clear at | parked |
|---|---|---|---|
| **before** (`release_s = inf`) | 10:52:00 | 11:05:35 | **815 s**, and `reading()` is `None` on all **812** reads from the trip to the first command a human put on the wire, with the register at −3.0 W in every one of them |
| **after** | 10:52:00 | **10:52:39** | **39 s**, with `0x0518` still answering the same **−3 W** |
⚠️ **THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS GRADED RATHER THAN
CLAIMED.** With an infinite bound `_release()` never fires, `_open_window()` is
only ever called where the register moves, and the window pair is the
change-stamp pair — byte-for-byte the pre-SAFETY-57 conjunction. Part 10b
asserts that by driving part 1's own fresh/stale pair through both bounds and
comparing the verdict streams.
The replay also found a **one-read trip at 10:48:14** that clears one second
later. That is a pre-existing false positive and it is now recorded as
survivor 5 — §7.
---
## 3. What the card's option 2 actually buys, which is nothing here
The card offered *"do not arm the gate when the command being graded is 0 W"*
and warned that it might hold already. **It does, and it has since SAFETY-37**:
`_grade()` returns at `abs(standing_w) < self.bound.move_w`, and
`test_freshness.py` part 4 has a named check on that term, added because a
mutation removing it survived.
The warning in the card is the right one and it is worth writing down: **the
trip and the stuck state are different moments.** The gate armed at **+1060 W**,
where the conjunction is entirely correct — a command travelled 800 W and the
register did not move for 35 s. The site then went to **0 W** *because of the
verdict*. An entry-side rule cannot reach a state that is entered legitimately
and then persists. **The defect is the exit, and nothing else in the conjunction
is touched by this card.**
Option 3 — probing with a small non-zero command — was not taken and does not
need to be. It writes to a real actuator to test an assumption, and §4's rule
does not need a probe: the site's *normal* control resuming is the test, and it
is gated behind the same `inputs_ok` everything else is.
---
## 4. The decision: which verdicts get an exit, and what the bound is
> The stale verdict holds as before, **unless** the obligation that produced it
> is no longer standing — `|standing| < MOVE_W` — **and** it has been gone
> longer than the register would have needed to answer it. On a release the
> latch clears and a **new grading window** opens at that instant.
This is [[SAFETY-13]]'s §4 in a different module, and deliberately so: that card
established the house answer to "a latch with no exit" and its answer was *not*
a plain time bound but a **predicate plus a time bound**, where the predicate
says whether the state can end on its own. Here the predicate is that **the
question is no longer being asked.** A register under a 0 W command is obliged
to be nowhere, so a verdict about whether it is following cannot be tested, and
a verdict that cannot be tested may not stand for ever.
### 4.1 Why the window has to re-open, and what happens if it does not
`_grade()` compares the register's unchanged time against `unchanged_s` and the
command's travel against the command standing when the window opened. If a
release cleared the latch **without** re-opening the window, the first read at
which the resumed command travels 250 W would re-trip immediately — the
register would never get the 17 s in which it could answer, and the live
episode's recovery (a charge the pack **accepts**) would be killed inside two
seconds. Mutating exactly that (`graded_from` re-anchored to `changed_mono`,
+82 B) takes four named checks red.
The new window is `BATT_UNCHANGED_S` = **35 s**, the same bound as entry,
because it is the same question — *does this register follow the commands issued
from now on?* — and there is no principled shorter answer to it. It is also the
number the exposure argument in §5 rests on, so it is not free to move.
### 4.2 `RELEASE_S` = `BATT_OBLIGED_S` = 17.0 s, and which half is safe
The quantity needed is *has the register had its chance to answer the command
standing now?*, and `freshness.py` already derives it as the floor of
`BATT_UNCHANGED_S`'s band:
```
6.0 s plant goodwe/NOTES.md §5.9, measured 2026-08-22
10.0 s publisher firmware/goodwe-master.yaml:181, update_interval: 10s
1.0 s this add-on run_control()'s await asyncio.sleep(1)
```
⚠️ **IT IS THE FLOOR OF ITS BAND, NOT A SENSITIVITY PREFERENCE** — the mirror of
`MOVE_W` taking the **ceiling** of its own for the mirrored reason.
| | the question | the safe half | why |
|---|---|---|---|
| SAFETY-13's `SETTLE_CYCLES` | may the plant still be the **explanation**? | the **longer** half | a shorter bound stops believing the plant too early, and then it accuses |
| this one | may the verdict still be **tested**? | the **shorter** half | being wrong *long* is the failure being fixed; being wrong *short* costs one re-grading window, and §5 bounds that |
Below 17.0 s the release pre-empts the **cheaper exit** — the register answering
the new 0 W command by itself, which clears the latch on evidence rather than on
a timer. That is graded behaviourally rather than asserted: a register that
answers at +16 s clears by **movement** at `release_s = 17.0` with `releases`
still 0, and by **timer** at `release_s = 5.0`.
The family is computed and printed (habit 10): `5, 16, 17, 18, 35, inf` give
six distinct release instants, so no check in the file can confuse two of them.
### 4.3 Two clocks, and the operator reads the register's
`changed_mono` / `unchanged_s()` remain facts about the **register**: after a
release they go on reporting that it has not moved for 850 s, because it has
not. `graded_from` / `graded_s()` are the **verdict's** window. They coincide
until the first release. Collapsing them either lies to the operator or defeats
the re-grading, and both directions are mutated red.
The stale sentence's `unchanged for N s` slot carries the **register's** number;
a re-trip appends a clause naming the window and the grade count, so the two
durations cannot be read as one number that moved (habit 14).
---
## 5. What it costs, measured
Driven against a register frozen for good, 1201 reads, shipped defaults
(`test_freshness.py` part 10c):
| | parked | the held number reaches a consumer | releases |
|---|---|---|---|
| before | 1166 s | **35 reads (2.9 %)** | 0 |
| after | 571 s | **630 reads (52.5 %)** | 17 |
⚠️ **52 % IS PAID EVERY CYCLE, NOT OCCASIONALLY.** The period is
`stale_input_s` (15 s) + `RELEASE_S` (17 s) parked, then one 35 s window.
It is payable because of one inequality, and it is the inequality
`test_freshness.py` part 6 has graded against the shipped `config.yaml` since
SAFETY-37:
```
35 s (the re-grading window) < 75 s (circulation_hold_s) < 85 s (unit_sign_disagree_s)
```
Every unbroken run of exposure is **exactly one window**, so neither consumer
can reach a verdict inside one, and the re-trip that ends each run hands them a
`None`, which **clears** their windows rather than accumulating into them.
**And nothing SAFETY-37 detects is given back**, which is the half that makes
this a trade rather than a regression: a permanent `None` clears those same two
windows for ever as well, so on the shipped-before module those detectors are
*equally* unable to speak about the unit. What the held number does reach for
35 s is `site_batt_w`, the anti-windup input of `control.compute()` — which
answers a large unexplained divergence by **freezing the output**, the
conservative direction and SAFETY-13's business — and the command itself is
derived from the **P1 meter**, never from `batt`.
### 5.1 The narrowing this does NOT take, named so the next card can
The live episode's register refused a **discharge** into an 11 % pack and then
followed a **charge** three seconds later. A refusal in one direction is not
evidence about the other, and a gate that knew the pack's floor could re-grade
on the first command that crosses it instead of on a timer — which would shrink
the 52 % above. That needs SoC, which this module deliberately does not take
(see the `soc` row and TEL-08), and the pack floor, which is SAFETY-32's number.
It is **not needed to give the state an exit**, and folding it in here would
make the exit depend on a second measurement being right.
---
## 6. The measurement that says the old exit was luck
`scenarios/loop-2026-09-02_2026-09-17/` replayed at 1 Hz through the shipped
conjunction (`test_freshness.py` part 8b): **93 trips in 16 days.** The only
thing that could clear any of them was the register carrying a different number,
and that took:
| | |
|---|---|
| minimum | 0.6 s |
| **median** | **143.4 s** |
| maximum | **21440.1 s** (5 h 57 m) |
| at or over the live episode's 710 s | **17 of 93** |
The short waits are the BMS keep-alive pulse — SAFETY-50 measured its ceiling at
508 W — twitching `0x0518` and satisfying the clearing rule **by accident**.
Over a fifth of the trips are cleared in under 20 s by something nothing in this
add-on asked for.
⚠️ **WHAT THAT REPLAY IS AND IS NOT.** The capture runs 2026-09-02…17 and
0.10.0 — the first version carrying this gate — was cut on 2026-09-16, so the
setpoint column is an **ungated** controller's. That makes each trip an honest
register-versus-command divergence off the real bus, and it makes the post-trip
command column something a gated box would **not** have written. The figure
taken from it is therefore the only one that does not depend on the command at
all — how long this register goes without changing value — and it **under**-states
the wait, because a site parked at 0 W gives the register even less reason to
move than the one recorded here.
---
## 7. Survivors, and the two things the battery found
The battery is 22 mutations over `app/freshness.py`, `app/main.py` and
`tools/decode_bus_capture.py`, plus one over the committed capture itself. All
run through `ci_run.sh`, a mirror of `checks.yml`'s own step: a purged copy of
the tree, bind-mounted `:ro`, one file at a time, `PYTHONDONTWRITEBYTECODE=1`.
Per MUTATION.md habit 18 the harness's scratch file is `mktemp`ed per
invocation and the tree is fingerprinted **after the revert** as well as before;
every revert restored the recorded sha256 and byte count.
**21 red, 1 argued equivalent, 0 unexplained survivors.** The table is in the
PR body. Two of them are worth keeping here.
### 7.1 The equivalent mutant, argued rather than rounded off
Swapping `_release()` and `_grade()` in `submit()` is a **zero-byte** edit and
survived the whole battery. **It is not a missing fixture and no fixture can
close it**: `_grade()` acts only when `|standing| >= move_w` and `_release()`
only when `|standing| < move_w`, so at most one of them can do anything on any
read, whichever runs first.
⚠️ **THE FIRST VERSION OF THE CODE CLAIMED THE ORDER WAS LOAD-BEARING**, in a
`⚠️` comment, with a reason that sounded right. It was false, and the battery is
what said so — **MUTATION.md habit 22 exactly, one card later: an explanation in
a comment is a claim, and this one had not been run.** The comment now states
the disjointness, and part 10 pins **that** — at 0, 100, 249, 250, 251 and
1300 W — instead of pinning an order that is not observable.
### 7.2 The sibling that survived, which is habit 11 earning its place
`_release()`'s obligation test is `abs(standing_w) >= self.bound.move_w`.
Swapping `move_w` for `unchanged_s` — comparing **watts against 35** — survived
the file at **+5 B**, because **every fixture rested at exactly 0 W**, and 0 is
below both 35 and 250. They separate only in the band between them, and a site
can sit there: a peak-guard floor, a shadow substitution, any claim that wins
with a small non-zero number. Closed by a three-point fixture at 0 / 100 / 300 W
that asserts the 100 W case releases and the 300 W case does not.
### 7.3 ⚠️ And the instrument lied once, which is habit 16's second half
The first form of the `graded_from` mutation was
`float(self.changed_mono or now_mono)`. It reported a **survivor at +21 B**, and
the tree fingerprint showed the edit really was on disk and really did run.
`changed_mono` is **0.0** in the dead-register fixture, and `0.0 or x` is `x` —
so the mutant was a *no-op on the fixture*, for a Python truthiness reason that
has nothing to do with the property under test. Rewritten as an explicit
`is None` test (+82 B) it is red on four named checks.
**A mutation is subject to every habit in MUTATION.md, including habit 4.** A
purge, a fingerprint and a byte delta all say the edit is present; none of them
says the edit *changed the answer on this stimulus*. MUTATION.md gains habit 19
for it.
---
## 8. What this does NOT fix, said plainly
**It does not tell a BMS refusal apart from a dead register**, and it does not
try to. Both are re-graded on the same timer. §5.1 names what telling them apart
would need.
**It does not make the diagnosis right.** The line the operator reads still says
*"battery-power register is not following its own command"*, which is wrong in a
different way when the pack is simply empty. That wording is **SAFETY-56's**,
together with `main.py`'s NOT READY row, and it is deliberately untouched here —
two cards editing one sentence is how one of them silently loses. Only the
**escape clause** was rewritten, because this card is what made the old one
false, and part 10h has a named check asserting the diagnosis half is still
there so that rewrite has to be a deliberate act.
> ⚠️ **SINCE SUPERSEDED, WHICH IS WHAT THAT CHECK WAS FOR.** [[SAFETY-56]] is
> the deliberate act: all three sentences now name **both** explanations and
> accuse neither, and part 10h's check is inverted — it requires the accusation
> to be **gone**. Nothing else in this section moved; the escape clause above is
> still this card's and is untouched by it. See
> `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`, and
> `test_freshness.py` part 11 for the graded pair of worlds.
**Survivor 5 is older than this card and is not closed by it.** After a long
rest the unchanged clock is already past 35 s, so the **first** read carrying a
travelled command trips the gate — before the 17.0 s in which a healthy register
could have answered it. The live capture contains one (10:48:14, cleared one
second later). Its size is one `stale_input_s` hold, during which `cycle()`
returns at `inputs missing {n}s` and commands nothing differently. Fixing it
means measuring the unchanged clock from when the **obligation** appeared rather
than from the register's last move, which re-derives the 35.0 s bound and is a
different card.
**Nothing was measured on two inverters.** Inverter B does not physically exist
(`goodwe/NOTES.md` §1.1b). Every two-unit figure in `test_freshness.py` is two
copies of A's behaviour with different numbers passed in.
**No deploy is proposed.** 0.10.0 is on the reference install and this branch's
CHANGELOG row is filed under it only because `test_version_gate.py` part 11b
requires the newest heading to be a version. Moving it, and deciding whether a
version is cut, is a PKG card's.
@@ -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.
@@ -0,0 +1,351 @@
# TEL-11 — the PV production channel
*What was decided, what it was decided against, and what the evidence was.*
## 0. The problem, in OPT-10's numbers
Over sixteen days at the reference site the pack reached 90 % SoC on **two** of
them, and **20.5 kWh of surplus left the site while the pack still had room** —
**12.6 kWh** of it with the battery **not charging at all**. That single
measurement is upstream of most of what else was found that week: the nightly
floor, SAFETY-37's misdiagnosis, the SAFETY-57 deadlock, a 2 h 47 min saturation
freeze.
It cannot be **attributed**, because **PV production is recorded nowhere in this
system**. Three causes need three different fixes:
| cause | fix |
|---|---|
| there was no irradiance | none — the site behaved correctly |
| production was there, charge power was limited | a limit, somewhere |
| production was there, the pack had room, the optimiser declined | **the only software defect** |
Guessing between them is how an optimiser gets tuned to solve a cloud problem.
**This card adds the channel and does not do the attribution.** Ship it, let it
record a week, OPT-10 picks it up with data.
## 1. It is an HA entity, not a register — and that is a product decision
The RS485 bus this add-on speaks carries the **battery** inverter it drives. On
nearly every site the PV inverter is a different box from a different vendor,
reporting into Home Assistant through its own integration; it is **not** on this
bus and there is no reason it would be. So the signal comes from an entity:
`pv_entity` takes whatever `sensor.*` the site's PV integration provides, any
brand HA can read.
Reading a register would have been cheaper on one house and wrong for a fleet —
the trade [[product-not-one-house]] names.
## 2. The sign convention: **positive is production**, and there is no `pv_invert`
### The convention is derived, not assumed
Two sign rules already ship, and both are recorded in `DOCS.md`'s *Sources*
table rather than inferred here:
* `meter_entity` — **positive must mean importing** (`meter_invert` flips it);
* `batt_entity` — **`+` = discharging** (`batt_invert` flips it).
Both are *"positive = power arriving at the house's AC bus"*. Production arrives
there too, so **positive = producing** is the continuation of the shipped
convention rather than a fresh choice.
### Why an invert flag would be over-building
The question is not "could a sensor be reversed" — it could. It is what a flag
would **buy**, and the answer is nothing, for two reasons that do not apply to
the two keys it would sit beside:
1. **Those two quantities are read by the control path; this one is read by
nothing.** `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor (`_read_unit()`'s own docstring says so), so a
reversed battery sensor hands the site a plausible number it then **acts**
on — a latch that trips when it should not, or does not trip when it should.
`pv_w` reaches no decision at all (§4), so a reversed PV sensor cannot
mislead one.
2. **Production is unipolar, so the error is self-announcing.** Battery power
and grid power are legitimately negative half the time, which is exactly why
a flipped sign is invisible in them. A PV array does not consume: a reversed
sensor reads **−4 kW at noon**, in the first day of the very history this card
exists to create. An invert flag would let an installer *hide* that by
flipping it to something that looks right, and a second place for the sign to
be wrong is a second thing to get wrong.
So: the convention is documented in `config.yaml`, both translations, `DOCS.md`
and `app/pv.py`, the value is published **exactly as the sensor reports it**,
and there is no flag. `test_pv.py` part 1 pins `-8 W` through unchanged.
### And no negative-value alarm either
The tempting middle path — warn when the reading is negative — needs a
threshold, because a PV inverter's own standby draw makes a small negative
reading at night entirely ordinary. Any threshold here would be a number nobody
measured, in a card whose whole subject is not fabricating numbers. The
published row is the detector.
## 3. Absence is never zero, and it is not "dropped" either
This is the constraint that shaped the publication, and the obvious
implementation fails it.
`MqttPublisher.publish()` **skips** a `None` value. That is right for
`circulation` — its note in `main.py` says a missing per-unit reading should
leave the last figure standing rather than publish a zero that reads as health —
and it is **exactly wrong here**: a skipped row leaves Home Assistant holding
the last number it was told, *forever*, and the recorder then draws a **flat
line at that value through the outage**. Read back a week later that is
indistinguishable from a real measurement, and if the last value happened to be
small it manufactures precisely the *"there was no surplus to store"* conclusion
the card exists to **test**.
So TEL-11 publishes the absence, using Home Assistant's own mechanism:
> `homeassistant/components/mqtt/const.py`: `PAYLOAD_NONE = "None"`.
> `homeassistant/components/mqtt/sensor.py`, in the state-message handler:
> `if payload == PAYLOAD_NONE: self._attr_native_value = None; return` — and it
> is tested **before** the numeric branch, so it holds for a row carrying
> `device_class: power`.
> (github.com/home-assistant/core, branch `dev`, retrieved 2026-09-24. The
> shipped MQTT-sensor documentation says the same thing in its `state_topic`
> entry: *"a 'None' value will set the sensor to an unknown state"*.)
The entity therefore goes to **`unknown`** and the recorder stores a **gap**.
`mqtt.ABSENT_AS_UNKNOWN` is a one-key set and `test_pv.py` part 3 asserts both
that the pv row gets this treatment **and that `circulation` still does not** —
widening the rule silently would be a behaviour change to another card's row.
⚠️ **What was NOT built, and why.** A per-entity `availability` block
(`availability_mode: all`, an extra retained topic) would also produce a
distinguishable state — `unavailable` rather than `unknown` — but it needs a
second topic, a second retained message, a change to `_payload()`'s
byte-order-sensitive key construction, and it survives restarts only if the
retention is right. The payload route is one branch in `publish()` and one
constant, and it is graded as **bytes on the topic** rather than as a claim
about HA's availability handling.
### Three states, not two
| state | frame | wire | page |
|---|---|---|---|
| no source (`pv_entity` unset) | no `pv` key | no topic, no entity | no row |
| no reading | `pv: None` | `goodwe_ctl/pv` = `None` | `PV —` + a caution naming the reason |
| zero watts | `pv: 0.0` | `goodwe_ctl/pv` = `0.0` | `PV 0 W` |
A failed read also **clears** the last good value (`_read_pv()` assigns on every
call), so a reading never outlives its own sensor. `test_pv.py` part 2 drives a
good read followed by a dead one, because a fixture that answers the same thing
forever cannot reach that failure — MUTATION.md habit 9's shape.
## 4. It must not become a control input, and that is graded twice
### In the suite
`test_pv.py` part 6 drives twelve cycles of the real `Controller.cycle()`
against a 6 kW export and asserts the commands written to the inverter are
**bit-identical** across `unset` / `no reading` / `0 W` / `8 kW` of production,
with a precondition check that the stimulus commands something at all (habit 1).
`inputs_ok` is asserted not to move either: a broken PV integration is **not** a
missing input, because nothing computes with it.
A computed census (habit 12 — the scope is derived from the tree, not typed)
then reports which of the 33 modules under `app/` mention the material in any
spelling, comments included. It is **three**: `pv.py`, `main.py`, `mqtt.py`. The
scanner's own red direction is exercised on every run.
### Across trees, by hash
`tools/safety56_behaviour_trace.py` is the pattern; `tools/tel11_pv_trace.py` is
this card's version of it, and **its control clause had to be different**, which
is worth stating because the difference is the interesting part.
SAFETY-56 changed wording and had to show behaviour did not move, so its
`--compare` **refuses** unless the `sentences` halves **differ** — otherwise the
two runs might be one tree twice. TEL-11's headline claim is the opposite shape:
with the option unset, **nothing** differs. "The documents are identical" is the
*result*, so it cannot also be the control. The two things proved instead are:
* the two runs came from **different trees** (`app/*.py` sha256 + byte count,
compared, and an identity is **refused**);
* **exactly one** side reacts to `pv_entity` — that side is the HEAD, the other
is a tree that does not have the option. Neither, or both, is refused.
It drives 41 stimulus cells (5 grid worlds × 8 PV schedules, plus a run stopped
part-way) and prints two documents per setting: `control` — every command
written, every `self.target`, `inputs_ok`, the loop's three fields, the
arbiter's resolution, the maintenance phase, tick by tick, **with no TEL-11
material in it** — and `telemetry`, which is everything the card is allowed to
move.
Measured, in the CI image, on purged trees:
```
HEAD is B; baseline is A (it does not react to `pv_entity`).
tree A 73296909170ec789237d6e704cce9ab0d9f83b3df1495a14d2497285c3041a12
tree B d5011b6a18e2e3803dc09cac0214b0378fa4c7388fc0ad8ad89b832a5c92af48
control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B set 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry B set 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
OK 1/3 UNSET: control AND telemetry are byte-identical to the baseline tree.
OK 2/3 SET: the control document is byte-identical with the channel switched on.
OK 3/3 CONTROL OF THE CONTROL: the telemetry document DOES move when the
option is set (42 stimulus cells differ).
```
Note what clause 1 claims and clause 2 does not: **unset** is identical in
`control` *and* `telemetry`, which is stronger than "the control path is
untouched" — an install that does not set the option sees no change of any kind,
including its announcement and its status rows. **Set** is identical in
`control` only, which is the whole of "instrumentation, not an input".
## 5. Genuinely optional — and not a second `price_now_entity`
`price_now_entity` / `price_avg_entity` are legal **only** while
`price_provider` is `off`, and the add-on **refuses to start** otherwise. That
is the trap this card was told not to rebuild, and it is avoided structurally
rather than by intention:
* `validate.py` does not mention `pv_entity` at all. It cannot conflict with
anything, because nothing else reads it.
* the MQTT row lives in `OPTIONAL_SENSORS`, not `SENSORS`, and
`discovery_payloads()`'s new `include` argument **defaults to empty**. So a
caller that has never heard of `pv_entity` — every fixture in `test_mqtt.py`,
`tools/rehearse_roster.py`, anything written next year — announces exactly
what it announced before. `p1_age` solves the same problem the other way
round, with `omit`, because it is announced by default and withdrawn when the
meter is off; getting that direction wrong here would have left most installs
with an entity nothing feeds, which is the hazard `omit`'s own note names.
* the read is gated on the option, so an unset install makes **no state call at
all** — not a read that fails quietly.
* there is **no status row** when it is unset. `peak_forecast_entity` nags when
it is unset because an unset forecast leaves a money guard without an input;
this is instrumentation, and a permanent caution on every install that never
asked for a PV channel is the warning spam the card forbids.
* when it **is** set and the read fails, the row is `ok=False, warn=True` — a
**caution**. `status_payload()` builds `NOT READY` out of rows that are
neither `ok` nor `warn`, so a telemetry channel can never put a battery site
into `NOT READY`.
## 6. The unit is read off the entity, and an unreadable one is refused
`read_inputs()` guesses kW-or-W for `peak_forecast_entity` with
`abs(raw) < 50`, and that is safe there: nobody's quarter-hour demand forecast
is 50 W. It is **not** safe here — 30 W of real production at dusk is an
ordinary reading, and that heuristic would publish it as 30 kW.
So `pv.watts()` reads the entity's own `unit_of_measurement` and converts
against Home Assistant's own `UnitOfPower` table (`mW`, `W`, `kW`, `MW`, `GW`,
`TW`; every factor an exact power of ten). A sensor with **no** unit, or a unit
outside that table, is **refused by name** and the status page says which. The
refusal is loud; a guess would be silent, and a factor of 1000 in the one
channel that exists to be believed a week later is the worse failure.
⚠️ `BTU/h` is in HA's enum and is deliberately **not** in the table: its factor
is a measured conversion rather than a decimal shift, no PV integration
publishes in it, and a fabricated constant is what this repository's whole
process exists to prevent. It lands in the refusal, by name, and `test_pv.py`
part 1 pins that.
## 7. The mutation battery
Every check this card adds was driven by the mutation that defeats the claim it
names. Method: `cp -a` of the tree per mutation into a **unique** directory,
the edit applied there, `__pycache__` purged, the suites run in the CI image
with the tree bind-mounted `:ro`, one file at a time. **The shipped tree is
never edited**, so habit 18's revert hazard — SAFETY-37's harness killed between
the edit and the `cp` that put the file back, and two harnesses sharing one
backup path — is closed by construction rather than by a verified revert. Each
row below records the byte delta, the file's sha256 before and after, and that
the mutated line was **read back off disk** before the run started.
| # | mutation | file | bytes | sha256 (first 16) before → after | red on |
|---|---|---|---|---|---|
| M1 | the `pv` row moved into `SENSORS`, i.e. announced on every install | `mqtt.py` | −79 B | `328d249b9d698ef6` → `532d3954c023bf3d` | 3 named in `test_mqtt.py`, 1 in `test_pv.py` |
| M2 | `publish()` drops a `None` `pv` row again, so HA keeps the last number | `mqtt.py` | −98 B | `328d249b9d698ef6` → `dd54be458f446475` | 1 named (the acceptance criterion itself) |
| M3 | `ABSENT_AS_UNKNOWN` widened past the one key it was written for | `mqtt.py` | +23 B | `328d249b9d698ef6` → `8fe571ebff3dacf4` | 1 named (`circulation` must still be dropped) |
| M4 | a dead sensor reported as **0 W** instead of an absence | `pv.py` | −1 B | `55ba7471e9995436` → `9eecc34152b7ac12` | 4 named |
| M5 | `_read_pv()` assigns only on a good read — a value outlives its sensor | `main.py` | +46 B | `c4d2cab34e50b5fd` → `189802aa69ce166d` | 2 named |
| M6 | the unit is read and then ignored — a kW sensor published as watts | `pv.py` | −18 B | `55ba7471e9995436` → `ab19ef66c52f9379` | 2 named |
| **M7** | **`self.grid = self.grid − self.pv_w` in `read_inputs()` — PV wired into the control path** | `main.py` | +106 B | `c4d2cab34e50b5fd` → `c38216edb7c5c8db` | **2 named, plus `tel11_pv_trace.py` clause 2/3 on 26 of the 41 stimulus cells** |
| M8 | `optional_keys()` returns `("pv",)` unconditionally | `mqtt.py` | −52 B | `328d249b9d698ef6` → `86ff23efe3088ad8` | 3 named |
| M9 | the PV caution made a `bad` row, i.e. a dead sensor is a `NOT READY` | `main.py` | +1 B | `c4d2cab34e50b5fd` → `45c74cab89943564` | 1 named |
| M10 | the read gate removed — an unset option is read once a second forever | `main.py` | −10 B | `c4d2cab34e50b5fd` → `b67baa8aba3019e8` | 2 named, **plus two crashes** |
`on_disk = True` for all ten: the mutated line was grepped back out of the file
after the edit and before the run (MUTATION.md reporting rule 2), and no
mutation is size-preserving, so rule 1's `+0 B` caveat does not arise.
⚠️ **M10's two extra reds are CRASHES, not named checks, and that is recorded
rather than tidied away.** Every `Hass` double in `test_inverter.py` and
`test_telemetry.py` predates this read and has no `state()` method, so an
unconditional PV read is an `AttributeError` rather than a failed assertion —
habit 20's shape, a red with no name attached. Fixing those fixtures would be a
change to what *their* checks report and belongs to whoever next touches them;
this card's own fixture has `state()` and grades the property by name.
⚠️ **M7's trace limb is where the two halves meet.** The mutant moves the
control document on 26 of 41 cells — every cell whose PV schedule produces a
READING. The five `none` cells and the five `zero` cells do **not** move, which
is correct and is worth seeing: subtracting nothing, and subtracting zero,
change nothing. A battery that only drove a dead sensor would have reported this
mutation as a survivor for the third time.
The clean tree those `before` hashes belong to: `app/main.py`
`c4d2cab34e50b5fd`, `app/mqtt.py` `328d249b9d698ef6`, `app/pv.py`
`55ba7471e9995436`.
### ⚠️ M7 was a SURVIVOR on the first battery, and the fixture was the reason
The card's **central** mutation — `self.grid = self.grid - self.pv_w` in
`read_inputs()`, i.e. exactly the defect the whole card forbids — came back
**green** on a six-file run at **+106 B**, on disk, purged, genuinely running.
The property did not hold; the fixture could not reach it. `test_pv.py` part 6
drove a **6 kW export over 12 cycles**, which puts the loop **against its
rail**: the command moves `slew_w` every cycle and −6000 W and −14 000 W of
apparent error produce the **same ramp, tick for tick**. MUTATION.md habit 23,
with a saturated actuator in place of a coinciding value — and the habit's list
of no-op-prone constructs is all *expressions*, so this is worth its own entry,
which it now has.
The fixture now drives a **600 W import the loop settles on**, with a
responsive meter (`grid = base − batt`) so the error actually closes, asserts
the resting target is well inside the rail, and asks habit 23's own question as
its own check: **shift the meter by the same 8 kW and confirm the commands
really would have moved.** On the first fixture that question answers *no*, in
one line. Every number in the table above is from the battery re-run against
the fixed tree.
⚠️ **A red count is a property of the suite as much as of the mutation.** These
name **this** tree at this commit; a later card that adds or re-bases a check
moves the number with the mutation untouched. Re-measure rather than inherit.
## 8. What none of this covers, with shapes
* **Home Assistant itself.** That `None` renders as `unknown` and that the
recorder keeps a gap is read out of HA's source and quoted in §3; no HA runs
in this suite. What is graded here is the bytes this add-on publishes.
* **Whether the operator's sensor tells the truth.** A sensor wired backwards,
or reporting DC where AC was meant, or a string instead of the array, is
published faithfully. §2 is the argument for why that is the right failure
mode; it is not a claim that it cannot happen.
* **A roster of more than one.** The row is a **site** row — there is one PV
array as far as this add-on is concerned — so nothing per-unit exists to
separate, and every fixture is the shipped roster of one.
* **The attribution.** This card records production. Whether the 20.5 kWh was
cloud, a limit or the optimiser is OPT-10's, and it needs a week of this
channel's history before it can be asked.
* **`tools/`, the firmware and the ingress HTML** are outside part 6's census.
* **The identity is only as wide as the stimulus.** `tel11_pv_trace.py` prints
its 41 cells with the documents for exactly that reason.
## 9. No `CHANGELOG.md` entry
`test_version_gate.py` 11b refuses a newest `## ` heading that is not
`config.yaml`'s `version:`, and opening an `## Unreleased` section would mean
taking a packaging decision this card does not get to take. The operator account
is in `DOCS.md`; the release note belongs to the next PKG ticket, which should
say that **0.10.1 → next** adds one optional key, announces no new entity on an
install that does not set it, and changes no control behaviour.
@@ -0,0 +1,359 @@
# TEL-12 — several PV inverters, and why a partial total is worse than none
TEL-11 shipped `pv_entity` as a single `str?` in 0.11.0. The owner of the
reference site reported **within minutes of it going live** that they have
several PV inverters and could configure one.
That is not a property of one house. A multi-string or multi-orientation roof
routinely carries two or three inverters, each publishing its own power sensor
into Home Assistant, and the add-on is sold to clients. So the channel had to
become a roster.
**OPT-10 is blocked on this and cannot start recording until it lands.** Which
sets the bar: the number this channel publishes has to be one an attribution can
believe, or the week of history OPT-10 is waiting for is worse than no history.
---
## 1. The surface: `pv_entities` beside `pv_entity`, and the measurement that
decided it
Two candidates, and they differ on exactly one question nobody in this
repository could answer by reading: **does Supervisor accept a stored STRING for
a key whose schema has become a LIST?** 0.11.0 shipped `pv_entity: str?`; the
live install *has* a string persisted in it; and an upgrade that refuses that
string leaves an add-on which will not start — CFG-04 §B's trap, in the other
direction, with the schema needed to fix it pinned to the version that cannot be
installed.
It was measured, against the release image, by a new **§G** of
`tools/supervisor_shape_probe.py` — the instrument CFG-04 built for exactly this
kind of question:
```
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" --entrypoint python3 \
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
/probe/supervisor_shape_probe.py
```
```
== G. TEL-12: the SHAPE of a several-inverter PV option ==
PASS the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED - the baseline every refusal below is varied from
pv_entity='sensor.pv_power' pv_entities=[]
PASS ⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as a LIST, the string an 0.11.0 install has persisted is REFUSED, and the refusal names the key - so that shape would leave the live add-on unable to start on upgrade
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's list strategy is `override`, so the operator's string survives the merge and is refused anyway
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...(the list shape itself is fine - it is only the STORED STRING it refuses, which is what makes this a migration and not a schema question)
a list value validates
PASS ⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, a list passes, and so does the integer 417; and the Configuration tab renders it as a plain text box rather than a repeatable field, so an operator cannot enter a second inverter in the UI at all
string=True list=True integer-417=True ui=[{"name": "pv_entity", "optional": true, "type": "string"}]
PASS ⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no `pv_entities` key at all, because it did not exist when that blob was written - validates against THIS manifest, with the new key defaulted to the empty list. The live install upgrades without touching its options
PASS ...and a three-entity roster validates, per-entry, with both keys set - which the add-on resolves by REPLACEMENT and says so on its status page
["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
PASS ⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the `pv_entities: []` default is load-bearing, exactly as `inverters: []` is: Supervisor treats a nested list as required unless its element is an OPTIONAL scalar, and `- str` is not one
Missing option 'pv_entities' in root in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...and the Configuration tab renders it as a REPEATABLE string field (`multiple: true`), so an operator adds their second and third inverter in the UI rather than in YAML
[{"name": "pv_entity", "optional": true, "type": "string"}, {"name": "pv_entities", "multiple": true, "required": true, "type": "string"}]
PASS a DOWNGRADE to a schema without `pv_entities` drops the key with a warning rather than refusing, and leaves `pv_entity` standing - so rolling back to 0.11.0 returns the install to its single entity rather than bricking it
== all cases passed ==
```
| Claim | Evidence |
|---|---|
| **Option 1 — `pv_entity` becomes a list — cannot ship** | §G: `Invalid list for option 'pv_entity'` on the string an 0.11.0 install has stored |
| …and a `[]` default does not rescue it | §G: `_OPTIONS_MERGER`'s list strategy is `override`, so the operator's string survives the merge and is refused anyway |
| …and it is not a *schema* problem | §G: a list **value** validates fine against a list schema. It is a **migration**, and a migration is a bigger ticket than this |
| **One key CAN carry both shapes** — `match(.*)?` | §G, and reported even though it *passes*, because "no spelling accepts both" would have been a **false reason** to reject option 1 |
| …and it loses anyway, for a measured reason | §G: it validates nothing at all — a string, a list, and the integer `417` all pass — and the Configuration tab renders it as a plain text box, so an operator cannot add a second inverter in the UI |
| **Option 2 — `pv_entities` beside `pv_entity` — ships** | §G: the 0.11.0 blob validates untouched, the new key defaults to `[]`, three entities validate, the UI renders `multiple: true` |
| `pv_entities: []` in `options:` is load-bearing | §G: the shipped defaults **minus only that key** are refused with `Missing option 'pv_entities' in root` — CFG-04 §B's isolated counterfactual, for this key |
| A downgrade is not the trap in reverse | §G: a schema without the key **drops** it and leaves `pv_entity` standing |
`p1_phase_import_entities` (`options: []` + `schema: - str`) is the shipped
precedent for the pair; nothing new was invented.
**So the surface is uglier than option 1 and that is the correct trade.** The
cleanest surface that breaks the one install this card exists to fix is not the
cleanest surface.
### And `pv_entities` REPLACES `pv_entity` rather than extending it
CFG-04's rule for `inverters:` ("declared, it REPLACES those six keys"), applied
to the only upgrade path that matters. The install being upgraded **has**
`pv_entity` set, to inverter A. Opening the Configuration tab, the natural action
is to put *all* the inverters in the new repeatable field — and under a union
rule that **double-counts A** unless the operator also remembers to clear the old
key. A doubled total is the same defect as a partial one pointing the other way,
in the one channel that exists to be believed a week later.
Under replacement the effective roster is exactly what one key says, and no
arithmetic depends on a de-duplication rule the operator has to know about. It
de-duplicates *within* the list (a site cannot have two sensors with one
entity_id, so a repeated id is a typo that would double that inverter), and
`Controller.checks()` prints a **caution** whenever both keys are set saying
which one won — a replacement nobody is told about is the "one of which silently
wins" `config.yaml` refuses elsewhere.
⚠️ It is a **caution and never a refusal to start**. `validate.py` still does not
mention either key. A telemetry channel that could stop a battery site from
starting would be observability with a foot in the control path.
---
## 2. ⚠️⚠️ The decision the card turns on: absence of ONE is absence of the TOTAL
Three inverters configured, one `unavailable`. The sum of the other two is **a
smaller number indistinguishable from a real one.** Fed into OPT-10's
attribution it reads as *"there was less PV than there was"* — which is the "no
irradiance" conclusion the channel exists to **test** rather than to manufacture.
Two designs were genuinely available:
**(a) publish the total as absent** — `pv.total()` answers `None`, the topic
carries HA's own `None`, the entity goes to `unknown`, the recorder stores a gap.
**(b) publish the partial sum alongside a count of how many contributed.**
**(a) shipped.** It wins on the one question that matters, which is *who reads
the number and when*:
* The **MQTT row** is read back **cold**, a week later, by OPT-10 and by anything
that plots `sensor.goodwe_pv_power` over that week. A partial sum there is a
smaller number indistinguishable from a real one, and the count that would
have qualified it lives in a **second entity with its own history** — so
believing the total requires joining two recorder series by timestamp to find
out which samples to distrust. Nobody re-derives that. And it is worse than a
gap in exactly the direction that matters: **a gap makes the attribution say
"I do not know"; a partial makes it say something false.**
* The **status page** is read **live**, by a human who wants the diagnostic. So
the partial information is *not* thrown away — the row names every entity that
did report, with its watts, and every one that did not, with its reason, and
states plainly that there is no site total. Different reader, different
instant, different rule.
A count published beside the total would also carry **no information** in the
state (a) admits: the total is published *only* when every entity contributed, so
the count would be the constant `len(roster)`.
And this **extends** TEL-11's rule rather than weakening it. `mqtt.py`'s
`ABSENT_AS_UNKNOWN` is still exactly one key and `publish()`'s `None` handling is
untouched: the line that turns an absence into HA's `unknown` did not have to
change, because "absence is never zero" already covered this one layer up.
### The mirror, which matters just as much
A member honestly reporting **0 W** is **summed**, not treated as absent. The
test is `watts is None`, never falsiness. Shading, a tripped string and
nightfall are all real zeros, and a roster that blanked the site whenever one
member read zero would report an absence **on every clear evening** and defeat
the channel from the other end.
### ⚠️ And the answer is NOT "document a template sensor that adds them up"
An operator may legitimately prefer one, and `pv_entity` will happily point at
it. Shipping it as *the* answer pushes the absence problem onto them unseen: a
template sensor summing an `unavailable` source silently yields the partial
total — the same defect one layer down, in a place this add-on cannot see or
report. DOCS.md says so in those terms.
### The three published states, side by side
| what happened | frame | `goodwe_ctl/pv` | status page |
|---|---|---|---|
| no roster at all | no `pv` key | no topic, no entity | no row |
| 1200 / 2100 / 450 W, all three | `pv: 3750.0` | `3750.0` | `PV 3 750 W` + one ok row naming all three |
| all three answer, one of them `0 W` | `pv: 1650.0` | `1650.0` | `PV 1 650 W` |
| one of the three `unavailable` | `pv: None` | `None` → `unknown`, recorder **gap** | `PV —` + a caution naming the missing entity, its reason, and what the others reported |
| all three gone | `pv: None` | as above | as above |
---
## 3. Still not a control input
Unchanged, and re-proved at the new roster size.
`tools/tel11_pv_trace.py` was **extended rather than replaced** — the file name
still says TEL-11 because `DECISIONS/TEL-11-pv-production.md` §5 and
`test_pv.py`'s mutation table quote it, and a renamed instrument makes both
references dangle for no gain.
It now produces **three** document sets per tree — `unset`, `one`, `multi` — and
grades five claims. The roster cells live in their own document precisely so
`unset` and `one` keep the `control` hashes TEL-11 recorded; appending cells to
an existing document would have moved a hash whose stability is itself the
evidence.
```
$ python3 tools/tel11_pv_trace.py --compare base.json head.json
HEAD is B; baseline is A - its roster cells ARE its unset cells, which is what a tree that has never heard of `pv_entities` does with them.
supports_roster A False / B True (self-reported; the verdict above is MEASURED)
tree A fa92ba6a6fc6d49c0f87a494e03b530a8575212a0b2c3046629cb400ef2ae0d4
tree B 0b39198015b6a5a3f7538d4157d77ecadc89311daef18fce883762d94d8351eb
control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control A one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control A multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
control B multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry A one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
telemetry B one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
telemetry A multi d269168ffece375027c2c18ea33631a961ff92775781884a0592d6a37d78845c
telemetry B multi 550a49a73f39267ba28b1e78cd12b8f5c71ef7269bbbd2e12112696be0fef1ff
OK 1/5 UNSET: control AND telemetry are byte-identical to the baseline tree. …
OK 2/5 ONE ENTITY: control AND telemetry are byte-identical to 0.11.0's single-entity path - the same commands, the same payloads, the same status rows, the same announcement.
OK 3/5 CONTROL, ONE ENTITY: the control document is byte-identical with the channel switched on - PV reaches no decision.
OK 4/5 CONTROL, THREE ENTITIES: every roster cell's control document equals its grid world's `unset` one - 30 cells over 5 grid worlds, covering three healthy entities, one dead at EVERY position, all three dead and one honestly reporting 0 W. Neither the total nor any PARTIAL total reaches a decision, and the document is byte-identical to the BASELINE tree's for the same cells.
OK 5/5 CONTROL OF THE CONTROL: the telemetry document DOES move under a roster (30 stimulus cells differ from their grid world's unset telemetry), so 1/5-4/5 are identities this instrument could have broken. AND in no cell with a dead member does the PV row carry the partial sum of the survivors, while every cell whose members all ANSWER publishes its total - including the one where a member honestly reports 0 W.
$ echo $?
0
```
**The control hash is unmoved: `846a10a5b89e…` on `unset` and on `one`, on both
trees.** The roster control document `2aafbfedfb9c…` is byte-identical between
the two trees, which is the strongest available form of the claim — the baseline
runs those cells with a key it has never heard of, so its control document for
them *is* 0.11.0's.
⚠️ **2/5 is a claim TEL-11 could not make.** TEL-11's baseline had no channel at
all to be identical to; TEL-12's baseline is 0.11.0, so "one entity behaves
exactly as 0.11.0 does" is a real cross-tree identity over **both** documents.
`pv.total()` passes a single entity's `(watts, why)` through verbatim for exactly
this reason, and `Controller.checks()` keeps 0.11.0's two f-strings in a
`len(...) == 1` branch rather than generalising them.
### ⚠️ TEL-11's `--compare` refusal had to change, and that is a finding
TEL-11's rule was *"exactly ONE side's `set` document may differ from its own
`unset` — that side is the HEAD"*. It was correct evidence when written, because
`release/1.0` predated `pv_entity` entirely. `release/1.0` is 0.11.0 now, **both**
sides react to `pv_entity`, and that refusal fires on **every honest run**. The
discriminator is the roster instead: only the HEAD has heard of `pv_entities`, so
only the HEAD's `multi` document fails to reduce to its own `unset` one.
It is asserted from the **documents**, not from `tree.supports_roster` — a
self-report is the tree describing itself, whereas "this side's behaviour did not
move when `pv_entities` was set" is the tree being measured. `supports_roster` is
printed beside the verdict so a disagreement between the two is visible.
See MUTATION.md habit 24.
---
## 4. Mutation battery
Twelve mutations, each on a **fresh `cp -a`** of the clean worktree (never
reverted — habit 18 / TARIFF-09's method), `__pycache__` purged before the run
and the tree checked for one afterwards, byte delta from `stat` and the edit
confirmed on disk with `cmp`, suites run in the CI image with `checks.yml`'s own
command one file at a time under a `:ro` mount.
| mutation | bytes | reds |
|---|---|---|
| N1 `total()` returns the partial sum, not an absence | +42 B | 6 named in `test_pv.py` + 1 crash |
| **N1b ⭐ THE REJECTED DESIGN: the partial sum with a contributor count** | −282 B | **10 named + the trace `REFUSED 5/5`** |
| N2 absence tested by falsiness (`not w`) | −4 B | 2 named |
| N3 `pv_entities` UNIONS with `pv_entity` | −27 B | 3 named |
| N4 the de-duplication dropped | −15 B | 1 named |
| N5 `sum` → `max` over the roster | **+0 B** | 6 named |
| N6 the roster-of-one stops passing `why` through | +13 B | 8 named |
| N7 `optional_keys()` back to reading `pv_entity` | −6 B | 1 named + 1 crash |
| N8 `self.grid -= self.pv_w` in `read_inputs()` | +106 B | 3 named + the trace (52 cells on 2/5, 26 on 3/5, 10 on 4/5) |
| N9 the roster caution made a `bad` row (NOT READY) | +1 B | 1 named |
| N10 `entity_is_ignored()` always False | +10 B | 2 named |
| N11 `pv_entities: []` deleted from `options:` | −18 B | 2 named in `test_translations.py` + `11 CASE(S) FAILED` in the probe |
**No survivors.**
⚠️ **N5 is `+0 B` — the risky delta.** `sum` and `max` are the same three
characters, so neither the file size nor its mtime moved, and TEST-15's
stale-bytecode defect is exactly what manufactures a survivor there. The run was
on a fresh purged copy and `cmp` confirmed the edit on disk before it started.
⚠️ **N1b is the row that matters, and it is not a defect.** It is the *other*
defensible design, implemented and run. The point is that the checks and the
instrument can **tell the two apart** — an unexamined partial sum is what the
card forbids, so a check that could not see one would be decoration:
```
REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at [('EXPORT-6K/EAST-DEAD', '2550.0'),
('EXPORT-6K/GARAGE-DEAD', '3300.0'), ('EXPORT-6K/WEST-DEAD', '1650.0'),
('IMPORT-3K/EAST-DEAD', '2550.0'), ('IMPORT-3K/GARAGE-DEAD', '3300.0'),
('IMPORT-3K/WEST-DEAD', '1650.0')] … - a smaller number indistinguishable from a
real one, which is the defect this card exists to prevent.
```
⚠️ **N8's 1/5 stays GREEN and that is correct rather than a miss.** With no PV
entity configured `pv_w` is `None` and the leak cannot fire — which is what makes
1/5 a claim about the **default** and 2/5–4/5 the claims about the channel.
### The fixture values are the design (habit 10 and habit 23)
`test_pv.py` carries **two** rosters over the same three entities, because the
two claims need different magnitudes and one fixture cannot serve both.
* **Parts 8–10: 1200 W + 2.1 kW + 450 W = 3750 W.** The sum is separable from
max (2100), min (450), first (1200), last (450), mean (1250), count (3) and
every partial of itself (2550 / 1650 / 3300) — the family is computed, printed
and asserted non-colliding as its own precondition. One member is in **kW** so
"correct against their own units" is exercised. **Not one member is zero**: a
member contributing 0 W could go absent without moving the sum of the rest, and
a fixture like that cannot tell "published the absence" from "published the
partial sum" — habit 23 with a coinciding value in the *fixture* rather than in
the plant, which is precisely the trap the brief named. Absence is driven at
**every position** of the roster, not at one (habit 10's fixture-as-a-SET), and
each position's partial is **derived from the fixture** rather than typed
beside it.
* **Part 6: 1500 W + 4.5 kW + 2000 W = 8000 W.** Sized so every *partial*
(6500 / 3500 / 6000) would still **move** a loop settling on a 600 W import.
Habit 23's own question is asked twice — shift the meter by 8000 W, and again
by 3500 W, the smallest partial any single dead member could produce — because
a leak of the partial is a different mutation leaking a smaller number.
### Two false results this card produced, and what they were
Both were in the **instrument**, and both are recorded rather than tidied away.
1. `partial_w`/`partial_wire` were first written **inside** each telemetry cell.
They are a fact about the *stimulus*, produced by the tool and identical on
both trees — so every roster cell differed from its unset reference for a
reason that was not behaviour, and the HEAD/baseline discriminator fired on
*both* sides. They live in a separate top-level `partials` map now.
2. The `WEST-ZERO` cell (all three answer, one honestly reporting 0 W) had its
**true total** labelled as its "partial". 5/5 then refused the *shipped* tree
for publishing 1650 W, which it is required to publish. A cell with no dead
member has no partial to leak, and `never` is `None` there.
---
## 5. What none of this can see
* **Home Assistant itself.** That `None` on the state topic renders as state
`unknown` and that the recorder keeps a gap is read out of HA's source and
quoted in `app/mqtt.py`; whether the long-term statistics table then
interpolates across the gap is HA's behaviour, and OPT-10 will be reading it.
* **Whether the operator's sensors are telling the truth.** A sensor wired
backwards reads −4 kW at noon and is summed as −4 kW. There is deliberately no
`pv_invert` — §2 of the TEL-11 record is the argument, and it holds at a roster.
* **A roster larger than three.** Three is the smallest size at which the sum
separates from the reduction family *and* absence can be driven at every
position. Nothing here says what twenty entities do to a cycle's latency — the
read is `asyncio.gather`ed across the roster for exactly that reason, so a slow
sensor costs what one costs, but that is an argument rather than a measurement.
* **A PV roster combined with a two-inverter battery roster.** No fixture
crosses CFG-04's `inverters:` with `pv_entities`.
* **The real MQTT transport, and wall-clock behaviour.** As TEL-11.
---
## 6. Not deployable as merged
`config.yaml` stays at `0.11.0`, so `tools/predeploy_check.py` reports a NO-OP
until PKG cuts a version. A CHANGELOG entry is **wanted** and was deliberately
not written: `test_version_gate.py` 11b forbids an `## Unreleased` heading and
the bump is PKG's call. The material an entry needs is this file.
The option surface moves **76 keys → 77**, one new optional key, `pv_entities`,
empty on every install that does not set it.
+593 -34
View File
@@ -4,8 +4,8 @@ Drives a GoodWe ES/BP battery inverter over its RS485 meter bus: holds net grid
exchange at zero, and runs a monthly battery maintenance cycle so the BMS can
balance cells and recalibrate its coulomb counter.
Installers: read `FIELD-GUIDE.md` in the repository. It is not optional reading —
it contains the commissioning gates and the failure modes.
Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
v0.1.0 and outdated; where it disagrees with this page, this page wins.
## Before you start
@@ -139,6 +139,8 @@ either direction, and the check is mutation-tested both ways.
| `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) |
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) |
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
those serve cached values, and a stale reading here ends the maintenance charge
@@ -163,12 +165,14 @@ inverters:
soc_entity: sensor.goodwe_a_soc
batt_entity: sensor.goodwe_a_battery_power
max_w: 5000
bms_soc_floor: 10
capacity_kwh: 15.36
- name: inverter-b
setpoint_entity: number.goodwe_b_setpoint_w
soc_entity: sensor.goodwe_b_soc
batt_entity: sensor.goodwe_b_battery_power
max_w: 4600
bms_soc_floor: 10
capacity_kwh: 20.48
```
@@ -188,16 +192,18 @@ inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
| `setpoint_entity` | yes | This inverter's own ESPHome `number.*_goodwe_setpoint_w` |
| `max_w` | yes | The supervised cap on what may be written to **this** inverter. The site's ceiling is the **sum** |
| `bms_soc_floor` | yes | This pack's own BMS cut-out percent (SAFETY-32). See [`bms_soc_floor`](#tariff-all-optional) |
| `soc_entity` | | This pack's state of charge |
| `batt_entity` | | This unit's battery power, `+` = discharging |
| `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs-opt-09) |
⚠️ **Nothing is inherited.** An entry that leaves out `soc_entity` has **no SoC
sensor** — it does *not* fall back to the flat one. That is deliberate: every
per-device value became per-instance in 0.5.x precisely so that no inverter is
ever graded against a sibling's numbers, and a silent fallback would rebuild
that in YAML. `name`, `setpoint_entity` and `max_w` are required of every entry
for the same reason: there is no rating to borrow.
that in YAML. `name`, `setpoint_entity`, `max_w` and `bms_soc_floor` are required
of every entry for the same reason: there is no rating or cut-out to borrow.
⚠️ **Clear the flat `setpoint_entity` when you declare a roster,** or the add-on
refuses to start and says so. It is the single-inverter form and it is not read
@@ -287,8 +293,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,13 +329,255 @@ 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
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
get then depend on roster order. Give your inverters plainly different names.
### PV production (`pv_entity`, TEL-11)
> **More than one solar inverter?** Use `pv_entities` instead — see
> [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) below. The
> rest of this section applies to both.
**Optional, off by default, and it changes no control behaviour whatsoever.**
Set it and the add-on records what your solar is producing beside what the
meter and the battery are doing. Leave it empty and the add-on reads no entity,
announces no entity, publishes no topic and shows no row — which is what every
install that upgrades into this version does.
#### Why it is a Home Assistant entity and not a register
This add-on's RS485 bus carries the **battery** inverter it drives. On nearly
every site the PV inverter is a different box from a different vendor, on its
own connection, reporting into Home Assistant through its own integration. So
`pv_entity` takes whatever `sensor.*` your PV integration already provides —
any brand Home Assistant can read. It does not have to be on the bus, and there
is no assumption anywhere that it is.
#### What it is for
OPT-10 measured this site over sixteen days: the battery reached 90 % on **two
of them**, and **20.5 kWh of surplus left the site while the pack still had
room** — 12.6 kWh of it with the battery not charging at all. Three different
things produce that number and they need three different fixes:
* there was no irradiance to store — nothing to fix;
* production was there and something limited the charge power;
* production was there, the battery could take it, and the optimiser declined.
Only the third is a software defect, and **none of them can be told apart
without a record of production.** This entity is that record. The attribution
itself is OPT-10's, with a week of history behind it.
#### The sign, and why there is no `pv_invert`
**Positive must mean producing.** That is the same convention as the two sign
rules the add-on already ships — `meter_entity` is positive when **importing**
and `batt_entity` is positive when **discharging**, both of which are "power
arriving at the house" — and production arrives there too.
`meter_invert` and `batt_invert` exist because those two quantities are
**bipolar and are read by the control path**: a reversed battery sensor is a
perfectly plausible number that the per-inverter sign failsafe then acts on.
Production is unipolar and is read by **nothing**, so a reversed sensor here
cannot mislead a decision — and it announces itself immediately as −4 kW at
noon in the very history this entity creates. A flag would only hide it, so
there is not one. If your sensor really is inverted, fix it where it is
produced.
#### The unit is read off the entity
The add-on reads your entity's own `unit_of_measurement` and converts: `mW`,
`W`, `kW`, `MW`, `GW`, `TW` — Home Assistant's own power units. A sensor with
**no** unit, or with a unit that is not one of those, is **refused**, and the
status page says which entity and which unit. That is deliberate: there is no
magnitude test that can separate 30 W of dusk from 30 kW of noon, so a guess
here would be a silent factor of 1000 in the one channel that exists to be
believed a week later.
#### ⚠️ Absence is never zero
| what happened | `sensor.goodwe_pv_power` | on the status page |
|---|---|---|
| neither `pv_entity` nor `pv_entities` set | the entity does not exist | no row at all |
| set, sensor answered `4100 W` | `4100.0` | `PV 4 100 W` |
| set, sensor answered `0 W` | `0.0` | `PV 0 W` |
| set, sensor unavailable / missing / bad unit | `unknown` | `PV —`, plus a caution naming the reason |
The last two rows are the point. A dead sensor publishes Home Assistant's own
"no value" payload, so the entity goes to **`unknown`** and the recorder stores
a **gap** — not a flat line at the last number it saw. Reading a week of
history back, *"no PV was reported"* and *"PV reported 0 W"* are different
pictures, which is exactly the distinction the whole card exists to preserve.
A failed read also **clears** the previous good one: the value never outlives
its own sensor.
### Several PV inverters (`pv_entities`, TEL-12)
A multi-string or multi-orientation roof routinely carries **two or three PV
inverters**, each publishing its own power sensor into Home Assistant. List them
all and the add-on records the **site total**:
```yaml
pv_entities:
- sensor.pv_east_power
- sensor.pv_west_power
- sensor.pv_garage_power
```
In the Configuration tab this is a repeatable field: press the **+** and add a
row per inverter. Each entity's **own** `unit_of_measurement` is read, so a
kW inverter beside a W one adds up correctly.
#### It replaces `pv_entity` — clear that one
Setting `pv_entities` makes `pv_entity` **ignored**, and the status page says so
until you clear it. That is deliberate rather than tidy: if the two were *added*
together, an upgrade where you leave your first inverter in `pv_entity` and also
list it here would count it **twice**, and a doubled total is as wrong as a
missing one in the one channel that exists to be believed a week later.
#### ⚠️⚠️ If ANY of them stops reporting, there is NO site total
This is the whole reason the key is shaped this way, so it is worth being plain
about:
| what happened | `sensor.goodwe_pv_power` | on the status page |
|---|---|---|
| all three reporting 1200 / 2100 / 450 W | `3750.0` | `PV 3 750 W` |
| all three reporting, one of them **0 W** | `1650.0` | `PV 1 650 W` |
| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which entity, why, and what the others reported |
| all three gone | `unknown` | as above |
**It does not publish the sum of the ones that answered.** Two inverters out of
three add up to a smaller number that is *indistinguishable from a real one* —
and read back a week later that says *"there was less sun than there was"*,
which is precisely the conclusion this recording exists to **test** rather than
to manufacture. A gap makes the analysis say *"I do not know"*; a partial makes
it say something false. So the total goes to `unknown` and the recorder stores a
gap, exactly as it does for a single dead sensor.
The diagnostic is not thrown away — it is put where a person will see it. The
status page names the entity that is missing, the reason, **and** what the
others reported. That page is read live by a human who wants to fix something;
the recorded number is read cold by an analysis that would believe it.
Note the **third row against the second**: a member honestly reporting `0 W` is
**summed**, not treated as missing. Shading, a tripped string and nightfall are
all real zeros, and blanking the site whenever one member read zero would report
an absence on every clear evening.
#### ⚠️ Not "just use a template sensor that adds them up"
You may legitimately prefer a template sensor, and `pv_entity` will happily
point at one. But it is **not** the answer to this problem: a template sensor
summing an `unavailable` source yields the partial total *silently*, which is
the same defect one layer down — in a place this add-on cannot see and cannot
report to you.
#### ⚠️ What it does NOT do
It is **instrumentation**. It is not an input to the control law, the
allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog;
it is not part of `inputs_ok`, so a PV integration that breaks cannot stop the
battery being regulated; and its status row is a **caution**, never a
`NOT READY`. Wiring production into the control law would be a behaviour change
on a system driving a real battery — and it would destroy the measurement,
because the loop would then be reacting to the thing being measured.
`tools/tel11_pv_trace.py` is what says so rather than claims it: it drives 41
stimulus worlds through the shipped controller and hashes a document of control
decisions containing none of this material, on this tree and on `release/1.0`,
with the option set and unset.
### P1 meter ingestion
`meter_entity` above expects one signed sensor, which usually means a template
@@ -354,7 +604,7 @@ than in YAML nobody reviews underneath a safety input.
> `ha_signed`.** It reads the same meter and produces the same numbers, but it
> polls the meter directly instead of watching a Home Assistant entity — and
> that is the difference between an age sensor a watchdog can threshold and one
> it cannot. See "`sensor.p1_sample_age_s`" below; `ha_signed` remains for
> it cannot. See "`sensor.goodwe_p1_sample_age`" below; `ha_signed` remains for
> installs where the meter is only reachable through Home Assistant.
> ⚠️ **`ha_dsmr` and `mqtt_p1` have never processed a telegram from real
@@ -441,6 +691,35 @@ next 10 s publish. There is **no option to configure**: the 35 s is derived from
the ESPHome publish interval and the two watchdog windows it has to stay inside,
and the add-on's own checks refuse a value that does not.
⚠️ **AND IF THE REGISTER DOES NOT REPORT A DIFFERENT VALUE, THE ADD-ON STOPS
WAITING AFTER 17 s (SAFETY-57).** This paragraph used to end at the sentence
above, and on 2026-09-21 that turned out to be a promise the state could not
keep: at 0 W **nothing is asking the register to be anywhere**, so "report a
different value" is evidence a parked site cannot ask for. The live site sat at
0 W for **790 s** while the house exported 2.4 kW, on a register that was
working perfectly — the pack was simply at its floor and the BMS was refusing
the discharge — and it was freed by a person restarting the add-on.
So: once the standing command has been **under 250 W for 17 s**, the verdict is
**withdrawn** and the register is graded again from scratch over a fresh 35 s.
The whole parked interval is now `stale_input_s` + 17 s — **32 s at the
defaults** — and it does not depend on what the battery decides to do.
| in the action log | what it means | what to do |
|---|---|---|
| `STALE <unit>` alone, then nothing | the register stopped following and has not come back | look at the unit — comms, the ESP32, the inverter |
| `STALE <unit>` then `STALE WITHDRAWN <unit>`, **once** | the pack was probably empty or full and the BMS refused the command. The site has resumed | nothing |
| the pair repeating every ~70 s | the register really is not following, and the add-on is re-testing it | look at the unit; the site is regulating on the meter in between |
⚠️ **`STALE WITHDRAWN` IS NOT "RECOVERED", AND THE LOG LINE SAYS SO ITSELF.**
Nothing has been learned about the register when it is printed; the verdict is
withdrawn because it can no longer be *tested*, and the next 35 s are the test.
During those 35 s the held number reaches the site's telemetry again. That is
deliberate and it is bounded: 35 s is shorter than both the circulating-power
hold (75 s) and the per-unit sign hold (85 s), so neither of those alarms can be
made to say anything by a held number, and the command itself is derived from
the **meter**, never from this register.
⚠️ **What this does NOT catch: a register whose value is WRONG rather than
STOPPED**, including a dead publisher whose last two samples happen to
alternate. Detecting that needs an arrival stamp inside the ESP32 firmware
@@ -480,7 +759,7 @@ connection-level reading is still accepted on its own.
**Why this mode exists:** every HTTP response is an *arrival*. The meter
answered, now, with its current reading — whether or not the number moved. That
is the signal `sensor.p1_sample_age_s` needs and the one Home Assistant cannot
is the signal `sensor.goodwe_p1_sample_age` needs and the one Home Assistant cannot
give it at all (see the note below). It is also simply fewer moving parts: the
five-second cadence is the meter's own, rather than an integration's polling of
it re-published as a state change.
@@ -517,12 +796,13 @@ as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real
power that figure stays in the seconds; minutes of it while the load is clearly
not near zero is the meter to go and look at.
#### `sensor.p1_sample_age_s`
#### `sensor.goodwe_p1_sample_age`
Published over MQTT discovery whenever a broker is available: **seconds since the
newest accepted telegram**, refreshed every second rather than only when a
telegram lands. The ESP32's stale-input watchdog subscribes to this exact entity
id, so do not rename it.
id; do not rename the `GoodWe` device or this entity, since HA composes the id
from both (see [What appears in Home Assistant at two inverters](#what-appears-in-home-assistant-at-two-inverters)).
The reason it is recomputed against the clock is that Home Assistant only pushes
a state when the state *changes*. A meter sitting at a genuinely constant reading
@@ -558,7 +838,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
> went **42.2 s and 97.0 s** between changes, and 23 Aug peaks at 29.1 s — all
> past the default `meter_max_age_s` of 30.
>
> So `sensor.p1_sample_age_s` on `ha_signed` is safe to *read*, and it is
> So `sensor.goodwe_p1_sample_age` on `ha_signed` is safe to *read*, and it is
> correct whenever the value is moving, but it must not yet be thresholded by
> the ESP32 stale-input watchdog: a quiet house would trip the battery to 0 W.
> Raising `meter_max_age_s` is **not** the fix — the two conditions produce an
@@ -601,6 +881,168 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
| `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below |
#### Shadow mode (`shadow_mode`, SAFETY-10)
Shadow mode runs the **whole** decision path — the price strategy, the capacity
guard, the maintenance shaping, the grid-following loop, the arbiter that ranks
them, SAFETY-28's authorisation gate and SAFETY-03's clamp — and **sends nothing
to the inverter**. It is how you find out what this add-on *would* do on a site
before you let it do it.
Every defect in the optimiser that this project has found was invisible from
outside while it was happening: a price lookup answering for the wrong
quarter-hour, a trade against a binding capacity cap that ran 28 charge onsets
in an hour, an SoC ceiling churning 598 Wh to gain 310 Wh. Each one was paid for
at the meter first and noticed afterwards. Shadow mode is the surface that
would have shown all three on the day.
**What "commands nothing" means, exactly.** Every inverter is held at **0 W**,
written by the ordinary single writer and refreshed on the ordinary heartbeat.
It is not silence. ⚠️ **The inverter holds its last command forever** — it has
no meter timeout — so a mode that simply stopped writing would leave whatever
was standing when you switched it on standing for as long as you left it on,
under a page saying nothing was being commanded. Refreshing 0 W is also what
the ESP32's 30 s stale-input watchdog requires of us: going quiet would put
that board into a *tripped watchdog* state for the whole run, which is an alarm
being used as a mode.
**The three values.**
| value | what happens |
|---|---|
| `auto` | **the shipped default.** Shadow mode while control is **stopped**; ordinary control once control is **started**. A new install ships `auto_start: false`, so it comes up in shadow mode and observes before it ever commands anything. An install that is already controlling is started, so nothing about it changes |
| `on` | shadow mode **always**, whatever the *Start / stop control* button says. This is the one to pick to watch an armed site for a day — the loop runs, arbitrage decides, the peak guard files its claims, and the battery does not move |
| `off` | ordinary control |
⚠️ **Existing installs are not changed.** The default is `auto` rather than a
boolean `true` precisely because a boolean would have changed them: the
Supervisor composes an add-on's options as *its defaults, overlaid with your
stored options*, so a new key with a new default reaches every install on the
next update. `shadow_mode: true` would have silently stopped a battery that is
regulating today, and the only symptom would be a battery that stopped doing
anything.
⚠️ **Under `auto`, pressing *Start* leaves shadow mode.** That is the point of
`auto` — the button means "control the battery". Set `on` if you want the
decisions recorded while the loop runs.
**What you get out of it.** The status page carries a red-amber banner saying
the battery is not being controlled, a `Shadow mode` row saying which rung
decided, what it **would have commanded on that cycle** and why — with the
caveat below, where the number was sized from a meter the battery never
moved — and the `Why` row with the arbiter's own sentence. The action log gets one line each time the decision
changes — the winner or the watts — rather than one per cycle, so a 40-row log
still contains the context of what it found. MQTT reports `status: shadow`.
**The failsafes.** The sign watchdogs (SAFETY-02/30) are **inert**: they grade
what this controller has standing on the wire, and that is 0 W, so they cannot
arm. That is deliberate and it is the single most important decision in this
feature — fed the shadow decision instead they would disagree with a meter that
never saw it, and **latch a trip that survives into live operation and a
restart**. The clamp (SAFETY-03), the write-authorisation gate (SAFETY-28), the
frozen-meter alarm (SAFETY-18) and the capacity guard all **observe**: they run
on the number the decision path produced, so a shadow run can tell you the
clamp would have engaged. ⚠️ **The capacity guard observes a meter with no
battery in it.** Every term of the quantity it thresholds is measured, so it
engages in a shadow run only if the *house alone* would have put the quarter
over the ceiling — 410 engaged cycles live against 0 in shadow, on the hour
tabulated below. Its *ceiling* and its *reasons* are worth reading from a
shadow run; its *engagements* are not evidence of anything. The circulation (SAFETY-31) and delivery (SAFETY-42)
watchdogs observe and may legitimately latch, because their evidence is
*measured* power, not a command. The operator stop is enforced at the wire
instead of as a claim, so that the recorded decision is the real one.
⚠️ **A latch that was already up stays up.** Shadow mode does not clear
anything. The banner names any standing latch and goes red for it, because a
latch that is up while the controller is inert is up the moment it stops being
inert.
⚠️ **Maintenance is unchanged.** The maintenance state machines are ticked on
every cycle whether control is started or not, so they already run against a
battery that does not move on a stopped install. Shadow mode inherits that
exactly; it neither improves it nor makes it worse.
⚠️ **What a shadow run will NOT tell you — and it is a whole category, not a
footnote.** The controller's carried command is re-seeded to the 0 W standing
on the wire at the end of every shadow cycle — that is what makes turning
shadow mode off safe, because the loop then resumes from where the battery
actually is. Everything below follows from the same fact.
**A shadow run cannot show you a closed-loop defect. Not partially — not at
all.** The battery is held at 0 W, so every *measured* input — the meter, the
billed-quarter accumulator behind it, the battery power, the state of charge —
describes a plant that did not respond. A closed-loop defect is one whose
stimulus **is** the plant's response, and there is no response to be the
stimulus. This is not a gap waiting to be closed: feeding the guards a
simulated plant instead is exactly what the sign-watchdog decision below
refuses, and for the same reason — a controller reacting to a plant that does
not exist drifts further from reality every tick. **The closed-loop answer
comes from the closed-loop rig or from a live run, and there is no third
place.**
Concretely, measured on the rig at `peak_cap_w` 1500 under a 1200 W house,
`max_w` 2000, one hour at a 5 s cycle — the same hour run twice, live and in
shadow, on the same code:
| | live | shadow |
|---|---|---|
| charge onsets in the hour | **28** | **1** |
| grid | **−455.5 … +3200.0 W** | **1200.0 W flat — the house** |
| capacity guard engaged | **410 / 720 cycles** | **0 / 720** |
| state of charge | 50.000019 → 51.856789 % | **50.000000 → 50.000000 %** |
(The onset and engagement figures are taken with the trade-sizing fix reverted,
because that is the defect being looked for; the SoC and grid figures are the
shipped build.)
⚠️ **The "strategies are unaffected" exemption that used to stand here was true
until the trade-sizing fix landed, and is now false by hours.** That fix makes
the buy leg `min(asked, headroom)`, where the headroom is
`(900 / seconds remaining) × (ceiling − projected quarter average)` — and the
projection reads the **open quarter's accumulator**, i.e. the meter, i.e. a
function of what the battery did earlier in that same quarter. What survives
of the exemption is exactly this much:
> **Which** hour a strategy picks, and **why**, are unaffected — the clock and
> the price series are not functions of the battery. Measured: identical live
> and shadow on 720 of 720 charging cycles at a binding cap. **How hard** it
> trades is not.
⚠️ **So the watts on the `Shadow mode` row are the right answer to a question
nobody asked, and the row now says so.** In a shadow run the billed quarter
really *is* under its ceiling, because the battery really *is* not charging, so
`900 / remaining` multiplies a gap a live run would have closed. On the same
rig, shipped code:
| cap / house | live commanded | shadow recorded | over by |
|---|---|---|---|
| 1500 / 1200 | −302.4 W | **−2000.0 W** | **6.61×** |
| 3500 / 3000 | −502.6 W | −2000.0 W | 3.98× |
| 3500 / 2000 *(shipped cap)* | −1502.6 W | −2000.0 W | 1.33× |
| 3500 / 1800 | −1702.6 W | −2000.0 W | 1.17× |
| 15000 / 350 | −2000.0 W | −2000.0 W | 1.00× |
Whenever a measured billed quarter sized the number, the row and the action log
carry **⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE
HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W** — and the watts are introduced
as what it would have commanded **on this cycle**, in every state, because no
number a shadow run records is a forecast of a live one. The figure is **not**
re-computed to look right: a shadow-only sizing would mean the recorded
decision was no longer the decision the shipped path reached, which is the one
thing this feature has to be able to say.
The **grid-following loop** is the same story told first: its recorded decision
is **one slew step** and stays there, because a trajectory depends on a battery
responding and none did.
**What a shadow run genuinely is for**, then: *which* strategy wins, on *which*
hour, at *which* price, for *which* reason, against which limits — the
wrong-quarter-hour class of defect, a trade armed on a day it should not be, a
refusal whose sentence names the wrong subsystem, a clamp that would have
engaged, a standing failsafe latch. That is a real and useful set, and it is
the set this feature covers.
#### The sign-disagreement failsafe
@@ -979,6 +1421,7 @@ What exists today, and what does not:
|---|---|
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
| Maintenance charging capped under `peak_cap_w` | ✅ |
| **Arbitrage charging capped under `peak_cap_w`** | ✅ — SAFETY-45, sized to the guard's own MEASURED headroom rather than to `peak_forecast_entity` |
| **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` |
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen |
@@ -1041,6 +1484,19 @@ projected over — raises one notification (`goodwe_peak_saturated`), because th
is the one state where the money will be spent and shedding load is the only
thing left that changes the bill.
**And it sizes a price-driven charge, not only a forced discharge (SAFETY-45).**
The same arithmetic read the other way answers *"how hard may this site charge
without putting the quarter over"* — `peakguard.charge_headroom_w()` — and
`arbitrage.decide()` sizes its buy leg to it. Before that, a trade commanded
`max_w` until the projection rose far enough to engage the guard, at which point
the interlock cancelled the trade outright, the loop took the actuator, the
projection collapsed and the trade came back at `max_w`: **28 charge onsets in
one hour** on TEST-23 stage 3's closed rig, with the grid swinging −456 to
+3200 W and every billed quarter finishing over the cap. It is one onset now,
the trade runs continuously at the affordable rate, and the billed quarters land
**on** the cap. ⚠️ The guard is what changed *around* arbitrage; `decide()`
itself is untouched, so nothing above this line is different.
**No new option for the CEILING.** It is `peak_cap_w`, which already shipped. The
coverage threshold is `meter_max_age_s`, the freeze predicate is
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
@@ -1372,7 +1828,8 @@ does, and only after you have read why.
| `price_provider` | `off` (default) or one of the three plugins below |
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
| `price_taxes_eur_kwh` | Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to **0.11736** — a real Flemish figure, and **yours is probably different**: see *What `price_taxes_eur_kwh` is made of* below |
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
| `export_price_eur_kwh` | The **flat injection** price, EUR/kWh. May be negative — see below |
| `price_export_formula` | The **injection formula**, if your contract has one. Same shape as `price_formula`, its own coefficients, its own `vat_pct` — see below |
| `price_csv_path` | Where `manual_csv` reads its file |
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
| `price_horizon_h` | How far ahead to ask for, hours |
@@ -1691,10 +2148,13 @@ the default markup and taxes, not an illustration.)
##### What it does not do
The **injection** leg is a separate card. Eneco uses `0,102` on consumption and
The **injection** leg is a separate card, and it has its own option:
`price_export_formula` (TARIFF-16). Eneco uses `0,102` on consumption and
`0,100` on injection, and injection is VAT-exempt where consumption is at 6 %,
so it is a second per-contract formula rather than a second copy of this one.
`export_price_eur_kwh` is still the scalar you enter by hand.
so it is a second per-contract formula rather than a second copy of this one —
the same parser, its own coefficients. `export_price_eur_kwh` remains the flat
injection price for a site that is paid one, and **no strategy reads either**;
see *Injection is priced separately* above.
#### `manual_csv` — the offline fallback
@@ -1845,25 +2305,122 @@ logged and reported on the status row, never raised. A cache that cannot answer
is not allowed to be a controller that cannot regulate, and that applies to the
volume being full exactly as it applies to the volume being absent.
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
often near zero, which by itself strongly favours self-consumption over export.
Negative values are accepted — you can be paid to *not* export at midday in
summer.
### Injection is priced separately
⚠️ It is a single number rather than a curve, and the reason given here until
TARIFF-07 — *"no supported supplier publishes a dynamic injection tariff"* — was
**false**. Suppliers do. Eneco publishes `0,1 x BELPEX-H − 1,188` on the same
tariff card as its consumption formula (identical on the January and the
September 2026 cards), and ENGIE publishes one in the same shape,
`(B x eSpot_15) − A`. What is true is narrower: an injection curve is the **same
two-coefficient formula** as the consumption curve but with **its own**
coefficients and **its own VAT treatment** — injection is VAT-exempt where
consumption is at 6% — so it is a second per-contract formula rather than a copy
of the first, and the add-on cannot evaluate either one yet. Until the formula
provider ships, **this scalar is a simplification you enter by hand — an average
of what you are actually paid — and not a reflection of the market.**
There are two ways to say what you are paid for an exported kWh, and which one
you want depends on your contract:
**The old Home Assistant price sensors.** `price_now_entity` and
| your contract | what to set |
|---|---|
| a flat injection price | `export_price_eur_kwh` — one number, EUR/kWh. This is the default and what every existing install uses |
| a price that moves with the market | `price_export_formula` — your card's injection line as `factor × index + offset`, in exactly the shape `price_formula` uses for consumption |
Negative values are accepted on both — you can be charged to export at midday in
summer, and the add-on says so rather than flooring it.
#### ⚠️ What the add-on does *not* do with an injection price
**No strategy in this add-on reads one.** Not the scalar, not the formula.
`arbitrage.py` prices the sell side as **displaced import** — both hours it
compares are all-in *consumption* prices — and its own comment gives the reason:
§9.3's near-zero Belgian injection price is exactly why the economically sound
trade on this market is *charge cheap to displace expensive import*, not *charge
cheap to export dear*. So setting `price_export_formula` changes **no decision
the add-on takes today**. It changes what you are shown at startup, and it is
the shape any future export-aware strategy would read.
That is the argument for doing it now rather than later: OPT-06 and any export
logic would otherwise inherit a scalar that cannot represent the market, and the
6% VAT asymmetry between the two legs would be invisible in the arithmetic.
`test_price.py` 17h parses every module under `app/` and goes red the day
something calls an injection reader, so this paragraph is graded rather than
attested.
#### Why a scalar was not enough
Belgian suppliers publish the injection leg as a formula, on the same card as
the consumption one. The reason given here until TARIFF-07 — *"no supported
supplier publishes a dynamic injection tariff"* — was **false**, and what they
publish has four properties one number cannot hold:
```
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c/kWh
"Het injectietarief is dynamisch en kan per uur wijzigen"
- identical on the January and the September 2026 cards
ENGIE Dynamic (B x eSpot_15) - A
Aspiravi (non-dynamic) 0,07 x Belpex - 2
```
1. **Its own factor.** Eneco uses `0,102` on consumption and `0,100` on
injection; Aspiravi `0,07` against about `0,10` on its consumption leg. It is
**not** the consumption `B` with a sign flip.
2. **A negative offset** — `− 1,188`, `− 2`. Note that
`price_markup_eur_kwh: float(0,1)` cannot express a negative markup at all,
so the offset has no home on the consumption side either.
3. **VAT-exempt.** *"Injectietarieven zijn vrijgesteld van btw"*, while
consumption is at 6%. `vat_pct` is **per leg**, so that six-point asymmetry
is expressible; a single scalar carries no VAT field at all.
4. **It goes negative, with no floor.** Eneco's injection price is below zero
for any BELPEX-H under **11,88 EUR/MWh** — and a PV household exports
precisely in the hours most likely to be negative. **You can be charged to
export.**
#### `price_export_formula`, field by field
Same grammar as `price_formula` — literally the same parser, with its option
name as an argument, so there is no second unit table and no second card rule to
diverge from the first. Eneco's published injection line, typed out:
```json
{"index":"epex_be","resolution":"hourly","factor":0.1,"offset":-1.188,
"units":"c_eur_per_kwh","vat_pct":0}
```
⚠️ **`vat_pct` is 0 here and 6 on the consumption leg.** That is the one field
people copy across from the other formula and get wrong.
⚠️ **The `card` block is optional on this leg and required on the other**, and
that is a statement about the cards rather than a relaxed rule. `price_formula`
can demand one because a Belgian card prints the consumption formula, the
reference index it assumed *and* the resulting price — so the add-on can
recompute that price and refuse to start unless your numbers reproduce it.
**Eneco's injection formula is printed with no price beside it.** There is
nothing to grade against on that card. Requiring a block would make the market's
own published line inexpressible; inventing the estimate would grade your
coefficients against arithmetic done with your coefficients, which is not a
check. So:
* if **your** card prints an injection estimate, fill the block in and it is
graded at the same tolerance as the consumption leg — the add-on refuses to
start if your numbers do not reproduce it;
* if it does not, the add-on **says so at startup** — *"its coefficients are
UNGRADED"* — and names what it checked instead.
What it checks instead, always, on both paths:
* **pass-through**: `factor`, converted to a share of the wholesale price, must
be between 0.10× and 10×. Eneco's injection leg is `1.00×` and Aspiravi's
`0.70×`; a coefficient typed a hundred times out reads `0.01×` or `100×`.
* **offset magnitude**: `|offset|` must be at most 0.20 EUR/kWh. The published
ones are `−0.01188` and `−0.02`.
Both bounds are on the **coefficients**, not on the price, which is why they can
bound a leg whose price legitimately goes below zero. `price_formula`'s
plausibility band is deliberately **not** reused here: its floor is `+0.02
EUR/kWh` of energy, and applying that to injection would refuse the real market
in exactly the hours where knowing the number matters most.
⚠️ **And the gap those bounds leave, with its size.** They bound the *shape* of
the coefficients, never their truth. With no printed estimate, a `factor` of
`0.070` where your contract says `0.100` — 30% of every export payment — passes
both, because `0.70×` is a pass-through somebody really publishes. Only a
printed estimate can catch a wrong-contract pair, and on Eneco's card there is
not one. That is why the startup line says the leg is ungraded instead of
implying it was checked.
### The old Home Assistant price sensors
`price_now_entity` and
`price_avg_entity` still work and are unchanged: the add-on compares them and
uses the result as a cheap-window hint for the maintenance charge. They are
**not compatible with `price_provider`, and not with `static_profile` either**
@@ -2073,7 +2630,9 @@ second set of 23- and 25-hour-day bugs.
is untouched — the table is routed through the same `capabilities().adds`
rule as the scalar, not bolted on.
- It does **not** apply to `static_profile`, whose prices are already all-in,
nor to `export_price_eur_kwh`.
nor to `export_price_eur_kwh` or `price_export_formula`. Those are the
injection leg, and nothing is added to what you are *paid* for export:
`price_taxes_eur_kwh` is a consumption component.
### Static day/night and fully-fixed tariffs (`static_profile`)
+210 -1
View File
@@ -172,7 +172,7 @@ manufactures them from a **fixture**: the check runs, the assertion is
evaluated, the sentence above it is true — and the numbers it compares are
arranged so that the failure it describes could not have changed the answer.
It is not hypothetical and it is not rare. ELEVEN instances in two weeks; four
It is not hypothetical and it is not rare. TWELVE instances in two weeks; four
were found by review, two - CFG-04's fixture pair - by an author's own mutation
battery, which is the cheaper place, one by review again, in the one
place nobody had thought to point the rule at: not a check in the suite, but a
@@ -414,6 +414,44 @@ same place twice, the check is decoration.
The general habits are **14** and **15** below.
* ⚠️ **SAFETY-47 IS THE TWELFTH, AND IT IS THE SECOND INSIDE A COMMITTED
EVIDENCE ARTIFACT.** `DECISIONS/SAFETY-10-shadow-mode.md` §6 tabulated a live
run and a shadow run agreeing on `arb_power_w`, on the reason **verbatim**
and on the arbiter's winner, and concluded that shadow mode reaches the same
decision. **Two independent reasons it could not have gone red, and closing
either one alone closes nothing:**
* its fixture is `peak_cap_w: 15000` against a 350 W house, so the capacity
guard never engages and the thing whose invariance is being claimed —
SAFETY-45's sizing of the buy leg from the billed-quarter meter — is in the
run and **inert**. ⚠️ **The harness's own comment says so**: *"at this
scenario's `peak_cap_w: 15000` it is not binding anyway"*. The rule was
written down beside the check it disabled and nobody read it as a
disclosure;
* and every identity row reads `_sa[0]`/`_la[0]` — **the first charging
sample**. At `elapsed = 0` the projection **is** the held level, so the two
runs agree **to the float even at a cap that does bind**. Measured:
widening the fixture alone — the obvious fix — leaves that comparison
passing.
⚠️ **THE SECOND REASON IS A NEW SHAPE FOR THIS LIST: AN INDEX, NOT A
FIXTURE.** Ask of every identity claim not only *"could this data differ"*
but *"**at the point I sampled it**, could it differ yet"*. A comparison
taken at `t = 0` between two runs that diverge **because one of them acts**
is a comparison of a plant with itself, and no amount of widening the fixture
changes that. The replacement, `test_arbitrage.py` part 3d, grades the whole
run — 716 of 720 charging cycles differ — and grades the `[0]` agreement **as
an expectation**, so nobody rebuilds a check on it believing it covers
something.
⚠️ **AND THE FIX'S OWN FIRST DRAFT CARRIED A THIRTEENTH.** The new part's
action-log check could not reach the log line at all: the sentence was an
f-string inside `Controller._shadow_settle()`, and the only suite that drives
that method uses a `meter_source: off` fixture where no arbitrage decision —
and therefore no caveat — exists. Deleting the caveat from it left **all 29
files green**. It is `shadow.log_line()` now. *A sentence assembled at a call
site is a sentence no check can grade*, which is CFG-04's substring lesson
arriving from the other direction.
Note what these have in common with a masked rung: the mutation *runs*, and the
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
can see it, because the code under test really did behave identically on the
@@ -773,6 +811,177 @@ 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.
23. ⚠️ **THE MUTATION IS SUBJECT TO EVERY HABIT ABOVE, HABIT 4 INCLUDED - AND
THE PURGE, THE FINGERPRINT AND THE BYTE DELTA ALL SAY IT TOOK.** SAFETY-57's
battery reported a **survivor at +21 B** for a mutation of
`Freshness._open_window()`: `self.graded_from = float(now_mono)` rewritten
as `float(self.changed_mono or now_mono)`, which is meant to re-anchor the
verdict's window to the register's own history and so delete the card's
whole exit. The harness purged, the tree fingerprint showed the edit on
disk, the delta was non-zero, and the mutant really did run. It was still a
survivor for a reason that has nothing to do with the property: in the
fixture the register never moves after `t = 0`, so `changed_mono` is
**`0.0`** - and `0.0 or x` is `x`. **The mutant was a no-op on that
stimulus.** Rewritten with an explicit `is None` test (+82 B) it is red on
four named checks.
Stale bytecode is a mutation that never *ran*. This is a mutation that ran
and could not *change the answer* on the data it was given, which is habit 4
exactly, one level up - and none of the three things a mutation report is
required to state (MUTATION.md's own list) can see it, because all three are
about the file rather than about the stimulus. Two rules:
* **Ask of every survivor what it would have had to change, and check that
the fixture reaches that value.** Here: "does any fixture have a non-zero
`changed_mono` at the moment `_open_window()` is called?" The answer was
no, and it is one line to ask.
* **Prefer a mutation that CANNOT be a no-op.** `or`, `and`, truthiness on
a numeric, a default argument and a `getattr` fallback all have a value at
which the mutant and the shipped tree coincide. An explicit comparison,
a constant swap or a deleted statement does not.
⚠️ Note where this one was found: in the battery of a card whose whole
subject is a check that cannot reach the failure it names. **Second
instance in an instrument rather than in a check since habit 16, and the
third overall.**
⚠️ **AND THE SAME CARD'S BATTERY CAUGHT A HABIT-22 COMMENT IN THE FIX
ITSELF, WHICH IS WHY THESE TWO BELONG NEXT TO EACH OTHER.**
`freshness.py` shipped `⚠️ RELEASE BEFORE GRADE, AND THE ORDER IS
LOAD-BEARING` with a worked reason. Swapping the two calls is **zero bytes**
and survived the battery: `_grade()` acts only when `|standing| >= move_w`
and `_release()` only when `|standing| < move_w`, so at most one can act on
any read and the order is **not observable**. Habit 22 says build X and
measure it; the battery is what builds X. The comment now states the
disjointness and the suite pins **that** - at 0, 100, 249, 250, 251 and
1300 W - rather than an order nothing depends on.
⚠️ **SECOND INSTANCE, TEL-11, AND THE NO-OP WAS A SATURATED RAIL RATHER
THAN A VALUE.** The card's whole subject is that a PV reading must never
reach the control law, so its central mutation is
`self.grid = self.grid - self.pv_w` in `read_inputs()` - **+106 B**, on
disk, purged, genuinely running - and it came back a **SURVIVOR** of a
six-file battery. The property held in no sense: the fixture drove a 6 kW
export over 12 cycles, which puts the loop **against its rail**, and a
command moving `slew_w` every cycle produces the SAME ramp for -6000 W and
for -14000 W of apparent error. The mutant ran and could not change the
answer.
Two things generalise past PV:
* **A SATURATED ACTUATOR IS A VALUE AT WHICH EVERY INPUT COINCIDES.** The
habit-23 list above names `or`, `and`, truthiness, default arguments and
`getattr` fallbacks - all *expressions* with a coinciding value. A rail,
a clamp, a slew limit, a deadband and a latched alarm are the same hazard
in the **plant**: they are where a controller's output stops being a
function of its input. A fixture that parks there cannot separate any two
inputs, and this repository's control fixtures park there by default,
because the dramatic stimulus is the saturating one.
* **SO THE PRECONDITION IS NOT "DOES IT COMMAND SOMETHING", IT IS "DOES IT
REST SOMEWHERE THE MUTATION COULD MOVE IT FROM".** `test_pv.py` part 6
now drives a 600 W import the loop SETTLES on, asserts the resting target
is well inside the rail, and asks habit 23's own question as a check:
shift the METER by the same 8 kW and confirm the commands really would
have moved. On the first fixture that question answers NO, in one line,
and it is the line that turns the identity into evidence.
24. **A REFUSAL DEFINED RELATIVE TO "THE BASELINE LACKS THIS FEATURE" DIES ON
THE RELEASE THAT SHIPS THE FEATURE — AND IT DIES AS A FALSE REFUSAL, WHICH
IS THE LOUD DIRECTION.** TEL-11 built `tools/tel11_pv_trace.py` to grade a
cross-tree identity, and because "the documents are identical" was its
*result* rather than its warning sign, it had to prove instead that the two
runs came from different trees and that **exactly one side reacts to
`pv_entity`**. That was correct evidence: `release/1.0` predated the option
entirely, so a side that reacted to it was the HEAD by construction.
TEL-11 then **merged**, `release/1.0` became 0.11.0, and the baseline for
the next card reacts to `pv_entity` too. `--compare` now answers
`REFUSED: set differs from unset on ['A', 'B']` on every honest run — the
instrument refuses its own shipped tree. TEL-12 found it on the first
comparison it ran.
* **A discriminator is a fact about a MOMENT, not about a card.** "The
baseline does not have X" is true until X ships and then false forever.
Write the discriminator against something the NEXT baseline still will not
have — TEL-12's is the roster key, not the channel — and say in the tool
which fact it rests on, so the next card can see what it has to replace.
* **Prefer a discriminator that is MEASURED over one the tree REPORTS.**
`tree.supports_roster` is the tree describing itself and would have been
one line; the shipped test is "this side's roster cells ARE its unset
cells", which is the tree being measured. Both are printed, side by side,
so a disagreement between the self-report and the measurement is visible
rather than silently trusted.
* ⚠️ **And this is the SAFE direction of the same defect, which is why it
was found in ten minutes rather than in a review.** A stale refusal is a
developer chasing a failure they cannot explain (the false-red case at the
top of this file). A stale *identity* — an instrument that goes on printing
OK because its reference drifted into agreement — is the survivor-
manufacturing direction, and nothing announces it. Ask of every cross-tree
instrument: *what does this print when the feature it is about has been in
the baseline for a year?*
TEL-12's own battery produced two more instrument defects of the ordinary
kind, both recorded in `DECISIONS/TEL-12-several-pv-inverters.md` §4: a
stimulus annotation written into the document being compared (so every cell
differed for a reason that was not behaviour, and the discriminator fired on
both sides), and a fixture cell whose TRUE total was labelled as its
"partial" (so the check refused the shipped tree for publishing a number it
is required to publish). ⚠️ **Both were caught by running the instrument
against the shipped tree BEFORE running it against a mutant** — which is the
one ordering rule that separates "the instrument is wrong" from "the tree is
wrong", and `test_mqtt.py` part 8's first line ("the shipped module satisfies
every property this instrument grades") is the same rule as a check.
⚠️ **AND TEL-12's SUBJECT WAS HABIT 23 IN THE FIXTURE ITSELF.** The card adds
a SUM over a roster, and the defect it exists to prevent is publishing the
partial sum when a member is absent. A fixture whose absent member
contributes **0 W** cannot see that defect at all: the partial and the full
total are **the same number**, so "published the absence" and "published the
partial" produce identical output. That is a rail in the *arithmetic* rather
than in the plant — the same hazard as TEL-11's saturated actuator, one
domain over. `test_pv.py`'s roster is three distinct non-zero contributions
in two different units, absence is driven at **every position** rather than
at one (habit 10), and each position's partial is **computed from the
fixture** and asserted to differ from the true total as its own precondition.
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
+123 -6
View File
@@ -184,6 +184,17 @@ IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
IDLE_NO_SIZE = (
"no pack's state of charge permits this side of the trade, so it is sized "
"at 0 W - and a 0 W arbitrage claim is a park order, not a trade")
# SAFETY-45. THE BUY LEG'S SIZE, AND ONLY THE BUY LEG'S - §9.1: "Discharge and
# export are never capped by the peak guard; only charging and import are."
IDLE_NO_HEADROOM = (
"the capacity-tariff guard has measured this billed quarter and it can "
"afford no charging at all, so a trade of any size would push it over the "
"ceiling - and a 0 W arbitrage claim is a park order, not a trade")
IDLE_HEADROOM_UNMEASURED = (
"the capacity-tariff guard did not size this quarter's charging headroom "
"this cycle, and an UNMEASURED headroom is not an unlimited one - charging "
"creates the billed quantity (§9.1) and nothing is measuring the quarter "
"this one would land in")
NEGATIVE_PRICE = (
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
"energy away and pay for the privilege. Arbitrage must not export at a "
@@ -303,7 +314,7 @@ def _point_at(plan, when: datetime):
return None
def decide(plan, now: datetime, *, peak_guard_active, soc,
def decide(plan, now: datetime, *, peak_guard_active, charge_headroom_w, soc,
min_spread_eur_kwh, uncertainty, charge_w, discharge_w,
soc_floor_pct, soc_ceiling_pct) -> ArbitrageDecision:
"""Should the battery trade this cycle, and how hard.
@@ -314,6 +325,18 @@ def decide(plan, now: datetime, *, peak_guard_active, soc,
None refuses, because the alternative is a strategy that creates the billed
quantity while the thing that would have limited it is switched off.
⚠️ `charge_headroom_w` IS SAFETY-45's SECOND, CONTINUOUS ANSWER FROM THE
SAME GUARD, AND IT IS NOT A DUPLICATE OF THE FIRST. `peak_guard_active` is a
SWITCH - it says whether the guard is holding - and a switch keyed on a
continuous quantity is what made the loop oscillate: the guard engaged at a
5 W floor, this module refused the whole trade, the loop took the actuator,
the meter fell, the guard released and the trade re-armed AT FULL POWER
130 s later. 28 times an hour, measured. `peakguard.charge_headroom_w()` is
the watts the billed quarter can still afford; sizing the buy leg to it means
the trade is CLAMPED rather than switched, which is the same shape
`control.maintenance_charge_floor()` has always given a maintenance charge.
⚠️ None IS NOT UNLIMITED here either - see IDLE_HEADROOM_UNMEASURED.
⚠️ EVERY OTHER ARGUMENT IS KEYWORD-ONLY AND HAS NO DEFAULT, for
`TariffPlan.arbitrage()`'s reason. A defaulted `soc_floor_pct` is a reserve
floor this module asserted rather than one the operator configured, and the
@@ -330,10 +353,12 @@ def decide(plan, now: datetime, *, peak_guard_active, soc,
return verdict.match(
refused=lambda v: _idle(v.reason),
allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w,
soc_floor_pct, soc_ceiling_pct))
soc_floor_pct, soc_ceiling_pct,
charge_headroom_w))
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
headroom_w):
"""The permitted case: which hour is this, and may the pack do it."""
here = _point_at(plan, now)
if here is None:
@@ -403,18 +428,110 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
f"nowhere to put the cheap energy", **common)
if not abs(float(charge_w)) > 0.0:
return _idle(IDLE_NO_SIZE, **common)
# SAFETY-45. THE BILLED QUARTER IS ASKED LAST, AND THE PLACEMENT IS
# DELIBERATE: every refusal that existed before this card keeps the
# precedence it had, so no sentence an operator already reads changes when
# it fires, and the two added here are placed where they cannot displace
# one. The pack's reasons come first because they are properties of the
# hardware in the cupboard; this one is a property of the fifteen minutes
# the trade would land in.
if headroom_w is None:
# ⚠️ NOT "the guard is clear". This is `guard_state()`'s None argument
# one layer down: a headroom nothing measured is not an unlimited one,
# and charging at max_w creates the billed quantity (§9.1).
return _idle(IDLE_HEADROOM_UNMEASURED, **common)
asked_w = abs(float(charge_w))
size_w = min(asked_w, max(0.0, float(headroom_w)))
if not size_w > 0.0:
# ⚠️ NOT IDLE_NO_SIZE, WHICH IS THE LINE ABOVE AND BLAMES THE PACKS.
# `asked_w > 0` is already established here, so the pack may be empty
# and willing and it is the billed quarter that has run out. A refusal
# naming the wrong subsystem sends an operator to the wrong page
# (§10.2), and these two are a whole quarter-hour apart in what they
# ask a human to do about them.
return _idle(IDLE_NO_HEADROOM, **common)
# ⚠️ THE SENTENCE CARRIES BOTH NUMBERS IN THEIR OWN SLOTS, not one figure
# twice. MUTATION.md habit 14: two values in one sentence can be transposed
# by a ZERO-BYTE edit that no byte delta and no shape pin can see, so the
# trade's own size and the guard's headroom are each named by the literal
# text around them.
capped = size_w < asked_w
return ArbitrageDecision(
action=CHARGE, power_w=-abs(float(charge_w)),
action=CHARGE, power_w=-size_w,
reason=(f"{CHARGE}: this is the cheapest firm hour at "
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest "
f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle "
f"had to cover; charging {abs(float(charge_w)):.0f} W from "
f"{soc:g} %"),
f"had to cover; charging {size_w:.0f} W from {soc:g} %"
+ (f" - SIZED BY THE CAPACITY-TARIFF GUARD: the trade asked for "
f"{asked_w:.0f} W and this billed quarter can afford "
f"{float(headroom_w):.0f} W (§9.1, §10.1 priority 2)"
if capped else "")),
spread_eur_kwh=v.spread_eur_kwh,
required_eur_kwh=v.required_spread_eur_kwh,
price_now_eur_kwh=price)
def sized_from_meter(decision: ArbitrageDecision, headroom_w, *,
winner: str | None) -> bool:
"""SAFETY-47. Was the number a shadow run just recorded SIZED FROM THE
BILLED QUARTER - i.e. from a measurement of what the battery itself did?
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
`peakguard.charge_headroom_w()`, `discharge_permitted()` and
`freeze.looks_frozen()` are: the check that grades it has to assert on THE
SHIPPED EXPRESSION. A predicate transcribed into its own test is one copy
plus a future disagreement about what the check means.
------------------------------------------------------------------------
THE DEFECT IT PREVENTS
`shadow.words()` and the action log carry "would have commanded N W". In
SHADOW MODE the battery is held at `shadow.WIRE_W`, so the billed quarter
the buy leg was sized against contains THE HOUSE AND NOTHING ELSE - while
a live run's meter rises as the trade charges. The same code, the same
hour, `peak_cap_w` 1500 under a 1200 W house: live commands -302.4 W and a
shadow run records -2000.0 W. 6.61x, on the status row, with no caveat.
This is the flag that puts the caveat there.
⚠️ IT IS TRUE WHENEVER A MEASURED HEADROOM ENTERED THE ARITHMETIC, NOT
ONLY WHEN IT BOUND. `_act()` sizes the buy leg as `min(asked, headroom)`,
and "the headroom did not bind" is ITSELF a measured fact about a quarter
the battery never entered. Keying this on `capped` - on whether the
shipped sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" - would answer
True at the START of the quarter, where a shadow run and a live run still
agree to the watt, and False at the END, where `(900/remaining)` has
multiplied the headroom past the ask and the recorded number is at its
most wrong. The quietest caveat exactly where it is needed loudest.
⚠️ THE BUY LEG ONLY. §9.1: "Discharge and export are never capped by the
peak guard; only charging and import are." A sell is sized from
`discharge_w`, which is the roster's own rating and not a measurement of
the quarter, so a sell's recorded watts are NOT subject to this and must
not carry a caveat that would then be false.
⚠️ AND THE ARBITER'S WINNER IS THE THIRD RUNG, NOT AN AFTERTHOUGHT. A trade
that was sized from the meter and then OUT-RANKED is not the number being
recorded - `Resolution.target_w` belongs to somebody else - and a caveat
filed against somebody else's number is a false sentence on the status row,
which is the defect class this whole card is about. It is in HERE and not
in `main.py` for the reason the two rungs above are: all three answer one
question, a check has to be able to reach each of them with the other two
clear (TEST-16), and a rung that lives at a call site is a rung no check
can drive.
`headroom_w` is whatever `peakguard.charge_headroom_w()` answered for the
cycle this decision was taken on. ⚠️ None IS "NOTHING MEASURED IT", and
`decide()` REFUSES the trade there (IDLE_HEADROOM_UNMEASURED), so a None
can only ever arrive here beside a non-trading decision - but it is tested
rather than assumed, because the caller is `main.py` and not this module.
`winner` is `Resolution.winner`: the source that actually set the watts.
"""
return (winner == SOURCE
and decision is not None and decision.action == CHARGE
and headroom_w is not None)
def claim(decision: ArbitrageDecision) -> Claim | None:
"""The decision as one arbiter claim, or None when nothing is to be traded.
+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:
+447 -61
View File
@@ -125,6 +125,49 @@ units and the market aggregator prints both conventions in one column, so the
add-on has to be told which one the card is in - and the unit conversion is the
add-on's job precisely because it is one of the two steps that silently costs a
factor of 10, 100 or 1000. The other is VAT, which is also the add-on's.
TARIFF-16 - THE SECOND LEG, AND WHY IT IS THIS PARSER AND NOT A SECOND ONE
==========================================================================
A Belgian dynamic contract has TWO published lines, not one, and the second is
what the site is PAID for export:
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
ENGIE Dynamic (B x eSpot_15) - A
Aspiravi (non-dynamic) 0,07 x Belpex - 2
Same `B x index + A` shape - which is why `price_export_formula` is this
module's parser with ONE argument moved (`option`), exactly as TARIFF-17 made
`parse_static_profile()`'s option name a parameter rather than forking the
period grammar. What differs between the two legs is FOUR numbers and a
handful of sentences, and all of them are rows of `_LEGS`:
* its OWN factor - Eneco 0,102 on consumption against 0,100 on injection,
Aspiravi 0,07 against ~0,10. NOT the consumption `B` with a sign flip;
* a NEGATIVE offset, which `factor`/`offset` already allow and which
`price_markup_eur_kwh: float(0,1)` cannot express at all;
* `vat_pct: 0` - "Injectietarieven zijn vrijgesteld van btw" - against 6 on
consumption, which is a SIX PERCENT ASYMMETRY between the buy leg and the
sell leg and is representable only because `vat_pct` is PER SPEC;
* a price that goes BELOW ZERO with no floor, in the hours a PV household
exports. `implausible_injection()` is the band that accepts that and still
refuses an implausible one; `implausible_all_in()` is NOT it, and its
docstring says why.
⚠️ AND THE CARD CANNOT ALWAYS GRADE IT. Eneco's injection formula is printed
with NO price beside it, so on that card there is nothing to reproduce. The
`card` block is therefore OPTIONAL on this leg and REQUIRED on the other, the
self-check runs wherever there is a printed estimate, and validate.py says at
startup when a leg is ungraded. Inventing an estimate to grade against would
be grading arithmetic against itself, which is the defect MUTATION.md habit 4
is about; TARIFF-15 demonstrated two tariffs end to end rather than three for
the same reason.
⚠️ NOTHING TRADES ON THIS TODAY, AND SAYING SO IS PART OF SHIPPING IT.
`arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment
says so, citing §9.3's near-zero Belgian injection price - and both hours it
compares are all-in CONSUMPTION prices. So this is not a wrong-trade defect
and must not be described as one. What it changes is what an operator is told,
and what a future export-aware strategy would rest on.
"""
import json
@@ -183,6 +226,150 @@ _CARD_KEYS = ("month", "index_eur_per_mwh", "expect_incl_vat")
_TOP_KEYS = ("index", "resolution", "factor", "offset", "units", "vat_pct",
"card")
# --------------------------------------------------------------------------- #
# TARIFF-16 - the SECOND leg, and the one parameter that carries it
# --------------------------------------------------------------------------- #
EXPORT_FORMULA_OPTION = "price_export_formula"
# ⚠️ THE PASS-THROUGH FRACTION, WHICH IS WHY THE NEXT TWO BOUNDS CAN BE
# INDEX-FREE. One EUR/MWh IS 0.001 EUR/kWh, so `factor x UNITS[units] / 0.001`
# is the dimensionless share of the wholesale price the contract passes
# through - 1.00 for Eneco's injection leg (0,100 c EUR/kWh per EUR/MWh), 0.70
# for Aspiravi's (0,07). A coefficient typed a hundred times too small or too
# large moves this by a hundred, whatever the index and whatever the sign of
# the price.
EUR_KWH_PER_EUR_MWH = 0.001
# The two plausibility bounds for an INJECTION leg. ⚠️ NEITHER LOOKS AT THE
# SIGN OF THE PRICE, AND THAT IS THE WHOLE POINT - see
# `implausible_injection()`.
PASSTHROUGH_LO = 0.10 # 10% of the wholesale price
PASSTHROUGH_HI = 10.0 # 1000% of it
INJECTION_OFFSET_MAX_EUR_KWH = 0.20
@dataclass(frozen=True)
class _Leg:
"""What ONE option's coefficients price, as sentences its refusals read.
⚠️ TARIFF-16 MADE `option` A PARAMETER RATHER THAN FORKING THE PARSER,
exactly as TARIFF-17 made `option`/`means` parameters of
`parse_static_profile()` rather than forking the period grammar. A second
copy of this parser would be a second `_finite()`, a second unknown-key
rule, a second unit table and a second `to_resolution()` - and the two
would diverge first on the 23- and 25-hour days, where nobody looks.
Everything that differs between the consumption leg and the injection leg
is a STRING in this table; the arithmetic, the enumerations, the card
comparison and the resolution rule are ONE implementation with one set of
checks over it.
"""
option: str
what: str # "consumption" / "injection", for a sentence
example: str # a VALID blob for this leg, in the JSON-error hint
when: str # when this option is read at all
flat: str # where a FLAT price of this kind already lives
zero_factor: str # what a factor of 0 actually costs on this leg
neg_factor: str # why a negative FACTOR is wrong on this leg
card_required: bool
card_note: str # why it is required, or what is lost without it
card_causes: str # the leg-specific tail of `card_refusal()`
_LEGS = {
"price_formula": _Leg(
option="price_formula",
what="consumption",
example=('{"index":"epex_be","resolution":"hourly","factor":0.102,'
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
'"expect_incl_vat":15.55}}'),
when="Leave it empty unless price_provider is 'custom_formula'.",
zero_factor=("every spread is exactly 0, and arbitrage can never "
"clear any threshold - a strategy that silently never "
"fires rather than one that is off"),
flat=("that is `static_profile` with a single period covering "
"00:00-24:00 on all days and price_provider 'off'; the add-on "
"then says so at startup instead of leaving you to find out "
"from a year of unchanged bills"),
neg_factor=("No consumption tariff is indexed negatively; if your "
"card shows a minus sign it is on the OFFSET (`A`), "
"which is allowed here and common on injection"),
card_required=True,
card_note=("It is the only thing that can tell a correct pair of "
"coefficients from a plausible-looking wrong one"),
card_causes=(
"or - the expensive one - network charges, levies or excise "
"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian "
"card's Tariefformule line is the ENERGY component alone (Eneco "
"tabulates network tariffs and levies separately; ENGIE says in "
"so many words that the formula excludes network, distribution, "
"transport, levies and excise), the add-on adds "
"price_taxes_eur_kwh on top of it, and folding ~12 c EUR/kWh of "
"network charge into `offset` bills you for it twice. That is "
"the error this check exists to catch, and it is why it cannot "
"be switched off."),
),
EXPORT_FORMULA_OPTION: _Leg(
option=EXPORT_FORMULA_OPTION,
what="injection",
# ⚠️ NO `card` BLOCK IN THE EXAMPLE, because Eneco's injection line has
# none to copy - the formula is printed with no price beside it. An
# example carrying one would invite an operator to compute the figure
# themselves, which is the one thing this leg must not encourage.
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
when=("Leave it empty unless your supplier pays you an injection "
"price that moves with the market; a flat one is "
"export_price_eur_kwh. ⚠️ This option is read whatever "
"price_provider is set to - it is the leg you are PAID on, not "
"the one you buy on."),
zero_factor=("every hour pays the same, which is a FLAT injection "
"price written as a formula. ⚠️ Note this is NOT the "
"arbitrage argument the consumption leg gives: no "
"strategy in this add-on reads an injection price at "
"all, so a flat one here fires nothing and declines "
"nothing - it is simply the wrong option"),
flat=("that is `export_price_eur_kwh`, the flat injection scalar, "
"which is what every site used before this option existed and "
"which still answers when this one is empty"),
neg_factor=("No Belgian injection tariff is indexed negatively - you "
"are paid MORE when the market is dearer, not less. The "
"minus sign on an injection card is on the OFFSET (`A`): "
"Eneco prints `0,1 x BELPEX-H - 1,188` and Aspiravi "
"`0,07 x Belpex - 2`, and a negative offset is accepted "
"here"),
card_required=False,
card_note=(
"⚠️ IT IS OPTIONAL ON THIS LEG, AND THAT IS A STATED GAP RATHER "
"THAN A RELAXED RULE. A Belgian card prints the CONSUMPTION "
"formula with the reference index and the resulting price beside "
"it, which is what makes `price_formula`'s self-check possible. "
"Eneco's injection line has NO printed price beside it, so on "
"that card there is nothing to grade against and requiring a "
"`card` block would make the market's own published formula "
"inexpressible - or, worse, invite an operator to compute the "
"estimate themselves, at which point the check grades the "
"coefficients against arithmetic done with the same coefficients. "
"Supply it if YOUR card prints one; when it is absent the add-on "
"says at startup that this leg is UNGRADED and the two "
"coefficient bounds below are all there is"),
card_causes=(
"or VAT added where there is none. ⚠️ INJECTION IS VAT-EXEMPT IN "
"BELGIUM - \"Injectietarieven zijn vrijgesteld van btw\" - while "
"consumption is at 6%, so `vat_pct` on THIS option is 0 on every "
"Belgian card the research found, and a 6 typed here because it "
"is 6 on the consumption leg is a ratio of exactly 1.06. Nothing "
"is added to this leg by the framework: price_taxes_eur_kwh is a "
"CONSUMPTION component and is not applied to what you are paid "
"for export, so an offset carrying network charges is a "
"different error here than it is on price_formula and this "
"check cannot tell you which one you made."),
),
}
@dataclass(frozen=True)
class FormulaSpec:
@@ -199,14 +386,44 @@ class FormulaSpec:
offset: float
units: str
vat_pct: float
card_month: str # "YYYY-MM", as the card is dated
card_index_eur_per_mwh: float
card_expect_incl_vat: float # in `units`, as the card prints it
card_month: str = "" # "YYYY-MM", as the card is dated
card_index_eur_per_mwh: float = None
card_expect_incl_vat: float = None # in `units`, as the card prints it
# TARIFF-16. Which OPTION this spec was read from, so a refusal, a log line
# and `card_graded` name the leg the operator actually typed.
option: str = "price_formula"
@property
def unit_to_eur_kwh(self) -> float:
return UNITS[self.units]
@property
def card_graded(self) -> bool:
"""Is there a printed estimate to grade these coefficients against?
⚠️ FALSE IS A REAL, SHIPPED STATE ON THE INJECTION LEG and is reported
rather than defaulted: Eneco's injection formula is printed with no
price beside it. Never False on `price_formula`, where `_card()`
refuses an absent block.
"""
return self.card_expect_incl_vat is not None
@property
def passthrough(self) -> float:
"""The dimensionless share of the wholesale price this passes through.
`factor x UNITS[units] / 0.001`. 1.00 for Eneco's injection leg, 0.70
for Aspiravi's. ⚠️ INDEX-FREE AND SIGN-FREE, which is what lets
`implausible_injection()` bound a leg whose price legitimately goes
below zero.
"""
return self.factor * self.unit_to_eur_kwh / EUR_KWH_PER_EUR_MWH
@property
def offset_eur_kwh(self) -> float:
"""The offset in EUR/kWh, whatever units the card printed it in."""
return self.offset * self.unit_to_eur_kwh
# --------------------------------------------------------------------------- #
# The arithmetic. Two functions, and the SECOND one is the contract.
@@ -241,7 +458,18 @@ def card_discrepancy(spec: FormulaSpec) -> float:
In the card's units, signed: positive means the formula computes MORE than
the card prints, which is the direction an `offset` carrying network
charges moves it.
⚠️ RAISES ON A SPEC WITH NO CARD RATHER THAN RETURNING 0.0 (TARIFF-16). An
ungraded injection leg has no printed estimate, and a discrepancy of zero
is exactly what "the coefficients reproduce the card" looks like - so a
silent zero here would report the strongest possible result for the one
state in which nothing was compared at all.
"""
if not spec.card_graded:
raise FormulaInvalid(
f"{spec.option} has no card block, so there is no printed "
f"estimate to measure a discrepancy against. Ask "
f"`spec.card_graded` before calling this.")
return in_card_units(spec, spec.card_index_eur_per_mwh) \
- spec.card_expect_incl_vat
@@ -250,7 +478,7 @@ def card_discrepancy(spec: FormulaSpec) -> float:
# Reading the option
# --------------------------------------------------------------------------- #
def _finite(value, field: str) -> float:
def _finite(value, field: str, option: str = "price_formula") -> float:
"""A real, finite number - or a refusal naming the field.
⚠️ SAME CLASS AS `_validate_raw()`'s NaN RULE, one layer earlier. A NaN
@@ -260,26 +488,37 @@ def _finite(value, field: str) -> float:
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise FormulaInvalid(
f"price_formula.{field} is {value!r}, which is not a number. "
f"{option}.{field} is {value!r}, which is not a number. "
f"Write the coefficient as a bare JSON number - 0.102, not "
f"\"0.102\" and not an expression: this provider takes FIXED "
f"coefficients and evaluates nothing.")
if not math.isfinite(float(value)):
raise FormulaInvalid(
f"price_formula.{field} is {value!r}, which is not finite. A NaN "
f"{option}.{field} is {value!r}, which is not finite. A NaN "
f"or an infinity here makes every price NaN, every price "
f"comparison False, and an arbitrage strategy that never fires "
f"while reporting that it is armed.")
return float(value)
def _card(raw) -> tuple:
"""The `card` block: the estimate the coefficients are graded against."""
def _card(raw, leg: _Leg = None) -> tuple:
"""The `card` block: the estimate the coefficients are graded against.
⚠️ REQUIRED ON THE CONSUMPTION LEG AND OPTIONAL ON THE INJECTION ONE, and
the asymmetry is a fact about the CARDS rather than a relaxed rule. See
`_LEGS[EXPORT_FORMULA_OPTION].card_note`: Eneco's injection formula is
printed with no price beside it, so on that card there is nothing to grade
against. `("", None, None)` is the ungraded answer and
`FormulaSpec.card_graded` is how every caller asks.
"""
if leg is None:
leg = _LEGS["price_formula"]
if raw is None:
if not leg.card_required:
return ("", None, None)
raise FormulaInvalid(
"price_formula has no \"card\" block, and it is REQUIRED. It is "
"the only thing that can tell a correct pair of coefficients from "
"a plausible-looking wrong one: the add-on recomputes your "
f"{leg.option} has no \"card\" block, and it is REQUIRED. "
f"{leg.card_note}: the add-on recomputes your "
"supplier's own printed example from the numbers you typed and "
"refuses to start if it does not come out. Copy the three figures "
"off the same tariff card as the coefficients: "
@@ -287,24 +526,26 @@ def _card(raw) -> tuple:
"\"expect_incl_vat\": 15.55}.")
if not isinstance(raw, dict):
raise FormulaInvalid(
f"price_formula.card is {type(raw).__name__}, not an object with "
f"{leg.option}.card is {type(raw).__name__}, not an object with "
f"{', '.join(_CARD_KEYS)}.")
unknown = sorted(set(raw) - set(_CARD_KEYS))
if unknown:
raise FormulaInvalid(
f"price_formula.card has no field(s) {unknown}. The fields it "
f"{leg.option}.card has no field(s) {unknown}. The fields it "
f"takes are {list(_CARD_KEYS)}; a key that is merely ignored is a "
f"number the operator believes took effect.")
month = str(raw.get("month", "") or "").strip()
if not _month_date(month):
raise FormulaInvalid(
f"price_formula.card.month is {raw.get('month')!r}; write the "
f"{leg.option}.card.month is {raw.get('month')!r}; write the "
f"month the card is dated as \"YYYY-MM\", e.g. \"2026-09\". It is "
f"not used in any price - it is what lets the add-on tell you "
f"your card has gone stale.")
return (month,
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh"),
_finite(raw.get("expect_incl_vat"), "card.expect_incl_vat"))
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh",
leg.option),
_finite(raw.get("expect_incl_vat"), "card.expect_incl_vat",
leg.option))
def _month_date(month: str):
@@ -316,9 +557,16 @@ def _month_date(month: str):
return None
def parse_price_formula(value):
def parse_price_formula(value, *, option: str = "price_formula"):
"""`price_formula` -> a `FormulaSpec`, or None when unset.
⚠️ `option` IS TARIFF-16'S WHOLE REUSE, and it is TARIFF-17's move. It
selects a row of `_LEGS` and nothing else: the enumerations, the
arithmetic, `to_resolution()`, the unknown-key rule and the card
comparison are shared, and every pre-existing call site - which passes no
`option` at all - lands on the consumption row and reads byte-identical
sentences. `parse_export_formula()` is a two-line wrapper, not a fork.
Accepts what the option can actually arrive as, exactly as
`parse_static_profile()` does:
@@ -335,6 +583,7 @@ def parse_price_formula(value):
fetch - go through this one function, so there is no route to a price that
has not been graded against the card.
"""
leg = _LEGS[option]
if value is None:
return None
if isinstance(value, str):
@@ -345,16 +594,13 @@ def parse_price_formula(value):
value = json.loads(text)
except ValueError as e:
raise FormulaInvalid(
f"price_formula is not valid JSON ({e}). It is a JSON object "
f"{option} is not valid JSON ({e}). It is a JSON object "
f"on one line, e.g.\n"
f' {{"index":"epex_be","resolution":"hourly","factor":0.102,'
f'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
f'"card":{{"month":"2026-09","index_eur_per_mwh":133.9905,'
f'"expect_incl_vat":15.55}}}}\n'
f"Leave it empty unless price_provider is 'custom_formula'.")
f" {leg.example}\n"
f"{leg.when}")
if not isinstance(value, dict):
raise FormulaInvalid(
f"price_formula is {type(value).__name__}, not a JSON object with "
f"{option} is {type(value).__name__}, not a JSON object with "
f"{', '.join(_TOP_KEYS)}.")
# ⚠️ AN UNKNOWN KEY IS REFUSED, NOT IGNORED, AND THIS IS THE HALF THAT
@@ -366,7 +612,7 @@ def parse_price_formula(value):
unknown = sorted(set(value) - set(_TOP_KEYS))
if unknown:
raise FormulaInvalid(
f"price_formula has no field(s) {unknown}. The fields it takes "
f"{option} has no field(s) {unknown}. The fields it takes "
f"are {list(_TOP_KEYS)}. ⚠️ There is deliberately NO field that "
f"takes an expression: this provider multiplies and adds two "
f"FIXED coefficients and evaluates nothing, because two "
@@ -378,7 +624,7 @@ def parse_price_formula(value):
index = str(value.get("index", "") or "").strip()
if index not in INDEXES:
raise FormulaInvalid(
f"price_formula.index is {value.get('index')!r}. The reference "
f"{option}.index is {value.get('index')!r}. The reference "
f"series this add-on can fetch are "
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}. "
f"If your card names a different index, the add-on cannot get it "
@@ -388,7 +634,7 @@ def parse_price_formula(value):
resolution = str(value.get("resolution", "") or "").strip()
if resolution not in RESOLUTIONS:
raise FormulaInvalid(
f"price_formula.resolution is {value.get('resolution')!r}; it "
f"{option}.resolution is {value.get('resolution')!r}; it "
f"must be one of {list(RESOLUTIONS)}. Belgium has been on a "
f"15-minute MTU since 2025-10-01 and the feed publishes 96 points "
f"a day; a contract written against the HOURLY series is priced "
@@ -402,16 +648,16 @@ def parse_price_formula(value):
units = str(value.get("units", "") or "").strip()
if units not in UNITS:
raise FormulaInvalid(
f"price_formula.units is {value.get('units')!r} and is REQUIRED - "
f"{option}.units is {value.get('units')!r} and is REQUIRED - "
f"there is no default, on purpose. It must be one of "
f"{sorted(UNITS)}. Your card prints the formula in ONE of these "
f"and two suppliers use two different conventions for the same "
f"shape, so assuming one would be a factor of 100 or 1000 on "
f"every hour with nothing able to notice.")
factor = _finite(value.get("factor"), "factor")
offset = _finite(value.get("offset"), "offset")
vat_pct = _finite(value.get("vat_pct"), "vat_pct")
factor = _finite(value.get("factor"), "factor", option)
offset = _finite(value.get("offset"), "offset", option)
vat_pct = _finite(value.get("vat_pct"), "vat_pct", option)
# ⚠️ ZERO IS NOT "NO INDEXATION", IT IS A FIXED TARIFF WEARING A DYNAMIC
# ONE'S CLOTHES. Every hour comes out at the same price, so every spread is
@@ -421,15 +667,10 @@ def parse_price_formula(value):
# the operator at it rather than let them build it by accident.
if factor == 0.0:
raise FormulaInvalid(
"price_formula.factor is 0, so the index would not enter the "
f"{option}.factor is 0, so the index would not enter the "
"price at all: every hour of every day comes out at "
f"{offset:g} x (1 + {vat_pct:g}/100), every spread is exactly 0, "
"and arbitrage can never clear any threshold - a strategy that "
"silently never fires rather than one that is off. If your tariff "
"really is one flat price, that is `static_profile` with a single "
"period covering 00:00-24:00 on all days and price_provider "
"'off'; the add-on then says so at startup instead of leaving you "
"to find out from a year of unchanged bills.")
f"{offset:g} x (1 + {vat_pct:g}/100), {leg.zero_factor}. If "
f"your {leg.what} tariff really is one flat price, {leg.flat}.")
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
# the dearest hour becomes the cheapest one, so the battery is told to
@@ -437,37 +678,96 @@ def parse_price_formula(value):
# mispriced site, it is a site actively trading against itself.
if factor < 0.0:
raise FormulaInvalid(
f"price_formula.factor is {factor:g}, which is NEGATIVE. That "
f"{option}.factor is {factor:g}, which is NEGATIVE. That "
f"inverts the market: the dearest hour of the day comes out as "
f"the cheapest one, so the battery would charge at the evening "
f"peak and discharge at 03:00 - trading against itself on every "
f"cycle. No consumption tariff is indexed negatively; if your "
f"card shows a minus sign it is on the OFFSET (`A`), which is "
f"allowed here and common on injection.")
f"cycle. {leg.neg_factor}.")
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
# and some cards are already quoted VAT-inclusive. The bound exists for the
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
if not 0.0 <= vat_pct <= 30.0:
raise FormulaInvalid(
f"price_formula.vat_pct is {vat_pct:g} and must be between 0 and "
f"{option}.vat_pct is {vat_pct:g} and must be between 0 and "
f"30. It is a PERCENTAGE, so Belgian residential electricity is "
f"6, not 0.06 and not 106. Zero is legitimate and is not refused: "
f"injection is VAT-exempt, and a card that already quotes "
f"VAT-inclusive coefficients needs no second multiplication.")
month, card_index, card_expect = _card(value.get("card"))
month, card_index, card_expect = _card(value.get("card"), leg)
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
offset=offset, units=units, vat_pct=vat_pct,
card_month=month, card_index_eur_per_mwh=card_index,
card_expect_incl_vat=card_expect)
card_expect_incl_vat=card_expect, option=option)
delta = card_discrepancy(spec)
if abs(delta) > CARD_TOLERANCE:
raise FormulaInvalid(card_refusal(spec, delta))
# ⚠️ THE CARD SELF-CHECK STILL RUNS WHEREVER THERE IS A CARD TO RUN IT
# AGAINST, on either leg, with the same tolerance. What TARIFF-16 adds is
# the honest answer for the leg where the card prints no estimate: NOT a
# weakened comparison and NOT an estimate computed here, but two bounds on
# the COEFFICIENTS that need no printed price - see
# `implausible_injection()` - and a startup line saying the leg is
# ungraded.
if spec.card_graded:
delta = card_discrepancy(spec)
if abs(delta) > CARD_TOLERANCE:
raise FormulaInvalid(card_refusal(spec, delta))
if option == EXPORT_FORMULA_OPTION:
band = implausible_injection(spec)
if band:
raise FormulaInvalid(band)
return spec
def parse_export_formula(value):
"""`price_export_formula` -> the INJECTION leg's `FormulaSpec`, or None.
TARIFF-16. THE DEFECT THIS EXISTS TO CLOSE: `price.py` modelled injection
as ONE scalar, and the market publishes a line with four properties a
scalar cannot hold. Verbatim off the cards:
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
"Het injectietarief is dynamisch en kan per uur wijzigen"
- identical on the January and the September 2026 cards
ENGIE Dynamic (B x eSpot_15) - A c EUR/kWh
Aspiravi (non-dynamic) 0,07 x Belpex - 2
1. ITS OWN FACTOR. Eneco uses 0,102 on consumption and 0,100 on
injection; Aspiravi 0,07 against ~0,10 on its consumption leg. It is
NOT the consumption `B` with a sign flip.
2. A NEGATIVE OFFSET - `- 1,188`, `- 2`. Note that
`price_markup_eur_kwh: float(0,1)` cannot express a negative markup
at all, so the offset has no home on the consumption side either.
3. VAT-EXEMPT. "Injectietarieven zijn vrijgesteld van btw", while
consumption is at 6% - a SIX PERCENT ASYMMETRY between the buy leg
and the sell leg, which a single scalar cannot even state.
4. IT GOES NEGATIVE, WITH NO FLOOR. Eneco's injection price is negative
for any BELPEX-H below 11,88 EUR/MWh, and a PV household exports
precisely in the hours most likely to be negative. You can be charged
to export.
⚠️ AND WHAT THIS DOES **NOT** AFFECT TODAY, SAID HERE SO NOBODY READS THE
ABOVE AS A TRADING BUG. `arbitrage.py` does not read
`export_price_eur_kwh` at all - its own comment says so, and gives §9.3's
near-zero Belgian injection price as the reason the economically sound
trade on this market is charge-cheap-to-displace-expensive-IMPORT. Both
hours it compares are all-in CONSUMPTION prices. So no trade the add-on
takes today is decided by this number, and this card is not a correction
of a wrong trade. What it changes is what an operator is told, and what
any future export-aware strategy would rest on: OPT-06 and any export
logic would otherwise inherit a scalar that cannot represent the market,
and the 6% VAT asymmetry would be invisible in the arithmetic.
⚠️ THE SCALAR IS NOT REPLACED. `export_price_eur_kwh()` is untouched and
still answers for every site with a flat injection price, which is every
site that exists today; this option ships "". That is `price.py`'s own
prediction coming true rather than a new design - "when it does land, an
injection curve gets a `PriceProvider` of its own and this scalar becomes
its FIXED-product default; the seam is already the right shape for that."
"""
return parse_price_formula(value, option=EXPORT_FORMULA_OPTION)
def card_refusal(spec: FormulaSpec, delta: float) -> str:
"""The sentence an operator reads when their formula misses its own card.
@@ -479,8 +779,9 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
got = in_card_units(spec, spec.card_index_eur_per_mwh)
ratio = (got / spec.card_expect_incl_vat
if spec.card_expect_incl_vat else float("inf"))
leg = _LEGS[spec.option]
return (
f"price_formula does not reproduce its own card, so the add-on will "
f"{spec.option} does not reproduce its own card, so the add-on will "
f"NOT start on it. Your contract's arithmetic is "
f"price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); "
f"on the card's own index that is "
@@ -496,15 +797,7 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
f"1000 - `units` must be the one the formula line is printed in); VAT "
f"left out or applied twice (ratio 1.06 or 1/1.06); a transposed "
f"digit; the two sides copied off DIFFERENT cards or different "
f"months; or - the expensive one - network charges, levies or excise "
f"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian card's "
f"Tariefformule line is the ENERGY component alone (Eneco tabulates "
f"network tariffs and levies separately; ENGIE says in so many words "
f"that the formula excludes network, distribution, transport, levies "
f"and excise), the add-on adds price_taxes_eur_kwh on top of it, and "
f"folding ~12 c EUR/kWh of network charge into `offset` bills you for "
f"it twice. That is the error this check exists to catch, and it is "
f"why it cannot be switched off.")
f"months; {leg.card_causes}")
# --------------------------------------------------------------------------- #
@@ -539,13 +832,27 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
fetched series. What it catches is a formula whose whole LEVEL is wrong,
which is the factor-of-1000 class arriving from the end
`MAX_PLAUSIBLE_EUR_KWH` does not bound.
⚠️ AND IT IS THE CONSUMPTION LEG'S BAND ONLY - TARIFF-16 DELIBERATELY DID
NOT POINT IT AT THE INJECTION LEG. Two reasons, and the second is an
acceptance criterion rather than a preference:
* it adds `taxes_eur_kwh`, and `price_taxes_eur_kwh` is a CONSUMPTION
component. Nothing is added to what a site is PAID for export, so the
`all_in` half would be adding a number the injection leg never carries;
* its floor is `energy >= 0.02 EUR/kWh`, and a REAL Eneco injection price
is negative for any BELPEX-H below 11,88 EUR/MWh - the hours a PV
household actually exports in. Applied to injection this floor would
refuse the market, and refuse it hardest in the hours where knowing the
number matters most. `implausible_injection()` is the injection band
and it is deliberately NOT this function with a lower floor.
"""
energy = price_eur_kwh(spec, spec.card_index_eur_per_mwh)
all_in = energy + float(taxes_eur_kwh)
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
return None
return (
f"price_formula's own card works out at {energy:.5f} EUR/kWh of "
f"{spec.option}'s own card works out at {energy:.5f} EUR/kWh of "
f"energy ({spec.card_expect_incl_vat:g} {spec.units} as the card "
f"prints it) and {all_in:.5f} EUR/kWh all-in once "
f"price_taxes_eur_kwh is added, which is outside the plausible band "
@@ -561,6 +868,85 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
f"the best charging opportunity of the year - is never refused.")
def implausible_injection(spec: FormulaSpec):
"""The INJECTION leg's plausibility band. Returns a refusal or None.
⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, NOT REFUSED, AND NEITHER RUNG
BELOW LOOKS AT THE SIGN OR THE LEVEL OF A PRICE. Eneco's published line
`0,1 x BELPEX-H - 1,188` is negative for any BELPEX-H under 11,88 EUR/MWh;
those are the midday hours a PV household exports in, so a band with a
floor above zero - which is what `implausible_all_in()` has, for the
consumption leg's own good reasons - would refuse the real market and
would hide it in exactly the hours it costs money. You CAN be charged to
export. The add-on says so rather than declining to represent it.
THE PROBLEM THAT LEAVES, STATED BEFORE THE SOLUTION. The consumption
floor was not decoration: it is what catches a whole card read a hundred
times too small, the one unit error the card self-check cannot see because
it cancels out of a comparison between the two sides. A band that spans
zero cannot catch it - 0.00132 EUR/kWh is inside any interval that
contains -0.03. So the bound moves OFF the price and ONTO the
coefficients, where it is index-free and sign-free:
* PASS-THROUGH. `factor x UNITS[units] / 0.001` is the dimensionless
share of the wholesale price the contract passes through, because one
EUR/MWh IS 0.001 EUR/kWh. Eneco's injection leg is 1.00 and Aspiravi's
is 0.70. A factor typed 100x too small reads 0.01 and 100x too large
reads 100, whatever the index and whatever the sign of the result.
* OFFSET MAGNITUDE. Eneco's is 1,188 c EUR/kWh = 0.01188 EUR/kWh and
Aspiravi's is 2 c = 0.02. A hundredfold error is 1.188 EUR/kWh, which
is not an injection tariff in any direction. The bound is on the
ABSOLUTE value, so a negative offset of a believable size passes and
that is the normal case on this leg.
⚠️ TWO RUNGS THAT CAN BOTH ANSWER ONE STIMULUS, SO THEY ARE GRADED ON
THEIR REASONS AND MUTATED TOGETHER (MUTATION.md, TEST-16). A whole card
read 100x too large trips both. The fixtures in test_price.py 17d are
built so that each rung is the ONLY one that fires on its own stimulus.
⚠️ AND WHAT THIS CANNOT SEE, WITH THE CLAIM (habit 13). It bounds the
SHAPE of the coefficients, never their truth: a contract really written
against a different index, or last year's coefficients, passes both rungs
and reproduces any card typed beside them. Where a card DOES print an
estimate the self-check grades the pair against it; where it does not -
which is Eneco's injection line - these two bounds are the whole of what
is mechanised, and validate.py says so at startup.
"""
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
return (
f"{spec.option}.factor is {spec.factor:g} {spec.units} per "
f"EUR/MWh, which passes through {spec.passthrough:.4g}x the "
f"wholesale price - outside the plausible "
f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x for an injection tariff. A "
f"Belgian injection formula pays a share of the market price a "
f"little under or about equal to it: Eneco's "
f"`0,1 x BELPEX-H - 1,188` c EUR/kWh is 1.00x and Aspiravi's "
f"`0,07 x Belpex - 2` is 0.70x. {spec.passthrough:.4g}x is what a "
f"factor typed in the wrong units looks like - check `units` "
f"({spec.units!r}) against the unit printed beside your formula, "
f"because the card self-check cannot see a factor of 10, 100 or "
f"1000 applied to BOTH sides at once. ⚠️ This bounds the "
f"COEFFICIENT and not the price: an injection price below zero is "
f"accepted and is the real market - Eneco's goes negative under "
f"11.88 EUR/MWh of BELPEX-H, which is the middle of a sunny day.")
if abs(spec.offset_eur_kwh) > INJECTION_OFFSET_MAX_EUR_KWH:
return (
f"{spec.option}.offset is {spec.offset:g} {spec.units} = "
f"{spec.offset_eur_kwh:+.5f} EUR/kWh, and |offset| above "
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh is not a Belgian "
f"injection tariff in either direction. The published ones are "
f"small and NEGATIVE - Eneco -1,188 c EUR/kWh (-0.01188), "
f"Aspiravi -2 c (-0.02) - so a minus sign here is expected and is "
f"not what this refuses; a MAGNITUDE two orders out is. The usual "
f"cause is the offset typed in a different unit from `units` "
f"({spec.units!r}), or a consumption offset copied onto the "
f"injection leg: they are different numbers on the same card. "
f"⚠️ Network charges and levies do not belong here either - "
f"nothing is added to what you are PAID for export, so an offset "
f"carrying them is simply wrong rather than double-counted.")
return None
def stale_card(spec: FormulaSpec, today=None):
"""A warning when the card is older than ~6 months, or None. NEVER a
refusal: ENGIE freezes a contract's coefficients for its duration, so an
@@ -571,7 +957,7 @@ def stale_card(spec: FormulaSpec, today=None):
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
return None
return (
f"price_formula's card is dated {spec.card_month}, which is "
f"{spec.option}'s card is dated {spec.card_month}, which is "
f"{(now - dated).days} days ago. Suppliers reprice: if your contract "
f"has been renewed or rolled over since then, the coefficients here "
f"are last year's and every price this add-on computes is wrong by "
+333 -20
View File
@@ -175,6 +175,124 @@ only one of them is WIRED - see `BOUNDS` below and the note on SoC.
integrator, and K. It does NOT owe a second freshness mechanism - `Freshness`
below takes its obligation term from the caller precisely so that an energy
integral can be handed in where command travel is handed in here.
THE EXIT, AND WHY THE FIRST VERSION OF THIS MODULE DID NOT HAVE ONE (SAFETY-57)
==============================================================================
⚠️ THE VERDICT ABOVE IS ABOUT AN OBLIGATION, AND ITS OWN CONSEQUENCE REMOVES
THE OBLIGATION. Declaring the reading stale makes `Controller.inputs_ok` False,
which commands 0 W (§11.1 rule 1). At 0 W nothing is asking the register to be
anywhere - `|standing| >= MOVE_W` is false, so the conjunction cannot arm - and
the class docstring's clearing rule, "an arrival carrying a DIFFERENT number",
asks the register for evidence that only a command could have produced. A pack
sitting at its BMS floor answers the same integer watt for hours. **The state
had no exit reachable from inside it**, and the operator-facing line said the
opposite: *"It clears itself the moment the register reports a different
value."*
MEASURED ON THE LIVE SITE, 2026-09-21, and committed as
`scenarios/safety57-2026-09-21/` - a passive RS-485 capture, decoded by
`tools/decode_bus_capture.py`:
10:51:24.7 0x0518 reaches -3 W. The pack is at 11 % and the loop has just
reversed from -447 W of charge into discharge; the BMS refuses
and the inverter idles. The register is HEALTHY and TRUTHFUL.
10:51:29.7 the standing command reaches +1060 W. 800 W of travel, and the
register is obliged to follow it.
10:52:00 the conjunction arms. `Inverter.batt` becomes None.
10:52:21.7 the site commands 0 W - on the wire, in the capture.
... 0x0518 does not change value for 850.0 s. The setpoint does not
change value for 790.0 s. Nothing in this add-on can end it.
11:04 a human restarts the add-on.
11:05:31.7 the restarted loop commands -400 W of charge, which the pack
ACCEPTS at 11 %, and the register moves 3 s later.
⚠️ AND IT HAD BEEN ESCAPING BY LUCK. This BMS emits a keep-alive pulse of a few
hundred watts, which twitches 0x0518 and satisfies the clearing rule by
accident. Replaying the committed 16-day loop capture
(`scenarios/loop-2026-09-02_2026-09-17/`) through the shipped conjunction at
1 Hz gives **93 trips**, and the time from each trip to the register's next
change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
property of this battery's firmware. It is not a property of this code, and the
add-on must not depend on it.
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
exit, not how long the block lasts":
RELEASE <=> the verdict is standing
AND |standing command| < MOVE_W has held continuously for
>= RELEASE_S
On a release the latch clears and a NEW grading window opens at that instant:
the register is re-graded, from scratch, against the commands issued AFTER it.
Nothing about the register's own history is rewritten - `changed_mono`,
`held` and `unchanged_s()` go on reporting that the register has not moved for
850 s, because it has not.
⚠️ `RELEASE_S` IS `BATT_OBLIGED_S`, 17.0 s, AND IT IS THE FLOOR OF ITS BAND
RATHER THAN A PREFERENCE - the mirror of `MOVE_W` taking the ceiling of its
own. The question the bound answers is *has the register had its chance to
answer the command that is now standing?*, and that is exactly the 17.0 s
already derived above: 6.0 s of plant, one 10 s publish, one 1 s read. Below it
the release happens before the cheaper exit - the register moving by itself -
could have occurred. Which half is safe is the opposite of SAFETY-13's, and for
the reason that card states in its §4.2 table: being wrong LONG here is the
failure being fixed (a site parked while it exports), and being wrong SHORT
costs one re-grading window, which is bounded below.
WHAT A RELEASE COSTS, MEASURED AND NOT ABSORBED - AND IT IS NOT SMALL. For the
duration of the new window the consumers get the held number again, which is
the exposure this module was written to close. Driven at the live install's
shape against a register frozen for good (`test_freshness.py` part 10c), over
1200 s:
shipped parked 1166 s, the held number reaches a consumer 35 s 2.9 %
SAFETY-57 parked 571 s, the held number reaches a consumer 630 s 52.5 %
⚠️ **52 % IS THE PRICE OF THE EXIT AND IT IS PAID EVERY CYCLE**, not an
occasional slip. The period is the whole of `stale_input_s` (15 s) + `RELEASE_S`
(17 s) parked, then one `BATT_UNCHANGED_S` (35 s) window of re-grading. It is
bounded by construction, and that bound is the one thing that makes it payable:
the new window is AT MOST BATT_UNCHANGED_S = 35 s, and 35 < 75 < 85 - the
circulation hold and the per-unit sign hold. NEITHER CONSUMER CAN REACH A
VERDICT INSIDE ONE WINDOW, and a re-trip returns them a None, which CLEARS
both windows rather than accumulating into them.
That inequality is the same one part 6 already grades, and it is why the window
is `BATT_UNCHANGED_S` rather than a number chosen to make the table above look
better. Neither detector can be made to say anything by a held number under
this scheme - which is exactly the state the shipped code leaves them in as
well, since a permanent None clears both windows for ever. **So nothing that
SAFETY-37 detects is given back.** What the held number does reach for those
35 s is `site_batt_w`, the anti-windup input of `control.compute()`, which
responds to a large unexplained divergence by FREEZING the output - the
conservative direction, and SAFETY-13's business. And the command itself is
derived from the P1 meter, never from `batt`, so a site regulating through a
re-grading window is regulating on the signal it always regulates on.
**A register frozen under a command that never travels 250 W is still not
detected at all** (survivor 2, unchanged), and a dead publisher whose value
alternates is still invisible (survivor 3).
⚠️ AND THE NARROWING THIS DOES NOT TAKE, named so the next card can. The live
episode's register refused a DISCHARGE into an 11 % pack and then followed a
CHARGE immediately; a refusal in one direction is not evidence about the other,
and a gate that knew the pack's floor could re-grade on the first command that
crosses it instead of on a timer. That needs SoC, which this module deliberately
does not take (see the `soc` row), and the pack floor, which is SAFETY-32's
number. It would shrink the 52 % above; it is not needed to give the state an
exit, and mixing it in here would make the exit depend on a second measurement
being right.
⚠️ WHAT THIS DOES NOT DO, because the temptation is to call it a fix for the
whole episode: it does not tell a BMS refusal apart from a dead register. The
site above was parked for 790 s by a register that was working perfectly, and
after this change it is parked for `stale_input_s + RELEASE_S` instead - about
32 s on the shipped default - and then regrades. Telling the two apart needs
the pack's own floor, which is SAFETY-32's number and not this module's input;
the wording the operator reads about it is SAFETY-56's.
"""
import time
@@ -211,6 +329,19 @@ BATT_UNCHANGED_S = 35.0
# and goes red if the two drift.
MOVE_W = 250.0
# SAFETY-57. How long the obligation must have been ABSENT before a standing
# verdict is withdrawn and the register re-graded. See the module docstring's
# last section for the live episode this exists for and for which half of the
# band is the safe one.
#
# ⚠️ IT IS `BATT_OBLIGED_S` AND NOT A NEW NUMBER, and that identity is the
# derivation rather than a convenience. The question is "has the register had
# its chance to answer the command standing NOW?", which is the same physical
# quantity the floor of `BATT_UNCHANGED_S`'s band is: 6.0 s of plant, one 10 s
# ESPHome publish, one 1 s add-on read. Releasing sooner would throw away the
# cheaper exit - the register moving by itself - before it could have happened.
RELEASE_S = BATT_OBLIGED_S # 17.0
@dataclass(frozen=True)
class Bound:
@@ -219,6 +350,7 @@ class Bound:
signal: str
unchanged_s: float
move_w: float
release_s: float
wired: bool
derivation: str
@@ -233,13 +365,19 @@ BOUNDS = {
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
unchanged_s=BATT_UNCHANGED_S,
move_w=MOVE_W,
release_s=RELEASE_S,
wired=True,
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]"),
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]. "
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
"standing command below 250 W, because at 0 W the register "
"is obliged to nothing and the verdict cannot be tested "
"again from inside the state it creates"),
"soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"),
move_w=float("inf"),
release_s=float("inf"),
wired=False,
derivation="TEL-08. Command travel is the WRONG obligation term for "
"SoC - a rested pack holds a percent for hours under a 0 W "
@@ -263,10 +401,33 @@ class Freshness:
Declaring the reading stale makes `Controller.inputs_ok` False, which
commands 0 W (§11.1 rule 1), which drops the standing command below
`MOVE_W`, which un-arms the conjunction, which would return the held number
to the consumers - and round again every read. So the ONLY thing that clears
the latch is an arrival carrying a DIFFERENT number, which is exactly the
evidence the verdict was about. A register that is working clears it on its
next 10 s publish; a register that is not, does not.
to the consumers - and round again every read. So an arrival carrying a
DIFFERENT number clears the latch, which is exactly the evidence the verdict
was about. A register that is working clears it on its next 10 s publish; a
register that is not, does not.
⚠️ AND IT IS NOT THE ONLY THING THAT CLEARS IT ANY MORE (SAFETY-57), BECAUSE
THAT MADE THE STATE INESCAPABLE FROM INSIDE. The command the verdict itself
produces is 0 W, and at 0 W the register is obliged to be nowhere - so
"report a different value" is evidence a parked site cannot ask for. The
live site sat at 0 W for 790 s on 2026-09-21 with a register that was
working correctly, and was freed by a human. `_release()` below withdraws a
verdict whose obligation has been gone for `bound.release_s`, and opens a
new grading window at that instant. The 1 Hz oscillation the latch exists to
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
no re-trip can land sooner than 35 s after a release, rather than on the
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
the site rests for longer than the window before it asks for anything, the
window is already old and the re-trip lands on the first read at which the
command travels 250 W again. That is survivor 5's shape, it predates this
card, and its cost is one `stale_input_s` hold.
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
different number. `graded_from` / `standing_at_grade` are the window the
VERDICT is computed over, which a release re-opens. They are the same thing
until the first release and diverge afterwards, and `unchanged_s()` - the
number an operator reads - is always the register's.
"""
def __init__(self, bound: Bound):
@@ -281,10 +442,20 @@ class Freshness:
# ability to fetch it.
self.held = None
self.changed_mono = None
# The standing command at that same instant. The conjunction compares
# against THIS, not against the previous cycle's command: what matters
# is whether the register has followed anything since it last moved.
self.standing_at_change = None
# SAFETY-57. The window the VERDICT is graded over, as opposed to the
# register's own history above, and the standing command at the instant
# it opened. The conjunction compares against THIS, not against the
# previous cycle's command: what matters is whether the register has
# followed anything since the current question was put to it. A register
# that moves opens a window, so until the first release these two are
# the change-stamps; `_release()` is where they diverge.
self.graded_from = None
self.standing_at_grade = None
# The instant the standing command last FELL below `move_w`, or None
# while an obligation is standing. The release timer runs off this.
self.rested_since = None
self.released_at = None
self.releases = 0
self.stale = False
self.reason = ""
self.arrivals = 0
@@ -311,20 +482,67 @@ class Freshness:
if self.changed_mono is None or value != self.held:
self.held = value
self.changed_mono = float(now_mono)
self.standing_at_change = (None if standing_w is None
else float(standing_w))
# Movement is the ONLY thing that clears the latch. See the class
# docstring on the 1 Hz oscillation this prevents.
self._open_window(standing_w, now_mono)
# Movement clears the latch, because it is exactly the evidence the
# verdict was about. See the class docstring on the 1 Hz oscillation
# the latch prevents, and `_release()` for the other way out.
self.stale = False
self.reason = ""
self.last = value
# ⚠️ THE ORDER OF THESE TWO IS NOT OBSERVABLE, AND THIS COMMENT SAYS SO
# BECAUSE THE FIRST VERSION OF IT CLAIMED THE OPPOSITE. Swapping them is
# a ZERO-BYTE edit and it SURVIVED the battery; the reason is structural
# rather than a missing fixture, so no fixture could have closed it:
# `_grade()` acts only when `|standing| >= move_w` and `_release()` only
# when `|standing| < move_w`, so at most one of them can do anything on
# any given read, whichever runs first. Release is written first because
# it reads in the order the state machine moves - withdraw, then
# re-grade - not because the code depends on it.
self._release(standing_w, now_mono)
self._grade(standing_w, now_mono)
def _open_window(self, standing_w, now_mono) -> None:
"""Start grading the register again, from this instant (SAFETY-57).
Called when the register moves - the window and the register's history
coincide there - and when a verdict is withdrawn, where they do not.
"""
self.graded_from = float(now_mono)
self.standing_at_grade = (None if standing_w is None
else float(standing_w))
def _release(self, standing_w, now_mono) -> None:
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
⚠️ THE TIMER RUNS ON THE COMMAND, NOT ON THE VERDICT. `rested_since` is
maintained on every arrival, latched or not, so the elapsed rest is the
real one and not "time since we last looked". An obligation reappearing
resets it to None, which is the whole point: a site that goes back to
asking the register for 250 W of movement has put the question back, and
the verdict stands until the question is withdrawn again.
"""
obliged = (standing_w is not None
and abs(float(standing_w)) >= self.bound.move_w)
if obliged:
self.rested_since = None
return
if self.rested_since is None:
self.rested_since = float(now_mono)
if not self.stale:
return
if float(now_mono) - self.rested_since < self.bound.release_s:
return
self.released_at = float(now_mono)
self.releases += 1
self.stale = False
self.reason = ""
self._open_window(standing_w, now_mono)
def _grade(self, standing_w, now_mono) -> None:
"""The conjunction. Sets the latch; never clears it."""
if self.stale or self.changed_mono is None:
if self.stale or self.graded_from is None:
return
held_s = self.unchanged_s(now_mono)
held_s = self.graded_s(now_mono)
if held_s < self.bound.unchanged_s:
return
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
@@ -333,30 +551,98 @@ class Freshness:
# consumer today. They are here for TEL-08, whose obligation term is an
# energy integral that has nothing to say until it has a window - and
# "nothing to say" must withhold the verdict, not manufacture one.
if standing_w is None or self.standing_at_change is None:
if standing_w is None or self.standing_at_grade is None:
return
standing_w = float(standing_w)
if abs(standing_w) < self.bound.move_w:
return
moved_w = abs(standing_w - self.standing_at_change)
moved_w = abs(standing_w - self.standing_at_grade)
if moved_w < self.bound.move_w:
return
self.stale = True
# ⚠️ THE SENTENCE SAYS "unchanged", SO IT GETS THE REGISTER'S OWN
# NUMBER, NOT THE WINDOW'S. They are the same until the first release
# and different afterwards, and an operator comparing this line against
# a history graph is reading the register.
shown_s = self.unchanged_s(now_mono)
# ⚠️ SAFETY-56. THE OBSERVATION IS A FACT, THE DIAGNOSIS WAS NOT, AND
# THE SENTENCE NOW STOPS WHERE THE EVIDENCE DOES. The conjunction above
# is satisfied by TWO disjoint worlds, and nothing this object is handed
# separates them:
# (1) the register has stopped reporting reality - what the gate was
# built for - or
# (2) reality is not moving, because the SOURCE cannot comply with
# the command. A pack the BMS is refusing at its floor holds
# still under any setpoint, and a register reporting that
# truthfully publishes the same unchanged number a dead one does.
# The live 2026-09-21 episode was (2) - the pack read 11 % for the
# whole 850 s hold, the commands were discharge-only, and the register
# answered the first CHARGE command within one obligation window
# (test_freshness.py 11a measures all three off the committed capture)
# - and this sentence asserted (1), which sent the operator to look at
# healthy hardware.
# Naming both is what the module can honestly say. Naming WHICH would
# need SoC and the pack floor - deliberately not this module's inputs
# (see the `soc` row in BOUNDS and the docstring's last section), and
# TEL-08's and SAFETY-32's numbers rather than this sentence's.
#
# ⚠️ AND IT IS NOT SHARED WITH `main.py`'s NOT READY ROW, WHICH SAYS
# THE SAME AMBIGUITY IN DIFFERENT WORDS ON PURPOSE. This sentence is a
# LOG LINE about a behaviour, quoted verbatim by `note()` onto the
# status page and by `_read_unit()` into the action log; that one is a
# BANNER about a named ENTITY, and the two answer different questions
# ("what did the register do" against "can I trust this entity"). One
# helper serving both would have to drop whichever half the other does
# not need, and the half it dropped would be the operator's next step.
self.reason = (
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s "
f"while the standing command moved {moved_w:.0f} W to "
f"{standing_w:+.0f} W - the reading is not following, so it is "
f"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
f"CANNOT TELL THEM APART: the register may have stopped reporting "
f"reality, or the source may be unable to comply with the command "
f"- a pack the BMS is refusing holds still under any setpoint, and "
f"an honest register then reads exactly like a frozen one. Either "
f"way the number cannot be read as this command's effect, so it is "
f"reported as MISSING rather than as a measurement (limits: "
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
f"{self.bound.move_w:.0f} W and moved by over "
f"{self.bound.move_w:.0f} W)")
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
# in its own slot: the register's unchanged time is in the sentence
# above, the WINDOW this verdict actually rests on is here, and so is
# how many times this unit has been round the loop. Without the second
# one the sentence reads as though 92 s of unchanged register were what
# was compared against the 35 s limit, and it was not.
if self.releases:
self.reason += (
f" [re-graded: the {held_s:.0f} s window this verdict rests on "
f"opened when the previous one was withdrawn, not when the "
f"register last moved - this unit has now been graded "
f"{self.releases + 1} times]")
def unchanged_s(self, now_mono) -> float | None:
"""Seconds since an arrival carried a different number. Status page."""
"""Seconds since an arrival carried a different number. Status page.
⚠️ THE REGISTER'S CLOCK, NEVER THE VERDICT'S (SAFETY-57). A release
re-opens the grading window; it does not make the register younger, and
an operator reading "unchanged for 12 s" off a register that has held
one value for 850 s would be reading this module's bookkeeping back as a
measurement. `graded_s()` is the other one and is named differently for
that reason.
"""
if self.changed_mono is None:
return None
return max(0.0, float(now_mono) - self.changed_mono)
def graded_s(self, now_mono) -> float | None:
"""Seconds the CURRENT verdict window has been open (SAFETY-57).
Equal to `unchanged_s()` until the first release, and shorter after one.
"""
if self.graded_from is None:
return None
return max(0.0, float(now_mono) - self.graded_from)
def reading(self):
"""What the consumers get: the value, or None when it cannot be trusted.
@@ -366,6 +652,13 @@ class Freshness:
rather than accumulating it, and `circulation.circulating_w` returns None
for a roster with a missing term, which clears the circulation window
too. This module is the thing that was missing between them.
⚠️ AND IT CAN GO BACK TO BEING A NUMBER WITHOUT THE REGISTER MOVING
(SAFETY-57). That is the exit, and it is bounded rather than free: the
window a release opens is `bound.unchanged_s` = 35 s, shorter than both
consumer holds (75 s and 85 s), so neither can reach a verdict inside
one - and a re-trip hands them a None, which CLEARS their windows. See
the module docstring's last section.
"""
return None if self.stale else self.last
@@ -373,6 +666,26 @@ class Freshness:
"""One line for the status page. Empty when there is nothing to say."""
return f"{unit_name}: {self.reason}" if self.stale else ""
def release_note(self, unit_name: str) -> str:
"""SAFETY-57's release, for the log. Empty when nothing was withdrawn.
⚠️ IT NAMES WHAT IS AND IS NOT BEING CLAIMED, because a line reading
"recovered" would be a lie: nothing has been learned about the register.
The verdict was withdrawn because it can no longer be tested, and the
next 35 s are the test.
"""
if self.released_at is None:
return ""
return (f"{unit_name}: STALE_WITHDRAWN after the standing command sat "
f"below {self.bound.move_w:.0f} W for "
f"{self.bound.release_s:.0f} s. NOTHING HAS BEEN LEARNED ABOUT "
f"THE REGISTER - it is still holding {self.held:+.0f} - but a "
f"verdict about a command that is no longer standing cannot be "
f"tested, and the 0 W this one produces is what stopped it "
f"being tested. The register is now re-graded over a fresh "
f"{self.bound.unchanged_s:.0f} s window; if it still does not "
f"follow, this returns")
def monotonic() -> float:
"""The clock this module's callers use, in one place so tests can find it.
+676 -12
View File
@@ -62,10 +62,12 @@ from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
)
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
from .mqtt import (MqttPublisher, announced_units, optional_keys, unit_slugs,
unit_state_key)
from . import peakguard
from . import packshare
from . import pv as pv_read
from . import shadow
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option, provider_configured
from .price_history import PriceHistory
@@ -830,10 +832,68 @@ class Controller:
# live state
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
# --- SAFETY-10 -------------------------------------------------------
# What the decision path reached on the last shadow cycle, in W, or
# None before the first one. ⚠️ IT IS KEPT SEPARATELY FROM
# `self.target` BECAUSE THEY ARE DIFFERENT FACTS: `self.target` is what
# is STANDING ON THE WIRE - held at `shadow.WIRE_W` for the whole of a
# shadow run, and re-seeded to it at the end of every shadow cycle so
# the loop and the sign watchdogs track reality - and this is what a
# live controller WOULD have put there. Folding them into one field is
# how a shadow run starts telling an operator a number nothing is
# doing.
self.shadow_would_w = None
# SAFETY-47. Was the number above SIZED FROM THE BILLED-QUARTER METER
# on the cycle it was recorded? ⚠️ IT IS RECORDED WHERE THE DECISION
# WAS TAKEN, NOT RE-DERIVED WHERE THE PAGE IS RENDERED. The status page
# runs on its own task and `self.peak_guard` has moved on by then, so a
# page that asked the question again would be answering it about a
# different cycle from the one whose watts it is displaying - which is
# the same two-facts-one-field defect the comment above this one is
# about. ⚠️ False AND NOT None BEFORE THE FIRST SHADOW CYCLE, unlike
# the field above: this one is a claim ABOUT a number, and there is no
# number yet, so the honest value is "nothing to disclose". The row's
# `would_w is None` branch renders no watts at all there.
self.shadow_sized_from_meter = False
# Edge state for the action log, both of them. `_shadow_was` is None
# until the first cycle, so an install that comes up IN shadow mode
# says so in the log it will be read from rather than leaving the
# operator to infer it from a page nobody is looking at yet. Coming up
# LIVE is silent - see `_shadow_settle()`, which says why.
self._shadow_was = None
self._shadow_winner = None
self.target = 0.0
self.sat_count = 0
self.i_w = 0.0 # the loop's integrator, carried between cycles
# TEL-10. What the last cycle's loop did, kept for publication. Seeded
# to the SAME values the "stopped or blind" branch of `cycle()` writes,
# because before the first cycle the loop has not run - which is the
# thing that branch is for.
self.loop_i_w = 0.0
self.loop_i_lead = 0.0
self.loop_reason = "not-running"
self.loop_w = None # what the loop asked for last cycle, or None
# --- TEL-11: the site's PV production, and NOTHING READS IT ----------
# ⚠️ INSTRUMENTATION. It is published, it is shown on the status page,
# and it reaches no decision: not `inputs_ok`, not the allocator, not
# the arbiter, not a claim, not a watchdog. Wiring it into the control
# law would be an unrequested behaviour change on a system driving a
# real battery AND would destroy the measurement, because the loop would
# then react to the quantity being measured. `tools/tel11_pv_trace.py`
# is the instrument that says so, on both trees and both settings.
#
# ⚠️ `None` IS A READING THAT DOES NOT EXIST, AND IT IS SEEDED AS ONE.
# Nothing here ever becomes 0.0 by default; see `app/pv.py`.
self.pv_w = None
# Why there is no reading, in words the page prints - or, on a good
# read, the UNIT the value came in. Never empty.
self.pv_why = ("not configured" if not self.pv_roster
else "not read yet")
# TEL-12. EVERY configured entity's own answer, `(entity, watts, why)`,
# in roster order - kept because `checks()` has to be able to name the
# sensor that is missing, and `self.pv_w` alone cannot. Empty before the
# first read, which is what "no entity has answered yet" is.
self.pv_parts = []
# 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
# wire right now, so it is recorded when the arbitration happens rather
@@ -904,6 +964,12 @@ class Controller:
# None (startup) or the PREVIOUS cycle's decision (an input hold), and
# the status row rendered all three identically.
self.arb_at = None
# SAFETY-45/47. The billed-quarter charging headroom the arbitrage step
# was handed on the cycle above, or None when nothing measured it. It
# is CARRIED rather than recomputed for `arbitrage.sized_from_meter()`,
# because `self.peak_guard` is a different cycle's decision by the time
# the status page renders.
self.arb_headroom_w = None
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
# is the exact, derived statement of "the latest cycle did not get as
# far as the arbitrage step", which is the fact the *Arbitrage* row
@@ -1607,6 +1673,57 @@ class Controller:
for inv in self.inverters)
# -- io ------------------------------------------------------------------
@property
def pv_roster(self) -> tuple:
"""Every configured PV production entity, or `()`. TEL-11/TEL-12.
⚠️ ONE READING OF THE OPTIONS, and it is `pv.entities()` - the SAME call
`mqtt.optional_keys()` makes. The thing that decides whether the row is
ANNOUNCED, the thing that decides whether it is READ and the thing that
decides WHAT IS SUMMED have to be one predicate, or the add-on ends up
with an entity nothing feeds, a topic nothing declared, or a total taken
over a different list from the announced one. `announced_units()` in
mqtt.py is the same argument for the inverter roster, in its own words.
⚠️ IT REPLACED A `pv_entity` PROPERTY RATHER THAN JOINING ONE. Two
properties where one is a subset of the other is the second spelling
arriving by the back door; every gate in this file reads this one.
"""
return pv_read.entities(self.o)
async def _read_pv(self) -> None:
"""This cycle's SITE PV production, or an ABSENCE with a reason.
TEL-11 for one entity, TEL-12 for a roster of them. `pv.total()` is where
"one of three is absent" becomes an absence of the SITE figure rather
than the sum of the two that answered, and its docstring is the argument.
⚠️ IT ASSIGNS ON EVERY CALL, INCLUDING THE FAILING ONES. Leaving the
last good number standing through an outage is what `circulation` does
on purpose - there, a stale figure beats a zero that reads as health -
and it is exactly wrong here: this row is read back as HISTORY, and a
value that persists through an outage is indistinguishable from a real
one at the moment somebody is trying to work out whether the sun was
shining.
⚠️ `hass.state()` RATHER THAN `hass.number()`, because the UNIT is half
the reading. See `pv.watts()`: a PV integration may publish kW, and
there is no magnitude heuristic that can separate 30 W of dusk from
30 kW of noon.
"""
# ⚠️ TEL-12: EVERY ENTITY IN ONE GATHER, for the reason the outer
# gather exists (see `read_inputs()`). Read serially, a three-inverter
# roster with one slow sensor would add up to three of `hass.state()`'s
# 10 s timeouts to a cycle that feeds the ESP32 heartbeat - i.e. the
# telemetry channel would be able to push a healthy battery towards
# layer 1's veto, which is exactly the foot in the control path this
# card is not allowed to have. Gathered, N entities cost what one does.
roster = self.pv_roster
states = await asyncio.gather(
*(self.hass.state(entity) for entity in roster))
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
list(zip(roster, states)))
async def read_inputs(self, now_mono: float | None = None) -> None:
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
@@ -1638,8 +1755,26 @@ class Controller:
# names for the WRITE path, one function up. Within a unit the order is
# unchanged (SoC, then battery power), so at the shipped roster of one
# this is the same two calls in the same order.
await asyncio.gather(*(self._read_unit(inv, now_mono)
for inv in self.inverters))
#
# --- TEL-11: AND THE PV READ RIDES IN THIS SAME GATHER ---------------
# ⚠️ IN THE GATHER RATHER THAN ON A LINE OF ITS OWN, AND THAT IS THE
# WHOLE "OBSERVABILITY MUST NOT BE ABLE TO DELAY CONTROL" DECISION.
# This coroutine feeds the ESP32 heartbeat; a sequential read would add
# its own worst case - `hass.state()`'s 10 s timeout - to every cycle,
# so a hung PV sensor at a client site would push a healthy battery
# towards layer 1's ~30 s veto for the sake of a telemetry row.
# Gathered, its latency OVERLAPS reads that already happen and the
# added worst case is zero. `peak_forecast_entity` below is read
# serially and stays that way because it is a CONTROL input (the
# capacity guard sizes off it), so the site is entitled to wait for it.
#
# ⚠️ AND AN EMPTY PV ROSTER APPENDS NOTHING. Not a coroutine, not an
# HTTP call, not a log line: at the shipped default this gather is the
# same list of the same coroutines it has always been.
_reads = [self._read_unit(inv, now_mono) for inv in self.inverters]
if self.pv_roster:
_reads.append(self._read_pv())
await asyncio.gather(*_reads)
if o.get("peak_forecast_entity"):
raw = await self.hass.number(o["peak_forecast_entity"])
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
@@ -1725,6 +1860,7 @@ class Controller:
inv.soc = await self.hass.number(inv.soc_entity)
raw = await self.hass.number(inv.batt_entity, inv.batt_invert)
was_stale = inv.batt_fresh.stale
was_releases = inv.batt_fresh.releases
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
# ⚠️ `inv.batt` IS THE GATED VALUE. A stale register is a MISSING input,
# not a measurement - `inputs_ok` sees the None, the site holds for
@@ -1733,11 +1869,88 @@ class Controller:
inv.batt = inv.batt_fresh.reading()
if inv.batt_fresh.stale and not was_stale:
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
_LOG.error("SAFETY-37: %s battery-power register is not following "
"its own command - %s. Treating it as MISSING, which "
# ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING
# ELSE IN IT. The escape clause used to read "It clears itself the
# moment the register reports a different value", full stop - and
# that was FALSE for the state this same line announces, because the
# 0 W it announces in the sentence before is exactly what stops the
# register ever being asked to report anything different. The live
# site sat in it for 790 s on 2026-09-21 and was freed by a human.
# ⚠️ SAFETY-56 REWROTE THE DIAGNOSIS HALF, WHICH SAFETY-57 LEFT
# STANDING ON PURPOSE. It used to open "battery-power register is
# not following its own command" - an accusation against a named
# piece of hardware, and FALSE on the very episode above. The
# pack read 11 % for the whole hold, every command in it was a
# DISCHARGE, and the register answered the first CHARGE command
# three seconds later - so the BMS was refusing, the register was
# publishing that refusal correctly, and the operator was sent to
# look at a working sensor. All three numbers are measured off the
# committed capture by test_freshness.py part 11a. The gate's
# conjunction is right and is NOT touched; only its conclusion was.
#
# ⚠️ AND THE TWO EXPLANATIONS ARE NAMED ONCE, IN `%s`, NOT TWICE.
# This line's own words are the OBSERVATION plus the one thing a
# log reader has to be told before acting - that the observation is
# not yet a verdict - and `freshness._grade()`'s sentence, which
# this line already interpolates, is where the pair is spelled out.
# Saying it in both halves of one sentence is how an operator
# learns to skim it.
_LOG.error("SAFETY-37: %s battery-power reading did not move under "
"a command that did, WHICH IS NOT BY ITSELF A VERDICT "
"ON THE REGISTER - %s. Treating it as MISSING, which "
"commands 0 W. It clears itself the moment the register "
"reports a different value.",
inv.name, inv.batt_fresh.reason)
"reports a different value, and failing that %.0f s "
"after the standing command falls below %.0f W "
"(SAFETY-57: a verdict whose own consequence is a 0 W "
"command cannot be tested again from inside it).",
inv.name, inv.batt_fresh.reason,
inv.batt_fresh.bound.release_s,
inv.batt_fresh.bound.move_w)
elif inv.batt_fresh.releases > was_releases:
# ⚠️ SAFETY-57. REPORTED, NEVER SILENT. A latch that lets go on a
# timer and says nothing is a latch no operator can audit - and this
# one is withdrawing a SITE STOP. `release_note()` is written to say
# that nothing has been learned about the register, because nothing
# has.
self.log_event(f"STALE WITHDRAWN {inv.name}")
_LOG.warning("SAFETY-57: %s",
inv.batt_fresh.release_note(inv.name))
@property
def shadow(self) -> bool:
"""SAFETY-10. Is the battery being left alone right now?
⚠️ A PROPERTY, NOT A FIELD, AND FOR THE REASON `self.units` IS ONE.
Three surfaces ask this question at three different moments - the
cycle, the write, and the status page rendered between two cycles - and
a cached flag is a fourth answer that can disagree with the option and
the button it is derived from. Derived, they cannot.
⚠️ AND THE WRITE GATE ASKS THIS, NOT A PARAMETER PASSED DOWN TO IT. A
suppression that had to be threaded through `write_setpoint()` would be
absent from every call site that forgot it - the heartbeat, the stop
button, `shutdown()` - which is exactly the shape of hole this add-on's
single choke point exists to close.
"""
return shadow.engaged(self.o, control_started=self.auto)
def shadow_latches(self) -> tuple:
"""The failsafe latches that are UP, named, for the shadow banner.
A latch that is standing while the controller is inert is standing when
it stops being inert. `shadow.banner()` carries them so an operator who
turns shadow mode off is not handed a battery that will not move and a
page that never mentioned why.
"""
out = []
if self.circ_wd.tripped:
out.append("circulating power")
if self.site_sign_wd.tripped and len(self.inverters) > 1:
out.append("site sign watchdog")
for inv in self.inverters:
if inv.sign_wd.tripped:
out.append(f"{inv.name} sign watchdog")
return tuple(out)
def write_refusal(self, value: float, inv=None) -> str | None:
"""Why `value` may NOT be written to `inv` right now, or None if it may.
@@ -1930,6 +2143,40 @@ class Controller:
self.log_event(f"{inv.name}: {result.describe()}")
value = result.applied_w
# --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE -----------
# Shadow mode runs the whole decision path and commands nothing. The
# suppression is HERE, in the one method every write in this add-on
# reaches - the cycle, the heartbeat, the stop button, shutdown() and
# maint_abort() alike - so there is no call site that can route round
# it. app/shadow.py carries the whole argument; three things about the
# placement belong here:
#
# ⚠️ IT IS A SUBSTITUTION, NOT A RETURN, exactly like the two refusals
# above it. Returning would leave the inverter LATCHED ON ITS LAST
# COMMAND with the ESP32's heartbeat still being fed - a battery held
# at whatever it was doing when shadow mode was switched on, under a
# status page saying nothing is being commanded. That is the failure
# this add-on keeps finding, and shadow mode must not be a new way to
# reach it. `shadow.WIRE_W` is 0.0 W and it is REFRESHED on the
# ordinary heartbeat, which is also what firmware/goodwe-master.yaml's
# 30 s stale-input watchdog requires of us.
#
# ⚠️ IT IS BELOW SAFETY-28's GATE AND BELOW SAFETY-03's CLAMP, so both
# still grade the number the decision path actually produced. A shadow
# run that could not say "it would have hit the rating here" would be
# answering a different question from the one it is for.
#
# ⚠️ IT IS NOT A SECOND WRITER. No claim is filed, no transport is
# opened and no arithmetic of its own reaches the wire: one actuator,
# one writer, and this is that writer emitting the value it emits for
# every other refusal.
if self.shadow and value != shadow.WIRE_W:
_LOG.info("SHADOW MODE: not commanding %+.0f W to %s - holding "
"%+.0f W. The decision was reached and recorded; "
"nothing was sent to the inverter.",
value, inv.name, shadow.WIRE_W)
value = shadow.WIRE_W
changed = (inv.last_write is None or value != inv.last_write)
if not (changed or force):
return
@@ -2549,7 +2796,16 @@ class Controller:
# operator switching control off for the whole site; "inputs missing" is
# the SITE's own telemetry gone - the meter this controller regulates
# against. Neither is about a pack, and both must stop every inverter.
if not self.auto:
# ⚠️ SAFETY-10: `and not self.shadow`, AND IT IS THE ONE FAILSAFE THIS
# CARD CHANGES. `P_SAFETY` out-ranks every other claim, so leaving the
# stop claim in while shadow mode is engaged would make the recorded
# decision "stopped" on every cycle - a shadow run that answers the
# wrong question, which is the same as no shadow run. The actuator ends
# at the same number either way, and the substitution in `_write_unit()`
# that replaces it is STRICTLY STRONGER than this claim: a claim can be
# out-ranked by a higher priority, and the wire gate cannot be
# out-ranked by anything.
if not self.auto and not self.shadow:
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped",
actuator=self.actuator_of("safety")))
elif not self.inputs_ok:
@@ -2720,12 +2976,35 @@ class Controller:
# `arb_at` is then the age the status row reports rather than a
# decision presented as current.
self.arb, self.arb_at = None, now
self.arb_headroom_w = None
if arbitrage.enabled(self.o):
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
# SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED. One evaluation of
# the shipped expression feeds both the sizing below and
# `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the
# caveat on the recorded watts can never be answered from a
# different cycle's guard than the watts were.
self.arb_headroom_w = peakguard.charge_headroom_w(self.peak_guard)
self.arb = arbitrage.decide(
self.fresh_tariff_plan(now), now,
peak_guard_active=arbitrage.guard_state(self.peak_guard),
# SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS
# ONE. `guard_state()` is a switch and it was the whole loop:
# the guard engaged on a 5 W floor, the trade was refused
# outright, the loop took the actuator, the meter fell, the
# guard released and the trade came back AT max_w. Measured on
# TEST-23 stage 3 at the shipped peak_cap_w/max_w: 28 charge
# onsets an hour, grid -456 <-> +3200 W. This is the watts the
# billed quarter can still afford, so the buy leg is CLAMPED
# instead - the shape control.maintenance_charge_floor() has
# always given a maintenance charge, off the MEASURED guard
# rather than off the optional peak_forecast_entity.
# ⚠️ SAME ORDERING ARGUMENT AS THE LINE ABOVE: this reads the
# peak block's conclusion, ~50 lines up, so it must stay after
# it. Evaluated earlier it would size this quarter's trade from
# the previous cycle's quarter.
charge_headroom_w=self.arb_headroom_w,
# The emptiest pack again, and for the sell side that is the
# right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there
# is one `soc` argument - `pack_floor_soc` names that residual
@@ -2877,7 +3156,14 @@ class Controller:
inv)))
# --- the grid-following loop -----------------------------------------
if self.auto and self.inputs_ok:
# ⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most
# needs and the one that is switched off by `auto_start: false`, so
# without this an install in `auto` shadow mode would record every
# strategy's decision and a permanent silence where the grid-following
# loop should be. It is safe because the loop's `prev_w` is
# `self.target`, which the end of this method re-seeds to what is
# actually standing on the wire.
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware
# actually did, and the controller must track reality or it jumps
@@ -2908,8 +3194,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,
@@ -2922,6 +3215,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
@@ -2946,6 +3274,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.
@@ -3211,6 +3548,133 @@ class Controller:
# reads only `self.p1`, which no line in this method writes.
self.peak.sync(self.p1)
# --- SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS -----
# Every guard above has now read `self.target` - the resolved intent -
# and SAFETY-18 in particular grades exactly that. Re-seeding before
# them would hand each one a 0 W that no decision produced. Re-seeding
# AFTER them hands the NEXT cycle, and the sign watchdogs, what is
# actually standing on the wire. See `_shadow_settle()`.
self._shadow_settle(resolution)
def _shadow_settle(self, resolution) -> None:
"""SAFETY-10. Record the decision, then re-seed the carried command.
TWO JOBS, AND THEY ARE IN THIS ORDER BECAUSE THE SECOND DESTROYS THE
FIRST'S EVIDENCE.
1. THE RECORD. A shadow run is only worth running if it answers "what
would it have done, and why". `self.shadow_would_w` and
`Resolution.explain()` are that answer, and the action log gets one
line per CHANGE OF DECISION rather than one per cycle - the log
holds 40 rows, and a level-logged shadow run would flush the context
of its own findings inside four minutes at a 15 s cycle floor. The
same edge rule the peak guard and the arbitrage strategy already
use, for the same reason.
2. THE RE-SEED. `self.target` and each `inv.target` are set back to
`shadow.WIRE_W` - what the write gate actually put on the wire.
⚠️ WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING. Three
machines read the carried command as a statement about the HARDWARE:
* `compute(prev_w=self.target, ...)` - the loop's slew limiter and
its integrator. Left holding a shadow decision, the first cycle
after shadow mode is turned off would slew from a base that never
existed: up to a full `slew_w` step onto a battery sitting at 0 W.
Re-seeded, the exit is safe BY CONSTRUCTION and needs no separate
transition code - the carried command already equals the standing
one.
* `Inverter.standing_w()`, which is what SAFETY-02/30's sign
watchdogs grade. Fed a shadow decision they would compare a number
against a meter that never saw it, disagree for as long as the
decision asked for power, and LATCH A TRIP THAT SURVIVES INTO LIVE
OPERATION AND A RESTART. Shadow mode would manufacture the failsafe
it exists to help debug. ⚠️ THIS ONE IS ALREADY HELD BY THE WIRE
GATE - `standing_w()` prefers `last_write`, and that is the 0.0 W
the gate accepted - so the re-seed is the SECOND rung, covering
the fallback before any write has been accepted. Two rungs
answering one question is MUTATION.md's masking shape, and the
mutation that reproduces the hazard has to defeat BOTH: driven
singly, dropping this line leaves the detector inert and names
nothing about the latch. Measured.
* SAFETY-42's delivery watchdog compares `inv.target` against what
the pack is producing. A share nothing was told to produce is a
delinquency report on every cycle of every shadow run.
⚠️ AND EVERY CYCLE'S DECISION IS THEREFORE COMPUTED FROM WHERE THE
PLANT REALLY IS, not from a counterfactual plant that drifts further
from reality every tick. "What would this controller command NOW,
given this meter and a battery at 0 W" is a true statement about the
present; "what would the eleventh cycle of a run that never happened
have commanded" is not a statement about anything.
"""
engaged = self.shadow
if engaged != self._shadow_was:
# ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. The
# edge is against `None` on cycle one, and an install that comes up
# CONTROLLING has nothing to report - a "shadow mode off" line at
# every start is one more row of boilerplate in a 40-row log, and
# it was measured pushing a real clamp episode's context out of
# test_clamp.py part 2's ring. Coming up IN shadow mode is the
# other direction and it is always worth a line: it is the log an
# operator reads to find out why nothing is being commanded.
first = self._shadow_was is None
self._shadow_was = engaged
if engaged:
self.log_event(
"SHADOW MODE ON - " + shadow.why(
self.o, control_started=self.auto)
+ f"; inverter held at {shadow.WIRE_W:.0f} W")
_LOG.warning(
"SAFETY-10 SHADOW MODE IS ON. The full decision path runs "
"and NOTHING is commanded: every inverter is held at "
"%+.0f W on the ordinary heartbeat. %s", shadow.WIRE_W,
shadow.why(self.o, control_started=self.auto))
elif not first:
self.log_event(
"SHADOW MODE OFF - the battery is being controlled again, "
f"resuming from {shadow.WIRE_W:.0f} W")
_LOG.warning(
"SAFETY-10 shadow mode is OFF. This controller is "
"commanding the battery again, starting from the "
"%+.0f W that was standing on the wire.", shadow.WIRE_W)
if not engaged:
self.shadow_would_w = None
self.shadow_sized_from_meter = False
self._shadow_winner = None
return
self.shadow_would_w = float(resolution.target_w)
# SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY. All three rungs - the
# arbiter's winner, the leg being a CHARGE, and the headroom having
# been MEASURED - live inside `arbitrage.sized_from_meter()`, which
# says why, including why it is NOT keyed on whether the trade was
# capped. A rung written at a call site is a rung no check can reach
# with the other two clear, which is TEST-16's rule.
self.shadow_sized_from_meter = arbitrage.sized_from_meter(
self.arb, self.arb_headroom_w, winner=resolution.winner)
# ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE. A decision
# that moves from the loop to the peak guard at the same number is a
# different decision and the operator needs the line; a loop target
# that wanders by a watt is not, and 15 s of those would be the whole
# log.
# ⚠️ SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE. The same winner at the
# same watts with and without the caveat are two different claims about
# the same number, and an edge key blind to that would print the first
# one and silently keep it standing. It cannot flood: the flag only
# moves when arbitrage starts or stops owning the resolution, or when
# the guard starts or stops measuring the quarter, and both of those
# move the winner or the watts as well on every cycle measured.
edge = (resolution.winner, round(float(resolution.target_w)),
self.shadow_sized_from_meter)
if edge != self._shadow_winner:
self._shadow_winner = edge
self.log_event(shadow.log_line(
would_w=resolution.target_w, reason=resolution.explain(),
sized_from_meter=self.shadow_sized_from_meter))
self.target = shadow.WIRE_W
for inv in self.inverters:
inv.target = shadow.WIRE_W
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
@@ -4000,6 +4464,14 @@ class Controller:
or (self.site_sign_wd.tripped
and len(self.inverters) > 1)
or self.circ_wd.tripped
# SAFETY-10: "shadow" is its own value and not a
# flavour of "stopped". A fleet dashboard reporting a
# shadow install as `running` would be calling a
# battery nobody is controlling healthy; one reporting
# it as `stopped` would lose the fact that the decision
# path is live and being recorded. ⚠️ BELOW `failsafe`:
# a latch that is up is up, and shadow does not clear it.
else "shadow" if self.shadow
else "running" if self.auto else "stopped"),
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
# `site_circulating_w`. `publish()` drops None values, so a cycle
@@ -4007,7 +4479,39 @@ 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,
}
# --- TEL-11 ----------------------------------------------------------
# ⚠️ THE KEY IS PRESENT WITH THE VALUE `None` WHEN THERE IS NO READING,
# AND ABSENT ENTIRELY WHEN THERE IS NO SOURCE. Those are two different
# states and the publisher treats them as two: `mqtt.ABSENT_AS_UNKNOWN`
# turns the first into HA's own `None` payload, so the entity's state
# goes to `unknown` and the recorder records a GAP - which is how a
# reader of a week of history tells "no PV reported" from "PV reported
# 0 W". The second never reaches the broker at all, because an install
# that never set `pv_entity` or `pv_entities` has no such entity
# announced. ⚠️ TEL-12: AND THE ROW IS STILL ONE NUMBER. At a roster of
# three it is the SITE total, `None` unless EVERY entity answered - see
# `pv.total()`, whose note is where the decision not to publish a
# partial sum is argued. This line did not have to change for that, and
# that is the point: TEL-11's `None`-is-an-absence rule already covered
# it, one layer up.
#
# ⚠️ AND THE GATE IS `optional_keys()`, THE SAME EXPRESSION THE
# ANNOUNCEMENT USES. Written as a second predicate here the two could
# disagree, and both directions are bad - an announced entity nothing
# feeds, or a topic nothing declared. `announced_units()` two blocks
# down is the same discipline for the roster.
if "pv" in optional_keys(self.o):
values["pv"] = (None if self.pv_w is None
else round(self.pv_w, 1))
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
# note in mqtt.py; the composed name is not the object_id - and forces layer-1
@@ -4289,8 +4793,38 @@ class Controller:
if not entity:
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
elif value is None:
# ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
# ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read
# "<entity> is missing or not numeric" for ANY None, and one of
# the ways this value becomes None is SAFETY-37's freshness gate
# withholding a reading that is present, numeric and updating -
# `Freshness.reading()` returns None BY DESIGN there. On the
# live site on 2026-09-21 the operator opened Developer Tools on
# the entity this row named, found it working, and had every
# reason to conclude the diagnostics were broken.
#
# ⚠️ AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. The
# row could ask `inv.batt_fresh.stale` - it is on the same
# object at the same moment - but this loop also renders the
# grid and SoC rows, which have no gate, and a branch per row
# would be a second place where "why is this None" is decided.
# The gate already has a surface of its own: `stale_note()`
# rides `liveness_notes()` a few rows below with the whole
# STALE_READING sentence in it, so the operator's discriminator
# is "is that row there?" and this row's job is only to stop
# pre-empting the answer. Same reason the wording is not shared
# with `freshness._grade()`: that one is a log line about a
# BEHAVIOUR, this one is a banner about an ENTITY.
out.append({"ok": False, "warn": False,
"text": f"{label}: {entity} is missing or not numeric"})
"text": f"{label}: no usable value this cycle from "
f"{entity} - and this row cannot tell which "
f"of two things that is: the entity is "
f"missing or not numeric, or it is "
f"publishing a number that this controller "
f"is withholding (a freshness gate does "
f"exactly that, and names itself in a row "
f"of its own). Confirm the entity in "
f"Developer Tools before replacing it"})
else:
out.append({"ok": True, "warn": False, "text": f"{label}: {entity} = {value:g}"})
@@ -4495,12 +5029,115 @@ class Controller:
# is both is a check nobody reads.
out.append({"ok": False, "warn": True,
"text": "no peak forecast: maintenance charging is not capacity-capped"})
# --- TEL-11 ----------------------------------------------------------
# ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A DELIBERATE
# DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one
# nags because an unset forecast entity leaves a MONEY GUARD without an
# input; this one is pure instrumentation, and a permanent caution on
# every install that never asked for a PV channel is exactly the
# "no warning spam, no degraded status row" the card forbids.
#
# ⚠️ AND WHEN IT IS SET, A FAILED READ IS `ok=False, warn=True` - a
# CAUTION, never `bad`. `status_payload()` builds "NOT READY" from rows
# that are neither ok nor warn, and a telemetry channel that could put a
# battery site into NOT READY would be observability with a foot in the
# control path. The reason comes from `pv.watts()` rather than being
# re-derived here, so the page cannot disagree with the reading.
#
# --- TEL-12: AND AT A ROSTER OF ONE THESE TWO SENTENCES ARE 0.11.0's --
# ⚠️ THE `len(...) == 1` BRANCH IS 0.11.0's CODE UNCHANGED, DOWN TO THE
# f-string, so an install that set `pv_entity` reads the row it already
# reads. That is an ACCEPTANCE CRITERION rather than a courtesy, and it
# is graded across trees by `tools/tel11_pv_trace.py` rather than argued
# here: `pv.total()` passes a single entity's `why` through verbatim for
# exactly this reason. Multi-entity sentences are separate BECAUSE they
# have to say something the single-entity ones never could.
roster = self.pv_roster
if roster:
if len(roster) == 1:
if self.pv_w is None:
out.append({"ok": False, "warn": True,
"text": f"PV production: no reading from "
f"{roster[0]} - {self.pv_why}"})
else:
# habit 14: the value sits inside its own literal text, in
# its own slot, beside the entity it was read from and the
# unit it was read in - so a transposition of any two of the
# three is visible in the sentence rather than merely
# present in it.
out.append({"ok": True, "warn": False,
"text": f"PV production: {self.pv_w:.0f} W "
f"from {roster[0]} (reported "
f"in {self.pv_why})"})
elif self.pv_w is None:
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
# THOSE WORDS. `pv.total()`'s note is the argument; this is
# where the operator meets it. The partial information is NOT
# thrown away - every entity that reported is named with its
# watts and every one that did not is named with its reason -
# because this surface is read LIVE by a human who wants the
# diagnostic, whereas the MQTT row is read back COLD a week
# later by an attribution that would believe a smaller number.
# Different reader, different instant, different rule.
out.append({"ok": False, "warn": True,
"text": "PV production: NO SITE TOTAL - "
+ self.pv_why + ". Reporting: "
+ (", ".join(
f"{ent} {watt:.0f} W"
for ent, watt, _y in self.pv_parts
if watt is not None) or "nothing")})
else:
# habit 14 at a roster: the total in its own slot, then EVERY
# contributor in its own slot with its own watts and its own
# unit. A sum that disagrees with the addends it is printed
# beside is visible in the sentence.
out.append({"ok": True, "warn": False,
"text": f"PV production: {self.pv_w:.0f} W from "
f"{len(self.pv_parts)} entities ("
+ ", ".join(
f"{ent} {watt:.0f} W in {why}"
for ent, watt, why in self.pv_parts)
+ ")"})
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
# `entities()` makes `pv_entities` REPLACE `pv_entity`, which is the
# right rule (its docstring says why) and is also the rule an
# operator upgrading from 0.11.0 is most likely to trip over: they
# add all their inverters to the new field and the old key is left
# standing, doing nothing. A replacement nobody is told about is the
# "one of which silently wins" `config.yaml` refuses elsewhere - and
# refusing to START over a telemetry key would be observability with
# a foot in the control path, so this is a CAUTION and never a `bad`
# row.
if pv_read.entity_is_ignored(self.o):
out.append({"ok": False, "warn": True,
"text": "PV production: `pv_entities` is set, so "
"`pv_entity` is IGNORED - the "
f"{len(roster)} entities in the "
"list are what is summed. Clear "
"`pv_entity` to remove this caution"})
return out
def status_payload(self) -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
if self.circ_wd.tripped:
# --- SAFETY-10, AND IT IS FIRST IN THE CASCADE -----------------------
# "The battery is NOT being controlled" is the one sentence on this
# page that may never be displaced by another condition. A banner that
# can be out-ranked does not STATE a thing plainly; it states it
# whenever nothing else is happening, which is the half of the time
# nobody needs telling.
#
# ⚠️ AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. The three
# `bad` branches below all say some version of "held at 0 W", which is
# still true in shadow mode - but an operator who turns shadow mode off
# with a latch up gets a battery that will not move and a page that
# never mentioned it. `shadow.banner()` names the latch and goes red
# for it, so nothing this cascade would have said is lost.
_shadow = shadow.banner(self.o, control_started=self.auto,
latched=self.shadow_latches())
if _shadow is not None:
level, banner = _shadow
elif self.circ_wd.tripped:
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are
# latched P_SAFETY stops, so the site is at 0 W either way and the
# banner can only carry one sentence. This one goes first because it
@@ -4597,9 +5234,31 @@ class Controller:
# identically zero and a permanent "0 W" row would be noise.
*(() if len(self.inverters) <= 1
else ((("Circulating", w(self.site_circulating_w)),))),
# TEL-11, and ONLY when a source is configured - see `checks()`.
# `w(None)` renders "—", which is how this row says "no reading"
# rather than "0 W". It sits beside Grid and Battery because the
# question it answers is the one those two cannot: at "Grid 0 W,
# Battery 0 W" the site is either becalmed or throwing away every
# watt it makes, and only this row separates them.
*(() if not self.pv_roster else ((("PV", w(self.pv_w)),))),
("State of charge", "—" if self.pack_floor_soc is None
else f"{self.pack_floor_soc:g} %"),
("Commanded", w(self.target)),
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
# "Commanded 0 W" above and a "Why" below that explains a trade is
# the exact pair an operator would otherwise have to guess at; this
# row is what makes the two of them consistent, and it is rendered
# in EVERY state - including `off`, where it says the battery IS
# being controlled - because a surface whose ABSENCE is how you
# learn something is a surface you have to already know about.
("Shadow mode", shadow.words(
self.o, control_started=self.auto,
would_w=self.shadow_would_w,
# SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE
# - the page runs on its own task and `self.peak_guard` has
# moved on. See `Controller.__init__`.
sized_from_meter=self.shadow_sized_from_meter,
reason=self.reason)),
("Why", self.reason),
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
else f"{self.maint.phase} ({self.maint_unit.name})"),
@@ -4855,6 +5514,11 @@ async def amain() -> None:
broker.get("username") if broker else None,
broker.get("password") if broker else None,
omit=() if is_enabled(opts) else ("p1_age",),
# TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE
# OPTION - `Controller.publish()` gates the row on this same
# call. An install with no PV roster announces exactly
# what it announced before: no topic, no entity, no row.
include=optional_keys(opts),
# TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here
# would construct a SECOND set of `Inverter`s off the same
# Store, minutes before `Controller.__init__` constructs the
+139 -4
View File
@@ -85,8 +85,114 @@ 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"),
]
# --- TEL-11: rows that exist ONLY when the operator configured a source ------
#
# ⚠️ A SECOND LIST RATHER THAN A TENTH ENTRY IN `SENSORS`, AND THE DEFAULT IS
# WHAT MAKES IT ONE. `SENSORS` is announced by every caller of
# `discovery_payloads()`; `OPTIONAL_SENSORS` is announced only by a caller that
# ASKS, so a caller that has not been taught about `pv_entity` - `test_mqtt.py`'s
# fixtures, `tools/rehearse_roster.py`, anything written next year - announces
# exactly what it announced before. `p1_age` solves the same problem the other
# way round, with `omit`, because `p1_age` is announced by DEFAULT and withdrawn
# when the meter is off; this one does not exist by default and appears when a
# source for it does. Getting that direction wrong is the hazard `omit`'s own
# note names: an entity that exists and is never fed is a worse signal than one
# that does not exist at all, and MOST installs will never set `pv_entity`.
#
# ⚠️ `state_class: measurement` IS THE WHOLE POINT OF THE ROW. It is what puts
# the number into the recorder's long-term statistics, and a week of statistics
# is what OPT-10 needs to say whether the surplus that left this site was cloud,
# a charge-power limit, or the optimiser declining to charge.
OPTIONAL_SENSORS = [
("pv", "goodwe_pv_power", "PV power", "W", "power", "measurement", None),
]
# ⚠️ HA's OWN "this sensor has no value" PAYLOAD, and the one place this add-on
# spells it. `homeassistant/components/mqtt/const.py` declares
# `PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric
# branch - `if payload == PAYLOAD_NONE: self._attr_native_value = None` - so the
# entity's state becomes `unknown` rather than a number. (Read out of
# github.com/home-assistant/core `dev` on 2026-09-24; the shipped MQTT-sensor
# documentation says the same thing in its `state_topic` entry: "a 'None' value
# will set the sensor to an unknown state".)
PAYLOAD_NONE = "None"
# The keys whose ABSENCE MUST BE PUBLISHED rather than skipped. ⚠️ THIS IS THE
# DIFFERENCE BETWEEN "no PV reported" AND "PV reported 0 W" IN A WEEK OF
# HISTORY, AND WITHOUT IT THE CHANNEL LIES IN THE WORSE DIRECTION. `publish()`
# below drops a `None` - which is right for `circulation`, where the last figure
# standing is better than a zero that reads as health - but a DROPPED pv row
# leaves Home Assistant holding the last number it was told, forever, and the
# recorder then draws a flat line at that value through an outage. Someone
# reading it back would conclude the sun was not shining, which is precisely the
# conclusion this card exists to TEST rather than to manufacture.
ABSENT_AS_UNKNOWN = frozenset({"pv"})
def optional_keys(opts: dict) -> tuple:
"""Which `OPTIONAL_SENSORS` rows this configuration has a source for.
⚠️ ONE ANSWER TO "IS THE PV ROW LIVE", read by the ANNOUNCEMENT (through
`MqttPublisher`) and by `Controller.publish()`. `announced_units()` above
exists for exactly this reason and says it in full: two spellings of one
predicate is how an entity gets announced that nothing feeds, or fed on a
topic nothing announced.
⚠️ AND SINCE TEL-12 IT IS `pv.entities()` RATHER THAN A SECOND READING OF
THE OPTION - `Controller.pv_roster` is the SAME call. TEL-11 got away with
two spellings because both were `bool()` of one string; with `pv_entities`
beside `pv_entity`, and `pv_entities` REPLACING it, a second spelling is how
the row gets announced off one key and summed off the other. ⚠️ THE ROW IS
ONE SITE TOTAL AT EVERY ROSTER SIZE: there is deliberately no per-entity
topic, because the recorder statistic OPT-10 reads back is the site figure
and a per-inverter breakdown is a dashboard question the operator's own PV
integration already answers.
"""
# ⚠️ THE IMPORT IS FUNCTION-LOCAL AND THAT IS NOT A STYLE CHOICE. This
# module is EXEC'D STANDALONE, outside its package, by `test_mqtt.py`
# part 8's mutation harness - which says so in its own comment, and whose
# honesty depends on it ("`app/mqtt.py` imports nothing from this package,
# which is what makes exec'ing it standalone honest rather than a stub").
# A module-scope `from . import pv` turns that whole instrument into an
# `ImportError` before it grades anything. `tools/tel11_pv_trace.py`'s
# `_load()` is the same decision for the same kind of reason. Measured, not
# assumed: move this line to module scope and test_mqtt.py part 8 dies.
from . import pv as _pv
return ("pv",) if _pv.entities(opts) else ()
BASE = "goodwe_ctl"
AVAILABILITY = f"{BASE}/availability"
@@ -269,15 +375,23 @@ def _payload(name, object_id, unique_id, state_topic, device,
return cfg
def discovery_payloads(units=(), omit=()) -> list:
def discovery_payloads(units=(), omit=(), include=()) -> list:
"""Every `(topic, payload)` this add-on announces, in announcement order.
Split out of `_announce()` so that the acceptance criterion can be graded as
a DIFF OF THE ANNOUNCED PAYLOADS against `release/1.0`, rather than as a
reading of the code. `_announce()` publishes exactly this list and nothing
else, which is itself checked.
`include` names `OPTIONAL_SENSORS` keys to announce. ⚠️ IT DEFAULTS TO
NOTHING AND THE ROWS GO LAST, so no existing row's POSITION moves under any
configuration - not the site rows, not the per-unit ones. Order is part of
the output here (`_payload()`'s note, and test_mqtt.py part 1 asserts an
ordered LIST precisely because a re-order is a zero-byte edit that
re-announces every entity on a live install).
"""
omit = set(omit)
include = [k for k in include if k not in omit]
out = []
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
if key in omit:
@@ -306,12 +420,19 @@ def discovery_payloads(units=(), omit=()) -> list:
unique_id,
f"{BASE}/{unit_state_key(slug, key)}",
device, unit, dev_class, state_class, icon)))
for key, object_id, name, unit, dev_class, state_class, icon in OPTIONAL_SENSORS:
if key not in include:
continue
out.append((
f"homeassistant/sensor/{BASE}_{key}/config",
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
DEVICE, unit, dev_class, state_class, icon)))
return out
class MqttPublisher:
def __init__(self, host, port, username=None, password=None, omit=(),
units=()):
units=(), include=()):
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
# sensor that nothing will ever publish to is not harmless here:
# p1_sample_age_s is a watchdog input, and an entity that exists but is
@@ -323,6 +444,10 @@ class MqttPublisher:
# per-unit is announced, so every caller that has not been taught about
# the roster keeps publishing what it published before.
self.units = [str(u) for u in units]
# TEL-11. `mqtt.optional_keys(opts)` - the shipped predicate, not a
# second reading of the option. Defaults to empty, so a caller that has
# not been taught about it announces exactly what it announced before.
self.include = [str(k) for k in include]
self.enabled = mqtt is not None and bool(host)
self.client = None
if not self.enabled:
@@ -359,7 +484,8 @@ class MqttPublisher:
# announcement, so the check that diffs it against `release/1.0` is
# diffing what actually goes on the wire - and a payload built here
# instead would be one this add-on publishes and no check has ever seen.
for topic, cfg in discovery_payloads(self.units, self.omit):
for topic, cfg in discovery_payloads(self.units, self.omit,
self.include):
self.client.publish(topic, json.dumps(cfg), retain=True)
self.client.publish(AVAILABILITY, "online", retain=True)
@@ -369,7 +495,16 @@ class MqttPublisher:
try:
for key, value in values.items():
if value is None:
continue
# ⚠️ TEL-11. A DROPPED ROW IS NOT AN ABSENT ROW. Skipping
# leaves Home Assistant holding the last value it was told,
# which is what `circulation` wants (its note in main.py
# says why) and is exactly wrong for a measurement somebody
# will read back as history: a flat line at the last number
# is indistinguishable from a real one. See
# `ABSENT_AS_UNKNOWN`.
if key not in ABSENT_AS_UNKNOWN:
continue
value = PAYLOAD_NONE
self.client.publish(f"{BASE}/{key}", str(value))
except OSError as err: # pragma: no cover
_LOG.debug("MQTT publish failed: %s", err)
+139
View File
@@ -822,6 +822,145 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
f"only give {ceil_top:.0f} W" if saturated else "")))
def charge_headroom_w(decision) -> float | None:
"""SAFETY-45. The hardest this SITE may charge without putting the billed
quarter over the ceiling, in W, positive - or None when nothing measured it.
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
`discharge_permitted()`, `shaping_suspended()`, `guard_state()` and
`looks_frozen()` are: the check that grades it has to assert on THE SHIPPED
EXPRESSION, and a predicate transcribed into its own test is one copy plus a
future disagreement about what the check means.
------------------------------------------------------------------------
THE DEFECT IT PREVENTS, AND IT IS NOT A NEW IDEA IN THIS REPOSITORY
`control.maintenance_charge_floor()` already caps MAINTENANCE charging at
"the headroom left under the site's cap", and its docstring says why in
words this card only has to widen:
"A maintenance charge is the only thing this system does that is big
enough and long enough to set that peak, so it is capped by the
headroom left under the site's cap."
⚠️ THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED. Price arbitrage is a
SECOND thing that is big enough and long enough - it commands `max_w` for a
whole tariff interval, deliberately, against the meter - and it was given no
such cap. Measured on TEST-23 stage 3's closed rig at the shipped
`peak_cap_w: 3500` / `max_w: 2000`: 28 charge onsets in one hour, mean period
131 s, grid swinging -456 <-> +3200 W, on any house drawing over 1500 W in
the cheapest quarter. §10.2: "an EMS that oscillates is worse than no EMS,
both for hardware wear and - per §9.1 - for the peak it is trying to avoid."
⚠️ AND IT IS THE **MEASURED** GUARD, NOT `peak_forecast_entity`.
`maintenance_charge_floor()` sizes itself from the forecast sensor, which is
OPTIONAL and empty on a default install - the exact hole SAFETY-25 had to
close in `shaping_suspended()`, where a suspension gated on the forecast
alone could not fire on the very site that needed it. This number comes off
`decide()`, which runs wherever P1 does.
------------------------------------------------------------------------
WHERE THE NUMBER COMES FROM: IT IS `wanted_w`, READ IN THE OTHER DIRECTION
`decide()` already computes
wanted = batt_w + excess = (the offtake there would be with the battery
at zero) - (the level this quarter can still afford)
as the battery DISCHARGE that would land the quarter exactly on the ceiling.
A negative `wanted` is not "nothing to say" - it is a permitted CHARGE of
`-wanted` watts, and throwing it away is SAFETY-45's whole defect. So this
function invents no arithmetic; it reads the sign the guard already had.
⚠️ AND THE QUANTITY IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE
TO IT CANNOT OSCILLATE. Raise the charge by dW and the meter rises by dW, so
`excess` rises by exactly dW while `batt_w` falls by dW: `wanted` does not
move. It is a FEED-FORWARD number - a function of the house, the ceiling and
the seconds already banked - so consuming it adds no feedback path around the
plant. The fixed point is `charge = ceiling - load`, with the meter sitting
ON the ceiling and `wanted` parked at `load - ceiling`, a whole `ceiling -
load` clear of the engagement boundary rather than chattering across it.
⚠️ AND A QUARTER BOUNDARY DOES NOT HAND IT BACK AT FULL POWER, WHICH IS THE
OTHER HALF OF SAFETY-45 AND IS NOT THE SAME CLAIM AS "IT DOES NOT CHANGE".
It DOES change - it is `(900 / remaining) * (ceiling - projected)`, so it is
largest in the last seconds of a block and collapses at the boundary. What
matters is WHAT IT COLLAPSES TO: at `elapsed = 0` the projection IS the held
level, so the answer is `ceiling - (load - pv)` - the house and the cap, and
nothing else. A trade refused at 14:59 therefore resumes at 15:00 at the rate
the new quarter can afford, not at `max_w`, and §10.1's answer survives the
boundary because the arithmetic does: no timer, no backoff constant, no state
carried between calls.
⚠️ AND THE AMPLIFICATION IS BOUNDED IN ITS EFFECT, NOT MERELY LARGE. Hold a
charge of exactly this size for the rest of the block and the block's average
lands on `ceiling` EXACTLY, wherever in the block it started:
(acc + (held + headroom) * remaining) / 900
= projected + (900/remaining) * (ceiling - projected) * remaining/900
= ceiling
so 162 kW of headroom with 5 s left and 900 W of it with the whole quarter to
go are the same statement about the bill. `test_peakguard.py` part 13 sweeps
that identity across the block rather than arguing it here.
------------------------------------------------------------------------
THE THREE ANSWERS
`None` nothing measured it: the guard never ran (`meter_source: off`), or
it ran with no battery reading to size a floor from, or the block
had no seconds left to influence. ⚠️ None IS NOT UNLIMITED. A caller
that treats it as "charge away" is `arbitrage.guard_state()`'s
collapse of None into False, one layer down - a strategy creating
the billed quantity while nothing measures the quarter it lands in.
`0.0` measured, and the answer is no charging: the guard is BLIND (its
claim already says so at `floor 0 W` and this agrees with it rather
than restating it), or it is holding and `wanted >= 0`.
`> 0` the watts a charge may pull this cycle.
⚠️ HOLDING IMPLIES ZERO, BY CONSTRUCTION AND NOT BY COINCIDENCE. Every state
that files a claim with `engaged=True` has `wanted > 0`, `wanted is None` or
`blind`, so this answers 0.0 or None there and can never hand a strategy
permission the guard's own `lo` is at that instant refusing.
------------------------------------------------------------------------
⚠️ WHY THIS IS A SIZING AND NOT A CLAIM, AND DO NOT "SIMPLIFY" IT INTO ONE
The obvious tidier answer is to delete `decide()`'s `wanted <= 0` gate and
let it file `lo = wanted` unconditionally - a NEGATIVE floor, meaning "you
may charge, but no harder than this". The arithmetic is identical, it is
continuous, and it would bind every claimant rather than only arbitrage. It
was measured, considered and REJECTED, and the reason is not style:
* `main.py`'s cheap-window maintenance charge files
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` - a NEGATIVE `hi`.
arbiter.py clause 2 applies EVERY limit at or above the winner's priority
and takes the most restrictive, with no notion of one limit out-ranking
another. A peak `lo = -300 W` against a shaping `hi = -1000 W` is
`lo > hi`: a CONTRADICTION, which resolves to 0 W - the battery PARKED,
in the one quarter of the month that sets the bill. That is the exact
failure `shaping_suspended()` exists to prevent, and the only way to
close it would be to key that suppression on "a claim exists", which on
every site this card is about suspends the maintenance charge-only
shaping PERMANENTLY.
* and it would change what `engaged` MEANS at five call sites outside this
module - `arbitrage.guard_state()`, `shaping_suspended()`, `claim()`,
main.py's edge-logged action line and main.py's status row - each of
which today reads it as "the guard is HOLDING".
`control.maintenance_charge_floor()` is a sizing and not a limit for the same
reason, which is why this is the shape that already fits.
DECISIONS/SAFETY-45-sizing-the-trade.md §2b carries the full argument.
"""
if decision is None:
return None
if decision.blind:
return 0.0
if decision.wanted_w is None:
return None
return max(0.0, -float(decision.wanted_w))
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
"""Must the maintenance charge-only shaping stand down this cycle?
+92 -17
View File
@@ -151,8 +151,8 @@ from textwrap import dedent
from urllib.parse import urlencode
from .custom_formula import (
FormulaInvalid, PREVIEW_HOURS, parse_price_formula, preview_lines,
price_eur_kwh, to_resolution)
EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula,
parse_price_formula, preview_lines, price_eur_kwh, to_resolution)
_LOG = logging.getLogger("goodwe.price")
@@ -935,6 +935,20 @@ PRICE_OPTION_DEFAULTS = {
# `static_profile` precedent exactly, and here for the same CFG-03 reason:
# this dict is the ONE in-code mirror of config.yaml.
"price_formula": "",
# TARIFF-16. The INJECTION leg, in `price_formula`'s OWN grammar and read
# by `custom_formula.parse_export_formula()` - the same parser with its
# option name as an argument, so a second unit table and a second unknown-
# key rule do not exist to diverge. "" is unset and the scalar
# `export_price_eur_kwh` above decides, which is every site today.
#
# ⚠️ IT IS IN FRAMEWORK_KEYS, for the reason the scalar is: a CONSUMPTION
# plugin that could read an injection curve could fold it into a
# consumption price, and nothing downstream could tell.
#
# ⚠️ AND IT LIVES HERE RATHER THAN IN custom_formula.py FOR CFG-03's SAKE,
# exactly as `price_formula` and `price_taxes_profile` do: this dict is the
# ONE in-code mirror of config.yaml.
"price_export_formula": "",
# OPT-04. §10.1 priority 3, and §14's `optimizer.cycle_cost_threshold_eur`.
#
# ⚠️ THEY LIVE HERE FOR CFG-03's SAKE, exactly as `static_profile` does, and
@@ -967,6 +981,13 @@ FRAMEWORK_KEYS = frozenset({
# derived from this set, so `code_leaks()` refuses a plugin that names it
# without anything else being edited.
"price_taxes_profile",
# TARIFF-16. The hour-varying spelling of `export_price_eur_kwh`, and it
# belongs here for the same reason the scalar does: a plugin that could
# read an INJECTION curve could fold it into the CONSUMPTION series it
# emits, and `assemble()` has no way to notice a component that arrived
# inside a RawPoint. `BANNED_IN_PLUGIN_CODE` is derived from this set, so
# `code_leaks()` refuses a plugin that names it with nothing else edited.
EXPORT_FORMULA_OPTION,
})
@@ -1080,28 +1101,82 @@ def export_price_eur_kwh(opts: dict) -> float:
consumption is at 6% - so it is a second per-contract formula, not a second
copy of the first.
⚠️ TARIFF-15 LANDED THE EVALUATOR AND DID NOT LAND THIS CURVE, and the
difference is the point rather than an omission. `custom_formula` can now
express `B x index + A` exactly - it is what it exists for - but it is a
CONSUMPTION provider: it declares {ENERGY, MARKUP}, the framework adds the
consumption taxes, and an injection leg carries its OWN coefficients
(Eneco: 0,102 consumption against 0,100 injection) and its own VAT
treatment (injection is exempt; consumption is at 6%). Pointing this
scalar at that machinery would mean pricing an injection curve with a
consumption card, which is the wrong-card defect the card self-check
exists to refuse. So until the injection card lands this scalar remains a
SIMPLIFICATION an operator enters by hand - an average of what they are
actually paid - and not a reflection of the market. When it does land, an
injection curve gets a `PriceProvider` of its own and this scalar becomes
its FIXED-product default; the seam is already the right shape for that.
⚠️ TARIFF-16 LANDED THE INJECTION LEG, AND THIS FUNCTION IS UNCHANGED. The
curve lives in `price_export_formula` and is read by `export_formula()`
below; this reader still answers the FLAT injection price, still from one
option, still with no formula anywhere in its arithmetic. That is the
arrangement this docstring predicted before either existed - "an injection
curve gets a `PriceProvider` of its own and this scalar becomes its
FIXED-product default" - and it is why a site with a flat injection price
sees nothing change: `price_export_formula` ships "", `export_price_at()`
falls through to this function, and this function's body is byte-identical
to the one TARIFF-02 shipped.
⚠️ It is NOT reachable from a plugin: `export_price_eur_kwh` is in
FRAMEWORK_KEYS, so a consumption plugin cannot quietly fold an injection
price into a consumption price.
price into a consumption price. `price_export_formula` is in the same set
for the same reason.
⚠️ AND NOTHING IN THIS ADD-ON READS EITHER ONE TO TAKE A DECISION.
`arbitrage.py` prices the sell side as DISPLACED IMPORT and says so in its
own comment; both hours it compares are all-in consumption prices. This
reader has no caller under `app/` and neither does `export_price_at()`.
They are what an operator is shown and what a future export-aware strategy
would call - not a number any trade turns on today.
"""
return float(price_option(opts, "export_price_eur_kwh"))
def export_formula(opts: dict):
"""The configured `price_export_formula` as a `FormulaSpec`, or None.
TARIFF-16. Raises `custom_formula.FormulaInvalid` on a malformed leg - and
⚠️ THAT EXCEPTION IS NOT MEANT TO REACH A RUNNING SITE, by the rule
`taxes_profile()` states above and `static_profile` established:
validate.py parses the same option at STARTUP and refuses to start on it,
because a formula that only fails when something reads it is a defect
discovered at the worst possible moment and §11.4 makes every price
defect quiet.
⚠️ THE PARSE IS THE SAME ONE THE CONSUMPTION LEG GETS. There is no second
parser, no second unit table and no second resolution rule -
`parse_export_formula()` is `parse_price_formula()` with its `option`
argument moved, so every refusal an operator can hit on this leg is a
refusal that already had checks over it.
"""
return parse_export_formula(price_option(opts, EXPORT_FORMULA_OPTION))
def export_price_at(opts: dict, index_eur_per_mwh: float) -> float:
"""What the site is PAID for one exported kWh at one index value, EUR/kWh.
THE SEAM, AND ITS HONEST SCOPE. This is the one function a future
export-aware strategy calls, and it has NO CALLER UNDER `app/` today -
stated here rather than implied, because the add-on takes no decision on an
injection price (see `export_price_eur_kwh()` above and `arbitrage.py`'s
accounting-basis comment). What it buys now is that OPT-06 and anything
after it inherit a shape the market fits, instead of a scalar that cannot
hold its own factor, a negative offset, or a VAT rate different from the
consumption leg's.
⚠️ THE FLAT PATH IS THE FALLTHROUGH AND IT IGNORES THE INDEX, which is
CORRECT rather than a simplification: a site with a flat injection price is
paid the same for every kWh whatever the market did, and that is what the
scalar means. With `price_export_formula` empty - every site today - this
returns exactly `export_price_eur_kwh(opts)` for every index.
⚠️ A NEGATIVE ANSWER IS A REAL ANSWER. Eneco's `0,1 x BELPEX-H - 1,188` is
below zero for any BELPEX-H under 11,88 EUR/MWh, which is the middle of a
sunny day and precisely when a PV household exports. Nothing here floors
it: a caller that silently clamped it to zero would hide the hours in which
exporting COSTS money, which is the one thing an injection price is for.
"""
spec = export_formula(opts)
if spec is None:
return export_price_eur_kwh(opts)
return price_eur_kwh(spec, index_eur_per_mwh)
def taxes_profile(opts: dict):
"""The configured `price_taxes_profile` as a period table, or None.
+259
View File
@@ -0,0 +1,259 @@
"""TEL-11/TEL-12 - the site's PV production, read from Home Assistant entities.
WHY THIS IS AN ENTITY AND NOT A REGISTER. This add-on's RS485 bus carries the
BATTERY inverter it drives. A household's PV inverter is, on nearly every site,
a different box from a different vendor that reports into Home Assistant
through its own integration - so the only place a product can read production
for an arbitrary install is an HA entity. `pv_entities` therefore takes whatever
`sensor.*` the site's own PV integration provides.
⚠️ AND THERE IS USUALLY MORE THAN ONE OF THEM, WHICH IS TEL-12. TEL-11 shipped
`pv_entity` as a single string and the owner of the reference site reported
within minutes of 0.11.0 going live that they have SEVERAL PV inverters and
could configure one. That is not a property of one house: a multi-string or
multi-orientation roof routinely carries two or three inverters, each
publishing its own power sensor. `entities()` below is the roster and `total()`
is the site figure over it.
⚠️ AND A PARTIAL TOTAL IS WORSE THAN NO TOTAL, WHICH IS THE WHOLE OF TEL-12.
Three inverters configured, one `unavailable`, and the sum of the other two is A
SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Fed into OPT-10's attribution
that reads as "there was less PV than there was" - which is the "no irradiance"
conclusion the card exists to TEST rather than to manufacture. So `total()`
answers `None` the moment ANY configured entity is absent. See the note on
`total()` for the alternative that was weighed and why it lost.
WHY THE CHANNEL EXISTS AT ALL. OPT-10 measured that 20.5 kWh of surplus left the
site in 16 days while the pack had room, 12.6 kWh of it with the battery not
charging at all - and could not say WHY, because PV production is recorded
NOWHERE. Three causes need three different fixes (no irradiance: nothing to fix;
a charge-power limit; the optimiser declining to charge - the only software
defect), and guessing between them is how an optimiser gets tuned to solve a
cloud problem. This module is the missing measurement and NOTHING ELSE.
⚠️ NOTHING HERE MAY EVER REACH A CONTROL DECISION, AND THAT IS THE CARD'S FIRST
CONSTRAINT RATHER THAN A STYLE NOTE. The add-on regulates a real battery; adding
production as a term of the control law would be an unrequested behaviour change
AND it would destroy the measurement, because the loop would then be reacting to
the quantity being measured. `tools/tel11_pv_trace.py` is what says the path is
untouched: it grades a control document that contains none of this material on
both trees and with `pv_entity` set and unset.
⚠️ ABSENCE IS NEVER ZERO, AND THAT IS THE SECOND CONSTRAINT. Every refusal below
returns `None` with a REASON, never `0.0`. A PV channel that reported 0 W for a
dead sensor would manufacture exactly the "there was no surplus to store"
conclusion the card exists to TEST - and it would do it silently, in a week of
recorder history nobody would re-derive. `app/hass.py`'s own note on `state()`
is the same rule one layer down. ⚠️ TEL-12 EXTENDS THAT RULE RATHER THAN
WEAKENING IT: at a roster of three, "one of them is absent" is an absence of the
SITE figure, and it publishes as an absence. A partial sum is a second way of
reporting 0-where-there-was-production, one layer up, and it is refused for the
same reason.
THE SIGN CONVENTION: POSITIVE IS PRODUCTION. Derived from the two conventions
this add-on already ships rather than assumed:
`meter_entity` positive = IMPORTING from the grid (DOCS.md, Sources)
`batt_entity` positive = DISCHARGING the pack (DOCS.md, Sources)
Both are "positive = power arriving at the house's AC bus", and production
arrives there too. There is deliberately NO `pv_invert` to go with
`meter_invert` and `batt_invert`: those two quantities are BIPOLAR and are READ
BY THE CONTROL PATH - a reversed `batt_entity` feeds SAFETY-30's per-unit sign
check and SAFETY-31's circulating-power sensor with a plausible number the site
then acts on - whereas production is unipolar and is read by nothing. A reversed
PV sensor announces itself as -4 kW at noon in the very history this card
creates, and flipping it here would only hide it. See
`DECISIONS/TEL-11-pv-production.md` §2.
"""
from .hass import BAD_STATES
# Home Assistant's OWN power units, each as the number of watts one of it is.
# ⚠️ READ OUT OF HA, NOT LISTED FROM MEMORY: `UnitOfPower` in
# `homeassistant/const.py` (github.com/home-assistant/core, `dev`, retrieved
# 2026-09-24) declares mW, W, kW, MW, GW, TW and BTU/h. Every factor here is an
# exact power of ten, so none of them is a number anybody chose.
#
# ⚠️ BTU/h IS IN HA's ENUM AND DELIBERATELY NOT HERE. Its factor (0.293071…)
# is a measured conversion rather than a decimal shift, no PV integration
# publishes in it, and a wrong constant in the one channel that exists to
# attribute a 20.5 kWh shortfall is worse than a refusal an operator can read.
# It lands in the "not a power unit" refusal below, BY NAME.
WATTS_PER = {
"mW": 0.001,
"W": 1.0,
"kW": 1000.0,
"MW": 1000000.0,
"GW": 1000000000.0,
"TW": 1000000000000.0,
}
# What `watts()` answers when there is nothing to report. ⚠️ THE REASON IS PART
# OF THE RETURN VALUE, not a log line: it is what the status page prints, and an
# absence whose cause is invisible sends an operator to look at the wrong box.
UNIT_KEY = "unit_of_measurement"
def watts(state: dict | None) -> tuple:
"""`(watts, why)` from a Home Assistant state object.
`watts` is `None` whenever there is no reading - and `why` then says which
of the five refusals it was, in words an operator can act on. On a good read
`why` is the unit the value was read in, because "1234 W" and "1234, unit
unknown" are different claims and the page has to be able to make the first.
⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO
MAGNITUDE HEURISTIC. `read_inputs()` guesses kW-or-W for
`peak_forecast_entity` with `abs(raw) < 50`, which is safe there because
nobody's quarter-hour demand forecast is 50 W. It is NOT safe here: 30 W of
real production at dusk is an ordinary reading, and that heuristic would
publish it as 30 kW - a fabricated number, in the channel whose entire
purpose is to be believed a week later. A sensor whose unit this cannot read
is REFUSED rather than guessed at.
"""
if state is None:
# `hass.state()` answers None for a non-existent entity, a 404 and a
# transport failure alike. All three are "no reading", and HA does not
# consider a non-existent entity an error - see `hass.state()`.
return None, "no state (entity missing, or the read failed)"
raw = str(state.get("state", "")).strip()
if raw.lower() in BAD_STATES:
# ⚠️ THE SAME LIST `hass.number()` REFUSES ON, IMPORTED RATHER THAN
# RESPELLED. A second copy of it is how one path starts treating
# `unavailable` as a number the other one rejects.
return None, f"entity is {raw or 'empty'}"
try:
value = float(raw)
except ValueError:
return None, f"state {raw!r} is not numeric"
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
if not unit:
return None, ("no unit_of_measurement on the entity - it cannot be "
"read in watts")
if unit not in WATTS_PER:
return None, (f"unit {unit!r} is not one of "
f"{', '.join(WATTS_PER)} - it cannot be read in watts")
return value * WATTS_PER[unit], unit
# --- TEL-12: the ROSTER, and the SITE figure over it --------------------------
def entities(opts: dict) -> tuple:
"""Every PV entity this configuration names, in configured order. TEL-12.
⚠️ ONE ANSWER TO "WHICH ENTITIES ARE THE PV CHANNEL", and it is the answer
BOTH sides read. `Controller.pv_roster` is this call and
`mqtt.optional_keys()` is `bool()` of this call, so the thing that decides
whether the row is ANNOUNCED, the thing that decides whether it is READ and
the thing that decides HOW MANY entities are summed cannot disagree. TEL-11
already had that discipline for one entity and spelled the predicate TWICE
- `str(opts.get("pv_entity") or "").strip()` in `main.py` and again in
`mqtt.py`. Two spellings of one predicate is how an entity gets announced
that nothing feeds; a roster makes it how a SUM gets taken over a different
list from the one that was announced. It is one function now.
⚠️ `pv_entities` REPLACES `pv_entity` RATHER THAN EXTENDING IT, and that is
CFG-04's shipped precedent (`inverters:` replaces the six flat keys) applied
to the only upgrade path that matters. The install this card exists for HAS
`pv_entity` set, to inverter A. Opening the Configuration tab, the natural
action is to put ALL the inverters in the new repeatable field - and under a
UNION rule that double-counts A unless the operator also remembers to clear
the old key. A doubled total is the same defect as a partial one in the
other direction, in the one channel that exists to be believed a week later.
Under REPLACE the effective roster is exactly what one key says, and no
arithmetic depends on a de-duplication rule the operator has to know about.
⚠️ `Controller.checks()` says WHICH key is in use whenever both are set, so
the replacement is stated on the page rather than being silent.
⚠️ AND IT DE-DUPLICATES WITHIN THE LIST, ORDER PRESERVED. A site cannot have
two sensors with one entity_id, so a repeated id in the list is never a
second inverter - it is a typo that would DOUBLE that inverter's production
in the total. `dict.fromkeys` is the order-preserving spelling.
"""
listed = [e for e in (str(x or "").strip()
for x in (opts.get("pv_entities") or [])) if e]
if listed:
return tuple(dict.fromkeys(listed))
one = str(opts.get("pv_entity") or "").strip()
return (one,) if one else ()
def entity_is_ignored(opts: dict) -> bool:
"""Is a set `pv_entity` being REPLACED by a set `pv_entities`? TEL-12.
⚠️ HERE RATHER THAN IN `Controller.checks()`, for the reason `entities()`
exists: the rule and the sentence that reports the rule must not be two
readings of the options. This is the negative half of `entities()`' first
branch and it is spelled once, beside it, where an edit to one is in front
of whoever is editing the other.
"""
return bool(str(opts.get("pv_entity") or "").strip()
and [x for x in (opts.get("pv_entities") or [])
if str(x or "").strip()])
def total(readings) -> tuple:
"""`(watts, why, parts)` - the SITE's production, or an ABSENCE. TEL-12.
`readings` is `[(entity_id, state), ...]` in `entities()` order, one entry
per configured entity, each `state` shaped as `hass.state()` returns.
`parts` is `[(entity_id, watts, why), ...]` - EVERY entity's own answer from
`watts()`, kept because the operator surfaces have to be able to name which
sensor is missing. `watts` is the sum when every entity answered, and `None`
otherwise; `why` always says which of those it was, in words a status page
can print.
⚠️ ONE OR MORE ABSENT -> THE TOTAL IS ABSENT. NOT THE PARTIAL SUM. This is
the decision the card turns on and it was taken deliberately against the
alternative, which was to publish the partial sum ALONGSIDE a count of how
many entities contributed. Both are defensible; this one wins on the ONE
question that matters, which is who reads the number and when:
* the MQTT row is read back COLD, a week later, by OPT-10's attribution
and by whatever plots `sensor.goodwe_pv_power` over that week. A partial
sum there is a smaller number indistinguishable from a real one, and the
count that would have qualified it lives in a SECOND entity with its own
history - so believing the total requires joining two recorder series by
timestamp to find out which samples to distrust. Nobody re-derives that,
which is the same "nobody will re-derive it" TEL-11's note names. And it
is worse than a gap in exactly the direction that matters: a gap makes
the attribution say "I do not know", a partial sum makes it say "there
was less sun than there was".
* the STATUS PAGE is read LIVE, by a human, who wants the diagnostic. So
the partial information is not thrown away - `Controller.checks()` names
every entity that DID report, with its watts, and every one that did
not, with its reason, in a row that states plainly that there is no site
total. Different reader, different instant, different rule.
A count published beside the total would also carry no information in the
state this function actually admits: the total is published ONLY when every
entity contributed, so the count would be the constant `len(parts)`.
⚠️ AND `0 W` IS STILL A READING AT EVERY ROSTER SIZE - the test is
`watts is None`, never falsiness. An inverter honestly reporting 0 W at dusk
beside two that are producing must give the SUM of the three, not blank the
site: shading, a tripped string and nightfall are all real 0s, and a roster
that went to `unknown` whenever one member read zero would report an absence
on every clear evening and defeat the channel from the other end.
"""
parts = [(ent, *watts(st)) for ent, st in readings]
if not parts:
# No roster. `Controller` does not call this in that state - the gate is
# `pv_roster` - but a reader of `parts` should never have to wonder.
return None, "not configured", parts
if len(parts) == 1:
# ⚠️ AT ONE ENTITY THIS IS `watts()` AND NOTHING ELSE: the same value
# and the SAME `why` string, passed through rather than re-composed, so
# 0.11.0's single-entity surfaces are byte-identical rather than
# similar. `tools/tel11_pv_trace.py` grades that identity across trees.
return parts[0][1], parts[0][2], parts
missing = [(ent, why) for ent, w, why in parts if w is None]
if missing:
return None, (
f"only {len(parts) - len(missing)} of {len(parts)} entities are "
f"reporting and a partial sum would understate production: "
+ "; ".join(f"{ent} - {why}" for ent, why in missing)), parts
return (sum(w for _e, w, _y in parts),
f"{len(parts)} entities", parts)
+518
View File
@@ -0,0 +1,518 @@
"""SAFETY-10 - shadow mode: the whole decision path, and no command.
WHAT DEFECT THIS PREVENTS
=========================
Not one defect - the CLASS of them. Every optimiser fault this repository has
found was invisible from outside while it was happening, and each one was paid
for at the meter before anybody could see it:
* SAFETY-45 arbitrage against a binding `peak_cap_w` ran 28 charge onsets an
hour, the grid swinging -455 .. +3200 W, and put a billed
quarter 59 W over the cap;
* TARIFF-14 the price lookup answered for the wrong quarter-hour 45 minutes
in every 60, and traded on it;
* SAFETY-46 the SoC ceiling churns 598 Wh to gain 310 Wh, no hysteresis.
There was no way to WATCH what the optimiser would do without letting it do it.
A commissioning engineer could arm arbitrage and find out, or not arm it and
learn nothing. Shadow mode is the third option: strategies, arbiter, clamps and
guards all run, the decision is recorded with its reason, and NOTHING THE
CONTROLLER DECIDES REACHES THE INVERTER.
⚠️ AND OF THOSE THREE, A SHADOW RUN CAN SHOW YOU EXACTLY ONE - SEE THE NEXT
SECTION. This header used to name SAFETY-45's limit cycle as the example
of what shadow mode surfaces. That was false, and SAFETY-47 measured it.
⚠️ WHAT A SHADOW RUN CAN AND CANNOT SHOW (SAFETY-47)
=====================================================
SHADOW MODE CANNOT SURFACE A CLOSED-LOOP DEFECT. Not badly, not partially -
NOT AT ALL, and by the same construction that makes it safe. The actuator is
held at WIRE_W and the carried command is re-seeded to WIRE_W, so every
MEASURED input - the meter, the quarter accumulator behind it, the battery
power, the state of charge - describes a plant that DID NOT RESPOND. A
closed-loop defect is by definition one whose stimulus is the plant's response
to the controller. No response, no stimulus, no defect to see.
That is not a gap to be closed later. Feeding the guards a simulated plant
instead is what SAFETY-10 §4 refuses for the sign watchdogs, for a reason that
applies to all of this: a controller reacting to a plant that does not exist
drifts further from reality every tick, and three cycles in its numbers
describe nothing. THE CLOSED-LOOP ANSWER COMES FROM A CLOSED-LOOP RIG
(TEST-23 stage 3) OR FROM A LIVE RUN. There is no third place.
So, of the three defects above, measured on that rig at `peak_cap_w` 1500 /
house 1200 / `max_w` 2000, one hour at a 5 s cycle:
* SAFETY-45's limit cycle NOT VISIBLE. Live: 28 charge onsets, the grid
swinging -455.5 .. +3200.0 W, the guard engaged
on 410 of 720 cycles. Shadow, same hour, same
code: 1 onset, the grid flat at the 1200 W
house, the guard engaged on 0 of 720. Every
term of `wanted = batt_w + excess` is measured.
* SAFETY-46's SoC churn NOT VISIBLE. The SoC moves 0.000000000 pp in a
shadow run, because it is the pack's integral of
what the pack was commanded and the pack was
commanded 0 W. No SoC limit can be REACHED in a
shadow run; the ones already crossed when it
started are reported, and that is all.
* TARIFF-14's wrong quarter VISIBLE, and it is the one. The price lookup's
inputs are the CLOCK and the PRICE SERIES, and
neither is a function of what the battery did.
Measured: which hour the strategy picked, its
price and the spread it cleared are identical
live and shadow on 720 of 720 charging cycles,
AT A BINDING CAP.
⚠️ THE EXEMPTION THAT USED TO BE HERE WAS TRUE UNTIL SAFETY-45 MERGED. This
module, DOCS.md and DECISIONS/SAFETY-10-shadow-mode.md all said "the strategies
are unaffected - they ask for a fixed power from inputs that do not depend on
what the battery did". SAFETY-45 made the buy leg `min(asked, headroom)` with
headroom = (900 / remaining) x (ceiling - projected)
and `projected` reads the OPEN QUARTER'S ACCUMULATOR - the meter - which is a
function of what the battery did earlier in that same quarter. The strategy
became closed-loop the day that landed. What survives of the exemption, and
only this:
WHICH hour a strategy picks, and WHY, are unaffected.
HOW HARD it trades is not.
⚠️ AND THE SIZE A SHADOW RUN RECORDS IS THE RIGHT ANSWER TO A QUESTION NOBODY
ASKED. `900/remaining` is correct arithmetic for a meter that is genuinely
under the ceiling - and in shadow the meter genuinely IS under it, because the
battery is not charging. So the number is not a bug; it is an answer computed
for a plant that did not exist. Measured on the same rig, shipped code:
cap / house live commanded shadow recorded over
1500 / 1200 -302.4 W -2000.0 W 6.61x
3500 / 3000 -502.6 W -2000.0 W 3.98x
3500 / 2000 -1502.6 W -2000.0 W 1.33x
3500 / 1800 -1702.6 W -2000.0 W 1.17x
15000 / 350 -2000.0 W -2000.0 W 1.00x
The last row is the fixture SAFETY-10's own evidence was taken on, which is why
nothing reported this. `words()` carries the caveat rather than a re-computed
number, and `DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md` §3 records the
three alternatives that were rejected and why.
⚠️ "REACHES NOTHING" IS NOT "WRITES NOTHING", AND THE DIFFERENCE IS THE WHOLE
SAFETY ARGUMENT
=======================================================================
The inverter holds its last command FOREVER - it has no meter timeout
(HANDOVER.md §6), and firmware/goodwe-master.yaml's header records what that
cost when a controller stopped refreshing: Illusmart went silent mid-command
and the inverter held +5000 W for 113 s. main.py's own header carries the same
measurement. So a shadow mode that
simply stopped writing would leave whatever was standing when it was switched
on standing for as long as it was left on. That is the exact failure this
repository keeps finding - LOOKS HEALTHY BUT IS INERT - with the status page
calmly reporting that nothing is being commanded.
So shadow mode does write, and it writes ONE number:
the actuator is held at WIRE_W (0.0 W), refreshed on the ordinary
heartbeat, by the ordinary single writer.
That is a DEFINED, SAFE actuator state, it is the same state the operator's
stop button reaches, and it honours the contract firmware/goodwe-master.yaml
§"CONTRACT CHANGE" imposes on us - HA must rewrite the setpoint every ~10 s or
the ESP32's 30 s stale-input watchdog cannot tell "steady" from "dead". Going
silent would put that board into a tripped-watchdog state for the whole shadow
run, which is an ALARM being used as a MODE: the operator who learns that
`wd_tripped` is normal during shadow runs is the operator who ignores it when
the add-on has actually died.
⚠️ AND IT IS NOT A SECOND WRITE PATH. SAFETY-28's rule is one actuator, one
writer. Shadow mode files no claim, owns no transport and calls nothing in
hass.py. It is a SUBSTITUTION inside `Controller._write_unit()` - the single
choke point every write in this add-on already goes through - in exactly the
form the three substitutions above it take (non-finite -> 0 W, unauthorised ->
0 W, clamped -> the rating). It is the LAST of them, so SAFETY-28's
authorisation gate and SAFETY-03's clamp still grade the number the decision
path actually produced, and a shadow run therefore records that the clamp WOULD
have engaged.
THE DEFAULT, AND WHY IT IS NOT A PLAIN `true` (§ the ticket's own warning)
==========================================================================
The card is titled "default-on". Taken literally - `shadow_mode: true` in
`config.yaml`'s `options:` block - it is the worst change in this repository's
history. Home Assistant's Supervisor composes an add-on's effective options as
`{**config.yaml defaults, **the stored user options}`, so a NEW key with a new
default reaches EVERY install on the next update, including the one regulating
a real battery in the author's house today. Literal default-on silently stops
an existing install from controlling, and the only symptom is a battery that
has stopped doing anything.
There is no mechanism in the add-on options system that lets one key default
differently for a new install and an existing one: `options:` IS the default,
and there is exactly one of it. So "default-on for new" has to be expressed as
a value the add-on RESOLVES, not as a constant the Supervisor merges.
Hence three values rather than two, defaulting to `auto`:
off never shadow. The controller commands the battery.
on always shadow, whatever the start/stop button says.
auto shadow WHILE CONTROL IS STOPPED; live once it is started.
`auto` is default-on for a new install and a no-op for an existing one, and it
is that without any persisted marker, any migration, and any guess:
* a FRESH install ships `auto_start: false` and has no stored `auto` key, so
it comes up stopped -> shadow. It observes a day of decisions before it is
ever allowed to command anything. That is the card's intent, delivered.
* an install that is CONTROLLING - `auto_start: true`, or the operator has
pressed *Start / stop control* and the store carries `auto: true` - is
started, so `auto` resolves to LIVE and its behaviour does not change by
one watt.
* an install that is STOPPED keeps its actuator at 0 W exactly as before -
the same number, from the same writer, on the same heartbeat - and gains
the recording. The only thing that changes for it is what the status page
and the action log SAY.
⚠️ AND `auto` MEANS THE START BUTTON LEAVES SHADOW MODE. That is deliberate:
under `auto` the button reads "Start / stop control" and pressing Start is the
operator saying "control the battery". An operator who wants the decisions
recorded WHILE the loop runs sets `shadow_mode: on` explicitly - that is the
configuration this card exists to make possible, and it is the one that cannot
be reached by accident.
⚠️ AN UNRECOGNISED VALUE RESOLVES TO SHADOW, NOT TO LIVE. `validate.py` refuses
it at startup so this is not meant to be reachable, but the fail-safe direction
for "the operator's intent is unreadable" is the one that does not move energy.
THE FAILSAFES, ONE BY ONE (the card asks for each to be decided and recorded)
============================================================================
| failsafe | in shadow mode |
|--------------------------------|-------------------------------------------|
| SAFETY-02/30 sign watchdogs | INERT, and by construction rather than by |
| (per-unit and site) | a special case. They grade |
| | `Inverter.standing_w()` - what this |
| | controller has standing on the wire - and |
| | shadow mode holds that at 0 W. A 0 W |
| | command cannot oppose a meter, so the |
| | detector never arms. ⚠️ THIS IS THE ONE |
| | THAT HAD TO BE GOT RIGHT: fed the SHADOW |
| | decision instead, every shadow run would |
| | latch a trip within `sign_disagree_s` - |
| | the decision opposes a meter that never |
| | saw it - and that latch SURVIVES INTO |
| | LIVE OPERATION and a restart. Shadow mode |
| | would manufacture the failsafe it is |
| | meant to help debug. |
| SAFETY-03 clamp | OBSERVES. It runs on the number the |
| | decision path produced, `clamp_last` is |
| | set and every engagement goes to the |
| | action log, because "it would have hit |
| | the rating" is part of what a shadow run |
| | is for. It changes no command: the value |
| | it narrows is replaced by 0 W below it. |
| SAFETY-28 write authorisation | OBSERVES, for the same reason and in the |
| | same place - above the substitution, so |
| | it still asks who chose the number. |
| SAFETY-18 meter freeze | OBSERVES, unchanged. It takes no action |
| | in live operation either; it grades the |
| | resolved intent against a meter that has |
| | stopped moving, and both halves are as |
| | true in shadow as out of it. |
| SAFETY-07/24/25 peak guard | OBSERVES, AND SEES ONLY THE HOUSE. It is |
| | level-triggered and unlatched and it files|
| | its claim as usual, so the recorded |
| | decision is the real one FOR THIS METER - |
| | but every term of `wanted = batt_w + |
| | excess` is measured, and in shadow mode |
| | the battery contributes nothing to either.|
| | ⚠️ SAFETY-47: this row used to claim |
| | SAFETY-45's limit cycle was visible from a|
| | shadow run. It is not - 410/720 engaged |
| | cycles live against 0/720 in shadow, on |
| | the same hour and the same code. |
| SAFETY-31 circulation | OBSERVES AND MAY LATCH, legitimately. Its |
| SAFETY-42 delivery | evidence is MEASURED power, not a |
| | command: packs pushing against each other |
| | while every one of them is told 0 W is a |
| | real fault and a latch is the right |
| | answer to it. |
| operator stop (`auto` False) | SUPPRESSED AS A CLAIM, ENFORCED AT THE |
| | WIRE. It is the one deliberate change. |
| | Its `P_SAFETY` 0 W claim out-ranks |
| | everything, so leaving it in would make |
| | the recorded decision "stopped" on every |
| | cycle - the shadow run would answer the |
| | wrong question. The actuator state is |
| | identical either way, and the wire |
| | substitution is STRICTLY STRONGER than |
| | the claim: a claim can be out-ranked, and |
| | this cannot. |
| inputs missing, non-finite, | UNCHANGED. All of them resolve to 0 W, |
| allocator refusal, arbiter | which is what the wire gets in shadow |
| contradiction | mode anyway, so they act redundantly and |
| | are recorded as they always were. |
⚠️ MAINTENANCE IS NOT IN THAT TABLE BECAUSE SHADOW MODE DOES NOT TOUCH IT. The
state machines are ticked on every cycle today whether control is started or
not, so a stopped install already runs them against a battery that does not
move. Shadow mode inherits that, unchanged and unimproved; it is a pre-existing
property of `auto_start: false` and it is recorded here rather than quietly
fixed under another card's name.
LEAVING SHADOW MODE
===================
`Controller.cycle()` re-seeds its carried command (`self.target` and each
`inv.target`) to WIRE_W at the END of every shadow cycle, AFTER every in-cycle
guard has read the decision. Two things follow, and both are safety properties:
* the loop's `prev_w` and its integrator track WHAT IS ACTUALLY STANDING ON
THE WIRE, so every shadow cycle answers "from where the plant really is,
what would this controller do NOW" - a true statement about the present,
rather than a simulation of a counterfactual plant that drifts further from
reality every tick;
* the exit is safe BY CONSTRUCTION. The carried command already equals the
standing command, so the first live cycle slews from 0 W like any other
cycle. Carrying the shadow decision instead would let the slew limiter
depart from a base that never existed - up to a full `slew_w` step onto a
battery sitting at zero.
The entry is safe for the ordinary reason: the substitution happens on the very
first write of the very first shadow cycle, and `last_write` was not 0 W, so
that write is issued rather than skipped as unchanged.
"""
from __future__ import annotations
# ⚠️ CFG-03's mirror. `test_defaults_pin.py` part 2 discovers every
# module-level name ending in OPTION_DEFAULTS and asserts each value
# reproduces config.yaml EXACTLY, so this table cannot drift from the shipped
# default. Nothing in the add-on may carry a literal fallback for a key a
# mirror answers for - part 3 scans for that - which is why `main.py` asks the
# functions below and never reads `shadow_mode` itself.
SHADOW_OPTION_DEFAULTS = {
"shadow_mode": "auto",
}
#: The three values `shadow_mode` may take, in the order config.yaml's schema
#: lists them. `validate.py` refuses anything else by name.
MODES = ("auto", "on", "off")
#: What stands on the wire while shadow mode is engaged. ⚠️ A NUMBER, NOT A
#: SILENCE - see this module's header. It is 0.0 W for the same reason every
#: other refusal in this add-on is 0.0 W: fail toward inaction, and keep
#: commanding it so the inverter cannot be left latched on anything else.
WIRE_W = 0.0
#: SAFETY-47. What the recorded watts mean when a MEASURED billed quarter sized
#: them.
#:
#: ⚠️ "UPPER BOUND" AND NOT "SMALLER", AND THE DIFFERENCE WAS MEASURED RATHER
#: THAN ARGUED. Charging only ADDS offtake, so a live meter's `projected` is
#: never below a shadow meter's and the headroom is never above it: the shadow
#: size is >= the live size, with EQUALITY at the start of every quarter, where
#: `900/remaining` is 1.0 and neither plant has moved. Measured pairwise over
#: 3600 charging cycles in five configurations - 0 cycles below, 730 exactly
#: equal. A sentence reading "smaller" would have been false on every one of
#: those 730.
#:
#: ⚠️ AND IT NAMES NO FACTOR. How far under the bound a live run lands is a
#: property of the site - 6.61x at a 1500 W cap under a 1200 W house, 1.00x at
#: a cap that does not bind - and a number invented for the row would be a
#: second helping of the defect this constant exists to disclose.
SIZED_FROM_METER = (
"⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE "
"HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same "
"trade against a meter the battery is moving, so this is an UPPER BOUND "
"and not a prediction; how far under it a live run lands is a closed-loop "
"question and a shadow run cannot answer it")
def mode(opts: dict) -> str:
"""The configured `shadow_mode`, normalised to one of `MODES`.
⚠️ AN UNRECOGNISED VALUE ANSWERS `"on"`, NOT `"auto"` AND NOT `"off"`.
`validate.py` refuses such a value at startup, so this branch is not meant
to be reachable at all; it exists because the fail-safe direction for "the
operator's intent cannot be read" is the one that does not move energy. A
typo that silently resolved to `off` would put a battery under a controller
whose configuration nobody could parse.
"""
raw = opts.get("shadow_mode", SHADOW_OPTION_DEFAULTS["shadow_mode"])
text = str("" if raw is None else raw).strip().lower()
if text in MODES:
return text
return "on"
def engaged(opts: dict, *, control_started: bool) -> bool:
"""Is the battery being left alone right now?
`control_started` is `Controller.auto` - the persisted start/stop state,
which a fresh install gets from `auto_start` and an operator moves with the
*Start / stop control* button. See the header on why `auto` reads it.
"""
m = mode(opts)
if m == "off":
return False
if m == "on":
return True
return not control_started
def why(opts: dict, *, control_started: bool) -> str:
"""Which rung decided, in the words an operator can act on.
⚠️ THE REASON, NOT ONLY THE VERDICT - MUTATION.md's masking rule. `on` and
`auto`-while-stopped produce the same boolean and want different operator
actions: one is turned off by editing an option, the other by pressing a
button. A surface that showed only "shadow" would make a mutation that
swapped the two rungs invisible.
"""
# ⚠️ THE LIVE SENTENCES SAY "NOT IN SHADOW MODE", NOT "THE BATTERY IS
# BEING CONTROLLED". The second is FALSE on a stopped install with
# `shadow_mode: off` - nothing is regulating there either - and a row that
# is false in one of the four live states is a row an operator learns to
# discount. What is true in every live state is that what this add-on
# decides reaches the inverter; whether control is RUNNING is the
# `Stopped` banner's question and it is answered there.
m = mode(opts)
if m == "off":
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
"add-on decides DOES reach the inverter")
if m == "on":
return ("on (shadow_mode: on) - decisions are recorded and nothing "
"is commanded")
if control_started:
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
"mode: what this add-on decides DOES reach the inverter")
return ("on (shadow_mode: auto, control is stopped) - decisions are "
"recorded and nothing is commanded")
def words(opts: dict, *, control_started: bool, would_w: float | None,
sized_from_meter: bool, reason: str = "") -> str:
"""The status page's `Shadow mode` row.
⚠️ THE ROW IS RENDERED IN EVERY STATE, INCLUDING `off`. A surface whose
ABSENCE is how you learn which mode you are in is a surface an operator
has to already know about; TARIFF-11's rule is that the rendered text is
what gets graded, so there has to be text in both states.
`would_w` is the decision the path reached this cycle - what a live
controller would have put on the wire GIVEN THIS METER AND A BATTERY AT
0 W - and `reason` is `Resolution.explain()`, the same sentence the `Why`
row carries. Together they are the whole of "what would it have done, and
why"; without them the row would say a shadow run happened and nothing
about what it found.
⚠️ `sized_from_meter` HAS NO DEFAULT, AND THAT IS SAFETY-47's WHOLE POINT.
It is True when a MEASURED billed-quarter headroom sized the number being
recorded - `arbitrage.sized_from_meter()` answers it, and it is the one
place the question is asked. A default of False here would be a caller
that forgot the caveat rendering a row that reads exactly like a caller
that decided it did not apply, which is the shape of defect this card is
about. TEST-14's rule with a new name on it.
⚠️ AND THE PHRASE IS "ON THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
CAVEAT FIRES. Every number a shadow run records was reached from a plant
that did not respond; "WOULD HAVE COMMANDED -2 000 W" reads as a forecast
of a live run and no number here is one. The ⚠️ below is the STRONGER,
conditional statement about the cycles where the answer can be multiples
out - see this module's header for the measured table.
⚠️ AND IT IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED. `arbitrage`'s own
sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" only when the headroom
came out BELOW the ask - which in a shadow run is the START of the
quarter, where the two runs still agree. By the end of the quarter
`900/remaining` has multiplied the headroom past the ask, the trade is
NOT capped, the sentence is silent and the recorded number is at its most
wrong. A caveat keyed on that sentence would be quietest exactly where it
is needed loudest. `test_arbitrage.py` part 3d-8 drives that.
"""
head = why(opts, control_started=control_started)
if not engaged(opts, control_started=control_started):
return head
if would_w is None:
return (f"{head}; no decision has been reached yet - the inverter is "
f"held at {WIRE_W:.0f} W")
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
# the whole sentence it ate the comma out of `head` - and `head` is the
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
# masking rule says must survive.
num = f"{float(would_w):+,.0f}".replace(",", " ")
out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter "
f"held at {WIRE_W:.0f} W")
if sized_from_meter:
out += f" {SIZED_FROM_METER}"
if reason:
out += f" - {reason}"
return out
def log_line(*, would_w: float, reason: str, sized_from_meter: bool) -> str:
"""The action-log line for one change of shadow decision.
⚠️ IT IS A FUNCTION FOR THE REASON `words()` IS ONE, AND SAFETY-47 LEARNED
IT THE EXPENSIVE WAY. This sentence used to be an f-string inside
`Controller._shadow_settle()`. Deleting the caveat from it there left all
29 files GREEN - the only check that could see the line drove a fixture
with `meter_source: off`, where no arbitrage decision and therefore no
caveat can ever be reached. A sentence assembled at a call site is a
sentence no check can grade, and the page would have said one thing while
the record a later analysis reads said another.
⚠️ AND IT IS THE SAME `SIZED_FROM_METER` CONSTANT THE ROW CARRIES, not a
second wording of it. Two sentences meaning one thing is how the two
surfaces start disagreeing about which cycles are caveated.
"""
out = (f"SHADOW: would have commanded {float(would_w):+.0f} W ON THIS "
f"CYCLE - {reason}")
if sized_from_meter:
out += f" {SIZED_FROM_METER}"
return out
def banner(opts: dict, *, control_started: bool, latched: tuple = ()) -> tuple:
"""`(level, text)` for the top of the status page, or `None` when live.
⚠️ IT NAMES A STANDING LATCH AND GOES RED FOR ONE. The shadow banner sits
ABOVE every other branch of the cascade, because "the battery is not being
controlled" is the one sentence that may never be displaced - but a
failsafe latch that is up while the controller is inert is up when it
stops being inert, and an operator who turns shadow mode off without
clearing it gets a battery that will not move and a green page that
never mentioned why. So the latch is carried INTO the shadow banner
rather than losing its own.
"""
if not engaged(opts, control_started=control_started):
return None
text = ("SHADOW MODE - the battery is NOT being controlled. Every "
f"inverter is held at {WIRE_W:.0f} W and nothing this add-on "
"decides reaches the inverter.")
if latched:
return ("bad", text + " ⚠️ A FAILSAFE LATCH IS STILL UP ("
+ ", ".join(latched) + ") and it will still be up when shadow "
"mode is turned off.")
return ("warn", text)
def problems(opts: dict) -> list:
"""`validate.py`'s rule: `shadow_mode` must be one of the three values.
⚠️ THE SENTENCE SPELLS `shadow_mode`. `configui.problems_by_field()` files
a refusal under the field whose key its text contains; a rule that never
names a key lands in the page's top-level block instead, where the operator
cannot see which control is wrong. UX-02's whole point is that the shipped
validator is the only rule surface, so the rule has to be readable there.
⚠️ AND IT IS A REFUSAL RATHER THAN A COERCION. `mode()` resolves an
unreadable value to shadow, which is the safe direction but is also a
SILENT reinterpretation of what the operator wrote - and an install left in
shadow mode by a typo commands nothing at all, indefinitely.
"""
raw = opts.get("shadow_mode", SHADOW_OPTION_DEFAULTS["shadow_mode"])
text = str("" if raw is None else raw).strip().lower()
if text in MODES:
return []
return [f"shadow_mode is {raw!r}, which is not one of "
+ ", ".join(f"'{m}'" for m in MODES)
+ ". 'auto' leaves the battery alone while control is stopped and "
"commands it once control is started; 'on' records every "
"decision and commands nothing at all; 'off' is ordinary "
"control."]
+109 -1
View File
@@ -325,7 +325,8 @@ import logging
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from .custom_formula import (
FormulaInvalid, implausible_all_in, parse_price_formula, stale_card)
EXPORT_FORMULA_OPTION, FormulaInvalid, implausible_all_in,
parse_export_formula, parse_price_formula, stale_card)
from .inverter import roster_entries
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import is_enabled, validate_meter_options
@@ -333,6 +334,7 @@ from .peakguard import PEAK_OPTION_DEFAULTS
from .price import (
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
from .shadow import problems as shadow_problems
from .static_tariff import (
ProfileInvalid, TAXES_PROFILE_OPTION, parse_static_profile,
parse_taxes_profile)
@@ -839,6 +841,18 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# missing rating one line up.
problems.extend(_roster_problems(opts))
# --- 11. SAFETY-10's shadow mode, and it is a REFUSAL ---------------------
# `shadow_mode` decides whether this controller commands the battery at all,
# so an unreadable value is not a detail to log and carry on past.
# `shadow.mode()` resolves an unrecognised one to SHADOW - the direction
# that does not move energy - and an install silently reinterpreted that way
# commands nothing, indefinitely, while its status page calmly explains
# that this is intentional. UX-02 made this validator the ONE rule surface
# both editors read, so the refusal belongs here rather than in a coercion
# nobody can see. The sentence spells `shadow_mode` so
# `configui.problems_by_field()` can file it under the field.
problems.extend(shadow_problems(opts))
# --- 2. the configured ceiling against the device's own rating -----------
# ⚠️ THE FLAT `max_w`, AND ONLY THE FLAT ONE. `dev_min`/`dev_max` are ONE
# device's range - the flat `setpoint_entity`'s - so this rule can only ever
@@ -1547,6 +1561,7 @@ def _price_problems(opts: dict) -> list[str]:
# CARD AGAINST REALITY. A consistent-but-wrong pair - both numbers off the
# wrong product - reproduces its own printed price and passes. Nothing can
# catch that.
spec = None # TARIFF-16 reads this back, below
raw_formula = opts.get("price_formula", "")
has_formula = bool(str(raw_formula or "").strip()) \
or isinstance(raw_formula, dict)
@@ -1598,6 +1613,99 @@ def _price_problems(opts: dict) -> list[str]:
# cannot start.
_LOG.warning("%s", aged)
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
#
# THE DEFECT THIS EXISTS TO PREVENT: `export_price_eur_kwh` models
# injection as ONE scalar, and the published market is a line -
# Eneco `0,1 x BELPEX-H - 1,188` c EUR/kWh, "het injectietarief is
# dynamisch en kan per uur wijzigen", identical on the January and the
# September 2026 cards. A scalar cannot hold its own factor (0,100 against
# 0,102 on the same supplier's consumption leg), cannot hold a negative
# offset, cannot state that injection is VAT-EXEMPT while consumption is
# at 6%, and cannot go below zero in the midday hours a PV household
# actually exports in.
#
# ⚠️ AND WHAT IT DOES NOT AFFECT TODAY, said here rather than left to be
# inferred: `arbitrage.py` does not read the injection price at all. Both
# hours it compares are all-in CONSUMPTION prices, by its own
# accounting-basis comment. No trade turns on this option. What it changes
# is what the operator is told and what a future export-aware strategy
# rests on - which is why it is validated here rather than trusted.
raw_export = opts.get(EXPORT_FORMULA_OPTION, "")
has_export = bool(str(raw_export or "").strip()) \
or isinstance(raw_export, dict)
if has_export:
try:
xspec = parse_export_formula(raw_export)
except FormulaInvalid as e:
xspec = None
problems.append(f"{EXPORT_FORMULA_OPTION} cannot be used: {e}")
if xspec is not None:
# ⚠️ THE 6% ASYMMETRY, PRINTED RATHER THAN ASSUMED. The two legs
# carry INDEPENDENT `vat_pct` values and a Belgian site's are 6 and
# 0. Printing both beside each other is the only place an operator
# can see that the add-on knows it - a status page shows one price
# and no arithmetic.
_LOG.info(
"%s is set: injection priced as (%g x index %+g) x "
"(1 + %g/100) %s, i.e. %.4g x the wholesale price with a "
"%+.5f EUR/kWh offset. ⚠️ NO STRATEGY IN THIS ADD-ON READS "
"IT: arbitrage.py prices the sell side as displaced IMPORT "
"and compares two all-in CONSUMPTION prices, so this option "
"changes no decision the add-on takes today. It is what you "
"are shown, and the shape a future export-aware strategy "
"would read.",
EXPORT_FORMULA_OPTION, xspec.factor, xspec.offset,
xspec.vat_pct, xspec.units, xspec.passthrough,
xspec.offset_eur_kwh)
if spec is not None:
# ⚠️ THE ASYMMETRY, AS AN ORDERED PAIR RATHER THAN TWO NUMBERS
# IN ONE SENTENCE (MUTATION.md habit 14). "6 and 0 appear
# somewhere" is satisfied by the two legs transposed, which is
# the sentence telling an operator that the add-on has their
# VAT treatment exactly backwards.
_LOG.info(
"the two legs are priced independently: consumption "
"factor %g at vat_pct %g, injection factor %g at vat_pct "
"%g. In Belgium those differ - \"injectietarieven zijn "
"vrijgesteld van btw\" while consumption is at 6%% - and "
"%s is the option that makes the difference "
"expressible; a single scalar could not state it.",
spec.factor, spec.vat_pct, xspec.factor, xspec.vat_pct,
EXPORT_FORMULA_OPTION)
if not xspec.card_graded:
# ⚠️ A WARNING AND NEVER A REFUSAL, AND THE GAP IS NAMED
# RATHER THAN WORKED AROUND. `price_formula`'s card block is
# required because a Belgian card prints the consumption
# formula, the reference index AND the resulting price. Eneco's
# INJECTION formula is printed with no price beside it, so on
# that card there is nothing to reproduce. Requiring a block
# here would make the market's own published line
# inexpressible; inventing the estimate would grade the
# coefficients against arithmetic done with the same
# coefficients. So the leg is accepted, the two coefficient
# bounds in `implausible_injection()` are applied, and the
# operator is told which of the two checks did NOT run.
_LOG.warning(
"%s has no \"card\" block, so its coefficients are "
"UNGRADED: nothing recomputed a printed price from them. "
"That is accepted because it is the real market - Eneco "
"prints its injection formula with no price beside it, so "
"there is nothing to grade against on that card - and "
"refusing would make the published line inexpressible. "
"What IS checked is the shape of the coefficients: the "
"pass-through (%.4g x the wholesale price) and the offset "
"(%+.5f EUR/kWh). If YOUR card prints an injection "
"estimate, add {\"month\": ..., \"index_eur_per_mwh\": "
"..., \"expect_incl_vat\": ...} and the add-on will "
"refuse to start unless your numbers reproduce it.",
EXPORT_FORMULA_OPTION, xspec.passthrough,
xspec.offset_eur_kwh)
else:
xaged = stale_card(xspec)
if xaged:
_LOG.warning("%s", xaged)
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
# site with no price data falls back to the static profile; a dynamic site
# with none configured has nothing to fall back to when its feed dies. That
+181 -8
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.9.0"
version: "0.12.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -93,7 +93,7 @@ options:
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
# rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and
# `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and
# validate.py rule 9 enforces it again for the callers the Supervisor never
# validate.py rules 8 and 9 enforce it again for the callers the Supervisor never
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
# SENSOR - `inputs_ok` already stops the site for that - and never means
# "use the other one's".
@@ -147,6 +147,74 @@ options:
inverters: []
estop_fitted: false
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
# DECIDES ANYTHING. Optional, empty on every install that does not set it, and
# when it is empty the add-on reads no entity, announces no entity, publishes
# no topic and shows no status row.
#
# ⚠️ AN HA ENTITY AND NOT A REGISTER, BECAUSE MOST SOLAR INVERTERS ARE NOT ON
# THIS BUS. The RS485 bus this add-on speaks carries the BATTERY inverter it
# drives; a household's PV inverter is normally a different box from a
# different vendor, reporting into Home Assistant through its own integration.
# So this takes whatever `sensor.*` the site's PV integration provides, any
# brand Home Assistant can read.
#
# ⚠️ POSITIVE IS PRODUCTION, and there is deliberately no `pv_invert` beside
# `meter_invert` and `batt_invert`. Those two quantities are bipolar AND are
# read by the control path, so a reversed sensor there is a plausible number
# the site acts on; production is unipolar and is read by nothing, so a
# reversed sensor announces itself as -4 kW at noon in the history this key
# exists to create. See DECISIONS/TEL-11-pv-production.md.
#
# ⚠️ THE ENTITY'S OWN `unit_of_measurement` IS WHAT IS BELIEVED - W, kW, MW
# and the rest of Home Assistant's power units. A sensor with no unit, or with
# a unit that is not power, is REFUSED and the status page says which: there
# is no magnitude heuristic that can tell 30 W of dusk from 30 kW of noon, and
# a factor of 1000 in the one channel that exists to be believed a week later
# is worse than a refusal an operator can read.
pv_entity: ""
# TEL-12 - THE SAME CHANNEL WHEN THE ROOF HAS SEVERAL INVERTERS, WHICH IS THE
# ORDINARY CASE AND NOT AN EDGE ONE. A multi-string or multi-orientation roof
# routinely carries two or three PV inverters, each publishing its own power
# sensor; 0.11.0 shipped `pv_entity` as a single string and the reference
# site's owner reported within minutes that they could configure one of
# theirs. List them all here and the row carries the SITE total.
#
# ⚠️ DECLARED, THIS REPLACES `pv_entity` - it does not extend it. Set both and
# ONLY this list is read, and the status page says so. That is CFG-04's rule
# for `inverters:` applied to the upgrade path this key exists for: the
# install being upgraded HAS `pv_entity` set, and an operator who adds all
# their inverters to this list while the old key still names one of them would
# DOUBLE that inverter under a union rule. Clear `pv_entity` when you fill
# this in.
#
# ⚠️ AND IF ANY ONE OF THEM IS NOT REPORTING, THERE IS NO SITE TOTAL - the row
# publishes `unknown` and the recorder records a GAP. It does NOT publish the
# sum of the ones that answered: two of three inverters add up to a smaller
# number that is indistinguishable from a real one, and a week later that
# reads as "there was less sun than there was" - the exact conclusion this
# channel exists to TEST rather than to manufacture. The status page names
# which entity is missing and why, and lists what the others reported, because
# a human reading it live wants the diagnostic; the recorded number does not,
# because nobody re-derives a week of history. See
# DECISIONS/TEL-12-several-pv-inverters.md.
#
# ⚠️ THE FIX IS NOT A TEMPLATE SENSOR THAT SUMS THEM. You may legitimately
# prefer one, but a template sensor summing an `unavailable` source yields the
# partial total silently - the same defect one layer down, in a place this
# add-on cannot see or report.
#
# pv_entities:
# - sensor.pv_east_power
# - sensor.pv_west_power
# - sensor.pv_garage_power
#
# Each entity's OWN `unit_of_measurement` is read, so a kW inverter beside a W
# one sums correctly. Empty is the shipped default and means "use `pv_entity`,
# which is also empty" - i.e. no PV channel at all, which is every install.
pv_entities: []
# ==========================================================================
# METER - where the grid reading comes from
# ==========================================================================
@@ -156,7 +224,7 @@ options:
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
# HomeWizard P1 publishes and what ha_dsmr cannot consume.
# homewizard_local skips Home Assistant entirely and polls the meter's own
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
# local API. ⚠️ It is the ONLY mode whose sensor.goodwe_p1_sample_age measures when
# the meter REPORTED rather than when the value last CHANGED - HA emits
# nothing at all for a repeated reading - so it is the only mode a firmware
# watchdog may threshold. See DOCS.md.
@@ -316,10 +384,33 @@ options:
price_taxes_eur_kwh: 0.11736
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
# which by itself strongly favours self-consumption over export. A scalar, not
# a second series: no shipped supplier publishes a dynamic injection curve.
# which by itself strongly favours self-consumption over export. This is the
# FLAT injection price, for a site paid the same for every exported kWh.
#
# ⚠️ THE REASON THIS COMMENT USED TO GIVE - "no shipped supplier publishes a
# dynamic injection curve" - WAS FALSE, and TARIFF-07 struck the same
# sentence from app/price.py and DOCS.md without reaching this file. Eneco
# publishes `0,1 x BELPEX-H - 1,188` c EUR/kWh on the same card as its
# consumption formula, ENGIE publishes `(B x eSpot_15) - A`, and Aspiravi
# `0,07 x Belpex - 2`. A site on one of those sets price_export_formula
# below; this scalar stays the fixed-product default and is what every site
# uses today.
export_price_eur_kwh: 0.04
# TARIFF-16. The INJECTION leg as a formula, in price_formula's own grammar
# and with its OWN coefficients: Eneco uses 0,102 on consumption and 0,100 on
# injection, and injection is VAT-EXEMPT where consumption is at 6%, so
# `vat_pct` here is normally 0. A negative `offset` is expected - it is what
# the published lines carry - and the resulting price legitimately goes below
# zero in the midday hours a PV household exports in.
#
# ⚠️ NO STRATEGY IN THIS ADD-ON READS IT. arbitrage.py prices the sell side
# as displaced IMPORT and compares two all-in CONSUMPTION prices; it does not
# read the injection price at all. This option is what the operator is shown
# and the shape a future export-aware strategy would rest on. Empty by
# default, which leaves every existing site on the scalar above.
price_export_formula: ""
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
# of the installed base is on [a static profile]" - this is what makes those
# sites first-class rather than "price_provider: off, good luck".
@@ -723,6 +814,38 @@ options:
# and not 150 or 200. 37 control cycles.
delivery_hold_s: 185
# SAFETY-10. Run the whole decision path and command NOTHING - strategies,
# arbiter, clamps and guards all run, the decision and its reason are
# recorded, and every inverter is held at 0 W. `app/shadow.py` carries the
# argument; the three things to know before changing this line are:
#
# auto shadow WHILE CONTROL IS STOPPED, live once it is started. This is
# the shipped value and it is what "default-on" means here.
# on always shadow, whatever the Start / stop button says. This is the
# one an operator picks to watch a day of decisions on an armed site
# before letting it trade.
# off ordinary control.
#
# ⚠️ IT IS NOT A BOOLEAN DEFAULTING TO `true`, AND THAT IS THE WHOLE CARD.
# The Supervisor composes an add-on's options as `{**these defaults, **the
# stored user options}`, so a NEW key with a new default reaches every
# EXISTING install on the next update - including one regulating a real
# battery. `shadow_mode: true` would silently stop it, and the only symptom
# would be a battery that has stopped doing anything. There is no mechanism
# in this options system that lets one key default differently for a new
# install and an existing one, so "default-on for new" has to be a value the
# add-on RESOLVES. `auto` is that value: a fresh install ships
# `auto_start: false` and therefore comes up in shadow mode; an install that
# is controlling is started, so `auto` resolves to live and NOTHING about it
# changes.
#
# ⚠️ AND UNDER `auto` THE START BUTTON LEAVES SHADOW MODE. Pressing *Start /
# stop control* is the operator saying "control the battery". Somebody who
# wants the decisions recorded WHILE the loop runs sets `on` explicitly -
# which is the configuration this option exists for, and the one that cannot
# be reached by accident.
shadow_mode: "auto"
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
@@ -793,12 +916,13 @@ schema:
# batteries, whereas an absent capacity has an honest answer - no opinion
# about the split, every unit weighs 1.0, which is what shipped.
#
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
# ⚠️ FOUR KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
# no `name` has no Store namespace and would share another unit's SAFETY-02
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
# no `max_w` would have to borrow a rating, which is the sibling-grading
# defect DEV-02 removed. The optional pair is the sensors, whose absence
# honestly means "this unit reports none".
# defect DEV-02 removed; `bms_soc_floor` is SAFETY-32's (options block above).
# The optional keys are the sensors, whose absence honestly means "this unit
# reports none", and `capacity_kwh`.
#
# ⚠️ THIS BLOCK IS DECLARED BEFORE `max_w:` ON PURPOSE. `max_w` now appears
# TWICE under `schema:` - once nested here, once flat below - and a reader
@@ -817,6 +941,37 @@ schema:
capacity_kwh: float(0.1,200)?
estop_fitted: bool
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
# `price_now_entity`/`price_avg_entity`, which are legal only while
# `price_provider` is `off` and make the add-on REFUSE TO START otherwise.
# Nothing in validate.py mentions this key: it cannot conflict with anything,
# because nothing else reads it.
pv_entity: str?
# TEL-12. ⚠️ A SECOND KEY RATHER THAN A LIST SHAPE ON `pv_entity`, AND THAT IS
# A MEASUREMENT AND NOT A PREFERENCE. `pv_entity` shipped in 0.11.0 as `str?`
# and the reference site has a string persisted in it. Declared as a list,
# Supervisor 2026.08.0 answers that stored string with
#
# Invalid list for option 'pv_entity' in GoodWe RS485 Controller
#
# and `deepmerge`'s list strategy is `override`, so a shipped `[]` default does
# NOT rescue it - the operator's string stands and is refused. That is CFG-04
# §B's trap in the other direction: an add-on that will not start, and the
# schema needed to fix it pinned to the version that cannot be installed. The
# only single-key spelling Supervisor accepts for BOTH shapes is `match(.*)?`,
# which validates nothing at all (it admits a number, a dict, any list of
# anything) and renders as a plain text box. Measured, both of them, by
# `tools/supervisor_shape_probe.py` §G - the transcript is in
# DECISIONS/TEL-12-several-pv-inverters.md §1.
#
# ⚠️ AND `pv_entities: []` IN THE `options:` BLOCK IS LOAD-BEARING, exactly as
# `inverters: []` is. §G measures the same isolated counterfactual: the
# shipped defaults MINUS only this key are refused with `Missing option
# 'pv_entities' in root`, which is every options blob written before 0.12.
# `p1_phase_import_entities` is the shipped precedent for the pair.
pv_entities:
- str
# ==========================================================================
# METER - where the grid reading comes from
@@ -901,6 +1056,15 @@ schema:
# bottoms out at 0 would make that site inexpressible.
export_price_eur_kwh: float(-1,1)
# TARIFF-16. A JSON string in `price_formula`'s grammar, parsed by
# `custom_formula.parse_export_formula()` - the SAME parser with its option
# name as an argument, so a second unit table and a second card rule do not
# exist to diverge. The `card` block is OPTIONAL on this leg and required on
# the other: Eneco prints its injection formula with no price beside it, so
# on that card there is nothing to grade against, and the add-on says at
# startup that the leg is ungraded rather than inventing an estimate.
price_export_formula: str?
# A JSON string rather than a nested schema, deliberately: it is ONE option
# key (§14 has one), it survives config.yaml's flat options block, and
# parse_static_profile() accepts an already-parsed dict too, so a future
@@ -1033,6 +1197,15 @@ schema:
# running short of one unit's share must be reported well inside one.
delivery_hold_s: int(30,900)
# SAFETY-10. ⚠️ A THREE-VALUED LIST AND NOT A `bool`, for the reason written
# against the default in the options block above: a boolean has one default
# and the Supervisor gives it to every install, new and existing alike.
# `app/shadow.MODES` is this tuple and `validate.py` rule 11 refuses anything
# else BY NAME, because `shadow.mode()` resolves an unreadable value to
# SHADOW - safe, but a silent reinterpretation that commands nothing for as
# long as nobody notices.
shadow_mode: list(auto|on|off)
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
@@ -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"
}
}
}
]
}
@@ -0,0 +1,70 @@
{
"generated": "2026-09-21T11:30:00+02:00",
"card": "SAFETY-57",
"what": "The 2026-09-21 episode: a healthy 0x0518 declared stale, the site parked at 0 W, and no exit reachable from inside the state.",
"source": {
"host": "sniffer@192.168.2.167 (goodwe-sniff)",
"nature": "PASSIVE listener on the RS-485 pair between the ESP32 master and the GW5000S-BP. It has no address on the bus and writes nothing.",
"file": "~/bus-a.log",
"window_local": [
"2026-09-21T10:30:00+02:00",
"2026-09-21T11:10:00+02:00"
],
"timezone": "Europe/Brussels (the capture stamps a time of day and no date; the date is asserted here, not derivable from the file)",
"extraction": "lines whose time-of-day field lies in the window, verbatim, in capture order"
},
"file": {
"name": "bus-a-2026-09-21-1030-1110.log.gz",
"sha256_gz": "876fdaaf6d087125b184bfb4dd7ef4c3d1dd71ccad0fc271f8d268e8b489d134",
"sha256_plain": "fa07e13b60e230626a8c0964b9539783a71b5627a522fc5930fd9fda54438a48",
"bytes_gz": 17181,
"bytes_plain": 182400,
"lines": 3840
},
"decoder": "tools/decode_bus_capture.py - and it is the ONLY interpretation of these bytes this repository carries. Re-run it to re-derive every number below.",
"frame_census": {
"lines": 3840,
"unparsed": 0,
"bad_crc": 0,
"requests": 720,
"responses": 720,
"writes": 1200,
"write_echoes": 1200,
"unknown": 0
},
"series": {
"setpoint_w": {
"frames": 1200,
"changes": 224,
"first_s": 37801.687,
"last_s": 40199.693
},
"batt_w": {
"frames": 240,
"changes": 133,
"first_s": 37804.716,
"last_s": 40194.724
},
"soc_pct": {
"frames": 240,
"changes": 4,
"first_s": 37806.725,
"last_s": 40196.732
}
},
"the_episode_in_the_capture": {
"10:51:24.736": "0x0518 reaches -3 W. The pack is at 11 %, the loop has just reversed out of -447 W of charge, and the BMS refuses the discharge.",
"10:51:29.695": "the setpoint reaches +1060 W of discharge - 800 W of travel from the command standing when the register last moved.",
"10:52:21.695": "the setpoint is written as 0 W and stays there. This is the add-on's own `inputs missing -> command 0 W`, on the wire.",
"10:51:24.736..11:05:34.734": "0x0518 does not change value. 850.0 s.",
"10:52:21.695..11:05:31.698": "the setpoint does not change value. 790.0 s.",
"~11:04": "a human restarts the add-on. NOT VISIBLE IN THIS FILE - it is an action on 192.168.2.158 - and it is the only reason the episode ends.",
"11:05:31.698": "the restarted loop commands -400 W of CHARGE, which the pack accepts at 11 %.",
"11:05:34.734": "0x0518 answers -137 W. The register was healthy throughout."
},
"not_in_this_file": [
"the grid meter - a P1 telegram on a different wire. The 2.4 kW of export during the episode is from Home Assistant history and is NOT re-derivable here.",
"anything the add-on believed: Inverter.batt, inputs_ok, log lines. Those are INFERRED by replaying the shipped modules against these series (test_freshness.py part 10).",
"the restart at ~11:04."
]
}
+869 -11
View File
@@ -73,6 +73,7 @@ Read MUTATION.md before you report a survivor.
import ast
import inspect
import math
import os
import sys
from datetime import datetime, timedelta, timezone
@@ -84,10 +85,17 @@ from app.arbiter import (
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
resolve,
)
# SAFETY-45 section 3c. The shipped loop, the shipped billed-quarter
# accumulator and the shipped projection - nothing here is transcribed.
from app.control import Tuning, compute
from app.p1 import QUARTER_S, QuarterAverager, make_sample, split_signed
from app.tariff import PeakTracker
from app.arbitrage import (
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
threshold_eur_kwh,
)
# SAFETY-47 section 3d. The shipped shadow-mode row, not a transcription of it.
from app import shadow as _shadow
from app.price import (
PRICE_OPTION_DEFAULTS, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, WIDEN_FACTOR, brussels, required_spread, widen_factor,
@@ -203,10 +211,19 @@ def hour(i, *, start=D0):
return start + timedelta(hours=i, minutes=30)
# SAFETY-45. THE HEADROOM EVERY CHECK THAT IS NOT ABOUT THE HEADROOM USES, and
# it is deliberately NOT binding: `min(MAX_W, this)` is MAX_W, so every existing
# check in this file grades what it always graded and the new rung is decoration
# in all of them. Section 3b is where it is made to bind, and 3b asserts the
# precondition that it does (MUTATION.md habit 1) rather than hoping.
HEADROOM_CLEAR = MAX_W * 10.0
def act(plan, i, *, peak=False, soc=50.0, thresh=0.0, unc=frozenset(),
floor=20.0, ceiling=95.0, start=D0):
floor=20.0, ceiling=95.0, start=D0, headroom=HEADROOM_CLEAR):
"""`decide()` with the fixture's constants filled in. One call shape."""
return decide(plan, hour(i, start=start), peak_guard_active=peak, soc=soc,
charge_headroom_w=headroom,
min_spread_eur_kwh=thresh, uncertainty=unc,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
@@ -721,6 +738,7 @@ check("a peak floor does NOT cap an arbitrage DISCHARGE - §9.1 bills offtake "
# MECHANISM 3, which is neither of the above: the guard that never ran.
_blind = decide(PLAN, hour(CHEAP_H), peak_guard_active=None, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
check("a peak guard that NEVER RAN is a third state, and it refuses: `None` is "
@@ -750,6 +768,835 @@ check("...and cycle() calls it rather than inlining the conjunction, so a "
encoding="utf-8").read(), "cycle")))
# --------------------------------------------------------------------------- #
print()
print("3b. SAFETY-45 - the buy leg is SIZED by the guard, not switched by it")
# ---------------------------------------------------------------------------
# AC3 above is the SWITCH: `peak_guard_active` refuses the whole trade while
# the guard holds. That is right and it stays. What was missing is the other
# half - the guard measures a CONTINUOUS number, "the watts this billed quarter
# can still afford", and until this card nothing consumed it. So a trade sat at
# max_w through the whole window in which the guard was silent, blew the
# quarter, tripped the switch, was cancelled outright, and came back at max_w
# the moment the meter fell far enough to release it. Measured on TEST-23
# stage 3's closed rig at peak_cap_w 1500 / max_w 2000 / a 1200 W house:
# 28 charge onsets in one hour, mean period 131 s, grid -456 <-> +3200 W.
#
# `control.maintenance_charge_floor()` has always given a MAINTENANCE charge
# exactly this shape - "capped by the headroom left under the site's cap" - and
# its own docstring says why: "a maintenance charge is the only thing this
# system does that is big enough and long enough to set that peak". OPT-04 is
# the second such thing and was given no cap. This section grades the cap.
# ---------------------------------------------------------------------------
# ⚠️ THE PRECONDITION, AND IT IS THE HABIT-1 ONE. The headroom must be strictly
# between 0 and the size the trade would otherwise ask for, or "sized by the
# guard" is indistinguishable from "not sized at all" and from "refused".
S45_HEADROOM = 300.0
check(f"PRECONDITION: the headroom under test ({S45_HEADROOM:.0f} W) is "
f"strictly inside the trade's own sizing ({MAX_W:.0f} W) and strictly "
f"positive, so a cap that did nothing and a cap that refused everything "
f"are both distinguishable from the answer below",
0.0 < S45_HEADROOM < MAX_W)
_s45_capped = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER,
headroom=S45_HEADROOM)
_s45_clear = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER)
print(f" headroom {S45_HEADROOM:.0f} W -> {_s45_capped.power_w:+.0f} W; "
f"headroom clear -> {_s45_clear.power_w:+.0f} W")
print(f" {_s45_capped.reason}")
check("the trade still HAPPENS - §10.1 ranks peak shaving above arbitrage, it "
"does not abolish it - and it is sized to the watts the billed quarter "
"can afford rather than to max_w",
_s45_capped.action == CHARGE
and near(_s45_capped.power_w, -S45_HEADROOM))
check("...and the claim the arbiter receives carries the SIZED number, so the "
"cap reaches the actuator and is not merely printed",
near(claim(_s45_capped).value, -S45_HEADROOM)
and claim(_s45_capped).priority == P_ARBITRAGE)
check("...and the SAME call with the headroom clear asks for max_w, so the "
"check above is graded against a difference this fixture can actually "
"produce (habit 7: one thing varied, from an accepted baseline)",
_s45_clear.action == CHARGE and near(_s45_clear.power_w, -MAX_W))
# ⚠️ THE ORDERED PAIR (MUTATION.md habit 14). Two numbers in one sentence can
# always be transposed by a ZERO-BYTE edit, and this sentence is what an
# operator reads when the battery buys less than the price says it should. So
# the check asserts each value inside the literal text that names it, and
# asserts that the TRANSPOSED sentence is not what it says.
_S45_PAIR = (f"the trade asked for {MAX_W:.0f} W and this billed quarter can "
f"afford {S45_HEADROOM:.0f} W")
_S45_SWAP = (f"the trade asked for {S45_HEADROOM:.0f} W and this billed "
f"quarter can afford {MAX_W:.0f} W")
check("the reason names WHAT WAS ASKED FOR and WHAT WAS AFFORDABLE in their "
"own slots, so an operator can tell a small trade from a small "
"opportunity", _S45_PAIR in _s45_capped.reason)
check("...and the transposed sentence is NOT what it says, so the check above "
"discriminates rather than decorates - both numbers are in the string "
"either way and a substring test would pass under the swap",
_S45_SWAP not in _s45_capped.reason)
check("...and it names the guard and the two spec sections, so the sentence "
"cannot be read as a pack limit or a price limit",
"CAPACITY-TARIFF GUARD" in _s45_capped.reason
and "§9.1" in _s45_capped.reason and "§10.1" in _s45_capped.reason)
check("...and an UNCAPPED trade does not carry that clause at all: a sentence "
"that always says 'sized by the guard' tells nobody anything",
"CAPACITY-TARIFF GUARD" not in _s45_clear.reason
and f"charging {MAX_W:.0f} W" in _s45_clear.reason)
# --- the two refusals, and they are told apart from the ones that existed ---
_s45_none = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=None)
_s45_zero = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
print(f" headroom None -> {_s45_none.action}; headroom 0 W -> "
f"{_s45_zero.action}")
check("a headroom nothing MEASURED refuses. This is guard_state()'s None "
"argument one layer down: charging creates the billed quantity (§9.1) "
"and an unmeasured quarter is not a clear one",
not _s45_none.trading
and _s45_none.reason == _arb.IDLE_HEADROOM_UNMEASURED)
check("...and it is DISTINGUISHABLE from the guard never having run at all, so "
"the status page does not report a missing battery sensor as a missing "
"meter", _s45_none.reason != _arb.IDLE_NOT_COVERED)
check("a headroom of ZERO refuses rather than filing a 0 W claim - "
"`Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the "
"loop, which is the failure claim()'s own note is written against",
not _s45_zero.trading and claim(_s45_zero) is None
and _s45_zero.reason == _arb.IDLE_NO_HEADROOM)
# ⚠️ TEST-16's RULE: TWO RUNGS THAT CAN ANSWER THE SAME STIMULUS MUST BE
# SEPARATED. `charge_w = 0` and `headroom = 0` both produce a 0 W sizing, and
# grading only `kind == "refused"` would let either absorb the other's work -
# after which weakening either one reports nothing. So the REASONS are graded,
# and each rung is reached with the other one clear.
_s45_nosize = decide(PLAN, hour(CHEAP_H), peak_guard_active=False, soc=50.0,
charge_headroom_w=HEADROOM_CLEAR,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=0.0, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
check("...and the PACK being sized at 0 W still reports OPT-09's own sentence, "
"not this card's: the two rungs answer the same stimulus and a refusal "
"that named the wrong subsystem would send an operator to the wrong page",
not _s45_nosize.trading
and _s45_nosize.reason == _arb.IDLE_NO_SIZE
and _s45_zero.reason != _s45_nosize.reason)
# --- §9.1: the SELL leg is untouched ---------------------------------------
_s45_sell = act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
"are never capped by the peak guard; only charging and import are' - the "
"capacity tariff bills offtake, and a sell creates none",
_s45_sell.action == DISCHARGE and near(_s45_sell.power_w, MAX_W))
# --- and main.py actually passes it, pinned on the AST ----------------------
# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (MUTATION.md habit 15). Nothing
# behavioural in this file can see main.py drop the argument - every check
# above calls decide() itself - and a required keyword-only parameter that no
# caller fills is a TypeError only on the one path a suite does not run. Same
# pin, same reason, as `weights=` in test_packshare.py part 10.
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
_S45_MAIN_TREE = ast.parse(_S45_MAIN)
_s45_calls = [n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, ast.Call)
and ast.unparse(n.func).endswith("arbitrage.decide")]
_s45_arg = [ast.unparse(k.value) for c in _s45_calls for k in c.keywords
if k.arg == "charge_headroom_w"]
def _s45_assigned(name, clear=("None",)):
"""Every expression main.py assigns to `self.<name>` that is not a CLEAR.
⚠️ SAFETY-47 HOISTED THE HEADROOM OUT OF THE CALL, so pinning the call
argument alone stopped covering anything: the literal a mutation would put
there now goes one line up instead. Both ends are pinned.
⚠️ `clear` NAMES THE CONSTANTS THAT MEAN "NOTHING HAS ANSWERED THIS YET" -
`__init__`'s value and the per-cycle reset - and they are not a second
source for the number. Excluding them does NOT soften the pin: a mutation
that replaces the real assignment with one of those constants leaves this
list EMPTY, which matches no expected value and is red.
"""
return [ast.unparse(n.value) for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, ast.Assign)
and [ast.unparse(t) for t in n.targets] == [f"self.{name}"]
and ast.unparse(n.value) not in clear]
_s45_head_src = _s45_assigned("arb_headroom_w")
print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); "
f"charge_headroom_w= is {_s45_arg}, and self.arb_headroom_w is assigned "
f"from {_s45_head_src}")
check("main.py passes the guard's OWN measured headroom at its one "
"arbitrage.decide() call site - the shipped expression, not a "
"transcription of it, so a mutation back to a literal or to max_w is red "
"rather than silent",
_s45_arg == ["self.arb_headroom_w"]
and _s45_head_src == ["peakguard.charge_headroom_w(self.peak_guard)"])
# ⚠️ AND THE HOIST DID NOT MOVE IT OUT OF THE CYCLE. An `arb_headroom_w` set
# once in `__init__` and never refreshed would satisfy the pin above and would
# size every trade from the guard decision of whatever cycle happened to run
# last - which is the defect the hoist's own comment warns about, one level up.
_s45_cycle = next(n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
and n.name == "cycle"
and any(isinstance(c, ast.Call)
and ast.unparse(c.func).endswith("arbitrage.decide")
for c in ast.walk(n)))
check("...and the assignment is INSIDE the same `cycle()` that makes the call, "
"so the headroom the trade is sized from is this cycle's guard decision "
"and not one carried from an arbitrary earlier one",
any(isinstance(n, ast.Assign)
and [ast.unparse(t) for t in n.targets] == ["self.arb_headroom_w"]
and ast.unparse(n.value)
== "peakguard.charge_headroom_w(self.peak_guard)"
for n in ast.walk(_s45_cycle)))
# --------------------------------------------------------------------------- #
print()
print("3c. SAFETY-45 - THE REGRESSION: the closed loop SETTLES")
# ---------------------------------------------------------------------------
# ⚠️ THIS IS THE CHECK THE CARD IS ACTUALLY ABOUT, AND A UNIT TEST ON decide()
# IS NOT IT. The defect is an INTERACTION - two strategies, a 900 s integrator
# and a plant that answers back - and every individual decision in it was
# correct. So the loop is closed here: prices decide, the battery model
# responds, the shipped QuarterAverager reports the consequence, and the
# shipped guard reacts to that.
#
# WHAT IS REAL, AND WHAT IS MODELLED - the same discipline TEST-23 stage 3's
# harness holds itself to, because a transcription proves what somebody typed:
#
# REAL peakguard.decide/claim/charge_headroom_w, arbitrage.decide/claim/
# guard_state, control.compute + Tuning, arbiter.resolve, and
# p1.QuarterAverager - the billed-quarter accumulator itself, so the
# quarters reported below are the ones a meter would bill.
# MODEL ONE cycle's ORDER (peak block, then arbitrage reading its
# conclusion, then the loop claim, then resolve) - twelve lines
# transcribed from main.py:2660-2900 and pinned above in 3b; the
# PLANT, an 800 W/s ramp into a 10 s first-order lag, which is
# plantsim's; and a flat synthetic house.
#
# ⚠️ AND THE HEADLINE NUMBERS WERE NOT TAKEN HERE. They were taken on the
# closed rig at /home/homelab/test23s3 against the shipped container's own
# checkout, which is where a divergence between this model and main.py's real
# event loop would show up. This section exists so CI can go red.
# ---------------------------------------------------------------------------
S45_CAP_W, S45_LOAD_W, S45_CYCLE_S, S45_RUN_S = 1500.0, 1200.0, 5.0, 2700.0
S45_RAMP_W_S, S45_LAG_S = 800.0, 10.0
# ⚠️ THE RUN STARTS ON A QUARTER BOUNDARY AND ENDS INSIDE THE SAME CHEAP HOUR,
# and both halves matter. Starting on the boundary makes the three closed
# blocks below whole billed quarters. Ending inside the hour keeps TEST-23
# finding 1 - the export overshoot at the END of any trade, ~0.37 x max_w, a
# separate and already-recorded property - out of a swing figure this card is
# grading. The run still crosses TWO quarter boundaries, which is the half of
# the defect the card says arbitrage must learn from.
S45_T0 = D0 + timedelta(hours=CHEAP_H)
def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
seconds=S45_RUN_S, shadow=False):
"""One closed hour. `sized=False` is the behaviour this card removes.
⚠️ THE COUNTERFACTUAL IS ONE ARGUMENT, NOT A SECOND CODE PATH. `sized=False`
hands `decide()` a headroom nothing can bind - which is exactly what the
tree did before this card, because nothing was passing one - so the two runs
below differ in one value and share every line of shipped code. Habit 7.
⚠️ SAFETY-47's `shadow=True` IS THE SAME DISCIPLINE, ONE ARGUMENT AGAIN. It
reproduces the THREE things `Controller._write_unit()` and
`_shadow_settle()` do and nothing else: the resolved command is RECORDED,
the plant is driven from `shadow.WIRE_W` instead of from it, and the
carried command is RE-SEEDED to `shadow.WIRE_W` after the record is taken.
Those three lines are a TRANSCRIPTION of main.py's placement and are marked
as one - `test_clamp.py` part 8 is the check on the shipped placement, and
it drives the real `Controller`. What is NOT transcribed is every number
that matters here: the averager, the projection, the guard, the headroom,
the strategy, the arbiter and the row's own words are all the shipped ones.
"""
avg = QuarterAverager(phases=1, max_hold_s=900.0)
tuning = Tuning(gain=0.6, max_w=MAX_W, slew_w=0.0)
t0 = S45_T0
target = i_w = 0.0
sat = 0
loop_w = None
batt_cmd = batt = 0.0 # + discharge, as main.py's site_batt_w
onsets = blocks = 0
rec = []
prev_action = "<start>"
grid_lo, grid_hi = None, None
billed = []
acc = 0.0
t = 0.0
while t <= seconds:
grid = load_w - batt
imp, exp = split_signed(grid)
for b in avg.add(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=t0 + timedelta(seconds=t),
ingest_mono=(t0.timestamp() + t))):
billed.append(round(float(b.offtake_avg_w), 1))
blocks += 1
if acc <= 1e-9:
acc = S45_CYCLE_S
now = t0 + timedelta(seconds=t)
claims = []
pk = _pg.decide(PeakTracker.project(avg, now), batt_w=batt,
cap_w=cap_w, reading=None, achievable_w=MAX_W,
soc=50.0, soc_floor=20.0, max_hold_s=900.0,
frozen=False)
pc = _pg.claim(pk)
if pc is not None:
claims.append(pc)
arb = decide(PLAN, now,
peak_guard_active=_arb.guard_state(pk),
charge_headroom_w=(_pg.charge_headroom_w(pk) if sized
else HEADROOM_CLEAR),
soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER, charge_w=MAX_W,
discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
ac = claim(arb)
if ac is not None:
claims.append(ac)
if arb.action == CHARGE and prev_action != CHARGE:
onsets += 1
prev_action = arb.action
if loop_w is None or target != loop_w:
i_w = target
d = compute(prev_w=target, grid_w=grid, actual_w=batt,
tuning=tuning, sat_count=sat, i_w=i_w)
sat, i_w, loop_w = d.sat_count, d.i_w, d.target_w
claims.append(Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W,
"max_w"))
claims.append(Claim.set("loop", P_LOOP, d.target_w, d.reason))
res = resolve(claims)
target = res.target_w
head_w = _pg.charge_headroom_w(pk) if sized else HEADROOM_CLEAR
rec.append(dict(
t_s=t, action=arb.action, power_w=arb.power_w,
reason=arb.reason, price=arb.price_now_eur_kwh,
spread=arb.spread_eur_kwh, winner=res.winner,
target_w=float(res.target_w), headroom_w=head_w,
engaged=bool(pk.engaged),
sized_from_meter=_arb.sized_from_meter(
arb, head_w, winner=res.winner)))
if shadow:
# --- TRANSCRIBED 1/3: `_shadow_settle()`'s record, taken
# BEFORE the re-seed destroys its evidence.
rec[-1]["would_w"] = float(res.target_w)
rec[-1]["row"] = _shadow.words(
{"shadow_mode": "on"}, control_started=False,
would_w=float(res.target_w),
sized_from_meter=rec[-1]["sized_from_meter"],
reason=res.explain())
# --- TRANSCRIBED 2/3: `_write_unit()`'s substitution - the
# plant below is driven from WIRE_W, not from the decision.
# --- TRANSCRIBED 3/3: `_shadow_settle()`'s re-seed of the
# carried command, which is what `compute(prev_w=...)`,
# `standing_w()` and SAFETY-42 read next cycle.
target = _shadow.WIRE_W
# THE PLANT, MODELLED: an 800 W/s ramp into a 10 s first-order lag.
step = min(1.0, acc)
delta = target - batt_cmd
batt_cmd += math.copysign(min(abs(delta), S45_RAMP_W_S * step), delta)
batt += (batt_cmd - batt) * (step / S45_LAG_S)
acc -= step
t += step
grid_lo = grid if grid_lo is None else min(grid_lo, grid)
grid_hi = grid if grid_hi is None else max(grid_hi, grid)
return dict(onsets=onsets, grid_lo=round(grid_lo, 1),
grid_hi=round(grid_hi, 1), swing=round(grid_hi - grid_lo, 1),
billed=billed, blocks=blocks, rec=rec,
engaged=sum(1 for r in rec if r["engaged"]),
cycles=len(rec))
S45_BEFORE = derived(lambda: s45_loop(sized=False))
S45_AFTER = derived(lambda: s45_loop(sized=True))
for _lab, _r in (("unsized (the shipped behaviour before this card)", S45_BEFORE),
("sized by the guard", S45_AFTER)):
print(f" {_lab}: {_r['onsets']} charge onsets/h, grid "
f"{_r['grid_lo']:.0f} .. {_r['grid_hi']:.0f} W (swing "
f"{_r['swing']:.0f} W), billed quarters {_r['billed']} W")
# ⚠️ THE NEGATIVE CONTROL FIRST. If the unsized run did NOT limit-cycle, every
# assertion below would be a statement about a loop that was never unstable -
# MUTATION.md's "a check whose data cannot reach the failure it names", which
# has now appeared in eleven cards in this repository.
check("PRECONDITION: with the trade UNSIZED this loop really does run away - "
"many charge onsets in one hour and a grid swing of kilowatts. Without "
"this, everything below grades a loop that was never unstable",
S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0)
check("...and it is the bang-bang shape and not a slow drift: the meter "
"reaches both a hard import and an EXPORT, so the actuator is being "
"handed back and forth rather than converging",
S45_BEFORE["grid_hi"] > S45_CAP_W + 1000.0
and S45_BEFORE["grid_lo"] < 0.0)
check(f"THE REGRESSION: sizing the trade to the guard's measured headroom "
f"settles the loop. {S45_BEFORE['onsets']} charge onsets an hour become "
f"{S45_AFTER['onsets']}, and the grid swing falls from "
f"{S45_BEFORE['swing']:.0f} W to {S45_AFTER['swing']:.0f} W",
S45_AFTER["onsets"] <= 2
and S45_AFTER["swing"] < 0.25 * S45_BEFORE["swing"])
check("...and the battery is still TRADING for the whole hour rather than "
"settling by giving up: §10.1 ranks peak shaving above arbitrage, it "
"does not cancel it, and a fix that simply stopped buying would pass an "
"onset count and fail the card",
S45_AFTER["onsets"] == 1)
check(f"...and every BILLED quarter comes in at or under the "
f"{S45_CAP_W:.0f} W cap, which the unsized run does not manage - the "
f"oscillation was not merely ugly, it was losing the money it was "
f"oscillating about (§10.2, §9.1)",
S45_AFTER["blocks"] >= 3
and all(v <= S45_CAP_W + 0.5 for v in S45_AFTER["billed"])
and any(v > S45_CAP_W for v in S45_BEFORE["billed"]))
check("...and the two runs are the same code on the same fixture: one "
"argument differs, and the loop, the guard, the arbiter, the averager "
"and the plant are shared line for line",
S45_BEFORE["blocks"] == S45_AFTER["blocks"])
# ⚠️ AND IT IS INERT WHERE THE DEFECT IS INERT, WHICH IS THE OTHER HALF OF A
# FIX. The rig's sweep found the bite rule `load + max_w > peak_cap_w`; below
# it, the trade never threatens the quarter and this card must change NOTHING.
S45_WIDE = derived(lambda: s45_loop(sized=False, cap_w=5000.0, load_w=1200.0,
seconds=900.0))
S45_WIDE_S = derived(lambda: s45_loop(sized=True, cap_w=5000.0, load_w=1200.0,
seconds=900.0))
print(f" peak_cap_w 5000 W, 1200 W house (load + max_w = "
f"{S45_LOAD_W + MAX_W:.0f} W, under the cap): unsized "
f"{S45_WIDE['billed']} W, sized {S45_WIDE_S['billed']} W")
def _s45_outcome(r):
"""Everything the two runs put on the PLANT and the METER.
⚠️ `rec` IS EXCLUDED AND THE EXCLUSION IS NAMED. SAFETY-47 added a
per-cycle record to this harness, and it carries `headroom_w` and
`sized_from_meter` - which are THE ONE ARGUMENT the two runs differ in, by
construction. Comparing them would make this check fail for the reason it
exists to rule out. The resolved command per cycle IS compared, below, and
that is a stronger statement than the six aggregates ever were.
"""
return {k: v for k, v in r.items() if k != "rec"}
check("on a site where the trade cannot reach the cap the two runs are "
"IDENTICAL to the watt - so this card costs nothing on the installs it "
"is not about, and the check above is not passing because the cap "
"dominates everything",
_s45_outcome(S45_WIDE) == _s45_outcome(S45_WIDE_S)
and S45_WIDE["onsets"] == 1)
check("...and identical CYCLE BY CYCLE, not merely in the six aggregates: "
"every resolved command and every traded size matches to the float",
[(r["target_w"], r["power_w"]) for r in S45_WIDE["rec"]]
== [(r["target_w"], r["power_w"]) for r in S45_WIDE_S["rec"]])
# --------------------------------------------------------------------------- #
print()
print("3d. SAFETY-47 - the SHADOW record against a LIVE run, at a BINDING cap")
# ---------------------------------------------------------------------------
# WHAT THIS PART EXISTS FOR, AND IT IS NOT A SECOND SAFETY-45 CHECK.
#
# SAFETY-10 shipped shadow mode saying, in three merged documents, that "the
# strategies are unaffected - they ask for a fixed power from inputs that do
# not depend on what the battery did - so a shadow run does show you the
# trades, the peak-guard engagements and the SoC-limit behaviour". That was
# true until 3c's own fix merged. SAFETY-45 makes the buy leg
# `min(asked, headroom)`, and the headroom is
#
# (900 / remaining) x (ceiling - projected)
#
# where `projected` reads the OPEN QUARTER'S ACCUMULATOR - the meter - which is
# a function of what the battery did earlier in that same quarter. The strategy
# became closed-loop the day 3c landed, and shadow mode holds the battery at
# 0 W, so in a shadow run that accumulator contains THE HOUSE AND NOTHING ELSE.
#
# ⚠️ AND SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED - TWO
# INDEPENDENT REASONS, AND CLOSING EITHER ONE ALONE CLOSES NOTHING.
# (1) its fixture is `peak_cap_w: 15000` against a 350 W house, so the guard
# never engages and the sizing is inert - MUTATION.md habit 4, inside a
# merged card's own proof;
# (2) every identity row reads THE FIRST CHARGING SAMPLE, and at `elapsed=0`
# the projection IS the held level, so the two runs agree to the float
# there even AT a binding cap. 3d-2 drives that and says so.
#
# So this part is the grader for the class: a shadow run and a live run, the
# same hour, the same code, at a cap that BINDS. What is REAL and what is
# TRANSCRIBED is in `s45_loop()`'s own docstring - the three lines that stand
# in for `Controller._write_unit()` and `_shadow_settle()` are named there, and
# `test_clamp.py` part 8 is the check on the shipped placement.
# ---------------------------------------------------------------------------
S47_LIVE = derived(lambda: s45_loop(sized=True))
S47_SHAD = derived(lambda: s45_loop(sized=True, shadow=True))
# The SAFETY-45 defect itself, watched from inside shadow mode - which is what
# SAFETY-10's header claimed a shadow run would surface.
S47_UNSIZED_SHAD = derived(lambda: s45_loop(sized=False, shadow=True))
_s47_lc = derived(lambda: [r for r in S47_LIVE["rec"] if r["action"] == CHARGE])
_s47_sc = derived(lambda: [r for r in S47_SHAD["rec"] if r["action"] == CHARGE])
# ⚠️ THE SECTION CARRIES ITS OWN PRECONDITION INTO EVERY CHECK, AND THIS IS
# FCST-06's SHAPE RATHER THAN TIDINESS. A mutation that stops either run
# trading at all leaves these lists EMPTY; the first `min()` below then raises,
# the file dies before part 4, and the run reports ZERO reds - which reads
# exactly like a survivor. Measured: driving `wanted = batt_w - ex` through
# this section did precisely that. So the lists are replaced by a sentinel that
# cannot satisfy anything, and `s47check()` fails every check in the section
# when the stimulus was not produced.
_S47_DEAD = dict(action=None, power_w=0.0, reason="<no charging cycle>",
price=None, spread=None, winner=None, target_w=0.0,
headroom_w=0.0, engaged=False, sized_from_meter=False,
would_w=0.0, row="", t_s=0.0)
_S47_OK = bool(_s47_lc) and bool(_s47_sc)
if not _S47_OK:
_s47_lc = _s47_sc = [dict(_S47_DEAD)]
_s47_n = min(len(_s47_lc), len(_s47_sc))
def s47check(name, cond):
"""A 3d check, AND the section's own precondition, in one verdict."""
check(name, _S47_OK and bool(cond))
check("PRECONDITION: both runs actually reached a charging decision - without "
"this the section raises on its first `min()`, the file dies before "
"part 4 and the run reports ZERO reds, which reads exactly like a "
"survivor (MUTATION.md, FCST-06's shape). Measured, not imagined",
_S47_OK)
print(f" live : {len(_s47_lc)} charging cycles, traded "
f"{min(r['power_w'] for r in _s47_lc):+.1f} .. "
f"{max(r['power_w'] for r in _s47_lc):+.1f} W, "
f"headroom {min(r['headroom_w'] for r in _s47_lc):.1f} .. "
f"{max(r['headroom_w'] for r in _s47_lc):.1f} W, "
f"grid {S47_LIVE['grid_lo']:.0f} .. {S47_LIVE['grid_hi']:.0f} W")
print(f" shadow: {len(_s47_sc)} charging cycles, RECORDED "
f"{min(r['power_w'] for r in _s47_sc):+.1f} .. "
f"{max(r['power_w'] for r in _s47_sc):+.1f} W, "
f"headroom {min(r['headroom_w'] for r in _s47_sc):.1f} .. "
f"{max(r['headroom_w'] for r in _s47_sc):.1f} W, "
f"grid {S47_SHAD['grid_lo']:.0f} .. {S47_SHAD['grid_hi']:.0f} W")
# --- 3d-1. THE PRECONDITION: the cap binds, and the meter proves it --------
# ⚠️ WITHOUT THIS EVERY CHECK BELOW IS SAFETY-10 §6's DEFECT IN A NEW FILE. At
# a cap the trade cannot reach, live and shadow agree exactly and nothing here
# could ever go red - which is precisely what the committed evidence did.
s47check("PRECONDITION: the cap BINDS - the live run's trade is sized by the "
f"guard on every charging cycle, never reaching the {MAX_W:.0f} W ask",
_s47_lc and all(abs(r["power_w"]) < MAX_W - 1.0 for r in _s47_lc))
s47check("...and the live METER is what closed the gap: it is held up at the "
f"{S45_CAP_W:.0f} W ceiling rather than sitting at the "
f"{S45_LOAD_W:.0f} W house, which is the feedback a shadow run does not "
f"have",
S47_LIVE["grid_hi"] > S45_LOAD_W + 200.0)
s47check("...and the SHADOW meter sees the house and nothing else for the whole "
"run, which is what makes its billed quarter a different quarter",
near(S47_SHAD["grid_lo"], S45_LOAD_W, 0.001)
and near(S47_SHAD["grid_hi"], S45_LOAD_W, 0.001))
# --- 3d-2. WHY THE OLD SHAPE COULD NOT FAIL --------------------------------
# Graded as an EXPECTATION rather than left as a footnote, so that nobody
# rebuilds a check on `[0]` and believes it covers something.
print(f" first charging cycle: live {_s47_lc[0]['power_w']:+.4f} W, "
f"shadow {_s47_sc[0]['power_w']:+.4f} W")
s47check("⚠️ THE FIRST CHARGING CYCLE IS IDENTICAL IN BOTH RUNS EVEN HERE, so "
"widening SAFETY-10 §6's fixture ALONE would not have made its table go "
"red: at elapsed=0 the projection IS the held level and neither plant "
"has moved yet. Both reasons had to be closed, and this is the one a "
"reviewer cannot see from a diff",
_s47_lc[0]["power_w"] == _s47_sc[0]["power_w"]
and _s47_lc[0]["reason"] == _s47_sc[0]["reason"])
# --- 3d-3. AND ACROSS THE WHOLE RUN THEY DIVERGE ---------------------------
_s47_mis = sum(1 for i in range(_s47_n)
if _s47_lc[i]["power_w"] != _s47_sc[i]["power_w"])
_s47_worst = min(_s47_sc, key=lambda r: r["power_w"])
_s47_live_worst = min(_s47_lc, key=lambda r: r["power_w"])
# ⚠️ NaN AND NOT AN EXCEPTION when the sentinel above is in play: `nan > 2.0`
# is False, so the check goes RED, while a ZeroDivisionError here would kill
# the file and report zero reds. Same defect, one line further down - it was
# driven, and it is why this is not a plain division.
_s47_over = (abs(_s47_worst["power_w"]) / abs(_s47_live_worst["power_w"])
if _s47_live_worst["power_w"] else float("nan"))
print(f" size mismatches {_s47_mis}/{_s47_n} charging cycles; the shadow "
f"run records {_s47_worst['power_w']:+.0f} W where the live run's "
f"hardest trade is {_s47_live_worst['power_w']:+.1f} W - "
f"{_s47_over:.2f}x")
s47check("ACROSS THE WHOLE RUN the recorded trade size diverges on all but the "
"first few cycles - the check SAFETY-10 §6 should have been",
_s47_mis > 0.9 * _s47_n)
s47check(f"...and the shadow run RECORDS a trade the live run never commanded, "
f"over by {_s47_over:.2f}x. `900/remaining` amplifies a "
f"(ceiling - projected) gap the battery never closed, and the row "
f"carried that number to a commissioning engineer with no caveat",
_s47_over > 2.0)
s47check("...and the amplification is IN THE HEADROOM, which is where the "
f"measurement enters: the shadow guard offers up to "
f"{max(r['headroom_w'] for r in _s47_sc):.0f} W of charging headroom "
f"against a {S45_CAP_W:.0f} W ceiling and a {S45_LOAD_W:.0f} W house, "
f"while the live guard never exceeds "
f"{max(r['headroom_w'] for r in _s47_lc):.0f} W",
max(r["headroom_w"] for r in _s47_sc) > MAX_W
and max(r["headroom_w"] for r in _s47_lc) < MAX_W)
# --- 3d-3b. THE SENTENCE THE ROW PRINTS, GRADED AS ARITHMETIC --------------
# ⚠️ THE ROW CLAIMS THE RECORDED NUMBER IS AN **UPPER BOUND**, and a claim on
# an operator-facing surface is a claim this file has to be able to fail.
# Charging only ADDS offtake, so a live `projected` is never below a shadow
# one and the headroom is never above it. The earlier wording said "smaller",
# which is FALSE on every cycle where the two are equal - the start of each
# quarter, where `900/remaining` is 1.0 and neither plant has moved.
_s47_below = [i for i in range(_s47_n)
if abs(_s47_sc[i]["power_w"]) < abs(_s47_lc[i]["power_w"]) - 1e-9]
_s47_equal = sum(1 for i in range(_s47_n)
if abs(_s47_sc[i]["power_w"]) == abs(_s47_lc[i]["power_w"]))
print(f" the shadow record is below the live command on "
f"{len(_s47_below)}/{_s47_n} cycles and exactly equal on {_s47_equal}")
s47check("the recorded size is an UPPER BOUND on the live one, on every compared "
"cycle - which is the sentence `shadow.SIZED_FROM_METER` puts on the "
"status row, so the row is making a claim this file can fail",
not _s47_below)
s47check("...and it is NOT strictly greater: the two are equal at the start of "
f"each quarter ({_s47_equal} cycles here), which is why the row says "
f"'upper bound' and not 'smaller' - the second would be a false "
f"sentence on exactly those cycles",
_s47_equal > 0)
# ⚠️ AND THE MECHANISM BEHIND THE BOUND, AS TWO POINTS OF THE SHIPPED
# ARITHMETIC RATHER THAN AS ONE PROPERTY OF A WHOLE RUN. Same ceiling, same
# elapsed, same house: one quarter that carried the battery's charge at the
# meter (LIVE) and one that did not (SHADOW). The closed-loop version above
# cannot be driven red by any mutation that ALSO stops the fixture trading -
# measured - so the relation it rests on is graded directly here.
def _s47_headroom(*, meter_w, batt_w, elapsed_s=450.0):
"""`charge_headroom_w()` for a quarter that banked `meter_w` for
`elapsed_s`, with `batt_w` measured at the pack. All shipped."""
avg = QuarterAverager(phases=1, max_hold_s=900.0)
t = 0.0
while t <= elapsed_s:
imp, exp = split_signed(meter_w)
avg.add(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=S45_T0 + timedelta(seconds=t),
ingest_mono=(S45_T0.timestamp() + t)))
t += 5.0
pk = _pg.decide(
PeakTracker.project(avg, S45_T0 + timedelta(seconds=elapsed_s)),
batt_w=batt_w, cap_w=S45_CAP_W, reading=None, achievable_w=MAX_W,
soc=50.0, soc_floor=20.0, max_hold_s=900.0, frozen=False)
return _pg.charge_headroom_w(pk)
_S47_TRADE_W = 300.0
_s47_h_shadow = derived(lambda: _s47_headroom(meter_w=S45_LOAD_W, batt_w=0.0))
_s47_h_live = derived(lambda: _s47_headroom(
meter_w=S45_LOAD_W + _S47_TRADE_W, batt_w=-_S47_TRADE_W))
print(f" half-way through one quarter, {S45_LOAD_W:.0f} W house under a "
f"{S45_CAP_W:.0f} W ceiling: the SHADOW meter (battery at 0 W) offers "
f"{_s47_h_shadow:.0f} W of headroom, the LIVE meter (the same house "
f"plus a {_S47_TRADE_W:.0f} W charge) offers {_s47_h_live:.0f} W")
s47check("the SHADOW meter offers STRICTLY MORE charging headroom than the "
"live one on the same house, same ceiling and same elapsed - which "
"is the whole mechanism, in two points of the shipped arithmetic "
"rather than as a property of a whole run",
_s47_h_shadow > _s47_h_live)
s47check("...and the live answer is the one that lands the block ON the "
"ceiling, so the shadow answer is the one that is wrong about the "
"bill rather than the other way round",
near(_s47_h_live, S45_CAP_W - S45_LOAD_W, 1.0))
# --- 3d-4. WHAT SURVIVES OF THE EXEMPTION, GRADED --------------------------
# The narrowed claim the three documents now make, as a check rather than as a
# sentence: WHICH hour and WHY are unaffected; HOW HARD is not.
s47check("WHICH hour the strategy picked and at WHAT price are identical on "
f"every one of the {_s47_n} compared cycles - the clock and the price "
f"series are not functions of what the battery did, which is the half "
f"of the old exemption that is still true",
all(_s47_lc[i]["price"] == _s47_sc[i]["price"] for i in range(_s47_n)))
s47check("...and so is the spread it had to clear",
all(_s47_lc[i]["spread"] == _s47_sc[i]["spread"]
for i in range(_s47_n)))
# --- 3d-5. THE CLOSED-LOOP DEFECT THE DOCUMENTS SAID WAS VISIBLE -----------
# ⚠️ SAFETY-10's HEADER NAMED SAFETY-45's LIMIT CYCLE AS EXAMPLE #1 OF WHAT A
# SHADOW RUN SURFACES. 3c's own PRECONDITION establishes that the unsized loop
# really does run away; the same stimulus watched from inside shadow mode is
# a settled, uneventful hour.
print(f" SAFETY-45's own defect, unsized: live {S45_BEFORE['onsets']} "
f"onsets/swing {S45_BEFORE['swing']:.0f} W, guard engaged "
f"{S45_BEFORE['engaged']}/{S45_BEFORE['cycles']} -> in shadow mode "
f"{S47_UNSIZED_SHAD['onsets']} onsets/swing "
f"{S47_UNSIZED_SHAD['swing']:.0f} W, guard engaged "
f"{S47_UNSIZED_SHAD['engaged']}/{S47_UNSIZED_SHAD['cycles']}")
s47check("⚠️ SAFETY-45's LIMIT CYCLE IS NOT VISIBLE FROM A SHADOW RUN, and three "
"merged documents said it was. The same hour, the same unsized code: "
f"{S45_BEFORE['onsets']} charge onsets and a "
f"{S45_BEFORE['swing']:.0f} W grid swing live, "
f"{S47_UNSIZED_SHAD['onsets']} onsets and "
f"{S47_UNSIZED_SHAD['swing']:.0f} W in shadow",
S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0
and S47_UNSIZED_SHAD["onsets"] <= 2
and S47_UNSIZED_SHAD["swing"] < 1.0)
s47check("...and neither are the guard ENGAGEMENTS the same documents promised: "
f"{S45_BEFORE['engaged']} engaged cycles live against "
f"{S47_UNSIZED_SHAD['engaged']} in shadow. Every term of "
f"`wanted = batt_w + excess` is MEASURED, and in shadow mode the "
f"battery contributes nothing to either term",
S45_BEFORE["engaged"] > 100 and S47_UNSIZED_SHAD["engaged"] == 0)
# --- 3d-6. THE THREE RUNGS OF `sized_from_meter()`, SEPARATED --------------
# TEST-16's rule: three rungs answer one question, so each is reached with the
# other two clear. Any two of them left standing would mask the third.
_s47_chg = _s47_worst
_s47_sell = derived(lambda: act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER,
headroom=0.0))
s47check("the CHARGE rung: a metered headroom that sized a buy leg answers True, "
"which is the state the caveat exists for",
_arb.sized_from_meter(
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
spread_eur_kwh=1.0, required_eur_kwh=0.1,
price_now_eur_kwh=0.05),
300.0, winner=_arb.SOURCE) is True)
s47check("...the SELL rung: a DISCHARGE answers False however the headroom reads. "
"§9.1 - discharge and export are never capped by the peak guard - so a "
"caveat on a sell would be a FALSE sentence on the status row",
_s47_sell.action == DISCHARGE
and _arb.sized_from_meter(_s47_sell, 300.0, winner=_arb.SOURCE) is False)
s47check("...the UNMEASURED rung: a None headroom answers False. Nothing sized "
"anything from a meter, so there is nothing to disclose - and `decide()` "
"refuses the trade there anyway",
_arb.sized_from_meter(
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
spread_eur_kwh=1.0, required_eur_kwh=0.1,
price_now_eur_kwh=0.05),
None, winner=_arb.SOURCE) is False)
s47check("...the WINNER rung: the same sized charge answers False once something "
"else owns the resolution. `Resolution.target_w` is then somebody "
"else's number and a caveat filed against it would be false",
_arb.sized_from_meter(
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
spread_eur_kwh=1.0, required_eur_kwh=0.1,
price_now_eur_kwh=0.05),
300.0, winner="loop") is False)
# --- 3d-7. THE ROW, AT THE WORST CYCLE -------------------------------------
_s47_row = _s47_chg["row"]
_s47_first_row = _s47_sc[0]["row"]
print(f" worst cycle row: {_s47_row[:230]}")
s47check("the shipped row carries the caveat at the worst cycle: the operator is "
"told the number was sized from a meter with only the house in it",
_shadow.SIZED_FROM_METER in _s47_row)
s47check("...and it still carries the watts, in their own slot, and the standing "
"0 W beside them - the caveat NARROWS the claim rather than deleting "
"the record a shadow run exists to produce",
"WOULD HAVE COMMANDED" in _s47_row and "inverter held at 0 W" in _s47_row
and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "")
s47check("...and the watts are introduced as what it would have commanded ON "
"THIS CYCLE, in every shadow state and not only the caveated ones - no "
"number a shadow run records is a forecast of a live run",
"ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_row
and "ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_first_row)
# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS -----------
# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY.
# Driven as one mutation, deleting the caveat from the log line alone left all
# 29 files green: the only check that could see that line drives a fixture with
# `meter_source: off`, where no arbitrage decision - and therefore no caveat -
# is reachable. The sentence is `shadow.log_line()` now, so it can be graded.
_s47_log = derived(lambda: _shadow.log_line(
would_w=_s47_chg["would_w"], reason="arbitrage -> -2000 W",
sized_from_meter=_s47_chg["sized_from_meter"]))
_s47_log_plain = derived(lambda: _shadow.log_line(
would_w=_s47_chg["would_w"], reason="loop -> -300 W",
sized_from_meter=False))
print(f" action log: {_s47_log[:190]}")
s47check("the ACTION LOG line carries the same caveat the row does - a page that "
"discloses and a 40-row record that does not is the worse half of the "
"two, because the record is what is read after the run",
_shadow.SIZED_FROM_METER in _s47_log)
s47check("...and it is the SAME constant, not a second wording of it, so the two "
"surfaces cannot start disagreeing about which cycles are caveated",
_shadow.SIZED_FROM_METER in _s47_row and _shadow.SIZED_FROM_METER
in _s47_log)
s47check("...and an uncaveated cycle gets the plain sentence, so the caveat is "
"not simply printed on everything",
_shadow.SIZED_FROM_METER not in _s47_log_plain
and "would have commanded" in _s47_log_plain
and "ON THIS CYCLE" in _s47_log_plain)
# --- 3d-8. ⚠️ AND IT IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED ----------
# THE TRAP THIS PART EXISTS TO SPRING. `arbitrage`'s own sentence says "SIZED
# BY THE CAPACITY-TARIFF GUARD" only when the headroom came out BELOW the ask.
# In a shadow run that is the START of the quarter - where the two runs still
# agree to the float. By the end `900/remaining` has multiplied the headroom
# PAST the ask, the trade is NOT capped, the sentence is silent, and the
# recorded number is at its most wrong. A caveat keyed on that sentence would
# be quietest exactly where it is needed loudest.
_S47_CAPPED = "SIZED BY THE CAPACITY-TARIFF GUARD"
print(f" at the worst cycle the shipped trade sentence says "
f"{'' if _S47_CAPPED in _s47_chg['reason'] else 'NOT '}capped; at the "
f"first it says "
f"{'' if _S47_CAPPED in _s47_sc[0]['reason'] else 'NOT '}capped")
s47check("PRECONDITION: the shipped 'capped' sentence IS present at the first "
"charging cycle, where the shadow number is RIGHT - so the inverse "
"below is a real inversion and not an absence everywhere",
_S47_CAPPED in _s47_sc[0]["reason"])
s47check("⚠️ ...and ABSENT at the worst cycle, where the shadow number is "
f"{_s47_over:.2f}x out. A caveat keyed on `capped` would fire only "
f"where the record is accurate and go silent where it is multiples "
f"wrong; `sized_from_meter()` is keyed on the headroom having been "
f"MEASURED, so it fires on both",
_S47_CAPPED not in _s47_chg["reason"]
and _s47_chg["sized_from_meter"] is True
and _s47_sc[0]["sized_from_meter"] is True)
# --- 3d-9. main.py's CALL SITES, PINNED ------------------------------------
# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (habit 15), same as 3b's. Nothing
# behavioural in this file can see main.py render the row with a hard-coded
# False, because every check above calls `shadow.words()` itself.
_s47_words = [n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, ast.Call)
and ast.unparse(n.func).endswith("shadow.words")]
_s47_flag = [ast.unparse(k.value) for c in _s47_words for k in c.keywords
if k.arg == "sized_from_meter"]
_s47_flag_src = _s45_assigned("shadow_sized_from_meter",
clear=("None", "False"))
print(f" main.py: shadow.words(sized_from_meter={_s47_flag}), and "
f"self.shadow_sized_from_meter is assigned from {_s47_flag_src}")
s47check("main.py renders the row from the flag THE CYCLE RECORDED, at its one "
"shadow.words() call site - not from a literal and not re-derived on "
"the status task, where `self.peak_guard` is a different cycle's",
_s47_flag == ["self.shadow_sized_from_meter"])
s47check("...and that flag comes from the SHIPPED predicate with all three of its "
"arguments - the decision, the headroom the trade was sized from, and "
"the arbiter's winner - so a mutation to `False`, to a literal or to a "
"two-argument transcription is red rather than silent",
_s47_flag_src == ["arbitrage.sized_from_meter("
"self.arb, self.arb_headroom_w, "
"winner=resolution.winner)"])
_s47_logcalls = [n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, ast.Call)
and ast.unparse(n.func).endswith("shadow.log_line")]
_s47_logflag = [ast.unparse(k.value) for c in _s47_logcalls for k in c.keywords
if k.arg == "sized_from_meter"]
print(f" main.py: shadow.log_line(sized_from_meter={_s47_logflag})")
s47check("...and the ACTION LOG line comes from the shipped sentence with the "
"same flag, at main.py's one call site - not re-assembled as an "
"f-string where no check can reach it, which is how it survived a whole "
"mutation battery once already",
_s47_logflag == ["self.shadow_sized_from_meter"])
# ⚠️ AND THE PARAMETER HAS NO DEFAULT. TEST-14's rule, and this card is the
# reason it matters here: a caller that FORGOT the caveat and a caller that
# DECIDED it did not apply would render the identical row.
_s47_sig = inspect.signature(_shadow.words)
_s47_logsig = inspect.signature(_shadow.log_line)
s47check("`shadow.words()` and `shadow.log_line()` BOTH require "
"`sized_from_meter` - no default on either, so a caller cannot omit the "
"caveat by accident and get the un-caveated surface",
"sized_from_meter" in _s47_sig.parameters
and _s47_sig.parameters["sized_from_meter"].default
is inspect.Parameter.empty
and "sized_from_meter" in _s47_logsig.parameters
and _s47_logsig.parameters["sized_from_meter"].default
is inspect.Parameter.empty)
# --------------------------------------------------------------------------- #
print()
print("4. §8.3 - no irreversible action on a provisional price")
@@ -822,7 +1669,8 @@ _ticks = []
for _t in range(24 * 120): # 24 h at the 30 s control cadence
_d = decide(_flap_plan, D0 + timedelta(seconds=30 * _t),
peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
_ticks.append(_d.action)
_transitions = sum(1 for a, b in zip(_ticks, _ticks[1:]) if a != b)
@@ -844,7 +1692,8 @@ _no_plan = act(None, CHEAP_H, thresh=_shipped)
_no_soc = act(PLAN, CHEAP_H, thresh=_shipped, soc=None)
_uncovered = decide(PLAN, D0 + timedelta(days=9), peak_guard_active=False,
soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
_reasons = [_no_plan.reason, _no_soc.reason, _uncovered.reason, _blind.reason,
_held.reason]
@@ -1218,7 +2067,8 @@ def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
d = decide(plan, p.start + timedelta(minutes=30),
peak_guard_active=False, soc=soc,
min_spread_eur_kwh=threshold, uncertainty=unc,
charge_w=MAX_W, discharge_w=MAX_W,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
if d.action is None:
continue
@@ -1952,10 +2802,12 @@ check("...and the same at the sell probe, against the verdict's dearest",
_pa_sell is derived(lambda: _qv.dearest))
_qbuy = decide(QPLAN, qprobe(Q_CHEAP_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
_qsell = decide(QPLAN, qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
check("...so decide() CHARGES in the cheapest quarter and DISCHARGES in the "
@@ -1980,7 +2832,8 @@ check(f"the off-peak probe's own price differs from its hour's :00 price "
and _QOFF_I not in (Q_CHEAP_I, Q_DEAR_I))
_qoff = decide(QPLAN, qprobe(_QOFF_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
check("a quarter that is neither extremum still idles, for the reason that "
"names the right rule, and reports ITS OWN price - not the price of the "
@@ -2007,7 +2860,8 @@ check(f"the negative day's dearest quarter is slot {Q_DEAR_I} and it is BELOW "
NO_WEATHER))
_qneg_sell = decide(QNEG_PLAN, qprobe(Q_DEAR_I), peak_guard_active=False,
soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER, charge_w=MAX_W, discharge_w=MAX_W,
uncertainty=NO_WEATHER, charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
check("selling into a negative-price QUARTER is refused, and the reason names "
"§17 and THAT QUARTER's price",
@@ -2020,7 +2874,8 @@ check("...while the SAME quarter-hourly series with that ONE price's SIGN "
"FLIPPED discharges instead",
decide(qplan_of(QPOS), qprobe(Q_DEAR_I), peak_guard_active=False,
soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == DISCHARGE
and sum(1 for a, b in zip(QNEG, QPOS) if a != b) == 1)
# §8.3's flag, read off the covering point. `_act`'s own provisional guard
@@ -2077,11 +2932,13 @@ check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
"their own prices",
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == CHARGE
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == DISCHARGE)
# An instant inside a CARRIED hour of the same series. The covering point is
# the carried one; the shipped scan answered a point from the FIRM block
@@ -2096,7 +2953,8 @@ check("an instant inside a CARRIED hour of the mixed series matches the "
and MIX_PTS[MIX_N_H - 1].provisional is True
and decide(MIX_PLAN, _mix_carried_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action is None)
print()
+444 -3
View File
@@ -74,6 +74,7 @@ Read MUTATION.md before you report a survivor.
import asyncio
import io
import math
import re
import sys
from datetime import datetime, timedelta, timezone
@@ -87,6 +88,7 @@ from app.arbiter import P_LOOP, Claim, UnitBounds, allocate, resolve
from app.control import Tuning, compute
from app.inverter import Inverter
from app.main import Controller
from app import shadow
fails = []
T0 = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
@@ -670,8 +672,15 @@ def arm(c, value):
async def _direct():
hass = _Hass(0.0)
# ⚠️ SAFETY-10: `shadow_mode: "off"` EXPLICITLY, BECAUSE THIS FIXTURE
# NEVER PRESSES START. The shipped default is `auto`, which is SHADOW
# MODE while control is stopped - so without this line the probe below
# would be graded against a controller that is correctly refusing to
# command anything, and the clamp it is here to exercise would never be
# reached. The shipped write path is unaffected: a stopped controller
# only ever writes 0 W in production, which shadow mode also writes.
c = Controller({"setpoint_entity": "number.sp", "max_w": 2000,
"meter_source": "off"},
"meter_source": "off", "shadow_mode": "off"},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
# The shape the heartbeat, shutdown() and handle_action() all use - each one
@@ -1121,10 +1130,16 @@ A_UNIT, B_HYPO = "inverter-a", "inverter-b"
def fresh(**over):
"""A Controller with a stub HA, no cycle run, and therefore NO record."""
"""A Controller with a stub HA, no cycle run, and therefore NO record.
⚠️ SAFETY-10: `shadow_mode: "off"` for `_direct()`'s reason - this fixture
never presses Start, and the shipped default is shadow mode while control
is stopped. Part 7 grades who is allowed to reach the WIRE; a controller
that is correctly commanding nothing cannot answer that question.
"""
hass = _Hass(0.0)
c = Controller(dict({"setpoint_entity": "number.sp", "max_w": 2000,
"meter_source": "off"}, **over),
"meter_source": "off", "shadow_mode": "off"}, **over),
hass, _Store(), _Pub(), stop=asyncio.Event())
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
return hass, c
@@ -1414,6 +1429,432 @@ check("...and the numbers those cycles put on the wire are the arbiter's own "
hass_r.writes and all(abs(w) <= LIVE_MAX_W for w in hass_r.writes))
# --------------------------------------------------------------------------- #
# 8. SAFETY-10 - SHADOW MODE: THE DECISION PATH RUNS AND NOTHING REACHES THE
# INVERTER
# --------------------------------------------------------------------------- #
# This part belongs in THIS file and not in a new one: shadow mode is a
# substitution inside `_write_unit()`, one line below SAFETY-03's clamp and two
# below SAFETY-28's gate, and the property it claims - "nothing reaches the
# inverter" - is the same property parts 5 and 7 already grade from the other
# side. The fake `set_number()` in this file IS the wire.
#
# ⚠️ EVERY CHECK BELOW IS GRADED ON THE WRITES OR ON THE RENDERED SURFACE, NOT
# ON `Controller.shadow`. A check that asserted the flag would pass on a build
# in which the flag is set and the substitution is deleted, which is precisely
# the build this section exists to refuse.
#
# ⚠️ AND EVERY ONE OF THEM RUNS THE SAME STIMULUS IN BOTH MODES. MUTATION.md's
# rule: a per-thing check that has never seen the things DISAGREE is a total.
# `_s10_live` and `_s10_shadow` are the same 6000 W import through the same
# `Controller.cycle()`; the only difference is one option.
print("\n8. SAFETY-10 - shadow mode\n")
def _s10(shadow_mode, *, auto=True, cycles=25, grid=6000.0, dev=None, **over):
"""The same wired run as part 3, with `shadow_mode` and the start button.
⚠️ `auto` IS A PARAMETER BECAUSE `shadow_mode: auto` READS IT. The three
values and the two button positions are six states and they are not all
the same state; the pure-function table over all six is in
`test_validate.py`, and 8f says why it is not here.
"""
async def go():
hass = _Hass(grid, track=True)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": shadow_mode},
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = auto
# `dev` narrows what the DEVICE says it will accept, which is the
# clamp's second stage - the only way to make the clamp engage on a
# target the loop has already bounded at `max_w`.
c.dev_min, c.dev_max = ((-LIVE_MAX_W, LIVE_MAX_W) if dev is None
else (-dev, dev))
# ⚠️ CAPTURED PER CYCLE, BECAUSE THE HEARTBEAT OVERWRITES IT.
# `clamp_last` is assigned by every write, and the heartbeat below
# writes `inv.target` - 0 W in shadow mode - so the clamp's verdict on
# the DECISION is gone by the end of the run. Looking for it afterwards
# would grade the refresh instead of the cycle.
c.clamp_seen = []
for n in range(cycles):
await c.read_inputs()
await c.cycle()
if c.clamp_last is not None:
c.clamp_seen.append(c.clamp_last)
# ⚠️ THE HEARTBEAT IS DRIVEN HERE AND NOT LEFT OUT, BECAUSE IT IS
# THE HALF THAT PROVES SHADOW MODE IS NOT SILENCE. `_write_unit()`
# skips an unchanged value unless `force` is set, so a cycles-only
# fixture sees exactly ONE 0 W write and could not tell "holds the
# inverter at 0 W" from "wrote 0 W once and went quiet" - and the
# second of those leaves a real install's ESP32 in a tripped
# stale-input watchdog for the whole run. `heartbeat_s=0` makes
# every tick due, which is the shipped path with its clock wound
# forward rather than a second implementation of it.
await c.send_heartbeats(float(n), 0.0)
return hass, c
return asyncio.run(go())
_live_h, _live_c = _s10("off")
_shad_h, _shad_c = _s10("on")
# --- 8a. THE PRECONDITION, WHICH IS THE HALF THAT MAKES THE REST MEAN ANYTHING
# MUTATION.md habit 4: a check whose DATA cannot reach the failure it names is
# decoration. "No non-zero value was written" is trivially true of a stimulus
# that would not have produced one, so the same stimulus is first shown to
# produce a large one with the option off.
check("PRECONDITION: with shadow_mode off this stimulus really does drive the "
f"wire - {len(_live_h.writes)} writes, "
f"{len([w for w in _live_h.writes if w != 0.0])} of them non-zero, "
f"largest {max((abs(w) for w in _live_h.writes), default=0):.0f} W",
len([w for w in _live_h.writes if w != 0.0]) >= 10
and max((abs(w) for w in _live_h.writes), default=0) > 1000.0
and 0.0 not in _live_h.writes)
# --- 8b. NOTHING THE DECISION PATH PRODUCED REACHES THE INVERTER -------------
check("AC 1: in shadow mode EVERY value that reached set_number() is 0 W. "
f"Distinct values written: {sorted(set(_shad_h.writes))}",
_shad_h.writes and set(_shad_h.writes) == {0.0})
# ⚠️ AND IT IS NOT SILENCE. The inverter holds its last command forever and the
# ESP32's stale-input watchdog needs a refresh; a shadow mode that stopped
# writing would leave a standing command up under a page saying nothing is
# commanded. A check that only asserted "no non-zero write" would pass on that
# build too - this is the half that separates them.
check("...and it WROTE that 0 W rather than going silent, which is what keeps "
f"the inverter from holding its last command ({len(_shad_h.writes)} "
"writes)",
len(_shad_h.writes) >= 2)
# --- 8c. THE DECISION PATH ACTUALLY RAN ------------------------------------
# "Nothing reached the inverter" is also true of a controller that decided
# nothing. Both halves, or the feature is an off switch with a long name.
check("the full decision path still ran: the loop reached a target and the "
f"arbiter resolved one ({_shad_c.loop_w!r} W, "
f"would have commanded {_shad_c.shadow_would_w!r} W)",
_shad_c.loop_w is not None and abs(_shad_c.loop_w) > 500.0
and _shad_c.shadow_would_w is not None
and abs(_shad_c.shadow_would_w) > 500.0)
check("...and the decision it recorded is a number a live controller would "
"have written - it is inside the rail and it is not the 0 W that went "
"on the wire",
_shad_c.shadow_would_w != 0.0
and abs(_shad_c.shadow_would_w) <= LIVE_MAX_W)
# --- 8d. THE CARRIED COMMAND TRACKS THE WIRE, WHICH IS WHAT MAKES THE EXIT
# SAFE
check("the carried site command is what is STANDING ON THE WIRE, not the "
f"decision ({_shad_c.target!r} W standing, "
f"{_shad_c.shadow_would_w!r} W decided)",
_shad_c.target == 0.0 and _shad_c.shadow_would_w != 0.0)
check("...and every unit's share is too, so SAFETY-42's delivery watchdog is "
"not handed a share nothing was told to produce",
all(i.target == 0.0 for i in _shad_c.inverters))
def _s10_exit(shadow_first):
"""Run twelve cycles, force shadow mode off, take ONE more cycle.
Answers with `inv.last_write` either side of that boundary - WHAT STANDS ON
THE WIRE, updated only on an accepted write, which is the number the
inverter is actually latched on. Reading `hass.writes` instead would be
blind to the converged case, where the controller correctly re-sends
nothing at all.
⚠️ THE COMPARISON IS AGAINST THE SAME RUN THAT NEVER ENTERED SHADOW MODE,
not against a constant. `slew_w` bounds a step from the standing command,
so "did it resume from the wire or from a fiction" is a question about a
DIFFERENCE, and a hard-coded expectation would be the number the code
produces compared with itself.
"""
async def go():
hass = _Hass(6000.0, track=True)
c = Controller(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": "on" if shadow_first else "off"},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
for _ in range(12):
await c.read_inputs()
await c.cycle()
before = c.inverters[0].last_write
c.o["shadow_mode"] = "off"
await c.read_inputs()
await c.cycle()
# ⚠️ NOT `float()`. `last_write` is None until something is ACCEPTED,
# and a build in which shadow mode writes nothing at all would turn
# this fixture into a TypeError - which MUTATION.md's FCST-06 section
# is about: a traceback grades none of the checks after it AND reads
# like a survivor. It is handed back as None and named below instead.
return before, c.inverters[0].last_write, c
return asyncio.run(go())
_exit_before, _exit_after, _exit_c = _s10_exit(True)
_never_before, _never_after, _never_c = _s10_exit(False)
print(f" shadow run: standing {_exit_before!r} W -> {_exit_after!r} W on "
"the first live cycle")
print(f" never in shadow: standing {_never_before!r} W -> "
f"{_never_after!r} W on the same cycle")
check("SOMETHING REACHED THE WIRE IN SHADOW MODE. `last_write` is set only on "
"an ACCEPTED write, so None here is the build that stops writing "
"altogether and leaves the inverter latched on its last command - the "
"failure shadow mode must not be a new way to reach",
_exit_before is not None and _exit_after is not None
and _never_before is not None and _never_after is not None)
_exit_before = -1.0 if _exit_before is None else float(_exit_before)
_exit_after = -1.0 if _exit_after is None else float(_exit_after)
_never_before = 0.0 if _never_before is None else float(_never_before)
_never_after = 0.0 if _never_after is None else float(_never_after)
check("PRECONDITION: the two arrangements DISAGREE - the run that was never in "
"shadow mode is standing somewhere else entirely at that boundary, so "
"the comparison below is not a number against itself",
_never_before != _exit_before and abs(_never_before) > 1000.0)
check("PRECONDITION: and the shadow run really was standing at 0 W, so the "
"step below is measured from the wire and not from a coincidence",
_exit_before == 0.0)
check("AC 2, leaving: the first live command after shadow mode is one slew "
"step from the 0 W that was actually standing on the wire - NOT one step "
"from the decision shadow mode reached, which never existed at the "
f"battery. |{_exit_after:.0f}| <= slew 1000 W",
abs(_exit_after) <= 1000.0)
check("...and the run that was never in shadow mode is NOT inside that bound "
"at the same cycle, which is what says the bound is a real constraint "
"here and not a property of the stimulus",
abs(_never_after) > 1000.0)
def _s10_enter():
"""Live first, then shadow mode, on a battery standing somewhere else."""
async def go():
hass = _Hass(6000.0, track=True)
c = Controller(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": "off"}, hass, _Store(), _Pub(),
stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
for _ in range(12):
await c.read_inputs()
await c.cycle()
standing = hass.writes[-1]
c.o["shadow_mode"] = "on"
await c.read_inputs()
await c.cycle()
# ⚠️ `inverters[0].last_write`, NOT `hass.writes[-1]`. The question is
# WHAT THE INVERTER IS LATCHED ON after the first shadow cycle, and a
# build that writes NOTHING leaves it latched on the live value while
# `hass.writes[-1]` still reads that same number - the two look
# identical from the write list and are opposite at the battery.
return standing, float(c.inverters[0].last_write), c
return asyncio.run(go())
_stood, _first_shadow, _enter_c = _s10_enter()
print(f" standing when shadow mode came on: {_stood:.0f} W")
check("PRECONDITION: the battery was standing at a real, non-zero command when "
"shadow mode was switched on - otherwise 'it reached 0 W' says nothing",
abs(_stood) > 500.0)
check("AC 2, entering: after ONE shadow cycle the inverter is latched on 0 W, "
"not on what it was doing a cycle earlier. The inverter holds its last "
"command FOREVER, so a shadow mode that merely stopped writing would "
"leave that command standing under a page saying nothing is commanded "
f"({_stood:+.0f} W -> {_first_shadow:+.0f} W)",
_first_shadow == 0.0)
# --- 8e. THE OTHER GUARDS ON THE WRITE PATH STILL OBSERVE --------------------
# The substitution is the LAST thing before the wire, below SAFETY-28's gate and
# below SAFETY-03's clamp, so both still grade the number the decision path
# produced. A shadow run that could not say "it would have hit the rating here"
# is answering a different question from the one it is for.
_cl_h, _cl_c = _s10("on", cycles=12, grid=9000.0, dev=400.0)
_cl_req = [r.requested_w for r in _cl_c.clamp_seen]
print(f" decision {_cl_c.shadow_would_w!r} W; what the clamp was asked "
f"about at each cycle boundary: {sorted(set(_cl_req))}")
check("PRECONDITION: the decision is a real, non-zero number and the wire "
"carried only 0 W on this run, so the two are distinguishable",
_cl_c.shadow_would_w is not None and _cl_c.shadow_would_w != 0.0
and set(_cl_h.writes) == {0.0})
check("SAFETY-03 still OBSERVES in shadow mode: the clamp was asked about the "
"number THE DECISION PATH PRODUCED, not about the 0 W that went on the "
"wire. That is what puts the substitution BELOW the clamp, and it is "
"what lets a shadow run report that the rating would have been hit",
_cl_req and all(r == _cl_c.shadow_would_w for r in _cl_req))
check("...and the clamp's own alarm still ran on it - the winding-loop "
"engagement reached the action log on a run where nothing was commanded",
any("clamp" in e.lower() or "CLAMP" in e for e in _cl_c.events))
check("SAFETY-28's gate still OBSERVES too - it refused nothing on this run, "
"which is the same answer it gives live, so shadow mode has not been "
"implemented by making every write unauthorised",
not [e for e in _cl_c.events if "WRITE REFUSED" in e])
# ⚠️ AND SAFETY-18 IS GRADED ON WHAT IT WAS HANDED, WHICH IS AN ORDERING
# CLAIM. `check_meter_frozen()` takes `self.target` - the arbiter's RESOLVED
# INTENT - and `_shadow_settle()` re-seeds that to 0 W at the very end of the
# cycle, AFTER it. Re-seed one line earlier and the freeze detector grades a
# 0 W that no decision produced: its conjunction needs a command at the rail,
# so it goes permanently blind and the whole suite stays green. The detector's
# own `evaluate` is wrapped here because the arguments are the only place that
# ordering is observable.
_fz_seen = []
_fz_real = _shad_c.freeze_wd.evaluate
def _fz_spy(unchanged, target, *a, **k):
_fz_seen.append(target)
return _fz_real(unchanged, target, *a, **k)
_shad_c.freeze_wd.evaluate = _fz_spy
async def _one_more():
await _shad_c.read_inputs()
await _shad_c.cycle()
asyncio.run(_one_more())
_shad_c.freeze_wd.evaluate = _fz_real
print(f" SAFETY-18 was handed: {_fz_seen}")
check("PRECONDITION: the freeze detector was consulted at all on that cycle",
len(_fz_seen) == 1)
check("SAFETY-18 still OBSERVES the DECISION: it is handed the arbiter's "
"resolved intent, not the 0 W the wire gate substituted. Re-seeding one "
"line earlier would hand it a command no decision produced and its "
"conjunction - a command at the rail against a meter that has stopped "
"moving - could never be satisfied again",
_fz_seen and _fz_seen[0] == _shad_c.shadow_would_w
and _fz_seen[0] != shadow.WIRE_W)
# --- 8f. THE MODE TABLE LIVES IN test_validate.py ---------------------------
# `shadow.engaged()` is a pure function of the option and the start button, and
# the six states it answers for are graded beside the rule that validates the
# option - not here, where a defeat of the DEFAULT stops part 3's wired run
# reaching the wire at all and takes this whole file down before part 8 runs.
# ⚠️ THAT IS MEASURED, NOT ANTICIPATED: the mutation that makes `auto` resolve
# to shadow unconditionally aborts part 3 with an IndexError, so its red count
# here is a LOWER BOUND and none of part 8 is graded (MUTATION.md, FCST-06).
# --- 8g. THE OPERATOR STOP, WHICH IS THE ONE FAILSAFE THIS CARD CHANGES ------
_stop_live_h, _stop_live_c = _s10("off", auto=False, cycles=6)
_stop_shad_h, _stop_shad_c = _s10("auto", auto=False, cycles=6)
print(f" stopped, shadow off: {_stop_live_c.reason!r}")
print(f" stopped, shadow on : {_stop_shad_c.reason!r}")
check("PRECONDITION: with shadow mode off, a stopped controller files the "
"P_SAFETY stop claim and the arbiter resolves to THAT source, at 0 W",
_stop_live_c.reason.startswith("safety")
and _stop_live_c.target == 0.0)
check("in shadow mode that claim is NOT filed, so the arbiter resolves to the "
"REAL winner and the recorded decision is the real one. A P_SAFETY claim "
"out-ranks everything - left in, every shadow run would answer the wrong "
f"question ({_stop_shad_c.reason!r})",
_stop_shad_c.reason.startswith("loop")
and _stop_shad_c.shadow_would_w is not None
and abs(_stop_shad_c.shadow_would_w) > 0.0)
check("...and the actuator ends at exactly the same place either way, because "
"the substitution that replaced the claim is STRICTLY STRONGER: a claim "
"can be out-ranked and a wire gate cannot",
set(_stop_shad_h.writes) == {0.0} == set(_stop_live_h.writes))
# --- 8h. THE RENDERED SURFACE (TARIFF-11's pattern) -------------------------
# Graded on `status_payload()`, which IS the status page: web.py's `PAGE` is a
# static shell whose banner and rows are filled from this JSON. Named slots, not
# substring tests, so a transposition moves a slot.
_ROW = "Shadow mode"
_shad_pay = _shad_c.status_payload()
_live_pay = _live_c.status_payload()
_shad_rows = dict(_shad_pay["rows"])
_live_rows = dict(_live_pay["rows"])
print(f" banner: {_shad_pay['banner']!r}")
print(f" row : {_shad_rows.get(_ROW)!r}")
check("the status page has a Shadow mode row in BOTH states, so its ABSENCE is "
"never how an operator learns which one they are in",
_ROW in _shad_rows and _ROW in _live_rows)
check("AC 3: the banner states plainly that the battery is not being "
"controlled",
"SHADOW MODE" in _shad_pay["banner"]
and "NOT being controlled" in _shad_pay["banner"])
check("...and it is not green. A page that reads `ok` while nothing is being "
"commanded is the 'looks healthy but is inert' failure itself",
_shad_pay["level"] == "warn")
check("...and it is FIRST in the cascade: the same controller has other "
"conditions to report and none of them displaces this sentence",
_live_pay["banner"] != _shad_pay["banner"]
and "SHADOW MODE" not in _live_pay["banner"])
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at "
r"([-\d]+) W", _shad_rows[_ROW])
check("the row answers 'what would it have done' with a NUMBER in its own "
"named slot, and says where the inverter actually is in a second one - "
"two slots in one sentence, so a transposition moves them",
_slot is not None)
if _slot is not None:
_would = float(_slot.group(1).replace(" ", ""))
check("...and that number is the decision the path reached, not the 0 W "
f"standing on the wire ({_would:.0f} vs {_shad_c.target:.0f})",
_would == round(float(_shad_c.shadow_would_w))
and _would != _shad_c.target)
check("...and the second slot is the STANDING command, so the two cannot "
"be read as one number",
float(_slot.group(2)) == 0.0)
check("the row answers 'and why' by carrying the arbiter's own sentence, "
"rather than a re-derivation of it",
_shad_c.reason in _shad_rows[_ROW])
check("with shadow mode off the row says so, and says what follows from it - "
"which is the arrangement that makes the one above a claim about this "
"controller and not about the string",
"NOT in shadow mode" in _live_rows[_ROW]
and "DOES reach the inverter" in _live_rows[_ROW]
and "WOULD HAVE COMMANDED" not in _live_rows[_ROW])
# ⚠️ A STANDING LATCH IS NAMED RATHER THAN HIDDEN. The shadow banner sits above
# the three `bad` branches, so it has to carry what they would have said - a
# latch that is up while the controller is inert is up the moment it stops
# being inert.
_shad_c.inverters[0].sign_wd.tripped = True
_latched = _shad_c.status_payload()
print(f" banner with a latch up: {_latched['banner']!r}")
check("a failsafe latch that is standing is NAMED in the shadow banner and "
"turns it red, so nobody turns shadow mode off into a battery that will "
"not move and a page that never mentioned why",
_latched["level"] == "bad"
and "LATCH IS STILL UP" in _latched["banner"]
and _shad_c.inverters[0].name in _latched["banner"])
check("...and it still says the battery is not being controlled, so the latch "
"has not displaced the sentence that may never be displaced",
"NOT being controlled" in _latched["banner"])
_shad_c.inverters[0].sign_wd.tripped = False
# --- 8i. THE RECORD A LATER ANALYSIS READS ----------------------------------
# A shadow run is only worth running if it answers "what would it have done,
# and why" AFTERWARDS, from the 40-row action log, and not only on a page
# somebody happened to be looking at.
_shadow_lines = [e for e in _shad_c.events if "SHADOW" in e]
print(f" action log, shadow lines: {len(_shadow_lines)} of "
f"{len(_shad_c.events)}")
check("the action log records that shadow mode came on, in words",
any("SHADOW MODE ON" in e for e in _shadow_lines))
check("...and what it would have commanded, with the arbiter's reason beside "
"it",
any("would have commanded" in e and "loop" in e for e in _shadow_lines))
check("...EDGE-logged, not level-logged: 25 cycles did not flush the 40-row "
"log that has to carry the context of what the run found",
len(_shadow_lines) < 25 and len(_shad_c.events) <= 40)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")

Some files were not shown because too many files have changed in this diff Show More