safety-96-stale-unit-site-total
511
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 @
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 ( |
||
|
|
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. |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 ( |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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
|
||
|
|
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_01BwRLw7eKYj2byVdwB6kbjUv0.10.0 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
16b1d6c422 | CI-9: a stacked PR is skipped, and a skipped check clears the merge gate (#95) | ||
|
|
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 |
||
|
|
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:
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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,
|
||
|
|
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. |
||
|
|
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
|
||
|
|
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.
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
9c65ac2cf8 |
TARIFF-18: price_taxes_eur_kwh stops shipping at half the real figure
checks / unit (pull_request) Successful in 7m48s
`price_taxes_eur_kwh` shipped at 0.06 EUR/kWh with no recorded provenance -
no card, no date, no component list - and roughly HALF the true Flemish
all-in non-energy figure. It now ships at 0.11736, and the rows it is made
of are in the code rather than in this message:
Fluvius distribution (Antwerpen) 5,35 c EUR/kWh
Bijzondere accijns, "Alle verbruik" 4,876
Bijdrage groene stroom en WKK Vlaanderen 1,51
Energiebijdrage (0 EUR/MWh since 2026-08-01) 0
------
11,736 c EUR/kWh = 0.11736
Eneco residential card for Flanders, September 2026, incl. 6 % VAT.
`app/price.py`'s FLEMISH_TAXES_COMPONENTS_EUR_KWH carries the rows and
test_price.py section 1b re-adds them - and also compares the total against
the card's own figures typed in the test file, so an edit that moved the
rows and the default together is still caught.
WHAT THIS IS NOT. It is not an arbitrage-correctness fix and it cannot be.
This scalar is a LEVEL component: an additive constant cancels exactly in
`max - min` and leaves the argmin where it was. TARIFF-17 shipped that proof
and it is unchanged - test_price.py 16b drives six flat scalars spanning and
exceeding the whole per-DSO range and gets the identical cheapest hour and
the identical spread to the last digit. DOCS and the CHANGELOG say so in
those words.
WHAT IT IS. A display and guard fix:
* every number an operator reads, which at half the true value made the
add-on look considerably better than it is; and
* §17's negative-price guard in arbitrage.py - the one rung that tests an
ABSOLUTE all-in price rather than a difference. It fires when
`spot < -(markup + taxes)`, so its boundary moves -0.09 -> -0.14736
EUR/kWh of spot. test_arbitrage.py section 2c drives both sides through
the shipped decide() and pins the boundary from below as well.
The default is Antwerpen's real row, near the LOW end of the 0.117-0.131
per-DSO band, and that is deliberate: the error directions are not
symmetric. Too high carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal - §17's own case. Too low only
declines a discharge that would have paid.
TARIFF-17's INTERACTION, WHICH HAD TO BE DECIDED. Nothing in that rung
hardcoded 0.06; it read PRICE_OPTION_DEFAULTS, so moving the default moves
what it refuses. The Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`, so a saved blob carries the key whether it was
typed or not - every stored 0.06 became "non-default" with nobody editing
anything, and a working Walloon or Brussels site with a taxes table would
have met SystemExit(78) on the upgrade over an inert number. price.py now
carries SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) and validate.py reads a
superseded default as untouched too. The rung is NOT weakened: 0.1198 beside
a table is still refused by name, and the refusal now names both figures.
config.yaml and PRICE_OPTION_DEFAULTS moved together; CFG-03's tripwire
(test_price.py section 1, test_defaults_pin.py) stayed green by agreeing.
test_price.py section 0's fourth check was REWRITTEN rather than re-pointed:
it used to justify the file's own grading constants by observing that the
shipped pair was degenerate (0.06 is exactly twice 0.03). TARIFF-18 ends
that coincidence, so the sentence is no longer true and is not kept.
29/29 test files green. Mutation ledger on the PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
7796f168a9 |
Merge pull request 'TARIFF-17: a charge that varies by hour gets somewhere to live' (#85) from TARIFF-17 into release/1.0
price.py modelled taxes as one flat scalar. In Flanders that is correct - Fluvius distribution is a single rate - but the Walloon DSOs publish day/night bands on the same card: ORES 13,27 vs 7,39, AIESH 15,17 vs 8,21, ORES IMPACT 16,578 vs 5,090 c EUR/kWh. The bands are ANTI-CORRELATED with what the battery wants to do - cheap network hours coincide with cheap spot hours - so flattening them systematically UNDERSTATES the spread and declines cycles that are genuinely profitable.
The flat scalar was never a wrong number. It sits in the additive bucket and provably cannot change which hour is cheapest; the defect was that a component belonging in the order-changing bucket had nowhere to live. That framing is the ticket, and it is what the regression grades.
Reproduced on one ordinary Belgian winter Thursday, same feed, same markup, taxes the only argument differing: five flat scalars spanning AND exceeding the table's own range - 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 - all give cheapest hour 13 and spread 0.1500 to the last digit, while the ORES table gives hour 03 and 0.1788. Asserted through the shipped decision, static_tariff.arbitrage().cheapest, not through assemble()'s output. Before this existed the shipped tree answered assemble(..., <table>) -> TypeError.
The fixture differs in exactly ONE way and that is its own precondition - same caps object, same RawPoint list, same markup, by identity - and the flat arm is a FAMILY rather than a single scalar, so what the comparison measures cannot be a level effect. That closes the shape that produced a whole-suite survivor in TARIFF-11, where a fixture varied two things at once.
GRAMMAR REUSED, NOT DUPLICATED. parse_taxes_profile() is parse_static_profile() with option= and means= as default arguments, so every pre-existing call site and sentence is byte-identical. Graded two ways: one check parses every module under app/ and asserts the 14 grammar names are defined in exactly one of them, with an AST pin that the wrapper's whole body is one call; another DERIVES all thirteen refusal sentences for the new option from the old option's own sentence. Copying the tiling check into price.py is red.
A WHOLE CARD TYPED IN CENTS IS ITS OWN RUNG, and not a restatement of the parser's plausibility bound. ORES's 13,27 trips that bound - but SIBELGA'S ENTIRE BRUSSELS CARD (9,39 / 7,10) IS UNDER IT and would have started as a 9.39 EUR/kWh levy. Graded by its own reason, and the mutation is red on the Sibelga line only.
Malformed tables are refused at STARTUP by validate.py across three rungs. If one ever gets past, load_prices() returns a section 11.4 no-data read naming the key and logs an error - no silent fall back to the scalar, no traceback into the tick.
Both plugins that need the framework to add taxes move 13 -> 03 (custom_formula {ENERGY,MARKUP} and generic_dayahead {ENERGY}); manual_csv, which is all-in, stays at 13. Enumerated from PROVIDERS rather than typed.
Evidence: 29/29 green. Thirteen mutations, all red, and no row is an edit-and-revert - each gets a fresh rsync'd copy with no __pycache__, mounted :ro, then deleted, which closes habit 18 structurally rather than by discipline. Hoisting the lookup out of assemble()'s loop (+17 B) keeps the number right while stopping it varying by hour: red on six named checks in test_price.py and GREEN IN THE OTHER 28 FILES.
Three things named rather than rounded off: one mutant is equivalent and unseparable by any fixture, because StaticProfile.at() calls brussels() on whatever instant it is given; DOCS' bucket table is prose that nothing asserts, though the numbers it quotes are printed by the checks on every run; and the card's own 0.2240/0.2422 figures were NOT verified and were not repeated - replaced with a claim that is derivable from the contract price, that the spread is proportional to factor and the offset drops out.
One design point is explicitly the owner's, not the agent's: a table beside a NON-DEFAULT price_taxes_eur_kwh is a refusal, so an operator adopting the table must set that key back to 0.06. The alternative - warn, table wins - is the silent-loser shape this repo refuses everywhere else, so the refusal was taken and flagged rather than quietly chosen.
|
||
|
|
2fac0aaf06 |
TARIFF-17: a price component that varies by hour has somewhere to live
checks / unit (pull_request) Successful in 7m25s
`price.py` modelled the whole non-energy part of a Belgian bill as ONE flat scalar. This adds `price_taxes_profile`, which carries the TAXES component as a period table, and makes `assemble()` evaluate it at each point's own instant. ⚠️ THE FLAT SCALAR WAS NOT A WRONG NUMBER, AND THIS IS NOT A CORRECTION OF ONE. The optimiser reads exactly two quantities off a series - the spread `max - min` over the firm points, and which hour is cheapest - and an additive constant identical in every hour cancels out of the first and leaves the second where it was. It is provably inert. Measured, and printed by test_price.py 16b on every run: with a flat taxes figure of 0.00, 0.0739, 0.1198, 0.1327 or 1.00 EUR/kWh the same 24-hour feed is cheapest at 13:00 in all five cases and its spread is 0.1500 EUR/kWh in all five cases. What was missing is anywhere for an HOUR-VARYING non-energy component to live. This is a missing capability, not a bad constant. Belgium has one. Fluvius distribution is a single rate - its tariff-card row reads `Normaal | Uitsl. nacht`, where exclusive night is a separate physical circuit and not a night register - so nothing changes for the reference site in Flanders. The Walloon DSOs and Sibelga are on the same card with four columns, `Enkelvoudig | Dag | Nacht | Uitsl. nacht`, and the gap is real money (c/kWh): ORES 13,27 / 7,39; AIESH 15,17 / 8,21; Tecteo RESA 12,20 / 7,01; Sibelga 9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090. ⚠️ AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO. The cheap network hours are the cheap spot hours, so flattening them does not shift the curve - it UNDERSTATES the spread and declines cycles that would have paid. Same feed, same markup, the only difference being the taxes argument: flat 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 -> cheapest hour 13, spread 0.1500 ORES dag 0.1327 / nacht 0.0739 -> cheapest hour 03, spread 0.1788 The regression check grades WHICH HOUR IS CHEAPEST, not the spread: an implementation that reads the table once and adds one number to every point is the LEVEL bucket wearing a period table - right magnitude, wrong shape - and a spread assertion passes on it. Measured: hoisting the lookup out of `assemble()`'s loop (+17 B) is red on six named checks in test_price.py and GREEN in the other 28 files. ONE GRAMMAR, NOT TWO. `static_tariff.py` already shipped the periods, the day selectors, the tiling rule and the DST handling, so `parse_taxes_profile()` is the same parser with the option name and the "what this number means" clause as arguments. A second tiling rule would be a second set of 23- and 25-hour-day bugs. test_static_tariff.py section 10 pins that two ways: by parsing every module under `app/` and asserting the grammar is defined in exactly one of them, and by deriving each of thirteen refusal sentences for the new option from the old option's own sentence rather than transcribing them. `Component.TAXES` semantics, `code_leaks()` and the double-counting guarantee are untouched. The new key joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived from that set, and a plugin that names it is refused at `register()`. It reaches both plugins that take taxes - `custom_formula` ({ENERGY, MARKUP}) and `generic_dayahead` ({ENERGY}) - and not `manual_csv`, which is all-in: the table is routed through `capabilities().adds`, exactly as the scalar is. ⚠️ A MALFORMED TABLE IS REFUSED AT STARTUP, NOT AT THE NEXT FETCH. It is read on the ~30 s planning path, and a table that first fails at 03:00 is a site that stops pricing at 03:00, which §11.4 turns into a quiet fallback indistinguishable from a healthy fixed-tariff day. validate.py refuses three things: a table that does not tile the day; a table set beside a NON-DEFAULT `price_taxes_eur_kwh`, because the table wins and the scalar would then be a number the operator typed that decides nothing; and a whole card typed in CENTS, which is how these figures are printed. That last rung is not a restatement of the parser's +-10 EUR/kWh bound - ORES's 13,27 trips the parser, but Sibelga's entire card, 9,39 and 7,10, is under it and would otherwise have started as a 9.39 EUR/kWh levy. Both rungs are graded by the REASON each gives, and mutating them together (TEST-16) moves three rows. NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "", and the scalar path is unchanged arithmetically rather than merely in intent: test_price.py 16f reproduces the pre-TARIFF-17 expression and compares 12 capability/scalar combinations point for point with `==`. 13 mutations, every one red on a named check, ledger in test_price.py below section 16k with byte deltas and the one equivalent mutant named. 29/29 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
c518007caf |
Merge pull request 'TARIFF-11: the add-on stops flattering itself' (#84) from TARIFF-11 into release/1.0
Four places where the add-on's account of itself was wrong, every one in the direction that flatters it, and none of them pinned by any check because nothing graded the strings a human reads. TariffPlan.words counted points and called them hours - 192 h for 192 quarter-hourly points, which is 48 hours; measured across a 40-hour soak the error ranged 1.79x to 5.33x with the firm/carried mix, and horizon_h was 48.0 at every sample while words never said 48. The section 8.3 refusal did the same and reached the status page through arb.explain(): 'only 1 of 96 hours carry a published price' for 96 points spanning 24. The plan's shape reached no HTTP surface at all - rows['Tariff'] was static_status()'s own sentence and read 'no static profile' at all 802 soak samples, while the plan was provisional for 76% of them and carried mixed 900/3600 s spacing for 70%. And main.py's 'if and only if' comment was false: cycle() returns on missing inputs before self.arb = None, so a site with trading ON rendered 'off (arbitrage_enabled)' - naming an option that was on. A FIFTH instance was found that the card did not list, in price_forecast.py's '%d of %d hours' - carried hours over points - which DOCS quotes verbatim, so leaving it would have re-broken the doc. THE CHECK IS THE DELIVERABLE, not the fixes. It renders Controller.status_payload()['rows'], parses each number out of its OWN slot, and compares it against a figure computed from the fixture's two construction parameters - never from the property the row was built out of. The slot parser is calibrated first against a correct row AND against a row wrong exactly as the defect was. Fixtures are a SET of four plan shapes and the check computes and prints the table of field pairs no fixture separates. origin and product are graded by disagreement over identical point lists, the Arbitrage row's states by inequality, the end-to-end row against the captured document's own length. The two Tariff sources stay distinguishable: one row, two explicitly timestamped halves, with the age taken from tariff_at - which TARIFF-12 deliberately does not move on a kept plan - and measured by the caller rather than by a second clock inside the renderer. The same plan object at 0 s and 4000 s must render differently, and hardcoding the age to 0 is red. FCST-04's Tariff-to-DOCS pin was honoured rather than routed around: wiring the plan in took test_price_history.py red until DOCS carried the pinned sentence, the row's five facts, the two-clock warning and a four-row table of the Arbitrage renderings. The trap fired exactly as designed. Evidence: 29/29 green, re-verified after merging TARIFF-13. Sixteen mutations, no survivors; one is a zero-byte edit transposing firm and carried, and it is red. TWO MUTATIONS BIT THE AGENT'S OWN CHECKS AND BOTH WERE CLOSED. One was a survivor of the whole suite at -190 B: the origin fixture gave the fallback plan a distinct provider string as well as a distinct origin, so the rows differed for two reasons and dropping origin entirely changed nothing observable - habit 4, occurring inside the fix for habit 4. The provider string is now held equal as its own precondition. The other produced a traceback and no named red, because a datetime comparison raised inside an f-string OPERAND rather than inside the condition - FCST-06's finding, one layer over. Left for its own card, named rather than folded in: TARIFF-09's price_feed_url override warning still predicts the wrong degraded state - on a cold store the unpublished hours are ABSENT, not carried and flagged - and absence is the worse state. |
||
|
|
a11597e0b3 |
Merge remote-tracking branch 'origin/release/1.0' into TARIFF-11
checks / unit (pull_request) Successful in 7m20s
# Conflicts: # goodwe_controller/CHANGELOG.md |
||
|
|
610725bed9 |
TARIFF-11: the plan's account of itself, corrected and put on the page
Four defects of one shape - the add-on's report of its own tariff plan was wrong, always in the direction that flattered it - and one check, which is the deliverable. 1. `TariffPlan.words` counted POINTS and called them HOURS. The live add-on logged `dynamic_dayahead from generic_dayahead, 132 h` for 132 quarter-hourly points = 33 hours. Belgium moved to a 15-minute MTU on 2025-10-01, so this was wrong every time it was printed, and NOT by a constant factor: a 40-hour soak measured 1.79x .. 5.33x, because §8.3's carried points are hourly while the firm ones are quarter-hourly. `words` now reports both quantities, plus the firm/carried split and the spacing, in FCST-04's *Price history* wording so the two rows can be compared. 2. §8.3's firm-pair refusal and the flat-tariff refusal did the same, and they reach an operator through `self.arb.explain()`. This is exactly the point<->hour conflation TARIFF-14 removed from `at()`, left standing in the prose. `price_forecast.py`'s §8.3 carry-forward log line carried the third instance - a carried-HOUR count over a POINT count - and DOCS quotes it verbatim, so it is corrected here too. 3. The plan reached no HTTP surface at all. `rows["Tariff"]` was `static_status()`'s 30 s-tick sentence and never saw the provider plan - it read 'no static profile' at all 802 soak samples on a site whose feed was answering. It now carries the plan's shape. ⚠️ The two sources are kept distinguishable: the plan half is stamped with its own age, measured from `tariff_at`, because it can be up to TARIFF_MAX_AGE_S (5400 s) old. FCST-04 pins that row to DOCS as a pair; DOCS moves in this commit. 4. `main.py`'s "if and only if" was false. `cycle()` returns at the input hold before `self.arb = None`, so an armed site read `off (arbitrage_enabled)` - naming an option that is on - and, after one completed cycle, showed a decision up to 15 s stale as a current one. The row now reads the OPTION and separates three facts; `cycle_at` and `arb_at` make "this cycle reached no decision" an observation. THE DELIVERABLE IS THE CHECK. All four survived 29 green suites because nothing graded the strings a human is shown. `test_price.py` section 12 renders through the shipped `status_payload()`, parses each number out of its OWN SLOT and compares it against a figure computed from the fixture's construction parameters - not from the property the row was built out of (habit 4), and not as a substring of a sentence the same file wrote (habit 14). The fixture set is four plan shapes and the check computes and prints the field pairs no fixture separates. The *Arbitrage* row's states are driven through real cycles with a sensor answering None and graded by INEQUALITY. No behaviour changed: no threshold, refusal rung, claim or setpoint moved. MUTATIONS: 16, no survivors, one at +0 B (the risky delta) and red. Two were found by the battery INSIDE THIS CARD'S OWN CHECK: a -190 B survivor whose fixture varied two things at once (habit 4), and a mutation whose red was a TRACEBACK in an f-string operand and therefore named nothing (FCST-06). Both closed; the table is on the PR. All 29 test files green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
b4a3801f68 |
Merge pull request 'TARIFF-13: the count of what was measured is as durable as the measurement' (#83) from TARIFF-13 into release/1.0
A 40-hour soak drove a restart and caught the capacity-tariff row asserting '0 quarter(s) banked' for a month in which 120 quarters had been measured. The peak and its attributed quarter persisted; the count did not. It is the count, not the peak, that tells an operator how much of the billing month the measurement actually covers - a peak of 365 W over 120 quarters and the same peak over 0 quarters are very different claims about the same number, and after a restart the page made the weaker claim while displaying the stronger one's value. Reproduced through a real Controller and P1Ingest with a real restart, never by reading code: 12 -> 0 with the store still holding 12 rows. Fixed durable rather than removed - the count is the useful half of the row. PeakStore.banked_count(month) queries quarter_blocks and `recorded` became a property over it. After the fix the printed count matches the store on both sides, 12 -> 13, and 13 is CORRECT: the restart's restore() banks the stale open block, which the old process-wide counter never saw. TWO DEFECTS THE TICKET DID NOT NAME, both closed by the same change: the count was never month-scoped, so a fresh October reported a quarter banked from September; and it missed blocks banked by restore(). An unreadable store now returns None and renders 'an unreadable number of quarter(s) banked' - rendering it as 0 was rejected because it reads as a real zero, which is TARIFF-01's own disarming-value argument applied to the coverage figure beside the peak. THE TICKET'S SECOND FINDING WAS FALSE AND THE AGENT DISPROVED IT. I wrote that ties at the same wattage are re-attributed to the most recent matching quarter. The upsert is strictly '>', so an exact tie never writes at all - six exactly-equal 365.0 W quarters re-attributed zero times. The soak's drift was a real new maximum below the row's whole-watt display resolution: 365.0000001 W is a new peak to the database and the same '365 W' to the reader. A plausible diagnosis, checked rather than implemented. Tie attribution is nevertheless decided deliberately - EARLIEST wins - on what the row is for rather than on money: wattage is what is billed, and an operator investigating one event needs a stable answer. It is also what recompute_month() already did, and the two readers genuinely diverged: fed newest-first, monthly_peak answered 09:45 and the recompute 09:00, breaking the invariant part 8 exists to prove. peak_w is untouched and SAFETY-07 reads identical watts. Evidence: 29/29 green. Eleven mutations, all red, purged before each with the fingerprint verified after every revert. One was a REAL SURVIVOR at -3 B: banked_count() has two None paths and the first battery reached only one, because a never-opened handle short-circuits before any SQL. Closed by dropping the table out from under an OPEN store, behind a precondition asserting the same call answered 4 first. main.py is untouched - the row picks the fix up unedited because `recorded` is now a property. The residual is named rather than hidden: `recorded` answers for now() while the peak follows checks(when=...). Production has exactly one call site, self.checks() with no argument, where both are the same month, so the live page is correct and durable; they part only under the suite's when= affordance, and that mismatch is strictly smaller than the process-wide counter it replaces. recorded_in(when) is shipped and the row's move onto it is one token. |