Compare commits

...
55 Commits
Author SHA1 Message Date
Claude Opus 5 17b7b447c4 TARIFF-09: ask the day-ahead feed for the horizon it was handed
checks / unit (pull_request) Successful in 7m10s
`generic_dayahead.fetch()` accepted `since` and `until` and requested the
bare `?bzn=BE`, which answers ONE Brussels day. Everything past today was
then filled from yesterday's profile by FCST-04, flagged provisional, and
§8.3 drops provisional hours from the trade set -- so the optimiser never
saw tomorrow as firm, at any hour of any day, and nothing reported it.

Measured on 4257e38 at 2026-09-10T09:00+02:00, 48 h horizon, live feed,
yesterday seeded through the shipped read path:

    60 firm points (15 whole hours) + 33 carried hours of 48 requested
    carried: 24 h on 2026-09-11, 9 h on 2026-09-12
    arbitrage(0.10): buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
    firm local days = [2026-09-10]

The ceiling was structural: with a one-day window, at hour H a 48 h horizon
is at most 24-H firm and 24+H carried. At 23:00 that is 1 and 47, and the
remaining firm spread (0.0464 EUR/kWh) no longer clears the default 0.10
threshold, so arbitrage is refused outright. FCST-04's fill covers the
window until the ~13:00-14:00 CET auction; it was never meant to cover
every hour of every day.

The data exists and the endpoint serves it: verified 2026-09-10T07:45:49Z,
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns 192 quarter-hours across
two local days. Committed unmodified beside TARIFF-08's captures, with the
URL, retrieval time and sha256 recorded and RE-DERIVED by the replay.

THE RANGE IS DERIVED ON THE BRUSSELS CALENDAR, not the UTC one. 22:30 UTC
in summer is already tomorrow in Belgium, so a UTC-derived start asks for
the day the controller has just left and drops the day it is standing in,
for the last one or two hours of every day. That is `_bdate()`'s lesson one
layer up with a worse consequence: `_bdate()` misfiles an hour, this never
fetches it. `until` is exclusive, so local midnight belongs to the day
before it; an inverted window collapses to one day rather than a backwards
range. Both ends are graded and both are driven red by mutation.

After, same code path and window, a feed serving both days: 39 firm hours
and 9 carried (was 15 and 33), firm across TWO Brussels days, and the
permitted cycle buys on one day and sells on the next.

`price_feed_url` IS THE EXACT URL AND THE PLUGIN APPENDS NOTHING. `start`/
`end` are this feed's spelling and the provider declares a second host
whose parameters are different, so merging would produce a request nobody
typed and the short horizon anyway. The cost is real, so it is not silent:
a WARNING on the shipped logger at fetch time names the consequence and the
start..end that would otherwise have been asked for. `dated_feed_url()`
refuses to append to a base that already carries a query string, so the
blind-append shape cannot come back by edit.
DECISIONS/TARIFF-09-the-range-and-the-override.md argues the alternative.

Before publication nothing regresses and it is graded, not assumed: at
09:46 CEST the two-day request came back byte-identical to the one-day
body, and that stays a `series` of what exists with FCST-04 filling the
rest. A `no_data` there would switch a working site's arbitrage off every
morning -- the mutation that adds that rule is red by name.

The plugin contract is still NOT enforced: three shipped plugins point at
endpoints nobody has verified, and a rule obliging them to pass a range
would be satisfied by an invented parameter name -- TARIFF-08's defect
moved from the response to the request. Which plugins consume the range is
pinned as a literal instead.

11 mutations, all red, no survivors; each applied to a fresh copy of the
tree rather than reverted, through tools/mutation_run.sh. One is +0 B (a
single price digit inside the committed capture) and the re-derived sha256
catches it. MUTATION.md habit 20 records why a fixture could never have
reached this: a captured body is the answer to a URL, and no property of a
body says which URL was asked for. All 29 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 10:35:12 +02:00
admin 4257e38e5a Merge pull request 'TARIFF-08: read the feed the add-on actually ships pointed at' (#76) from TARIFF-08 into release/1.0
_points_from_json() expected doc['data'] to be a list of {start, price_eur_mwh}. api.energy-charts.info/price?bzn=BE returns a columnar document - parallel unix_seconds and price arrays - so the shipped parser raised SourceUnavailable('the feed carried no data list') against every real response. generic_dayahead had never parsed one. With the three fabricated supplier URLs of TARIFF-07, that left manual_csv as the only working provider in a five-provider table.

It survived to release because every fixture fed the invented shape: test_price.py, test_price_history.py and test_static_tariff.py all built {'data':[{'start':...,'price_eur_mwh':...}]}, so the suite validated the parser against the parser's own assumption - a closed loop that cannot notice the assumption is wrong. Section 11.4 then made the failure silent: a parse error degrades to the static profile, which is correct and says nothing about the cause.

Verified live 2026-09-10, end to end: fetch -> parse -> assemble() -> TariffPlan returns 96 quarter-hourly points at 900 s, origin provider, product DAY_AHEAD, arbitrage allowed with a firm spread of 0.4645 EUR/kWh. First time the pipeline has completed against a real feed. Belgium moved to a 15-minute MTU on 2025-10-01, so FCST-04's H1 fix - the fill was decimating sub-hourly points to one per hour - was never theoretical; it is the shape of the live feed, and it is what makes this deliverable.

The columnar shape is read by a SECOND function, not a sniff, and feeding the row shape to generic_dayahead is now a graded no_data so the sniff cannot return quietly. Equal length refused in both directions with both lengths in the reason; the unit refused if not EUR/MWh, because MAX_PLAUSIBLE_EUR_KWH only bounds prices that are too large and a EUR/kWh switch would otherwise be invisible; nulls dropped with a count, an all-null body named as no_data; a deprecated feed warns while it still works.

Two real bodies captured as fixtures - unmodified bytes, with URL, retrieval time, unit, licence-as-returned and sha256 recorded, and the checksums RE-DERIVED by the replay rather than asserted. The invented fixtures are renamed synthetic_row_feed at every call site so the closed loop cannot re-form. Only one of four network providers has a capture, and a registry-derived check says so in its own sentence: the other three have nothing to capture, which is TARIFF-07.

Evidence: 29/29 green. 12 mutations, no survivors, purged tree and -B per TEST-15. One first-draft line was decoration and the mutation said so - the parser called brussels() per instant while assemble() already renders, so removing it changed nothing. The line was deleted rather than fenced with a check, and the mutation re-aimed at assemble() where the rule lives. MUTATION.md habit 19.

Follow-up filed as TARIFF-09: generic_dayahead ignores the since/until it is handed, so the feed supplies 24 h of a 48 h horizon and tomorrow is never firm.
2026-09-10 09:14:50 +02:00
adminandClaude Opus 5 7a5281e97f Merge release/1.0 (FCST-06) into TARIFF-08
checks / unit (pull_request) Successful in 7m10s
One conflict, in CHANGELOG.md's `## Unreleased` block, exactly as expected
from two branches landing beside each other: both `_this branch_` table rows
and both sections are kept, FCST-06's first. Nothing else conflicted -
FCST-06's `test_price_history.py` section 5d and this branch's columnar
`feed_body()` merged cleanly - and all 29 test files are green on the result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 09:06:39 +02:00
adminandClaude Opus 5 6eba18968c TARIFF-08: the day-ahead feed is read in the shape it actually publishes
checks / unit (pull_request) Failing after 3s
`generic_dayahead` could not read one byte of `api.energy-charts.info`, and
never had been able to. `_points_from_json()` asks for `doc["data"]`, a list of
`{start, price_eur_mwh}` objects; that document does not exist. Fetched live on
2026-09-10 the feed returns COLUMNAR parallel arrays and no `data` key at all -
`unix_seconds` (96 UTC epoch seconds, 900 s apart), `price` (96 floats),
`unit: 'EUR / MWh'`. Driving the shipped parser against that body:
`SourceUnavailable: the feed carried no 'data' list`. It is the only shipped
provider whose endpoint anybody has reached, so dynamic pricing did not work.

AND §11.4 MADE IT SILENT, which is why it survived to release: a parse error
becomes no_data, becomes the static profile, becomes arbitrage off - the
CORRECT degradation, saying nothing about the cause. A site would have sat on
the fallback indefinitely.

AND THE SUITE COULD NOT SEE IT, because every fixture was built from the
parser's own assumption. test_price.py, test_price_history.py and
test_static_tariff.py each built `json.dumps({"data": [...]})`, a shape invented
here and published by nobody, so the checks graded the parser against the
parser's guess - a closed loop no coverage inside it could ever have escaped.
MUTATION.md habit 19 records it as the fourteenth instance of habit 4 and the
first where the fixture and the code under test shared a SHAPE, not a number.

WHAT CHANGED

* `_points_from_columnar_json()` - a SECOND function, not a sniff. A parser that
  decides which document it received is one that can decide wrong, and a
  wrong-but-parseable response then becomes a price. Feeding the row shape to
  `generic_dayahead` is now a graded no_data.
* Unequal arrays are refused in both directions, with the two lengths in the
  reason. `zip()` stops at the shorter list silently, and a truncated `price`
  array would have produced a real series missing the evening peak - traded on
  as complete, where no data at all switches arbitrage off.
* A unit that is not EUR/MWh is refused. The plugin converts with a fixed
  0.001, and `MAX_PLAUSIBLE_EUR_KWH` only bounds prices that are too LARGE.
* `unix_seconds` are read as UTC instants explicitly; §9.3's Brussels rendering
  stays in `assemble()` rather than being copied into the plugin, where a
  mutation could not tell it from a no-op (M4 proved exactly that on a first
  draft, and the line was removed rather than fenced).
* A published null is dropped and logged with a count; an all-null body is
  no_data named by this parser rather than by the boundary validator.

FIXTURES

Two unmodified response bodies under `scenarios/`, captured by hand on
2026-09-10: quarter-hourly (2026-09-10, 96 points at 900 s) and hourly
(2025-09-10, 24 at 3600 s - the same endpoint before Belgium's 15-minute MTU).
`energy-charts-be-price-captures.json` records the URL, retrieval time, unit,
licence-as-returned and a sha256 of each, and the replay RE-DERIVES the
checksums, so a fixture edited to make a check pass stops being a capture. Data
CC BY 4.0, Bundesnetzagentur | SMARD.de, via Energy-Charts (Fraunhofer ISE),
published without changes - the same BE-zone licence check DATA-02 established.

The surviving invented fixtures are renamed `synthetic_row_feed` at every call
site. `engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy` are untouched
and have no capture: their URLs are unverified and withdrawing them is
TARIFF-07's open decision. Nothing should read their green checks as evidence
that those feeds parse.

MUTATIONS - 12, all red, no survivors. Purged tree, mounted `:ro`, `python3 -B`
(TEST-15). M1 drop the equal-length refusal; M2 make it one-sided; M3 drop the
unit check; M4 `assemble()` stops rendering at the Brussels offset; M5 read
`unix_seconds` as a naive local clock; M6 zero-fill a null instead of dropping
it; M7 drop the all-null refusal; M8 sniff for the row shape; M9 drop the
deprecated warning; M10 drop the dropped-nulls warning; M11 edit one price in
the committed capture; M12 point `generic_dayahead` back at the row parser -
red across all three price suites.

END TO END, LIVE, 2026-09-10: `resolve_tariff()` returns a `TariffPlan`,
`origin='provider'`, 96 quarter-hourly points, spacing [900] s, no decimation,
first point 2026-09-10T00:00:00+02:00 at 0.30608 EUR/kWh all-in. FCST-04's H1
fix was never theoretical. All 29 test files green.

STILL OPEN: `generic_dayahead` asks for no date range, so the default URL
returns one published day - a full 48 h horizon is reached only through §8.3's
carry-forward fill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 08:57:32 +02:00
admin 958f5cd218 Merge pull request 'FCST-06: cannot-persist is one question, asked after the attempt' (#75) from FCST-06 into release/1.0
The module claimed 'a store that cannot persist records nothing' and enforced it against `not self.ok` only. A store with ok=True whose _save() fails - a full volume, a read-only remount - kept answering yesterday() from memory until the next restart, which is the silently-forgetting cache the docstring rejects, reached by the condition the rule did not name.

Reproduced against real volumes rather than a monkeypatch: /data bind-mounted read-only (EROFS) and a full tmpfs (ENOSPC), with a healthy-/data control asserted either side. Before: yesterday() returned a 24-point series while the disk held {}.

Fixed in the code, not the docstring - weakening the doc would have documented a cache that forgets without saying so as intended.

The design answer is the part worth keeping: 'can this store persist' CANNOT be answered before the attempt, because a directory that was writable a moment ago is not a write that completed, so os.access or a probe write is a third symptom test wearing a predicate's name. It is answered after, once: _save() -> bool, true only if the file now holds what memory holds. The `not self.ok` test in _remember() is GONE rather than joined by a second one, and a check asserts its absence from the source. This is the same shape as FCST-04's M4 and FCST-05's HIGH - a rule enforced on the conditions someone thought of - and it is the first of the three to be closed by removing a test rather than adding one.

What is undone is the MERGE, not the store: a volume that fills at 14:00 leaves the yesterday written at 13:30 still answering. The snapshot is a deepcopy - measured, a shallow copy let a store holding 12 persisted points answer 24.

Evidence: 29/29 green. Six mutations, no survivors, purged tree each run with the mutated line greped off disk. One of them exposed an instrument defect now recorded in MUTATION.md: it printed 1 FAIL when the true figure was 3, because an f-string operand called len(read.points) and .points on a no_data raises Absence by design, killing the harness mid-section. One check earlier and it would have read as a survivor. A mutation's red count is a lower bound until the run is seen to reach the end of its section.
2026-09-10 08:52:09 +02:00
adminandClaude Opus 5 c45893ffad FCST-06: "cannot persist" becomes one question, asked once, about the outcome
checks / unit (pull_request) Successful in 7m14s
price_history.py's docstring states its rule unconditionally - "A store that
cannot persist now records nothing, and both of its answers say the same
thing" - and FCST-04 enforced it against `not self.ok` only, which is one
SYMPTOM: the durable volume is missing. `ok` is about the DIRECTORY, and a
read-only remount and a full disk both leave it True. Reproduced on 5059e28
against two real volumes, a /data bind-mounted read-only and a full tmpfs:

    last_error = 'could not write ([Errno 30] Read-only file system)'
    words      = '2026-01-14 (24 points over 24 h); 2026-01-15 (24 points
                  over 24 h) [could not write (...)]'
    yesterday()= series, 24 points
    ON DISK    = {}

`words` and `yesterday()` were describing two different stores, and the 24
points were gone on the next restart with nothing saying so - the
silently-forgetting cache that rule exists to refuse, reached by the condition
it did not name. No shipped path reaches it: /data is the Supervisor's durable
volume and this module is its only writer. The defect is that a guarantee the
module states about itself did not hold in one condition.

Route taken: `_remember()` refuses to retain what it could not write, and the
docstring's claim stands as written. It is now ONE derived predicate rather
than two places testing two symptoms. "Can this store persist?" cannot be
answered BEFORE the attempt - a directory that existed and was writable a
moment ago is not a write that completed - so it is answered after it, once,
by the only thing holding the evidence: `_save()` returns whether the file now
holds what memory holds. No volume is one False, an OSError is another.
`_remember()` keeps only what that one answer says was kept, and the
`not self.ok` test in `_remember()` is GONE rather than joined by a second one.
FCST-04's M4 and FCST-05's HIGH were the same shape, so a third cause now needs
no third branch.

What is undone is the MERGE, not the store. The state restored is the last one
that IS on disk, so a site whose volume fills at 14:00 goes on answering the
yesterday it wrote at 13:30 - that record survives a restart, so answering it
is not a promise the store cannot keep. It stops adding to it. The snapshot is
copy.deepcopy: a one-level copy shares the day record the merge rebinds
`points` on, and measured with one, a store holding 12 persisted points
answered 24 after a failed merge.

Not fatal, deliberately: §6 and this module's opening - a broken cache must not
be a broken controller, and an add-on that will not start because /data filled
up is a worse defect than this one. `_save()` reports and returns; the shipped
resolve_tariff() path still gives a provider plan with no §11.4 degradation.
What changed is that the failure is now acted on rather than only logged.

test_price_history.py section 5d drives it on two REAL failures with different
syscalls and different errnos - a directory where the file goes (os.replace,
EISDIR) and a volume moved out from under an open store (mkstemp, ENOENT) -
because the rule is enforced on the outcome and one cause would grade a
coincidence. DOCS.md's "where there is no /data" sentence is widened to the
condition the code now holds to.

MUTATION.md gains what M4 exposed: a mutation whose red is a TRACEBACK reports
however many checks ran before it and grades none after it. M4 first printed
1 FAIL; the true figure is 3. An f-string operand read `len(read.points)`, and
`.points` on a no_data raises `Absence` by design - why()'s rule arriving in
what a check PRINTS rather than in what it asserts. `npts()` is now beside
why(), and a red count is a lower bound until the run is seen to reach the end
of its section.

6 mutations, purged tree, every one RED on named checks, no survivors:
M1 self._days = before -> dict(self._days)          +10 B  9 red
M2 _save() OSError handler returns True             -1 B   9 red
M3 _save() returns True on `not self.ok`            -1 B   3 red
M4 self._days = before -> self._days = {}           -4 B   3 red
M5 copy.deepcopy -> dict                            -9 B   3 red
M6 `if not self.ok: return` back in _remember()     +43 B  1 red

All 29 test files green. Nothing deployed; no option added; config.yaml and
app/ behaviour on a healthy /data untouched. No version number picked - that
is the open decision the Unreleased block names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 08:43:17 +02:00
admin 5059e28860 Merge pull request 'FCST-05: one byte stops stopping the add-on, and the write path gets the read path's standard' (#74) from FCST-05 into release/1.0
Both defects were found by the QA pass on merged FCST-04, and both are the same shape: a corrupt price cache taking the controller with it.

HIGH: _load() caught (JSONDecodeError, OSError) and UnicodeDecodeError is neither - it is a ValueError. One non-UTF-8 byte in /data/price_history.json escaped to Controller.__init__, which is unguarded, and the add-on did not start. The except is now (ValueError, OSError) - the content family beside the bytes family - rather than the two members known today, because a third ValueError subclass nobody thought of is exactly how this arrived. The new fixture writes real bytes and asserts the old fixture's string IS valid UTF-8, so the pair cannot collapse back into one.

MEDIUM: a malformed `start` under the day the feed was writing raised out of _remember() on every refresh forever - the add-on stayed up while the plan retired after 90 min and arbitrage stayed off, with no self-heal, and `words` reported the store healthy throughout. Read and write paths now share one validator, and the merge key is the validated instant (which also fixes a lexicographic sort putting the 25-hour day's second 02:00 first).

Also: the row counts what it says it counts, and two false claims of strengthening in _audit's docstring are corrected - a differential over the realisable domain returns 0 weaker, 0 stronger, and that differential is now a committed check with FCST-03's audit frozen beside it. PROC-01: a right decision with a fabricated reason is still a defect.

_audit completeness was NOT attempted, and the reason is measured rather than asserted: the rule as stated takes the horizon checks red on the shipped fill and a healthy feed, because `published` is the whole read while the fill emits only [since, until). The rule is wrong, not merely absent; making it right needs the window in the signature. Separate card.

Evidence: 29/29 green. 12 mutations, all red, each on a fresh tree copy fingerprinted against clean. One survivor was found and closed during the work - two rungs reported into one last_error, so a -1 byte deletion on the half of the fix that tells an operator the cache self-healed survived the whole suite. TEST-16's masking pattern, inside a check written to close a false-health defect. Recorded in MUTATION.md.
2026-09-10 01:13:41 +02:00
glenn schrooyenandClaude Opus 5 375a1e29d2 FCST-05: one byte stops stopping the add-on, and the write path gets the read path's standard
checks / unit (pull_request) Successful in 7m10s
HIGH - a non-UTF-8 byte in /data/price_history.json stopped the add-on
from starting. `_load()` caught `(json.JSONDecodeError, OSError)`.
`UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError`:
neither of those, and `issubclass(UnicodeDecodeError, OSError)` is False. So
it escaped `_load()` -> `_open()` -> `PriceHistory.__init__`, which
`Controller.__init__` does not guard - breaking `_load()`'s own stated
contract, "a corrupt cache must not stop the controller". The handler now
names the ValueError FAMILY (the file's content) beside OSError (its bytes),
because a third subclass nobody has thought of is how this arrived the first
time. The suite could not see it: its "unreadable file" fixture writes
"{ this is not json", which is valid UTF-8 and fails one step later, in
`json`. A fixture holding a genuinely undecodable byte is now beside it,
with the difference between the two asserted as a precondition.

MEDIUM - a malformed `start` under the day the feed is writing to killed the
tariff plan permanently. `_remember()` merged with `{q["start"]: q ...}` and
sorted on `q["start"]`, trusting a value read off /data to be a hashable,
sortable string. An int, null, a dict or a list raised TypeError out of
`carry()` -> `resolve_tariff()` -> `run_tariff_refresh`, which catches
Exception - so the add-on stayed UP while every refresh raised forever, the
plan retiring after TARIFF_MAX_AGE_S with arbitrage and the cheap window off
for good, and no self-heal because `_remember()` raised before `_save()`.
The read path was properly defensive throughout, which is why `yesterday()`
survived and the existing fixture passed - it files its malformed rows under
a day the feed is NOT writing to. Both paths now share one validator,
`_point_of()`. A row that is not a point is dropped, reported, and written
back out clean.

...and the status row said the store was healthy while the plan was dead:
`words` read '2026-01-15 (1 h)'. `_load()` now counts the rows inside a kept
record as well as the record itself, so the store stops reporting plain
health from the moment it is opened; the note names what was FOUND at open
and what was DROPPED by the refresh, and clears once the file is rewritten.

...and the merge key is the validated INSTANT rather than its spelling,
which is what rule 4 already said. The lexicographic sort beside the string
key also put the second 02:00 of the 25-hour October day (+01:00) before the
first (+02:00); stored order only, since `_parse_points()` re-sorts.

LOW - the status row counted POINTS and printed them under the HOUR's name:
'2026-01-13 (96 h)' for a 24-hour day on the quarter-hourly feed this add-on
already supports. It is now '96 points over 24 h' - two facts, each under
its own unit.

LOW - two stated reasons were false. `_audit`'s "WHY IT IS STRICTER" claimed
the rework refused the same published point emitted twice and a carried
point sharing a published hour, "which the old count never looked at". A
differential over the whole domain the audit can express finds ZERO
divergence in both directions: the old `seen` count already refused the
first (one point twice is one hour twice) and the old IDENTITY rule already
refused the second. The reworked audit is EQUAL to FCST-03's, not stronger;
the only behavioural change is the intended relaxation, and it stays. The
differential is committed as a check rather than left as prose.

...and the audit's real limit is named: it does not check COMPLETENESS. Fed
the decimation defect FCST-04 was raised for it ACCEPTS, and it accepts an
empty output. So `_audit` is not what stops that recurring - the count and
identity checks are, and the text now points at them. Not made complete:
`published` is the whole read while the fill emits only [since, until), so
the rule as stated is wrong, and building it takes the 1/12/48 h horizon
checks red on a healthy feed. Measured, not assumed.

12 mutations, purged tree, every one RED, no survivors. All 29 test files
green. Nothing deployed; no option added; config.yaml untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 01:04:30 +02:00
admin c2787da539 Merge pull request 'FCST-04: the carry-forward fill reaches the plan, and the day it carries survives a restart' (#73) from FCST-04 into release/1.0
Review found 2 HIGH, 4 MEDIUM, 12 LOW - all driven to a concrete failure, all fixed on the branch.

H1 was a regression this PR introduced: fill_from_yesterday keyed the published set as {_utc_hour(p): p}, so a sub-hourly feed lost three points in four. eneco_eddy is the Belgian imbalance product and settles per quarter. Measured on 96 quarter-hourly firm points: 96 -> 15 points, spread 0.143 -> 0.014 EUR/kWh. The fix passes every published point through by identity and carries only whole hours with no coverage at all; _audit's one-per-hour rule became two rules that subsume it, so it caught MORE after the change than before - a repeated published point and a carried point sharing an hour with a real print both now raise, and neither was checked before.

H2 falsified the branch's own 'a broken cache is not a broken controller': a day record that parsed but was not a dict raised out of words(), which is unguarded in Controller.__init__, so the add-on did not start and could not self-heal.

Two of the branch's own checks were decoration and are recorded as such in DECISIONS rather than quietly repaired: the prune mutation survived because the fixture seeded one day and never reached the code it graded, and the AST ordering check compared ast.walk BFS depth rather than source order - shipped and reversed scored identically. Both are now driven by behaviour.

Evidence: 29/29 test files green. 20 mutations driven across the rework, fresh tree copy and __pycache__ purge before each per MUTATION.md/TEST-15. No survivors.
2026-09-10 00:03:08 +02:00
glenn schrooyenandClaude Opus 5 b59f5ce31b FCST-04: the carry-forward stops decimating a quarter-hourly feed, and a broken cache stops stopping the add-on
checks / unit (pull_request) Successful in 7m22s
Rework of the FCST-04 branch against review. Every finding below was proven by
a runnable script before it was touched and re-run after; every check added or
repaired was driven red by a mutation, `__pycache__` purged first (MUTATION.md,
TEST-15). 29/29 test files green.

⚠️ H1 - A SUB-HOURLY FEED WAS DECIMATED TO ONE POINT AN HOUR, AND THE SPREAD
WENT WITH IT. `fill_from_yesterday()` keyed the published set as
`{_utc_hour(p.start): p for p in r.points}` - LAST POINT OF THE HOUR WINS, the
other three dropped. `eneco_eddy` is the Belgian imbalance product and settles
per QUARTER, and `_validate_raw()` accepts any strictly-increasing spacing, so
this is a shape the product supports today. Measured on a 96-point quarter-hourly
feed - ALL FIRM, FULLY PUBLISHED, nothing to carry: the plan reached the
optimiser as 15 points, the arbitrage spread fell from 0.143 to 0.014 EUR/kWh,
and the cheapest "hour" became a `:45` point whose `at()` window runs into the
next one. Not the windowing effect this card is about; a feed losing 84 % of its
own prints, with no flag anywhere reporting it. The fill now decides only WHICH
WHOLE HOURS NEED CARRYING: every published point inside the window passes
through BY IDENTITY, and a carried point is added only for a whole hour with no
published coverage at all. Same feed, same fixture: 60 of 60 firm points kept,
spread 0.134 over the hours still ahead.

⚠️ AND `_audit()` GOT STRICTER, NOT LOOSER - that was the condition on doing it
at all. The old post-condition refused any repeated `_utc_hour`, a count that
cannot express four points in one hour. Two rules replace it and SUBSUME it: a
published point must BE one of that hour's published points by identity and may
be emitted only once; a carried point may stand only in an hour absent from the
published set, must be flagged True, and may not repeat. So the duplicated
carried hour it existed to catch still raises, the same published point emitted
twice now raises, and a carried point sharing an hour with a real print now
raises too - which the count never looked at. Three mutations, three reds.

⚠️ H2 - A DAY RECORD THAT WAS NOT A RECORD STOPPED THE ADD-ON FROM STARTING.
`_load()` validated `version` and that `days` is a dict, never that each VALUE
is one. `{"version": 1, "days": {"2026-01-14": "oops"}}` loaded as ok=True,
last_error=None, and then `rec.get(...)` raised AttributeError out of `words`,
`yesterday()` and `resolve_tariff()` alike. `PriceHistory(...)` and `.words` are
both unguarded in `Controller.__init__`, so the controller did not come up - and
it never self-healed, because `_remember()` raised on the same record before it
could reach `_save()`. That falsifies the CHANGELOG's "A BROKEN CACHE IS NOT A
BROKEN CONTROLLER" in the one direction that matters. The asymmetry that
produced it is visible in the file: `_parse_points()` is carefully defensive
about every field of a POINT and `_load()` was not defensive about the record
holding them. Unusable records are now dropped and REPORTED (`words` says how
many and why), and `words` is total by construction as a second line.

M3 - `yesterday()` WAS OFF BY ONE DAY FOR 00:00-00:59 AFTER SPRING-FORWARD.
`brussels()` renders at the FIXED offset for that instant, so `brussels(stamp) -
timedelta(days=1)` subtracts 24 ABSOLUTE hours - and the Brussels day before the
March transition is 23 hours long. At 2026-03-30T00:30+02:00 it returned
2026-03-28's prices as "yesterday", or `no_data` naming a date that is not
yesterday. The arithmetic is now on the local DATE, which has no offset to lose,
and both transitions have a check - the module's headline claim had none.

M4 - WITH NO /data THE STORE STILL FILLED, IN MEMORY. `_save()` short-circuited
on `not ok` while `_remember()` populated `self._days` anyway: `words` said
"unavailable" and `yesterday()` answered A SERIES that is gone on the next
restart. The module docstring rejects exactly that ("a yesterday that silently
forgets") as its reason for not calling makedirs, so the code now matches the
docstring rather than contradicting it.

M5 - THE PRUNE MUTATION SURVIVED BECAUSE THE STIMULUS WAS DEGENERATE. §8's
clock-jump check seeded ONE day, so `if len(self._days) <= KEEP_DAYS: return`
fired and the body it is named after never ran: pruning by arithmetic against
the clock - which empties the store on exactly the morning the fill is for -
passed the whole 29-file suite. The fixture is now written to a file and
reopened (`_load()` does not prune, which is how a store legitimately holds more
than the cap) with KEEP_DAYS + 2 days, and the mutant is red.

M6 - THE AST ORDERING CHECK COULD NOT CATCH A REVERSAL, AND TWO DOCUMENTS CITED
IT AS A GUARANTEE. `_calls.index("_remember") < _calls.index("yesterday")` grades
`ast.walk` BFS DEPTH: `self._remember(...)` is a statement and
`self.yesterday(...)` is an ARGUMENT of the fill, one level deeper, so
`_remember` came out first whichever statement was written first - shipped and
reversed both scored True. It is now line numbers AND, the half that matters,
the behaviour: a feed carrying yesterday alongside today into an EMPTY store
must fill the whole horizon; reversed, the lookup finds nothing and the plan is
only the published hours. Reversing the two statements is red on both.

L14/L9 - KEEP_DAYS = 3 IS THE STEADY STATE EXACTLY, so a newest-N cap has no
slack: a feed publishing to D+2, or ONE stray point in the wrong year - which
sorts LAST and can never age out - pushes yesterday out for good. `_prune()` now
PINS the two days a lookup can actually ask for on top of the cap. Pinning is
additive, so it can only RETAIN and the WITNESS-03 property (nothing is deleted
by clock arithmetic) is preserved by construction; `stamp` stops being a dead
parameter; the store is bounded at KEEP_DAYS + 2.

L8 - the horizon end was not floored while `since` was, so a 48 h request made
at any instant off the hour returned 49 points. §1's count was green only
because its NOW was exactly :00:00, a shape production never has; the fixture
now sits at :17:43. L10 - a failed `_save()` leaked its temp file; at 48 writes
a day a persistent write failure leaked 48 inodes a day onto the volume that was
already the problem. L15 - `{"version": 1.0}` passed, since `1.0 == 1`. L16 -
the `_remember` tree walk scanned `app/*.py` only and matched a bare string; it
now parses `app/`, `tools/`, `scenarios/` and `estop/` and looks for the NAME,
so a comment does not fail it and a call in `tools/` cannot pass unseen.

CLAIM-ACCURACY DEFECTS, WHICH THIS REPO TREATS AS REAL:

* L7. DOCS said "*Tariff* on the status page reads `provisional` through the
  morning". FALSE - `tariff_words` is assigned only from the HA price-entity
  branch and from `static_status()`, both on the 30 s tick, and the plan's own
  sentence reaches `_LOG.info`. The doc now says what an operator can see (the
  Price history row, the Arbitrage row's own refusal sentence, the refresh log
  line) and a check pins the PAIRING - wiring the plan into the row is fine,
  correcting the doc is fine, doing one without the other is red.
* L11. "fills EVERY unpublished hour" is conditional: a partial yesterday leaves
  the local hours it never held out of the series (§11.4 for those hours, and
  `at()` raises rather than answering). Correct behaviour, overclaimed wording,
  and untested - all three fixed.
* L12. DECISIONS §1 leaned on `TariffPlan.cheap_now()`, which has NO PRODUCTION
  CALLER on the provider plan (`self.cheap` comes from `static_status()`, which
  builds its own). The conclusion holds on `arbitrage()`'s firm-pair refusal
  alone, which does reach control; the evidence is now stated at the reach it
  has.
* L13. §1's "passed through by identity" check compared `eur_per_kwh` between
  two SEPARATE `resolve_tariff()` calls, where `is` could never have held. The
  value comparison is kept and now says what it grades; identity is graded on a
  read the file constructs and still holds.
* L17. `PriceHistory.words` and the new status row had no check at all - and
  `words` is one of the two things H2 crashed. Both now have one.
* L18. main.py's inline note carried the "widens what the optimiser can SEE,
  never what it may act on" guarantee without the windowing caveat that the
  CHANGELOG, DOCS and `resolve_tariff()`'s docstring all carry. It is the one
  place a maintainer reads it unqualified.

EVIDENCE. All eight reviewer proofs re-run: p1/p1b return 29 March, p4 keeps
60 of 60 firm points at spread 0.134, p5 starts and reports, p6 D leaves no
temp, p6 E refuses 1.0, p6 F records nothing, p7 returns 48 for 48. p2's script
still scores True/True because it re-implements the OLD algorithm - the
reversal is now driven as a mutation instead, and is red. p3's script always
showed the shipped code correct; what was wrong was the fixture, and its mutant
is now red. Twenty mutations driven, no survivors, tree fingerprinted and
`__pycache__` purged before each: decimation (2 suites red), audit x3, unfloored
`until` (2), unvalidated day records (7 checks), instant-subtraction yesterday,
in-memory fill on a volume-less store, clock-arithmetic prune, cap without the
pin, leaked temp, loose schema type, empty `words` (5), reversed `carry()` (2),
a `_remember` leak in tools/ spelled two ways, a neighbour-filling fallback, a
copy instead of identity (2), the DOCS claim in both directions, and the caveat
removed from main.py. Baseline and the unmutated tree both green.

No option added, config.yaml untouched, and the CHANGELOG entry still picks no
version: PKG-07's rule, and only the deployer knows whether 0.9.0's image has
been pulled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-09 23:54:23 +02:00
glenn schrooyenandClaude Opus 5 0618194d9c FCST-04: the carry-forward fill reaches the plan, and the day it carries survives a restart
checks / unit (pull_request) Successful in 7m11s
FCST-03 shipped `fill_from_yesterday()` complete and checked, and shipped the
consumers written for it - `TariffPlan.firm_points()`, `cheap_now()`'s refusal
of a provisional hour, and `arbitrage()`'s refusal 3, which drops provisional
hours from the trade set rather than refusing the whole series. It shipped NO
CALLER. Nothing in `app/` named the fill, so no series ever contained a carried
point and every one of those consumers was exercising its all-firm branch.

⚠️ THE DEFECT THAT PRODUCED WAS NOT AN ERROR ANYWHERE. `load_prices()` returns
what the feed published and nothing widened it to the requested horizon, so on
a dynamic site between midnight and the 14-16 h CET publication the plan
covered the rest of today and nothing more - measured on the fixture here, 24 h
against a 48 h request. `arbitrage()` then took `max - min` over that truncated
window and reported a spread. The number was real, the window was half the one
configured, and nothing said so: tomorrow evening's peak, the hour a morning
charge would be sold into, was not in the series to be compared against
anything. A missing hour is not a wrong price - it is an hour the optimiser
never learns exists.

WHAT THIS ADDS. `app/price_history.py` keeps the PUBLISHED prices of the last
KEEP_DAYS Brussels days in /data/price_history.json; `resolve_tariff()` gains a
required keyword-only `history` and fills every unpublished hour of the horizon
from yesterday's same-wall-clock hour, flagged. Persistence rather than a
variable on the refresh task, because the window the fill exists for is
midnight -> publication and an add-on restarted at 08:00 would otherwise spend
all of it on the truncated series. Not in state.json: the refresh runs every
1800 s and that file holds the maintenance history and SAFETY-02's latch.

⚠️ ONE CHANGE HERE MOVES ENERGY, AND IT IS NOT THE ONE THE CARD IS NAMED FOR.
Carried hours are provisional, so §8.3 keeps them out of the trade set: the
fill widens what the optimiser can SEE, never what it may DO, and
test_price_history.py checks that so it cannot be overclaimed later. What does
change a trade is that the filled series is windowed to [since, until), so
hours that have ALREADY ELAPSED are no longer in the spread. Before, a cheap
night that had gone sat in the series as the day's cheapest hour - and a cycle
cannot buy in an hour that is past, so the trade was quietly never taken.
Section 2 grades this by finding the cheapest firm hour with and without the
fill and showing it MOVES, from 02:00 UTC to 08:00 UTC on the fixture.

⚠️ A DEAD FEED IS STILL §11.4, NOT YESTERDAY. The fill runs only on a `series`
read. The two are indistinguishable to the optimiser - both 100 % provisional,
so arbitrage refuses on the firm-pair rung and `cheap_now()` is False - so
preferring yesterday's feed over the `static_profile` an operator configured
FOR that case would change a documented degradation for a difference only the
status page can see. That, and two more, in
DECISIONS/FCST-04-the-day-that-is-carried.md.

⚠️ THE ANTI-DECAY RULE IS AN ORDERING, NOT A VALIDATION, and saying so is more
useful than a guarantee that would not hold. Feeding the filled series back in
is how this rots - day 2 carries day 1's guesses, day 3 carries day 2's carry
of them, behind a `provisional` flag that stays TRUE while the NUMBER decays.
No value-level check can catch it: a carried point and `eneco_eddy`'s
published-provisional quarter are the same three fields with the same flag. So
`carry()` is the only public entry, it records BEFORE it fills, the filled
series exists only as a return value the object never sees again, and
test_price_history.py walks app/ for any other module naming `_remember` and
reads `carry()`'s AST to pin the order.

EVIDENCE. 29/29 test files pass in the CI image. Eight mutations driven against
app/price_history.py and app/static_tariff.py, __pycache__ purged before each
run per MUTATION.md/TEST-15: newest-record lookup (5 red), recording the filled
series (1), replace-instead-of-merge (10), UTC-date keying (10), removing the
fill call entirely (12), a lost price field defaulting to 0.0 (2), prune by
clock arithmetic (1), a degraded reason that stops naming its cause (1). No
survivors; baseline and revert both green. Three of those first crashed the
harness instead of reporting a FAIL - "it was refused" and "it broke on the way
to being refused" must not grade the same - so `why()` and `_priced()` were
added and the run repeated.

No option added, config.yaml untouched, and the CHANGELOG entry deliberately
picks no version: PKG-07's rule, and only the deployer knows whether 0.9.0's
image has been pulled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-09 19:37:45 +02:00
admin 587751b115 Merge pull request 'INSTR-01: grade diff()'s suppression rule at the level it is written at, one case per clause' (#72) from INSTR-01 into release/1.0 2026-09-09 01:05:36 +02:00
glenn schrooyenandClaude Opus 5 3de4453956 INSTR-01: match the finding KIND, because M12 was not holding this file to its own rule
checks / unit (pull_request) Successful in 7m7s
Review APPROVED f6f3f22 plainly. Three LOW notes, all applied - the first is a
real gap and the other two are one line each.

⚠️ M12's ASSERTION WAS KIND-BLIND. It matched on the key and ignored the
finding kind, so renaming `diff()`'s `"VANISHED STORE KEY"` to
`"GONE STORE KEY"` left M12 GREEN - review measured it. A vanished latch
quietly downgraded to a softer heading is exactly what part 3 of this same file
requires every mutation to be graded against ("the SPECIFIC finding it should
produce"), so the file was not holding itself to its own doctrine. Inherited
from SAFETY-44's original M12, not introduced here, and fixed now:

    ("VANISHED STORE KEY", "unit/shed/sign_tripped") in _m12_new

Driven: the kind rename is exit 1 and M12 is among the reds where it was green
before, and C5's removal still turns M12 red, so the strengthening did not cost
the discrimination it exists for.

C4 IS NAMED IN THE CLAUSE MAP. It has no 5e row deliberately - part 3's M9
reaches `vanished_unit is not None` end-to-end and a synthetic row would
duplicate that rather than add separability - but C5 lives in the same `if`,
and listing one guard while omitting the other reads as an oversight rather
than a boundary. Review's own recommendation was not to add the row.

AND THE PRINTED SET SAYS WHAT IT LOSES. The finding regex captures the first
TOKEN of each text, so two findings of one kind collapse into one tuple; the
line now says "first token only" so nobody reads it as an inventory. Harmless
for M12 - the difference is one-directional and the pair it asserts on is exact
- but the display was lossier than it looked. Unused `_m12_out` dropped.

28/28 files pass. `rehearse_roster.py` is untouched by this round; review
confirmed its AST is identical between 91b0d5e and f6f3f22, so every
measurement made against the shipped rule still stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-09 00:58:03 +02:00
glenn schrooyenandClaude Opus 5 f6f3f227c1 INSTR-01: restore M12 on the leg I generalised past, and measure the clause that had no row
checks / unit (pull_request) Successful in 7m6s
Review found two things this PR asserted about itself that were false. Both are
mine and both are worse than the defect the card was filed for.

⚠️ "NO FIXTURE CAN RESTORE IT, MEASURED BOTH WAYS" WAS NOT TRUE, AND TWO
MEASUREMENTS ARE NOT AN IMPOSSIBILITY PROOF. I deleted M12 - SAFETY-44's
working end-to-end control for the off-roster narrowing - on the argument that
the provenance clause made it unrestorable. The structural claim behind it,
that `run()` builds both snapshots from one state file so "already latched" and
"made by this transition" are incompatible, IS TRUE OF SAME-STEP AND FALSE OF
STAGED: `snapshot_over()` starts the after-leg over the file the BEFORE-leg
wrote, so a transition-made destination is available there. Both arrangements I
measured were same-step shaped. Review found the third.

RESTORED, and it discriminates with all six clauses in:

    staged leg, legacy+shed, shipped     _m12_new = VANISHED unit/shed/sign_tripped
    ...C5 narrowing removed              _m12_new = []          ⚠️ suppressed

⚠️ AND MY FIRST RESTORATION DIDN'T DISCRIMINATE EITHER, for a reason worth
recording: `data.pop(k, False)` RETURNS the value, so shed lost the KEY and
kept the LATCH - `_grade_store_migration()` saw `on_roster_latched`, never
re-armed garage, and there was no transition-made destination. The finding was
reported whether or not C5 existed and the control looked restored. `and False`
is the difference between "the key is gone" and "the LATCH is gone", and only
the second reaches the re-arm this arrangement needs.

⚠️ WHAT ACTUALLY BROKE WAS M12's exit-0 PRECONDITION, NOT M12. A legacy-shaped
fixture is never clean. That precondition is the one SAFETY-44's own commit
called "belt-and-braces, load-bearing only if the exit-0 requirement is ever
relaxed", saying what closed the gap is `_m12_new` BEING A SET DIFFERENCE - and
a stable dirty baseline is fine for a set difference. So I deleted a control on
the strength of a precondition the same commit had already called not
load-bearing. That is removing coverage rather than failing to add it, which is
the worse of the two directions.

⭐ AND THE BARE-KEY CLAUSE HAD NO ROW AT ALL. Review deleted
`k.rsplit("/", 1)[-1] == bare` and the whole 28-file suite stayed GREEN. 5e
mutated five clauses; the comprehension carries more, and this one is
load-bearing: a vanished SAFETY-02 `sign_tripped` was suppressed by a SAFETY-42
`delivery_tripped` on a roster unit - a DIFFERENT FAILSAFE's key. The rule's own
comment says "THE TEST IS NOT 'the name appears somewhere'. It is: the SAME bare
key", and nothing measured that sentence. `unit/*/delivery_tripped` is
machine-read, so it is reachable on any real two-latch store. R7 added; C2 now
flips exactly R7.

All six clause-removals now flip exactly their own row:

    C1 machine_read   -> ['R6-machine-read']    C5 off-roster  -> ['R2-narrowing']
    C2 same bare key  -> ['R7-bare-key']        C6 injectivity -> ['R5-injectivity']
    C3 same value     -> ['R4-value']           C7 provenance  -> ['R3-provenance']

THREE DANGLING CITATIONS TO A CONTROL THIS PR HAD DELETED, one of them in the
shipped tool eight lines below the paragraph condemning exactly that ("a
citation is the worst possible carrier, because it reads as having been
checked"). The tool no longer names a check; it states the property - every
clause has a row, and each removal flips its own.

A STALE SAFETY-44 BLOCK saying both fixes were "REVERTED OUT OF THIS CARD,
because THIS SUITE CANNOT MEASURE EITHER ONE" - describing a tree this PR
replaced. Rewritten as a map of where each clause is now measured. A comment
that outlives its subject is the same defect as a citation that outlives its
check.

AND A SELF-CONTRADICTION about the driver, in the file and in the last commit
message: "THE DRIVER IS WRITTEN INTO THE TREE UNDER TEST" eight lines above
"THE DRIVER GOES IN A TEMPDIR, NOT IN THE TREE". The code does the tempdir; the
first sentence stated the read-only-CI bug the same commit says it fixed, as if
it were the design. It now says the driver IMPORTS the tree and is WRITTEN to a
tempdir.

ONE HOLE REMAINS OPEN AND IS STATED AS UNMEASURED: attribution. `sorted()`
decides which vanished key claims a destination, so a deleted key can sort
first and be named as the one that migrated while the key that really moved is
reported VANISHED. Review measured that `sorted(` -> `list(` leaves the suite
green - every 5e case has at most one candidate, so no row can see ordering.
Still exit 1, so a wrong sentence rather than a hidden loss. Recorded rather
than left looking guarded.

28/28 files pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-09 00:29:00 +02:00
glenn schrooyenandClaude Opus 5 91b0d5e4dc INSTR-01: grade the suppression rule at the level it is written at, one case per clause
checks / unit (pull_request) Successful in 7m4s
SAFETY-44 produced THREE inert controls for this one rule, each inert
differently, and split the two remaining fixes out rather than ship them
unmeasured. This is that card, and the first thing it did was explain why the
end-to-end route could never have worked.

⚠️ `moved_to` IS ONE COMPREHENSION CARRYING FIVE CONDITIONS WITH FIVE DIFFERENT
REASONS TO EXIST, AND `run()` BUILDS BOTH SNAPSHOTS FROM ONE STATE FILE. So
"the destination was already latched" and "the destination was made by this
transition" are the same file answering two incompatible questions: the
arrangement that reaches one forecloses the other. That is why rebuilding a
fixture to reach clause N kept moving the others, and it is not a fixture
problem.

⭐ SO THE RULE IS GRADED WHERE IT IS WRITTEN - `diff()` on constructed
Snapshots, ONE CASE PER CLAUSE. Six rows, and on the SHIPPED tree before any
fix they already separated the two known defects:

    R1 rename (legitimate)   migrated                      correct
    R2 narrowing             vanished                      correct
    R3 provenance            MIGRATED                      ⚠️ the defect
    R4 same value            vanished                      correct
    R5 injectivity           BOTH migrated                 ⚠️ the defect
    R6 machine_read          vanished                      correct

The two one-line fixes SAFETY-44 reverted are back, and R3/R5 flip while
R1/R2/R4/R6 do not - so the legitimate rename is still suppressed and this is
not an over-correction.

⭐ AND EACH CLAUSE REMOVAL FLIPS **EXACTLY ITS OWN ROW**, which is the
acceptance criterion this card was filed with:

    C1 machine_read  removed -> ['R6-machine-read']
    C3 same value    removed -> ['R4-value']
    C5 off-roster    removed -> ['R2-narrowing']
    C6 injectivity   removed -> ['R5-injectivity']
    C7 provenance    removed -> ['R3-provenance']

A removal that flips nothing is an unmeasured clause; a removal that flips
someone else's row means the cases are not separable after all. Neither
happened.

⚠️ AND M12 IS DELETED, WHICH IS THE MOST USEFUL THING THIS CARD FOUND. M12 was
SAFETY-44's end-to-end control for the narrowing and it was a REAL control -
red when the narrowing was removed. Adding the provenance clause disarmed it,
and NO FIXTURE CAN RESTORE IT, measured both ways: with the sibling latched in
its own right, C7 rejects the destination before C5 is consulted, so the
mutation is reported whether or not C5 exists; making the destination
transition-made means a flat latch, which makes the shipped run dirty and
breaks M12's own precondition. The second half of the card's acceptance
criterion - "M12 still turns red with the new clauses in" - is therefore
UNSATISFIABLE, and the honest answer is to remove the control rather than keep
one that has become inert. R2 measures C5 strictly better. Part 5 keeps the
end-to-end coverage; discrimination lives in 5e.

`state-both-latched.json` goes with it. Nothing referenced it once M12 was
gone, and the first draft of that paragraph said it was "kept, because 5e's
R2/R3 are derived from the arrangements it represents" - a nice sentence and
not a reference. An unused fixture a comment calls load-bearing is an inert
check one directory over.

TWO THINGS THE DRIVER GETS RIGHT ON PURPOSE:
  - it is written to a TEMPDIR, not beside the tool. `checks / unit` mounts the
    checkout READ-ONLY, so writing into the tree works on a dev box and is
    `OSError: Read-only file system` in CI - green for whoever wrote it and red
    on every PR. Caught by running it in the check image before committing.
  - the TABLE stays in the test file and the driver goes into the tree under
    test, so a mutation to the tool cannot also move the answer it is graded
    against.

28/28 files pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 23:51:35 +02:00
admin 806390d8b1 Merge pull request 'SAFETY-44: the latch migration takes the roster, and the guard stops being defeated by the key the defect wrote' (#71) from SAFETY-44 into release/1.0 2026-09-08 19:16:32 +02:00
glenn schrooyenandClaude Opus 5 775f3a2e82 SAFETY-44: keep attribution in-tree, add the third clause, and correct a claim I made for my own precondition
checks / unit (pull_request) Successful in 6m56s
Review APPROVED f109153 and the split. Three asks, all applied, plus a
correction it caught in the round-4 commit message.

C STAYS IN-TREE. Attribution is a third property of the same comprehension -
`sorted()` decides which vanished key wins a destination, so a build renaming
`inverter-b` -> `garage` while DELETING `inverter-a` names the deleted key as
migrated and the one that really moved as VANISHED. A reader of the disclosure
paragraph wants it beside the other two rather than only in Trello. One
sentence, same block.

AND A FOURTH CLAUSE REVIEW MEASURED THIS ROUND: `and after.data.get(k) ==
value` survives the suite and is load-bearing. Without it the rule degenerates
to BARE-NAME matching - a deleted `unit/old-x/sign_reason` was reported as
having migrated into a `sign_reason` holding a COMPLETELY DIFFERENT SENTENCE,
and essentially every off-roster vanished `unit/*` key would be suppressed.
Added to the paragraph and to INSTR-01, so all of `moved_to` is covered by one
fixture design rather than two-thirds of one.

⚠️ AND THE POINT IS THAT THEY ARE FOUR CLAUSES OF **ONE COMPREHENSION**, WHICH
IS STRUCTURAL AND NOT A LIST OF CHORES. Four conditions, four different reasons
to exist, one fixture set that can reach only some at a time - so rebuilding a
fixture to reach clause N moves the others. That is exactly how round 4's
disarming happened, and it is what INSTR-01 has to design against rather than
patch around. Review called this the sharpest thing found in four rounds and it
is a fact about the function, not about the two clauses that exposed it.

⚠️ AND I OVERCLAIMED FOR MY OWN PRECONDITION. The round-4 message said the
strengthened M12 precondition was needed because "the exit code alone cannot
say the second thing". Review checked: on a clean run the finding regex matches
nothing, so the second conjunct is IMPLIED by the first today. It is
belt-and-braces, load-bearing only if the exit-0 requirement is ever relaxed.
WHAT ACTUALLY CLOSED THE FIRST DRAFT'S GAP IS `_m12_new` BEING A SET DIFFERENCE
- the mutation must ADD a finding - and that is the check the narrowing's
removal turns red. The wrong half was credited, and the comment now says which
half does the work.

Nit: the fixture's trailing newline and two backticks, lost last round.

`rehearse_roster.py` is still byte-identical to d1b7e3f - this round touches
only the test file's disclosure paragraph and the fixture's comment. 28/28 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 19:02:59 +02:00
glenn schrooyenandClaude Opus 5 f109153216 SAFETY-44: ship only what this suite can measure, and split the two clauses it cannot
checks / unit (pull_request) Successful in 6m58s
⚠️ REVIEW FOUND THE INJECTIVITY CHECK INERT IN THE SAME WAY M12's FIRST DRAFT
WAS, three paragraphs below the comment diagnosing it. Removing the whole
mechanism left 28/28 GREEN: part 5's two destinations are `sign_reason` and
`sign_tripped`, DIFFERENT bare keys, which can never contend. The
`len(_dests) >= 2` precondition was true and established nothing - "not
asserting over an empty list" was correct, "this run can exercise injectivity"
was not.

Review also measured a second, narrower silent loss: `moved_to` accepts a
destination regardless of whether the TRANSITION put the value there, so a
roster unit already latched in its own right is a free suppression slot. A build
deleting `old-a` while renaming `old-b` -> `shed` reported exit 0, NO FINDINGS,
with `old-a` "migrated" into a key that already held True.

⚠️ I WROTE BOTH ONE-LINE FIXES AND HAVE REVERTED BOTH, because THIS SUITE
CANNOT MEASURE EITHER. Three attempts, three different ways of proving nothing:

  1. assert injectivity over part 5's real destinations
     -> passes with the entire mechanism deleted (non-contending bare keys)
  2. build a dedicated fixture for the two-claimant case
     -> discriminates NOTHING: both snapshots read one state file, so nothing
        vanishes and there are no migrated lines with the clause or without it
  3. rebuild M12's fixture so the destination is transition-made, to reach the
     provenance case
     -> ⚠️ DISARMED M12 FOR THE NARROWING. All three clause-removals came back
        GREEN at once, and the control I had measured RED last round was gone.

⭐ (3) IS WHY THIS IS A SPLIT AND NOT A JUDGEMENT CALL. A later fix to the same
function silently disarmed the control for an earlier one, and I saw it only
because I re-drove the removal rather than trusting the suite's exit code.
Shipping two more unmeasured clauses into the rehearsal instrument, on a card
whose entire subject is checks that cannot reach what they name, would be the
fourth inert artifact in one PR. They go on their own card, with review's
measurements as the starting evidence and the fixture built BEFORE the fix -
which is the ordering that worked for M12 and failed every time I inverted it.

⚠️ AND THE SPLIT IS SAFE, WHICH IS WHY IT IS AVAILABLE. Both remaining holes are
in the REHEARSAL TOOL, not the controller. The round-2 narrowing guarantees a
suppressed key is always OFF the roster, so no roster unit loses protection;
what is left is rollback-view/history mis-reporting on a tool an operator runs
before commissioning. Nothing here can disarm a latch on a live site, and
inverter B goes in on ~2026-09-12. Review offered this split explicitly.

M12's PRECONDITION IS STRENGTHENED, because the exit code could not carry it.
It now requires the shipped tree to be clean AND to not already report the
finding the mutation exists to produce. The first draft passed on exactly that
gap: the fixture's own litter made the run red and nothing separated the two.

Re-driven after the revert, on the round-3 fixture:

    shipped                                   exit 0
    `vanished_unit not in after.units` gone   ⚠️ exit 1

28/28 files pass. The controller half is unchanged since d1b7e3f and review has
no further concerns about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 18:44:38 +02:00
glenn schrooyenandClaude Opus 5 f6d7bfcb04 SAFETY-44: give the moved-not-lost branch a control that reaches it, and make the move injective
checks / unit (pull_request) Successful in 7m38s
⚠️ THE CONTROL I WROTE LAST ROUND WAS INERT, AND REVIEW MEASURED IT. M12 ran its
mutant over `state-legacy.json` and the findings diff against the UNMUTATED tree
was EMPTY. It "passed" because that fixture's STAGED leg is already exit 1 on
pre-existing litter, and `"migrated  unit/shed/" not in out` was trivially true
because a pre-DEV-02 FLAT store has no shed latch to lose - after migration only
the FIRST roster entry is ever latched.

The decisive measurement is the one it could not make: reverting the
`vanished_unit not in after.units` narrowing - restoring exactly the rule that
hides a real latch loss - left 28/28 GREEN.

⚠️ SO IT WAS HABIT 4 WEARING THE CLOTHES OF ITS OWN FIX, AND WORSE THAN THE
"NO CONTROL" IT REPLACED, because it reads as checked. That is this commit's
own argument about the M3 mis-citation, turned on the thing I wrote to answer it.

⭐ THE FIXTURE CAME FIRST THIS TIME. `state-both-latched.json` is a store at
SCHEMA_VERSION with `unit/garage/*` AND `unit/shed/*` both latched by ONE site
event, so the reasons match too - the arrangement in which same-value
suppression is most dangerous, and the one no existing fixture could produce.
M12's mutant then makes ONE roster unit's latch key VANISH while its sibling's
survives at the same value:

    shipped tree, both-latched fixture      exit 0, no findings
    one unit's latch key deleted            exit 1, ADDS
                                            VANISHED STORE KEY unit/shed/sign_tripped
    narrowing reverted                      ⚠️ RED  (was 28/28 green)

And it asserts the PRECONDITION - the fixture is clean on the shipped tree -
plus that the mutation ADDS findings rather than coinciding with ones the
fixture already had. An empty diff there is exactly what made the last draft
inert, and nothing said so.

⭐ AND THE MOVE IS NOW INJECTIVE, which is the narrower hole review found still
open. `moved_to` is "any machine-read key with the same bare name and the same
value", and `DEFAULTS["sign_tripped"] is False` means `True` is the ONLY
non-default value a latch can hold - so N vanished keys could all claim the SAME
destination, by construction rather than by coincidence. Measured on a rename
carrying `inverter-a` -> `garage` correctly while DROPPING `inverter-b` -> `shed`:

    migrated  unit/inverter-a/sign_tripped = True -> unit/garage/sign_tripped
    migrated  unit/inverter-b/sign_tripped = True -> unit/garage/sign_tripped

Zero store-key findings, on a run where `shed` came up UNLATCHED. Two keys
cannot both have become one key. `diff()` claims each destination once.

⚠️ AND THE CHECK FOR IT IS ANCHORED TO A RUN THAT ACTUALLY PRODUCES MIGRATED
LINES. A dedicated mutant was written first and asserted over an EMPTY list -
vacuous, passing, indistinguishable from working, the same defect twice in one
sitting. These fixtures cannot produce two claimants (`migrate_unit_keys()`
moves FLAT keys only, so one flat latch yields one migration), so rather than
build a fiction to grade, injectivity is asserted over part 5's real migrations,
with a PRECONDITION that there are at least two of them. THE TWO-CLAIMANT
ARRANGEMENT IS A STATED RESIDUAL: bounded in `diff()` by construction, not
measured end-to-end.

Finding 4, and my correction to it was also wrong. The comment cited "line 315"
and "two hundred lines below"; the fix I wrote said "~54 lines down", which was
review's distance to the WRONG line, carried to a new referent without
re-measuring - inside the sentence apologising for a wrong number. Recomputed at
5e382f5: 327-261 = 66, 327-252 = 75, and 54 was neither. There is no distance
now, because a distance is a coordinate and the sentence already names the thing.

And the residual gains its second half, which review noticed and I had not: the
reason re-armed from is the FLAT mirror, carrying whatever tripped LAST, so this
path can print one inverter's evidence under another's name. The latch is right
and the explanation may not be. `test_maintenance.py`'s check that the reason is
not the removed unit's is guaranteed by that fixture's flat string, NOT by the
code, and now says so.

28/28 files pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 18:08:16 +02:00
glenn schrooyenandClaude Opus 5 d1b7e3f113 SAFETY-44: decide per key, because one shared flag let an unrelated latch veto the repair
checks / unit (pull_request) Successful in 6m58s
⚠️ REVIEW DEFEATED THIS CARD'S OWN FIX WITH ONE ADDED KEY, and the project's own
instrument agreed on the unmodified tree. The guard classified each key and then
wrote ONE site-wide `namespaced_latched` for BOTH "on the roster" and
"off-roster but not UNIT_A", so a single latched key belonging to a unit the
operator had removed from `inverters:` vetoed the repair of the misdirected
`inverter-a` latch beside it. Measured, roster ['garage','shed'], store stamped
so the migration fix cannot reach it:

  sign_tripped + unit/inverter-a/sign_tripped     -> [('garage', True),  ...]
  ...AND unit/old-shed/sign_tripped               -> ⚠️ [('garage', False), ...]

A site DRIVING on a state file that reads `sign_tripped: true` - the sentence in
this card's own first paragraph - and `tools/rehearse_roster.py` printed
`LATCH CAME UP DISARMED` on it, exit 1, against shipped code. It matters more
than a normal hole because this guard is, as main.py says, the ONLY repair path
for an install already stamped by 0.9.0.

⚠️ AND IT IS INSTANCE #17 OF HABIT 4, IN THE EXTENSION MUTATION.md ADDED TWO
DAYS AGO. The two fixtures written for this card each hold ONE arrangement -
`_staged_store` has only `inverter-a`, `_other_store` only `inverter-b` - so the
flag was never written twice with opposite intents in a single store. A per-key
check whose keys have never disagreed WITHIN ONE STORE is a check over whichever
key came last. Three variables now, one per case, and a fixture holding BOTH.
Four cases driven: stranded-only repairs, +1 unrelated repairs, +2 repairs,
off-roster-only does NOT (which is DEV-02's control, still green).

⭐ AND THE MOVED-NOT-LOST RULE HID A GENUINE LATCH LOSS. The match was bare key
plus value over the whole of `machine_read`, with no identity - and
`DEFAULTS["sign_tripped"] is False`, so the ONLY non-default value a latch can
hold is `True`. Any surviving latched roster unit therefore suppressed any other
unit's vanished latch. Review measured a build that drops `unit/shed/sign_*`
while `unit/garage/sign_*` survives, both latched by one site event so the
reasons matched too:

    none in LEG SAME-STEP: nothing vanished, nothing stranded ...
    NO FINDINGS in the rehearsed transitions            exit 0

A roster unit's persisted SAFETY-02 latch deleted, transition reported clean.
The legitimate case is a RENAME - the key left `inverter-a`, which is not on
this roster, and arrived under `garage`, which is - so the vanished key's own
unit must be OFF the roster. A key whose unit is still on the roster has not
been renamed, it has been lost, and no amount of the same value elsewhere makes
that untrue.

⚠️ AND THE BOUND I CITED FOR IT DID NOT EXIST. Both the comment and the last
commit message said "what M3 (delete the flat rollback view) measures". M3 is
not that mutant - it drops `sign_reason` from `LEGACY_UNIT_KEYS` - and the one I
meant, M9, vanishes only FLAT keys, so neither can satisfy the `unit/` gate the
rule sits behind. NEITHER EXERCISED THE BRANCH; it had no negative control at
all, in a card whose subject is checks that cannot reach the failure they name.
A citation is the worst carrier for that, because it reads as having been
checked. M12 is written for it and driven: a roster unit that stops restoring
its latch is exit 1, and the suppression does not swallow it.

`misdirected` IS UNREACHABLE FROM THE CALL SITE, and the claim that it was a
benefit is withdrawn rather than restated. `unit` is `roster_names(opts)[0]` and
`build_roster()` names its first entry from the same list, so `unit` is a member
of `roster` by construction; review measured it - dropping the `roster` argument
inside `migrate_unit_keys()` survives 28/28. The argument stays for the reason
`_verify_moves()` gives for its own `roster=None` branch, it is what a NEW
caller gets - and a guard nothing exercises is a guard nobody knows works, so it
is now exercised at the function's own level: a wrong `unit` comes back
misdirected, the marker is not stamped, and the same call with a roster name
stamps it. Dead `if _roster_names else UNIT_A` removed; `roster_names()` never
returns [].

THE OPERATOR MESSAGE IS ASSERTED. Review deleted it whole (`if stranded:` ->
`if False:`) and 28/28 stayed green - `stranded` had no other consumer, so the
list existed only to print a line nothing checked, and that line is the entire
operator-facing half of the repair. Now: exactly one SAFETY-44 line at ERROR,
naming the stranded key and the roster it was measured against.

A wrong line citation removed: the call-site comment said "line 315's comment"
and "two hundred lines below", and both were wrong (315 is the CFG-03 defaults
note; the comment meant is ~54 lines down). Cite the thing, not its coordinates.

⚠️ AND WHAT 5b/5c ACTUALLY PIN IS RECORDED, because review is owed the
distinction: `mutant_tree()` asserts its target exists, so EDITING the guarded
line makes the file red at the ANCHOR before any behaviour is measured - which
looks identical from the exit code and proves much less. It happened twice while
writing this commit. The behavioural coverage lives in test_maintenance.py
section 13 and test_inverter.py, which build stores rather than strings.

Fixture strings no longer say "measured" - they are synthetic, and the repo's
other fixture carries an explicit SYNTHETIC header.

28/28 files pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 17:08:41 +02:00
glenn schrooyenandClaude Opus 5 5e382f52f6 SAFETY-44: the latch migration takes the roster, and the guard stops being defeated by the key the defect wrote
checks / unit (pull_request) Successful in 6m56s
⚠️ A DISARMED SAFETY-02 GUARD ON AN INSTALL WHOSE STATE FILE SAYS IT IS
PROTECTED. `Controller.__init__` called `migrate_unit_keys(store, UNIT_A)` - a
hardcoded constant - while its sibling `migrate_maint_keys(store, unit, roster)`
takes the roster and refuses a write no machine reads. On a store that ran
pre-DEV-02 flat and declares `inverters: [garage, shed]`, the latch moved to
`unit/inverter-a/sign_tripped`, `_verify_moves()` read it back under the name it
had been HANDED, was satisfied, and stamped the marker. Not one inverter came up
latched, and the next start skipped the migration too.

That is the MAINT-05 defect verbatim, still live in the function whose own
docstring describes it, and `_grade_store_migration()` did not catch it: its
"is anything namespaced latched" test was satisfied BY THE MISDIRECTED VALUE
ITSELF.

⚠️ THE REASON IT WAS LEFT WAS A HALF-TRUTH. "There is no roster yet - the
`Inverter`s restore their latch in their constructors." The KEYS must be written
before the `Inverter`s read them; the NAMES do not come from the `Inverter`s.
`roster_names(opts)` reads them off the options before anything is constructed -
which is exactly what it already existed to do for `MqttPublisher` - and returns
`[UNIT_A]` on the flat shape, so today's upgrade path is byte-identical.

TWO EDITS, AND THEY DO DIFFERENT JOBS. Measured per leg by reverting each one:

                          SAME-STEP leg          STAGED leg
  both edits (shipped)    clean                  latch armed, litter reported
  guard reverted          latch armed            ⚠️ DISARMED
  call site reverted      latch armed, litter    latch armed, litter

So the GUARD carries the safety and the CALL SITE carries the hygiene and the
withheld marker. The table is in test_rehearse.py part 5 because a reader who
assumed the roster-aware migration re-arms the latch would delete the guard as
redundant in a tidy-up and take the STAGED column with it - and STAGED is what
"upgrade now, declare the roster later" produces, which is the path an operator
actually takes. Only the guard reaches an install already stamped by 0.9.0.

⚠️ AND "OFF THE ROSTER" ALONE WAS TOO BLUNT, WHICH test_inverter.py CAUGHT. The
first draft treated every off-roster latch as litter. In DEV-02's restart case
the store carries `unit/inverter-b/sign_tripped = True` from B's own trip while
the roster being graded holds only A - and the blunt rule re-armed a HEALTHY A
off the flat key B's trip had mirrored. DEV-02's defect rebuilt inside its own
guard, which is what the paragraph I was overruling had been right to fear.

Narrowed to `UNIT_A`, which is the only name the broken migration ever wrote -
`roster_names()` falls back to a POSITIONAL `inverter-N`, so `inverter-a` is not
a name this code hands to anyone else. A latch stranded under any other name is
a real unit out of today's config and still counts as protection. Residual,
stated in the code rather than left to be found: a site whose own first unit is
NAMED `inverter-a`, latched, then taken out of the roster gets re-armed on
`inverters[0]` - a healthy unit held at 0 W until an operator clears it. The
conservative direction, against a site DRIVING on a file that says it is latched.

⭐ AND FIXING THE CODE EXPOSED A FALSE POSITIVE IN THE HARNESS. With the latch
now landing under `garage`, `rehearse_roster.py` reported VANISHED STORE KEY for
`unit/inverter-a/sign_tripped` three lines above the same value sitting under
`unit/garage/sign_tripped`. A key that moved to a namespace a machine reads has
not vanished - and an alarm that fires on the harmless case is one an operator
learns to dismiss before the real one arrives, which is CONTROL-C's own
argument. The test is not "the name appears somewhere": same bare key, same
value, under a key in `machine_read`, the set built by driving a real start. A
latch that genuinely went missing has no such key and is still a finding, which
M3 (delete the flat rollback view) still measures red.

Test parts replaced deliberately, as both of them asked to be:
  - test_rehearse.py part 5 asserted the defect "as REQUIREMENTS so that a fix
    turns them red". It now asserts the fix, with 5b and 5c as negative controls
    - one per edit - and 5b doubles as the leg-splitter's positive control,
    since reverting the guard is the build where the two legs DISAGREE.
  - test_maintenance.py section 13 was headed "RECORDED, NOT FIXED" and said the
    fixing card "should expect them to go red and should replace them". Replaced,
    plus two cases it could not have had: the already-stamped store, and the
    `inverter-b` negative control for the narrowing above.

28/28 files pass. Not run: nothing - this is host-side only, no rig, no hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 15:36:37 +02:00
admin 46c022c926 Merge pull request 'ARCH-02: rehearse the flat-to-roster switch - and SAFETY-44 re-confirmed by a second instrument' (#70) from ARCH-02 into release/1.0 2026-09-08 15:09:27 +02:00
glenn schrooyenandClaude Opus 5 22ca341459 ARCH-02: assert the invariant, because a fixture cannot enumerate arrangements
checks / unit (pull_request) Successful in 6m55s
Review approved and then attacked part 5b, the control that stands between B1's
defect and a green suite. It got through in three edits:

    A1  delete `END LEG`                        5b CATCHES it        2 FAIL
    A2  ...and reorder the legs                 5b BLIND             1 FAIL
    A3  ...and reword the closing summary       0 FAIL, "all checks passed"
                                                with a splitter that closed
                                                ZERO legs

⚠️ TWO OF THOSE THREE ARE ORDINARY REFACTORS - reordering report legs, rewording
a summary line - and neither reads as touching a safety check. 5b caught the
unclosed splitter only because the clean leg happened to be printed LAST, and
the backstop that saved A2 was a hardcoded transcription of the tool's own
sentence, so rewording that sentence blinded it too.

FIXED BY ASSERTING THE STRUCTURE RATHER THAN ADDING A FIXTURE: a leg may not
open while one is open, and every leg that opens must be explicitly closed.
Driven - deleting `END LEG` now fails on the FIRST edit, whatever the order or
the wording:

    AssertionError: leg 'SAME-STEP' opened while 'STAGED' was still open - the
    tool stopped closing its legs, and a splitter that never closes swallows the
    closing summary into the last leg.

All three attacks include that edit, so one assertion kills the class. Clean
tree unchanged: 28/28, all checks passed.

⭐ AND THE RULE IN MUTATION.md IS EXTENDED, because review's finding is the
general case of it. It said "a per-thing check that has never seen the things
DISAGREE is a total". Part 5b DOES make them disagree, every run, and was still
blind. So:

    ...AND ONE THAT HAS ONLY SEEN THEM DISAGREE IN ONE ARRANGEMENT IS A TOTAL
    OVER THAT ARRANGEMENT. When a fixture cannot enumerate the arrangements,
    assert the invariant they share.

Also recorded, NOT fixed, with the reason: the reference carries `entity_id
unique_id device` and grades all three, but never the discovery TOPIC. Review
measured `homeassistant/... -> ha/...` exiting 0 with no findings while every
announced entity dies. `discovery_payloads()` already returns the topic and
`announced_rows` discards it, so a fourth column closes it - but the prefix is
HA's fixed contract rather than a name this project chooses, so it is not what a
tidy-up rename reaches. Lower LIKELIHOOD than the N1 gap it resembles, not lower
consequence, and it stops being a note the day the topic becomes configurable.

Two stale figures corrected: the tool claimed five migration mutations where
there are nine, and pointed at a DOCS.md heading that does not exist (the real
one is "Before you commission inverter B", verified present at DOCS.md:1577).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 15:01:55 +02:00
Claude Opus 5 9c1a9e0f17 ARCH-02: M7's evidence was filtered out by its own finding kind
checks / unit (pull_request) Successful in 6m56s
The mutation battery prints the distinct findings each mutation caused, matched
on the finding's KIND. The pattern's character class was `[A-Z -]`, which has no
underscore - so `UNIQUE_ID DRIFT:` did not match, and M7, the mutation that
renames `mqtt.BASE`, printed its verdict with no evidence under it at all.

A filter that silently drops the very finding a mutation exists to produce is
the same defect as the rest of this card, at display scale. No behaviour change:
M7 was red before and is red now, and it now shows why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 14:23:35 +02:00
Claude Opus 5 638c213944 ARCH-02 round 2: the pin could not tell one red leg from two, and the tool went
checks / unit (pull_request) Successful in 6m57s
quiet when a flag was forgotten

Both blocking findings were reproduced before anything was changed, and both are
the class this card exists to guard against.

B1. THE PIN WAS A COUNT, AND A COUNT CANNOT SEPARATE TWO ROUTES. The rehearsal
reaches the disarmed latch by two independent paths and prints every finding
TWICE per red leg - once in the leg's block, once in the closing summary - so
ONE red leg satisfied `count(...) >= 2`. Measured: shipped code 4; with
`migrate_unit_keys()` handed the roster's own name (SAME-STEP fixed) 2, PASSES;
with a guard-side fix (both fixed) 0. The check named "NOT THE SAME-STEP PATH
ALONE" reported ok on the build whose only remaining failure IS the staged path.
The tool now prints `LEG <id>` / `END LEG <id>` and the pin requires the finding
in EACH leg by name.

⚠️ AND THE FIRST SPLITTER CARRIED THE SAME DEFECT ONE LEVEL DOWN - it opened on
the marker and never closed, so the last leg absorbed the closing summary and
read red whenever ANY leg was red. Caught by building the one fixture that
separates them. Part 5b is now that fixture, run every time: a tree in which
SAME-STEP is genuinely clean and STAGED is not. A per-thing check that has never
seen the things disagree is a total with extra steps.

⚠️ AND IT CORRECTS A CLAIM THIS CARD MADE. `main.py:250` is NOT the whole fix.
Measured on that exact change: LEG SAME-STEP clean, LEG STAGED still DISARMED -
the staged path stamps `schema_version` on the flat start and the roster start's
migration returns early before the name it is handed matters. DOCS.md now says
so, with the measurement.

B2. A FORGOTTEN FLAG WAS A CLEAN BILL OF HEALTH. Omitting `--live-entities`
printed "nothing orphaned" and exited 0, with `grep -ci` for the words returning
0 - while an UNREADABLE file exited 2. The asymmetry was backwards. Omitting it
now refuses (exit 2); `--no-live-entities` is the deliberate opt-out and carries
a standing banner in the header AND on the exit line, because a report is
skimmed from the bottom.

N1. THE REFERENCE DID NOT CARRY WHAT HA KEYS ON. It held entity_ids only, so
renaming `mqtt.BASE` (every unique_id moves) and changing `DEVICE["identifiers"]`
(HA sees a new device) both came back exit 0 - real orphanings with every
composed id byte-identical. The reference is now three columns
(`entity_id unique_id device`), is loaded on EVERY run including a degraded one,
and M7/M8 pin both. `object_id` is deliberately not a column and its rename is
required to SURVIVE, which is what bounds the four mqtt.py mutations.

Also found while fixing the above, and fixed with its own paired positive: the
VANISHED check did not carry the default/non-default rule the STRANDED check
does, so a pair of migrated DEFAULTS moving namespace read as two findings and
CONTROL-C went red on a CORRECT build. M9 drops non-default keys and must still
be a finding.

N2/N3. DOCS.md withdraws the unmeasured "not recoverable by advancing again" -
its own reasoning cut the other way and nothing here tested a re-advance; the
measured part (eight rows stop updating, history gaps, automations stop firing)
stands. And the byte-identical-input property is `test_rehearse.py`'s assertion,
not the tool's.

FRAMING. SAFETY-44 is ALREADY RECORDED on the merge base - `test_maintenance.py`
section 13, with the "guard is defeated by the very key the defect wrote"
sentence, in a file this card does not touch. DOCS.md now says this is
independent re-confirmation through a second instrument, reachable from an
owner-supplied state file rather than only from a fixture, and not a discovery.

app/ is still untouched. 59 checks in test_rehearse.py, 9 mutations red, 2
required survivors, 3 clean controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 14:21:34 +02:00
Claude Opus 5 1841416aa4 ARCH-02: put the fresh-install checks in the part they belong to
checks / unit (pull_request) Successful in 6m49s
They were printing under "6. THE REVERSE DIRECTION", which is not what they are
about, and part 2's heading still said two controls when there are three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:23:03 +02:00
Claude Opus 5 cb8f800180 ARCH-02: the harness was red on every fresh install, and a third control says so
A store the add-on has never written has no latch and no history. The DEV-02
migration still runs on it - `schema_version` is absent, so `from_version` is 0 -
and it writes `sign_tripped = False` and `sign_reason = ""` under `inverter-a`
before `build_roster()` exists. On a declared roster nothing reads either of
them, so the first draft called both STRANDED and exited 1. Every fresh install
that grows a second inverter would have been reported as a failed transition.

⚠️ THAT IS A DEFECT IN THE ALARM, NOT A CASE TO SUPPRESS, and store.py already
carries the distinction it needed: `MigrationReport.lost` reports a dropped
`sign_tripped=True` and NOT a dropped `sign_tripped=False`, because "absent" and
"never tripped" are the same statement - and because an alarm that fires on the
harmless case is one an operator learns to dismiss before the real one arrives.
The stranded check now makes the same split, on the VALUE and not on the key
name: a key stranded at its default is printed under its own heading, visibly,
and is not a finding; the same key stranded at a non-default still is. SAFETY-44
turns on `sign_tripped = True`, so it is untouched, and a check pins both
halves against each other so the softening cannot widen into a hole.

CONTROL-C is the fresh install, and it is a third clean control rather than a
line in a comment: the shipped code is right about it and the harness must say
nothing. It is the control that found this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:20:49 +02:00
Claude Opus 5 018da00b74 ARCH-02: show the mutation evidence, not the tool's own docstring
The mutation battery printed every line of the harness's output starting with a
warning sign, and the harness's header is four paragraphs of them - so each
mutation's evidence arrived buried under "THIS TOOL RUNS THE REAL Controller"
repeated six times, and each finding appeared once per transition leg plus once
in the summary. The filter now matches a finding's KIND and de-duplicates, so
what part 3 prints is the set of distinct findings the mutation caused.

No behaviour change: the same six mutations go red for the same reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:16:47 +02:00
Claude Opus 5 e06906d65f ARCH-02: rehearse the flat-to-roster switch, and it comes up disarmed
Nothing had ever run the real `Controller` through the transition inverter B's
commissioning performs on 2026-09-12. `tools/rehearse_roster.py` does, over a
COPY of a state file given as an input path, and reports the difference: every
store key that moved, appeared, vanished or was STRANDED under a name no
machine reads; every entity id before and after with orphans named; and whether
the maintenance history MAINT-05 moved once survived being moved again.

⭐ WHAT IT FOUND, AND IT IS SAFETY-44's ANSWER. A store that ran pre-DEV-02 flat
and declares a multi-inverter roster comes up with its SAFETY-02 latch DISARMED
unless the first roster entry happens to be called `inverter-a`.
`Controller.__init__` hands `migrate_unit_keys()` the hardcoded `UNIT_A`, so the
latch lands in `unit/inverter-a/sign_tripped`; a roster of ["garage","shed"]
reads `unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()`
- the guard written for exactly this - sees the migration's OWN misdirected key
satisfy its `namespaced_latched` test and returns without re-arming. Measured,
not argued: the file says `sign_tripped: true`, no inverter is latched, nothing
is held at 0 W. The same state file onto a roster whose first entry IS
`inverter-a` comes up latched, so the finding is attributable to the name.

The staged upgrade reaches it too: the flat start stamps `schema_version`, and
the roster start's migration then takes its early return.

⚠️ NO RUNTIME CODE IS CHANGED HERE. The fix to `migrate_unit_keys()` belongs to
SAFETY-44 reopened as a defect, shown red before it; this commit is the
instrument that shows it red.

THE CHECK CAN FAIL, AND WAS SEEN TO. `test_rehearse.py` establishes two CLEAN
CONTROLS first - two transitions the shipped code gets right, on which the
harness must say nothing - and then breaks the shipped migration and the
entity-id composition six ways in a private copy of the tree, requiring each to
turn its control from green to red WITH THE RIGHT FINDING NAMED. One mutation
is required to SURVIVE, with the reason written down. The synthetic state
carries maintenance history and a legacy-shaped latched `sign_tripped` with no
`schema_version` beside it, because state built by the happy path cannot detect
a migration that only mishandles legacy shapes.

⚠️ THE FIRST DRAFT OF THE HARNESS HAD HABIT 4 IN IT and its own battery caught
it - MUTATION.md's thirteenth instance, third in an instrument. The entity diff
composed both sides with the same build, so renaming the MQTT device moved every
id equally and it reported "no orphans" about a change that orphans everything.
`--live-entities` is the fix: a reference that does not move with the code.

THE RETREAT is supported and does not corrupt state - the flat rollback view
keeps the latch and the first pack's history live - but it orphans eight HA
entities, and the ROUND TRIP is not supported: a maintenance cycle completed
while rolled back is discarded on re-advance. Stated in DOCS.md as a decision.

Fetching /data/state.json, the options blob and the entity list are OWNER STEPS
and are written down. ⚠️ Nothing here was run against real state and nothing
here claims to have been.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:15:05 +02:00
admin 37dd0f00cc Merge pull request 'SAFETY-43: delinquency measured against the ask, not against a flat 500 W' (#69) from SAFETY-43 into release/1.0 2026-09-08 05:56:44 +02:00
glenn schrooyenandClaude Opus 5 1448bd909b SAFETY-43: the no_account switch was graded by nothing
checks / unit (pull_request) Successful in 6m29s
Review's remaining non-blocking finding, and it is B1's shape one level down -
in the fix for B1.

`wedged_loop`'s `no_account=True` was used in exactly ONE place: the cut-out
fixture, where it PROVABLY cannot change the answer, because a pack at its own
cut-out is never asked and the gate is never reached. Review neutered the switch
so it removed nothing, and the file stayed GREEN while printing

    ...named=(nobody); with account() DELETED: 0/200

with `account()` not deleted. A line asserting a mechanism that did not happen.

⭐ THE SWITCH DOES BITE, ON A FIXTURE THIS HELPER COULD ALREADY REACH. At the
same cut-out with EQUAL weights, `plan()` hands B a 50/50 share, so the account
gate is the only thing keeping it silent. Measured:

    cut-out, OPT-09 weights   gate on   0/200   gate DELETED     0/200
    cut-out, EQUAL weights    gate on   0/200   gate DELETED   200/200

Asserted. Driven: neutering the switch now goes RED on exactly that check, and
nothing else.

Two things this buys beyond closing the finding. It proves the switch removes
the gate, so the printed "with account() DELETED" line is about a run in which
something was. And it gives part 8b the end-to-end account-gate control ON A
POSITIVE SHARE that the block previously had to outsource to test_delivery.py
part 1 - the arm is no longer only true, it is locally demonstrated.

Suite: 27 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 04:38:18 +02:00
admin a0d981731c Merge pull request 'SAFETY-31 AC 4: the alarm's attribution was graded by nothing, and a mutation proved it' (#68) from SAFETY-31-attribution into release/1.0 2026-09-08 04:27:16 +02:00
Claude Opus 5 b21e229adf SAFETY-43 round 2: the cut-out "positive control" was vacuous, and the record
checks / unit (pull_request) Successful in 6m29s
stated the wrong mechanism

⚠️ REVIEW FINDING, BLOCKING, AND IT IS THE EVIDENCE THAT WAS WRONG RATHER THAN
THE BEHAVIOUR. Part 8b's cut-out arm claimed an "identical power trace" to the
wedged arm and that "what separates them is `account()`". EVERY PART OF THAT WAS
FALSE, and the check survived deleting the gate it named. Measured:

    cut-out arm, account() live        named 0/200, distinct B shares: [0]
    cut-out arm, account() FORCED None named 0/200, distinct B shares: [0]
    wedged  arm, account() live        named 200/200, shares 77/125/133/154 W

At its own cut-out the pack's usable energy ABOVE that cut-out is zero, so
`weights()` answers 0.0, `_fill()`'s preferred pass gives it nothing, the healthy
sibling covers the load alone so the last-resort pass never runs, and its share
is 0 W ON ALL 200 CYCLES. A unit with a 0 W share is not in `asked`, so
`len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()` is
never consulted. Nothing was identical, "100 % of the ask" was 0/0, and the arm
never reached the comparison it was written to grade.

WHAT REPLACES IT IS A BETTER FACT, NOT A WEAKER ONE: at its own cut-out OPT-09
STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached. That
is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the
allocator declines to ask before the detector has to decline to alarm. So the arm
is kept and re-pointed, not deleted:

  * the 0 W share is now ASSERTED (`_sat_last[1][Bee] == 0`), not inferred from
    the silence - a control that cannot say WHY it is quiet cannot fail;
  * the arm runs a SECOND time with `account()` DELETED (`no_account=True`) and
    must still be silent, which is what proves which mechanism does the work;
  * the account gate's claim is re-pointed at test_delivery.py part 1, where it
    is graded on a REAL positive 125 W share.

⚠️ AND IT WAS INVISIBLE FOR ONE REASON WORTH KEEPING: that was the ONLY line in
part 8b that did not print its shares. `inverter-b+0` would have been on screen
from the first run. Every line in the part prints them now. The derated control's
vacuity guard also now asserts ITS OWN unit is asked, rather than re-asserting a
different run's 200/200 - which is exactly why the old guard could not see this.

ALSO, from the same review:

  N1  app/delivery.py said 0.05 was "twelve times more sensitive" than 0.85.
      0.85/0.05 = 17 and no reading of the pair gives 12. An unsupported number
      in shipped source; replaced with what was actually measured.
  N2  M14's recorded count was 4 named checks in test_delivery.py and it is 5 -
      part 1's pre-existing "a shortfall AT the threshold is not one" also goes
      red. It under-claimed, so it never inflated the evidence, but a recorded
      figure that does not reproduce is still one.
  N3  test_delivery.py part 1 printed the alarm sentence with a bare
      `delinquent(...)["b"]`, which raises KeyError under M12/M13 and ABORTED
      THE FILE AT PART 1, leaving parts 2-8 ungraded. The direction is safe (an
      abort is a red; MUTATION.md's defect fabricates only greens) but the
      recorded coverage read smaller than the truth. It is a `.get` now, and
      M12/M13's counts are re-run: 7 and 7 named checks, not 5 and 6.

CFG-07: the assessment is unchanged (worse by one control cycle, 35.0 -> 40.0 s,
deliberately not fixed here) and now says WHEN it has to close - BEFORE INVERTER
B IS COMMISSIONED (~2026-09-12), because the band is unreachable at N=1 and
commissioning B is when it first becomes reachable - and that CFG-07 HAS NO
DECISION DOCUMENT IN THIS TREE, so whoever takes it writes one first.

No behaviour change. `delinquent()`'s two arms, the fraction, `min_w` and the
N=1 path are all untouched by this commit. 27/27 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 04:15:07 +02:00
Claude Opus 5 2271e147c6 SAFETY-43: delinquency measured against the ask, not against a flat 500 W
checks / unit (pull_request) Successful in 6m20s
`delivery.delinquent()` named a unit whose `short_by()` gap exceeded an
ABSOLUTE `min_w = Tuning.saturation_w = 500 W`. OPT-09's SoC-aware weights
shrink a low-headroom unit's share, and shrinking the share shrinks the gap, so
a unit standing at +125 W and delivering 0 W - 100 % delinquent - was never
named. Measured end to end on the real loop (test_packshare.py part 8b):

    load 1500 W, B wedged at 15 %, equal weights    named on 199/200 cycles
    load 1500 W, B wedged at 15 %, OPT-09 weights   named on   0/200 cycles

⚠️ The suppressed regime is the quiet one: at a load the healthy sibling covers,
the meter is satisfied, `unmet_w` is 0 and no action-log line fires, so this
alarm is the ONLY instrument that would have told the operator.

WHAT CHANGED, in one line:

    named if  short_by() > min_w  OR  short_by() > frac * share

⚠️ `min_w` WAS NOT LOWERED. It exists to stop normal saturation being reported
as delinquency, and lowering it trades a missed alarm for a false one on every
unit at once - SAFETY-41's four-attempt regime. This is a SECOND way to be
named, not a looser first one: all seven of test_delivery.py part 1's existing
fixtures answer identically under both rules, and the absolute arm's suppression
window is still exactly 1000 W / 6500 W, still asserted in part 8b.

THE FRACTION IS DERIVED, 0.85. admin/goodwe:sim/delivery_window.py gained a
`--fractions` axis and grades the constant over the SAME 2640 no-fault runs that
produced `delivery_hold_s`'s 35.0 s floor. Worst continuous legitimate episode,
worst of three families, twenty fractions:

    frac   0.05  0.10  0.15  0.20  0.25  0.30  0.35  0.40  0.45  0.50
    worst  90.0  90.0  70.0  70.0  60.0  55.0  55.0  60.0  45.0  45.0  s
    frac   0.55  0.60  0.65  0.70  0.75  0.80 *0.85  0.90  0.95  0.99
    worst  40.0  40.0  45.0  45.0  45.0  45.0  40.0  40.0  40.0  40.0  s
    absolute arm alone: 35.0 s (SAFETY-42 §3.3, reproduced in the same runs)

⚠️ NO CLIFF ANYWHERE: 0.45-0.99 is flat within one control cycle, and it is not
monotone - §3.3's own mechanism (c). ⚠️ "Zero false namings" is NOT achievable
at any fraction and the record says so; what is zero is false ALARMS - 90.0 s
worst against a 185 s hold, so nothing comes within 95 s of notifying anybody.
N=1 control 0.0 s at every fraction in all 2640 one-inverter runs; flapping
0.0 s in all 840.

DRIVEN. Part 8b pinned the SUPPRESSED behaviour and said "if it ever goes red
because the alarm fired, read the card that made it fire". It now grades BOTH
rules on one run - `share_frac=None` is SAFETY-42's rule exactly:

    OPT-09 weights, absolute arm alone   named on   0/200
    OPT-09 weights, the shipped rule     named on 200/200

POSITIVE CONTROL. Same fixture, same weights, same 125 W share, one legitimate
fact changed: a pack at its own 10 % cut-out is named on 0/200 (account() still
gates it), and a unit delivering HALF its share is named on 0/200.

N=1 BYTE-IDENTICAL, asserted: `plan()` handed a `weights` mapping that raises on
any read still answers identically at a roster of one, and the same mapping
raises at a roster of two; `delinquent()` answers {} on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.

MUTATION, run on fresh :ro trees: M12 (drop the relative arm), M13 (frac -> 1.0)
and M14 (frac -> 0.05) are all red on named checks. The constant cannot move in
either direction unseen.

⚠️ CFG-07 gets ONE CONTROL CYCLE WORSE and is deliberately not fixed here: the
worst legitimate episode goes 35.0 -> 40.0 s, so the schema's `int(30,900)`
false-tripping band widens from [30,35] to [30,40]. CFG-07 should be re-derived
against 40.0 s.

No option value, no schema bound and no default changed. 27/27 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 03:14:16 +02:00
admin 6719bd882f Merge pull request 'TEST-21: delivery_hold_s's floor re-derived — 30.0 s did not reproduce, it is 35.0 s, and 185 still stands' (#67) from TEST-21 into release/1.0 2026-09-07 22:41:35 +02:00
glenn schrooyenandClaude Opus 5 f76639c7b2 TEST-21: two of the argmax's four axes are on an edge, and the doc did not say so
checks / unit (pull_request) Successful in 6m22s
§3.3 already set the standard - "a worst case sitting on an edge of the grid
would be a number about the grid" - and then did not check its own argmax
against it. Review did. Reproduced here independently before writing it down:

    stagger  0 -> 1 -> 2 -> 3 -> 4      0.0  15.0  25.0  30.0  35.0 s
    weights (A) at stagger 4            0.5 -> 20.0   0.7/0.9/0.99 -> 0.0
                                        0.10 -> 35.0 s

Strictly increasing in stagger, maximal at the largest modelled stagger, and
maximal at the most asymmetric split. Interior only in `gain` and `slew_w`.

⚠️ SO THE HEADLINE IS 0.0 s AT stagger=0. The 35.0 s is ENTIRELY a product of
the modelled serialised-write stagger, which is declared MODELLED in the harness
and is a TERM of the figure rather than a detail of it. A site whose two writes
land in the same cycle does not produce this episode at all.

It does not move `delivery_hold_s`: 185 s must cover the worst the modelled site
can produce, and four cycles of stagger is inside what a real two-inverter write
path can do. What changes is that a reader asking "is 35.0 s about the plant or
about the grid?" now gets the answer instead of the invitation.

⚠️ THE PR BODY HAD CLAIMED THE ARGMAX WAS "interior to the grid, not on an edge
of an axis I had to choose". That was wrong, and it is the claim the doc's own
standard exists to test. The grid is the record's own, fixed by §3.3 before this
rebuild ran, which is the mitigating half and is stated too.

Also: `nl.yaml` wrote "35.0 s" beside "5,29 keer" and "75,0 s" in one sentence.
Dutch takes the comma.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 21:47:08 +02:00
adminandClaude Opus 5 57775d4e06 TEST-21: delivery_hold_s's floor re-derived, and 30.0 s did not reproduce
No code and no option changes. What changes is a number this repository claimed
about a shipped safety constant.

`delivery_hold_s: 185` shipped resting on "30.0 s, the worst continuous
legitimate episode at the shipped threshold across all 2640 runs", plus a lag
sweep giving 45.0 s and 90.0 s - from a harness `DECISIONS/SAFETY-42` §1
deliberately did NOT commit, because it imports `plantsim` and `frame_check` out
of admin/goodwe. So the floor under a safety constant could not be re-run. That
is PROC-01's shape, with eleven instances in three days.

The harness is committed now, in admin/goodwe as `sim/delivery_window.py`, and
NOT here. `plantsim` and `frame_check` are that repo's; restating them in this
tree is CI-08's coupling, which has broken three times, and the alternative
fate - skipped for want of its imports - is a green that establishes nothing.
There the dependency runs one way: THIS repo's `control`, `arbiter` and the
shipped `delivery.delinquent()` are imported live out of whichever checkout is
under test. Its `--selftest` is now a step in that repo's `checks.yml`, so a
change here that moves `saturation_w`, `max_w`, `delivery_hold_s` or the shape
of `delinquent()` goes red there instead of leaving a stale number here.

    cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
        python3 -B delivery_window.py --selftest --campaign --blag

⚠️ THE HEADLINE DID NOT REPRODUCE: 35.0 s, not 30.0 s. Recorded as measured. The
harness was not adjusted until it agreed - the one correction made during the
rebuild was made AGAINST the doc's number (§1 records it) and made every figure
LARGER.

WHAT REPRODUCED - and it is the part the compatibility promise rests on:
  * the N=1 control arm is 0.0 s at EVERY threshold in EVERY run. §3.2's algebra
    measured, not argued.
  * the flapping family is 0.0 s everywhere in all 840 runs - the family that
    defeated SAFETY-30's site window still cannot touch this check.
  * 45.0 s at twice A's lag, to the cycle.

WHAT DID NOT:
  * the headline, by one control cycle (30.0 -> 35.0 s); the reversal family by
    two (20.0 -> 30.0 s).
  * the NON-MONOTONE curve. §3.3 finding 3 read 55.0 s at 250 W against 40.0 s
    at 100 W as a property of the reduction. This rebuild's curve is monotone.
    The mechanism is real and is still the reason not to read either table as a
    sensitivity curve, but it is an ARGUMENT now, not a measurement, and it is
    written down as weaker.
  * the low-threshold rows, badly (40.0 -> 120.0 s at 100 W). Nothing ships
    there, but that gap is the honest measure of how much of the original table
    was a property of axes nobody wrote down. §3.3 fixes every axis COUNT and
    RANGE and no individual value; the rebuild prints its choices above its own
    table so the caveat travels with the number.
  * the four-times-slower row moved the OTHER way: 75.0 s, not 90.0 s.

185 s IS NOT CHANGED. It is 5.29x the re-measured worst (the record claimed
6.2x), 2.47x the four-times-slower case, 6.2x the firmware's LINK_STALE_S veto
and 4.9x inside the billing quarter. All four bounds clear and none narrowly,
and the ordering of the argument is unchanged. Moving a shipped safety constant
because a margin went from 6.2x to 5.3x while every bound holds would re-open
SAFETY-41's four-attempt false-trip regime for nothing.

⚠️ ONE CONSEQUENCE AN OPERATOR CAN HIT. At 30.0 s the rig's floor coincided with
the firmware's veto and a reader could not tell which was binding. At 35.0 s the
rig's is strictly the binding one - and it is ABOVE the schema's own
`int(30,900)` minimum. `delivery_hold_s: 30` is therefore below a MEASURED
legitimate episode and would report on ordinary saturation. Both translations
now say so. Raising the schema floor is a change to a shipped option's contract
and is left to its own card rather than smuggled in here.

⚠️ THE B-LAG ROWS ARE RELABELLED THROUGHOUT. Inverter B does not physically
exist (~2026-09-12); `plantsim.INVERTER_B` is A COPIED, `measured=False`. Every
two-inverter figure in this record is two copies of A, and the lag sweep is a
SENSITIVITY STUDY ON A HYPOTHESIS ABOUT B, not a measurement of it. The old
wording - "MEASURED, not felt" - overstated exactly that, and "the RAMP does not
move it at all" was too strong: halving it is worth 0 s at A's lag and +5 s at
twice it.

Touched: DECISIONS/SAFETY-42 §1 and §3.3, app/delivery.py's provenance comment,
config.yaml's `delivery_hold_s` block, both translations, CHANGELOG Unreleased.
26/26 suites pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 21:47:08 +02:00
glenn schrooyenandClaude Opus 5 9099ceb1af SAFETY-31: the grid is 42, not 90, and the wrong number was in the failure text
checks / unit (pull_request) Successful in 6m27s
Review reproduced every figure in both PRs except one, and rejected on it.

⚠️ "0 of 90 GAINS x SPLITS combinations produce opposing shares" - the SUBSTANCE
is right and review re-measured it, but the DENOMINATOR is invented. sitesim.py
declares GAINS with 7 entries and SPLITS with 6: the grid is 42. 90 is not even
divisible by 7, so no reading of "GAINS x SPLITS" reaches it. It is almost
certainly TEST-20`s RUN count (84 + 6), which is a different quantity that
happened to be nearby.

⚠️ AND THE WORST COPY OF IT WAS INSIDE THE `PRECONDITION FAILED` TEXT, so the
next engineer to see this scenario go red would have been handed an
unreproducible measurement as their diagnosis - the exact defect class this
campaign exists to correct, in the failure path where it does the most harm.

Corrected in all seven places. At the failure-text site the denominator is now
`len(GAINS) * len(SPLITS)`, computed rather than typed, so it cannot drift from
the grid again - the same fix test_translations.py already took.

Also N3: a missing blank line was going to render the S3 and M19 paragraphs as
one in DECISIONS.

Re-run after the change: `inject.py two_master_circulation` PASS in 35 s, norig,
no rig lock taken. Add-on suite 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 20:13:01 +02:00
glenn schrooyenandClaude Opus 5 295a2aedb6 SAFETY-31 AC 4: the alarm's attribution was graded by nothing, and a mutation proved it
checks / unit (pull_request) Successful in 6m27s
`pos, neg = opposing(...)` -> `([], [])` in main.py's `check_circulating()`
(-102 B) deletes the "Right now X is discharging while Y charges" clause from
the operator notification, and SURVIVED ALL 26 FILES. The commit that shipped
this card claimed "18 mutations, all RED"; the ledger has no entry for the
attribution, and this is that entry.

It is not dead code. Ledger M18 - the whole `await check_circulating()` call
removed - is red on four checks, so the function runs. The three checks on the
notification asserted METER CANNOT SEE, CONTROL HAS BEEN STOPPED and `isolator`,
and never an inverter name. `circulation.py`'s own docstring calls that sentence
the first thing somebody woken by this alarm needs and "impossible to
reconstruct from the number alone".

test_watchdog.py section 6a closes it on a fixture that can tell three failures
apart:

  * the roster is ["garage", "shed", "cellar"] - operator names, none of them
    `UNIT_A`, for test_maintenance.py section 11's reason: on a fixture whose
    names are the module's own constants a check cannot tell the roster from
    the constant;
  * the site is run TWICE, MIRRORED, so the sentence is not symmetric and a
    pos/neg swap is a different red from a deletion;
  * a third pack rests at 5 W, so the floor that stops a pack in standby being
    named as a participant in a 6 kW loop is graded too.

Mutations, on a tree with no __pycache__ mounted :ro into the checks image:

  M20  pos, neg = opposing(...) -> ([], [])          -102 B  RED (2 checks)
  M21  pos and neg exchanged                          +0 B   RED (3 checks)
  M22  opposing(..., min_w=cfg.min_w/2.0) -> 0.0      -25 B  RED (3 checks)

26/26 files green with the block in place.

AND A DISCLOSURE, WHICH IS THE UNCOMFORTABLE HALF. DECISIONS section 3 records
that the 924-run campaign behind `circulation_w: 700` and `circulation_hold_s:
75` used a harness that is not committed in either repository. Two further
sweeps have now been run and no two of the three agree: peaks of 1700 W, 2750 W
and 1048 W, and worst-continuous figures above 700 W of 5.0 s, 15.0 s and 5.0 s.

That is NOT evidence of fabrication and is not reported as any. The committed
sweep measures why the spread is that wide: on one grid, changing only the
plant model and the write-stagger set, the worst legitimate peak is 1048 W, or
2250 W, or 0 W. The plant and the stagger are TERMS of the number. What the
spread does establish is that the claim cannot be adjudicated while the
instrument is missing.

The window argument survives and is now committed - all three sweeps trip zero
times, on 924 + 48 + 840 non-faulty runs. The threshold CURVE does not survive
as stated: `505.0 s at 100 W`, which is what the "knee at 400 W" argument rests
on, is 10.0 s on the committed grid. The curve still falls with the threshold,
so the knee is real in direction; its depth is established by nothing anybody
can run, and this commit says so rather than repeating it. The overclaiming
comment at test_watchdog.py's 1700 W fixture ("the worst single transient the
rig can produce") is replaced with what the fixture can support.

The committed, re-runnable half is admin/goodwe's `two_master_circulation`,
opened as a separate PR against that repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 19:08:33 +02:00
admin bb469163a6 Merge pull request 'UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02' (#66) from UX-04 into release/1.0 2026-09-07 19:03:35 +02:00
glenn schrooyenandClaude Opus 5 477911afa1 UX-04: the 59 was computed and then graded against "> 15"
checks / unit (pull_request) Successful in 6m22s
Review approved and found four things worth fixing. Three are cosmetic; the
fourth is the same defect this card is about, one level up.

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

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

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

Driven, both directions, against the real 2026.08.0 image:

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

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

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

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

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

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

⚠️ AND THE 12 HIDES THAT ARE EXPRESSIBLE WOULD BE BOUGHT WITH THE UPGRADE PATH.
A key absent from options: has no default for the Supervisor to deep-merge into
an existing box's stored blob. That is exactly the delivery_hold_s mechanism
this release rests on: a REQUIRED key a 0.7.0 blob has never stored resolves to
its options: default of 185 instead of becoming exit 78. Hiding a key is
precisely the edit that removes that. It would also promote the 27 unpinned
in-code literal fallbacks the new check ledgers into the ACTUAL source of the
defaults on a live install - the CFG-03 defect the same card's AC 4 exists to
police, made load-bearing by its AC 3.

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

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

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

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

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

⭐ ONE PREVIOUSLY UNREPORTED DIVERGENCE, and it is what that last check is for:
main.py:290 answers a missing target_grid_w with 0 where config.yaml ships -10.
It cannot reach an operator - the deep-merge guarantees the key is present - and
the only paths that hand over a partial dict are sim/inject.py and this repo's
fixtures. Recorded as the single permitted divergence with its reason rather
than corrected; changing it alters the control loop's target and belongs to a
card that grades the loop. A second divergence goes red.

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

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

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

Both plants removed.

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

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

  1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches
  the rail collapsing to the 2000 default under an explicit 400 W slew

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

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

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

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

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

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

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

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

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

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

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

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 17:27:36 +02:00
glenn schrooyenandClaude Opus 5 0b653c35a6 PKG-13: the no-op half of the pre-deploy check has never run as documented
checks / unit (pull_request) Successful in 6m19s
⚠️ THE INVOCATION ON PKG-09's CARD AND IN THE TOOL'S OWN DOCSTRING RUNS ONLY
HALF THE CHECK. The container runs as uid 0 against a checkout owned by the
operator, and git refuses that:

    fatal: detected dubious ownership in repository at '/w/goodwe-addon'

`_git` returned non-zero, `last_version_change()` returned None, and the
silent-no-op check - THE REASON PKG-09 EXISTS - reported SKIPPED. Measured, on
the documented command against release/1.0:

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

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

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

⚠️ THE TWO HALVES WERE MUTUALLY EXCLUSIVE ACROSS THE TWO DOCUMENTED
ENVIRONMENTS. In the container the validator ran and git skipped; on a bare host
git ran and the validator skipped on aiohttp. NEITHER DOCUMENTED COMMAND RAN
BOTH. The pre-0.9.0 clearance was sound only because it happened to be driven as
two separate invocations.

⚠️ AND IT WAS INVISIBLE IN THE ONLY CASE ANYONE HAD RUN. `main()` consults the
git result only when `shipped == deployed`; every real run so far compared 0.7.0
against 0.9.0 and printed its verdict without ever needing git.

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

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

  - a directory that is NOT a git repository, so the lookup genuinely fails:
    the gate must exit 2 saying INCONCLUSIVE and must NOT say CLEARED.
  - ⚠️ THE EXIT CODES PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0` is
    satisfied by collapsing NOT-CLEARED into REFUSED, which review found the
    earlier draft could not tell apart. 1 says "this deploy is wrong"; 2 says
    "nobody checked". Driven: collapsing 2 into 1 now goes red on exactly that
    check.

Suite: 26 files, all pass.

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

Replaced with what was measured, twice:

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

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

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

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

Suite: 26 files, all pass.

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

  - `options:` and `schema:` hold 71 top-level keys on both 06661c5 and HEAD.
    The one schema addition is a PER-ENTRY key, `capacity_kwh: float(0.1,200)?`,
    and the trailing `?` makes it OPTIONAL.
  - An unmodified 0.8.0-era config validates clean on 0.9.0. Measured through
    the shipped validator on both the flat single-inverter shape the reference
    install runs and an N=2 roster with no `capacity_kwh` on any entry: 0
    problems each.
  - OPT-09's two new refusals fire only on configs that USE the new key - an
    unparseable value, or the key stated on one entry and left empty on another.
    Neither can reach a config written before the key existed.

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

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

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

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

Suite: 26 files, all pass.

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

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

THE REFUSED CONFIG. A config the shipped validator rejects is SystemExit(78) -
the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES WITH IT, because the
add-on serving it is not running, so recovery means the Supervisor UI.

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

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

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

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

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

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

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

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

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

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

Suite: 26 files, all pass.

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

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

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

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

So the alarm returns BELOW the formula bound, not above it, and the largest
site target reached with the wedged unit still under the 500 W threshold is
5320 W - not 6500. The equal-weights "suppressed below 1000 W" bound is
unreachable for the same reason: the equal arm names the wedged unit 199/200 at
EVERY load swept, 1000 W included.

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

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

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

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

Suite: 25 files, all pass.

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

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

The first clause is true and is about the ENVELOPE. The second clause is
FALSE, and it was load-bearing for the claim that OPT-09 and SAFETY-42 meet
without overlapping. A weight is a function of a pack's ENERGY HEADROOM, not
of what the pack delivers, so a wedged unit's weight moves whenever its
headroom differs from its sibling's - the ordinary case this card exists to
exploit. And delivery.delinquent() (app/delivery.py:484, threshold at :531)
names a unit only while its short_by() gap exceeds min_w = Tuning.saturation_w
= 500 W in ABSOLUTE WATTS. Shrinking the share shrinks the gap below the
threshold, and the alarm stops firing on a unit still delivering nothing.

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

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

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

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

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

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

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

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

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

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

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

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

   test_packshare.py exits 1. Restored: exits 0.

3. THREE PROC-01 PROSE DEFECTS

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

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

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

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

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

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

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

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

SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops
the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a
pack that said it could does not - and its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:55:20 +02:00
admin 3d2999337d Merge pull request 'UX-02: the validator's refusals, next to the field, before the save' (#61) from UX-02 into release/1.0
UX-02: the validator refusals, next to the field, before the save
2026-09-06 22:45:49 +02:00
59 changed files with 14697 additions and 335 deletions
+722
View File
@@ -2,6 +2,492 @@
## Unreleased
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`,
so `ha apps update` is a silent no-op on an unbumped change. This branch
deliberately does **not** pick the number, because the right one depends on a
fact the branch cannot observe - whether 0.9.0's image has already been built
and pulled. If it has not, this belongs inside 0.9.0's section before it ships;
if it has, it needs 0.10.0. Whoever deploys decides.
| | |
|---|---|
| _this branch_ | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
| _this branch_ | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard |
| _this branch_ | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
| _this branch_ | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
| _this branch_ | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
### TARIFF-09 - the feed is asked for the range the caller wanted
**`generic_dayahead` took `since` and `until` and requested the bare feed URL,
which answers one Brussels day.** Everything past today was then filled from
yesterday's profile by FCST-04, flagged provisional, and §8.3 drops provisional
hours from the trade set - so **the optimiser never saw tomorrow as firm, at any
hour of any day.** Measured on `4257e38` at 2026-09-10 09:00 CEST over the
shipped 48 h horizon, live feed, yesterday seeded:
| | 48 h horizon, one-day feed |
|---|---|
| firm | **15 whole hours** (60 quarter-hour points) |
| carried, provisional | **33 hours** - 24 on D+1, 9 on D+2 |
| permitted cycle | buy 11:45, sell 19:45 - **both on 2026-09-10** |
| firm local days | `[2026-09-10]` |
The ceiling was structural, not incidental: with a one-day window, at hour *H*
of the local day a 48 h horizon is at most **24−H hours firm and 24+H carried**.
At 23:00 that is **1 firm hour and 47 carried**, and the remaining firm spread
(0.0464 EUR/kWh) no longer clears the default 0.10 round-trip threshold at all -
so arbitrage was refused outright. This is **not** what FCST-04's fill is for:
that covers the window until the ~13:00-14:00 CET auction, after which a healthy
feed publishes tomorrow in full.
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns 192 quarter-hours across two
local days, committed unmodified beside TARIFF-08's captures. The plugin simply
never asked.
**The range is now derived from `since`/`until` on the BRUSSELS calendar**, as
two inclusive dates. Not the UTC calendar: 22:30 UTC in summer is already
tomorrow in Belgium, so a UTC-derived start asks for the day the controller has
just left and drops the day it is standing in, for the last one or two hours of
every day. That is `price_history._bdate()`'s lesson one layer up, with a worse
consequence - `_bdate()` misfiles an hour, this never fetches it. `until` is
exclusive, so a horizon ending exactly at local midnight asks for one day fewer
than the naive reading gives, and an inverted window collapses to a single day
rather than a backwards range.
Same code path, same window, a feed serving both days:
| | before | after |
|---|---|---|
| firm hours of 48 | 15 | **39** |
| carried, provisional | 33 | 9 |
| firm local days | 1 | **2** |
| permitted cycle | one calendar day | **buys on one day, sells on the next** |
The 9 remaining carried hours are the tail of the horizon that runs into a third
Brussels day, which no day-ahead market publishes. FCST-04 still fills them and
still flags them; what changed is what is firm, never what may be acted on.
**Before publication nothing regresses, and that is graded rather than
assumed.** Asking for tomorrow before the auction returns fewer days than
requested - measured, at 09:46 CEST the two-day request came back byte-identical
to the one-day body - and that stays a `series` of what exists, with FCST-04
filling the remainder. A `no_data` there would switch a working site's arbitrage
off every morning.
⚠️ **`price_feed_url` is the EXACT URL and the plugin appends nothing to it, so
an overriding site keeps the short horizon.** That is a decision, not an
oversight: `start`/`end` are this feed's spelling, the provider declares a second
host whose parameters are different, and rewriting a URL an operator typed is
how a request nobody intended gets made. **It is not silent** - the plugin logs a
`WARNING` at fetch time naming the consequence and the `start..end` it would
otherwise have asked for. See
`DECISIONS/TARIFF-09-the-range-and-the-override.md`.
The plugin contract - `fetch(self, opts, since, until, transport)` hands every
plugin the range - is still **not** enforced, because three shipped plugins point
at endpoints nobody has verified and a rule obliging them to pass a range would
be satisfied by an invented parameter name. Which plugins consume the range is
pinned as a literal in `test_price.py` instead, so the next supplier has to
decide rather than inherit the silence.
### FCST-06 - the second way to be a store that cannot persist
**A price cache that could not write went on answering a yesterday the file did
not contain.** `price_history.py`'s docstring states its rule unconditionally -
*"A store that cannot persist now records nothing, and both of its answers say
the same thing"* - and FCST-04 enforced it against `not self.ok`, which is one
**symptom**: the durable volume is missing. `ok` is about the DIRECTORY, and a
read-only remount and a full disk both leave it `True`. Reproduced on `5059e28`
against two real volumes - a `/data` bind-mounted read-only and a full tmpfs:
| | with a `/data` that cannot be written |
|---|---|
| `last_error` | `could not write ([Errno 30] Read-only file system)` |
| `words` | `2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over 24 h) [could not write (...)]` |
| `yesterday()` | **series, 24 points** |
| on disk | `{}` |
`words` and `yesterday()` were describing two different stores, and the 24
points were gone on the next restart with nothing saying so - the
silently-forgetting cache that rule exists to refuse, reached by the condition
it did not name. **No shipped path reaches it**: `/data` is the Supervisor's
durable volume and this module is its only writer. The defect is that a
guarantee the module states about itself did not hold in one condition.
**"Cannot persist" is now ONE derived predicate, asked once, about the
outcome.** It cannot be answered *before* the attempt - a directory that existed
and was writable a moment ago is not a write that completed - so `_save()`
returns whether the file now holds what memory holds: `False` with no durable
volume, `False` on an `OSError` from the write, `True` otherwise. `_remember()`
keeps only what that one answer says was kept, and **the `not self.ok` test in
`_remember()` is gone** rather than joined by a second one. FCST-04's M4 and
FCST-05's HIGH were the same shape - a rule enforced on the conditions somebody
thought of - so a third cause now needs no third branch.
**What is undone is the MERGE, not the store.** The state restored is the last
one that *is on disk*, so a site whose volume fills at 14:00 goes on answering
the yesterday it wrote at 13:30 - that record survives a restart, so answering
it is not a promise the store cannot keep. It simply stops adding to it. The
snapshot is a `copy.deepcopy`: a one-level copy shares the day record the merge
rebinds `points` on, and measured with one, a store holding 12 persisted points
answered **24** after a failed merge.
⚠️ **A failed write is still not fatal, and that was a constraint rather than an
oversight.** §6 and this module's opening: a broken cache must not be a broken
controller. An add-on that will not start because `/data` filled up is a worse
defect than the one being fixed, so `_save()` reports and returns - the failure
stays in the log, in `last_error`, in `words` and in `yesterday()`'s reason -
and the shipped `resolve_tariff()` path still returns a provider plan with no
§11.4 degradation. What changed is that it is now *acted on*.
`test_price_history.py` section 5d drives it on two real failures with different
syscalls and different errnos - a directory where the file goes (`os.replace`,
EISDIR) and a volume that moves out from under an open store (`mkstemp`,
ENOENT) - because the rule is enforced on the outcome and one cause would grade
a coincidence. See `DECISIONS/FCST-06-cannot-persist-is-one-question.md`.
### TARIFF-08 - no network price provider had ever parsed a live response
**`generic_dayahead` could not read one byte of `api.energy-charts.info`, and
had never been able to.** `_points_from_json()` asks for `doc["data"]`, a list
of `{start, price_eur_mwh}` objects. That document does not exist. Fetched live
on 2026-09-10, the feed returns **columnar** parallel arrays and no `data` key
at all:
```
top-level keys: ['deprecated', 'license_info', 'price', 'unit', 'unix_seconds']
unix_seconds: 96 UTC epoch seconds, first 1788991200, spacing 900 s
price: 96 floats, first 216.08
unit: 'EUR / MWh'
```
Driving the shipped parser against that body: `SourceUnavailable: the feed
carried no 'data' list`. Since `generic_dayahead` is the only shipped provider
whose endpoint anybody has reached, **dynamic pricing did not work at all**.
⚠️ **AND §11.4 MADE THAT SILENT, WHICH IS WHY IT LASTED.** A parse error becomes
`no_data`, becomes the static profile, becomes arbitrage off - the *correct*
degradation, and it says nothing whatsoever about the cause. A site would have
sat on the fallback indefinitely while the status page reported only that the
feed was down.
⚠️ **THE SUITE COULD NOT SEE IT BECAUSE EVERY FIXTURE WAS BUILT FROM THE
PARSER'S OWN ASSUMPTION.** `test_price.py`, `test_price_history.py` and
`test_static_tariff.py` each built `json.dumps({"data": [{"start": ...,
"price_eur_mwh": ...}]})` - a shape invented here, published by nobody. The
checks graded the parser against the parser's guess: a closed loop that cannot
notice the guess is wrong, and no amount of coverage inside it would have. The
loop is broken by **bytes nobody in this repository wrote**: two unmodified
response bodies are committed under `scenarios/`, one quarter-hourly
(2026-09-10, 96 points at 900 s) and one hourly (2025-09-10, 24 points at
3600 s - the same endpoint before Belgium's 15-minute MTU), with URL, retrieval
time, licence-as-returned and a sha256 recorded beside them in
`energy-charts-be-price-captures.json` and **re-derived** by the replay, so a
fixture edited to make a check pass stops being a capture. The remaining
invented fixtures are renamed `synthetic_row_feed` at every call site.
**The columnar document is read by its own function, `_points_from_columnar_json()`.**
Not by teaching the row parser to sniff for both: a parser that decides which
document it received is a parser that can decide wrong, and a wrong-but-
parseable response then becomes a price. Two shapes, two functions, and each
plugin names the one its feed publishes. Feeding the row shape to
`generic_dayahead` is now a graded `no_data`, so the sniff cannot come back
quietly.
⚠️ **UNEQUAL ARRAYS ARE REFUSED RATHER THAN ZIPPED.** `zip()` stops at the
shorter list and says nothing, so a truncated `price` array would have produced
a **real price series with its tail missing** - and the tail of a Belgian day is
the evening peak. That is worse than no series: `no_data` switches arbitrage
off, whereas a short series is traded on as though complete, and nothing
downstream can tell them apart. Refused in both directions, with the two lengths
in the reason.
⚠️ **A UNIT CHANGE IS REFUSED FOR A REASON `_validate_raw()` CANNOT COVER.** The
plugin converts with a fixed `0.001`; if the feed ever published EUR/kWh under
the same keys, every price would come out a **thousand times too small** and
`MAX_PLAUSIBLE_EUR_KWH` would not fire, because it only bounds prices that are
too large. DATA-02's fetcher already refused on this field; the shipped path
does now too.
**`unix_seconds` are UTC instants, said so explicitly**
(`datetime.fromtimestamp(t, timezone.utc)`); §9.3's rendering at the Brussels
offset stays where it already lived, in `assemble()`, rather than being copied
into the plugin where a mutation could not tell it from a no-op. A published
`null` is dropped and logged with a count - a missing quarter is not a free
quarter - while an all-null body is `no_data` named by this parser rather than
by the boundary validator.
**End to end against the live feed, 2026-09-10:** `resolve_tariff()` returns a
`TariffPlan` with `origin='provider'`, **96 quarter-hourly points**, spacing
`[900]` s and no decimation, first point `2026-09-10T00:00:00+02:00` at 0.30608
EUR/kWh all-in. FCST-04's H1 fix was never theoretical - 900 s is the shape of
the real feed.
⚠️ **WHAT THIS DOES NOT FIX.** `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy` are untouched: their URLs are unverified, no real body can be
captured for them, and withdrawing them is TARIFF-07's open decision. Their
fixtures are now named `synthetic_row_feed` so nothing reads them as evidence
that those feeds parse. And `generic_dayahead` still ignores `since`/`until`
and asks for no date range, so the default URL returns one published day - a
full 48 h horizon is reached only through §8.3's carry-forward fill.
### FCST-05 - what FCST-04's own contract still did not hold
**A single non-UTF-8 byte in `/data/price_history.json` stopped the add-on from
starting.** `_load()` caught `(json.JSONDecodeError, OSError)`.
`UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError` -
neither of those, and `issubclass(UnicodeDecodeError, OSError)` is **False** -
so it escaped `_load()` -> `_open()` -> `PriceHistory.__init__`, which
`Controller.__init__` does not guard. Reproduced: `UnicodeDecodeError: 'utf-8'
codec can't decode byte 0xff`, and **the controller did not come up**. That is the same class as the day-record defect the store was reworked
for, in the one byte class the rework left uncovered: valid-UTF-8-invalid-JSON,
a UTF-8 BOM and a non-dict day record all degraded correctly. The suite could
not see it because its "unreadable file" fixture writes `"{ this is not json"`,
which is **valid UTF-8**. The handler now names the `ValueError` family - the
file's CONTENT - beside `OSError`, the file's BYTES, and a fixture holding a
genuinely undecodable byte is in `test_price_history.py` section 5.
**A malformed `start` under the day the feed is writing to killed the tariff
plan permanently.** `_remember()` merged a day's points with
`{q["start"]: q ...}` and sorted on `q["start"]`, trusting a value read off
`/data` to be a hashable, sortable string. An `int`, `null`, a `dict` or a
`list` there raised `TypeError`; `run_tariff_refresh` catches `Exception`, so
the add-on stayed **up** while **every refresh raised, forever** - the plan
retiring after `TARIFF_MAX_AGE_S` and arbitrage and the cheap window off for
good, with no self-heal because `_remember()` raised before reaching `_save()`.
The read path (`_parse_points()`) was properly defensive throughout, which is
why `yesterday()` survived and the existing fixture passed: it files its
malformed rows under a day the feed is **not** writing to, so `_remember()`
never opened that record. **The two paths now share one validator**,
`_point_of()`, and neither has a standard of its own. A row that is not a point
is dropped and reported, and `_save()` rewrites the record clean.
⚠️ **AND THE STATUS ROW SAID THE STORE WAS HEALTHY WHILE THE PLAN WAS DEAD.**
`words` read `'2026-01-15 (1 h)'` about the record that was killing every
refresh. `_load()` now counts the rows inside a kept record as well as the
record itself, so the store stops reporting plain health from the moment it is
opened; the note names what was **found** at open and what was **dropped** by
the refresh, and clears itself once the file has been rewritten.
⚠️ **AND THE ROW COUNTED POINTS AND CALLED THEM HOURS.** `f"{d} ({_n_points(d)}
h)"` on the quarter-hourly feed this add-on already supports reads
`'2026-01-13 (96 h)'` for a 24-hour day - the same points/hours conflation the
sub-hourly defect above was about, on the row that defect's own card shipped.
The cell is now `2026-01-13 (96 points over 24 h)`: two facts, each under its
own unit, and the hour figure is the one that says whether tomorrow morning's
fill has a whole day to carry.
⚠️ **AND THE MERGE KEY IS THE INSTANT RATHER THAN ITS SPELLING**, which is what
rule 4 already said. Aware datetimes hash and compare by instant. The string
key merged by spelling, and the lexicographic sort beside it put the second
02:00 of the 25-hour October day (`+01:00`) **before** the first (`+02:00`);
only the stored order was affected, because `_parse_points()` re-sorts by
instant on the way out.
⚠️ **TWO STATED REASONS WERE FALSE, AND THIS REPOSITORY TREATS THAT AS A
DEFECT.** `price_forecast._audit()`'s "WHY IT IS STRICTER" section claimed the
rework refused two things FCST-03's audit did not. A differential over the
whole domain the audit can express - every published mask over 3 hours crossed
with every output series of length 0-3 - found **zero divergence in both
directions**: the reworked audit is exactly **EQUAL** to FCST-03's, not
stronger. The old `seen` count already refused the same published point emitted
twice (one point twice is one hour twice), and the old **identity** rule
already refused a carried point sharing a published hour. The only behavioural
change is the intended **relaxation** - several distinct published points in
one hour - and that stays. The docstring and the two check texts now say what
is true, and the differential is committed as a check in
`test_price_forecast.py` rather than left as prose.
⚠️ **AND `_audit` IS NOT WHAT STOPS THE DECIMATION DEFECT RECURRING.** It does
not check **completeness**: fed the decimation directly - four published points
in an hour, output emitting only the last - it **accepts**, and it accepts an
empty output. That is a limit, not a regression, and the module docstring said
otherwise by presenting the audit as the post-condition guarding provenance.
What actually guards it is the point **count** and the pass-through **identity**
checks in `test_price_forecast.py` and `test_price_history.py`, and the text now
points at them. It is **not** made complete: `published` is the whole read while
the fill emits only `[since, until)`, so "every published point appears" is not
a true post-condition of those two arguments. Measured - the rule as stated
takes the 1 h, 12 h and 48 h horizon checks red on legitimate series.
**No option was added, `config.yaml` is untouched, and nothing was deployed.**
**FCST-03 shipped the fill and no caller.** `price_forecast.fill_from_yesterday()`
was complete and checked, and so were the consumers written for it -
`TariffPlan.firm_points()`, `cheap_now()`'s refusal of a provisional hour, and
`arbitrage()`'s refusal 3, which drops provisional hours from the trade set
rather than refusing the whole series. Nothing in `app/` called the fill, so no
series ever contained a carried point and every one of those consumers was
exercising its all-firm branch.
⚠️ **THE DEFECT THAT PRODUCED WAS NOT AN ERROR ANYWHERE.** `load_prices()`
returns what the feed published and nothing widened it to the requested horizon,
so on a dynamic site between midnight and the 14-16 h CET day-ahead publication
the plan covered the rest of today and **nothing more** - typically 10-24 of the
48 hours `price_horizon_h` asked for. `arbitrage()` then took `max - min` over
that truncated window and reported a spread. The number was real, the window was
half the one configured, and **nothing said so**: tomorrow evening's peak, the
hour a morning charge would be sold into, was not in the series at all, so the
spread it would have created was never compared against anything.
**What changes:**
* `app/price_history.py` (new) keeps the **published** prices of the last few
Brussels days in `/data/price_history.json`, and `resolve_tariff()` now fills
an unpublished hour of the horizon from yesterday's same-wall-clock hour,
flagged provisional. ⚠️ **"An" unpublished hour, not "every" one:** the fill
can carry only an hour yesterday actually published, so a partial yesterday
leaves the local hours it never held **out of the series** - §11.4 for those
hours, and asking for one raises rather than answering. Correct behaviour,
and the wording is now the behaviour.
* ⚠️ **A sub-hourly feed keeps every print.** `eneco_eddy` is the imbalance
product and settles per **quarter**; the fill walks whole hours to decide
what needs carrying, and that is not a rule about what a published hour may
contain. Every published point inside the horizon passes through by identity,
and a carried point is added only for a whole hour with no published coverage
at all. The first draft keyed the published set by hour - last point wins -
and a 96-point quarter-hourly feed reached the optimiser as 15 points with
its spread down from 0.143 to 0.014 EUR/kWh, on an all-firm, fully-published
series.
* The horizon is **whole hours at both ends**: `since` is floored to the hour
that contains it, and so is `until`. Asked for 48 hours at 08:17, the plan is
48 hours long and not 49.
* `resolve_tariff(history=...)` is **required and keyword-only**. A caller with
no durable volume passes the explicit `NoHistory()`. A default of `None`
meaning "skip the fill" would hand a caller who forgot the argument exactly
the truncated horizon this card removes, silently.
* A new status row, **Price history**, names the days held, how many points
each holds and how many whole hours they cover (see FCST-05: it shipped
counting points and printing them under the hour's name).
⚠️ **ONE CHANGE HERE MOVES ENERGY, AND IT IS NOT THE ONE THE CARD IS NAMED FOR.**
Carried hours are provisional, so §8.3 keeps them out of the trade set: the fill
widens what the optimiser can **see**, never what it may **do**. What does
change a trade is that the filled series is windowed to `[since, until)`, so
**hours that have already elapsed are no longer in the spread**. Before, a cheap
night that had already gone sat in the series as the day's cheapest hour - and a
cycle cannot buy in an hour that is past, so the trade was quietly never taken.
`test_price_history.py` section 2 grades this by finding the cheapest firm hour
with and without the fill and showing it **moves**.
⚠️ **A DEAD FEED IS STILL §11.4, NOT YESTERDAY.** The fill runs only on a
`series` read; a feed that published nothing still degrades to the configured
`static_profile`. The two are indistinguishable to the optimiser - both are
100 % provisional, so arbitrage refuses on the firm-pair rung and `cheap_now()`
is False - so preferring yesterday would change a documented degradation for a
difference only the status page can see.
`DECISIONS/FCST-04-the-day-that-is-carried.md` carries that and two more.
⚠️ **A BROKEN CACHE IS NOT A BROKEN CONTROLLER.** No `/data`, an unreadable
file, a schema this build does not know (including a `version` of `1.0`, which
`== 1`), a **day record that is not a record**, a malformed point - each its own
state with its own reason, none of them a price and none of them an empty
series. In every one the plan is still a real provider series and the controller
still regulates; the horizon is just as short as it was before this card.
`.words` never raises, because `Controller.__init__` reads it unguarded and a
status line that throws is an add-on that does not start.
⚠️ **AND WHERE THERE IS NO `/data`, NOTHING IS RECORDED AT ALL** - not even in
memory. An in-memory yesterday answers for one run and is gone on the next
restart, which is a cache that forgets without saying so; the honest pair is
`unavailable` on the status row and no prices carried.
⚠️ **"YESTERDAY" IS A LOCAL DATE, AND THE ARITHMETIC IS ON THE DATE.**
`brussels()` renders at a fixed offset, so subtracting `timedelta(days=1)` from
it subtracts 24 **absolute** hours - and the Brussels day before a
spring-forward is 23 hours long. Between 00:00 and 00:59 on the morning after
the March transition that lands on the day *before* yesterday. **No option was
added and `config.yaml` is untouched.**
## 0.9.0
⚠️ **MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK - AND THAT DISTINCTION IS THE
POINT.** 0.8.0 was minor because a config that started on 0.7.0 could be
REFUSED on it (rule 10 against 0.7.0's own default `slew_w: 1000`). Nothing of
that kind is true here, and it was checked rather than assumed:
- `options:` and `schema:` both hold **71 top-level keys**, the same 71 as
0.8.0. The one schema addition is a per-entry key, `capacity_kwh:
float(0.1,200)?` - **note the trailing `?`, it is OPTIONAL**.
- An unmodified 0.8.0-era config validates clean on 0.9.0. Measured on both the
flat single-inverter shape the reference install runs and an N=2 roster with
no `capacity_kwh` on any entry: **0 problems each**.
- The two refusals [[OPT-09]] adds fire only on configs that USE the new key -
an unparseable value, or the key stated on one roster entry and left empty on
another. Neither can reach a config written before the key existed.
So: existing installs upgrade untouched, and the bump is minor because the
option surface and the behaviour grew.
Contents, in merge order since 0.8.0 (`06661c5`):
| | |
|---|---|
| `3d29993` | UX-02 - the validator's refusals, next to the field, before the save |
| `fdb3b69` | OPT-09 - the weights parameter nothing ever passed, and what it cannot do |
| `fcaa724` | PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject |
**UX-02** builds the real configuration surface in the ingress panel, beside the
Supervisor Configuration tab that cannot be removed. The prize is not styling:
`validate.py` writes long cross-field refusals the Supervisor UI *structurally
cannot show*, and until now an operator met them as **exit 78 in a log**, after
saving and restarting. ⚠️ Two config surfaces now exist and the DECISIONS record
names the **stored options blob** as authoritative, neither UI.
**OPT-09** makes the architecture's one free parameter real. `main.py` called
`resolve()` without weights, so the split was a fixed 50/50 regardless of what
either pack could deliver; it now passes SoC- and capacity-aware weights.
⚠️ **OPT-09 SHIPS A DISCLOSED DETECTION REGRESSION AT N=2.** `delivery.
delinquent()` reads an ABSOLUTE 500 W gap, so weighting a low-headroom unit down
to a small share puts a unit delivering **none of what it was asked** under the
threshold. Measured: a wedged inverter goes from named on 199 of 200 cycles to
**0 of 200** at a load its sibling can carry alone. The alarm returns once the
healthy pack saturates - swept, that is a 5000 W load on the fixture, **below**
the proportional-regime formula's 6500 W and not above it. ⚠️ **N=1 IS
UNAFFECTED**: `allocator.plan()` returns before weights are read at all.
⚠️ **CLOSED BY SAFETY-43 ON THIS BRANCH** - see its section below; the regression
above is history, and the record of it is kept because the measurement that
found it is what the fix is graded against.
**PKG-09** adds `tools/predeploy_check.py`. ⚠️ **RUN IT BEFORE DEPLOYING THIS
RELEASE** - it is what caught the fact that 0.8.0 had five runtime commits
behind it, which is why this section exists:
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.8.0 --options /w/live-options.json
Exit `0` cleared, `1` REFUSED, `2` NOT CLEARED. ⚠️ **2 is not a pass.**
✅ **RUN AGAINST THIS RELEASE, 2026-09-07: CLEARED, exit 0.** The reference
install's options were exported from the Supervisor (70 keys) and checked twice
- the stored blob as-is, and the **effective post-upgrade config**, being those
70 keys merged over 0.9.0's 71 defaults. Both pass. `max_w` 5000, `slew_w` 400
against a rule-10 ceiling of 555.6 W.
⚠️ **THE STORED BLOB HAS 70 KEYS, NOT 71, AND THAT IS THE INTERESTING PART.**
`delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running 0.7.0
has never stored it - and it is REQUIRED in the schema, no trailing `?`, which
is the shape that becomes exit 78. It resolves to its `options:` default of
**185**, because the Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`; `tools/supervisor_selfapi_probe.py` §D pins that
against a real Supervisor image rather than assuming it. The second check above
exists because "your saved settings are valid" and "what the box will actually
run is valid" are different questions, and only the second one matters.
⚠️ **THE VERSION WENT UNBUMPED FOR THE THIRD TIME IN THREE DAYS** - PKG-06 six
merges, PKG-07 six, this one five - and this time it was caught by PKG-09's own
tool during PKG-09's review rather than by a person. `ha apps update` against an
unchanged version installs nothing and reports success. PKG-10 carries the
post-merge alarm so that noticing stops being a human's job.
## Unreleased
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`,
so `ha apps update` is a silent no-op on an unbumped change. This branch
@@ -14,6 +500,107 @@ what happens when nobody does.
| | |
|---|---|
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
| _this branch_ | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression |
⚠️ **SAFETY-43 CLOSES THE DETECTION REGRESSION OPT-09 DISCLOSED, AND IT IS A
TWO-INVERTER FIX. N=1 IS BYTE-IDENTICAL.** `delivery.delinquent()` named a unit
whose shortfall exceeded an **absolute** `min_w = saturation_w = 500 W`. OPT-09's
weights shrink a low-headroom unit's share, and shrinking the share shrinks the
gap - so a unit standing at +125 W and delivering **0 W**, 100 % delinquent, was
never named. It now names a unit whose gap exceeds `min_w` **OR** exceeds
`DELIVERY_SHARE_FRACTION` (0.85) of that unit's **own share**. Measured on the
same closed-loop fixture, same weights, same detector:
load 1500 W OPT-09 weights the old rule named on 0/200 cycles
load 1500 W OPT-09 weights the shipped one named on 200/200 cycles
⚠️ **`saturation_w` WAS NOT LOWERED, AND THAT IS THE POINT.** Lowering it would
report normal saturation as delinquency on every unit at once - SAFETY-41's
four-attempt false-trip regime, *"i'm getting really annoyed by the notifications
in my HA"*. This ADDS a second way to be named: all seven of `test_delivery.py`
part 1's existing fixtures answer identically under the old rule and the new one,
and the absolute arm's suppression window is still exactly 1000 W / 6500 W.
**0.85 is derived, not chosen.** `admin/goodwe:sim/delivery_window.py --campaign
--fractions` grades it over the SAME 2640 no-fault runs that produced
`delivery_hold_s`'s floor. The worst continuous legitimate episode is **35.0 s**
under the absolute arm alone (SAFETY-42 §3.3, reproduced in the same run) and
**40.0 s** at 0.85. ⚠️ **THE CURVE HAS NO CLIFF AND THAT IS THE FINDING**: swept
at twenty fractions from 0.05 to 0.99, everything from **0.45 to 0.99** is 40.0
or 45.0 s - within one control cycle of every other - and below 0.45 it rises
gradually to 90.0 s at 0.05. ⚠️ **"Zero false namings" is not achievable at any
fraction and the record says so**; what is zero is false ALARMS - the longest
legitimate episode in the whole sweep is 90.0 s against a 185 s hold, so nothing
comes within 95 s of notifying anybody at any fraction measured, and 145 s at the
shipped one. The N=1 control arm is 0.0 s at every fraction in all 2640
one-inverter runs, and the flapping family is 0.0 s in all 840.
⚠️ **[[CFG-07]] GETS ONE CONTROL CYCLE WORSE AND IS NOT FIXED HERE.**
`delivery_hold_s` is declared `int(30,900)` while the measured legitimate worst
was 35.0 s; it is now 40.0 s, so the band of schema-legal false-tripping values
widens from `[30, 35]` to `[30, 40]`. CFG-07 should be re-derived against 40.0 s.
⚠️ **IT MUST CLOSE BEFORE INVERTER B IS COMMISSIONED (~2026-09-12)**, not before
the next deploy: the band is UNREACHABLE at N=1 - `delinquent()` answers `{}` for
every input at a roster of one, so no `delivery_hold_s` can false-trip on the
install running today - and commissioning B is the moment it becomes reachable.
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE** - it exists as a card
reference here and in SAFETY-43's record and nowhere else, so whoever takes it
writes `DECISIONS/CFG-07-*.md` first.
**SAFETY-43 CHANGES NO OPTION VALUE, NO SCHEMA BOUND AND NO DEFAULT** - the
fraction is a derived constant with a committed harness behind it, deliberately
not a knob, for the reason there is no `delivery_w`. The one edit to
`config.yaml` is a COMMENT: the `delivery_hold_s` block described the check as
"short by more than `saturation_w`", which is now only half of it.
**No behaviour change at one inverter, asserted rather than argued**: `plan()` is
handed a `weights` mapping that raises if anything reads it and still answers
identically at a roster of one (and the same mapping raises at a roster of two,
so the tripwire is real), and `delinquent()` answers `{}` on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` carries the curve, the
positive controls and the CFG-07 assessment.
⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD
CLAIMED.** `delivery_hold_s: 185` shipped resting on *"30.0 s, the worst
continuous legitimate episode at the shipped threshold across all 2640 runs"* -
from a harness `DECISIONS/SAFETY-42-unit-delivery.md` §1 deliberately did **not
commit**, because it imports `plantsim` and `frame_check` out of `admin/goodwe`.
A shipped safety constant the next reviewer cannot re-run is PROC-01's shape.
The harness is committed now, **in `admin/goodwe` as `sim/delivery_window.py`**,
beside `sitesim.py`. It is there and not here on purpose: those two modules are
that repo's, and restating them in this tree is the CI-08 coupling that has
broken three times. The dependency runs one way instead - this repo's `control`,
`arbiter` and the shipped `delivery.delinquent()` are imported live out of
whichever checkout is under test. Re-run it with:
```
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
python3 -B delivery_window.py --selftest --campaign --blag
```
⚠️ **AND 30.0 s DID NOT REPRODUCE - IT IS 35.0 s.** Reported as measured rather
than adjusted to agree. What DID reproduce is the part the compatibility promise
rests on: the **N=1 control arm is 0.0 s at every threshold in every run**, and
the **flapping family is 0.0 s everywhere** in all 840 runs. What did not: the
headline (one control cycle worse), the reversal family (20.0 → 30.0 s), the
non-monotone curve (this rebuild's is monotone, so that finding is now an
argument and not a measurement), and the low-threshold rows, badly. The B-lag
rows moved the other way - a hypothetical unit four times slower than A gives
**75.0 s**, not 90.0 s - and they are relabelled throughout as a **sensitivity
study on a hypothesis about inverter B, which does not physically exist yet**,
rather than as a measurement of it.
**`delivery_hold_s` is NOT changed.** 185 s is 5.29× the re-measured worst
(was claimed 6.2×), 2.47× the four-times-slower case, 6.2× the firmware's
`LINK_STALE_S` veto and 4.9× inside the billing quarter: all four bounds still
clear and none narrowly. ⚠️ One consequence is worth an operator's attention:
at 35.0 s the rig's floor is now **above** the schema's own `int(30,900)`
minimum, so `delivery_hold_s: 30` is below a measured legitimate episode and
would report on ordinary saturation. The option is marked *derived, do not
change*; raising the schema floor is left to its own card.
⚠️ **UX-02 ADDS A SECOND PLACE TO CONFIGURE THIS ADD-ON, AND NO NEW WAY TO
COMMAND THE INVERTER.** The ingress panel gains a Configuration page beside
@@ -50,6 +637,47 @@ already declared, and the panel was already live.
the Configuration tab **can** be removed - at the cost of all 71 options, which
is why it stays. `DECISIONS/UX-02-two-config-surfaces.md` carries both.
⚠️ **UX-04 CHANGES NO BEHAVIOUR, NO SCHEMA AND NO DEFAULT.** It adds one check
file, three assertions to a probe, and a decisions record; `config.yaml`,
`translations/` and every module under `app/` are byte-identical to `6e7b233`.
It closes out [[UX-01]], which shipped in **0.7.0 and is live**, with three
criteria that were never met:
* **AC 3 - "the visible count drops to ~15" - is WITHDRAWN as unsatisfiable.**
`options:` and `schema:` declare the same 71 keys, so the set HA's *show
unused optional configuration options* toggle can hide is **empty**, and
Supervisor's own `UiOptions` marks **59 of the 71 required** - the frontend
shows those unconditionally, so 59 is the floor even with `options:` emptied
altogether. The target was ~4x below what any manifest can reach. ⚠️ And the
12 hides that *are* expressible would be bought with the deep-merge: a key
absent from `options:` has no default for the Supervisor to merge into a
0.7.0 box's stored blob, which is exactly the `delivery_hold_s` mechanism
that makes this release's upgrade safe rather than exit 78. The count was
**printed** by `tools/supervisor_shape_probe.py` §F from the day it was
written and never graded, which is how this passed review; it is now three
assertions, and removing one key from `options:` turns two of them red.
* **AC 4's third clause - the AST check for stray in-code literal fallbacks -
is BUILT**, as `test_defaults_pin.py`. It discovers the six
`*_OPTION_DEFAULTS` mirrors from the tree rather than naming them, grades all
30 mirrored keys against `config.yaml`, and freezes the CFG-03 backlog at
exactly today's **27 (module, key) pairs** so it can only shrink. It found
one previously unreported divergence: `main.py` answers a missing
`target_grid_w` with **0** where `config.yaml` ships **-10** - unreachable on
a Supervisor install, recorded rather than corrected. Its red direction runs
on every invocation, and a literal planted in the real tree was shown red.
* **AC 5 / the CFG-04 double-literal `max_w` hole is closed, by
[[UX-02]]'s `bcee235` and not by UX-01's `fef6ef2`.** Re-measured on this
branch: the `2000 -> 2500` triple mutation is **RED at `test_selfconfig.py`**,
where it survived 21/21 green on 0.7.0. The attribution is corrected in
`DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md`; nothing was
re-done.
The 8 per-entry `inverters:` translations **stay**. They are shipped, validated
and measured *not* to reach the Supervisor tab - `ha-selector-object` labels
those fields `fields[name].label || name` and the converter sets no `label` -
and UX-02's ingress panel is the surface that can render them. A written
decision, not a change.
## 0.8.0
⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
@@ -206,6 +834,100 @@ the bound from 10 to 13. That ceiling change also removes a degenerate setting:
the release bound, so **no accepted configuration can stop the block engaging**.
At the old bound of 9 a setting of 9 or 10 deleted it silently.
⚠️ **THE SITE TARGET IS NO LONGER SPLIT 50/50 BETWEEN TWO PACKS ([[OPT-09]]) -
AND THIS ADDS A PER-ENTRY OPTION, SO IT IS A SCHEMA CHANGE.**
`capacity_kwh: float(0.1,200)?` joins the `inverters` roster as an **optional**
per-entry key. Optional, where SAFETY-32 made `bms_soc_floor` required, and for
that card's own reason: a missing cut-out would have to be *defaulted* and one
default for two batteries protects the shallower pack with the deeper pack's
number, whereas a missing capacity has an honest answer - no opinion about the
split, every unit weighs 1.0, which is exactly what shipped. It is **all or
nothing across the roster** and rule 9 refuses a partial one. There is no flat
`capacity_kwh`: at one inverter there is no split to weight.
The allocator has had a `weights` argument since [[ALLOC-01]] and **nothing had
ever passed one** - `main.py` called `resolve()` without it and `arbiter.py`
defaulted it to 1.0 per unit - so the split was fixed 50/50 regardless of what
either pack could do. Two things now feed it, and they are not the same kind of
thing:
- **an ENVELOPE, which is a permission.** A new `pack` UNIT claim at `P_SAFETY`
closes a unit's `hi` at 0 W when its own SoC is at or under its own
`bms_soc_floor`, and its `lo` at 0 W at 100 %. The allocator obeys it
absolutely, so an empty pack is not asked to discharge and its share moves to
the sibling. ⚠️ **Not filed at all at a roster of one** - every source routes
to the site there, so it would bound the SITE TARGET, and [[SAFETY-25]]'s
Option A (no loop-side SoC floor) stands untouched on the shipped install.
- **WEIGHTS, which are a preference.** Between those limits each pack is asked
for the fraction matching its **energy** headroom in kWh - not its power
rating. The two packs are asymmetric in opposite directions (5000 W /
15.36 kWh against 4600 W / 20.48 kWh **from datasheet**), so the two ways of
dividing hand the work to different packs.
⚠️ **AND WEIGHTS ARE A COMFORT CHOICE FOR STABILITY, NOT FOR DELIVERY.**
[[ARCH-01]]'s convex combination guarantees a bad split cannot destabilise the
site; it says nothing about whether the site can regulate. Measured (two 2000 W
units, one at its floor, modelled plant, real `compute()`): at a 3000 W load the
weighted arm and the shipped arm command the **identical** +2000 W into the empty
pack and deliver the **identical** 2000 W, because `_fill()`'s last-resort pass
hands the remainder to a zero-weighted unit rather than leave watts unplaced.
Closing the envelope does not recover those watts either - it reports them:
`unmet_w` becomes +2000 W and the action log says so, where before the record
read impeccable. A unit that cannot deliver still parks the site short by its
share, and that is now visible instead of silent.
Also: `arbitrage.decide()` is sized per pack rather than at the site rating on
both sides, so a two-pack site cannot ask to buy 9600 W with one pack able to
absorb it; and a 0 W sizing is refused by name, because a 0 W arbitrage claim is
a park order that outranks the loop. ⚠️ The sell side is a **no-op on every
roster today** - the sell gate reads the emptiest pack, so it refuses outright in
exactly the states where the sum could be smaller - and moving that gate is
deliberately not this card's; `DECISIONS/OPT-09-soc-aware-weights.md` §6 says
why.
⚠️ **[[SAFETY-42]]'s end-to-end fixture moved, and its finding did not.** That
card's evidence was that a wedged unit and a pack at its floor are byte-identical
in the power domain. After this card the cold pack is no longer *asked*, so the
shares differ - but the two quantities SAFETY-42's rejected discriminator reads
(saturation persistence and |grid error|) are still inseparable on both arms, so
the conclusion is unchanged and is now stated on the predicate's own inputs.
`test_delivery.py` part 4 carries the whole argument. The two cards meet at one
hazard from opposite sides: **OPT-09 stops the site ASKING a pack whose SoC says
it cannot deliver; SAFETY-42 alarms when a pack that said it could does not.**
⚠️ **AND THIS CARD NARROWS SAFETY-42's DETECTION WINDOW. IT IS A DETECTION
REGRESSION AT TWO INVERTERS AND IT IS DISCLOSED RATHER THAN FIXED.** An earlier
draft of this entry and of `DECISIONS/OPT-09-soc-aware-weights.md` §5 said a
wedged unit's weight does not move, so OPT-09 could not see the case at all. The
ENVELOPE half is true - a wedged box reads a healthy SoC and nothing closes. The
WEIGHT half was **false**: a weight is a function of a pack's ENERGY HEADROOM,
not of what the pack delivers, so a wedged unit's weight moves whenever its
headroom differs from its sibling's. And `delivery.delinquent()` names a unit
only while its shortfall exceeds `saturation_w` - **500 W in absolute watts** -
so shrinking the share shrinks the gap below the threshold and the alarm stops
firing. Measured end to end with the real loop, the real allocator and the real
detector, on a unit wedged at a healthy 15 % against its own 10 % floor (a
SAFETY-42 true positive by that card's own definition): at a 1500 W load the
healthy pack can carry alone, equal weights name it on **199 of 200** cycles and
these weights name it on **none of the 200** - shares 1505/125, gap 125 W, and
125 W is under the threshold. The suppression window widens from a site target
below 1000 W to a site target below `saturation_w * (w_A + w_B) / w_B`, which on
that fixture is **6500 W of a 9600 W site**; above it the healthy pack hits its
own rating, the allocator's last-resort pass hands the wedged unit a big share
again and the alarm returns. So in the quiet regime - where `unmet_w` is 0, the
meter is satisfied and no action-log line fires - **a wedged inverter with less
energy headroom than its sibling can now go unreported at ordinary house
loads.**
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
`test_packshare.py` part 8b grades it and pins the CURRENT behaviour, so
the day it changes a named check moves. Closing it means making `delinquent()`
relative to the ask rather than absolute, which is SAFETY-42's code and a design
change; that is filed as its own card and is deliberately not done here.
⚠️ Every two-unit figure above is **two copies of inverter A** through a modelled
plant - inverter B does not exist yet - and B's ratings are datasheet and serial.
⚠️ **AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME
([[SAFETY-42]]) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.**
`delivery_hold_s: 185` joins `options:` and `schema:` (`int(30,900)`). The
@@ -0,0 +1,181 @@
# FCST-04 — what "yesterday" is, and what the fill is allowed to reach
Four decisions, recorded because each had a defensible alternative that a later
reader would otherwise re-open from scratch.
---
## 1. The fill runs on a `series` read only. A dead feed still goes to §11.4.
`fill_from_yesterday()` accepts a `no_data` read and will happily build a
100 %-carried series from yesterday alone. `resolve_tariff()` does not let it:
a feed that published nothing still falls to `_degrade()` and §11.4's own row —
*"No price data → fall back to the static profile, mark the plan provisional,
no arbitrage."*
**Why not prefer yesterday's real prices to a coarse day/night profile?**
Because it buys nothing the optimiser can use, and it silently replaces a
fallback the operator configured.
Measured, not assumed — and stated at the reach it actually has:
- **This one reaches control.** A fully-carried series is 100 % provisional, so
`TariffPlan.arbitrage()` finds fewer than two firm hours and refuses on
§8.3's firm-pair rung. That is the verdict the optimiser reads: `arbitrage.py`
calls `plan.arbitrage(...)` on `fresh_tariff_plan()`, and it is the only path
by which a provider plan moves energy. The §11.4 static fallback refuses on
the same rung, for the same reason.
- **This one does not, and saying so is the point.** `TariffPlan.cheap_now()`
also returns False on a fully-carried series, because `firm_points()` is
empty — but it has **no production caller on the provider plan**.
`self.cheap` comes from `static_status()`, which builds its *own* static plan
and calls `cheap_now()` on that. So it is a property of the type, checked in
`test_static_tariff.py`, and it is not evidence about the shipped control
path. The conclusion below holds on the first bullet alone.
The two paths therefore differ only in which numbers the status page shows.
So the choice is between two identical control behaviours, and the tie-breaker
is that `static_profile` is a documented option an operator fills in **for this
case**. Overriding it for a cosmetic difference is a behaviour change nobody
asked for.
⚠️ **The consequence, stated so it is not discovered later as a bug:**
`fill_from_yesterday()`'s `no_data` branch is not reached from production.
It stays checked in `test_price_forecast.py`. Turning it on is a product
decision — it would want a rule about how old a carried profile may be before it
stops being offered at all — and not a wiring one.
---
## 2. "Yesterday" is a date, not the newest record.
`newest()` is the obvious accessor and it is the one that decays. An add-on down
from Friday to Monday would answer Friday's prices to a fill whose only
vocabulary is `provisional: true` — a flag that says "this is a guess", never
"this guess is three days old". Nothing downstream can tell the difference and
nothing reports it.
So `yesterday(stamp)` computes the Brussels date before `stamp`'s Brussels date
and looks up **that** record. Anything else is `no_data` with a reason naming
both the date wanted and the dates held.
The cost is real and accepted: after an outage of more than a day, the first
morning back has no fill. That is the honest state — there is no yesterday —
and the horizon is short for one morning rather than wrong for several.
---
## 3. What is recorded is the provider's read, and the mechanism is ordering.
Feeding the filled series back into the store is how this rots: day 2 carries
day 1's guesses, day 3 carries day 2's carry of them, and after a week of a dead
feed the "yesterday" being carried is a fossil of the last real publication.
Every point still carries `provisional=True`, so the **flag never lies** — what
decays is the number, behind a flag that is telling the truth.
⚠️ **No value-level check can catch that**, and claiming one would be worse than
claiming none. A carried point and a published-provisional point (`eneco_eddy`'s
current quarter, §9.2) are the same three fields carrying the same flag. The
difference is provenance, and provenance is not in the value.
The defence is therefore an **ordering**, not a validation:
- `carry()` is the only public entry point. It records its argument, then
returns the fill.
- The filled series exists only as that return value. The object never sees it
again.
- `_remember()` is private and called from exactly one place.
- `test_price_history.py` parses every shipped module under `app/`, `tools/`,
`scenarios/` and `estop/` and fails if any of them **uses the name**
`_remember` — an attribute, a bare name, a `def`, or the string constant a
`getattr` would need. By name rather than by substring, so a comment that
merely mentions the rule does not fail it and a call in `tools/` cannot pass
unseen.
- and it pins the order two ways: the **line numbers** of the two calls in
`carry()`, and — the one that matters — the **behaviour**. A feed carrying
yesterday alongside today, into an empty store, must fill the whole horizon;
reversed, the lookup finds nothing and the plan is only the hours the feed
published. Driven, so a reversal is red.
⚠️ **The first version of that ordering check graded nothing, and this file
said it did.** It compared positions in `ast.walk`, which is breadth-first:
`self._remember(...)` is a statement and `self.yesterday(...)` is an *argument*
of `fill_from_yesterday(...)`, one level deeper, so `_remember` came out first
whichever statement was written first. Measured — shipped and reversed both
scored the same. A decorative check that a document cites as a guarantee is
worse than no check, which is why the correction is recorded here rather than
quietly made.
That ordering has a second benefit worth keeping: a feed that returns yesterday
alongside today refreshes yesterday's record from live data *before* it is
consumed as the profile.
---
## What this ticket does not claim
It does not make a trade possible that yesterday's prices justify. Carried hours
are dropped from the trade set by §8.3, and that is not a limitation to be fixed
later — it is the rule.
The one change that **does** move energy came from the windowing, not the
carrying: the filled series covers `[since, until)`, so hours that have already
elapsed are no longer in the spread. A cheap night that has gone used to be the
series' cheapest hour, and a cycle cannot buy in an hour that is past — so the
trade was silently never taken. `test_price_history.py` section 2 grades that by
finding the cheapest firm hour with and without the fill and showing it moves.
⚠️ **And section 1's identity check now grades identity.** It used to compare
`eur_per_kwh` between `_before` and `_after`, which come from two *separate*
`resolve_tariff()` calls — each parsing the feed body into its own objects, so
`is` could never have held between them however the fill were written. FCST-03's
whole anti-copy argument is `filled.points[i] is fetched.points[j]`, and floats
cannot grade it. The value comparison is kept and now says what it is (the fill
moved the window, it did not touch a price); identity is graded on a read the
file constructs and still holds a reference to.
---
## 4. A sub-hourly feed passes through whole; the fill only decides which hours are missing.
`eneco_eddy` is the Belgian imbalance product and settles **per quarter**, and
`price.py`'s `_validate_raw()` accepts any strictly-increasing spacing — so a
published hour may legitimately hold four points.
FCST-04's first caller keyed the published set as `{_utc_hour(p.start): p for p
in r.points}`. It reads perfectly ordinary and it keeps the **last** point of
each hour. Measured on a 96-point quarter-hourly feed — all firm, fully
published, nothing to carry: the plan reached the optimiser as **15 points**,
the arbitrage spread fell from **0.143 to 0.014 EUR/kWh**, and the cheapest
"hour" became a `:45` point whose `at()` window runs into the next hour. That is
not the windowing effect above; it is a feed losing 84 % of its own prints, and
no `provisional` flag would ever have reported it.
**The alternative was rejected:** refusing sub-hourly feeds at `_validate_raw()`
would have made the count correct by breaking the one supported product that
legitimately publishes per quarter.
**What was done instead** keeps FCST-03's existing contract rather than weakening
it: *every* published point inside the window passes through **by identity**,
and a carried point is added only for a whole hour with **no** published
coverage at all. A carried hour is still a single hourly point whatever the
feed's own spacing is, because yesterday's profile is only known per wall-clock
hour — and carried hours are provisional, so they are out of the trade set
anyway.
⚠️ **`_audit()` got stricter, not looser, and that was the condition on doing
this at all.** The old post-condition refused any repeated `_utc_hour` — a count
that cannot express four points in one hour. It is replaced by two rules that
**subsume** it:
- a **published** point must *be* one of that hour's published points, by
identity, and may be emitted only once;
- a **carried** point may stand only in an hour that does not appear in the
published set at all, must be flagged `True`, and may not repeat.
So a duplicated carried hour — the DST mispricing the count existed to catch —
still raises; so does the same published point emitted twice; and a carried
point sharing an hour with a real print now raises too, which the count never
looked at. Each of those three is driven by its own mutation in
`test_price_forecast.py`.
@@ -0,0 +1,125 @@
# FCST-06 — "cannot persist" is one question, and it is asked about the outcome
Two decisions. The first is the one the ticket put up for judgement; the second
is the one that decides what a store answers *after* it has failed to write.
---
## 1. One derived predicate, not two places testing two symptoms — yes
`price_history.py`'s module docstring states, unconditionally:
> ⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. …
> A store that cannot persist now records nothing, and both of its answers say
> the same thing.
It was enforced against `not self.ok`, and `ok` is **about the directory**: it
says `/data` was there when the store opened. A read-only remount and a full
volume both leave it `True`. So the second way to be a store that cannot
persist — `ok=True`, the directory present, `_save()` failing — was outside the
one condition the rule named, and in that state `words` printed two full days
beside the write error while `yesterday()` answered 24 real points, from memory,
until a restart dropped them without a word.
**The ticket's question was whether "cannot persist" should be one derived
predicate rather than two places each testing a different symptom. It should,
and the reason is not tidiness — it is that the enumeration cannot be
completed.** FCST-04's M4 enforced the rule on the symptom that was in front of
it. FCST-05's HIGH was the same shape — an `except` clause naming the one
content-failure member somebody had met (`json.JSONDecodeError`) instead of the
`ValueError` family — and its fix was to name the family. This is the third instance in
one module, and it is the last place in this file where the rule can be spelt
twice. A fourth cause — an `OSError` subclass, a filesystem that accepts the
`mkstemp` and refuses the `os.replace`, a container whose volume is unmounted
mid-refresh — needs no fourth branch now, because none of them is asked about.
### Where the one question can be asked, which is the whole design
**"Can this store persist?" cannot be answered before the attempt.** A
directory that exists and was writable a moment ago is not a write that
completed; there is no pre-check that is not itself a guess, and adding one
(`os.access`, a probe write) would be a *third* symptom test — precisely what
the ticket rules out. So the predicate is an **outcome**:
_save() -> bool # True only if the file now holds what memory holds
Both ways of not persisting are the same answer from the same call: no durable
volume returns `False` without touching the disk, an `OSError` out of the write
returns `False` after recording why. `_remember()` reads that one answer and
keeps only what it says was kept. **The `not self.ok` test in `_remember()` is
gone** — not moved, gone — because there is nothing left for a second test to
add, and a check in `test_price_history.py` 5d asserts its absence from the
source.
⚠️ **That last check is deliberately a source check, and habit 4 is why it is
allowed to be.** The claim "there are not two places testing two symptoms" is a
claim about the *shape of the code*, not about a behaviour — the behaviour is
identical whether the rule is enforced once or twice. Every other check in 5d
is driven.
### What was NOT done, and why
- **Not a `can_persist()` helper.** It would have to answer before the attempt,
which is the thing that cannot be done honestly, and it would be a second
place to keep in step with the first.
- **Not a probe write at `_open()`.** It would make `ok` mean something new, it
would write to `/data` on every start, and a volume that is writable at 08:00
and full at 14:00 would still defeat it. A predicate that can go stale is a
symptom test wearing a predicate's name.
- **Not making a failed write fatal.** §6, and this module's own opening: a
broken cache must not be a broken controller. An add-on that will not start
because `/data` filled up is a worse defect than the one being fixed. The
store degrades; it does not raise. `_save()` reports and returns.
---
## 2. The MERGE is undone, not the store
When `_save()` answers no, `_remember()` restores the state the read started
from — **not** an empty one.
The alternative considered was to clear `self._days` outright, on the argument
that a store which cannot write is a store that should stop answering. It is
wrong, and measurably so: the state being restored is the last one that **is on
disk**, so answering it is not a promise the store cannot keep. A site whose
volume fills at 14:00 goes on answering the yesterday it wrote at 13:30 —
through the restart, because that record is in the file. What it stops doing is
*adding* to it.
Clearing would throw away a good yesterday that survives a restart in order to
punish a write that failed, and it would make the morning fill worse on exactly
the day the operator is already dealing with a full disk.
That gives the file the invariant its rule was always describing, stated once
in the class docstring:
> `self._days` **never holds a point the file does not.**
⚠️ Not *"mirrors the file"* — `_usable_days()` drops records the file still
holds, and that asymmetry is deliberate. The only direction that can **invent a
yesterday** is the one named.
⚠️ **And the snapshot is `copy.deepcopy`, not `dict(self._days)`.** A one-level
copy shares the day *records*, and the merge rebinds `points` on the ones it
touches — so the "restored" store would answer the merge that never reached the
file. Measured: with a shallow copy, a store holding 12 persisted points
answered **24** after a failed merge. It would also be correct only for as long
as nobody makes the merge mutate a `points` list or a row in place, which is the
same "correct for the conditions someone thought of" this ticket is about. The
store holds at most `KEEP_DAYS + 2` records and writes 48 times a day; a copy
that is right by construction is worth more than the allocations.
---
## What this does not change
- **No shipped path reaches the defect.** `/data` is the Supervisor's durable
volume and this module is its only writer, so on a real install `_save()`
succeeds and nothing above is on the hot path. The fix is that a guarantee the
module states about itself now holds in the condition it did not.
- **The failure was already visible and still is** — `_LOG.error("could not
persist price history: %s")` and `last_error`, reported by both `words` and
`yesterday()`'s reason. What changed is that it is now *acted on*.
- **Nothing about the prices.** The series served is the feed's own, every
carried point is still flagged provisional, and a broken cache is still a
short horizon rather than a §11.4 degradation.
@@ -0,0 +1,334 @@
# OPT-09 - the weights parameter, and what it is honestly for
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3d29993` (rebased onto the UX-02 merge; it read `3368231`, and before that `464d398` - each rebase moved it and review caught it each time, which is why the counts in `test_translations.py` are now computed rather than typed)
(`git merge-base HEAD 3368231`; the branch was rebased and this line used to name
the pre-rebase base, `464d398`).
## 0. What was actually wrong
`allocator.plan()` has taken a `weights` argument since ALLOC-01, `_fill()`
consumes it in two passes, and **nothing had ever passed one**. Confirmed on the
base commit:
arbiter.py:535 def resolve(claims, *, units=None, weights=None, live=None, ...)
main.py:2422 resolve(claims, units=self.units, live=self.live_units(), ...)
So `weights` was None on every tick of every install, `plan()` turned that into
1.0 per unit, and the site target was split **50/50 regardless of what either
pack could do**. The card was filed as "per-pack arbitrage sizing"; the architect
pass found the sharper content, and it is the sharper content that this branch is
mostly about.
## 1. Two instruments, and they are not interchangeable
`allocator.py` states the rule this card is built on, and it is mechanical rather
than advisory:
> WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
> "please do not use this unit", and it is honoured while any other unit has
> headroom - but it is NOT a constraint.
`_fill()` runs a deliberate **last-resort pass** over the zero-weight units when
the preferred ones saturate, so that a preference can never manufacture a
standing error at the meter. A pack at its own cut-out that is only *weighted*
to zero is therefore still commanded to discharge the moment its sibling fills
up. So this branch ships both:
| | question | mechanism |
|---|---|---|
| `packshare.envelope()` | MAY this pack be asked, at all, in this direction? | a `pack` UNIT claim at `P_SAFETY`, `hi = 0` at the cut-out, `lo = 0` at 100 % |
| `packshare.weights()` | GIVEN that both may, which should carry more? | energy headroom in kWh, handed to `resolve()` |
## 2. ⚠️ What the weights CANNOT do
ARCH-01's convex-combination argument - the gain at the meter is
`sum(a_i * g_i)`, so it lies in `[min g_i, max g_i]` for **any** split -
guarantees that a bad split cannot **destabilise** the site. It says nothing
about whether the site can **regulate**. A unit at its energy limit has
`g_i = 0`; the combination is still inside the interval and the load it was
asked to carry simply does not move.
Measured, `test_packshare.py` part 8, two units rated 2000 W each, B sitting on
its own floor and therefore answering 0 W to any discharge command. The plant is
modelled in that file (`lag_s = 0`, obeys within one cycle, bounded by its
rating) and the loop is the real `compute()`:
| load | arm | site target | shares | grid | unmet |
|---|---|---|---|---|---|
| 1500 W | shipped | +3000 W | A +1500, B +1500 | 0 W | 0 W |
| 1500 W | weights | +1500 W | A +1500, B +0 | 0 W | 0 W |
| 1500 W | envelope | +1500 W | A +1500, B +0 | 0 W | 0 W |
| 3000 W | shipped | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
| 3000 W | weights | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
| 3000 W | envelope | +4000 W | A +2000, B **+0** | +1000 W | **+2000 W** |
Three things are worth reading off that table, and the second and third are the
uncomfortable ones:
1. At a load the healthy pack can cover, the weights alone deliver the whole
improvement. The shipped arm reaches the same meter reading only by winding
the site target to **twice the load** and standing a +1500 W command on a
battery that answers nothing, permanently saturation-frozen. That is not a
working site, it is a site with no headroom left for the next load step.
2. At a load it cannot cover, **the weights stop working entirely**. The
weighted arm and the shipped arm command the identical +2000 W into the empty
pack, because `_fill()`'s last-resort pass hands the remainder to the
zero-weighted unit. Same shares, same watts, same error.
3. **And the envelope does not rescue the delivery either - it names the
shortfall.** Same 2000 W delivered, same 1000 W standing at the meter. What
changes is that the empty pack is commanded 0 W instead of 2000 W, and 2000 W
of site target is *reported* as unplaceable instead of appearing to have been
placed.
> **Weights are a comfort choice for STABILITY and NOT for DELIVERY.** Nothing in
> this add-on shifts a unit's load to its sibling once the sibling is at its
> rating, and weighting a failing unit down is not a remedy for it.
## 2b. ⚠️ The card's own premise is only half true
The card opens with *"a unit that stops delivering its share (empty, full,
curtailed, wedged) parks the site with a standing grid error and no alarm."*
After this branch: the **ask** stops, and while the sibling has headroom the site
regulates to zero error instead of winding to twice the load. The **standing grid
error does not stop** once the sibling is at its rating - §2's table measures
1000 W standing in all three arms - and there is **still no alarm** for it, only
a report. SAFETY-42 will not alarm on a pack at its own floor; that is one of its
acceptance criteria and its evidence is that the benign and the wedged case are
indistinguishable in the power domain.
So the two cards together mean: an empty pack limiting the site is now REPORTED
(`unmet_w`, an action-log line) and still, deliberately, NOT ALARMED. Whether it
should be alarmed is a decision - and the alarm it would need is about a site's
stored ENERGY, not about an inverter's delivery, so it is not this card's and not
SAFETY-42's. Recorded rather than quietly closed against a premise this card only
half meets.
## 3. Which floor, and which ceiling - the two places this could have become policy
**The floor is `bms_soc_floor` alone, never `peakguard.pack_floor_pct()`.** That
function's conjunction with `peak_soc_floor` is right for the peak guard and
wrong here: SAFETY-25 decided (2026-09-02, Option A) that `peak_soc_floor` bounds
the peak guard **only**, ordinary self-consumption discharges straight past it,
and closing an envelope at it would turn an operator preference into a hard stop
on the grid-following loop - the memo's **deferred Option B**, built by accident.
`delivery.py`'s `account()` made the same choice one step out, for the same
reason. Graded: a pack at 15 % with a 10 % cut-out is not closed, though a 20 %
EMS floor would have closed it.
**The ceiling is 100 %, and the coarseness is the honest answer.** There is no
per-pack charge ceiling anywhere in this add-on - `delivery.py` says so at length
and calls it a card, and it is why SAFETY-42 is armed on discharge shares only.
The only other number available is `arbitrage_soc_ceiling`, which is one
site-wide **trade policy**; filing it as a `P_SAFETY` envelope on the loop would
be the mistake above in the charge direction. So the envelope closes only where
the ask is physically impossible, and everything between is left to the weights -
which is the right instrument for a graded preference and is honest about being
only a preference.
## 4. N=1, and the tripwire this had to argue past
`actuator_for()` routes **every** source to the site at one inverter, so a `pack`
interval filed there would bound the SITE TARGET rather than a share. `main.py`
therefore does not file it at all below a roster of two - the same guard, for the
same reason, that SAFETY-30's `sign_site` carries. AC 4 ("at N=1 allocation is
byte-identical") is that `if`.
`test_peakguard.py` part 10f pins the claim-source set against `main.py`'s own
AST precisely so that a state-of-charge-conditioned claim cannot arrive quietly.
`pack` is state-of-charge-conditioned, so the argument is written at that set
rather than the count edited: the number is not an option, the claim is
unit-scoped so it cannot bound the site target, it is not filed at N=1, and part
10f's own note names this as the revisit trigger - *"under ALLOC-01 the reserve
becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a ninth claim
here"*. Stage 1 is how an interval reaches the allocator and stage 1's input is
claims, so a unit-scoped claim is the shape that sentence asks for.
**What N=1 still does not get, said plainly:** nothing stops the loop discharging
into the BMS cut-out at one inverter. That is unchanged, it is SAFETY-25's
deferred option, and the BMS is what stops it.
## 5. SAFETY-42 - the same hazard from the opposite side
The seam is **the state of charge** - and they overlap in one direction, which
is what the rest of this section is about:
* **OPT-09 stops the site ASKING** a pack whose own SoC says it cannot deliver.
* **SAFETY-42 alarms** when a pack that said it could does not.
A wedged unit reads a healthy SoC, so **no envelope closes** - that half is
absolute, and it is why this card cannot stop the ask on a wedged box. A pack at
its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after
this card that ask does not arrive in the first place, which removes the benign
arm from the detector's input rather than teaching it a new discriminator.
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWED SAFETY-42's DETECTION WINDOW.
THAT WAS A DETECTION REGRESSION AT N=2, IT WAS NOT FIXED HERE, AND SAFETY-43
CLOSED IT - see the block at the end of this section.** An earlier draft of
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
at all". The second clause was **false**, and it was load-bearing for the claim
that the two cards do not overlap. A weight is a function of the pack's ENERGY
HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever
its headroom differs from its sibling's - which is the ordinary case this card
exists to exploit. And `delivery.delinquent()`
named a unit only while its `short_by()` gap exceeded `min_w =
Tuning.saturation_w`, and that was **500 W in absolute watts** with nothing
else beside it. Shrinking a unit's share shrinks its gap. Shrink the
share far enough and the gap drops below 500 W while the unit is still delivering
nothing, and the alarm stops firing.
Measured on the closed loop - real `compute()`, real `plan()`, real
`delinquent()`, modelled plant, B wedged at 15 % against its own 10 % floor so
nothing closes and `account()` explains nothing, i.e. a SAFETY-42 **true
positive** by that card's own definition:
load 1500 W equal weights (shipped) named on 199/200 cycles
shares {a:1500, b:1500} grid +0 W named=['inverter-b']
load 1500 W OPT-09 weights named on 0/200 cycles
shares {a:1505, b: 125} grid -5 W named=(nobody)
⚠️ That plant is modelled in `test_packshare.py`, and **inverter B does not
exist** (expected ~2026-09-12): it is two copies of A driven with different
numbers, and B's 4600 W / 20.48 kWh / 10 % floor are from the datasheet and the
serial. The claim is about the arithmetic of the split and the threshold, which
needs no hardware, and not about a GW5000S-BP.
At a load the healthy pack can cover - the regime where nothing else tells the
operator anything, `unmet_w` is 0, the meter is satisfied and no action-log line
fires - a wedged inverter goes from named on essentially every cycle to **never
named**. The suppression window widens from `site target < 2 * min_w` = 1000 W to
`site target < min_w * (w_A + w_B) / w_B`, which on that fixture is **6500 W** of
a 9600 W site; above it - once the healthy pack is at its own rating and
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
returns. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
`test_packshare.py` part 8b graded all of it and **pinned the unflattering
behaviour**, so that the day it changed a named check would move. It has moved.
### ⚠️ CLOSED BY SAFETY-43 - THIS IS NO LONGER A DISCLOSED RESIDUAL
This section used to end *"until then this is a disclosed residual [...] at two
inverters, a wedged unit with materially less energy headroom than its sibling
can go unreported at ordinary house loads"*. That sentence is **retired, not
softened**, and what replaces it is a measurement rather than an intention.
`delivery.delinquent()` is now relative to the ask **as well as** absolute:
named if short_by() > min_w OR short_by() > frac * share
with `frac = delivery.DELIVERY_SHARE_FRACTION`. A unit delivering 0 of 125 W is
100 % delinquent and is named; a unit delivering 0 of 1500 W was already named
and still is. On the **same fixture, under the same weights**, part 8b now
grades both rules in one run - `share_frac=None` is SAFETY-42's absolute arm,
exactly:
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
⚠️ **`min_w` WAS NOT LOWERED, AND THAT IS THE HALF THAT MATTERS HERE.** The
absolute arm's suppression window is still exactly the arithmetic above - 1000 W
at equal weights, 6500 W at these - and part 8b still asserts both numbers.
Lowering `saturation_w` would have traded a missed alarm for a false one on
every unit at once, which is SAFETY-41's four-attempt regime. SAFETY-43 added a
**second** way to be named and left the first where it was.
⚠️ **AND WHAT OPT-09 STILL DOES NOT DO IS UNCHANGED.** This card stops the site
ASKING a pack whose own SoC says it cannot deliver; it does not re-allocate a
wedged unit's watts, and SAFETY-43 did not make it. The alarm got louder; the
loop did not move. The derivation of `frac` - swept over the same 2640 no-fault
runs that produced `delivery_hold_s`'s floor - is in
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md`.
So the seam, stated accurately **as of SAFETY-43**: OPT-09 stops the ask, and it
still narrows the ABSOLUTE arm's window for a low-headroom unit exactly as
described above - but it no longer narrows the DETECTOR, because the detector no
longer reads only that arm.
⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without
connecting it.** That module's rejection of re-allocation rests on *"the only
re-allocation this add-on can express is ALL OR NOTHING [...] a partial
re-allocation is what would make acting safe, and the allocator has no way to
express it today"*. Passing weights **is** that expression. Wiring delinquency
into them is deliberately **not** done here: it would be acting on the alarm,
which SAFETY-42 refused with rig evidence (a unit derated to 50 % is 400 W worse
dropped than kept, and no instrument this add-on has can tell it from a wedged
one), and - see §2 - a weight cannot stop a unit being commanded anyway. That is
a card, and this paragraph is its input.
## 6. Per-pack arbitrage sizing, and the half that is deliberately not moved
`arbitrage.decide()` was handed `site_max_w` on **both** sides, so a two-pack
site would buy at the sum of the ratings whatever either pack's state of charge
was. It is now sized to the packs whose own SoC permits that direction
(`Controller.trade_power_w()`), using **arbitrage's own** bounds rather than the
BMS cut-out - sizing a trade is the trade's business; permitting a command is the
pack's.
Two honest limits:
* **The sell side is a no-op on every roster today.** `decide()` is handed
`pack_floor_soc` - the emptiest pack - so a sell is refused outright whenever
*any* pack is at or under the floor, i.e. in exactly the states where the sum
could be smaller than the site rating. The arithmetic is graded directly
(`test_packshare.py` part 9); it becomes reachable the day that gate is
per-pack.
* **Moving that gate is not this card's.** Sizing a sell off the *fullest* pack
would let the trade proceed while the emptiest pack was below
`arbitrage_soc_floor`, and the only per-pack instrument that could keep it out
of that pack is the weights - which are a preference, and `_fill()` will hand
it watts anyway. Doing it safely needs an envelope that depends on WHICH CLAIM
WON the tick, which the arbiter's partition does not express. §2 is why that is
a real objection and not a hedge.
`arbitrage.decide()` also now refuses a **0 W sizing** by name (`IDLE_NO_SIZE`),
because a `Claim.set(..., 0.0)` at `P_ARBITRAGE` is a park order that outranks
the loop - the failure the note on `claim()` is already written against. The
sizing can never be zero on a tick that trades today; that is a coincidence of
the existing gate, and this makes it structural instead.
## 7. The fixture, which is the hard part
Two packs that differ in only ONE of SoC and capacity cannot separate a
SoC-aware weighting from a capacity-aware one; two identical packs cannot
separate ANY weighting from any other. `test_packshare.py` part 1 grades the
fixture itself before anything is asserted with it - four candidate schemes
(equal, power rating, SoC only, energy), four distinct splits of the same
1000 W:
equal A +500, B +500
power A +521, B +479
soc only A +125, B +875
energy A +97, B +903
and the counterfactuals: on two identical packs all four collapse to one answer;
at the same SoC, `soc only` collapses onto `equal`; at the same capacity,
`energy` collapses onto `soc only`.
## 8. Provenance of every number
* A: **5000 W, 15.36 kWh, 10 % floor** - measured (GW5000S-BP, register 0x0560).
* B: **4600 W, 20.48 kWh, 10 % floor** - **FROM DATASHEET AND SERIAL**
(`goodwe/NOTES.md` §1.1b, GW5048D-ES `55048ESU22AW0992`). ⚠️ **Inverter B does
not exist** (expected ~2026-09-12).
* Every two-unit run in `test_packshare.py` is arithmetic over intervals and
weights, or a plant modelled in that file. `sim/plantsim.py:216`'s `INVERTER_B`
is **inverter A copied** with `measured=False`, so any two-inverter simulation
result anywhere in this project is **two copies of A**.
* No rig was taken, no hardware was touched, and nothing here is evidence about
a second GoodWe inverter.
## 9. Config
`capacity_kwh` joins the `inverters` roster as an **optional** per-entry key
(`float(0.1,200)?`) - the opposite choice to SAFETY-32's required
`bms_soc_floor`, and for that card's own reason: a missing cut-out would have to
be *defaulted*, and one default for two batteries protects the shallower pack
with the deeper pack's number, whereas a missing capacity has an honest answer -
no opinion about the split, every unit weighs 1.0, which is exactly what shipped.
It is **all or nothing across the roster** and rule 9 refuses a partial one: a
pack measured in kWh beside a sibling weighing a dimensionless 1.0 is not a
comparison, `weights()` answers "no opinion" for the whole roster in that case,
and a key that silently changes nothing is worse than one that refuses to load.
There is deliberately **no flat `capacity_kwh`**: at one inverter there is no
split to weight, so it would be an option an operator can set and nothing reads.
@@ -149,8 +149,70 @@ each swept across the **whole range `config.yaml` accepts** for the two settings
that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and
`slew_w` `int(50,5000)`.
### ⚠️ THE HARNESS BEHIND EVERY FIGURE ABOVE IS NOT COMMITTED, AND UNTIL 2026-09-07 THAT MADE THEM UNCHECKABLE
**Read this before quoting any number in this section.** The paragraph below
said the campaign harness "is not committed here" and treated that as a
packaging detail. It is not: a figure produced by a script that exists in
nobody's tree cannot be re-run, cannot be reviewed, and cannot be defended when
it is contradicted. It has now been contradicted twice.
| sweep | worst peak | worst continuous | trips |
|---|---:|---:|---:|
| the 924-run campaign below — **harness uncommitted** | 1700 W | 505.0 s at 100 W; 5.0 s above 700 W | 0 / 924 |
| independent reviewer sweep, 2026-09-07, 48 honest reversals | **2750 W** | **15.0 s above 700 W** | 0 / 48 |
| `admin/goodwe` `two_master_circulation`, 840 runs — **committed** | 1048 W | 10.0 s at 100 W; 5.0 s above 700 W | **0 / 840** |
**This is not proof that anything was fabricated, and it should not be reported
as one.** The three swept different grids. What the third one adds is a
measurement of *why* the spread is that wide — on one grid, changing nothing but
the model:
| plant / stagger | worst legitimate peak |
|---|---:|
| modelled plant (lag 10 s, ramp 800 W/s), staggers up to a whole cycle | 1048 W |
| `INSTANT` (lag 0, unbounded ramp), staggers up to a whole cycle | 2250 W |
| `INSTANT`, staggers strictly under a whole cycle | **0 W** |
A factor of 2.1×, and a factor of infinity, between three defensible
descriptions of the same rig. **The plant model and the write-stagger set are
terms of the number**, and a worst case quoted without them is not a
measurement. That is enough to explain the whole spread above without anybody
having invented anything — and it is exactly what cannot be established when the
instrument is missing.
**What survives, and what does not.**
* **The window argument survives, and is now committed.** The claim that
decides `circulation_hold_s: 75` is *"the worst continuous legitimate episode
above 700 W is about one control cycle"*. All three sweeps agree on the shape
of that (5.0 s, 15.0 s, 5.0 s — one to three cycles against a fifteen-cycle
window), and **all three trip zero times**. `two_master_circulation` re-runs
it on every pull request in `admin/goodwe`.
* **The threshold curve does NOT survive as stated.** `505.0 s at 100 W` is the
number the "knee at 400 W" argument for `circulation_w: 700` rests on, and the
committed sweep measures **10.0 s** at 100 W on its grid — 50× smaller. The
curve still *falls* with the threshold (126 of 840 runs cross 100 W against 1
that crosses 700 W), so the knee is real in direction; its depth is not
established by anything anybody can run. **Do not quote 505.0 s.**
* **Nothing about the guard is compromised.** 0 trips in 924 + 48 + 840
non-faulty runs, from three independent grids.
**What would settle it:** the original harness, committed. Until then the
committed sweep is the only figure in this section anybody can check, and it is
labelled as its own grid's rather than as a reproduction.
### Reproducing it
⚠️ **THE COMMITTED VERSION IS `admin/goodwe`'s `sim/inject.py
two_master_circulation`** (`sim/sitesim.py:circulation_findings`, runnable on its
own as `python3 sim/sitesim.py --circulation`, `norig`, ~36 s, graded by
`checks.yml` on every pull request there). It produces the fault with
`plantsim.Inverter.wedge()` — a unit answering its bus and no longer steering its
power stage, which is HW-01 — and sweeps the legitimate family with the
serialised write path staggered up to a whole control cycle. The paragraph below
describes the ORIGINAL campaign, which is the one that is not committed.
The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness
is not committed here because it imports that repo's `plantsim`/`frame_check`,
which are not present in this one or in CI. What it does is small enough to
@@ -206,7 +268,11 @@ margin above the knee is what absorbs that without re-opening this card.
**Revisit when SPIKE-04 lands, not before.**
The **schema floor is the knee itself**, 400 W, so a legally configured install
cannot land in the 445–505 s regime. What the threshold costs: a circulating
cannot land in the 445–505 s regime — ⚠️ and those two figures are from the
uncommitted campaign; the committed sweep measures 10.0 s at 100 W on its own
grid. See the disclosure at the head of this section. The floor is still the
right shape (fewer runs cross a higher threshold, measured 126/840 at 100 W
against 1/840 at 700 W); its DEPTH is not established by anything re-runnable. What the threshold costs: a circulating
loop smaller than 350 W per unit is not reported, which is deliberate — at that
scale the round-trip loss is a few watts.
@@ -289,6 +355,32 @@ SAFETY-25 — so no stale `.pyc` could exist to be read. Byte deltas recorded;
| M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check |
| M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks |
| M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED |
| **M20** | `main.py` | **`pos, neg = opposing(...)` → `([], [])`** — the attribution clause deleted from the alarm | −102 B | **SURVIVED ALL 26 FILES on the shipped battery — GAP CLOSED**, then RED on §6a |
| M21 | `main.py` | `pos` and `neg` exchanged — the alarm names both packs, both the wrong way round | **+0 B** | **RED** — §6a ×3 (the mirrored run, and "only its own sentence") |
| M22 | `main.py` | `opposing(..., min_w=cfg.min_w / 2.0)` → `min_w=0.0` | −25 B | **RED** — §6a's resting-pack check |
**And four in `admin/goodwe`**, graded through `python3 sim/sitesim.py
--circulation` rather than through the injector — deliberately, because
`inject.py` is the file that takes the rig lock, and "run the injector to grade a
mutation" is not a sentence anybody should have to type. Byte deltas over
`plantsim.py` + `sitesim.py` together.
| # | file | mutation | Δ | result |
|---|---|---|---|---|
| S1 | `plantsim.py` | `wedge()` no longer stops the latch reaching the power stage | −20 B | **RED** — the opposing-signs precondition **and** "the watchdog did not trip" |
| S2 | `sitesim.py` | the sensor computed over the **allocation record** instead of register 0x0518 | **+0 B** | **RED** ×3 — this is M10 restated on the rig |
| S3 | `sitesim.py` | the campaign run on `plant=INSTANT` | −51 B | **RED** — the plant is a *term* of the figures; it was a SURVIVOR until that check was added, and finding out why produced §3's 1048/2250/0 W table |
| S4 | `sitesim.py` | the write stagger removed from the legitimate campaign | −19 B | **RED** — "the legitimate campaign produced no opposition at all" |
**S3 is recorded as a survivor because it was one, and it is the most useful
result in this table.** The scenario first shipped with a vacuity check whose
text claimed `plant=INSTANT` would make the sweep measure 0 W. S3 was run and
**passed**: `INSTANT` with a stagger of exactly one control cycle samples the
plant before the second unit has stepped, so it produces opposition anyway — and
a *larger* worst peak (2250 W) than the modelled plant does (1048 W). The claim
in the check's own text was wrong. It was corrected against the measurement, the
plant was pinned as an explicit term, and S3 is red now for a stated reason
rather than by accident. §3's spread table is that measurement.
**M19 is recorded as a survivor because it was one.** The first battery found no
check grading what `publish()` does when a per-unit reading is missing, so
@@ -297,6 +389,30 @@ watches went unnoticed — the sensor's own `None` rule, defeated one layer out.
A check was added and the mutation re-run; it is red now. The run that found it
was purged, so it is a result and not an artefact.
**M20 IS RECORDED AS A SURVIVOR BECAUSE IT WAS ONE, AND IT IS THE MORE
UNCOMFORTABLE OF THE TWO.** The commit message for this card claimed *"18
mutations, all RED"*, and M1–M19 above have no entry for the notification's
attribution clause. Verification on 2026-09-07 ran it and it **survived the
whole 26-file suite**: the alarm still fires, still stops the site, still says
the meter cannot see it — and no longer says *which two packs* or *which way
round*. `circulation.py`'s own docstring calls that sentence the first thing
somebody woken by this alarm needs and *"impossible to reconstruct from the
number alone"*.
It survived for one reason, and it is not reachability: **M18 (the whole call
removed) is red**, so `check_circulating()` is exercised. The three checks on
that notification asserted `METER CANNOT SEE`, `CONTROL HAS BEEN STOPPED` and
`isolator` — and **never an inverter name**. Nothing graded the attribution.
`test_watchdog.py` §6a closes it, and closes it on a fixture that can tell the
three failures apart. It runs a roster of **`["garage", "shed", "cellar"]`** —
operator names, none of them `UNIT_A`, for test_maintenance.py §11's reason:
on a fixture whose names are the module's own constants a check cannot tell the
roster from the constant. It runs the site **twice, mirrored**, so the sentence
is not symmetric and a swap (M21, `+0 B`) is a different red from a deletion.
And it carries a **third pack resting at 5 W**, so the floor that stops a pack
in standby being named as a participant in a 6 kW loop (M22) is graded too.
## 7. What this card does NOT close
* **Inverter B's ramp and lag are placeholders.** Every transient number in §3
@@ -305,7 +421,9 @@ was purged, so it is a result and not an artefact.
margin. **Do not present any figure here as a measurement of inverter B.**
* **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the
notification says so; there is no software fix and this card does not pretend
to one.
to one. It *can* now be produced on demand: `plantsim.Inverter.wedge()`.
* **The depth of the threshold curve is not established by anything committed.**
§3's disclosure. The window is; the knee's magnitude is not.
* **A loop below ~350 W per unit is not reported.** Deliberate, §3.
* **The saturation freeze fires under a wedged unit and leaves a standing grid
error.** Observed on the rig while producing §2's numbers: with B wedged, the
@@ -68,22 +68,102 @@ deliberately *not* in the model). **Every two-inverter figure in this document
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
margin that costs.
### Reproducing it
### Reproducing it — ⚠️ THE HARNESS IS COMMITTED NOW (TEST-21, 2026-09-07)
⚠️ **THIS SECTION USED TO SAY THE HARNESS WAS NOT COMMITTED, AND THAT WAS THE
DEFECT TEST-21 FOUND.** A shipped safety constant whose floor the next reviewer
cannot re-run is PROC-01's shape, and PROC-01 had eleven instances in three
days. The paragraph this replaces was true — what the harness does *is* small
enough to restate — and it was not enough: a restatement in prose leaves every
axis it does not name to the next person's judgement, and §3.3 below now records
how far that moves the number. **It moved. See §3.3's second table.**
It is committed in the **other** repository, `admin/goodwe`, as
`sim/delivery_window.py`, beside `sitesim.py` — the container-free two-master
harness it is built on. From clean checkouts of both repos:
```
cd goodwe/sim
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --selftest
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --campaign --blag
```
stdlib only. No container, no HA, no esphome, **no rig and no rig lock** — it is
a `plantsim` campaign, not a rig job. About fifteen minutes for the whole thing.
⚠️ **AND IT IS IN `admin/goodwe` RATHER THAN HERE, WHICH IS CI-08's RULE
APPLIED RATHER THAN IGNORED.** It imports `plantsim` (the plant, the per-unit
wire, `Site`) and `frame_check` (the firmware's own encoder), both **owned by
that repo** and neither present in this one's CI image. Committed here it had
two possible fates and both are the coupling CI-08 has been broken by three
times: skipped for want of its imports — a green that establishes nothing — or
the plant and the encoder **restated in this tree**, which is a second copy of
something the other repo owns, free to drift, and drift in a fixture is
invisible until the number it defends is wrong. There the dependency runs one
way and stays one-directional: **this** repo's `control`, `arbiter` and
`delivery` are imported live out of whichever checkout is under test
(`$GOODWE_ADDON_REPO`), never copied — so a change to `delinquent()` moves the
number on the next run instead of silently disagreeing with a fixture. That is
the seam `sitesim.py` already stands on, which is the other reason the campaign
is its neighbour and not a third arrangement.
⚠️ **THE SELFTEST IS CI-GATED; THE CAMPAIGN IS NOT, AND THE SPLIT IS
DELIBERATE.** `admin/goodwe`'s `.gitea/workflows/checks.yml` gained one step —
`python3 -B delivery_window.py --selftest`, seconds, beside the existing
`norig, no lock` TEST-19 and TEST-20 steps, in the workflow that **already**
checks out this repo for `two_writers` and accepts that boundary in writing. So
a change **here** that moved `saturation_w`, `max_w`, `delivery_hold_s` or the
shape of `delinquent()` turns that step red rather than leaving a stale number
in this record. The **campaign** (`--campaign --blag`, ~15 minutes) is not
gated: it is a campaign, not a gate, and `failure-injection.yml`'s jobs run on
the **rig** runner, which a container-free simulation has no business holding.
What TEST-21 asked for is a command a reviewer can run from a clean checkout —
that is the block above.
⚠️ **`--selftest` IS A FALSIFIABILITY ARM, NOT A SMOKE TEST, AND IT RUNS
FIRST.** A campaign whose N=1 column and whose flapping column are 0.0 s
everywhere is indistinguishable from a campaign whose detector was never wired
up — `sitesim.broadcast_onset` exists for the same reason. So the selftest
drives a genuinely **wedged** unit through the same loop and refuses to proceed
unless the shipped `delinquent()` names it for longer than the shipped hold
(it names it for 275.0 s). Every 0.0 s in §3.3 is only worth reading because
that arm passes.
What it does, unchanged from the restatement it replaces: build two
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`; close the loop with the
add-on's own `compute()` on the SITE sum (`Controller.site_batt_w`) and
`arbiter.allocate_record()` over per-unit envelopes of `±min(max_w, rating)`;
put each share on its own wire through `Inverter.command()`, honouring a
configurable **write stagger** so B's number can land up to four cycles after
A's (the serialised write path); advance `Site.settle()` one control cycle; and
record, per unit, the command standing *before* the write and that unit's 0x0518
power. Then grade each cycle with the **shipped** `delivery.delinquent()` and
take the **longest run of consecutive cycles** on which it names anybody — not a
count of naming samples, because the window is cleared by any cycle that does
not.
⚠️ **SINCE [[SAFETY-43]] THE ROWS OF §3.3's TABLE ARE GRADED WITH
`share_frac=None`** — `delinquent()`'s ABSOLUTE arm alone, which is this card's
rule exactly. Said here because it is the difference between §3.3 still meaning
what it is labelled as and quietly becoming a table about a different predicate:
SAFETY-43 added a SECOND way to be named, the harness grades it in its own rows
(`--fractions`), and the numbers below are unchanged and were re-measured to
confirm it. `DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` §3 carries
those rows and what they cost — the worst legitimate episode goes from 35.0 s to
40.0 s at the shipped fraction, which does not move `delivery_hold_s`.
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
command standing before the write" is the **controller's** standing command —
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
wire. A write that has been issued but has not landed yet is standing as far as
this add-on is concerned, which is the whole of the write-stagger axis. Grading
against the plant's latch instead reads a number no add-on has, and it makes
both the stagger *and a wedged unit* invisible: a unit whose writes never arrive
shows a standing command of 0 W, is never `asked`, and is never named. That is
not a conservative harness, it is a harness that cannot fail — and the rebuild
did exactly this on its first run, which is how the falsifiability arm earned
its place above.
The harness is **not committed here**, for the reason SAFETY-30's and
SAFETY-31's were not: it imports `plantsim` and `frame_check`, which live in the
other repository and are not present in this one's CI image. What it does is
small enough to restate: build two `plantsim.Inverter`s from `INVERTER_A` /
`INVERTER_B`; close the loop with the add-on's own `compute()` on the SITE sum
(`Controller.site_batt_w`) and `arbiter.allocate_record()` over per-unit
envelopes of `±min(max_w, rating)`; put each share on its own wire through
`Inverter.command()`, honouring a configurable **write stagger** so B's number
can land up to four cycles after A's (the serialised write path); advance
`Site.settle()` one control cycle; and record, per unit, the command standing
*before* the write and that unit's 0x0518 power. Then grade each cycle with the
**shipped** `delivery.delinquent()` and take the **longest run of consecutive
cycles** on which it names anybody — not a count of naming samples, because the
window is cleared by any cycle that does not.
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
trees. Run it with `python3 -B` and purge.
@@ -213,7 +293,9 @@ Every run is executed a second time at a **roster of one** on the same stimulus.
check"* from *"a property of the second inverter"*.
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
threshold:
threshold. **ORIGINAL CAMPAIGN, 2026-09-06, from the harness that was not
committed — SUPERSEDED, and kept here because a record that quietly replaced its
own numbers would be worth less than one that shows both:**
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|---:|---:|---:|---:|---:|
@@ -225,7 +307,100 @@ threshold:
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
Three findings worth stating rather than leaving in the table:
#### ⚠️ TEST-21, 2026-09-07 — THE RE-RUN, AND 30.0 s DID NOT REPRODUCE
`admin/goodwe:sim/delivery_window.py --campaign`, same three families, same
counts, same thresholds, the shipped `delinquent()`:
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640 runs)** |
|---:|---:|---:|---:|---:|
| 100 W | 120.0 s (770) | 0.0 s (0) | 165.0 s (685) | **0.0 s (0)** |
| 250 W | 65.0 s (687) | 0.0 s (0) | 100.0 s (590) | **0.0 s (0)** |
| **500 W** | **30.0 s (460)** | **0.0 s (0)** | **35.0 s (403)** | **0.0 s (0)** |
| 700 W | 25.0 s (342) | 0.0 s (0) | 30.0 s (318) | **0.0 s (0)** |
| 1000 W | 15.0 s (4) | 0.0 s (0) | 25.0 s (131) | **0.0 s (0)** |
| 1500 W | 0.0 s (0) | 0.0 s (0) | 20.0 s (43) | **0.0 s (0)** |
| 2000 W | 0.0 s (0) | 0.0 s (0) | 0.0 s (0) | **0.0 s (0)** |
**The floor is 35.0 s, not 30.0 s** — weighted family, and the worst single run
is `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W reversal. The
argmax is printed with the table on purpose (see below): a worst case sitting on
an edge of the grid would be a number about the grid.
⚠️ **AND BY THAT STANDARD, TWO OF ITS FOUR AXES ARE ON AN EDGE. Measured, at the
argmax's other axes held fixed:**
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
0.10 -> 35.0 s
Strictly increasing in stagger and maximal at 4, the largest modelled stagger;
and maximal at the most asymmetric split. It is interior only in `gain` (2nd of
6) and `slew_w` (3rd of 5).
⚠️ **SO THE HEADLINE IS 0.0 s AT stagger=0, AND 35.0 s IS ENTIRELY A PRODUCT OF
THE MODELLED SERIALISED-WRITE STAGGER.** That stagger is declared MODELLED at
`sim/delivery_window.py:60-64` — it is a term of this figure, not a detail of
it. A site whose two writes land in the same cycle does not produce this episode
at all.
This does not move `delivery_hold_s`: 185 s must cover the worst case the
modelled site can produce, and a stagger of four cycles is inside what a real
two-inverter write path can do. But a reader entitled to ask "is 35.0 s about
the plant or about the grid?" deserves the answer, and the answer is: **partly
about the grid, and the grid's shape is the record's own, fixed by §3.3 before
this rebuild ran.** Found in review of TEST-21; the PR body had claimed the
argmax was interior, and it is not.
**WHAT REPRODUCED, EXACTLY:**
* the **N=1 control column is 0.0 s at every threshold**, in every run. That is
§3.2's algebra measured, and it is the claim the compatibility promise rests
on. (This rebuild ran the control arm over all three families — 2640 runs —
where the original ran 1890, i.e. the two families whose stimulus is not
weighted. The weighted stimulus at a roster of one is the same stimulus with
inert weights, and it too reports nothing at any threshold.)
* the **flapping family is 0.0 s at every threshold**, in all 840 runs. The
proper-subset rule really does defeat the family that defeated SAFETY-30's
site window.
* **45.0 s at twice A's lag**, to the cycle. See the B-lag table below.
**WHAT DID NOT:**
* the headline. **35.0 s against a recorded 30.0 s** — one control cycle worse,
and 20.0 s → 30.0 s on the reversal family, two cycles worse.
* **the non-monotonicity.** §3.3 finding 3 read the original 55.0 s at 250 W
against 40.0 s at 100 W as a property of the reduction. **This rebuild's
curve is monotone decreasing on both N=2 families.** The *mechanism* finding 3
describes is real and is still the reason not to read either table as a
sensitivity curve — lowering the threshold really can turn a proper subset
into the whole roster and break the run — but **this grid does not exhibit
it**, so the claim now rests on the mechanism alone and no longer on a
measurement. That is a weaker claim than the one this record made, and it is
written down as weaker.
* the low-threshold rows, badly: 120.0 s / 165.0 s at 100 W against 40.0 s /
75.0 s. Nothing ships at 100 W, but the size of that gap is the honest measure
of how much of the original table was a property of axes nobody wrote down.
⚠️ **WHY THE NUMBERS MOVED, AS FAR AS IT CAN BE ESTABLISHED.** §3.3 fixes the
*counts* of every axis (6 gains × 5 slews; 7 reversal sizes; 7 flap periods × 4
amplitudes; 5 staggers; and the 5 splits, which it lists) and the *ranges*
(`config.yaml`'s `gain` `float(0.05,1.0)` and `slew_w` `int(0,5000)`). It does
**not** fix the individual values, the run length, or the per-unit cap. The
rebuild spread them over the admissible ranges and **prints every one of them
above its own table**, so the caveat travels with the number instead of living
in somebody's memory. The residual disagreement — one to two control cycles at
the shipped threshold — is inside the sensitivity to that choice; the
low-threshold rows are not, and are reported as not.
⚠️ **AND THE HARNESS WAS NOT ADJUSTED UNTIL IT AGREED.** The one substantive
correction made during the rebuild was made *against* the doc's number, not
towards it, and it is recorded in §1: grading on the plant's latch rather than
the controller's standing command produced a harness that could not name even a
wedged unit. Fixing that made the numbers **larger**. Nothing else was tuned.
Findings 1-3 below are the original campaign's, kept with finding 3 now
qualified by the re-run above:
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
threshold.** That is the compatibility promise measured, not argued.
@@ -268,37 +443,77 @@ would be a preference, and all the margin this check needs is in the window.
Four bounds, and the value clears all four.
* **lower, from the rig — 30.0 s.** The worst continuous legitimate episode at
the shipped threshold across all 2640 runs (weighted family; 20.0 s on
reversals, 0.0 s on flapping).
* **lower, from the rig — 35.0 s** (TEST-21, 2026-09-07; **the original record
said 30.0 s and it did not reproduce**). The worst continuous legitimate
episode at the shipped threshold across all 2640 runs — weighted family, worst
single run `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W
reversal; 30.0 s on reversals, 0.0 s on flapping. Re-run it with
`admin/goodwe:sim/delivery_window.py --campaign` (§1).
⚠️ **THIS IS NOW STRICTLY THE BINDING LOWER BOUND, WHICH IT WAS NOT BEFORE.**
At 30.0 s it coincided with the firmware's veto below and a reader could not
tell which of the two was doing the work. At 35.0 s the rig's number is on its
own, above the firmware's — so **the rig is what stops `delivery_hold_s` going
low**, and the schema's `int(30,900)` floor no longer sits above the worst
legitimate episode. An operator who sets it to the schema minimum of 30 s is
now **below** a measured legitimate episode and will get false reports; the
option is marked *derived, do not change* and this is what that marking is
for. Raising the schema floor to 35 is a change to a shipped option's
contract and is left to its own card rather than smuggled in here.
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
executing its share for less than the firmware's own veto window is a *link*
fault the add-on already answers by dropping it from the split. Reporting
inside that window would be a second, louder alarm about a state already
being handled — the SAFETY-15/SAFETY-19 failure exactly.
* **margin, from inverter B not existing — and it is MEASURED, not felt.** The
campaign's B is A copied, so 30.0 s is two identical machines. Sweeping a
*different* second unit through the weighted family (750 runs each):
* **margin, from inverter B not existing.** ⚠️ **THIS ROW IS A SENSITIVITY
STUDY ON A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, AND THE ORIGINAL
WORDING ("MEASURED, not felt") OVERSTATED IT.** Inverter B is not installed
(~2026-09-12; NOTES.md §1.1b). The campaign's B is **A copied**, `measured=
False`, so the 35.0 s above is **two identical machines** and every row below
is a *what-if*: what a slower second unit would cost this window. **No row
here is B's lag.** What it is genuinely evidence for is the SHAPE of the
margin — which term the window has to be sized against — and that is what it
is used for. Sweeping a *different* second unit through the weighted family
(750 runs each). Second column is the original record, third is TEST-21's
re-run with the committed harness (`--blag`):
| B lag | B ramp | worst legitimate | runs naming |
|---:|---:|---:|---:|
| 10.0 s (A) | 800 W/s (A) | **30.0 s** | 368/750 |
| 20.0 s | 800 W/s | 45.0 s | 469/750 |
| 10.0 s | 400 W/s | **30.0 s** | 370/750 |
| 20.0 s | 400 W/s | 45.0 s | 469/750 |
| 40.0 s | 200 W/s | **90.0 s** | 500/750 |
| B lag | B ramp | 2026-09-06 (uncommitted) | **TEST-21 re-run** | runs naming |
|---:|---:|---:|---:|---:|
| 10.0 s (A) | 800 W/s (A) | 30.0 s | **35.0 s** | 403/750 |
| 20.0 s | 800 W/s | 45.0 s | **45.0 s** | 454/750 |
| 10.0 s | 400 W/s | 30.0 s | **35.0 s** | 411/750 |
| 20.0 s | 400 W/s | 45.0 s | **50.0 s** | 454/750 |
| 40.0 s | 200 W/s | 90.0 s | **75.0 s** | 478/750 |
**The RAMP does not move it at all and the LAG moves it roughly linearly.**
The shortfall lasts as long as the unit's first-order lag holds it more than
`saturation_w` below its share; at these step sizes the ramp is never the
binding term. So the number to carry margin against is B's lag, and a unit
**four times slower than A** produces 90.0 s.
**The LAG moves it roughly linearly and the RAMP barely moves it**, which is
the original finding and it survives — with one correction: *"the RAMP does
not move it at all"* is too strong. Halving the ramp is worth 0 s at A's lag
and **+5 s at twice it** (45.0 → 50.0), so the ramp is a second-order term
rather than no term. The shortfall lasts as long as the unit's first-order lag
holds it more than `saturation_w` below its share; at these step sizes the
ramp is rarely the binding one. So the number to carry margin against is still
B's lag — and a hypothetical unit **four times slower than A** produces
**75.0 s**, where the original record said 90.0 s. ⚠️ Note the direction: this
row moved **in the margin's favour**, while the floor above moved against it.
Neither was adjusted to suit the other.
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
**185 s** is thirty-seven control cycles: **6.2×** the measured worst, **2.06×**
the four-times-slower case, **6.2×** the firmware veto and **4.9×** inside the
quarter. ⚠️ Deliberately **not 180** — that is SAFETY-02's site hold, and a
reader must not be able to think this was carried across — and not 150 or 200.
**185 s** is thirty-seven control cycles. Against the TEST-21 re-run it is
**5.29×** the measured worst legitimate episode (35.0 s), **2.47×** the
hypothetical four-times-slower second unit (75.0 s), **6.2×** the firmware veto
(30 s) and **4.9×** inside the quarter (900 s). ⚠️ Deliberately **not 180** —
that is SAFETY-02's site hold, and a reader must not be able to think this was
carried across — and not 150 or 200.
⚠️ **185 STANDS, AND HERE IS THE ARITHMETIC RATHER THAN THE ASSURANCE.** The
record claimed 6.2× the measured worst; the re-run makes that **5.29×**. All
four bounds still clear, none of them narrowly, and the *ordering* of the
argument is unchanged: the rig's floor is the binding one, the quarter is the
ceiling, and 185 sits between them with margin at both ends. So the value is
**not changed** — a shipped safety constant is not moved because its margin
went from 6.2× to 5.3× while every bound still holds, and moving it would
invalidate SAFETY-41's four-attempt false-trip regime for nothing. What IS
changed is the claim: this record no longer says 6.2×, and no longer says 30.0
s, because neither is what the harness produces.
**What it costs, stated rather than hidden:** up to 185 s of a site running
short by one unit's share before anybody is told. Against a fault that ran
@@ -0,0 +1,381 @@
# SAFETY-43 — delinquency measured against the ask, not against a flat 500 W
**Status: closed.** `delivery.delinquent()` names a unit whose `short_by()` gap
exceeds `min_w` **or** exceeds a fraction of that unit's own share. The fraction
is `delivery.DELIVERY_SHARE_FRACTION = 0.85`, derived on the campaign in §3.
⚠️ **INVERTER B DOES NOT PHYSICALLY EXIST** (expected ~2026-09-12; NOTES.md
§1.1b). `plantsim.INVERTER_B` is inverter A **copied**, `measured=False`, and
`test_packshare.py`'s plant is modelled in that file. **Every two-inverter
figure in this document is two copies of A.** Nothing here is a measurement of
inverter B.
## 0. The defect, and why it is the quiet regime that matters
[[OPT-09]] passes SoC- and capacity-aware weights to the allocator. A weight is
a function of a pack's **energy headroom**, not of what the pack delivers, so a
**wedged** unit — one that accepts its share and produces nothing — is weighted
down exactly like a legitimately low-headroom one. `delinquent()` read an
**absolute** gap, `min_w = Tuning.saturation_w = 500 W`. Shrinking the share
shrinks the gap. Measured end to end on the real loop (`test_packshare.py`
part 8b: real `compute()`, real `plan()`, real `packshare.weights()`, real
`delivery.account()`, real `delinquent()`; B wedged at a healthy 15 % against its
own 10 % cut-out, so no envelope closes and `account()` explains nothing — a
SAFETY-42 **true positive** by that card's own definition):
load 1500 W equal weights shares {a:1500, b:1500} named on 199/200
load 1500 W OPT-09 weights shares {a:1505, b: 125} named on 0/200
A unit delivering **0 of 125 W** is 100 % delinquent and 125 W short. Only the
absolute threshold disagreed.
⚠️ **THE SUPPRESSED REGIME IS THE DANGEROUS ONE.** At a load the healthy sibling
covers, the meter is satisfied (grid within 5 W of zero on that fixture),
`unmet_w` is 0 because both units *accepted* their shares, no envelope closes and
no action-log line fires. The delinquency alarm is the **only** instrument that
would have told the operator the site was quietly running one inverter.
## 1. What was NOT done, and it is the larger half of this card
⚠️ **`min_w` WAS NOT LOWERED.** It is `Tuning.saturation_w` — the gap at which
the control law itself concludes the inverter is not following — and it exists to
stop **normal saturation** being reported as delinquency. Lowering it trades a
missed alarm for a false one on every unit at once. [[SAFETY-41]] spent **four
attempts** on notification cadence in this system and the owner's words were
*"i'm getting really annoyed by the notifications in my HA"*.
So the change is an **`or`**, never a looser first arm:
named if short_by() > min_w OR short_by() > frac * share
Everything `min_w` named before is still named. Nothing `min_w` refused becomes
noisy **unless the unit is delivering almost none of its ask**, which is not
saturation-shaped at all. `test_delivery.py` part 1 runs all seven of its
existing `delinquent()` fixtures under **both** rules — `share_frac=None` is
SAFETY-42's rule exactly — and requires identical answers on every one.
The absolute arm's suppression window is therefore **unchanged**: still
`site target < 2·min_w` = 1000 W at equal weights, still
`site target < min_w·(w_A+w_B)/w_B` = 6500 W under OPT-09's weights on part 8b's
fixture. Part 8b still asserts both numbers, now as evidence that the first arm
did not move.
## 2. The evidence is a diff between two rules on ONE run
`delinquent()` takes `share_frac`, and **`None` means the absolute arm alone**.
That parameter exists for one reason: so the red and the green come out of one
run over one tree instead of out of two commits that a reader has to trust.
`test_packshare.py` part 8b grades every cycle twice:
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
load 1500 W equal weights absolute arm alone named on 199/200 cycles
load 1500 W equal weights the shipped rule named on 200/200 cycles
The equal-weights arm gains **exactly one cycle** — the first, where the slew
limit holds the split at 500/500, a 500 W gap is not *over* a 500 W threshold
(the absolute bound is strict) and the relative arm names a box delivering none
of its 500 W. That single cycle is the whole of what this card costs an arm that
was already firing.
⚠️ **THE PIN THAT INVERTED.** Part 8b was written by OPT-09 to pin the
**suppressed** behaviour, and its comment said: *"if it ever goes red because the
alarm fired, read the card that made it fire before 'fixing' this line."* This is
that card. The pin now asserts the opposite answer and keeps the old one beside
it, measured rather than remembered.
## 3. ⭐ Deriving the fraction
⚠️ **DERIVED ON THE SAME CAMPAIGN AS `delivery_hold_s`'s FLOOR**, not chosen.
`admin/goodwe:sim/delivery_window.py` — the harness [[TEST-21]] committed —
gained a second grading axis: the same **2640 no-fault runs at N=2** (three
families × 6 gains × 5 slews × write staggers; every unit executes the share the
allocator gave it) and the same **2640 N=1 control runs**, graded at the shipped
`min_w = 500 W` for each candidate fraction, on every cycle of every run — so a
pass grades every fraction it is given, and the absolute arm beside them, over
one tree and one set of runs. Every row in a pass is comparable to every other
row in it, run for run.
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
python3 -B delivery_window.py --selftest --campaign --fractions
⚠️ **THE TABLE BELOW WAS PRODUCED BY TWO PASSES; `FRACTIONS` NOW HOLDS ALL
TWENTY SO IT TAKES ONE.** The derivation ran the shipped ten
`(0.25 … 0.99)` and then the ten that resolve the low end
`(0.05 … 0.65)` with only that tuple edited between them. **The absolute-arm
control row came out identical in both** (30.0 / 0.0 / 35.0 / 0.0, same argmax),
which is what says the two passes are one campaign. A table a reviewer cannot
reproduce without knowing to edit a line nobody wrote down is PROC-01's shape in
miniature, so the committed `FRACTIONS` was widened to all twenty in review: the
command above now reproduces every row below.
### 3.1 The curve
Worst **continuous** legitimate episode (the measure `DeliveryWatchdog` actually
sees — its window is cleared by any cycle that does not name), runs naming in
brackets:
| `share_frac` | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640)** |
|---:|---:|---:|---:|---:|
| absolute only | 30.0 s (460) | 0.0 s (0) | 35.0 s (403) | **0.0 s (0)** |
| 0.05 | 85.0 s (826) | 0.0 s (0) | 90.0 s (616) | **0.0 s (0)** |
| 0.10 | 75.0 s (824) | 0.0 s (0) | 90.0 s (607) | **0.0 s (0)** |
| 0.15 | 70.0 s (836) | 0.0 s (0) | 70.0 s (609) | **0.0 s (0)** |
| 0.20 | 70.0 s (839) | 0.0 s (0) | 60.0 s (629) | **0.0 s (0)** |
| 0.25 | 55.0 s (832) | 0.0 s (0) | 60.0 s (611) | **0.0 s (0)** |
| 0.30 | 55.0 s (840) | 0.0 s (0) | 50.0 s (614) | **0.0 s (0)** |
| 0.35 | 55.0 s (840) | 0.0 s (0) | 55.0 s (618) | **0.0 s (0)** |
| 0.40 | 50.0 s (840) | 0.0 s (0) | 60.0 s (658) | **0.0 s (0)** |
| 0.45 | 45.0 s (840) | 0.0 s (0) | 45.0 s (627) | **0.0 s (0)** |
| 0.50 | 45.0 s (840) | 0.0 s (0) | 45.0 s (636) | **0.0 s (0)** |
| 0.55 | 40.0 s (840) | 0.0 s (0) | 40.0 s (636) | **0.0 s (0)** |
| 0.60 | 40.0 s (840) | 0.0 s (0) | 40.0 s (645) | **0.0 s (0)** |
| 0.65 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| 0.70 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| 0.75 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| 0.80 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| **0.85** | **35.0 s (840)** | **0.0 s (0)** | **40.0 s (635)** | **0.0 s (0)** |
| 0.90 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
| 0.95 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
| 0.99 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
Worst of the three families, which is what `delivery_hold_s` has to cover:
frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
⚠️ **THE FIRST ROW IS THE CONTROL AND IT REPRODUCED SAFETY-42 §3.3 EXACTLY** —
30.0 s / 0.0 s / 35.0 s, and the same argmax (`gain=0.2 slew_w=1000 stagger=4
split=0.10/0.90`, ±2200 W reversal). It is measured **in the same run** as every
row below it, which is what makes those rows a comparison rather than a claim.
### 3.2 What the curve says
* ⚠️ **THERE IS NO CLIFF ANYWHERE IN THE CURVE, AND THAT IS THE FINDING.** From
**0.45 to 0.99** every figure is 40.0 or 45.0 s — within **one control cycle
(5.0 s)** of every other — and below 0.45 it rises **gradually**: 60.0 s at
0.25, 70.0 s at 0.15, 90.0 s at 0.05. A reviewer asking "why 0.85 and not
0.90?" is asking about a difference this campaign cannot resolve, and the
honest answer is that it cannot.
* ⚠️ **IT IS NOT MONOTONE** — 0.40 reports 60.0 s where 0.35 reports 55.0, and
0.65–0.80 report 45.0 s where 0.55/0.60 and 0.85–0.99 report 40.0 — and that is
SAFETY-42 §3.3's own mechanism (c) rather than noise in the plant: **a more
sensitive rule puts MORE units into the short set, which can turn a proper
subset into the WHOLE roster and END the episode.** The reduction is not
monotone in its own threshold. Do not read this column as a sensitivity curve
in the ordinary sense.
* **The shoulder is at about 0.45**, and it is a shoulder rather than an edge:
the reversal family's naming count saturates at 840 of 1050 runs from 0.30
upward, so below the shoulder what grows is the LENGTH of an episode, not the
number of runs that have one.
* **The N=1 column is 0.0 s at every fraction, in all 2640 one-inverter runs.**
The compatibility promise is measured for the new arm, not argued.
* **The flapping family is 0.0 s at every fraction, in all 840 runs.** A common
stimulus makes both units short at once and the subset stops being proper —
the property that defeated SAFETY-30's site window still holds.
### 3.3 ⚠️ "Zero false namings" is not available at ANY fraction
The card asked for *the largest fraction that yields zero false namings across
the whole no-fault campaign*. **There is none.** Every fraction adds naming
cycles: the reversal family goes from 460 runs naming to 840, the weighted family
from 403 to 635. A relative arm that never fired on a legitimate transient would
be a relative arm that never fired at all — the write stagger lands B's number up
to four cycles late, and a unit whose new command has not arrived is, for those
cycles, delivering a small fraction of what is standing on its wire.
What **is** zero at every fraction is **false alarms**:
> the longest continuous legitimate episode anywhere in the 20-point sweep is
> **90.0 s** — at 0.05, the most sensitive fraction measured — against a
> `delivery_hold_s` of **185 s**. Nothing in 2640 no-fault runs comes within
> **95 s** of notifying an operator, **at any fraction measured**. At the
> shipped 0.85 it is 40.0 s, i.e. 145 s of slack.
That is the quantity SAFETY-41 cares about and it is the one this card had to
protect. Stated as margin, at the shipped fraction: **185 s is 4.62×** the worst
legitimate episode, where the absolute arm alone gave 5.29×.
### 3.4 The bounds 0.85 answers to
* **LOWER, from the campaign** — the shoulder at about **0.45**, below which the
added episode starts to grow (60.0 s at 0.25, 90.0 s at 0.05). 0.85 is 0.40
above it.
* **UPPER, from the card's own case** — strictly **below 1.0**. At 1.0 the arm
can only fire on a unit that is *importing* while asked to discharge, which is
SAFETY-30's fault and not this one, so a unit delivering **none** of its share
(gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
wedged box reporting a small standby figure instead of a clean zero.
* **UPPER, from the hold** — every fraction in the flat range leaves
`delivery_hold_s` between **4.11×** (45.0 s) and **4.62×** (40.0 s) the worst
legitimate episode, well inside the four bounds SAFETY-42 §3.3 requires of it.
**`delivery_hold_s` IS NOT CHANGED BY THIS CARD.**
* ⚠️ **NOT DERIVED, AND SAID SO: the exact value inside [0.45, 1.0).** The
campaign cannot separate them. 0.85 is the midpoint of the argument above —
0.40 of margin above the shoulder, 0.15 below the ceiling — and a
re-derivation that moved it to 0.90 would be moving a number this campaign
never resolved. It is not moved for that reason.
## 4. The positive control — what the relative arm must still NOT say
A card that adds a way to raise an alarm has to show what it did **not** add.
`test_packshare.py` part 8b runs the same fixture, the same weights and the same
125 W share with the one fact that makes the shortfall legitimate changed:
| fixture | B's share | named | why it is silent |
|---|---|---|---|
| B wedged at a healthy 15 % (the fault) | 77–154 W | **200 / 200** | — it is the fault |
| B at its own 10 % cut-out | **0 W, all 200 cycles** | **0 / 200** | it is never **asked** |
| B delivering **half** its share | 120 W | **0 / 200** | short by 50 %, inside both arms |
### ⚠️ The cut-out row does NOT grade `account()`, and this record said it did
**Corrected in review.** The first draft of this section claimed the wedged and
cut-out arms had *"identical power traces — 0 W against a positive share, 100 %
of the ask missing"* and that *"what separates them is `account()`"*. **Every
part of that was false**, and the check that carried it was **vacuous**:
* at its own cut-out the pack's usable energy **above that cut-out is zero**, so
`packshare.weights()` answers **0.0** for it, `_fill()`'s preferred pass gives
it nothing, the healthy sibling covers the load alone so the last-resort pass
never runs, and its share is **0 W on all 200 cycles**;
* a unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns `{}`
**before either arm is evaluated** and `account()` is **never consulted**;
* the traces are not identical — the cut-out arm's share is **0 W on every
cycle** (asserted in part 8b and printed on its line) while the wedged arm's
is **125 W** at the last cycle (asserted in the GREEN check) and takes four
distinct values 77/125/133/154 W across the 200, measured by re-running the
same loop with per-cycle recording during review — and "100 % of the ask" is
0/0.
Measured, by deleting the gate the claim rested on:
cut-out arm, account() live named 0/200, distinct B shares: [0]
cut-out arm, account() DELETED named 0/200, distinct B shares: [0]
wedged arm, account() live named 200/200, shares [77,125,133,154]
⚠️ **The fact that replaces it is better, not weaker.** At its own cut-out
**OPT-09 stops asking the pack at all**, so SAFETY-43's relative arm is never
reached — a **second, independent protection sitting upstream of `account()`**:
the allocator declines to ask before the detector has to decline to alarm. Part
8b now asserts the 0 W share and prints it, and runs the arm a second time with
`account()` deleted to show which mechanism is doing the work.
⚠️ **It was invisible for one reason worth keeping**: that line was the only one
in part 8b that did not print its shares. `inverter-b+0` would have been on
screen from the first run. Every line in that part prints them now, and the
derated control's vacuity guard now asserts **its own** unit is asked, rather
than re-asserting a different run's 200/200 — which is exactly why the old guard
could not see this.
**The account gate is graded where it is real**, on a positive 125 W share, in
`test_delivery.py` part 1 (*"THE ACCOUNT GATE IS UNTOUCHED BY IT"* — the same
0 of 125 W with the shortfall explained names nobody). That part also carries
that the relative bound is **strict** (a gap of exactly `frac · share` is not
over it) and that the proper-subset rule still refuses a site-wide cause (both
units delivering none of a 125 W share names nobody).
## 5. N=1 is byte-identical, and it is asserted rather than argued
The live install is **N=1**. Two independent statements, each graded against the
thing that would break it (`test_packshare.py` part 8b):
1. **`allocator.plan()` returns before `weights` is read at all.** Graded by
handing it a `weights` mapping that **raises on any read**: at a roster of one
it answers identically to the same call with no weights; at a roster of two
the same mapping is read and raises, so the tripwire is a real one.
2. **`delinquent()` answers `{}` at a roster of one for every input** — 84
combinations of (share, measured, rule), including **0 of 125 W**, the exact
input this card added an arm for. `len(asked) < 2` returns before either
threshold is compared, so the fraction is never even multiplied.
Nothing in this card is reachable at one inverter. It must land before inverter B
is commissioned (~2026-09-12), not before the next deploy.
## 5b. Mutation — the fraction is pinned from BOTH directions
⚠️ **RUN, NOT REASONED ABOUT.** Each mutation was applied to a tree **copied
fresh** from this branch and mounted `:ro` inside the checks image, so no `.pyc`
could go stale — MUTATION.md's defect, and all three of these are **reds**, the
direction that defect cannot fabricate. Recorded in `test_packshare.py` part 11
as M12–M14.
| mutation | named checks | where |
|---|---:|---|
| **M12** drop the relative arm, keep `gap > min_w` — *the card itself reverted* | **7** | `test_delivery.py` part 1 (3: the 0-of-125 W naming, the proportion in the sentence, the strict relative bound) + `test_packshare.py` 8b (4: GREEN back to 0/200, the equal arm's 200 back to 199, the 6000 W arm back to 195/199, the derated control's guard). `test_alloc.py` / `test_control.py` stay green — nothing outside this check moves. |
| **M13** `DELIVERY_SHARE_FRACTION` → **1.0** — *too loose* | **7** | part 1 (2) + 8b (5), including the PRECONDITION that the fraction is strictly below 1.0. At 1.0 the card is a no-op: gap/share of exactly 1.0 is not *over* 1.0. |
| **M14** `DELIVERY_SHARE_FRACTION` → **0.05** — *too sensitive* | **6** | part 1 (2: the strict bound, and *"IT TOOK NOTHING AWAY"* stops being true) + part **4b** (3: the `saturation_w` pin goes **vacuous**, the relative arm naming B at every threshold) + 8b's derated control, where a unit delivering half its share starts being named. |
M13 and M14 are the pair that matters: the constant cannot be moved in **either**
direction without a named check moving with it.
### ⚠️ Two of these counts were wrong, and the reason is worth keeping
**Corrected in review.**
* **M12 and M13 read 1 and 1 in `test_delivery.py`, not 3 and 2.** Part 1 printed
the alarm sentence with a bare subscript, `delinquent(_tiny, 500)["b"]`. On any
tree where the relative arm is broken or absent that raises `KeyError` and
**aborts the file at part 1**, leaving parts 2–8 ungraded — so the recorded
coverage of a mutation run read *smaller* than the truth. The direction is safe
(an abort is a red, and MUTATION.md's defect can only fabricate a green), but a
figure that does not reproduce is a figure that does not reproduce. It is a
`.get` now, with the reason written beside it, and the counts above are from
re-runs with the guard in place.
* **M14 was recorded as 4 named checks in `test_delivery.py` and it is 5** — part
1's pre-existing *"a shortfall AT the threshold is not one"* also goes red. That
one under-claimed, so it never inflated the evidence; it is still a recorded
number that did not reproduce.
## 6. ⚠️ [[CFG-07]] is adjacent and is NOT absorbed here
CFG-07 is that `delivery_hold_s` is declared `int(30,900)` in `config.yaml`'s
schema while the measured worst legitimate episode at the shipped threshold is
**35.0 s** — so the schema offers an operator a value (30) that is **below a
measured legitimate episode** and would report on ordinary saturation.
**This card makes it worse, and by exactly one control cycle.** The relative arm
takes the worst legitimate episode from **35.0 s to 40.0 s** at the shipped
fraction, so the band of schema-legal but false-tripping values widens from
`[30, 35]` to `[30, 40]`. The floor the schema should have is now **40 s**, not
35 s. It is not fixed here: raising a schema floor is an option-surface change
with its own compatibility argument (an existing config carrying `35` would start
being refused), and folding it into a safety fix would hide it. **CFG-07 should
be re-derived against 40.0 s, not 35.0 s.**
⚠️ **WHEN IT HAS TO CLOSE: BEFORE INVERTER B IS COMMISSIONED (~2026-09-12).**
Not before the next deploy. The band is **unreachable at N=1** — `delinquent()`
returns `{}` for every input at a roster of one, so no value of
`delivery_hold_s` can false-trip on the install that is running today — and the
live install is N=1 until B exists. Commissioning B is the moment the band
becomes reachable, and that is the deadline this card hands it.
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE.** It exists as a
`[[CFG-07]]` mention in `CHANGELOG.md` and in this section, and nowhere else —
so there is no file for the 40.0 s to be recorded in, and a reader who goes
looking will find a card reference with no record behind it. Whoever takes
CFG-07 writes `DECISIONS/CFG-07-*.md` first; the number it has to answer to is
**40.0 s**, measured here, and the harness that produces it is
`admin/goodwe:sim/delivery_window.py --campaign --fractions`.
## 7. Provenance of every number in this document
| number | where it came from |
|---|---|
| 199/200, 0/200, 200/200, shares 1505/125 | `test_packshare.py` part 8b, run |
| 35.0 s absolute-arm floor, and its argmax | `delivery_window.py --campaign`, re-run here, reproduces SAFETY-42 §3.3 |
| every row of §3.1 | `delivery_window.py --campaign --fractions`, two passes over the same campaign with `FRACTIONS` changed between them |
| 1000 W / 6500 W suppression windows | part 8b's arithmetic, asserted there |
| 185 s, 5.29× | SAFETY-42 §3.3 / TEST-21, unchanged by this card |
| 4.62× | 185 / 40.0, printed by the campaign |
| 0/200 on both positive controls, and B's 0 W share at the cut-out | part 8b, run and asserted |
| 0 W cut-out share / 125 W wedged share | part 8b, asserted and printed |
| 77/125/133/154 W (the wedged arm's four distinct shares) | the same loop re-run in review with per-cycle recording — NOT part 8b's printed line, which shows the last cycle only |
| M12/M13/M14 named-check counts (7 / 7 / 6) | re-run in review on fresh `:ro` trees, after the `KeyError` abort in part 1 was guarded |
| 84 single-unit rosters | part 8b, run |
⚠️ Every two-inverter figure above is **two copies of inverter A**.
@@ -0,0 +1,141 @@
# TARIFF-09 — asking the feed for the horizon, and what `price_feed_url` means
Two decisions. The first had no defensible alternative once it was measured;
the second did, and the alternative is argued below rather than dismissed.
---
## 0. The measurement this card exists for
`generic_dayahead.fetch()` accepted `since` and `until` and requested the bare
`https://api.energy-charts.info/price?bzn=BE`. That URL names no range, and the
feed answers **one Brussels day**.
Measured on the merged tree (`4257e38`) at 2026-09-10 09:00 CEST, 48 h horizon,
live feed, yesterday seeded through the shipped read path:
```
60 firm points (15 whole hours) + 33 carried hours of 48 requested
carried: 24 h on 2026-09-11, 9 h on 2026-09-12
arbitrage(0.10): allowed, spread 0.4645, buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
firm local days = [2026-09-10]
```
Everything past today was carry-forward, flagged provisional, and §8.3 drops
provisional hours from the trade set. **The optimiser never saw tomorrow as
firm, at any hour.** The ceiling was structural rather than incidental: with a
one-day window, at hour *H* of the local day a 48 h horizon is at most
**24−H hours firm and 24+H carried** — 15/33 at 09:00, and **1/47 at 23:00**,
where the remaining firm spread (0.0464 EUR/kWh) no longer even clears the
default 0.10 round-trip threshold and arbitrage is refused outright.
That is not what FCST-04's fill was built for. FCST-04 covers the
pre-publication window until the ~13:00–14:00 CET auction, after which a
healthy feed hands over tomorrow in full.
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns **192 quarter-hours across
two local days**, committed unmodified as
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`. The
plugin simply never asked.
After the change, same code path, same window, a feed serving both days:
**39 firm hours and 9 carried**, firm across **two** Brussels days, and the
permitted cycle buys on one day and sells on the other. Graded in
`test_price_history.py` section 1c against both feed states.
---
## 1. The range is derived on the **Brussels** calendar, from `since`/`until`
`since` and `until` are **instants**; the API takes **dates**. Those are not
the same question, and the conversion is where the day gets lost.
`brussels_date_range()` returns two inclusive `date`s:
* the first is `brussels(since).date()`. **Not `since.date()`, and not the UTC
date.** 22:30 UTC in summer is already tomorrow in Belgium, so a UTC-derived
start asks for the day the controller has just left and drops the day it is
standing in — for the last one or two hours of *every* day. This is
`price_history._bdate()`'s lesson one layer up, with a worse consequence:
`_bdate()` misfiles an hour, this never fetches it.
* the last is `brussels(until).date()`, **stepped back a day when `until`
falls exactly on local midnight**, because `until` is exclusive and that
instant is the first tick of a day nobody asked for. Without it every
whole-hour horizon requests one extra day — harmless on this feed, but it
makes the request a claim the caller never made.
* an empty or inverted window collapses to a single day rather than producing
a backwards range, which no feed has a defined answer for.
Both ends are graded, and both are driven red by mutation — including the two
that swap `brussels()` for the UTC date (`test_price.py` section 2c).
---
## 2. `price_feed_url` is the **exact URL**, used as given. The plugin appends
nothing.
The alternative — merge our `start`/`end` into whatever query string the
operator supplied — was considered and rejected. Three reasons, in the order
they decided it:
1. **`start`/`end` are energy-charts' spelling, and this provider declares a
second host.** `generic_dayahead.capabilities().egress_hosts` is
`("api.energy-charts.info", "web-api.tp.entsoe.eu")`. That second host's
day-ahead date parameters have not been verified by this card and are not
guessed at here — but they are not `start`/`end`. So an override to the
*other declared host* — a case the declaration itself anticipates — is
exactly where merging appends two parameters that host ignores, producing
the short horizon anyway, with a URL nobody typed as the receipt.
2. **Merging means re-serialising a URL somebody else wrote.** Parsing and
rebuilding reorders parameters, collapses duplicates and re-escapes values.
"Override the feed" is what the option already says it means, and an
override that is silently rewritten is not one.
3. **Blind appending is not on the table at all.** `dated_feed_url()` refuses
to add a query string to a base that already has one, so
`?bzn=BE?bzn=BE&start=…` cannot be produced by this module even by a later
edit that folds the parameters back into the constant.
⚠️ **The cost is real and it is therefore NOT a doc footnote.** An overriding
operator gets exactly the short horizon this card exists to fix. So the plugin
emits a `WARNING` on the shipped `goodwe.price` logger at **fetch time**, naming
the consequence — one Brussels day, the rest carried and flagged, §8.3 dropping
them from the trade set — and naming the `start..end` it *would* have asked
for. §11.4 is what makes a price defect quiet; an operator who has taken the
short horizon has to be able to find out from the log that they did.
The warning is graded, with a control that the default path stays silent, and
a mutation that downgrades it to `debug()` takes that check red.
**Fetch time rather than startup**, because that is where the URL actually in
use is known. The trade-off is one line per refresh (the tariff task, not the
30 s control tick). If the status page ever grows a price-source row, this is
the sentence it should carry.
---
## 3. The plugin contract: `fetch(self, opts, since, until, transport)`
The signature promises every plugin the range, and until this card **none of
the four networked plugins used it**. The obvious follow-up is to enforce the
promise. It is **not** enforced, and the reason is TARIFF-08's:
Three of the five shipped plugins point at endpoints nobody has ever verified
(TARIFF-07 is the open decision on withdrawing them). A rule obliging them to
pass a date range would be satisfied by an **invented parameter name** — which
is the parser-graded-against-its-own-guess defect moved from the response to
the request, and it would ship looking like a guarantee.
What exists instead is a **roster pin** in `test_price.py` section 2c: which
plugins reference their `since`/`until` is asserted as a literal, derived from
the registry and the AST. A sixth supplier, or a change to any of the three,
has to edit that line and make the decision rather than inherit the silence.
It is named as a source-shape pin, not as evidence that any feed parses;
`generic_dayahead`'s behaviour is graded separately by the URL it really
requests.
**Recommendation for whoever picks up TARIFF-07:** enforce it at the moment
those three endpoints are either verified or withdrawn, not before. If they are
withdrawn, `generic_dayahead` and `manual_csv` are the whole roster and the
rule is one line — `manual_csv` reads a whole file and is windowed downstream,
so it would be the single documented exemption.
@@ -0,0 +1,379 @@
# UX-04 — what the Configuration tab renders, and the pin that was never built
**Status: DECIDED 2026-09-07.** Three decisions and one confirmation, in the
order [[UX-01]]'s unmet acceptance criteria are numbered:
1. **UX-01 AC 3 (hide the optional fields behind HA's toggle) is WITHDRAWN as
unsatisfiable**, not deferred. The target was arithmetically unreachable, and
the part of it that *was* reachable would be bought with the upgrade path.
The measurement is now **graded** in `tools/supervisor_shape_probe.py` §F
rather than printed, so the withdrawal cannot be quietly reversed.
2. **UX-01 AC 4's third clause is now BUILT**: `test_defaults_pin.py`, an AST
check, with its red direction executed on every run.
3. **UX-01 AC 5 / the CFG-04 double-literal hole is CONFIRMED CLOSED on
`release/1.0`, by [[UX-02]]'s `bcee235`, not by UX-01's `fef6ef2`.** The
attribution is corrected here; no work was re-done.
4. **The 8 per-entry `inverters:` translations STAY.** They do not reach the
Supervisor tab and cannot be made to; [[UX-02]]'s ingress panel is where they
become visible.
Filed with UX-04. Card `6a9ec94617e9c48ed74ca5dd`. Every number below was
measured on `release/1.0` at `6e7b233` on 2026-09-07, against
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` — the version
`ha info` reports on the live install — and against the 26-file check suite in
`goodwe-addon-checks:1`. Nothing here contacted the live Supervisor at
192.168.2.158, posted options, or restarted anything.
---
## 1. Hiding: withdrawn, and why that is the *safe* answer
### 1.1 What AC 3 claimed
> "The visible count drops to ~15, with the rest behind HA's *show unused
> optional configuration options* toggle."
### 1.2 What the tab actually renders
`config.yaml` declares **71 keys under `options:` and the same 71 under
`schema:`**. The set that HA's toggle is *able* to hide — schema-only keys — is
therefore **empty**:
```
hideable = set(schema) - set(options) -> [] (0 of 71)
```
On the deployed 0.7.0 tree the same measurement is 70/70. Nothing was hidden
before UX-01, nothing is hidden after it, and no line of `fef6ef2` moved a key
either way. AC 3 is not partially met; it is unstarted.
### 1.3 The rule that decides it, replayed rather than paraphrased
From the frontend bundle shipped **in this image**,
`api/panel/frontend_latest/5680.*.js` (frontend 20250925.1):
```js
_filteredSchema = (opts, schema) => schema.filter(e => e.name in opts || e.required)
```
`opts` is the add-on's options **as Supervisor serves them** — the operator's
stored blob after `options:` has been deep-merged into it. So a field is hidden
only when it is **both** absent from `options:` **and** not `required`. The
probe now runs that filter on this manifest rather than quoting it.
### 1.4 Reason one: the target was never reachable
Supervisor's own `UiOptions` marks `required` on **59** of the 71 fields; 12 are
optional. The filter above shows a required field unconditionally. So the
**floor** — what the tab renders with `options:` emptied *altogether*, which is
the most hiding that is even expressible — is 59 fields.
| `options:` block | fields rendered |
|---|---|
| as shipped (71 keys) | **71** |
| emptied entirely (0 keys) | **59** |
| AC 3's target | ~15 |
AC 3 asked for a number roughly four times below the floor. No arrangement of
`options:` reaches it. The criterion could not have been met by any
implementation; it was mis-specified, and the honest disposal of a
mis-specified criterion is withdrawal, not a partial gesture toward it.
### 1.5 Reason two: the 12 hides that *are* expressible cost the deep-merge
Moving a key out of `options:` is not free. `App.options` is
`deepmerge(config.yaml defaults, the stored user options)`, so **a key absent
from `options:` has no default for the Supervisor to merge**.
That is exactly the mechanism the 0.9.0 upgrade rests on. From `CHANGELOG.md`,
the pre-deploy check run against this release on 2026-09-07:
> The reference install's stored blob has **70 keys, not 71**.
> `delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running
> 0.7.0 has never stored it — and it is REQUIRED in the schema, no trailing
> `?`, which is the shape that becomes **exit 78**. It resolves to its
> `options:` default of **185** because the Supervisor deep-merges.
Hiding a key is done by taking it *out* of `options:` and marking it `?`. That
is, precisely, the edit that removes a key's merge default — the thing standing
between a 0.7.0 box and exit 78 on upgrade. It would be traded for at most 12
fewer rows on a form, on a site where the add-on is holding a real house's grid
exchange at zero.
### 1.6 Reason three: it would promote 27 unpinned literals to load-bearing
And the cost compounds with §2 of this document. `test_defaults_pin.py` part 4
ledgers **27 (module, key) pairs** in `app/` that still answer a missing option
with an in-code literal. Today those literals are unreachable on a Supervisor
install, because the deep-merge guarantees every `options:` key is present in
the served blob. Remove keys from `options:` to hide them and those literals
stop being dead code: they become **the actual source of the defaults on a live
install** — the CFG-03 defect the same card's AC 4 exists to police, made
load-bearing by the card's own AC 3.
### 1.7 What now grades this
`tools/supervisor_shape_probe.py` §F. The count was printed there from the day
it was written — *"the Configuration tab renders 71 fields"* — and the probe
exited 0 through UX-01's whole review while displaying the number that
contradicted the criterion it was evidence for. **A measurement that is
displayed but not asserted is how this passed review.** Three assertions
replace the bare print:
```
PASS every one of the 71 `schema:` keys is ALSO in `options:`, so schema-only
- the only set HA's toggle can hide - is EMPTY. Hideable: []
PASS ...so replaying the frontend's own _filteredSchema on the served options
hides NOTHING: 71 of 71 fields render
PASS ...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is
expressible - the tab still renders 59 required fields. THAT is the
floor, and AC 3 asked for ~15.
```
Every number in them is computed from the manifest and from Supervisor's own
`required` flag; none is typed. **The red direction was run**: deleting one key
(`price_feed_url`) from `options:` and nothing else turns the first two to FAIL
— `Hideable: ['price_feed_url']`, `70 of 71 fields render` — and the probe
exits 1. The counterfactual is isolated: one key varied, nothing else.
So if a future editor empties `options:` to shorten the form, this goes red and
somebody has to read §1.5 before merging.
---
## 2. The defaults pin: `test_defaults_pin.py`
### 2.1 What AC 4 asked for and what shipped
AC 4 has three clauses.
| clause | state before UX-04 |
|---|---|
| "every `schema:` key absent from `options:` has exactly one defaults-table entry" | **vacuous** — that set is empty (§1.2), so the clause is true of any tree, including one with no defaults at all |
| "the table changed → red" | **works**, six times over, once per mirror in its own suite |
| "no module carries its own literal fallback for a schema default — an AST check, not a grep" | **never built** |
The only literal scan in the repository before this card is
`test_validate.py:1025`: a `re.findall` restricted to eight `maintenance_*`
keys across three named files. It is a grep, it predates UX-01, and it was not
written for this.
### 2.2 What an "in-code literal fallback" is, structurally
A call in `app/` taking a string literal that names a `schema:` key, with a
constant in the next positional slot:
```python
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
```
Found by walking `ast.Call` nodes — so a multi-line call and a renamed `opts`
are found, and the same text inside a comment or docstring is not. Two
exclusions, both structural and both argued at the site:
* **`getattr(obj, "max_w", 0.0)`** reads an attribute off an object, not an
option off a blob. Excluded by callee name.
* **the empty string.** `opts.get("static_profile", "")` is Python's "not
stated", and all six such sites in `validate.py` normalise it immediately —
`str(opts.get(k, "") or "").strip()`. It cannot be a stale copy of a *number*,
which is the whole of the CFG-04 defect class. Excluded by value. **`0` is
not excluded**: `slew_w: 0` and `integrator_max_w: 0` are real shipped
defaults with real meanings.
### 2.3 What it grades, measured on `release/1.0` @ `6e7b233`
```
2. the mirrors, DISCOVERED from the tree rather than listed here
6 table(s): circulation.py:CIRC_OPTION_DEFAULTS, delivery.py:DELIVERY_OPTION_DEFAULTS,
maintenance.py:MAINT_OPTION_DEFAULTS, peakguard.py:PEAK_OPTION_DEFAULTS,
price.py:PRICE_OPTION_DEFAULTS, watchdog.py:SIGN_OPTION_DEFAULTS
30 mirrored key(s) of 71 in `schema:`
3. the AST scan
scanned 29 modules, 34 literal fallback site(s): 0 over mirrored keys,
34 over unmirrored ones
4. the CFG-03 ledger
27 (module, key) pair(s) carry a literal for a key no mirror answers for
```
Four properties, none of them vacuous:
1. **The mirrors are discovered, not listed.** Six suites already pin their own
table to `config.yaml` — and every one of those pins names its table by
hand, so a *seventh* table added tomorrow would be pinned by nobody and
nothing would say so. This walks `app/` for module-level `*_OPTION_DEFAULTS`
assignments and grades whatever it finds against `config.yaml`. That is the
property the six hand-written pins cannot have.
2. **No literal survives for a key a mirror already answers for.** 30 keys,
0 violations.
3. **The CFG-03 ledger is FROZEN, by (module, key) pair, not by count.** A
count goes green again the moment somebody deletes one site and adds
another. Line numbers are deliberately *not* in the key: a ledger that goes
red on an unrelated reformat is a ledger people learn to re-bless without
reading. The 27 pairs are the open CFG-03 backlog; they are **not** removed
here, because that is behaviour change across `main.py`, `p1.py`,
`inverter.py` and `validate.py` that no single card should make in passing.
The set can now only shrink.
4. **Every remaining literal is graded against `config.yaml`** — the half that
catches CFG-04's actual failure mode, two copies of one number and one moves.
### 2.4 One divergence found, and it is new
```
main.py target_grid_w: code 0 vs config.yaml -10
```
`main.py:290` reads `target_grid_w=float(opts.get("target_grid_w", 0))` while
`config.yaml` ships **-10**. A dict handed to `Controller` without the key holds
net exchange at 0 W instead of the shipped -10 W bias into import. **It cannot
reach an operator**: on a Supervisor install the deep-merge guarantees the key
is present, and the only paths that hand over a partial dict are
`sim/inject.py` and this repository's own fixtures. It is recorded in the check
as the single permitted divergence, with the reason, rather than corrected —
changing it alters the control loop's target and belongs to a card that grades
the loop. A *second* divergence goes red.
### 2.5 ⭐ The red direction, shown rather than claimed
A check that has never been shown failing is not a check — which is exactly
what clause one of this AC was. Two things follow from that:
**(a) The red direction runs on every invocation.** Part 5 feeds the scanner a
synthetic module containing a deliberate violation of each rule and *requires*
it to be flagged, in-process, through the same `literal_fallbacks()` the graded
parts call. A scanner that goes blind — a renamed helper, an `ast` change, a
`SCHEMA` that comes back empty — stops working here too, and the total site
count is additionally graded against a floor.
**(b) A literal was planted in the real tree and the check went red.** Planted
at `main.py:290`, `float(opts.get("maintenance_charge_w", 2000))` — a mirrored
key:
```
3. the AST scan: no module carries its own literal fallback for a MIRRORED schema default
scanned 29 modules, 35 literal fallback site(s): 1 over mirrored keys, 34 over unmirrored ones
ok the scanner is not blind: it found 35 sites (floor 20)
main.py:290 get('maintenance_charge_w', 2000)
FAIL no literal fallback survives for any of the 30 keys a mirror already
answers for - those sites must index the mirror
1 FAILED (exit 1)
```
And part 4's two directions, planted the same way with
`opts.get("p1_host", "x")` and `opts.get("slew_w", 999)`:
```
4. the CFG-03 ledger
28 (module, key) pair(s) carry a literal for a key no mirror answers for
FAIL no NEW module/key pair has started carrying a literal fallback [('main.py', 'p1_host')]
main.py p1_host: code 'x' vs config.yaml ''
main.py slew_w: code 999 vs config.yaml 0
main.py target_grid_w: code 0 vs config.yaml -10
FAIL every remaining literal still AGREES with config.yaml, except the 1
divergence(s) recorded above it
(exit 1)
```
The second of those is the CFG-04 defect class itself: a literal that has
drifted away from `config.yaml` while the file still says something else. Both
plants were removed; the tree is byte-identical to `6e7b233` apart from this
card's own files.
---
## 3. CFG-04: closed on `release/1.0`, by UX-02, not by UX-01
UX-01's AC 5 claimed the double-literal `max_w` hole. It did not close it.
`fef6ef2` touched `config.yaml`, `translations/`, `test_translations.py` and the
probe — **no defaults code at all** — and on the 0.7.0 tree the triple mutation
survives: 21 files, 0 RED.
**Confirmed on current `release/1.0` @ `6e7b233`, 2026-09-07.** The same triple
mutation, `2000 -> 2500` at the three descendants of 0.7.0's
`inverter.py:438` / `validate.py:383` / `validate.py:684`:
| 0.7.0 | `release/1.0` @ `6e7b233` |
|---|---|
| `inverter.py:438` `max_w=float(opts.get("max_w", 2000))` | `inverter.py:640` `raw = opts.get("max_w", 2000)` |
| `validate.py:383` `cap = _number(opts, "max_w", 2000.0)` | `validate.py:466`, same line |
| `validate.py:684` `max_w = _number(opts, "max_w", 2000.0)` | `validate.py:836`, same line |
Result — 26 files, **RED at `test_selfconfig.py`**, exit 1:
```
1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the
rail collapsing to the 2000 default under an explicit 400 W slew, so the box
cannot be emptied into a quietly halved rail
```
`git log -L 310,325:goodwe_controller/test_selfconfig.py` attributes that check
to **`bcee235`, "UX-02: pin both halves of the max_w fix, because neither was
graded", glenn schrooyen, 2026-09-06** — not to UX-01. The hole ships closed
with the pending 0.9.0 deploy. **The AC is met by UX-02.** No work was re-done
here; the mutation was run once, on a throwaway copy, to confirm it.
---
## 4. The 8 per-entry translations: they stay
### 4.1 The measurement, and it is negative
`translations/{en,nl}.yaml` carry a name and a description for all 8 per-entry
keys of the repeatable `inverters:` section. They are loaded by Supervisor's own
loader, they pass `SCHEMA_APP_TRANSLATIONS`, and `test_translations.py` grades
their coverage. **They do not reach the screen**, and this is not a defect in
them. `UiOptions` gives `inverters` `type: "schema"` with `multiple: True`, and
the frontend's `_convertSchemaElement` sends exactly that case to a selector
rather than to a nested `ha-form`:
```js
if ("schema" === t.type && !t.multiple) return {...type:"expandable"...} // not us
if ("schema" === t.type) { const o = {}; for (const a of t.schema)
o[a.name] = {required: a.required, selector: ...}; return {object:{multiple:t.multiple, fields:o}} }
```
and `ha-selector-object` labels those fields `fields[name].label || name`, while
`_convertSchemaElementToSelector` sets **neither `label` nor `translation_key`**.
So an operator sees raw `name`, `setpoint_entity`, `max_w`, `bms_soc_floor`. The
probe already asserts this, negatively and by name.
### 4.2 Decision: keep them. Four reasons.
1. **The Supervisor tab cannot be made to render them.** The gap is in
somebody else's converter, not in this repository. There is no change here
that fixes it; deleting the translations does not fix it either.
2. **[[UX-02]]'s ingress panel is where they become visible**, and it renders
the roster itself rather than handing it to `ha-selector-object`. The
`fields:` block is the shape Supervisor's own schema defines for exactly
this, and it is what a surface that wires `label` through consumes.
3. **They cost nothing.** No runtime path reads them; they are two YAML blocks
in files the Supervisor already parses, and the parse is graded green.
4. **Deleting them would remove the only thing that catches a per-entry key
added without a translation.** `test_translations.py` grades top-level and
per-entry coverage symmetrically; drop the per-entry half and SAFETY-32's
`bms_soc_floor` — a *required* per-entry key added after UX-01 — would have
joined the roster with no label and nothing to say so.
This is a written decision, not a change. The tree is unmodified in
`translations/`.
### 4.3 Not re-filed
`tools/supervisor_selfapi_probe.py` has been known red since OPT-09 — it
hardcodes `len(_sub) == 7` against 8 per-entry keys. That is **TOOL-04**,
already filed, and it is not re-filed or fixed here. It is named only so a
reader of §4.1 knows why the *other* probe is the one quoted.
---
## 5. What this card did not do
* It did not remove a key from `options:`. See §1.5 — the deep-merge default is
what makes the pending 0.9.0 upgrade safe on a 0.7.0 box.
* It did not pay down the 27-pair CFG-03 ledger. See §2.3.
* It did not correct `main.py`'s `target_grid_w` fallback. See §2.4.
* It did not touch `translations/`. See §4.2.
* It did not re-do CFG-04. See §3.
* It did not touch hardware, the live Home Assistant at 192.168.2.158, or
`sim/`. Both probe runs were throwaway containers over a read-only mount.
+399 -2
View File
@@ -110,7 +110,8 @@ so Home Assistant's *Configuration* tab renders human labels and a description
for every option instead of the raw option keys. What that does and does not buy
you is worth stating plainly, because it was **measured against the Supervisor
image this site runs** (`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`)
rather than assumed — `tools/supervisor_shape_probe.py` section F prints the
rather than assumed — `tools/supervisor_shape_probe.py` section F GRADES (it
used only to print) the
whole rendered list, in both languages.
| | |
@@ -136,6 +137,7 @@ either direction, and the check is mutation-tested both ways.
| `soc_entity` | yes | Battery state of charge — use the **ESP32's own read** |
| `batt_entity` | yes | Battery power — again the ESP32's read, `+` = discharging |
| `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
@@ -161,11 +163,13 @@ inverters:
soc_entity: sensor.goodwe_a_soc
batt_entity: sensor.goodwe_a_battery_power
max_w: 5000
capacity_kwh: 15.36
- name: inverter-b
setpoint_entity: number.goodwe_b_setpoint_w
soc_entity: sensor.goodwe_b_soc
batt_entity: sensor.goodwe_b_battery_power
max_w: 4600
capacity_kwh: 20.48
```
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
@@ -229,6 +233,54 @@ in the log rather than silent, but it is an outage until someone puts the
single-inverter entity back in that field. **Before downgrading, write down the
`setpoint_entity` of the unit you want to keep running.**
#### How the site target is divided between the packs (OPT-09)
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
version before it the split is a fixed 50/50 regardless of what either pack can
do** — that is the defect this section describes the fix for, so if the add-on
you are running reports 0.7.0, none of what follows is happening yet. From 0.8.0
the split is driven by two separate things, and it is worth knowing which is
which, because only one of them is a rule:
* **The envelope — a permission.** A pack whose own state of charge is at or
under its own `bms_soc_floor` is **not asked to discharge**; a pack at 100 %
is **not asked to charge**. Its share goes to the sibling, inside that
sibling's own rating. This is absolute: the allocator cannot exceed it.
* **The weights — a preference.** Between those limits, each pack is asked for
the fraction of the site target that matches its **energy headroom**: the kWh
it has above its own cut-out when the site is discharging, the kWh it has
below full when the site is charging. That is what `capacity_kwh` is for.
⚠️ **`capacity_kwh` is optional, and it is all-or-nothing.** Leave it out
everywhere and the add-on has no opinion about the split — the target is divided
evenly, exactly as it was before. Fill it in on **every** entry or on none: a
pack measured in kWh beside one that is not is not a comparison, and the add-on
refuses to start rather than ignore half of what you typed.
⚠️ **Why energy and not the rating.** The two packs here pull in opposite
directions — A is 5000 W with 15.36 kWh, B is 4600 W with 20.48 kWh, so B has
about a third more energy and about 8 % less power. Dividing by rating hands the
work to A; dividing by energy hands it to B. The question a split is answering is
"which pack can sustain its share for the hour", so it is the energy one. Each
inverter's own rating still binds its own share, separately, through `max_w`.
⚠️ **What the weights will NOT do for you, stated plainly.** They are a comfort
choice about which pack does the work. They are **not** a way to make the site
deliver. A unit that is at its energy limit — or that has simply stopped
responding — does not hand its load to its sibling: the loop sees the shortfall,
freezes, and the site runs short by that unit's share. Weighting a failing unit
down does not fix that, because when the healthy unit reaches its own rating the
allocator hands the remainder to the down-weighted one anyway rather than leave
watts unplaced. What you get instead is honesty: the action log reports the watts
that could not be placed (`ALLOCATOR: +2000 W of the site target could not be
placed on any inverter`), and a unit that accepts its share and does not produce
it is named by the delivery alarm (SAFETY-42, above).
⚠️ **At one inverter none of this applies and nothing changed.** There is no
split to weight, `capacity_kwh` is not read, and no envelope is closed —
self-consumption still discharges down to the battery's own cut-out exactly as
it always has.
### What appears in Home Assistant at two inverters
**Nothing you already have changes.** The eight entities this add-on has always
@@ -1308,7 +1360,7 @@ does, and only after you have read why.
| `price_taxes_eur_kwh` | Levies and VAT, EUR/kWh. Same rule |
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
| `price_csv_path` | Where `manual_csv` reads its file |
| `price_feed_url` | Override the plugin's default feed. Only a host the plugin itself declares is accepted |
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
| `price_horizon_h` | How far ahead to ask for, hours |
| `price_timeout_s` | Per-request timeout |
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
@@ -1343,6 +1395,50 @@ flagged as high-volatility, which widens arbitrage thresholds once the
optimiser lands. That comes from the plugin's own declaration, not from its
name.
**`generic_dayahead` and the shape of the public feed.** It reads Fraunhofer
ISE's Energy-Charts API (`https://api.energy-charts.info/price?bzn=BE`, no key
and no registration), which publishes a *columnar* document — parallel
`unix_seconds` and `price` arrays, in EUR/MWh, with the unit and the licence
stated in the response. Two things follow that are worth knowing before you
point `price_feed_url` somewhere else:
* The timestamps are **UTC instants**. They are rendered at the Brussels offset
that applies to each one, so the hour that happens twice every October is two
points rather than one overwritten one.
* Belgium moved to a **15-minute market time unit** on 2025-10-01, and the feed
followed. A published day is **96 points, not 24**. Nothing in the add-on
assumes either spacing, and nothing aggregates the quarters away.
* The request **names the days it wants**, derived from the horizon on the
**Brussels** calendar — `?bzn=BE&start=2026-09-09&end=2026-09-10`, two
inclusive local dates. Without that the feed answers a single day, and
everything past today would be carried forward from yesterday's profile and
flagged provisional — which §8.3 excludes from arbitrage, so tomorrow would
never be tradeable at any hour. The calendar is Brussels rather than UTC
because 22:30 UTC in summer is already tomorrow in Belgium.
**What you get before the auction publishes.** The day-ahead auction clears at
about 13:00–14:00 CET. Ask for tomorrow before that and the feed returns fewer
days than requested — that is normal, not a failure. The add-on keeps the
series of what exists and fills the remaining hours from yesterday's profile,
flagged provisional; arbitrage runs on the firm hours and ignores the flagged
ones. So a dynamic site's plan is part firm and part carried every morning and
fully firm once tomorrow is published, and neither state is an error.
⚠️ **If you set `price_feed_url`, you keep the short horizon.** The override is
sent exactly as you wrote it and the add-on adds no `start`/`end` to it, because
those are this feed's parameter names and an override may point somewhere with
different ones — rewriting a URL you typed is how a request nobody intended gets
made. The consequence is a **`WARNING` in the log on every price refresh**,
naming the range that would otherwise have been requested. If you only wanted a
different host and not a different horizon, the fix is to clear the option.
If the arrays ever come back different lengths, or the unit stops saying
EUR/MWh, the fetch is refused outright rather than trusted in part: a price
series missing its last hours is a series missing the evening peak, and it
would be traded on as though it were complete. A refused fetch is the §11.4
row — static profile, provisional plan, no arbitrage — and the reason is in the
log.
**`manual_csv` — the offline fallback.** It needs no internet, no supplier
account and no HTTP stack at all, and it is the supported way to run this
add-on price-aware on a site with no outbound connectivity. Write a file and
@@ -1372,6 +1468,85 @@ distinct state from "prices, all of them zero": the plan is marked provisional
and arbitrage is disabled, per the degradation matrix. It never falls back to a
price of zero, which would read as free electricity.
#### The hours the feed has not published yet (FCST-04)
A day-ahead auction publishes tomorrow's prices once a day, at **14–16 h CET**.
Before that, the feed is serving today and nothing else — so a `price_horizon_h`
of 48 asks for 48 hours and gets back the ten or fifteen that are left in the
day. Until FCST-04 those other hours were simply **absent** from the plan: not
priced badly, not flagged, just missing, and nothing said so.
Now the add-on keeps the **published prices of the last few days** in
`/data/price_history.json` and fills an unpublished hour of the horizon with
yesterday's price for the same Brussels wall-clock hour, flagged
`provisional: true`. Two things follow, and the second one matters more:
- **The plan says it is guessing** — `provisional: true` on every carried hour,
which is what the optimiser reads. Before, a half-length plan looked
completely settled.
- **Nothing may trade on a carried hour.** §8.3 forbids acting on a provisional
price, so arbitrage drops those hours from the trade set entirely — it needs a
buy hour and a sell hour that were *both* published. Carrying a price forward
widens what the optimiser can **see**; it never widens what it may **do**.
**"An unpublished hour", not "every unpublished hour"** — the fill can only
carry an hour yesterday actually published. If yesterday's record is itself
partial (a first day, an outage, a feed that only ever serves the rest of the
day), the local hours it never held are **left out of the series**: §11.4
applies to those hours, and asking the plan for one raises rather than
answering. An hour with no price is not a free hour, and the fill does not
invent a neighbour, a mean or a zero for it.
**A quarter-hourly feed keeps all four of its prints.** `eneco_eddy` settles
per quarter, and the fill decides *which whole hours need carrying* — never what
a published hour may contain. Every point the provider published inside the
horizon is passed through untouched; a carried point is added only for a whole
hour the feed did not print at all.
**What you can actually see on the status page.** *Price history* (below) says
what is held. *Arbitrage* carries the strategy's own sentence, which names the
firm/provisional split whenever that split is what refused a trade. *Tariff*
does **not** report the fill: it is the static-profile or Home Assistant
price-entity sentence, regenerated on the 30 s tick. The provider plan's own
line — `dynamic_dayahead from generic_dayahead, 48 h, provisional` — goes to the
**add-on log** at every refresh, alongside `§8.3: 33 of 48 hours carried forward
from yesterday's profile and flagged provisional; 15 published`.
The plan is also now cut to the horizon you asked for, which means **hours that
have already elapsed are no longer in it**. That is a real change to arbitrage:
a cheap night that has already gone used to sit in the series as the cheapest
hour of the day, and a cycle can never buy in an hour that is past — so the
trade was quietly never taken. The spread is now taken over hours still ahead.
A new status row, **Price history**, says which days are held, how many price
points each holds and how many whole hours those cover — `2026-01-13 (96 points
over 24 h)`. ⚠️ **Those are two different numbers and the row used to print one
of them under the other's name**: it counted points and labelled them `h`, so a
quarter-hourly feed read `96 h` for a 24-hour day. The figure that says whether
tomorrow morning's fill has a whole day to carry is the **hour** one. If it
reads `empty` or `unavailable`, nothing is carried, the horizon
is as short as the feed makes it, and every other guarantee is unchanged — a
cache that cannot answer is not allowed to be a controller that cannot regulate.
The store is rebuilt by the next successful fetch; on a brand-new install the
first fill is possible the day after the first refresh.
The same applies to a file that is *partly* broken: a day record this build
cannot read is dropped, the row says how many were dropped and why, and the rest
of the file still loads. And a store that **cannot persist records nothing** —
not even in memory. That covers a missing `/data`, and it also covers a `/data`
that is there and cannot be **written**: a full volume, a read-only remount. An
in-memory yesterday would answer for one run and vanish on the next restart,
which is a cache that forgets without saying so; the row naming the reason and
*no prices carried* are the honest pair.
⚠️ **What a failed write does not do is throw away what is already in the
file.** A volume that fills at 14:00 leaves the yesterday written at 13:30 still
answering, because that record survives a restart — the store stops *adding* to
it, nothing more. And it never stops the add-on: a write that cannot complete is
logged and reported on the status row, never raised. A cache that cannot answer
is not allowed to be a controller that cannot regulate, and that applies to the
volume being full exactly as it applies to the volume being absent.
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
often near zero, which by itself strongly favours self-consumption over export.
It is a single number rather than a curve because no supported supplier
@@ -1521,3 +1696,225 @@ If an MQTT broker is available the add-on publishes setpoint, grid power,
battery power, circulating power, state of charge, maintenance phase and
controller status by MQTT discovery. This is observability only — the controller works fine without a
broker, and MQTT problems can never affect control.
## Before you commission inverter B: `tools/rehearse_roster.py` (ARCH-02)
The day the `inverters:` roster is declared, two things change that are
**one-way on a live box and invisible until after the restart that applies
them**: the store migration renames the keys that hold the SAFETY-02 latch and
the maintenance history, and the entity ids Home Assistant composes change
shape. This tool runs the **real `Controller`** through that transition against
a **copy** of the state file and reports the difference.
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v <repo>:/w:ro \
goodwe-addon-checks:1 sh -c 'cd /w/goodwe_controller && \
python3 tools/rehearse_roster.py \
--state /w/state-copy.json \
--before /w/live-options.json \
--after /w/roster-options.json \
--live-entities /w/live-entities.txt \
--reverse --round-trip'
Exit `0` clean · `1` at least one finding · `2` could not run. ⚠️ **`2` is not a
pass.**
### The owner steps, because this session could not take them
⚠️ **The rehearsal is only as good as the state it is pointed at, and nothing in
this repository can fetch that.** SSH to the live box is blocked by policy and
that is correct — `/data/state.json` is the file the running add-on is writing.
So three things have to be exported by hand, onto the machine that runs the
check, **before** the roster is declared:
1. **The state file.** From the Supervisor host:
ha addons info local_goodwe_controller # confirm the slug first
docker cp addon_local_goodwe_controller:/data/state.json ./state-copy.json
or take it out of a full add-on backup, which is the same bytes. ⚠️ **Copy
it, never move it, and never write to the original.** The tool opens the path
it is given exactly once and does all of its writing on a scratch copy — but
the safe habit is to hand it a file the add-on has never heard of.
2. **The options blob**, as `--before`. It is not reachable through the Home
Assistant REST API and no entity carries it; export it from the Supervisor,
the same way `tools/predeploy_check.py` needs it. Write the roster you intend
to deploy into a second file and pass it as `--after`.
3. **The entity list**, as `--live-entities` — one entity id per line:
Developer tools -> Template:
{{ states | map(attribute='entity_id') | select('search','goodwe') | join('\n') }}
⚠️ **Omitting it is a REFUSAL (exit 2), not a quieter run.** The orphan check
is graded against the ids the install actually has, and without that file it
is not graded against reality at all — so the tool will not run rather than
hand back a clean bill of health for a half of the transition it did not
check. If you genuinely have to run without it, pass `--no-live-entities`:
the run then carries a standing banner in the report header **and** on the
exit line saying the orphan check was not graded. The asymmetry used to be
the other way round — an unreadable file refused, a missing flag passed with
exit 0 and no disclosure at all — and the owner runs this once, at
commissioning, under time pressure.
The install carries roughly 49 `goodwe*` entities and this add-on announces
eight of them. The tool separates the ids it owns from the ids it does not,
rather than claiming orphans it cannot cause.
### The reference, which is loaded on every run
`--reference` defaults to `tools/rehearsal/announced-reference.txt` and is read
**whether or not** you pass an entity list. It records what the build is
supposed to announce, three columns per row:
<entity_id> <unique_id> <device identifier>
⚠️ **All three, and the second and third are the ones an entity_id-only list
cannot see.** The tool's own before/after diff is composed twice by the same
build, so a change to the composition moves both sides equally and the diff
reports nothing. Worse, two changes orphan an install *without moving a single
composed entity_id*: renaming `mqtt.BASE` (every `unique_id` changes) and
changing `DEVICE["identifiers"]` (HA sees a new device). Home Assistant keys its
entity registry on `unique_id` and its device registry on the identifier, so
either one creates a **new** entity beside the old — and because discovery is
published `retain=True` with no removal payload, the old config stays on the
broker and the old entity stays in the registry, still named by every automation
that referenced it, never updating again. Both were measured coming back
**exit 0** before the reference carried three columns.
`object_id` is deliberately **not** a column: `app/mqtt.py` records that HA
2026.8 does not honour it in a discovery payload, so renaming one is a true
no-op, and `test_rehearse.py` requires that mutation to **survive** — otherwise
"the reference notices any edit to `mqtt.py`" would be indistinguishable from
"the reference notices what HA keys on".
⚠️ **No result in this repository was produced against real state.** Everything
recorded here and in `test_rehearse.py` was measured on the synthetic fixtures
in `tools/rehearsal/`.
### What it confirms on the synthetic fixtures
⚠️ **This is a SECOND INSTRUMENT reaching a conclusion this repository already
holds, not a discovery.** `test_maintenance.py` section 13 — *"RECORDED, NOT
FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a
LATCH"* — already states it, already names `namespaced_latched`, and already
carries the sentence *"the guard is defeated by the very key the defect wrote"*.
That file is on `release/1.0` and this card does not touch it. What is new here
is that the finding is now reachable **from a state file the owner supplies**
rather than only from a fixture built in a test, and that it is reached by a
different route: a full `Controller` start diffed against its predecessor,
rather than assertions over a `MemStore`. Two instruments, one answer.
**The finding.** A pre-DEV-02 flat store that declares a multi-inverter roster
comes up with its SAFETY-02 latch **DISARMED**, unless the first roster entry
happens to be named `inverter-a`. `Controller.__init__` calls
`migrate_unit_keys(store, UNIT_A)` with a hardcoded constant, so the latch lands
in `unit/inverter-a/sign_tripped`; a roster of `["garage", "shed"]` reads
`unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()` —
the guard written for exactly this — sees the migration's own misdirected key
satisfy its `namespaced_latched` test and returns without re-arming. The file
says `sign_tripped: true`, the banner is green, and nothing is held at 0 W.
**Both routes reach it, and they are NOT the same defect.** The rehearsal
reports them as separate legs because they need separate fixes:
| leg | how it gets there |
|---|---|
| `SAME-STEP` | the roster is declared in the same step as the upgrade. The migration runs, and is handed `UNIT_A`. |
| `STAGED` | the flat config runs once under 0.7.0+ first. That start stamps `schema_version = 1`, so the roster start's migration takes its `from_version >= SCHEMA_VERSION` early return and **the latch never moves at all**. |
⚠️ **So "hand `migrate_unit_keys()` the roster's own first name" is NOT the
whole fix, and the rehearsal measures that rather than asserting it.**
`test_rehearse.py` part 5b builds exactly that change and reports:
LEG STAGED: DISARMED
LEG SAME-STEP: clean
The staged path never reaches the line that was changed. A fix that stops there
closes the half an operator is least likely to take and leaves the half that
"upgrade first, declare the roster later" produces. The guard side —
`namespaced_latched` counting a namespace that is not on the roster — is what
covers both, and a change there turns every SAFETY-44 check in part 5 red, which
is what a fix should do to a pin that specifies a defect.
The fix belongs to its own card, shown red first. Part 5 pins the current
behaviour as a requirement so it cannot drift green by accident.
### The reverse direction: the retreat is supported, the round trip is not
If commissioning B goes wrong, **backing out to the flat single-inverter config
is supported and does not corrupt state.** Measured on the fixtures:
* the SAFETY-02 latch and the first pack's maintenance history both survive,
because `store.py` rule 2 keeps the flat spellings **live** rather than merely
present — `maintenance.UnitHistory.mirror_flat` writes them on every change,
so a single-FSM add-on finds a current history rather than one dated the day
the roster was declared;
* **it orphans eight Home Assistant entities** — the four per-unit rows for each
of the two units. **Measured:** the add-on stops announcing them and stops
publishing their state, so their history gaps and any automation whose
trigger names them stops firing, silently and without an error.
⚠️ **What is NOT measured, and was previously overstated here:** whether
advancing again restores them. An earlier draft of this section said the
orphaning was "not recoverable by advancing again: HA keeps the id it composed
at first discovery" — that reasoning cuts the other way, since keeping the
composed id is precisely what would let a re-advertised entity land back on
it, and discovery is published `retain=True` with no removal payload. Nothing
in this repository has tested a re-advance against a real Home Assistant, so
the claim is withdrawn rather than restated in the other direction. Plan the
retreat around the measured part: **the eight rows stop updating for as long
as you are retreated.**
⚠️ **What is NOT supported is the round trip.** A maintenance cycle that
completes *while the install is rolled back* is **discarded** when it
re-advances: `maint_schema_version` is already stamped, so `migrate_maint_keys()`
takes its early return and the pack reads the namespaced history from before the
retreat. The pack is then recalibrated on a schedule that does not know it
already was. **So: retreat freely, but treat a retreat as ending the current
maintenance bookkeeping — and if a cycle ran during it, correct
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
decision, not a discovery made at the inverter.
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
Two ways a deploy has gone wrong on this system, both of which the Supervisor
reports as success:
**The silent no-op.** The Supervisor keys the built image by `version:` in
`config.yaml`. `ha apps update` against an unchanged version installs nothing
and says it worked. This happened twice: PKG-06 (six merges behind an unbumped
version) and PKG-07 (six again). Both times a human noticed. Nothing else would have.
**The refused config.** A config the shipped validator rejects is
`SystemExit(78)` — the add-on does not start. ⚠️ **And the ingress panel goes
with it**, because the add-on serving it is not running, so recovery means the
Supervisor UI. SAFETY-39's rule 10 made this reachable from a config that
started fine on the previous version: 0.7.0's own default `slew_w: 1000`
exceeds the 555.6 W ceiling on a 5000 W rail.
Run it inside the container, so the validator half actually runs:
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.7.0 --options /w/live-options.json
Export `live-options.json` from the Supervisor first — the options blob is not
reachable through the Home Assistant REST API, and no entity carries it.
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
add-on's dependencies the validator half skips and returns 2 rather than
pretending; `test_version_gate.py` pins that a skipped check can never exit 0,
because the first draft of that test reported four silent skips as passes.
⚠️ **Why this is not a CI check.** The obvious gate — "a pull request touching
runtime code must bump the version" — is the wrong shape here. This project
deliberately batches many merges into one deploy, so a per-PR bump would put
every branch in conflict over one line and still not prevent the failure, which
happens at deploy and not at merge. The check belongs where the mistake is made.
⚠️ This file's own change does **not** need a version bump, and the gate agrees:
`tools/` and `test_*.py` are not in its runtime set, because demanding a bump
for a typo fix trains people to bump without reading — the habit the check
exists to prevent.
+188
View File
@@ -127,6 +127,43 @@ Two rules come out of it, and both are cheap:
function, that pair is the first thing to try — TEST-16's two survivors were
adjacent lines in one function and neither was individually live.
⚠️ **AND FCST-05 WALKED INTO IT AGAIN, IN ITS OWN NEW CHECK, WHICH IS WHY THE
RULE IS “mutate every check you add”.** `price_history.py` reports a malformed
point row from **two** places into **one** field: `_usable_days()` records what
it FOUND when the file was opened, and `_remember()` records what it DROPPED on
the next refresh. Both land in `last_error`, and `words` prints `last_error`.
The first draft of the new check asked `"malformed point row" in words` — which
whichever rung is still standing satisfies — so deleting `_remember()`'s note
entirely was a **−1 B SURVIVOR of the whole suite**, on the half of the fix that
tells an operator the cache **self-healed**. It is graded on the TENSE now
(`… in <day>` at open, `… dropped from <day>` after the refresh), the two are
asserted to DIFFER, and the paired mutation — both notes deleted, −91 B — is
red on 15 named checks. Same shape as TEST-16 and the same fix: **grade the
reason, not the verdict**, and when two rungs answer one stimulus, mutate them
together.
⚠️ **AND FCST-06 FOUND THE COUNT ITSELF LYING — LOW, NOT SILENT.** A mutation
whose red is a **traceback** does not report one red; it reports however many
checks ran before the traceback, and grades **none** of the ones after it. The
new section drove M4 (`self._days = before` → `self._days = {}`) and the run
printed **1 FAIL**. The true figure is **3**: an f-string in the next check read
`len(read.points)`, and `.points` on a `no_data` **raises `Absence` by design**,
so the harness died mid-section with the remaining checks ungraded. That is
`why()`'s and `derived()`'s rule — *"it answered nothing" and "it broke on the
way to answering" must not grade the same* — arriving in the **printed operand**
of a check rather than in its condition, which is the half that is easy to miss
because it looks like formatting. Two things follow:
* **a mutation report's red COUNT is only a lower bound until the run is seen to
have reached the end of its section.** Read the output, not the count;
* **the total accessor has to cover what a check PRINTS, not only what it
asserts.** `test_price_history.py` 5d added `npts()` beside `why()` for
exactly this, and re-driving M4 against it moved 1 FAIL to 3.
Worse, had the traceback landed one check earlier the mutation would have
produced **no named red at all** — a result that reads like a broken harness and
gets retried rather than read.
## The third way a check lies: its DATA cannot reach the failure it names
Stale bytecode manufactures survivors from a mutation that never ran. Masking
@@ -225,6 +262,78 @@ same place twice, the check is decoration.
forever, *including after the call site is fixed*. The check was true and
passed for the wrong reason. Habit **9** below is what falls out of it.
* ⚠️ **ARCH-02's OWN REHEARSAL HARNESS, CAUGHT BY ITS OWN MUTATION BATTERY —
the THIRTEENTH instance, and the third in an INSTRUMENT rather than in a
check.** `tools/rehearse_roster.py` reports orphaned Home Assistant entities
by composing the ids the add-on announces **before** the roster switch and
**after** it, and diffing. Both sides are composed by the same build. So the
two mutations that change the id composition itself — renaming the MQTT device
(`"GoodWe"` -> `"GoodWe Controller"`), renaming one site sensor (`"Grid power"`
-> `"Grid power W"`) — moved **every id on both sides equally**, and the
harness reported *"no orphans"* about a change that orphans the entire live
entity list. It read exactly like a clean transition. The first draft's
M5/M6 came back `exit 0` and `exit 2`, and neither was the failure they name.
The fix is habit 2 with a filename: **the reference has to be data that does
not move with the code.** `--live-entities` takes a list of the ids the
install actually has, `tools/rehearsal/live-entities-recorded.txt` is the
recorded stand-in that keeps it live in CI, and a build that announces an id
the list does not carry is now a `COMPOSITION DRIFT` finding rather than a
silence. ⚠️ **The anchor moved for the same reason**: it had been
`compose(mqtt.DEVICE["name"], p1["name"]) == "sensor.goodwe_p1_sample_age"`,
which any renaming of the device satisfies by moving both operands, and it is
now composed from two recorded **literals**.
Ask of an instrument what habit 4 asks of a check: *where did each side of
this comparison come from* — and if a mutation of the subject moves both
sides, the instrument is decoration.
⚠️ **AND REVIEW FOUND TWO MORE IN THE SAME CARD, ONE OF THEM INSIDE THE FIX
FOR THE OTHER — the FOURTEENTH and FIFTEENTH.** Both are the pin, not the
tool, and both are the same sentence in a new costume: *a check whose data
cannot reach the failure it names.*
* **Fourteenth — a COUNT cannot tell one red leg from two.** The rehearsal
reports two independent routes to the disarmed latch (`SAME-STEP` and
`STAGED`) and prints every finding **twice per red leg**: once in that
leg's own block and once in the closing summary. The check named *"NOT THE
SAME-STEP PATH ALONE"* was written `out.count("LATCH CAME UP DISARMED")
>= 2`, so **one** red leg always satisfied it. Measured on three builds:
shipped code (both legs red) → 4; `migrate_unit_keys()` handed the roster
name (SAME-STEP fixed) → 2, **passes**; a guard-side fix (both fixed) → 0.
The check reported `ok` on the build whose only remaining failure *is* the
path it says is not the only one. The fix is to parse the legs and require
the finding in **each**, which is habit 2 again: grade the thing, not a
total over the thing.
* **Fifteenth — and it is INSIDE THAT FIX, which is the third time this file
has had to write that sentence.** The first leg-splitter opened on a
`LEG <id>` marker and never closed, so every line after the *last* marker
was attributed to that leg — including the closing summary, which repeats
every finding from every leg. The last leg therefore read red whenever
**any** leg was red, and the new per-leg check was the old count with extra
steps. It was caught by building the one fixture that separates them: a
tree in which `SAME-STEP` is genuinely clean and `STAGED` is not. The tool
now prints `END LEG <id>` and the splitter closes on it.
The rule that falls out, and it is the cheap one: **a per-thing check that
has never seen the things DISAGREE is a total.** Part 5b exists to make
them disagree on every run.
⚠️ **AND ONE THAT HAS ONLY SEEN THEM DISAGREE IN ONE ARRANGEMENT IS A TOTAL
OVER THAT ARRANGEMENT.** Review found this by attacking the fix itself. Part
5b does make the legs disagree, on every run - and was still blind to a build
that merely REORDERS them, because it had only ever seen the clean leg
printed LAST. Three edits, two of them ordinary refactors (reorder the report
legs, reword a summary line), produced `all checks passed` from a splitter
that closed ZERO legs.
A fixture cannot enumerate the arrangements, so the STRUCTURE is asserted
instead: a leg may not open while one is open, and every leg that opens must
be explicitly closed. That fires on the FIRST edit of all three attacks,
whatever the order or the wording. **When a fixture cannot enumerate the
arrangements, assert the invariant they share.**
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
reaching outside the suite. The probe is the committed evidence that
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
@@ -586,6 +695,84 @@ Cheap habits fall out of it, and all of them are now used in this repository:
that two of it cannot silently overwrite each other's work. A fixed path
in a tool that edits source is a shared mutable global with a filename.
19. ⚠️ **A FIXTURE IN A SHAPE NOBODY PUBLISHES GRADES THE PARSER AGAINST THE
PARSER — FOURTEENTH INSTANCE OF HABIT 4, AND THE FIRST WHERE THE FIXTURE
AND THE CODE UNDER TEST SHARED A *SHAPE* RATHER THAN A NUMBER.** TARIFF-08.
`price.py`'s `_points_from_json()` read `doc["data"]` as a list of
`{start, price_eur_mwh}` objects. `api.energy-charts.info` publishes
parallel `unix_seconds` / `price` arrays and has no `data` key at all, so
`generic_dayahead` raised `SourceUnavailable` on **every live fetch it ever
made** — and the suite was green, because all three price suites built
their bodies with `json.dumps({"data": [...]})`. The fixture was derived
from the parser's guess, so no mutation of the parser and no amount of
coverage inside that loop could ever have reached the failure. §11.4 then
turned it into a *correct* silent degradation: parse error -> `no_data` ->
static profile -> arbitrage off, with nothing naming the cause.
Habit 2 with a network on the end of it: **the reference has to be data the
code under test did not shape.** For a parser that means BYTES, captured
from the source and committed unmodified, with the URL, the retrieval time
and a checksum beside them - `scenarios/energy-charts-be-price-*.json` and
its `-captures.json` provenance, re-derived by the replay so an edited
fixture stops being a capture. Two granularities, because the same endpoint
answers hourly before 2025-10-01 and quarter-hourly after it and a fixture
at one spacing cannot grade an assumption about the other.
⚠️ **AND THE SURVIVING SYNTHETIC FIXTURES ARE NAMED FOR WHAT THEY ARE.**
Three shipped plugins point at endpoints nobody has verified (TARIFF-07),
so no capture exists for them; their fixture builder is called
`synthetic_row_feed` at every call site, because the next reader's default
assumption will otherwise be that a green check means the feed parses.
⚠️ **ONE OF THIS CARD'S OWN NEW LINES WAS DECORATION AND THE BATTERY SAID
SO.** The columnar parser first rendered its instants with `brussels()`
before returning them. `assemble()` already renders every plugin's points,
so removing the parser's call changed nothing observable - **M4 came back a
survivor on a `:ro`, purged tree**, which is not the direction stale
bytecode fabricates. The line was removed rather than fenced with a check:
a second copy of a rule that already has an owner is indistinguishable from
a no-op, and the mutation that proves the rendering is real belongs on
`assemble()`, where it is now aimed.
20. ⚠️ **A FIXTURE GRADES THE RESPONSE. NOTHING ABOUT THE REQUEST IS REACHABLE
FROM ONE, AND THE TRANSPORT HAD BEEN RECORDING IT ALL ALONG.** TARIFF-09,
and it is habit 19's closed loop with the other half still shut.
`generic_dayahead.fetch()` took `since` and `until` and asked for the bare
`?bzn=BE`, which answers ONE Brussels day - so §8.3 dropped every hour past
today from the trade set as provisional and the optimiser never saw
tomorrow as firm, at any hour of any day. Measured at 09:00: **15 firm
hours and 33 carried of 48**, both ends of the permitted cycle inside one
calendar day; at 23:00, **1 and 47**, with the firm spread no longer
clearing the round-trip cost.
TARIFF-08 had just fixed the RESPONSE side of this same plugin by
committing real bytes. It could not have caught this: a captured body is
the answer to a URL, and **no property of any body says which URL was
asked for**. `canned()` in `test_price.py` had recorded `(url, timeout_s)`
on every call since TARIFF-02 and not one check had ever asserted on the
first element - so the harness *knew* the plugin was ignoring its arguments
and nothing asked it. The fix is one line of habit: **where a plugin takes
arguments that shape a request, assert on the REQUEST off the injected
transport, not only on what comes back.** Section 2c does, and the
shipped-defect mutation (restore the bare URL, `-173 B`) is red on six
named checks in `test_price.py` and two in `test_price_history.py`.
⚠️ **AND A MUTATION THAT TURNS A READ INTO `no_data` CRASHES A SUITE
INSTEAD OF NAMING A CHECK, UNLESS THE CHECK EXPECTS IT.** `_NoData.points`
raises `Absence` by design, so `derived(...) < derived(...)` over two
absent reads is a `TypeError` and `[p for p in plan.read.points]` is a
traceback - habit 5's shape with an exception in place of a hang. The
mutation that matters here is the "defensive" one somebody writes after
reading this card: *refuse when the feed returned fewer days than we asked
for*. It is exactly what a healthy feed does every morning before the
~13:00-14:00 CET auction, and switching arbitrage off for it would be a
worse defect than the one being fixed. It is red BY NAME in
`test_price.py` (33 checks, the AC's own line among them) and takes
`test_price_history.py` down at **FCST-04's section 1**, whose `_before`
read is unguarded - a red, but an unnamed one. That is recorded rather than
retrofitted: guarding another card's section is a change to what its
checks report, and it belongs to whoever next touches it.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
@@ -643,6 +830,7 @@ commit and PR bodies.
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
| **MAINT-05 round 3**, `migrate_maint_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **was a survivor — now CLOSED** | measured against head `65cddf3` under the purge, tree mounted `:ro`, `19/19 PASS`. It stamps the schema marker on a migration that LOST state, defeating the older `lost` guard as well as the new `misdirected` one, because every check that reached the marker did so on a store whose `kept` list was empty. Closed by `test_maintenance.py` **9d**; the mutation is now red on three named checks. |
| **MAINT-05 round 3**, `migrate_unit_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **found here — SAFETY-02's half, now CLOSED** | the SAME mutation one function up, on the LATCH migration rather than the history one. Measured a survivor against this branch under the purge, `:ro`, `19/19 PASS`, then closed by `test_inverter.py` part 10's new fixture — a store with `unit/inverter-a/sign_reason` already present (so `kept` is non-empty) whose `sign_tripped: true` write is dropped. Shipped: marker withheld. Mutant: marker stamped on a **disarmed latch**, and the next start skips the migration that would have re-armed it. |
| **TARIFF-09** 11 mutations over `app/price.py` and the two-day capture, all red, no survivors | **not at risk** | direction, and method: every mutation was applied to a FRESH `cp -a` of the clean worktree rather than reverted, so there is no revert to verify (habit 18) and no shared scratch path two runs could collide on. Each ran through `tools/mutation_run.sh`, which purges and fingerprints. Byte deltas recorded on the PR; one is **+0 B** - a single price digit edited inside the committed capture, the risky delta - and the re-derived sha256 catches it. |
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
**Nothing here says a past result is wrong.** The two survivors re-run above
+8
View File
@@ -394,6 +394,14 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
`.pinned_by` and `.contradiction` so that this module does not
import the arbiter and cannot grow a dependency on claims.
`weights` unit -> non-negative comfort weight. Missing units weigh 1.0.
⚠️ A MAPPING, ALWAYS - never a function. OPT-09 lets `resolve()`
accept a callable of the site target, because an energy-headroom
weighting is direction-dependent and the direction is stage 2's
answer; `resolve()` calls it and hands the RESULT here. Keeping
that out of this module is what keeps `plan()` a pure function of
numbers and intervals: a callback invoked inside the fill would
put a caller's code, and whatever it reads, back into the module
whose whole discipline is that it reads nothing.
`live` the units whose master has reported recently enough to be
commanded, or None for "all of them". A unit not listed is
DROPPED and the record says so; the survivors then carry the
+46 -1
View File
@@ -205,6 +205,27 @@ CLAIM_SCOPE = {
# At N=1 the two are the same comparison and this claim is the one filed,
# exactly as it shipped.
"failsafe": SCOPE_UNIT,
# main.py: `Claim.limit("pack", P_SAFETY, lo, hi, ...)`, OPT-09. THIS PACK'S
# OWN STATE OF CHARGE against THIS PACK'S OWN `bms_soc_floor`, filed only
# where it BINDS and only at a roster of two or more. It says "this battery
# cannot do that", which is a fact about one cupboard in the same way
# `device`'s rating is a fact about one box, so it is scoped like `device`
# and filed at the same priority.
#
# ⚠️ AND IT IS NOT SAFETY-25's DEFERRED LOOP-SIDE RESERVE, WHICH LOOKS
# EXACTLY LIKE IT AND IS THE REASON test_peakguard.py PART 10f EXISTS. That
# one was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
# `loop_soc_floor` option - an EMS preference, chosen by an operator,
# stopping the grid-following loop at a depth somebody picked. This is the
# pack's own BMS cut-out, which is measured off register 0x0560 and required
# of every roster entry since SAFETY-32; no option is added, `peak_soc_floor`
# is deliberately NOT consulted, and the SITE target is not touched - only
# which unit carries it. Part 10f's own note names the revisit trigger:
# "under ALLOC-01 the reserve becomes a PER-INVERTER INTERVAL AT THE
# ALLOCATOR rather than a ninth claim here", and stage 1 is how an interval
# reaches the allocator, so a unit-scoped claim is the shape that sentence
# asks for rather than an evasion of it.
"pack": SCOPE_UNIT,
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
@@ -544,7 +565,10 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
wear - and cannot destabilise the site whatever it says. `live` is the set
wear - and cannot destabilise the site whatever it says. ⚠️ OPT-09 LET IT BE
A CALLABLE as well as a mapping: called with the resolved site target once
stage 2 has run, because an energy-headroom weighting is direction-dependent
and the direction is stage 2's answer. See the call site below. `live` is the set
of units whose master has reported recently enough to be commanded; a unit
left out of it is DROPPED from the envelope set and the record says so,
because the survivor carrying a dead unit's share silently is the failure
@@ -600,6 +624,27 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
# left as a log line nothing grades.
out.scope_errors = scope_violations(claims)
out.unit_bounds = bounds
# OPT-09. THE WEIGHTS MAY ARRIVE AS A FUNCTION OF THE SITE TARGET, and this
# is the one line that makes SoC-aware weighting expressible at all.
#
# ⚠️ THE REASON IS THAT THE DIRECTION IS NOT KNOWN ANY EARLIER. A weighting
# by ENERGY HEADROOM is direction-dependent - room above the floor when the
# site is discharging, room below full when it is charging - and the two
# give opposite answers on the same roster. The site target is chosen by
# stage 2, three lines up, so a caller computing weights before `resolve()`
# would have to guess which way the site is about to go, and would be wrong
# in one of the two directions on every tick.
#
# ⚠️ IT IS CALLED ONCE, HERE, BETWEEN STAGE 2 AND STAGE 3, AND NEVER FROM
# INSIDE THE ALLOCATOR. `allocator.plan()` goes on taking a plain mapping,
# which is what keeps it a pure function of numbers and intervals: a
# callback invoked inside the fill would put a caller's code - and whatever
# it reads - back into the module whose whole discipline is that it reads
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
# own direction can differ from the target's sign under a pin, in which case
# the weighting is a worse preference and still cannot be unsafe).
if callable(weights):
weights = weights(out.target_w)
out.shares, out.allocated, out.allocation = allocate_record(
out.target_w, bounds, weights=weights, live=live,
scope_errors=out.scope_errors, site_priority=out.winner_priority,
+15
View File
@@ -173,6 +173,17 @@ IDLE_NO_SOC = (
"state of charge is not being read, and a battery whose fill nobody knows "
"cannot be told to empty or to fill")
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE
# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating
# and are now SIZED to the packs whose state of charge permits that direction, so
# a caller can hand this function a zero. A zero must not reach `claim()`: a
# `Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the loop,
# which is the failure the note on `claim()` is written against - it would
# silently disable self-consumption for the whole hour. So the sizing is checked
# where the decision is made rather than trusted to arrive positive.
IDLE_NO_SIZE = (
"no pack's state of charge permits this side of the trade, so it is sized "
"at 0 W - and a 0 W arbitrage claim is a park order, not a trade")
NEGATIVE_PRICE = (
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
"energy away and pay for the privilege. Arbitrage must not export at a "
@@ -372,6 +383,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
f"arbitrage may not spend (§10.1 priority 4)", **common)
if not abs(float(discharge_w)) > 0.0:
return _idle(IDLE_NO_SIZE, **common)
return ArbitrageDecision(
action=DISCHARGE, power_w=abs(float(discharge_w)),
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
@@ -388,6 +401,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
f"nowhere to put the cheap energy", **common)
if not abs(float(charge_w)) > 0.0:
return _idle(IDLE_NO_SIZE, **common)
return ArbitrageDecision(
action=CHARGE, power_w=-abs(float(charge_w)),
reason=(f"{CHARGE}: this is the cheapest firm hour at "
+208 -39
View File
@@ -264,36 +264,66 @@ DELIVERY_OPTION_DEFAULTS = {
# such family, so reading its numbers across would have understated this.
#
# Every run was executed a second time at a roster of ONE, on the same
# stimulus, as the control. Worst CONTINUOUS legitimate episode:
# stimulus, as the control.
#
# ⚠️ RE-RUN BY TEST-21 (2026-09-07) AND THE HEADLINE DID NOT REPRODUCE. The
# harness that produced the original table was never committed, which is
# PROC-01's shape; it is committed now, in the OTHER repository, as
# `admin/goodwe:sim/delivery_window.py` (it imports `plantsim` and
# `frame_check`, which that repo owns - restating them here would be CI-08's
# coupling, and CI-08 has broken three times). Re-derive it with:
#
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
# python3 -B delivery_window.py --selftest --campaign --blag
#
# Worst CONTINUOUS legitimate episode. Second block is the re-run:
#
# threshold reversals flapping weighted N=1 control (1890 runs)
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s } 2026-09-06
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s } harness
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s } NOT
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s } COMMITTED
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s } - see
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s } DECISIONS
#
# threshold reversals flapping weighted N=1 control (2640 runs)
# 100 W 120.0 s 0.0 s 165.0 s 0.0 s } TEST-21
# 250 W 65.0 s 0.0 s 100.0 s 0.0 s } 2026-09-07
# * 500 W 30.0 s 0.0 s 35.0 s 0.0 s } committed
# 700 W 25.0 s 0.0 s 30.0 s 0.0 s } harness,
# 1000 W 15.0 s 0.0 s 25.0 s 0.0 s } re-runnable
# 1500 W 0.0 s 0.0 s 20.0 s 0.0 s }
# 2000 W 0.0 s 0.0 s 0.0 s 0.0 s }
#
# * is the shipped threshold, `Tuning.saturation_w`.
#
# ⚠️ THREE THINGS IN THAT TABLE ARE WORTH MORE THAN THE ROW THEY SIT ON.
# (a) The N=1 column is ZERO EVERYWHERE, in all 1890 runs at every
# threshold. The compatibility promise is measured, not argued.
# (b) The FLAPPING column is zero everywhere too - and that is the family
# that defeated SAFETY-30's site window (980 s at N=2, 1000 s at N=1, no
# window inside the billing quarter surviving it). It cannot touch this
# check, because a common stimulus makes BOTH units short at once and
# the subset stops being proper.
# (c) THE CURVE IS NOT MONOTONE - 55.0 s at 250 W against 40.0 s at 100 W -
# and that is a property of the reduction, not noise: a lower threshold
# puts MORE units into the short set, which can turn a proper subset
# into the whole roster and BREAK the run. Do not read it as a
# sensitivity curve.
# (a) The N=1 column is ZERO EVERYWHERE, at every threshold, IN BOTH
# CAMPAIGNS. The compatibility promise is measured, not argued, and it
# is the part that reproduced most cleanly.
# (b) The FLAPPING column is zero everywhere too, in both - and that is the
# family that defeated SAFETY-30's site window (980 s at N=2, 1000 s at
# N=1, no window inside the billing quarter surviving it). It cannot
# touch this check, because a common stimulus makes BOTH units short at
# once and the subset stops being proper.
# (c) THE ORIGINAL CURVE WAS NOT MONOTONE - 55.0 s at 250 W against 40.0 s
# at 100 W - and that was read as a property of the reduction: a lower
# threshold puts MORE units into the short set, which can turn a proper
# subset into the whole roster and BREAK the run. ⚠️ THE RE-RUN'S CURVE
# IS MONOTONE. The mechanism is real and is still the reason not to read
# either table as a sensitivity curve, but it is no longer something
# these numbers demonstrate - it is an argument now, not a measurement.
#
# THE BOUNDS THE VALUE ANSWERS TO:
#
# LOWER, from the rig - 30.0 s, the worst continuous legitimate episode at
# the shipped threshold across all 2640 runs.
# LOWER, from the rig - 35.0 s (TEST-21; the original record said 30.0 s
# and it did not reproduce), the worst continuous legitimate episode at
# the shipped threshold across all 2640 runs. ⚠️ THIS IS NOW STRICTLY THE
# BINDING LOWER BOUND: at 30.0 s it coincided with the firmware's veto
# below and a reader could not tell which was doing the work; at 35.0 s it
# is ABOVE it, and it is above the schema's own `int(30,900)` floor too -
# so an operator who sets `delivery_hold_s` to 30 is below a MEASURED
# legitimate episode. That is what "derived, do not change" is for.
#
# LOWER, from the firmware - `inverter.LINK_STALE_S`, 30 s. A unit not
# executing its share for less than the firmware's own veto window is a
@@ -302,26 +332,47 @@ DELIVERY_OPTION_DEFAULTS = {
# second, louder alarm about a state already being handled - the
# SAFETY-15/SAFETY-19 failure exactly.
#
# MARGIN, from INVERTER B NOT EXISTING, AND IT IS MEASURED RATHER THAN
# GUESSED. The campaign's B is A COPIED (`plantsim.INVERTER_B`,
# `measured=False`), so the 30.0 s above is two identical machines.
# Sweeping a DIFFERENT second unit through the weighted family: the worst
# legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 90.0 s at
# four times it. That is the sensitivity the margin has to carry until
# SPIKE-04 measures the real unit.
# MARGIN, from INVERTER B NOT EXISTING. ⚠️ THIS IS A SENSITIVITY STUDY ON
# A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, and the wording here used
# to say "MEASURED rather than GUESSED", which overstated it. B is not
# installed (~2026-09-12). The campaign's B is A COPIED
# (`plantsim.INVERTER_B`, `measured=False`), so the 35.0 s above is TWO
# IDENTICAL MACHINES and every row below is a what-if. NO ROW HERE IS B's
# LAG. Sweeping a DIFFERENT second unit through the weighted family
# (2026-09-06 -> TEST-21 re-run):
#
# B lag / ramp original re-run
# 10.0 s / 800 W/s 30.0 s 35.0 s (= A, both copies)
# 20.0 s / 800 W/s 45.0 s 45.0 s
# 10.0 s / 400 W/s 30.0 s 35.0 s
# 20.0 s / 400 W/s 45.0 s 50.0 s
# 40.0 s / 200 W/s 90.0 s 75.0 s (4x A's lag)
#
# The LAG moves it roughly linearly; the RAMP barely does. "The ramp does
# not move it AT ALL" was too strong - halving it is worth 0 s at A's lag
# and +5 s at twice it - so the ramp is second-order, not absent. That is
# the sensitivity the margin has to carry until SPIKE-04 measures the real
# unit.
#
# UPPER, from the bill - 900 s, the quarter that sets the capacity tariff.
# A site running short of one unit's share must be reported well inside
# one.
#
# 185 s: thirty-seven control cycles. 6.2x the worst legitimate episode this
# rig produces, 2.06x the worst a second unit FOUR TIMES slower than A
# produces, 6.2x the firmware's own veto window, and 4.9x inside the
# quarter. ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
# 185 s: thirty-seven control cycles. Against the TEST-21 re-run: 5.29x the
# worst legitimate episode this rig produces (35.0 s), 2.47x the worst a
# hypothetical second unit FOUR TIMES slower than A produces (75.0 s), 6.2x
# the firmware's own veto window (30 s), and 4.9x inside the quarter
# (900 s). ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
# is a different check on a different quantity, so a reader must not be
# able to think it was carried across - and not 150 or 200.
#
# ⚠️ 185 STANDS AND IS NOT CHANGED. The record claimed 6.2x the measured
# worst; the re-run makes it 5.29x. All four bounds still clear and none of
# them narrowly, and the ordering of the argument is unchanged. A shipped
# safety constant is not moved because a margin went from 6.2x to 5.3x while
# every bound holds - moving it would re-open SAFETY-41's four-attempt
# false-trip regime for nothing. What changed is the CLAIM, not the value.
#
# WHAT IT COSTS, stated rather than hidden: up to 185 s of a site running
# short by one unit's share before anybody is told. Against a fault that ran
# 6 h 25 m unnoticed, and against a guard that TAKES NO ACTION, this window
@@ -363,6 +414,94 @@ DELIVERY_OPTION_DEFAULTS = {
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
# ---------------------------------------------------------------------------
# SAFETY-43. THE SECOND WAY TO BE NAMED, AND WHY IT IS A FRACTION OF THE ASK.
#
# `min_w` above is an ABSOLUTE gap, and OPT-09 made that a hole. `weights()`
# shrinks a low-headroom unit's share, and shrinking the share shrinks the gap:
# a unit standing at +125 W and delivering 0 W is 100 % delinquent and 125 W
# short, so an absolute 500 W never names it. Measured end to end
# (`test_packshare.py` part 8b, real `compute()`, real `plan()`, real
# `delinquent()`, B wedged at a healthy 15 % against its own 10 % cut-out):
#
# load 1500 W, equal weights the wedged unit named on 199/200 cycles
# load 1500 W, OPT-09 weights named on 0/200 cycles
#
# ⚠️ AND THE SUPPRESSED REGIME IS THE QUIET ONE. At a load the healthy sibling
# covers, the meter is satisfied, `unmet_w` is 0 and no action-log line fires,
# so this alarm is the ONLY instrument that would have told the operator.
#
# ⚠️ THE FIX IS NOT A SMALLER `min_w`, AND THAT IS THE WHOLE SHAPE OF THIS
# CARD. `min_w` is `Tuning.saturation_w` and it exists to stop NORMAL
# SATURATION being reported as delinquency - the crying-wolf failure the whole
# module is written against, and the one SAFETY-41 spent four attempts on
# ("i'm getting really annoyed by the notifications in my HA"). Lowering it
# trades a missed alarm for a false one on every unit at once. A fraction of
# the unit's OWN SHARE is a SECOND way to be named that leaves the first
# exactly where it was: `gap > min_w OR gap > frac * share`. Nothing that was
# silent before this card becomes noisy unless the unit is delivering almost
# none of what it was asked for, which is not saturation-shaped at all.
#
# WHERE 0.85 COMES FROM - MEASURED ON THE SAME CAMPAIGN AS `delivery_hold_s`.
# `admin/goodwe:sim/delivery_window.py --campaign --fractions` grades this
# constant over the SAME 2640 no-fault runs (three families x 6 gains x 5 slews
# x the write staggers, every unit executing the share it was given) that
# produced the 35.0 s worst legitimate episode, at the shipped `min_w`, and
# reports what the relative arm ADDS. Worst continuous legitimate episode,
# worst of the three families:
#
# frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
# worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
#
# frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
# worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
#
# absolute arm alone: 35.0 s - SAFETY-42 §3.3's figure, re-measured in the
# SAME runs, which is what makes every row above a comparison and not a claim.
# N=1 control: 0.0 s at EVERY fraction, in all 2640 one-inverter runs.
# Flapping family: 0.0 s at EVERY fraction, in all 840 runs.
#
# ⚠️ READ THE CURVE, NOT THE ROW. THERE IS NO CLIFF ANYWHERE IN IT. From 0.45
# to 0.99 every figure is 40.0 or 45.0 s - within ONE CONTROL CYCLE (5.0 s) of
# every other - and below 0.45 it rises gradually to 90.0 s at 0.05. It is not
# monotone either (0.40 reports 60.0 s where 0.35 reports 55.0), which is
# §3.3's own mechanism (c) rather than noise in the plant: a more sensitive rule
# puts MORE units into the short set, which can turn a proper subset into the
# WHOLE roster and END the episode. So this constant is INSENSITIVE over the
# whole usable range, and that is the finding rather than a caveat on it. 0.85
# sits in the flat part, 0.40 above the shoulder at ~0.45 and 0.15 below 1.0.
#
# ⚠️ AND "ZERO FALSE NAMINGS" IS NOT AVAILABLE AT ANY FRACTION - said here
# because the card asked for exactly that. Every fraction adds naming CYCLES to
# the no-fault campaign; a relative arm that never fired on a legitimate
# transient would be one that never fired at all, because the modelled write
# stagger lands B's number up to four cycles late and a unit whose new command
# has not arrived IS delivering a small fraction of what is standing on its
# wire. What is zero at every fraction is false ALARMS: the longest legitimate
# episode anywhere in the 20-point sweep is 90.0 s - at 0.05, the most sensitive
# fraction measured and far below anything this card would ship - against a
# `delivery_hold_s` of 185 s, so NOTHING in 2640 no-fault runs comes within 95 s
# of notifying an operator at any fraction measured. At 0.85 the hold is 4.62x
# the worst legitimate episode where the absolute arm alone gave 5.29x. That is
# the price, in full.
#
# ⚠️ THE DIRECTION IS THE OPPOSITE OF `min_w`'s AND IT IS EASY TO GET BACKWARDS.
# A SMALLER fraction names MORE units - it is the more sensitive setting and the
# more dangerous one - so the campaign puts a LOWER bound on this number, not an
# upper one. It is bounded ABOVE by the card's own case: at 1.0 the arm can only
# fire on a unit that is IMPORTING while asked to discharge, which is
# SAFETY-30's fault and not this one, so a unit delivering NONE of its share
# (gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
# wedged box reporting a small standby figure rather than a clean zero.
#
# ⚠️ AND IT IS NOT AN OPTION, FOR `delivery_w`'s REASON ONE PARAGRAPH UP. An
# operator has no instrument to derive it with, a knob here is a knob on how
# loud this add-on is, and SAFETY-41 is the record of what that costs. It is a
# derived constant with a committed harness behind it, and the way to move it
# is to re-run the campaign.
DELIVERY_SHARE_FRACTION = 0.85
@dataclass(frozen=True)
class DeliveryConfig:
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
@@ -481,12 +620,30 @@ def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None:
return None
def delinquent(units, min_w: float) -> dict:
def delinquent(units, min_w: float,
share_frac: float = DELIVERY_SHARE_FRACTION) -> dict:
"""Which units are short with NO ACCOUNT, when the roster disagrees with them.
`units` one `UnitDelivery` per inverter, in roster order.
`min_w` the shortfall that counts - `control.Tuning.saturation_w`. See
`MIN_W_IS_SATURATION_W`.
`units` one `UnitDelivery` per inverter, in roster order.
`min_w` the ABSOLUTE shortfall that counts - `control.Tuning.
saturation_w`. See `MIN_W_IS_SATURATION_W`.
`share_frac` SAFETY-43. The RELATIVE shortfall that counts, as a fraction
of that unit's own share. See `DELIVERY_SHARE_FRACTION`; it
is a parameter rather than a read of the constant so the
campaign that derived it can sweep it over the shipped code.
⚠️ `None` MEANS THE ABSOLUTE ARM ALONE - SAFETY-42's rule,
exactly - and it exists for ONE caller: the campaign in
`admin/goodwe:sim/delivery_window.py`, which has to be able
to measure what this card ADDS against what it replaced, run
for run, over ONE tree. Nothing in the add-on passes it, and
`main.py` passes nothing at all.
⚠️ THE TWO ARMS ARE AN `or` AND THE ORDER OF THAT SENTENCE MATTERS. The
absolute arm is untouched by SAFETY-43: everything `min_w` named before it
is still named, and nothing `min_w` refused becomes noisy unless the unit
is delivering almost NONE of its ask. Lowering `min_w` would have been the
other change - the one that reports normal saturation on every unit at once
- and `DELIVERY_SHARE_FRACTION` says at length why it was refused.
Returns `{name: sentence}`, empty when there is nothing to say. The sentence
is the operator's first line and it carries the peer clause, because "B is
@@ -528,7 +685,13 @@ def delinquent(units, min_w: float) -> dict:
gaps = {}
for u in asked:
gap = short_by(u.share_w, u.measured_w)
if gap is not None and gap > float(min_w):
if gap is None:
continue
# SAFETY-43: absolute OR relative. `share_w` is > 0 for every member
# of `asked`, so the second comparison is always well formed.
if gap > float(min_w) or (share_frac is not None
and gap > float(share_frac)
* float(u.share_w)):
gaps[u.name] = gap
unaccounted = {u.name: gaps[u.name] for u in asked
@@ -544,7 +707,13 @@ def delinquent(units, min_w: float) -> dict:
out[u.name] = (
f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W "
f"and delivering {float(u.measured_w):+.0f} W - "
f"{unaccounted[u.name]:.0f} W of its share is not arriving - while "
f"{unaccounted[u.name]:.0f} W of its share "
# SAFETY-43. The PROPORTION is in the sentence because the watts
# alone stopped being the whole story the moment a unit could be
# named on the relative arm: "125 W is not arriving" reads like a
# rounding error, and "125 W (100 % of its share)" is the fault.
f"({100.0 * unaccounted[u.name] / float(u.share_w):.0f} % of it) "
f"is not arriving - while "
f"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
f"that state. Nothing this add-on reads explains it")
return out
+55 -3
View File
@@ -192,8 +192,8 @@ class Inverter:
def __init__(self, name, *, setpoint_entity="", soc_entity="",
batt_entity="", batt_invert=False, max_w=2000.0,
bms_soc_floor=None, store=None, sign_cfg=None,
delivery_cfg=None):
bms_soc_floor=None, capacity_kwh=None, store=None,
sign_cfg=None, delivery_cfg=None):
self.name = str(name)
self.setpoint_entity = str(setpoint_entity or "")
self.soc_entity = str(soc_entity or "")
@@ -215,6 +215,20 @@ class Inverter:
# the config by name long before that, at startup.
self.bms_soc_floor = (None if bms_soc_floor is None
else float(bms_soc_floor))
# OPT-09. THIS PACK'S USABLE CAPACITY IN kWh, or None when the roster
# entry did not state one.
#
# ⚠️ OPTIONAL WHERE `bms_soc_floor` IS REQUIRED, AND THE ASYMMETRY IS
# THE POINT RATHER THAN AN INCONSISTENCY. A missing cut-out would have
# to be defaulted, and one default for two batteries protects the
# shallower pack with the deeper pack's number - SAFETY-32's whole card.
# A missing capacity has an honest answer that costs nothing: this pack
# states no energy headroom, so `packshare.weights()` has NO OPINION
# about the split and every unit weighs 1.0, which is exactly the
# behaviour that shipped. None, never 0.0: a zero-capacity pack is one
# that may never be used, and nobody decides that by omitting a key.
self.capacity_kwh = (None if capacity_kwh is None
else float(capacity_kwh))
# --- the device's own reported range, read from its own entity -------
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
@@ -482,7 +496,9 @@ def build_roster(opts: dict, store, sign_cfg=None,
it - see the options-block note on `inverters:`.
NON-EMPTY each entry describes one inverter and the flat keys describe
none of them. ⚠️ NOTHING IS INHERITED. An entry that omits
none of them. OPT-09's `capacity_kwh` is an entry key too, and an
OPTIONAL one - see `_entry_capacity_kwh()` and `Inverter.__init__`
on why that absence is answerable where a missing cut-out is not. ⚠️ NOTHING IS INHERITED. An entry that omits
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
because "this unit's number came from somewhere else" is the
defect DEV-02 spent a card removing and a fallback here would
@@ -519,6 +535,13 @@ def build_roster(opts: dict, store, sign_cfg=None,
# mirror exactly as validate.py rule 8 defaults it, so an install
# that never set the key reads the same 10 % in both places.
bms_soc_floor=_flat_bms_floor(opts),
# ⚠️ OPT-09 ADDS NO FLAT `capacity_kwh`, AND THE ABSENCE IS THE
# DECISION. Capacity is read for ONE purpose - weighting a split
# between two packs - and at the single-inverter shape there is no
# split: `allocator.plan()` is a pass-through at N<=1 and a weight
# is arithmetic nobody reads. A flat key would be an option an
# operator can set, that the add-on reads, and that changes nothing,
# which is the worst of the three.
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)]
roster = []
@@ -546,6 +569,11 @@ def build_roster(opts: dict, store, sign_cfg=None,
# SAFETY-32's own defect written in three lines - the shallower pack
# graded against the deeper pack's BMS.
bms_soc_floor=_entry_bms_floor(entry),
# OPT-09, and NOT inherited either, for the reason above it: a
# capacity borrowed from a sibling would weight a 15.36 kWh pack as
# though it were a 20.48 kWh one, which is the split this card
# exists to stop being wrong.
capacity_kwh=_entry_capacity_kwh(entry),
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg))
return roster
@@ -617,6 +645,30 @@ def _flat_max_w(opts: dict) -> float:
except (TypeError, ValueError):
return 0.0
def _entry_capacity_kwh(entry: dict):
"""One entry's OWN usable capacity in kWh, or None when it stated none.
None rather than 0.0 and rather than a borrowed number: `_entry_max_w()` can
answer 0.0 because a zero rating commands nothing and rule 9 refuses it by
name, but a zero CAPACITY is a pack with no energy headroom in either
direction, i.e. a pack the weighting would silently stop preferring. "Not
stated" and "empty" must not be the same answer - `packshare.weights()`
turns the first into "no opinion at all" and the second into a weight of
zero, and those are different splits.
A NON-POSITIVE or unparseable value also answers None. Rule 9 refuses it at
startup with a message; until it does, withholding is the direction that
cannot invent a preference.
"""
raw = entry.get("capacity_kwh")
if raw is None or raw == "":
return None
try:
val = float(raw)
except (TypeError, ValueError):
return None
return val if val > 0.0 else None
def _entry_max_w(entry: dict) -> float:
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
+404 -21
View File
@@ -65,8 +65,10 @@ from .maintenance import (
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
unit_state_key)
from . import peakguard
from . import packshare
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .store import migrate_maint_keys, migrate_unit_keys
@@ -145,9 +147,27 @@ def load_options() -> dict:
return {}
def pack_energy_of(inv):
"""OPT-09. ONE roster entry as the view `packshare` consumes.
⚠️ ONE BUILDER, TWO CALLERS, AND THAT IS THE POINT. `Controller.pack_energy`
needs the whole roster (for the weights and the trade sizing) and
`Controller.cycle()` needs one entry at a time (for that unit's `pack`
claim). Written out twice, the two would drift - and the direction they
would drift in is a unit whose envelope is computed from a different set of
numbers than its weight, which is precisely the class of defect DEV-02 spent
a card removing. It is a module function rather than a method on `Inverter`
so that `inverter.py` does not have to import `packshare`.
"""
return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor,
inv.capacity_kwh, inv.max_w)
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db"):
peak_db_path: str = "/data/tariff.db",
price_history_path: str = "/data/price_history.json"):
self.o = opts
self.hass = hass
self.store = store
@@ -229,7 +249,40 @@ class Controller:
hold_s=float(opts.get("delivery_hold_s",
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
)
self.store_migration = migrate_unit_keys(store, UNIT_A)
# SAFETY-44. THE NAMES THE MACHINES READ, HANDED TO THE MIGRATION THAT
# WRITES FOR THEM. This line used to read `migrate_unit_keys(store,
# UNIT_A)`, and the constant was wrong in exactly the way the
# `maint_migration` call below says the SAME constant would be wrong -
# ⚠️ this comment used to cite "line 315" and "two hundred lines
# below", and both were wrong. The correction I wrote for them was
# wrong too: it said "~54 lines down", which was review's distance to
# the WRONG line, carried to a new referent without re-measuring -
# inside the sentence apologising for a wrong number. There is no
# distance here now, because a distance is a coordinate and the
# sentence above already names the thing. On a declared roster the flat
# latch landed under
# `unit/inverter-a/`, which is on no roster, was read back under the
# name it was handed, and stamped the marker. `roster_names()` is the
# naming rule itself and returns `[UNIT_A]` on the flat shape, so the
# upgrade path every install runs today is byte-identical.
#
# ⚠️ AND `misdirected` IS UNREACHABLE FROM **THIS** CALL SITE, WHICH THE
# COMMIT MESSAGE ORIGINALLY CLAIMED AS A BENEFIT. `unit` is
# `roster_names(opts)[0]` and `build_roster()` names its entries from
# the same `roster_names()` list, so `unit` is a member of `roster` by
# construction and the refusal can never fire here. Review measured it:
# dropping the `roster` argument from the `_verify_moves()` call inside
# `migrate_unit_keys()` survives 28/28.
#
# THE ARGUMENT STAYS ANYWAY, and the reason is the one `_verify_moves()`
# already gives for keeping its own `roster=None` branch: it is what a
# NEW caller gets, and the grading it enables is measured at the
# function's own level in test_maintenance.py rather than through here.
# What is NOT true is that this call site buys the refusal, so that
# claim is gone rather than restated.
_roster_names = roster_names(opts)
self.store_migration = migrate_unit_keys(
store, _roster_names[0], _roster_names)
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
@@ -531,6 +584,42 @@ class Controller:
self.peak.restore(self.p1.averager)
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
# FCST-04. The §8.3 carry-forward store: the published prices of the
# last few Brussels days, so the hours a day-ahead feed has not printed
# yet can be filled from yesterday's profile and FLAGGED, instead of
# being absent from the series entirely.
#
# ⚠️ IT IS AN INSTRUMENT'S BACKING FILE, NOT A STRATEGY. It commands
# nothing and claims nothing; what it changes is how many hours the
# plan covers. Before this, a dynamic site between midnight and the
# 14-16 h CET publication had a series covering only the rest of today
# - typically 10-24 of the 48 hours `price_horizon_h` asked for - and
# `TariffPlan.arbitrage()` took its spread over that truncated window
# without anything reporting that it had. Every carried hour is
# provisional, so refusal 3 drops it from the trade set: CARRYING a
# price widens what the optimiser can SEE, never what it may act on.
#
# ⚠️ AND THE CAVEAT THAT BELONGS WITH THAT SENTENCE, because this is
# the one place a maintainer reads it on its own. THE OTHER HALF OF
# THIS CHANGE DOES MOVE ENERGY, and it is the windowing rather than the
# carrying: the filled series covers `[since, until)`, so hours that
# have ALREADY ELAPSED are no longer in the spread. A cheap night that
# has gone used to sit in the series as the day's cheapest hour, and no
# cycle can buy in an hour that is past - so the trade was quietly
# never taken. `resolve_tariff()`'s docstring, DOCS.md and the CHANGELOG
# all carry this beside the guarantee; it may not appear here without it.
#
# ⚠️ A BROKEN OR ABSENT STORE IS NOT AN ERROR HERE. It answers `no_data`,
# the fill carries nothing, and the plan is the pre-FCST-04 one minus
# the elapsed hours - short, but honest and still refusing on the same
# rules. It must not be an error in the other sense either: a day
# record this build cannot read is DROPPED by `PriceHistory._load()`
# and reported in `.words`, because both the constructor above and the
# `.words` read below are unguarded, and a cache that raises here is an
# add-on that does not start.
self.price_history = PriceHistory(price_history_path)
_LOG.info("§8.3 price history: %s", self.price_history.words)
# SAFETY-07. The guard TARIFF-01 was built for. It is stateless by
# design (peakguard.py holds no history between calls), so the only
# state here is what an EDGE needs: what the last cycle concluded, so
@@ -628,6 +717,12 @@ class Controller:
# edge-triggered. Starts as the empty tuple - nothing has been dropped
# yet and nothing has been said about it.
self._dropped_last = ()
# OPT-09. The last cycle's `unmet_w`, so the line that reports it is
# edge-triggered for `_dropped_last`'s reason: a site that cannot place
# every watt stays that way for as long as the pack is empty, and a line
# a second would flush the 40-row action log of the context explaining
# it. 0 is "every watt was placed", which is the state at boot.
self._unmet_last = 0
# ⚠️ THE SITE MEASUREMENT, AND THE ONLY ONE LEFT ON THIS OBJECT. `soc`
# and `batt` were per-device scalars and now live on each `Inverter`;
# `grid` is P1, one meter, one connection, and belongs here.
@@ -727,23 +822,132 @@ class Controller:
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
return
# ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET", AND IT IS ASKED OF
# THE STORE RATHER THAN OF THE ROSTER. At a roster of two a trip on B
# leaves A untripped, so re-arming A off a flat key that B's own trip
# mirrored would latch a healthy inverter on every restart - the
# flat-key defect this card removed, coming back through its own guard.
# And the STORE is the right place to ask: it holds every
# `unit/*/sign_tripped` this install has ever written, including one
# belonging to a unit that is not in TODAY's roster (a roster is
# configuration and it changes), whereas the roster only knows what is
# configured right now. The flat key carries new information only when
# nothing namespaced is latched at all.
namespaced_latched = any(
bool(v) for k, v in data.items()
if k.startswith("unit/") and k.endswith("/sign_tripped"))
if namespaced_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
# ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET **THAT A MACHINE
# READS**", AND THE LAST CLAUSE IS SAFETY-44's SECOND HALF. At a roster
# of two a trip on B leaves A untripped, so re-arming A off a flat key
# that B's own trip mirrored would latch a healthy inverter on every
# restart - the flat-key defect DEV-02 removed, coming back through its
# own guard. That much stands, and it is why the question is asked of
# the STORE's keys rather than of the roster's objects.
#
# ⚠️ BUT IT USED TO ACCEPT **ANY** `unit/*/sign_tripped`, and the
# paragraph here defended that: the store "holds every one this install
# has ever written, including one belonging to a unit that is not in
# TODAY's roster ... whereas the roster only knows what is configured
# right now". THE PREMISE IS TRUE AND IT ARGUES THE WRONG WAY. A latch
# under a name no unit answers to is not protection an operator has - it
# is a key nothing reads, which is the definition of the state this
# method exists to repair. Counting it as protection made the guard
# return early on precisely the install that needed it, and that is not
# hypothetical: it is where the migration above USED to put the latch
# on every declared roster, so the guard was blinded by its own upgrade.
#
# THE MIGRATION FIX DOES NOT REACH THOSE INSTALLS. A store stamped by
# the shipped version already has the marker, so `migrate_unit_keys()`
# returns at the version check and never runs again. This guard is the
# ONLY repair path for a site that upgraded before this card, and it
# re-arms onto `inverters[0]` - which lands the latch back under a name
# the roster reads.
# ⚠️ AND "OFF THE ROSTER" IS NOT ENOUGH ON ITS OWN - IT HAS TO BE THE
# MIGRATION'S OWN FINGERPRINT, WHICH IS `UNIT_A`. The first draft of
# this treated EVERY off-roster latch as litter, and test_inverter.py's
# DEV-02 restart case caught it: there the store carries
# `unit/inverter-b/sign_tripped = True` from B's own trip while the
# roster being graded holds only A, and the blunt rule re-armed a
# HEALTHY A off the flat key that B's trip had mirrored - DEV-02's
# defect rebuilt inside its own guard, which is exactly what the
# paragraph above was right to be afraid of.
#
# `UNIT_A` is the discriminator because it is the ONLY name the broken
# migration ever wrote. `roster_names()` falls back to a POSITIONAL
# `inverter-N`, so `inverter-a` is not a name this code hands to anyone
# else. A latch stranded under any OTHER name is a real unit that is
# out of today's config - a roster is configuration and it changes -
# and it still counts as protection.
#
# ⚠️ THE RESIDUAL, STATED RATHER THAN LEFT TO BE FOUND: a site whose
# own first unit is NAMED `inverter-a`, latched, and then taken out of
# the roster gets re-armed on `inverters[0]`. That is a healthy unit
# held at 0 W until an operator clears it, which is the conservative
# direction; SAFETY-44's is a site DRIVING on a state file that says it
# is latched.
#
# ⚠️ AND THE SECOND HALF OF THAT RESIDUAL IS THE **SENTENCE**, NOT THE
# BOOLEAN. The reason re-armed from is the FLAT mirror, which carries
# whatever tripped LAST - so on a multi-unit site this path can print
# one inverter's evidence under another inverter's name. The latch is
# right and the explanation may not be. It is disclosed here because the
# banner is what an operator acts on, and `test_maintenance.py`'s check
# that the reason is not the removed unit's is guaranteed by that
# fixture's choice of flat string, NOT by this code.
# ⚠️ AND THE THREE CASES ARE THREE VARIABLES, NOT ONE SHARED FLAG.
# THIS IS WHERE THE FIRST VERSION OF THIS FIX WAS DEFEATED, BY ONE KEY.
# It wrote `namespaced_latched = True` for BOTH "on the roster" and "off
# the roster but not UNIT_A", so a single latched key belonging to a
# unit the operator had removed from `inverters:` vetoed the repair of
# the misdirected `inverter-a` latch sitting beside it. Measured on the
# shipped build, roster ['garage', 'shed'], store stamped so the
# migration cannot reach it:
#
# sign_tripped + unit/inverter-a/sign_tripped
# -> [('garage', True), ('shed', False)] repaired
# ...AND unit/old-shed/sign_tripped
# -> [('garage', False), ('shed', False)] ⚠️ DRIVING
#
# A site DRIVING on a state file that reads `sign_tripped: true`, which
# is the sentence this whole card exists to make false - and
# `tools/rehearse_roster.py` said so, on the unmodified tree, exit 1.
#
# ⚠️ IT IS ALSO INSTANCE #17 OF HABIT 4, IN THE EXTENSION MUTATION.md
# ADDED TWO DAYS AGO. The two fixtures written for this card each hold
# ONE arrangement - `_staged_store` has only `inverter-a`, `_other_store`
# has only `inverter-b` - and the flag is written twice with opposite
# intents only when BOTH are present. A per-key check whose keys have
# never disagreed WITHIN ONE STORE is a check over whichever key came
# last. The fixture below now holds both.
#
# THE THREE CASES, AND WHY THE ANSWER DIFFERS FOR EACH:
# on the roster a machine reads it: real protection, stop.
# off-roster, `UNIT_A` the broken migration's fingerprint - this
# IS the flat latch, misdirected. Repair.
# off-roster, other name a real unit out of today's config. Its
# latch is unread too, but it is NOT the flat
# key's misdirection, and re-arming a healthy
# sibling off it is DEV-02's defect. Stop -
# UNLESS a stranded `UNIT_A` latch is also
# present, because then the flat key IS
# accounted for and the repair is the point.
on_roster = {inv.name for inv in self.inverters}
stranded, on_roster_latched, other_off_roster = [], False, False
for k, v in data.items():
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
continue
if not bool(v):
continue
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1 -
# split through the writer's own spelling rather than re-deriving.
unit = k.split("/")[1]
if unit in on_roster:
on_roster_latched = True
elif unit == UNIT_A:
stranded.append(k)
else:
other_off_roster = True
if on_roster_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
return
if other_off_roster and not stranded:
# A removed unit's own latch, and no misdirected one. The flat key
# is that unit's mirror, so it carries no new information and
# re-arming `inverters[0]` from it would latch a healthy pack.
return
if stranded:
_LOG.error(
"SAFETY-44: this store carries a LATCHED sign-disagreement "
"failsafe under %s, and that unit is NOT on today's roster "
"%s - so no machine reads it and the site came up DRIVING on "
"a state file that says it is latched. Re-arming below.",
", ".join(sorted(stranded)), sorted(on_roster))
first = self.inverters[0]
# The one state this method exists for.
reason = str(data.get("sign_reason", "") or "")
@@ -1015,6 +1219,55 @@ class Controller:
"""
return float(sum(inv.max_w for inv in self.inverters))
def trade_power_w(self, floor_pct, ceiling_pct) -> tuple:
"""OPT-09. `(charge_w, discharge_w)` - how hard a trade may be SIZED.
The site rating is what the site can push; this is what the site can
push AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` used to be
handed `site_max_w` on both sides, so on a roster of two the trade was
sized at the sum of the ratings whatever either pack's state of charge
was - the site would buy at 9600 W with only one pack able to absorb it,
and the watts that could not be placed would come back as `unmet_w` for
the site integrator to chase for the rest of the hour.
⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` /
`arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, because SIZING
A TRADE IS THE TRADE'S BUSINESS. This method answers "how much may this
strategy ask for"; the ENVELOPE - `packshare.envelope()`, filed as a
`pack` claim - answers "what may this pack be commanded at all", and
that one is the pack's own physical cut-out for the reason SAFETY-25
gives. Sizing with the physical floor would let the strategy ask for
watts its own reserve forbids; bounding the envelope with the trade's
reserve would let a trade preference stop the grid-following loop.
THEY ARE DIFFERENT QUESTIONS AND THEY GET DIFFERENT NUMBERS.
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack ends up
carrying the trade is the allocator's answer, and the only per-pack
instrument it has for a TRADE reserve is the weights - which are a
preference, not a permission (`allocator._fill()` will hand watts to a
zero-weighted unit rather than leave them unmet). So a pack between its
arbitrage floor and its BMS cut-out can still take part of a sell. That
is stated rather than fixed: fixing it needs a per-pack envelope that
depends on WHICH claim won the tick, which the arbiter's partition does
not express, and is a card of its own.
A pack whose SoC did not read is counted on NEITHER side. It is not
reachable with control running - `inputs_ok` stops the site when any
pack's SoC is missing - and withholding is the direction that cannot
over-size.
⚠️ NEITHER SIDE CAN BE ZERO ON A TICK THAT ACTUALLY TRADES, and it is
not left to that coincidence: `arbitrage.decide()` refuses a 0 W sizing
by name (`IDLE_NO_SIZE`), because a 0 W arbitrage claim is a park order
that outranks the loop.
"""
# ⚠️ THE REDUCTION ITSELF LIVES IN `packshare`, NOT HERE, and this
# method is the roster view it is applied to. A transcription of the
# arithmetic into a check is one copy plus a future disagreement about
# what it means, and the copy that drifts is always the grading one -
# so `test_packshare.py` part 9 calls the SAME function this does.
return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct)
@property
def site_rating_w(self) -> float:
"""The largest magnitude the SITE can actually be commanded, in W.
@@ -1132,6 +1385,44 @@ class Controller:
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
for inv in self.inverters]
@property
def pack_energy(self) -> list:
"""OPT-09. Each pack as the FOUR numbers a split needs, in roster order.
The fourth is `capacity_kwh`, and it is why this is not `packs` above.
That list answers the FLOOR question - may this pack be spent - and its
consumers (`pack_permissions`, `binding_soc_floor`) have no use for a
capacity and would have to default one. This list answers the SPLIT
question - which pack should carry more of the site target - and cannot
be asked without one.
⚠️ EVERY unit, live or not, for `packs`'s reason. The allocator drops
what `live_units()` does not list and `plan()` ignores a weight for a
unit it dropped, so filtering here would only move the same decision to
a worse place - and `packshare.weights()` needs to see a pack that did
not answer in order to withhold its opinion about the whole roster.
"""
return [pack_energy_of(inv) for inv in self.inverters]
def pack_weights(self, target_w):
"""OPT-09. The comfort weights for a site target of `target_w`.
⚠️ IT IS A METHOD HANDED TO `resolve()`, NOT A DICT COMPUTED BEFORE IT,
and that is not indirection for its own sake. An energy-headroom
weighting is direction-dependent - room above the floor when the site is
discharging, room below full when it is charging - and the two give
opposite answers on the same roster. The direction is the SIGN OF THE
SITE TARGET, which is stage 2's output, so nothing here can know it
until `resolve()` has run stage 2. `arbiter.resolve()` calls this once,
between stage 2 and stage 3, with the number it just chose.
⚠️ AND IT IS READ FRESH ON EVERY CALL, NEVER CACHED, for the reason the
allocator refuses to cache an envelope: a pack's SoC moves for reasons
that have nothing to do with the allocator, and a weighting held from
construction prefers a pack that has since emptied.
"""
return packshare.weights(self.pack_energy, target_w)
@property
def inputs_ok(self) -> bool:
"""The site meter, and EVERY pack's own two readings.
@@ -1930,6 +2221,39 @@ class Controller:
# setpoint entity (§11.3), not a bound on the grid connection.
claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w",
actuator=self.actuator_of("supervised", inv)))
# --- OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS -------------
# ⚠️ THE POINT OF THE CARD, AND IT IS AN ENVELOPE RATHER THAN A
# WEIGHT. Weighting an empty pack down does NOT stop it being
# commanded: `allocator._fill()` runs a deliberate last-resort pass
# over the zero-weight units when the preferred ones saturate, so
# that a preference can never manufacture a standing error at the
# meter. Only a closed envelope stops the ask, and `cap` is obeyed
# absolutely. So a pack at or under its own BMS cut-out gets
# `hi = 0` and a pack at 100 % gets `lo = 0`, and the site target
# goes to the sibling instead of being asked of a battery that
# cannot answer it.
#
# ⚠️ AT A ROSTER OF ONE THIS IS NEVER FILED, AND THAT IS THE SAME
# IDENTITY `sign_site` IS GUARDED BY RATHER THAN A SPECIAL CASE.
# `actuator_for()` routes every source to the SITE at N=1 - with one
# inverter the fabricated grid reading and the battery setpoint are
# the same number - so this interval would bound THE SITE TARGET
# there, not a share. That is a behaviour change on the live
# single-inverter install and it is the shape SAFETY-25 decided
# against on 2026-09-02 (Option A): ordinary self-consumption is not
# given an SoC floor by this card. There is also nothing for it to
# do at N=1 - no sibling to move the share to - so the whole of AC 4
# ("at N=1 allocation is byte-identical to today") is this `if`.
#
# WHAT IT COSTS, SAID PLAINLY: at N=1 nothing here stops the loop
# discharging into the BMS cut-out. That is unchanged, it is
# SAFETY-25's deferred Option B, and the BMS itself is what stops it.
if n_units > 1:
_pe = packshare.envelope(pack_energy_of(inv))
if _pe.binding:
claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi,
_pe.why,
actuator=self.actuator_of("pack", inv)))
# The SITE bound, for the site-level consumers below. It is the roster
# sum: what this site can deliver, not what one of its inverters can.
max_w = self.site_max_w
@@ -2202,6 +2526,7 @@ class Controller:
self.arb = None
if arbitrage.enabled(self.o):
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
self.arb = arbitrage.decide(
self.fresh_tariff_plan(now), now,
peak_guard_active=arbitrage.guard_state(self.peak_guard),
@@ -2214,7 +2539,22 @@ class Controller:
soc=self.pack_floor_soc,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
charge_w=max_w, discharge_w=max_w,
# OPT-09. PER-PACK SIZING. Not `max_w` - the site rating - on
# both sides any more: the trade is sized to the packs whose own
# state of charge permits that direction, so it can never ask
# for more than the roster can take. `trade_power_w()` carries
# the argument, including which of the two SoC bounds belongs
# here and which belongs on the envelope. ⚠️ AT N=1 BOTH SIDES
# ARE `max_w` EXACTLY, whenever the trade is permitted at all:
# the one pack that passed the gate is the whole roster.
# ⚠️ AND ON THE SELL SIDE THIS IS A NO-OP TODAY on any roster,
# because `soc=` below is the EMPTIEST pack, so a sell is
# refused outright whenever any pack is at or under the floor -
# i.e. whenever this sum could be smaller than the site rating.
# It becomes reachable the day that gate becomes per-pack;
# `test_packshare.py` part 9 grades it directly rather than
# letting an unreachable branch go ungraded.
charge_w=charge_w, discharge_w=discharge_w,
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
# SITE, and arbitrage.claim() says so on the object it builds. It
# buys and sells at the grid against a day-ahead price: the price is
@@ -2419,8 +2759,24 @@ class Controller:
# stale from this line onwards. Incremented BEFORE resolve() so that the
# record resolve() builds is stamped with the tick it is for.
self.alloc_tick += 1
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. ARCH-01 left the
# allocator one free parameter and ALLOC-01 built the machinery that
# consumes it (`allocator._fill`); this line is the first thing that has
# ever passed one. Without it every unit weighed 1.0 and the split was
# a fixed 50/50 regardless of what either pack could do.
#
# ⚠️ A METHOD, NOT A DICT. The weighting is by ENERGY HEADROOM and is
# therefore direction-dependent, and the direction is the sign of the
# site target - which stage 2 chooses inside `resolve()`. See
# `pack_weights()` and `packshare.weights()`; the arbiter calls it once,
# between stage 2 and stage 3.
#
# ⚠️ AND IT IS A PREFERENCE, NOT A PERMISSION. Nothing about the site's
# ability to DELIVER rests on this argument - a weight cannot stop a
# unit being commanded and cannot move a failing unit's load to its
# sibling. The `pack` claim filed above is the half that constrains.
resolution = resolve(claims, units=self.units, live=self.live_units(),
tick=self.alloc_tick)
weights=self.pack_weights, tick=self.alloc_tick)
self.allocation = resolution.allocation
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
@@ -2514,6 +2870,25 @@ class Controller:
if self._dropped_last and not _dropped:
self.log_event("ALLOCATOR: every inverter is back in the split")
self._dropped_last = _dropped
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
# `unmet_w` is the site target the envelopes could not absorb. It is
# NOT a broken allocation - `Allocation`'s own docstring says why
# fusing it with `ok` would let one saturated inverter park the whole
# site - but it IS the site failing to deliver what the loop asked
# for, and before this card nothing anywhere reported it. It is the
# state a closed envelope produces when BOTH packs are at their
# limits: the ask has nowhere left to go, and an operator watching a
# standing grid error needs the reason rather than the symptom.
# Edge-triggered on the VALUE, for `_dropped` reason.
_unmet = int(self.allocation.unmet_w if self.allocation else 0)
if _unmet != self._unmet_last:
if _unmet:
self.log_event(
f"ALLOCATOR: {_unmet:+d} W of the site target could not be "
f"placed on any inverter - {self.allocation.explain()}")
else:
self.log_event("ALLOCATOR: the whole site target fits again")
self._unmet_last = _unmet
if resolution.scope_errors:
# A claim reached the arbiter on an actuator its source's scope does
# not permit. It is a BUG in the filing site, not a state to survive:
@@ -3118,7 +3493,8 @@ class Controller:
now = datetime.now(timezone.utc)
try:
plan = await resolve_tariff(
self.o, now, now + timedelta(hours=horizon), now=now)
self.o, now, now + timedelta(hours=horizon), now=now,
history=self.price_history)
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)
except Exception as err: # noqa: BLE001
@@ -3967,6 +4343,13 @@ class Controller:
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
# between a 48-hour plan and a 12-hour one on a weekday morning.
# It is its own row rather than a clause of "Tariff" because it
# answers a different question: "Tariff" says what the plan IS, and
# this says what the plan could still be filled from if the feed is
# late again tomorrow.
("Price history", self.price_history.words),
# OPT-04. The strategy's own sentence, not a re-derivation of it -
# every refusal it can make already carries a reason a human can
# act on, and "Arbitrage: no" would throw all of them away.
+464
View File
@@ -0,0 +1,464 @@
"""OPT-09 - what each pack can still do, in the two forms stage 3 accepts.
ARCH-01 left the allocator ONE free parameter - `weights` - and ALLOC-01 built
the machinery that consumes it (`allocator._fill`). Nothing ever passed one.
`main.py` called `resolve()` without `weights=`, `arbiter.resolve()` defaulted
it to None and `allocator.plan()` turned that into 1.0 for every unit, so the
split was a fixed 50/50 REGARDLESS OF WHAT EITHER PACK COULD DO. This module is
what fills that argument, and it fills the other one too - the per-unit
interval - because on their own the weights cannot do the job. Read the two
sections below in order; the second is the important one.
⚠️ THE TWO ARE NOT INTERCHANGEABLE, AND THE ALLOCATOR SAYS SO FIRST
===================================================================
`allocator.py`'s docstring states the rule this module is built on:
WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
"please do not use this unit", and it is honoured while any other unit has
headroom - but it is NOT a constraint [...] To make a unit genuinely
unusable, close its envelope or leave it out of `live`.
That is mechanical, not advisory: `_fill()` runs a SECOND, last-resort pass over
the zero-weight units when the preferred ones fill up, deliberately, so that a
preference can never manufacture a standing error at the meter. So a pack at its
own cut-out that is only WEIGHTED to zero is still commanded to discharge the
moment its sibling saturates. Closing its `hi` is what actually stops the ask.
Hence this module produces two things and they answer two different questions:
envelope(pack) MAY this pack be asked, at all, in this direction?
-> a `hi`/`lo` that main.py files as a UNIT claim, and the
allocator obeys absolutely (`cap` is never overridden).
weights(packs,S) GIVEN that both may, which should carry more of it?
-> a comfort choice, in the sense ARCH-01 means: the gain
seen at the meter is a CONVEX COMBINATION of the
per-unit gains for ANY split, so this cannot destabilise
the site whatever it says.
⚠️ AND WHAT THE WEIGHTS CANNOT DO, BECAUSE THE CONVEX-COMBINATION ARGUMENT IS
EASY TO OVER-READ
============================================================================
ARCH-01 guarantees that a bad split cannot DESTABILISE the site. It says nothing
whatever about whether the site can REGULATE. A unit sitting at its energy limit
has g_i = 0 - it answers no watts to any command - and the convex combination
then contains a zero: `gbar = sum(a_i * g_i)` is still inside [min g_i, max g_i]
and the site is still stable, while the load it was asked to carry simply does
not move. `compute()` sees the resulting standing error, trips its saturation
freeze and holds the site target where it is, so the share of a unit that cannot
deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
⚠️ SO: WEIGHTS ARE A COMFORT CHOICE FOR STABILITY AND NOT FOR DELIVERY.
Weighting a failing unit down is not a remedy for it. The remedies this
add-on has are the ENVELOPE (here, when the reason is the pack's own state
of charge - a fact this controller reads) and `live_units()` (when the unit
cannot be commanded at all). SAFETY-42 measured what the third case costs:
with one unit wedged and equal weights, the site settles 700 W short and
never recovers, because the freeze pins the target and the sibling's share
never rises. That module DETECTS it; this one cannot fix it, and saying so
is worth more than a weighting scheme that looks like it might.
⚠️ AND IT WAS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROWED SAFETY-42's
DETECTION WINDOW - A DETECTION REGRESSION AT N=2, NOW CLOSED BY SAFETY-43
=======================================================================
This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said
with it - that a wedged unit's weight does not move, so OPT-09 cannot see the
case at all. THE ENVELOPE HALF IS TRUE: a wedged box reads a healthy SoC and
nothing here closes. THE WEIGHT HALF WAS FALSE. `weights()` is a function of a
pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight
moves whenever its headroom differs from its sibling's - the ordinary case this
module exists to exploit.
`delivery.delinquent()` named a unit only while its `short_by()` gap exceeded
`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's
share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
own 10 % cut-out, so `envelope()` does not close and `account()` explains
nothing): at a 1500 W load the healthy pack can carry alone, equal weights named
the wedged unit on 199 of 200 cycles and these weights named it on NONE. The
suppression window widened from `site target < 2 * min_w` = 1000 W to
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it had
an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass
hands the wedged unit a big share again and the absolute arm names it.
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE - KEPT HERE BECAUSE IT IS A MEASUREMENT, EVEN THOUGH SAFETY-43 HAS SINCE MADE THE WINDOW IRRELEVANT TO THE DETECTOR. Swept on this fixture with the ABSOLUTE arm alone, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returned BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm named the wedged unit 199/200 at every load swept, 1000 W included.
⚠️ THAT IS NO LONGER A RESIDUAL. SAFETY-43 made `delinquent()` relative to
the ask AS WELL AS absolute - `gap > min_w OR gap > frac * share`, and a
unit delivering 0 of 125 W is as delinquent as one delivering 0 of 1500 W.
On the SAME fixture, under the SAME weights, the wedged unit is now named
on 200 of 200 cycles. Part 8b grades both rules on one run:
`share_frac=None` is SAFETY-42's absolute arm alone and still says nothing,
which is what keeps the paragraph above honest rather than nostalgic.
⚠️ AND `min_w` WAS NOT LOWERED. The absolute arm's suppression window is
still exactly the arithmetic above - 1000 W at equal weights, 6500 W at
these - because a lower `saturation_w` would report NORMAL SATURATION as
delinquency on every unit at once, which is the false-trip regime SAFETY-41
spent four attempts in. What changed is that there is a SECOND way to be
named; nothing that was quiet before became noisy.
⚠️ WHAT THIS MODULE STILL CANNOT DO IS UNCHANGED. OPT-09 stops the site
ASKING a pack whose own SoC says it cannot deliver; it does not, and will
not, re-allocate a wedged unit's watts to its sibling - `delivery.py`'s
"IT REPORTS. IT DOES NOT ACT" block is the measurement behind that, and
SAFETY-43 did not touch it. The alarm is louder; the loop is identical.
WHY ENERGY AND NOT POWER
========================
The two packs at this site are asymmetric IN OPPOSITE DIRECTIONS. From the
datasheet and the serials (NOTES.md 1.1b): A is 5000 W / 15.36 kWh, B is 4600 W
/ 20.48 kWh - B has about 33 % more ENERGY and about 8 % less POWER. A split by
power rating hands the trade to A; a split by energy headroom hands it to B. The
question the split is answering is "which pack can sustain its share for the
hour", so it is the energy one, and the ratings are already applied - as the
`supervised` and `device` claims, per unit, which is where a POWER bound belongs.
⚠️ INVERTER B DOES NOT EXIST YET (expected ~2026-09-12) AND NOTHING HERE IS
MEASURED ON IT. `sim/plantsim.py`'s INVERTER_B is inverter A copied, with
`measured=False` on it. Every figure above is FROM DATASHEET AND SERIAL, and
every two-unit result this card reports is TWO COPIES OF A driven with different
numbers, which is a statement about the arithmetic and not about the hardware.
WHICH FLOOR, AND WHICH CEILING - THE TWO CHOICES THAT ARE POLICY IF YOU GET
THEM WRONG
==========================================================================
⚠️ THE FLOOR IS `bms_soc_floor` ALONE, NOT `peakguard.pack_floor_pct()`'s
conjunction with `peak_soc_floor`. This is the same choice `delivery.py`'s
`account()` had to make and it is made for the same reason, one direction
further out: `peak_soc_floor` bounds the PEAK GUARD ONLY (SAFETY-25), ordinary
self-consumption discharges straight past it, and closing a unit's envelope at
it would silently turn an EMS preference into a hard stop on the grid-following
loop - which is the memo's DEFERRED Option B, decided against on 2026-09-02, and
not this card's to build. `bms_soc_floor` is a property of the battery in the
cupboard: below it the pack's own BMS stops, so a discharge command sent there
is a command that cannot be executed by anything.
⚠️ AND THE CEILING IS 100 %, WHICH IS COARSE, AND THE COARSENESS IS THE HONEST
ANSWER RATHER THAN A GAP TO BE PAPERED OVER. There is no per-pack charge ceiling
anywhere in this add-on - `delivery.py` says so at length and calls it a card,
and it is why SAFETY-42 is armed on discharge shares only. The numbers that
exist are `arbitrage_soc_ceiling` (one site-wide number, and a TRADE policy) and
nothing else. Filing a trade policy as a P_SAFETY envelope on the loop would be
exactly the mistake the paragraph above refuses, in the charge direction. So the
envelope closes only where the ask is physically impossible - a pack reporting
100 % being told to take more - and everything between is left to the WEIGHTS,
which is the right instrument for a graded preference and is honest about being
only a preference.
CAPACITY IS OPTIONAL AND "UNKNOWN" MEANS "NO OPINION"
=====================================================
`capacity_kwh` is a new, OPTIONAL per-entry key. Optional, where SAFETY-32 made
`bms_soc_floor` required, because the two absences mean different things: a
missing cut-out would have to be DEFAULTED and a default protects the shallower
pack with the deeper pack's number, whereas a missing capacity can be answered
with "then this module has no opinion about the split" - which is exactly
today's behaviour, 1.0 for every unit. `weights()` therefore answers None unless
EVERY pack in the roster states both a capacity and a state of charge: a
half-known roster would weight the packs that answered against a sibling
weighing 1.0, which is a comparison between a kWh and a dimensionless one.
"""
from dataclasses import dataclass
from math import inf, isfinite
from .peakguard import discharge_permitted
# The only charge ceiling that is a property of a PACK rather than of a policy.
# See the module docstring: `arbitrage_soc_ceiling` is a trade preference and
# must not become a P_SAFETY bound on the grid-following loop.
FULL_PCT = 100.0
# The claim source name main.py files these intervals under.
#
# ⚠️ MAIN.PY STILL WRITES THE STRING LITERALLY AND MUST GO ON DOING SO, so this
# constant is a NAME FOR THE CHECKS rather than something to import at the
# filing site. test_peakguard.py part 10f reads `Claim.limit(...)`'s FIRST
# POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set, and part
# 10g reads `actuator_of('<literal>')` the same way; both go blind on a name
# they would have to resolve. So the two spellings are tied by a check instead -
# `CLAIM_SCOPE[packshare.SOURCE] == SCOPE_UNIT` in test_packshare.py part 10 -
# which is the one direction that cannot make the scanners vacuous.
SOURCE = "pack"
@dataclass(frozen=True)
class PackEnergy:
"""ONE pack's four numbers for the two questions this module answers.
`name` the roster name. An envelope that does not say WHICH pack it
closed sends an operator to the wrong cupboard - the defect
`peakguard.Pack` and CFG-04's `unit_caps_indexed()` were
both written for.
`soc` THIS pack's measured state of charge, in percent. None when
its sensor did not answer, and ⚠️ None IS NOT A FULL
BATTERY - `discharge_permitted()` says why at length.
`bms_floor` THIS pack's OWN `bms_soc_floor`, the percent at which its
BMS cuts out. Required of every roster entry since
SAFETY-32, so at a declared roster this is never None.
`capacity_kwh` THIS pack's usable capacity. None when the entry did not
state one, which means "no opinion" and never 0 - a zero
capacity is a pack that may never be used, which is a
decision nobody made by omitting a key.
`max_w` THIS unit's supervised rating. ⚠️ IT IS NOT AN INPUT TO THE
SPLIT AND MUST NOT BECOME ONE - the whole of deliverable 2
is that the split is by ENERGY and the rating is applied
separately, per unit, as the `supervised` and `device`
claims. It is here for `trade_power_w()` alone, which
answers a different question: how many watts the packs that
are ALLOWED to take a trade can take between them.
⚠️ IT IS A SEPARATE TYPE FROM `peakguard.Pack` RATHER THAN AN EXTENSION OF
IT, AND THAT IS DELIBERATE. `Pack` carries the three numbers the FLOOR
question needs and is consumed by `pack_permissions()` and
`binding_soc_floor()`, whose answers are site verdicts about spending a pack
for money. This one carries a fourth number that is meaningless to those
reductions and would have to be defaulted into them. Two small types with
one purpose each beat one type with a field half its callers must ignore.
"""
name: str
soc: float | None
bms_floor: float | None
capacity_kwh: float | None
max_w: float = 0.0
@dataclass(frozen=True)
class PackEnvelope:
"""The interval OPT-09 asks for on one pack, and the sentence that says why.
`(-inf, +inf)` is "no opinion" and is the common answer; `binding` is how a
caller asks whether there is anything to file. A claim filed for every pack
on every cycle regardless would put an open limit into the arbiter's
considered list on every tick, which costs nothing arithmetically and buries
the one cycle where it means something.
"""
lo: float = -inf
hi: float = inf
why: str = ""
@property
def binding(self) -> bool:
return self.lo != -inf or self.hi != inf
def usable_kwh(pack) -> float | None:
"""Energy this pack can still DISCHARGE before its own BMS cuts out.
None when it cannot be computed - no capacity, no reading, or no floor - and
never 0.0 for those, because "no opinion" and "nothing left" are the two
answers a weighting must not confuse: the first weighs 1.0 and the second
weighs nothing at all.
Clamped at 0.0 from below. A pack measured BELOW its own cut-out is at
-0.4 kWh of usable energy by arithmetic and at zero in fact.
"""
if (pack.capacity_kwh is None or pack.soc is None
or pack.bms_floor is None):
return None
return max(0.0, float(pack.capacity_kwh)
* (float(pack.soc) - float(pack.bms_floor)) / 100.0)
def spare_kwh(pack) -> float | None:
"""Energy this pack can still ACCEPT before it is full.
`FULL_PCT`, not `arbitrage_soc_ceiling`: the module docstring carries the
argument, and it is the same one that keeps `peak_soc_floor` out of
`usable_kwh()`. A pack's own fullness is a fact; a trade's reserve is not.
⚠️ AND IT IS A WEIGHTING INPUT, NOT A PERMISSION. It says nothing about the
constant-voltage taper, in which a pack legitimately accepts far less than
it is told for tens of minutes - there is no configured ceiling in this
add-on that could describe that, which is `delivery.py`'s stated reason for
being armed on discharge shares only.
"""
if pack.capacity_kwh is None or pack.soc is None:
return None
return max(0.0, float(pack.capacity_kwh)
* (FULL_PCT - float(pack.soc)) / 100.0)
def envelope(pack) -> PackEnvelope:
"""The interval this pack may be commanded in, from its state of charge.
Positive is DISCHARGE throughout this add-on, so:
* a pack at or below its own cut-out gets `hi = 0` - it may still be
charged, it may not be asked to discharge;
* a pack at 100 % gets `lo = 0` - it may still be discharged, it may not
be asked to charge.
⚠️ THE DISCHARGE HALF ASKS `peakguard.discharge_permitted()`, THE PREDICATE
SAFETY-32 MADE PUBLIC, rather than writing `soc <= floor` a third time. That
function is already the one place this question is answered - for the peak
guard's own verdict and for `delivery.account()`'s discriminator - and a
safety predicate transcribed into a third copy is two future disagreements
about what it means. What is NOT reused is its wording, which is peak-guard
flavoured; the floor handed in is the pack's own cut-out, exactly as
`delivery.account()` hands it.
⚠️ AND A MISSING READING OR A MISSING FLOOR WITHHOLDS THE CLOSURE, NOT THE
DISCHARGE - WHICH IS THE OPPOSITE DIRECTION FROM `discharge_permitted()`'s
OWN, ON PURPOSE. That predicate answers "may this site SPEND this pack for
money", where an unread SoC must not authorise the spend and a full battery
is exactly the value that would disarm the floor. The question here is
different: whether to take a unit's share away from it and hand it to a
sibling. An absent measurement is not evidence that a battery is flat, and
closing on it would park a healthy unit's discharge on every tick for as
long as its sensor was missing - moving load onto its sibling on the
strength of a reading nobody took.
Both absences already have a stronger answer arriving first, which is why
this one costs nothing: a missing SoC stops the WHOLE SITE through
`inputs_ok` (a site-level P_SAFETY set of 0 W), and a roster entry with no
`bms_soc_floor` is refused at startup by validate.py rule 8 and by
config.yaml's own schema, which requires the key of every entry. So the
conjunction below is "positive evidence only": both numbers present, and the
predicate says the pack is at or under its own cut-out.
⚠️ `discharge_permitted()` IS STILL THE PREDICATE, and it is still the only
place the comparison is written - what this module decides for itself is the
two None cases, which it decides differently and says why. The BOUNDARY
(`<=`, so a pack sitting exactly on its floor is closed) comes from there
and is graded against it over the whole percent range in test_packshare.py
part 2, so a future change to that boundary moves both.
⚠️ AT P_SAFETY IT BINDS THE MAINTENANCE DRAIN TOO, AND THAT IS RIGHT RATHER
THAN AN OVERSIGHT. main.py files this as a `P_SAFETY` limit, so clause 2 of
the arbiter's rule applies it to the §12 recalibration drain (a SET at
P_MAINTENANCE): a drain into a pack already at its BMS cut-out is clamped to
0 W. On the shipped numbers it cannot even bite - `maintenance_soc_floor`
defaults to 11 % against a 10 % cut-out, so the drain finishes above this
interval. On a config that sets the drain target BELOW the cut-out
(`int(5,30)` against `int(0,50)`, so the schema permits it) the drain stalls
and the FSM's 12 h DRAIN timeout eventually reports "drain stuck" - which is
what ALREADY happens without this card, because the pack's own BMS refuses
the command whatever the allocator sends. The failure is the config's, the
remedy is the same, and this envelope does not make it worse; it only makes
the refusal visible one layer earlier, in the allocation record.
⚠️ BOTH ENDS CANNOT CLOSE AT ONCE ON ANY CONFIGURATION THE SCHEMA ACCEPTS.
It would take a pack reporting at least 100 % while sitting at or under its
own cut-out, and `config.yaml` bounds `bms_soc_floor` at `int(0,50)` on both
the flat key and every roster entry - so the two conditions cannot both hold.
If they somehow did, the result is `[0, 0]`, which is an interval containing
exactly one integer rather than a contradiction, and the allocator commands
that unit 0 W. Nothing is special-cased for it here: inventing a second
opinion about an unreachable state is how unreachable states get opinions
that are wrong.
"""
hi, lo, why = inf, -inf, ""
if (pack.soc is not None and pack.bms_floor is not None
and discharge_permitted(pack.soc, pack.bms_floor) is not None):
hi = 0.0
why = (f"{pack.name} is at {float(pack.soc):g} %, at or under its own "
f"{float(pack.bms_floor):g} % BMS cut-out - it is not asked to "
f"discharge")
if pack.soc is not None and float(pack.soc) >= FULL_PCT:
lo = 0.0
why = (f"{pack.name} is at {float(pack.soc):g} % and cannot take a "
f"charge - it is not asked to")
return PackEnvelope(lo=lo, hi=hi, why=why)
def weights(packs, target_w) -> dict | None:
"""Per-unit comfort weights for THIS tick's site target, or None for none.
The weight of a pack is the energy it has left IN THE DIRECTION THE SITE IS
ABOUT TO TRAVEL - usable energy above its own cut-out when the site is
discharging, room below full when it is charging. `allocator._fill()` uses
only the RATIOS, so the unit is free and kWh is the honest one to leave it
in: it is what a reader of the action log can check against the datasheet.
⚠️ THE DIRECTION IS AN ARGUMENT BECAUSE IT IS NOT KNOWABLE ANY EARLIER. The
two directions give OPPOSITE answers on the same roster - A at 20 % of
15.36 kWh and B at 80 % of 20.48 kWh weigh 1.5 : 14.3 to discharge and
12.3 : 4.1 to charge - so a direction-free weighting is not a compromise
between them, it is wrong in one of the two on every tick. The site target
is chosen by `arbiter.site_resolve()`, i.e. by stage 2, so the earliest
honest moment to ask this is between stage 2 and stage 3. That is why
`resolve()` accepts a CALLABLE here and calls it with the resolved target,
and why main.py hands it a method rather than a dict.
⚠️ `target_w >= 0` IS THE DISCHARGE SIDE, AND IT IS THE SITE TARGET'S SIGN
RATHER THAN THE FILL'S. `allocator.plan()` decides its own direction from
`need = remainder - placed`, which differs from `sign(target_w)` only when a
PIN or an envelope that excludes zero has already moved the base - a
maintenance drain, a failsafe park. On those ticks the weighting is computed
for the other direction and is therefore merely a worse preference; it
cannot be unsafe, for ARCH-01's reason, and it cannot break the sum, which
is integer arithmetic in the allocator. Chasing the exact direction would
mean handing this function into `plan()` and calling it from inside the
fill, which would make the allocator's purity depend on a caller's callback.
None means "no opinion, weigh everything 1.0", and there are three ways to
get it, all of which are today's behaviour rather than a new one:
* fewer than two packs. At N<=1 `plan()` is a pass-through and a weighting
would be arithmetic nobody reads;
* any pack that cannot state its headroom - see the module docstring on
why a half-known roster is worse than an unweighted one;
* every pack at zero headroom in this direction. All-zero weights would
put the whole roster into `_fill()`'s LAST-RESORT pass, where the shares
come out equal anyway and every `UnitPlan.why` reads "last resort: the
preferred units were full" - a true sentence about the wrong thing.
"""
packs = list(packs)
if len(packs) < 2:
return None
room = (usable_kwh if float(target_w) >= 0.0 else spare_kwh)
out = {}
for p in packs:
kwh = room(p)
if kwh is None or not isfinite(kwh):
return None
out[p.name] = float(kwh)
if not any(v > 0.0 for v in out.values()):
return None
return out
def trade_power_w(packs, floor_pct, ceiling_pct) -> tuple:
"""`(charge_w, discharge_w)` - how hard an arbitrage trade may be SIZED.
The site rating is what the site can push; this is what the site can push
AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` was handed
`site_max_w` on BOTH sides, so a two-pack site would ask to buy at the sum of
the ratings whatever either pack's state of charge was - and the watts that
could not be placed would come back as `unmet_w` for the site integrator to
chase for the rest of the hour.
⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` /
`arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, BECAUSE SIZING A
TRADE IS THE TRADE'S BUSINESS. This answers "how much may this strategy ask
for"; `envelope()` above answers "what may this pack be commanded at all",
and that one is the pack's own physical cut-out for SAFETY-25's reason.
Sizing with the physical floor would let the strategy ask for watts its own
reserve forbids; bounding the envelope with the trade's reserve would let a
trade preference stop the grid-following loop. TWO QUESTIONS, TWO NUMBERS.
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack carries the trade
is the allocator's answer, and the only per-pack instrument it has for a
TRADE reserve is the weights - which are a preference, not a permission. So
a pack between its arbitrage floor and its BMS cut-out can still take part
of a sell. That is stated rather than fixed: fixing it needs an envelope
that depends on WHICH CLAIM WON the tick, which the arbiter's partition does
not express, and it is a card of its own.
A pack whose SoC did not read is counted on NEITHER side: withholding is the
direction that cannot over-size, and `inputs_ok` has stopped the site by
then anyway.
⚠️ NEITHER SIDE MAY REACH `arbitrage.claim()` AS A ZERO - a 0 W arbitrage
claim is a PARK ORDER that outranks the loop. It cannot be zero on a tick
that trades today, because the sell gate refuses on the emptiest pack and
the buy gate refuses when every pack is at the ceiling; `arbitrage.decide()`
refuses a 0 W sizing by name anyway, so that is structural rather than a
coincidence of the gates.
"""
return (float(sum(p.max_w for p in packs
if p.soc is not None and float(p.soc) < ceiling_pct)),
float(sum(p.max_w for p in packs
if p.soc is not None and float(p.soc) > floor_pct)))
+330 -4
View File
@@ -140,6 +140,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from textwrap import dedent
from urllib.parse import urlencode
_LOG = logging.getLogger("goodwe.price")
@@ -184,6 +185,87 @@ def brussels(instant: datetime) -> datetime:
return instant.astimezone(brussels_offset(instant))
def brussels_date_range(since: datetime, until: datetime) -> tuple:
"""The Brussels calendar days a half-open window [since, until) touches.
Returns `(first, last)`, two `date`s, BOTH INCLUSIVE - which is the shape
`api.energy-charts.info` takes: `?bzn=BE&start=2026-09-09&end=2026-09-10`
answered 192 quarter-hours across exactly those two local days, measured
2026-09-10T07:45:49Z and committed as
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`.
THE DEFECT THIS EXISTS TO PREVENT (TARIFF-09). `generic_dayahead.fetch()`
accepted `since` and `until` and requested the bare default URL, so the
feed answered ONE Brussels day. Everything past today was then carried
forward from yesterday's profile, flagged provisional, and §8.3 dropped all
of it from the trade set - so the optimiser never saw tomorrow as firm, at
any hour. Measured on the merged tree at 2026-09-10T09:00+02:00 over a 48 h
horizon: 15 firm whole hours and 33 carried, and both ends of the only
permitted cycle inside ONE calendar day. The ceiling was structural: at
hour H of the local day a one-day window gives at most 24-H firm hours and
24+H carried - 1/47 at 23:00, where the firm spread no longer even clears
the round-trip cost. That is not what FCST-04's fill is for; it covers the
pre-publication window until the ~13:00-14:00 CET auction, after which a
healthy feed hands over tomorrow in full.
⚠️ THE CALENDAR IS BRUSSELS, NEVER UTC, AND THIS IS `_bdate()`'s LESSON ONE
LAYER UP. `since`/`until` are INSTANTS; the API takes DATES, and the two are
not the same question. 22:30 UTC in summer is already tomorrow in Belgium,
so a range derived from `since.date()` asks for the wrong FIRST day for the
last one or two hours of every single day - and the day it drops is the one
the controller is standing in. price_history.py made this mistake at the
cache layer and documents the same arithmetic; here it decides what is
fetched at all, so getting it wrong loses the hours rather than misfiling
them.
⚠️ `until` IS EXCLUSIVE, SO LOCAL MIDNIGHT BELONGS TO THE DAY BEFORE IT.
A 48 h horizon from local midnight ends at local midnight two days later,
and that instant is the first tick of a day the caller did not ask for.
Taking `brussels(until).date()` unconditionally would request a THIRD day
on every whole-hour horizon. This feed answers such a request without
complaint, so nothing would report it - which is the reason to get it right
here rather than to rely on the server's tolerance: the range is a claim
about what the caller asked for, and it should be true.
⚠️ AND AN EMPTY OR INVERTED WINDOW COLLAPSES TO ONE DAY RATHER THAN TO A
BACKWARDS RANGE. `start` after `end` is a request no feed has a defined
answer for; some return everything, some return an error, and neither is a
thing to hand a battery controller.
"""
first = brussels(since).date()
edge = brussels(until)
last = edge.date()
if (edge.hour, edge.minute, edge.second, edge.microsecond) == (0, 0, 0, 0):
last = last - timedelta(days=1)
if last < first:
last = first
return first, last
def dated_feed_url(base: str, **params) -> str:
"""`base` plus a query string. Refuses a `base` that already carries one.
⚠️ THE REFUSAL IS THE POINT, NOT THE FORMATTING. Appending `&start=...` to
a URL that may already hold its own query string is how a plugin produces a
request nobody intended - `?bzn=BE?bzn=BE&start=...`, or a duplicated
parameter whose winner is the server's choice rather than ours. So the base
is required to be parameterless and the parameters are added exactly once,
by `urlencode`, which is also what keeps a date out of the escaping
question. A future edit that folds the query back into the constant is a
refused fetch with a sentence, not a silently malformed URL.
⚠️ IT IS NOT REACHED BY AN OPERATOR'S `price_feed_url`. See
`GenericDayAhead.fetch()`: an override is used exactly as given.
"""
if "?" in base or "#" in base:
raise SourceUnavailable(
f"the feed base {base!r} already carries a query string or "
f"fragment, and this builder appends parameters. Adding a second "
f"'?' produces a URL the server parses differently from the one "
f"anybody intended; put the fixed parameters in the call instead")
return f"{base}?{urlencode(params)}" if params else base
# --------------------------------------------------------------------------- #
# Absence, borrowed wholesale from sim/riglock.py (TEST-04).
# --------------------------------------------------------------------------- #
@@ -731,6 +813,24 @@ class PriceProvider(ABC):
`transport(url, timeout_s) -> bytes` is injected so the checks never
touch a network and `manual_csv` never needs one. `opts` is a
`PluginOptions`, so the markup and taxes are not reachable from here.
⚠️ `since`/`until` ARE THE RANGE THE CALLER WANTS, AND A PLUGIN THAT
IGNORES THEM GETS WHATEVER ITS FEED DEFAULTS TO (TARIFF-09). That is
not a small difference: `generic_dayahead` ignored both, the public
EPEX feed answered ONE Brussels day, and §8.3 then dropped every hour
past today from the trade set as provisional - so the optimiser never
saw tomorrow as firm, at any hour of any day, and nothing reported it.
This promise is NOT mechanically enforced, and the honest reason is
TARIFF-08's: three of the five shipped plugins point at endpoints
nobody has verified (TARIFF-07 is the open decision on withdrawing
them), so nobody knows how those feeds spell a date range. A rule
obliging them to pass one would be satisfied by an INVENTED parameter
name - the parser-graded-against-its-own-guess defect, moved from the
response to the request. What exists instead is a roster pin in
test_price.py: which plugins consume the range and which do not is
asserted as a literal, so a sixth supplier, or a change to one of the
three, forces the decision rather than inheriting the silence.
"""
@@ -979,9 +1079,166 @@ def _points_from_json(body, price_key, scale, provisional_default):
return out
# --------------------------------------------------------------------------- #
# TARIFF-08: the COLUMNAR document, which is what the public EPEX feed really
# publishes.
#
# ⚠️ THIS IS A SECOND SHAPE, READ BY A SECOND FUNCTION ON PURPOSE.
# `_points_from_json()` above reads a row-per-point document: `{"data": [{...},
# {...}]}`. `https://api.energy-charts.info/price?bzn=BE` does not publish that
# and never did - it publishes PARALLEL ARRAYS:
#
# {"unix_seconds": [1788991200, 1788992100, ...], <- UTC epoch seconds
# "price": [216.08, 209.90, ...], <- one per instant
# "unit": "EUR / MWh", "license_info": "...", "deprecated": false}
#
# The shipped parser was pointed at that URL and asked for `doc["data"]`, so
# every fetch ended `SourceUnavailable: the feed carried no 'data' list` ->
# §11.4 -> static profile, arbitrage off, and nothing in the status page said
# why. Verified live 2026-09-10 against both fixtures under `scenarios/`.
#
# ⚠️ AND IT IS NOT ONE FUNCTION THAT SNIFFS FOR BOTH. A parser that decides
# which document it got is a parser that can decide wrong, and a wrong-but-
# parseable response then becomes a price. Two shapes, two functions, and each
# plugin names the one its own feed publishes.
#
# The columnar shape was already known in this repository: DATA-02's
# `scenarios/fetch_epex_be.py` has read `zip(d["unix_seconds"], d["price"])`
# since 2026-08-31. Only the SHIPPED path never learned it, because every
# fixture in the suite was built from the invented row shape - the parser was
# graded against its own assumption. See test_price.py section 2b.
# --------------------------------------------------------------------------- #
COLUMNAR_TIME_KEY = "unix_seconds"
COLUMNAR_PRICE_KEY = "price"
COLUMNAR_UNIT_KEY = "unit"
def _columnar_unit(text) -> str:
"""`"EUR / MWh"` -> `"eur/mwh"`. Spacing and case are not information."""
return "".join(str(text).split()).lower()
def _points_from_columnar_json(body, expect_unit, scale,
provisional_default):
"""Parse a COLUMNAR price document: parallel `unix_seconds` / `price`.
Defects this refuses, each of which has a way of becoming a number rather
than an error:
* ⚠️ UNEQUAL ARRAYS ARE `SourceUnavailable`, NOT A SHORT SERIES. `zip()`
stops at the shorter array and says nothing, so a `price` list that
arrived truncated would produce a REAL price series with its tail
missing - and the tail of a Belgian day is the evening peak. A series
that is missing the expensive hours is worse than no series: §11.4's
no-data row switches arbitrage OFF, whereas a truncated series is
traded on. Nothing downstream can tell the two apart, so the refusal
has to happen here.
* ⚠️ A UNIT THAT IS NOT THE DECLARED ONE IS REFUSED. `scale` is the
caller's standing assumption about the unit; if the feed ever
publishes EUR/kWh under the same keys, every price silently comes out
a THOUSAND times too small. `_validate_raw()` cannot catch that -
`MAX_PLAUSIBLE_EUR_KWH` only bounds prices that are too LARGE.
DATA-02's fetcher refuses on the same field for the same reason.
* ⚠️ `unix_seconds` ARE UTC INSTANTS, SAID SO EXPLICITLY.
`datetime.fromtimestamp(t, timezone.utc)`. Without that second
argument CPython returns a NAIVE datetime in the HOST's zone, and a
naive datetime is a wall clock reading plus an assumption about whose
clock it was. `brussels()` and `_validate_raw()` both refuse one, so
the mistake surfaces as a refused fetch rather than as a mispriced
day - but they refuse ALL naive datetimes and can say nothing about
whether this one was the right instant. That part is decided here.
§9.3's rendering AT THE BRUSSELS OFFSET is `assemble()`'s, once, for
every plugin - `brussels(p.start)` there is the single line that does
it, and calling it here as well would be a second copy of a rule that
already has an owner, indistinguishable from a no-op under mutation.
A plugin's job is the right INSTANT; the framework's is the rendering.
A `null` price is DROPPED and logged, not fatal, and that is a different
judgement from the one above: an unequal pair of arrays means the pairing
of instant to price is unreliable, whereas a null means the source stated
it published no price for that one instant. A dropped instant leaves a
hole, and `_Series.at()` already answers a hole with `Absence` - §11.4 for
that hour. `scenarios/fetch_epex_be.py` dropped 0 nulls over the 12 months
to 2026-08-31, so this is a shape the source allows rather than one it uses.
"""
try:
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
else str(body)
doc = json.loads(text)
except (ValueError, UnicodeDecodeError) as e:
raise SourceUnavailable(f"the feed did not parse as JSON: {e}")
if not isinstance(doc, dict):
raise SourceUnavailable(
f"the feed is a {type(doc).__name__}, not a columnar object with "
f"{COLUMNAR_TIME_KEY!r} and {COLUMNAR_PRICE_KEY!r} arrays")
times = doc.get(COLUMNAR_TIME_KEY)
prices = doc.get(COLUMNAR_PRICE_KEY)
for key, col in ((COLUMNAR_TIME_KEY, times), (COLUMNAR_PRICE_KEY, prices)):
if not isinstance(col, list):
raise SourceUnavailable(
f"the feed carried no {key!r} array; this provider reads the "
f"columnar shape (parallel {COLUMNAR_TIME_KEY}/"
f"{COLUMNAR_PRICE_KEY} arrays), and a row-per-point document "
f"is a different feed, not this one with a field missing")
if len(times) != len(prices):
raise SourceUnavailable(
f"{COLUMNAR_TIME_KEY} has {len(times)} entries and "
f"{COLUMNAR_PRICE_KEY} has {len(prices)}; refusing to zip them. "
f"The short array would truncate the series silently, and a price "
f"series missing its last hours is one missing the evening peak - "
f"traded on as if complete, where no data at all would have "
f"switched arbitrage off (§11.4)")
unit = _columnar_unit(doc.get(COLUMNAR_UNIT_KEY, ""))
if unit != _columnar_unit(expect_unit):
raise SourceUnavailable(
f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not "
f"{expect_unit!r}. This provider converts with a fixed factor of "
f"{scale}, so a changed unit is a factor of 1000 on every price "
f"with nothing downstream able to notice it")
if doc.get("deprecated"):
# Not a refusal: today's prices are still prices. But §11.4 makes a
# dead feed look exactly like a quiet one, so the warning that the
# source is going away has to be emitted while it still works.
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
"but this endpoint is scheduled to stop",
doc.get("deprecated"))
out = []
dropped = []
for i, (t, v) in enumerate(zip(times, prices)):
if v is None:
dropped.append(i)
continue
if isinstance(t, bool) or not isinstance(t, (int, float)) or \
not math.isfinite(t):
raise SourceUnavailable(
f"{COLUMNAR_TIME_KEY}[{i}] is {t!r}, not epoch seconds")
if isinstance(v, bool) or not isinstance(v, (int, float)) or \
not math.isfinite(v):
raise SourceUnavailable(
f"{COLUMNAR_PRICE_KEY}[{i}] is {v!r}, which is not a price")
start = datetime.fromtimestamp(float(t), timezone.utc)
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
provisional=bool(provisional_default)))
if dropped:
_LOG.warning("the feed published no price for %d of %d instants "
"(index %s); those instants are left OUT of the series, "
"never zero-filled - a missing hour is not a free hour",
len(dropped), len(times),
", ".join(str(i) for i in dropped[:5])
+ (", ..." if len(dropped) > 5 else ""))
if not out:
raise SourceUnavailable(
f"the feed carried {len(times)} instants and a price for none of "
f"them")
return out
@register
class GenericDayAhead(PriceProvider):
"""Public EPEX BE day-ahead feed, in EUR/MWh.
"""Public EPEX BE day-ahead feed, COLUMNAR, in EUR/MWh.
SOURCES: energy
The framework ADDS: markup, taxes
@@ -990,6 +1247,19 @@ class GenericDayAhead(PriceProvider):
is the wholesale clearing price and nothing else, so both user-entered
components have to be added to it - and because this plugin cannot read
either number, it cannot be the one that adds them twice.
⚠️ IT READS THE COLUMNAR SHAPE, AND SAYS SO IN ITS OWN `fetch()`. Until
TARIFF-08 it called `_points_from_json()` and asked
`api.energy-charts.info` for a `"data"` list it has never published, so
every fetch this provider ever made ended in `SourceUnavailable` and §11.4
turned that into a silent fall back to the static profile. See
`_points_from_columnar_json()` for the shape and for why there are two
parsers rather than one that guesses.
⚠️ THE FEED IS QUARTER-HOURLY SINCE BELGIUM'S 15-MINUTE MTU (2025-10-01):
96 points a day at 900 s, not 24 at 3600 s. Nothing here assumes either -
the instants come from the document - and both granularities are replayed
from real captured bodies in test_price.py section 2b.
"""
id = "generic_dayahead"
@@ -1001,12 +1271,68 @@ class GenericDayAhead(PriceProvider):
sources=frozenset({Component.ENERGY}),
egress_hosts=("api.energy-charts.info", "web-api.tp.entsoe.eu"))
FEED_BASE = "https://api.energy-charts.info/price"
BZN = "BE"
async def fetch(self, opts, since, until, transport):
url = opts.get("price_feed_url") or \
"https://api.energy-charts.info/price?bzn=BE"
"""Request the range the CALLER asked for, on the Brussels calendar.
⚠️ THE ARGUMENTS ARE USED. Until TARIFF-09 this method took `since` and
`until` and asked for the bare `?bzn=BE`, which answers ONE Brussels
day - so a 48 h horizon was 15 firm hours and 33 carried at 09:00, and
1 firm and 47 carried at 23:00, with the whole trade set inside a
single calendar day at every hour of every day. See
`brussels_date_range()` for the measurement and for why the calendar
is Brussels rather than UTC.
⚠️ AN OVERRIDE IS THE EXACT URL AND THIS METHOD APPENDS NOTHING TO IT,
WHICH COSTS THE OVERRIDING SITE THE RANGE. That is a decision, not an
oversight, and the sentence below is why it is not a footnote:
* `price_feed_url` says "override the plugin's default feed", and an
override that is silently rewritten is not one. Merging parameters
into an operator's query string means parsing and re-serialising a
URL they wrote - which reorders, de-duplicates and re-escapes it,
and can hand the server a request nobody typed.
* `start`/`end` are ENERGY-CHARTS' spelling, and this provider
DECLARES A SECOND HOST: `web-api.tp.entsoe.eu`. Nothing here has
verified that host's day-ahead parameters and nothing here should
guess them - but they are not `start`/`end`, so an override to the
other DECLARED host is exactly the case where appending our
vocabulary adds two parameters that host ignores and yields the
short horizon anyway, with nothing saying so.
* and blind appending is refused outright: `dated_feed_url()` will
not add a query string to a base that has one.
So the cost is real and it is REPORTED, at fetch time, on the shipped
logger, naming the horizon consequence rather than the substitution.
§11.4 is what makes a price defect quiet; an operator who has taken the
short horizon has to be able to find out from the log that they did.
"""
override = str(opts.get("price_feed_url") or "").strip()
if override:
url = override
_LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as "
"given and is adding NO date range to it. The public feed "
"answers a single Brussels day to a request that names no "
"range, and every hour past that day is then carried forward "
"from yesterday's profile and flagged provisional - §8.3 "
"drops provisional hours from the trade set, so arbitrage "
"would never see tomorrow as firm. Clear price_feed_url to "
"let this plugin ask for the %s..%s it was given.",
self.id, url, *(d.isoformat()
for d in brussels_date_range(since, until)))
else:
first, last = brussels_date_range(since, until)
url = dated_feed_url(self.FEED_BASE, bzn=self.BZN,
start=first.isoformat(),
end=last.isoformat())
check_egress(url, self.capabilities())
body = await transport(url, float(opts.get("price_timeout_s")))
return _points_from_json(body, "price_eur_mwh", 0.001, False)
return _points_from_columnar_json(body, expect_unit="EUR / MWh",
scale=0.001,
provisional_default=False)
@register
+170 -27
View File
@@ -48,12 +48,24 @@ So the flag is not passed through anywhere in this file:
the object the provider produced is the object in the output series - so
there is no copy that could lose or gain a flag. `filled.points[i] is
fetched.points[j]` is a checkable property, and test_price_forecast.py
checks it.
checks it. EVERY published point in the window is passed through, not one
per hour: see the sub-hourly note below.
* `_audit()` RE-DERIVES the expected flag for every output point from the set
of hours the feed actually published, and RAISES if any point disagrees.
It is a post-condition, not a filter: it never fixes a flag, because a
module that quietly corrects its own provenance is one whose provenance
cannot be trusted.
⚠️ IT GRADES THE POINTS THAT ARE EMITTED AND NOTHING ABOUT THE ONES THAT
ARE NOT (FCST-05). `_audit()` is a provenance check, NOT a completeness
check: fed FCST-04's own decimation defect - four published points in an
hour, only the last emitted - it ACCEPTS, and it accepts an empty output
too. Measured, and the FCST-03 audit it replaced was equally blind, so
this is a limit rather than a regression. What actually guards against a
decimated series is the point COUNT and the pass-through IDENTITY checks
in `test_price_forecast.py` (section on the quarter-hourly feed) and
`test_price_history.py`; see `_audit()`'s own docstring for why the
completeness rule is not expressible from its two arguments.
* and test_price_forecast.py walks THIS FILE'S AST and fails if any
`PricePoint(...)` in it passes anything but a literal for `provisional`, so
the pass-through idiom cannot come back in a later edit and pass review by
@@ -90,6 +102,24 @@ which is the row the spec actually writes for "no price data". If NOTHING can
be produced, the result is `PriceRead.no_data(reason)` and not an empty series,
because an empty series is not constructible on purpose (price.py trap 3).
⚠️ A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR, and the hourly walk
below is about WHICH HOURS NEED FILLING - never about what a published hour is
allowed to contain. `eneco_eddy` is the Belgian imbalance product and settles
per QUARTER, `_validate_raw()` accepts any strictly-increasing spacing, and a
published hour may therefore hold four points. So EVERY published point inside
the window is passed through, in order, and a carried point is added only for a
whole hour with NO published coverage at all.
The alternative was measured rather than reasoned about, because FCST-04's
first caller shipped it: keying the published set as `{_utc_hour(p.start): p
for p in r.points}` reads perfectly ordinary and keeps the LAST point of each
hour, dropping the other three. On a 96-point quarter-hourly feed - all firm,
fully published, nothing to carry - the series came out 15 points long, the
arbitrage spread collapsed from 0.143 to 0.014 EUR/kWh, and the cheapest
"hour" became a `:45` point whose `at()` window runs into the next hour. A feed
losing 84 % of its own prints is not a windowing effect and is not something a
provisional flag would ever have reported.
AC2: THE IRREVERSIBLE-ACTION GATE
=================================
@@ -156,6 +186,25 @@ def _utc_hour(instant: datetime) -> datetime:
return u.replace(minute=0, second=0, microsecond=0)
def _published_by_hour(read: PriceRead) -> dict:
"""UTC hour -> the provider's OWN points that start inside it, in order.
⚠️ A TUPLE PER HOUR, NOT A POINT, AND THAT IS THE WHOLE OF THE SUB-HOURLY
RULE. `{_utc_hour(p.start): p for p in r.points}` reads identically, is one
character shorter, and silently keeps one point per hour - the last one
written. The mapping answers two DIFFERENT questions here: "does this hour
need filling at all?" is `u in published`, and "what does the output carry
for it?" is all of `published[u]`. Collapsing them is how a quarter-hourly
feed loses three quarters of its prints.
"""
out: dict = {}
read.match(
series=lambda r: [out.setdefault(_utc_hour(p.start), []).append(p)
for p in r.points],
no_data=lambda _r: [])
return {u: tuple(ps) for u, ps in out.items()}
def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
"""A point filled from yesterday's profile. §8.3: ALWAYS provisional.
@@ -172,37 +221,122 @@ def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
def _audit(points, published) -> None:
"""Post-condition: every flag matches the point's TRUE provenance (AC1).
`published` maps a UTC hour to the point the provider produced for it.
`published` maps a UTC hour to the TUPLE of points the provider produced
for it (`_published_by_hour()`). An hour ABSENT from that mapping is an hour
the feed did not print, and a carried point is the only thing that may
stand in it.
The rule, in the two forms an output point can take:
* PUBLISHED - the point must BE one of `published[u]`, by identity, and
may be emitted only once. Its flag is whatever the feed said, never
forced to False: an imbalance print is published AND provisional (§9.2,
TARIFF-02), and a check that "corrected" it would be a bug wearing a
guarantee.
* CARRIED - the hour must not appear in `published` AT ALL, the flag must
be exactly True, and that hour may hold no second point.
⚠️ WHAT REPLACED "AT MOST ONE POINT PER HOUR", AND WHAT THAT DID AND DID
NOT CHANGE. This audit began as FCST-03's `seen` set: any repeated
`_utc_hour` raised, beside an identity rule `p is not src`. A bare hour
count cannot express a quarter-hourly feed, where four distinct published
points share one hour legitimately - so the count was replaced by the two
rules above, which subsume it.
⚠️ AND IT IS NOT STRICTER. FCST-04's version of this paragraph claimed two
strengthenings and BOTH WERE FALSE; FCST-05 measured it. A differential
over the whole domain this audit can express - every published mask over 3
hours crossed with every output series of length 0-3 drawn from
{published, published-provisional, carried, carried-unflagged,
copy-of-published} - found ZERO divergence in EITHER direction:
OLD raised, NEW accepted (WEAKER): 0
OLD accepted, NEW raised (STRONGER): 0
The two specific claims, one at a time:
* "so does the same published point emitted twice" - the OLD `seen`
count already raised on that, because emitting one point twice is one
HOUR twice. Preserved by the identity/`emitted` rules, not added.
* "a carried point may no longer share an hour with a published one,
which the count never checked at all" - true of the COUNT, and the old
IDENTITY rule raised on it anyway: a carried point in a published hour
took the `else` branch and failed `p is not src`. The message was about
a copy rather than about a carry, which is the only thing that moved.
The one real behavioural change is the intended RELAXATION - several
distinct published points in one hour, which the old audit could not
express at all and refused. That relaxation is why this was rewritten, and
it stays. A right decision with a fabricated reason is still a defect here.
⚠️ AND THE REAL LIMIT, WHICH IS NOT A REGRESSION AND IS NOT SMALL: THIS
AUDIT DOES NOT CHECK COMPLETENESS. It grades every point that IS emitted
and says nothing about the ones that are not. Fed the H1 decimation
directly - four published points in an hour, output emitting only the last
- it ACCEPTS; so does two of four, and so does an EMPTY output. So `_audit`
is NOT what stops H1 recurring. What stops it is the point COUNT and the
IDENTITY checks in `test_price_history.py` and `test_price_forecast.py`,
which compare the emitted series against the feed's own prints.
It is not made complete here, and the reason is structural rather than
effort: `published` is `_published_by_hour(read)` over the WHOLE read,
unwindowed, while `fill_from_yesterday()` emits only [since, until). A
published point outside the window is legitimately absent, so "every
published point appears" is not a true post-condition of these two
arguments - it would need the window passed in, which is a different
signature and a different check.
⚠️ AND THAT IS MEASURED, NOT ASSUMED. FCST-05 built the rule - raise unless
every point of every `published[u]` is emitted, +162 B - and ran it. It
takes the 1 h, 12 h and 48 h HORIZON checks in test_price_forecast.py RED,
on the shipped fill and a healthy feed: at an off-the-hour instant the
feed's already-elapsed prints are outside [since, until) and are correctly
dropped, and the rule calls that a provenance failure. (The differential
beside those checks scores the same rule as 49 STRONGER cases, so it is a
real change of behaviour and not a no-op.) The rule as stated is therefore
not merely unimplemented, it is WRONG - and stating a false post-condition
is MUTATION.md habit 16's defect: an instrument that asserts a completeness
it does not have is worse than no instrument.
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
provenance is one whose provenance means nothing; the only safe response to
"this point's flag disagrees with where it came from" is to refuse to emit
the series at all.
"""
seen = set()
carried_hours = set()
emitted = set()
for p in points:
u = _utc_hour(p.start)
if u in seen:
raise ValueError(f"the filled series repeats {u.isoformat()}; a "
f"duplicated hour is how a DST day gets mispriced")
seen.add(u)
src = published.get(u)
prov = Provenance.CARRIED if src is None else Provenance.PUBLISHED
if src is None:
if u in carried_hours:
raise ValueError(
f"the filled series repeats the carried hour "
f"{u.isoformat()}; a duplicated hour is how a DST day gets "
f"mispriced")
carried_hours.add(u)
if p.provisional is not True:
raise ValueError(
f"{u.isoformat()} is {prov.value} and is flagged "
f"provisional={p.provisional!r}. §8.3: an hour filled from "
f"yesterday's profile is provisional whether or not the "
f"number turns out to be right - the flag is a claim about "
f"PROVENANCE, not about accuracy.")
f"{u.isoformat()} is {Provenance.CARRIED.value} and is "
f"flagged provisional={p.provisional!r}. §8.3: an hour "
f"filled from yesterday's profile is provisional whether "
f"or not the number turns out to be right - the flag is a "
f"claim about PROVENANCE, not about accuracy.")
else:
if p is not src:
if not any(p is q for q in src):
raise ValueError(
f"{u.isoformat()} is {prov.value} but the series carries a "
f"COPY of that point rather than the point itself. A copy "
f"is where the provisional flag gets lost; published points "
f"pass through by identity.")
f"{u.isoformat()} is {Provenance.PUBLISHED.value} but the "
f"series carries a COPY of that point rather than the "
f"point itself. A copy is where the provisional flag gets "
f"lost; published points pass through by identity.")
if id(p) in emitted:
raise ValueError(
f"the filled series emits the published point at "
f"{p.start.isoformat()} TWICE. The provider's own points "
f"are strictly increasing (`_validate_raw`), so a repeat "
f"is never a second print - it is a duplicated hour, which "
f"is how a DST day gets mispriced")
emitted.add(id(p))
def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
@@ -217,11 +351,21 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
Returns a `PriceRead`:
* a series, whose points are the provider's own objects for the hours it
published and `_carried()` points for the rest, ordered by instant;
* a series, carrying the provider's own objects - ALL of them, which on
a quarter-hourly feed is four inside one hour - for the hours it
published, and one `_carried()` point for each remaining whole hour,
ordered by instant;
* `no_data` when neither source can produce a single hour - never an
empty series, which is not constructible (price.py trap 3).
⚠️ THE WINDOW IS WHOLE HOURS AT BOTH ENDS. `since` is floored to the hour
that CONTAINS it, because the current hour is still a tradeable hour; and
so is `until`, which is the half FCST-04's first draft left out. With
`since` floored and `until` compared raw, a 48 h horizon asked for at 08:17
emitted 49 hours - the hour of `until` was still `< until` - and the count
was one too many for every `now` that is not exactly on the hour, which is
every `now` a running controller has.
⚠️ IDEMPOTENT AND RE-RUNNABLE, WHICH IS THE §5.4 LATE-PUBLICATION CONTRACT.
Day-ahead publication at 14-16 h CET can be late or fail, so the fetcher
retries and this runs again against the fresher read. Published hours always
@@ -234,9 +378,7 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
raise ValueError(f"{name} must be tz-aware; a naive instant is "
f"ambiguous for one hour every October")
published = read.match(
series=lambda r: {_utc_hour(p.start): p for p in r.points},
no_data=lambda _r: {})
published = _published_by_hour(read)
# Yesterday's profile, keyed by BRUSSELS WALL-CLOCK HOUR. First point wins
# when yesterday itself was a 25-hour day and printed local hour 2 twice.
@@ -248,11 +390,12 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
out = []
u = _utc_hour(since)
end = until.astimezone(timezone.utc)
end = _utc_hour(until)
while u < end:
if u in published:
out.append(published[u]) # BY IDENTITY. No copy.
else:
got = published.get(u)
if got:
out.extend(got) # BY IDENTITY, and ALL of them. No copy,
else: # and no decimation to one an hour.
src = by_local_hour.get(brussels(u).hour)
if src is not None:
out.append(_carried(u, src.eur_per_kwh))
+864
View File
@@ -0,0 +1,864 @@
"""FCST-04 - the day the carry-forward carries, kept somewhere a restart cannot reach.
WHAT WAS MISSING, AND WHY IT WAS INVISIBLE. FCST-03 shipped
`price_forecast.fill_from_yesterday()` complete and checked, and shipped the
consumers that were written to expect it - `TariffPlan.firm_points()`,
`cheap_now()`'s refusal of a provisional hour, and `arbitrage()`'s refusal 3,
which drops provisional hours from the trade set instead of refusing the whole
series. Every one of those was live. The FILL ITSELF HAD NO CALLER: nothing in
`app/` referenced `fill_from_yesterday`, so no series ever contained a carried
point and every one of those consumers was exercising its all-firm branch.
The defect that produced is not an error anywhere. `load_prices()` returns
whatever the feed published and nothing widens it to the requested horizon, so
on a dynamic site between midnight and the 14-16 h CET day-ahead publication
the series covers the rest of TODAY and nothing more - typically 10-24 of the
48 hours `price_horizon_h` asked for. `TariffPlan.arbitrage()` then computes
`max - min` over that truncated window and reports a spread. The number is
real, the window is half the one the operator configured, and NOTHING SAYS SO:
tomorrow evening's peak - the hour a morning charge would be sold into - is
simply not in the series, so the spread it would have created cannot clear a
threshold it was never compared against. A missing hour is not a wrong price;
it is an hour the optimiser never learns exists.
So this module is the half FCST-03 left out, and it is deliberately the SMALL
half: the fill's arithmetic, its DST rule and its provenance audit all stay in
`price_forecast.py`, untouched. What lives here is the one thing that
arithmetic needs and could not have: A YESTERDAY THAT SURVIVES A RESTART.
WHY PERSISTENCE, AND NOT A VARIABLE ON THE REFRESH TASK
======================================================
An in-memory "the last full day I saw" costs nothing and is wrong exactly when
it matters. The window the fill exists for is midnight -> publication; an
add-on restarted at 08:00 - a config change, an HA update, a power cut - would
come up with no yesterday at all and spend that whole window on the truncated
series, which is the pre-FCST-03 behaviour arriving silently on the mornings
after an operator touched something. The fill's own value is a function of
surviving a restart, so the day it carries has to.
⚠️ AND NOT IN `/data/state.json`. That file holds the maintenance history and
SAFETY-02's sign latch, and `Store.set()` rewrites the whole of it on every
write. The tariff refresh runs every `TARIFF_REFRESH_S` (1800 s), so folding a
price cache into it would put 48 extra whole-file rewrites a day through the
file whose corruption reads as "no maintenance ever ran" and "never tripped".
The cache is worth having; it is not worth putting anywhere near that. Separate
file, separate blast radius.
⚠️ AND THE DIRECTORY IS NOT CREATED, which is `tariff.PeakStore`'s rule and its
reason applies unchanged: `/data` is mounted by the Supervisor and always
exists on a real install, so `makedirs` here could only ever succeed where the
durable volume is MISSING - manufacturing a cache on the container's ephemeral
layer that writes fine, reads fine, and is thrown away on the next restart.
That is a yesterday that silently forgets, which is worse than one that says it
cannot answer.
⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. FCST-04
first shipped it half-done: `_save()` short-circuited on `not self.ok` while
`_remember()` filled `self._days` anyway, so a store with no durable volume
answered `words` = "unavailable" and `yesterday()` = A SERIES - an in-memory
yesterday that works all day and is gone on the next restart, which is exactly
the silently-forgetting cache this rule exists to refuse. A store that cannot
persist now records nothing, and both of its answers say the same thing.
⚠️ AND "CANNOT PERSIST" IS ONE QUESTION, ASKED ONCE, ABOUT THE OUTCOME -
FCST-06. The paragraph above enforced that rule against `not self.ok`, which is
one SYMPTOM of not persisting: the durable volume is missing. There is a second,
and FCST-06 measured it. With `ok=True` and the directory present, `_save()` can
still fail - a read-only remount (`[Errno 30] Read-only file system`), a full
volume (`[Errno 28] No space left on device`) - and it logs, records
`last_error` and returns. `_remember()` had already populated `self._days`, so
`words` read `'2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over
24 h) [could not write (...)]'` while the file on disk held `{}`, and
`yesterday()` answered 24 REAL POINTS for the rest of the process's life. Gone
on the next restart, and nothing said it would be - the same silently-forgetting
cache, reached by the condition the rule did not name.
⚠️ SO IT IS NOT TWO RULES, AND IT IS DELIBERATELY NOT TWO TESTS. "Can this
store persist?" CANNOT BE ANSWERED BEFORE THE ATTEMPT - a directory that existed
and was writable a moment ago is not a write that completed - so it is answered
AFTER it, once, by the only thing holding the evidence: `_save()` returns
whether the file now holds what memory holds. A missing volume is one of its
False cases and an `OSError` from the write is another, and `_remember()` KEEPS
ONLY WHAT THAT ONE ANSWER SAYS WAS KEPT. There is no `not self.ok` test in
`_remember()` any more, because there is nothing left for a second test to add -
and a third symptom nobody has thought of is how this defect arrived both times.
⚠️ AND WHAT IS KEPT IS THE LAST STATE THAT IS ON DISK, NOT AN EMPTY ONE. The
MERGE is undone, not the store. A site whose volume fills at 14:00 goes on
answering the yesterday it wrote at 13:30 - that record survives a restart, so
answering it is not a promise the store cannot keep. What it stops doing is
adding to it. The invariant the rule was always describing, stated once:
`self._days` NEVER HOLDS A POINT THE FILE DOES NOT. (Not "mirrors the file" -
`_usable_days()` drops records the file still holds. The only direction that can
invent a yesterday is the one named.)
THE FOUR RULES THIS FILE IS SHAPED BY
=====================================
1. IT RECORDS WHAT THE PROVIDER PUBLISHED, NEVER WHAT THE FILL PRODUCED.
Feeding a filled series back in is the defect that makes the whole mechanism
rot: day 2 carries day 1's guesses, day 3 carries day 2's carry of them, and
after a week of a dead feed the "yesterday" being carried forward is a
fossil of the last real publication with nothing anywhere reporting its age.
Every point still carries `provisional=True`, so the flag never lies - what
decays is the NUMBER, silently, behind a flag that is telling the truth.
⚠️ NO VALUE-LEVEL CHECK CAN CATCH THAT, and saying so is more useful than a
guarantee that would not hold. A carried point and a published-provisional
point (`eneco_eddy`'s current quarter, §9.2) are the same three fields with
the same flag; the difference is provenance, and provenance is not in the
value. So the mechanism is ORDERING, not validation: `carry()` is the only
public entry point, it remembers the argument and returns the fill, and the
filled series exists only as a return value that `carry()` itself never
sees again. `_remember()` is private, is called from exactly one place, and
`test_price_history.py` PARSES every shipped module under `app/`, `tools/`,
`scenarios/` and `estop/` and fails if any of them uses the NAME - an
attribute, a bare name, a def, or the string constant a `getattr` needs. By
name rather than by substring, so a comment about the rule does not fail it
and a call from `tools/` cannot pass unseen.
⚠️ AND THE ORDER IS GRADED BY DRIVING IT, NOT BY READING THE SOURCE. The
first version of that check compared positions in `ast.walk`, which is
breadth-first: `self._remember(...)` is a statement and `self.yesterday(...)`
is an ARGUMENT of the fill, one level deeper, so `_remember` came out first
whichever statement was written first - shipped and reversed scored the
same. What grades it now is the behaviour: a feed carrying yesterday
alongside today, into an EMPTY store, must fill the whole horizon, and
reversed it fills nothing.
2. "YESTERDAY" MEANS YESTERDAY, NOT "THE NEWEST DAY I HAVE".
`newest()` is the natural accessor and it is the one that decays: an add-on
down from Friday to Monday would answer Friday's prices to a fill that flags
them as one day old. The lookup is BY DATE - the Brussels day before the
Brussels day of the instant asked about - and a record for any other date is
NO_RECORD with a reason naming both dates. A three-day-old profile is not a
worse yesterday; it is not a yesterday.
3. A STORE THAT CANNOT ANSWER SAYS SO, IN THE TYPE.
`yesterday()` returns a `PriceRead`: a `series` when a full record exists,
`no_data(reason)` for every other outcome - no file, no record for that
date, an unreadable file, a record that parses to nothing. There is no
empty-series answer and no `.get(..., [])`-shaped accessor, for
`price.py`'s reason: an empty series is how "the cache is broken" gets to
wear the shape of "yesterday was free". `fill_from_yesterday()` takes a
`PriceRead` and handles `no_data` on its own, so the degraded case needs no
branch at the call site - it fills nothing and the horizon stays short,
which is exactly the behaviour that shipped before this ticket.
4. HOURS ACCUMULATE WITHIN A DAY; THEY DO NOT REPLACE IT.
A feed that serves only the REMAINING hours of the day (a legitimate shape -
nothing in the plugin contract promises a whole day) would, under
replace-the-record, overwrite a complete 24-hour profile with the two points
left at 22:00 and hand the next morning a yesterday with two hours in it. So
a record is MERGED by instant: a later read updates the hours it carries and
leaves the rest, and a revised price for an hour already held wins because
it is the newer statement about the same hour.
⚠️ KEYED BY INSTANT, NOT BY LOCAL HOUR, AND THAT IS THE DST DECISION. Storing
`hour -> price` would collapse the two 02:00s of a 25-hour October day into
one, inventing a second and different collapsing rule beside the one
`fill_from_yesterday()` already documents ("the first of its two 02:00 points
is the one used, deterministically"). Storing the instants keeps both, in
order, and lets the fill's own rule be the only one in the system.
WHAT THIS DOES NOT DO, ON PURPOSE
=================================
It does not extend the fill to a feed that published NOTHING. `resolve_tariff()`
applies `carry()` only on a `series` read; a `no_data` read still falls to
`_degrade()` and §11.4's row - "No price data -> fall back to the static
profile, mark the plan provisional, no arbitrage" - which is the fallback the
operator configured `static_profile` FOR. Preferring yesterday's feed over it
would change a documented degradation without being asked, and it would buy
nothing measurable: a fully-carried series is 100 % provisional, so
`arbitrage()` finds fewer than two firm hours and refuses, and `cheap_now()` is
False, exactly as on the §11.4 fallback. The two differ only in which numbers
the status page shows. `fill_from_yesterday()` handles a `no_data` read anyway,
and that branch is therefore not reached from production - it stays checked,
and turning it on is a product decision, not a wiring one. See
DECISIONS/FCST-04-the-day-that-is-carried.md.
"""
import copy
import json
import logging
import os
import tempfile
from datetime import datetime, timedelta, timezone
from .price import PricePoint, PriceProduct, PriceRead, brussels
from .price_forecast import fill_from_yesterday
_LOG = logging.getLogger("goodwe.price.history")
# How many Brussels days of published prices the SORT-ORDER cap keeps. The fill
# needs exactly one - yesterday - and rule 2 refuses anything older, so this is
# not a horizon: it is the number of days a record may sit unused before
# pruning removes it, and it is small because a bigger cache would only hold
# days no lookup can ask for. Three covers today, yesterday, and the day a
# fetch spanning midnight writes ahead of itself.
#
# ⚠️ IT IS A CAP, NOT THE RETENTION RULE, AND THREE HAS NO SLACK. The steady
# state is already {yesterday, today, tomorrow}; a feed that publishes to D+2,
# or ONE stray point in the wrong year - which sorts LAST and is therefore
# immortal under a newest-N cap - pushes yesterday out of a three-slot store
# and keeps it out, permanently and silently. So `_prune()` PINS the two days a
# lookup can actually ask for on top of the cap; see its docstring. The store
# is then bounded at KEEP_DAYS + 2 records rather than at KEEP_DAYS.
KEEP_DAYS = 3
SCHEMA = 1
def _bdate(instant: datetime) -> str:
"""The Brussels calendar date of an instant, as `YYYY-MM-DD`.
⚠️ BRUSSELS, NOT UTC. 23:30 UTC in July is already tomorrow in Belgium, and
a cache keyed on the UTC date would file the last 90-120 minutes of every
day under the wrong one - so a lookup on the following morning would find a
"yesterday" missing its own evening hours, which are the expensive ones.
"""
return brussels(instant).strftime("%Y-%m-%d")
def _yesterday_of(instant: datetime) -> str:
"""The Brussels date of the day BEFORE `instant`'s Brussels day.
⚠️ THE ARITHMETIC IS ON THE LOCAL DATE, NOT ON THE INSTANT, AND THAT IS A
DST DEFECT THIS FILE SHIPPED. `brussels()` renders at the FIXED offset in
force at that instant, so `brussels(stamp) - timedelta(days=1)` subtracts 24
ABSOLUTE hours from a wall clock - and the Brussels day before a
spring-forward is 23 hours long. Measured at 2026-03-30T00:30+02:00, the
first hour of the morning after the March transition: it returned
2026-03-28, so the fill carried a TWO-DAY-OLD profile flagged as yesterday's
(or, with only the last three days held, answered `no_data` naming a date
that is not yesterday).
A calendar day minus one day is `date` arithmetic, and a `date` has no
offset to lose. The 25-hour October day is right for the same reason.
"""
return (brussels(instant).date() - timedelta(days=1)).strftime("%Y-%m-%d")
def _point_of(row):
"""One stored row -> a `PricePoint`, or `None` if the row is not one.
THE ONE VALIDATOR, AND THE ASYMMETRY IT CLOSES (FCST-05's MEDIUM). This
file used to hold TWO standards for what a stored point is. The READ path
(`_parse_points()`) was properly defensive - every field checked, a
malformed row dropped and reported. The WRITE path was
`{q["start"]: q for q in raw if isinstance(q, dict) and "start" in q}`
followed by `sorted(..., key=lambda q: q["start"])`, which trusts
`q["start"]` to be a hashable, sortable string it never checked. Measured,
with the malformed row under the day the feed is currently writing to:
start is an int -> TypeError: '<' not supported between instances
of 'str' and 'int'
start is a dict -> TypeError: cannot use 'dict' as a dict key
start is null -> TypeError: '<' not supported between 'str' and
'NoneType'
start is a list -> TypeError: cannot use 'list' as a dict key
⚠️ AND THE BLAST RADIUS IS PERMANENT, WHICH IS WHY IT IS NOT MERELY UNTIDY.
`_remember()` is reached through `carry()` -> `resolve_tariff()` ->
`run_tariff_refresh`, which catches `Exception` - so the add-on stays UP
and EVERY refresh raises forever. The plan retires after
`TARIFF_MAX_AGE_S`, and arbitrage and the cheap window are off from then
on. There is no self-heal, because `_remember()` raises before it reaches
`_save()`. `_usable_days()` only required `rec["points"]` to be a LIST; the
elements were unchecked on the write path, and the existing fixture put its
malformed row under a day the feed was NOT writing to, so `_remember()`
never touched it.
So both paths call this, and neither of them has a standard of its own.
Returning `None` rather than raising is the contract: a caller decides
whether a bad row is dropped (both of them do) and how it is reported.
"""
if not isinstance(row, dict):
return None
try:
start = datetime.fromisoformat(row["start"])
value = float(row["eur_per_kwh"])
flag = row["provisional"]
except (KeyError, TypeError, ValueError):
return None
if start.tzinfo is None or start.utcoffset() is None \
or not isinstance(flag, bool):
return None
return PricePoint(start=start, eur_per_kwh=value, provisional=flag)
def _held(rec) -> tuple:
"""`(points, whole hours they cover)` for a day record. `(0, 0)` otherwise.
⚠️ POINTS AND HOURS ARE TWO NUMBERS, AND FCST-04's STATUS ROW PRINTED THE
FIRST UNDER THE SECOND'S NAME. The row was `f"{d} ({_n_points(rec)} h)"`,
which counts POINTS: on the quarter-hourly feed this add-on already
supports it read `'2026-01-13 (96 h)'` - 96 "h" for a 24-hour day. That is
the points/hours conflation H1 was raised for, on the row H1's own ticket
shipped. Both numbers are reported now, so an operator can see a day that
is 24 points over 24 h and a day that is 96 points over 24 h and tell them
apart, and neither number is wearing the other's unit.
⚠️ TOLERANT, AND IT IS THE SECOND LINE OF DEFENCE, NOT THE FIRST. `_load()`
drops a malformed day record and says so, so this should never see one. It
is written this way because `words` is read from `Controller.__init__` and
from every status render: a status LINE that raises takes the add-on down,
and "the cache is broken" must never be able to mean "the controller does
not start". That is exactly what FCST-04 shipped - `{"days": {"2026-01-14":
"oops"}}` gave `ok=True`, `last_error=None`, and an `AttributeError` out of
`words`, `yesterday()` and `resolve_tariff()` alike, with no self-heal
because `_remember()` raised before it could rewrite the file.
⚠️ AND IT COUNTS ONLY ROWS THAT ARE POINTS. A row `_point_of()` refuses is
not held prices, and a row count that included it would report a store as
healthier than it is - which is the second half of FCST-05's MEDIUM: the
status row said `'2026-01-15 (1 h)'` about a record whose single row was
killing every tariff refresh. What is dropped is named in `last_error`, and
`words` prints that beside the counts.
"""
if not isinstance(rec, dict):
return (0, 0)
raw = rec.get("points")
if not isinstance(raw, (list, tuple)):
return (0, 0)
n, hours = 0, set()
for row in raw:
pt = _point_of(row)
if pt is None:
continue
n += 1
hours.add(pt.start.astimezone(timezone.utc)
.replace(minute=0, second=0, microsecond=0))
return (n, len(hours))
def _row_of(day: str, points: int, hours: int) -> str:
"""One day's cell of the status row: what is held, in BOTH of its units.
⚠️ THE UNIT IS SPELT OUT ON EACH NUMBER. FCST-04's cell was
`f"{day} ({points} h)"` - the point count wearing the hour's name, which on
a quarter-hourly feed reads `'2026-01-13 (96 h)'` for a 24-hour day. What
an operator needs from this row is whether tomorrow morning's fill has a
whole day to carry, and that is the HOUR figure; the point count says how
finely it is priced. Neither answers for the other.
"""
return (f"{day} ({points} point{'' if points == 1 else 's'} "
f"over {hours} h)")
def _bad_rows(rec) -> int:
"""How many rows of a day record are not points. Total; never raises."""
if not isinstance(rec, dict):
return 0
raw = rec.get("points")
if not isinstance(raw, (list, tuple)):
return 0
return sum(1 for row in raw if _point_of(row) is None)
class PriceHistory:
"""The published price series of the last few Brussels days, in /data.
⚠️ `ok` IS ABOUT THE FILE, NOT ABOUT THE DATA. A healthy store with no
record for yesterday is `ok` and answers `no_data` - a normal state on a
site's first day, and a different thing from a store that could not be
opened. Both are visible: `ok`/`last_error` describe the file, and
`yesterday()`'s reason describes the answer.
⚠️ AND `ok` IS NOT "CAN PERSIST" (FCST-06). It says the directory was
there when the store opened, which a full volume and a read-only remount
both leave true. The one thing that answers "can this store persist" is
`_save()`'s return value, and `self._days` NEVER HOLDS A POINT THE FILE DOES
NOT, because `_remember()` undoes a merge that answer refused. That is what
makes `words` and `yesterday()` describe the same store in EVERY degraded
state rather than only in the ones somebody enumerated.
"""
def __init__(self, path: str = "/data/price_history.json"):
self.path = path
self.ok = False
self.last_error: str | None = None
self._days: dict = {}
self._open()
# -- the file ------------------------------------------------------------
def _open(self) -> None:
parent = os.path.dirname(self.path)
# See the module note: NOT created. A cache on the ephemeral layer is
# one that forgets without saying so.
if parent and not os.path.isdir(parent):
self.last_error = f"no durable volume at {parent}"
# INFO, not ERROR: on a dev host or in a test container there is no
# /data and nothing is wrong.
_LOG.info("no price history: %s - §8.3 carry-forward has nothing "
"to carry and the horizon stays as short as the feed",
self.last_error)
return
self.ok = True
self._load()
def _load(self) -> None:
try:
with open(self.path, "r", encoding="utf-8") as fh:
blob = json.load(fh)
except FileNotFoundError:
_LOG.info("no price history yet at %s - the first refresh writes "
"one, and the fill has nothing to carry until the day "
"after that", self.path)
return
except (ValueError, OSError) as err:
# A corrupt cache must not stop the controller, and must not be
# silently re-created either: the record is discarded, the reason
# is kept, and `yesterday()` reports it rather than answering
# "no record", which would read as a normal first day.
#
# ⚠️ `ValueError`, NOT `json.JSONDecodeError`, AND THAT IS
# FCST-05's HIGH. Reading this file has two failure families: the
# BYTES (`OSError`) and the CONTENT - and the content family has at
# least two members, because `open(..., encoding="utf-8")` decodes
# before `json` ever sees a character. `UnicodeDecodeError` is
# `ValueError -> UnicodeError -> UnicodeDecodeError`; it is NOT a
# `json.JSONDecodeError` and it is NOT an `OSError`
# (`issubclass(UnicodeDecodeError, OSError)` is False). So ONE
# non-UTF-8 byte in /data/price_history.json escaped `_load()` ->
# `_open()` -> `PriceHistory.__init__`, which is unguarded at
# main.py's `Controller.__init__`, AND THE ADD-ON DID NOT START.
# Measured: `UnicodeDecodeError: 'utf-8' codec can't decode byte
# 0xff in position 39`. Valid-UTF-8-invalid-JSON, a UTF-8 BOM and a
# non-dict day record all degraded correctly - it was the one byte
# class left uncovered, which is the same class as the defect the
# `_usable_days()` rework was raised for.
#
# The tuple names the FAMILY rather than the two members known
# today, because "the file's content is not readable as JSON" is
# exactly what this handler says, and a third `ValueError` subclass
# nobody has thought of is how this defect arrived the first time.
self.last_error = f"unreadable ({err})"
_LOG.warning("price history at %s is %s - starting from empty. "
"§8.3 carry-forward has nothing to carry until the "
"next refresh has written a full day",
self.path, self.last_error)
return
found = blob.get("version") if isinstance(blob, dict) else None
# ⚠️ THE TYPE IS PART OF THE VERSION. `1.0 == 1` in Python, so a float
# written by some other tool would pass a bare `!=` and be read as
# schema 1 - a version check that cannot tell 1 from 1.0 is one that
# would not notice a writer this build has never met.
if not isinstance(blob, dict) or isinstance(found, bool) \
or not isinstance(found, int) or found != SCHEMA:
self.last_error = f"schema {found!r}, not {SCHEMA}"
_LOG.warning("price history at %s has %s - discarding it rather "
"than guessing what its points mean",
self.path, self.last_error)
return
days = blob.get("days")
if not isinstance(days, dict):
self.last_error = "no `days` object"
_LOG.warning("price history at %s has %s", self.path, self.last_error)
return
self._days = self._usable_days(days)
def _usable_days(self, days: dict) -> dict:
"""The day records that are shaped like day records. The rest are dropped.
⚠️ THE ASYMMETRY THAT PRODUCED THE DEFECT: `_parse_points()` below is
carefully defensive about every field of a POINT, and `_load()` was not
defensive about the record holding them at all. `{"version": 1, "days":
{"2026-01-14": "oops"}}` therefore loaded as `ok=True`,
`last_error=None` - and then `rec.get(...)` raised `AttributeError` out
of `words`, `yesterday()` and `resolve_tariff()`. `PriceHistory(...)`
and `.words` are both unguarded in `Controller.__init__`, so THE ADD-ON
DID NOT START; and it never self-healed, because `_remember()` raised on
the same record before it could reach `_save()`.
A broken cache is a short horizon. It is not a controller that will not
regulate a battery, and §6's rule - absent machinery must not disable
the loop - does not stop applying because the machinery is a JSON file.
Dropped, not repaired: an unusable record has nothing in it to keep, and
the next `_remember()` writes a fresh one under that date.
⚠️ AND THE ROWS INSIDE A KEPT RECORD ARE COUNTED, NOT ONLY THE RECORD
(FCST-05). This checked that `rec["points"]` is a LIST and stopped
there, so a record holding one row with a `start` of `7` loaded as
healthy: `last_error=None`, and `words` said `'2026-01-15 (1 h)'` about
a store whose next `_remember()` would raise on that row and go on
raising forever. The rows are NOT dropped here - `_parse_points()` is
the read path's one filter and stays so - but they are COUNTED, so the
store stops reporting health it does not have from the moment it is
opened. `_remember()` drops them on the next refresh and `_save()`
rewrites the file, after which a restart clears the note.
"""
kept, dropped, malformed = {}, [], {}
for key, rec in days.items():
if not isinstance(key, str) or not isinstance(rec, dict) \
or not isinstance(rec.get("points"), list):
dropped.append(str(key))
continue
bad = _bad_rows(rec)
if bad:
malformed[key] = bad
kept[key] = rec
notes = []
if dropped:
notes.append(f"{len(dropped)} unusable day record(s) dropped "
f"({', '.join(sorted(dropped))})")
if malformed:
notes.append(
f"{sum(malformed.values())} malformed point row(s) in "
+ ", ".join(f"{d} ({n})" for d, n in sorted(malformed.items())))
if notes:
# Recorded, not silent: `yesterday()` and `words` both report
# `last_error`, and a cache that quietly discards half a file reads
# to an operator exactly like a normal first day.
self.last_error = "; ".join(notes)
_LOG.warning("price history at %s: %s - the controller starts "
"anyway and the horizon is as short as the feed makes "
"it", self.path, self.last_error)
return kept
def _save(self) -> bool:
"""Write `self._days` out. True only if the file now holds it.
⚠️ THE RETURN VALUE IS THE "CAN THIS STORE PERSIST?" PREDICATE, AND
IT IS THE ONLY ONE (FCST-06). Both ways of not persisting end here and
are the same answer: no durable volume returns False without touching
the disk, an `OSError` out of the write returns False after recording
why. `_remember()` acts on that one answer, so the second place that
used to test the first symptom on its own - and missed the second - has
nothing left to test. See the module docstring's fifth warning.
⚠️ AND IT REPORTS, IT DOES NOT RAISE. §6 and this module's opening: a
broken cache must not be a broken controller. An add-on that will not
start because /data filled up is a worse defect than the one FCST-06
fixed, so a failed write is logged, kept in `last_error` - where `words`
and `yesterday()` both read it - and RETURNED, never thrown.
⚠️ THE TEMP FILE IS REMOVED ON EVERY FAILING PATH. A write that dies
between `mkstemp` and `os.replace` used to leave its temp behind, and
the refresh task runs 48 times a day: a persistent write failure - a
full volume being the likeliest cause - leaked 48 inodes a day onto the
volume that was already the problem.
"""
if not self.ok:
return False
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path))
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump({"version": SCHEMA, "days": self._days}, fh, indent=1)
# Atomic: a power cut mid-write must not leave a truncated file
# that parses as an empty day.
os.replace(tmp, self.path)
tmp = None # it IS the file now; do not unlink
except OSError as err:
self.last_error = f"could not write ({err})"
_LOG.error("could not persist price history: %s", err)
return False
finally:
if tmp is not None:
try:
os.unlink(tmp)
except OSError: # nothing further to try, and a
pass # failed cleanup is not a fault
return True
# -- the one public entry point -----------------------------------------
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
stamp: datetime) -> PriceRead:
"""Remember what this read published, then §8.3-fill the missing hours.
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
recorded FIRST, so a feed that hands back yesterday alongside today
refreshes yesterday's record from live data before it is consumed as
the profile; and the filled series - the one thing that must never be
recorded - exists only as this method's return value, which this object
never sees again. See rule 1, including how that order is graded.
Returns whatever `fill_from_yesterday()` returns: a series over the
whole hours of [since, until), carrying EVERY point the provider
published inside it - by identity, and four of them in one hour on a
quarter-hourly feed - plus one carried point for each remaining whole
hour FOR WHICH YESTERDAY HELD THAT WALL-CLOCK HOUR. An hour neither
source can price is left out (§11.4 for that hour), and `no_data` comes
back when that is every hour.
"""
self._remember(read, stamp)
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
since=since, until=until, stamp=stamp)
# -- the halves, private because rule 1 is an ordering ------------------
def _remember(self, read: PriceRead, stamp: datetime) -> None:
"""File a provider read's points under their own Brussels days.
⚠️ PRIVATE, AND CALLED FROM EXACTLY ONE PLACE. A caller that can reach
this can record a filled series, which is the decay in rule 1. There is
no value-level defence against that - a carried point and a published
provisional point are the same three fields - so the defence is that
nothing outside this file names it, and `test_price_history.py` checks
the tree for that rather than trusting the underscore.
"""
points = read.match(series=lambda r: r.points, no_data=lambda _r: ())
if not points:
return
product = read.match(series=lambda r: r.product.value,
no_data=lambda _r: None)
provider = read.match(series=lambda r: r.provider,
no_data=lambda _r: None)
# ⚠️ THE STATE THIS READ STARTED FROM, KEPT SO IT CAN BE PUT BACK
# (FCST-06). Everything below merges into `self._days` IN PLACE, and
# whether any of it may be KEPT is not knowable until `_save()` has
# tried - so the merge runs, and it is undone if the file did not take
# it. There is no `not self.ok` branch above any more: a store with no
# durable volume is one of the answers `_save()` gives, not a separate
# symptom with a test of its own. Module docstring, fifth warning.
#
# ⚠️ DEEP, NOT `dict(self._days)`. A one-level copy shares the day
# RECORDS, and the loop below rebinds keys on the ones it touches - so
# the "previous" state would hand back records the merge had already
# edited, and a store with a good yesterday on disk would go on
# answering the merge of it that never reached the file. It would also
# be right only for as long as nobody makes this loop mutate a `points`
# list or a row in place. A copy that is right by construction outlives
# that, and the store holds at most KEEP_DAYS + 2 records, 48 times a
# day.
before = copy.deepcopy(self._days)
touched = set()
junked = {}
for p in points:
day = _bdate(p.start)
rec = self._days.setdefault(
day, {"product": product, "provider": provider, "points": []})
# Rule 4: MERGE by instant. A later read updates the hours it
# carries and leaves the rest; a revised price for an hour already
# held wins, because it is the newer statement about that hour.
#
# ⚠️ EVERY HELD ROW GOES THROUGH `_point_of()` (FCST-05's MEDIUM).
# This was `{q["start"]: q for q in raw if isinstance(q, dict) and
# "start" in q}` sorted by `q["start"]` - a key and a sort key that
# trusted a value read off disk to be a hashable, sortable string.
# An `int`, `null`, a `dict` or a `list` there raised `TypeError`
# out of `carry()` on the day the feed was writing to, and
# `run_tariff_refresh` caught it, so the add-on stayed up while
# EVERY refresh failed for good. A row that is not a point is now
# dropped and reported here, exactly as the read path drops it, and
# `_save()` below writes the record back clean - which is the
# self-heal the raising version could never reach.
#
# ⚠️ AND THE MERGE KEY IS THE VALIDATED INSTANT, NOT ITS SPELLING.
# Aware datetimes hash and compare by instant, so this is rule 4 as
# rule 4 is written. The old string key merged by SPELLING, and the
# old lexicographic sort put the second 02:00 of the 25-hour
# October day (`+01:00`) BEFORE the first (`+02:00`). Only the
# stored order was affected - `_parse_points()` re-sorts by instant
# on the way out - but the store now holds what it says it holds.
raw = rec.get("points")
held = {}
for q in (raw if isinstance(raw, list) else ()):
kept = _point_of(q)
if kept is None:
junked[day] = junked.get(day, 0) + 1
continue
held[kept.start] = q
held[p.start] = p.canonical()
order = sorted(held, key=lambda t: t.astimezone(timezone.utc))
rec["points"] = [held[t] for t in order]
rec["product"], rec["provider"] = product, provider
touched.add(day)
if junked:
# Said, not swallowed. `words` prints `last_error`, so a store that
# has just discarded rows off disk stops reporting plain health -
# and after `_save()` below the file is clean, so a restart clears
# this. `_save()` overwrites it if the write itself fails, which is
# the more urgent fact of the two - and on that path the drop is
# undone with the rest of the merge, so those rows are still in the
# file and the next write that completes drops them again.
self.last_error = (
f"{sum(junked.values())} malformed point row(s) dropped from "
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
_LOG.warning("price history at %s: %s - the record is rewritten "
"without them and the refresh continues",
self.path, self.last_error)
self._prune(stamp)
if not self._save():
# ⚠️ A STORE THAT CANNOT PERSIST RECORDS NOTHING - the rule, asked
# ONCE as a question about the outcome instead of tested twice
# against two symptoms of it. Keeping the merge here is exactly the
# cache the module docstring refuses: `yesterday()` would answer a
# series the file does not contain, all day, until a restart
# dropped it without a word - and `words` and `yesterday()` would
# be describing two different stores. The reason is already in
# `last_error` (from `_save()`, or from `_open()` when there is no
# volume at all), so both answers now describe what is on disk.
#
# It does NOT raise, and §6 is why: a broken cache must not be a
# broken controller. `carry()` still returns the fill, the horizon
# is as short as the feed makes it, and the add-on regulates.
self._days = before
return
_LOG.debug("price history: %d point(s) filed under %s; %d day(s) held",
len(points), ", ".join(sorted(touched)), len(self._days))
def _prune(self, stamp: datetime) -> None:
"""Drop records no lookup can reach. A sort-order cap, plus two pins.
⚠️ NOTHING IS DELETED BY ARITHMETIC AGAINST THE CLOCK. A clock that
jumped - the failure mode WITNESS-03 spent a session on - would
otherwise delete every record in one pass and leave the fill with
nothing on the morning after, which is the one morning it is for.
Deleting on a bad clock is unrecoverable; holding a few days too many
is not, so the cap is `sorted(...)[-KEEP_DAYS:]` and stays that way.
⚠️ AND A CAP ALONE IS DEFEATED BY ONE POINT IN THE WRONG YEAR. Sorting
is lexicographic on the date string, so a stray far-future record sorts
LAST and is immortal - it consumes a slot for good, and with the steady
state already {yesterday, today, tomorrow} filling all three, yesterday
is what falls out. A feed that publishes to D+2 does the same thing
without any stray at all. So the two days `yesterday()` can ACTUALLY be
asked for are pinned on top of the cap.
`stamp` is READ here, which it was not before: the pin is the only use
of it, and it is additive. Pinning can only RETAIN a record, never
delete one, so a jumped `stamp` pins two dates that are not held and
changes nothing - the WITNESS-03 property is preserved by construction
rather than by remembering to check it.
"""
if len(self._days) <= KEEP_DAYS:
return
keep = set(sorted(self._days)[-KEEP_DAYS:])
keep |= {_bdate(stamp), _yesterday_of(stamp)}
for day in [d for d in self._days if d not in keep]:
del self._days[day]
def yesterday(self, stamp: datetime) -> PriceRead:
"""The published series of the Brussels day BEFORE `stamp`'s, or no_data.
⚠️ THE DATE IS COMPUTED, NOT LOOKED UP. See rule 2: `newest()` would
answer Friday's prices to a Monday fill, and the fill would flag them as
one day old because that is the only thing its flag can say.
"""
if stamp.tzinfo is None or stamp.utcoffset() is None:
raise ValueError(
f"yesterday({stamp!r}): a naive datetime is not an instant, "
f"and 'which Brussels day was yesterday' is exactly the "
f"question a wall-clock reading cannot answer.")
want = _yesterday_of(stamp)
if self.last_error and not self._days:
return PriceRead.no_data(
f"the price history at {self.path} is {self.last_error}, so "
f"§8.3 has no profile to carry forward for {want}; the plan "
f"covers only the hours the feed published")
rec = self._days.get(want)
if rec is None:
have = ", ".join(sorted(self._days)) or "no days at all"
return PriceRead.no_data(
f"no published prices recorded for {want} (held: {have}), so "
f"§8.3 has no profile to carry forward. A record for another "
f"day is not a yesterday")
points, bad = _parse_points(rec.get("points"))
if not points:
why = bad or "it is empty"
return PriceRead.no_data(
f"the {want} record holds no usable point ({why}), and an "
f"empty series is not a free day")
try:
product = PriceProduct(rec.get("product"))
except ValueError:
return PriceRead.no_data(
f"the {want} record names product {rec.get('product')!r}, "
f"which is not a PriceProduct. The optimiser switches on that "
f"enum, so a record that cannot name one cannot be a series")
if bad:
_LOG.warning("price history: %s in the %s record were dropped; "
"%d point(s) carried forward", bad, want, len(points))
return PriceRead.series(points, product,
str(rec.get("provider") or "price_history"),
stamp)
# -- observability -------------------------------------------------------
@property
def words(self) -> str:
"""One line for the status page. Says what is held, or why nothing is.
⚠️ THIS MUST NOT RAISE, EVER. It is read once from `Controller.__init__`
and again on every status render, and neither call site guards it - so
an exception here is an add-on that does not start. It went out that
way: a day record that was a string instead of a dict raised
`AttributeError` from the `len(...)` below. `_load()` now drops such a
record and `_held()` is total, so this has two independent reasons not
to fail on a broken file.
⚠️ AND IT COUNTED POINTS AND CALLED THEM HOURS (FCST-05's LOW). The row
was `f"{d} ({_n_points(rec)} h)"`; on the quarter-hourly feed this
add-on already supports that reads `'2026-01-13 (96 h)'` for a 24-hour
day. Both numbers are printed now - see `_held()` - because they are
two different facts and only one of them was ever being shown.
"""
if not self.ok:
return f"unavailable - {self.last_error}"
if not self._days:
why = f" - {self.last_error}" if self.last_error else ""
return f"empty{why} - nothing to carry forward yet"
held = "; ".join(_row_of(d, *_held(self._days[d]))
for d in sorted(self._days, key=str))
# A file that PARTLY loaded says so, or the dropped days read as days
# that were simply never written.
return held + (f" [{self.last_error}]" if self.last_error else "")
def _parse_points(raw) -> tuple:
"""Canonical dicts -> PricePoints, and a phrase naming what was dropped.
⚠️ A MALFORMED POINT IS DROPPED, NOT DEFAULTED, and the drop is reported.
`float(row.get("eur_per_kwh", 0))` is the shape of the whole family of
defects this repo has been removing: a cache that lost a field would answer
"that hour was free" to a fill that flags it as an ordinary guess.
"""
if not isinstance(raw, list):
return (), "the `points` field is not a list"
out, dropped = [], 0
for row in raw:
# ⚠️ `_point_of()` IS THE ONE VALIDATOR, and this loop used to BE it.
# The write path had its own, weaker standard; see `_point_of()` for
# what that cost. One standard, two callers, no asymmetry to re-open.
pt = _point_of(row)
if pt is None:
dropped += 1
continue
out.append(pt)
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
class NoHistory:
"""The explicit "this caller keeps no history" - a NAME, not a `None`.
⚠️ IT EXISTS SO THE PARAMETER CAN BE REQUIRED. `history=None` meaning "skip
the fill" is the shape this repo keeps removing: a caller who forgets the
argument gets the truncated-horizon behaviour of the bug this ticket is
about, silently, and the code reads as though the fill is happening. With
no default there is nothing to forget, and a caller with no durable volume
- a check, a replay harness - has to say `NoHistory()` and be visible in
the diff.
It carries out the ordering faithfully: it records nothing and carries
nothing, so `carry()` returns the provider's read unchanged and the plan is
exactly what it was before FCST-04.
"""
ok = False
last_error = "no history is kept by this caller"
words = "not kept"
def carry(self, read: PriceRead, *, since, until, stamp) -> PriceRead:
return read
def yesterday(self, stamp: datetime) -> PriceRead:
return PriceRead.no_data(
"this caller keeps no price history (NoHistory), so §8.3 has no "
"profile to carry forward")
+49 -2
View File
@@ -1019,7 +1019,7 @@ def static_status(opts: dict, now: datetime):
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
transport=None, now=None) -> TariffPlan:
transport=None, now=None, *, history) -> TariffPlan:
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
@@ -1033,9 +1033,42 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
price_provider off + profile -> the CONFIGURED product (§9.2 rows 3-4)
price_provider off + none -> no prices at all (§11.4, nothing to
fall back to)
provider series -> the feed (§9.2 rows 1-2)
provider series -> the feed, §8.3-FILLED (§9.2 rows 1-2)
provider no_data + profile -> §11.4 FALLBACK, provisional, no arbitrage
provider no_data + none -> no prices at all
⚠️ `history` IS REQUIRED, KEYWORD-ONLY, AND HAS NO DEFAULT (FCST-04). It is
the §8.3 carry-forward store - `price_history.PriceHistory`, or the explicit
`price_history.NoHistory()` for a caller that keeps none. A default of
`None` meaning "skip the fill" would give a caller who forgot the argument
the TRUNCATED HORIZON this ticket exists to remove, silently, while the code
read as though the fill were happening. There is nothing to forget now.
⚠️ AND THE FILL RUNS ONLY ON A `series` READ. A feed that published nothing
still falls to `_degrade()` and §11.4's own row - the static profile the
operator configured FOR that case - rather than to yesterday's feed. The two
are indistinguishable to the optimiser (both are 100 % provisional, so
`arbitrage()` finds fewer than two firm hours and refuses, and `cheap_now()`
is False), so preferring yesterday would change a documented degradation for
a difference only the status page can see. See price_history.py's closing
note and DECISIONS/FCST-04-the-day-that-is-carried.md.
⚠️ THE FILL IS PER WHOLE HOUR AND ITS COVERAGE IS CONDITIONAL. Every point
the provider published inside the window passes through BY IDENTITY - four
of them in one hour on a quarter-hourly product like `eneco_eddy` - and a
carried point is added only for a whole hour the feed did not print at all
AND yesterday did. An hour neither source can price is left out, which is
§11.4 for that hour: `plan.price_at()` raises rather than answering, and an
hour with no price is not a free one.
⚠️ THE FILLED SERIES IS WINDOWED TO [since, until), AND THAT IS A CHANGE.
`load_prices()` returns whatever the feed sent, past hours included, and
`TariffPlan.arbitrage()` took its spread over all of it - so an hour that
had already gone could be the cheapest hour of a cycle nothing can now buy
in. `fill_from_yesterday()` walks the requested horizon and emits exactly
that, so the spread is over hours that are still ahead. Both directions of
the difference are real: the window gains the unpublished hours it was
missing and loses the elapsed ones it should never have had.
"""
stamp = now or datetime.now(timezone.utc)
pid = str(price_option(opts, "price_provider") or "off").strip()
@@ -1044,6 +1077,20 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
read = await load_prices(opts, since, until, transport)
return read.match(
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
"""§8.3: record what the feed published, carry yesterday into the gaps.
The fill can itself return `no_data` - a feed whose points all fall outside
the horizon, with nothing in the history to fill it from - and that is the
§11.4 row arrived at one step later, so it degrades exactly as a failed
fetch does rather than being special-cased here.
"""
filled = history.carry(read, since=since, until=until, stamp=stamp)
return filled.match(
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
+40 -8
View File
@@ -253,7 +253,7 @@ class MigrationReport:
return out
def migrate_unit_keys(store, unit: str) -> MigrationReport:
def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
"""Move the pre-DEV-02 flat per-inverter keys into `unit`'s namespace.
`unit` is the FIRST inverter in the roster, which on every install that can
@@ -261,6 +261,24 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
version that knew about more than one inverter is already at
SCHEMA_VERSION and is not touched.
⚠️ `roster` IS THE NAMES THE MACHINES READ, AND IT IS SAFETY-44. This
function shipped WITHOUT it, taking a hardcoded `inverter.UNIT_A` at its one
call site - which is, exactly, the MAINT-05 defect `_verify_moves()` spends
a paragraph describing, still live in the sibling that paragraph was written
beside. On a store that ran pre-DEV-02 flat and declares
`inverters: [garage, shed]`, the latch moved to `unit/inverter-a/
sign_tripped`, was read back under that same name, was SATISFIED, and
stamped the marker - so the next start skipped the migration too and NO
inverter came up latched on an install whose state file says `sign_tripped:
true`. A disarmed SAFETY-02 guard, every symptom of a clean migration.
`roster_names(opts)` answers this BEFORE any `Inverter` exists - it is
defined to, for `MqttPublisher`, which is constructed before the roster for
the same reason - so the "there is no roster yet" that kept this argument
out was never true. On the flat shape it returns `[UNIT_A]`, so the upgrade
every install runs today passes `unit == roster[0] == UNIT_A` and is
byte-identical.
THE THREE RULES, each one a case that would otherwise be a wrong answer:
1. IT NEVER OVERWRITES AN EXISTING NAMESPACED VALUE. A store that has both
@@ -302,7 +320,7 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
rep.ran = True
_move_flat_keys(store, unit, LEGACY_UNIT_KEYS, rep)
_verify_moves(store, rep)
_verify_moves(store, rep, roster)
if rep.ok:
store.set(SCHEMA_KEY, SCHEMA_VERSION)
@@ -383,12 +401,26 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
itself. `test_maintenance.py` section 9c calls this twice with the SAME
wrong `unit` over one store, which is the shape nothing in the suite had.
`roster=None` means "not graded", and the one caller that passes it is
`migrate_unit_keys()`, which runs BEFORE `build_roster()` for the reason its
own docstring gives - the `Inverter` objects restore their latch in their
constructors, so the keys have to be in place before there is a roster to
check against. That gap is real and is recorded as a running check rather
than left to a comment: see test_maintenance.py section 13.
`roster=None` still means "not graded", and SAFETY-44 is what it cost. This
paragraph used to end here, saying that the one caller passing it was
`migrate_unit_keys()`, "which runs BEFORE `build_roster()` ... so the keys
have to be in place before there is a roster to check against", and calling
that gap real. THE FIRST HALF IS TRUE AND THE CONCLUSION IS NOT. The keys do
have to be written before the `Inverter`s restore from them - but the NAMES
do not come from the `Inverter`s. `roster_names(opts)` reads them straight
off the options, before anything is constructed, which is the whole reason
it exists (`MqttPublisher` is built before the roster too). So the argument
that could have graded that write was available at the call site the entire
time, and the sibling function three screens down had already been fixed by
passing it.
⚠️ SO NO CALLER PASSES `None` TODAY, and this branch is kept for one reason:
it is what a NEW caller gets by DEFAULT. A future migration that forgets the
roster gets the ungraded read-back and stamps its marker over a namespace no
machine reads - silently, exactly as this one did. That is the shape of both
defects, and the gap is recorded as a running check rather than left to a
comment: test_maintenance.py section 13, and test_rehearse.py part 5, which
drives it end-to-end through the harness an operator actually runs.
"""
for target, value in rep.moved.items():
got = getattr(store, "data", {}).get(target, "<<missing>>")
+49 -1
View File
@@ -570,7 +570,7 @@ def _roster_problems(opts: dict) -> list[str]:
rules up. A rule the Supervisor duplicates costs nothing and covers the
callers it does not.
Five things are refused, and each is a config that would otherwise RUN:
Seven things are refused, and each is a config that would otherwise RUN:
a. `inverters` is not a list. `roster_entries()` wraps a scalar so this
can name it; without the rule the roster would be one inverter built
@@ -593,6 +593,18 @@ def _roster_problems(opts: dict) -> list[str]:
config. Refused rather than ignored, for §1.5's reason and for this
card's: a config that silently becomes a different config is worse than
one that refuses to load.
f. OPT-09. An entry whose `capacity_kwh` is stated but is not a usable
positive number of kWh. It is read to weight the SPLIT by energy
headroom; a zero, a negative or a word is a pack the split would
quietly stop preferring while the operator can see a value in the box.
g. OPT-09. `capacity_kwh` stated on SOME entries and not others. The
weighting is a COMPARISON between packs, so a pack measured in kWh
against a sibling weighing a dimensionless 1.0 is not one -
`packshare.weights()` answers "no opinion" for the WHOLE roster in that
case, which means every key the operator filled in does nothing at all
and nothing says so. All or none, and this is clause (e)'s argument
restated: a config that silently becomes a different config is worse
than one that refuses to load.
"""
problems: list[str] = []
raw = opts.get("inverters")
@@ -651,6 +663,42 @@ def _roster_problems(opts: dict) -> list[str]:
"sibling is exactly what DEV-02 removed. State this unit's own "
"rating in watts.")
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
stated, blank = [], []
for i, entry in enumerate(entries):
if not isinstance(entry, dict):
continue # already refused above, by name
raw = entry.get("capacity_kwh")
nm = str(entry.get("name") or "").strip()
who = f"inverters[{i}] ({nm})" if nm else f"inverters[{i}]"
if raw is None or raw == "":
blank.append(who)
continue
try:
val = float(raw)
except (TypeError, ValueError):
val = None
if val is None or not (val > 0.0):
problems.append(
f"{who} has capacity_kwh {raw!r}, which is not a usable number "
"of kilowatt-hours. It is THIS pack's usable capacity and it is "
"read to weight the split of the site target by ENERGY headroom "
"rather than by power rating; a zero or an unparseable value is "
"a pack the split would quietly stop preferring while the box "
"still shows a value. State it in kWh, or leave it empty on "
"every entry.")
else:
stated.append(who)
if stated and blank:
problems.append(
f"capacity_kwh is stated on {', '.join(stated)} and left empty on "
f"{', '.join(blank)}. It is OPTIONAL but it is not PARTIAL: the "
"split weights one pack's energy headroom against another's, so a "
"pack measured in kWh beside a sibling that weighs a dimensionless "
"1.0 is not a comparison - the add-on answers 'no opinion' for the "
"whole roster and the value you filled in changes nothing. State a "
"capacity for every inverter, or for none of them.")
flat_sp = str(opts.get("setpoint_entity") or "").strip()
if entries and flat_sp:
problems.append(
+63 -13
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.8.0"
version: "0.9.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -108,6 +108,25 @@ options:
# EVERY entry and names the one it refuses. The flat key below is unchanged and
# is the single-inverter shape's own cut-out.
#
# ⚠️ OPT-09: `capacity_kwh` IS PER ENTRY AND IS OPTIONAL, WHICH IS THE
# OPPOSITE CHOICE TO `bms_soc_floor` ABOVE AND IS MADE FOR THE SAME REASON.
# It is THAT pack's usable capacity in kWh, and it is read for exactly one
# purpose: weighting the SPLIT of the site target between packs by ENERGY
# headroom rather than by power rating. The two packs here are asymmetric in
# OPPOSITE directions - 5000 W / 15.36 kWh against 4600 W / 20.48 kWh, from
# the datasheet and the serials - so a split by rating and a split by energy
# hand the work to DIFFERENT packs, and the energy one is the question ("which
# pack can sustain its share for the hour"). A missing cut-out would have to be
# DEFAULTED, and one default for two batteries protects the shallower pack with
# the deeper pack's number; a missing capacity has an honest answer instead -
# this add-on then has NO OPINION about the split and every unit weighs 1.0,
# which is exactly what shipped before this card. ⚠️ IT IS ALL OR NOTHING
# ACROSS THE ROSTER: state it on every entry or on none, because a pack
# weighted in kWh against a sibling weighing a dimensionless 1.0 is not a
# comparison. There is deliberately NO flat `capacity_kwh` - at one inverter
# there is no split to weight, so the key would be one an operator can set and
# nothing reads.
#
# ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a
# roster is declared is two config sites aimed at actuators, one of which
# nothing writes; if it names an entity an entry also names, SAFETY-28's
@@ -299,6 +318,14 @@ options:
# validate.py warns about exactly that.
static_profile: ""
price_csv_path: "/share/goodwe_prices.csv"
# ⚠️ AN OVERRIDE IS THE EXACT URL (TARIFF-09). The plugin sends it verbatim
# and appends nothing - including the `start`/`end` date range it derives from
# the horizon for its own feed - so an override gets whatever range that URL
# answers, which for a bare day-ahead endpoint is ONE Brussels day. Every hour
# past it is then carried from yesterday and flagged provisional, and §8.3
# keeps provisional hours out of the trade set. The plugin logs a WARNING
# naming that consequence on every fetch that uses an override; clearing this
# option is what restores the full horizon.
price_feed_url: ""
price_horizon_h: 48
price_timeout_s: 10
@@ -529,8 +556,10 @@ options:
# only ever ran at 500 could not tell this wiring from a hard-coded number.
#
# How long ONE inverter may stand at a discharge share it is not producing -
# short by more than `saturation_w` - while at least one sibling is NOT in
# that state, before the add-on names it. It REPORTS: nothing is commanded
# short by more than `saturation_w`, OR short by more than a fixed FRACTION
# of its own share (SAFETY-43; the fraction is derived and is not an option,
# see app/delivery.py) - while at least one sibling is NOT in that state,
# before the add-on names it. It REPORTS: nothing is commanded
# differently, the loop is untouched, and the site goes on running short by
# that unit's share until somebody looks. app/delivery.py carries why, with
# the rig measurements on both sides of the decision.
@@ -541,17 +570,31 @@ options:
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 2640 closed-loop runs
# at a roster of two in which nothing was faulty, over 6 gains x 5 slews - the
# whole range this schema accepts - across load reversals, a flapping load and
# ASYMMETRIC SPLITS, plus 1890 runs at a roster of ONE as the control. At the
# shipped threshold the worst CONTINUOUS legitimate episode is 30.0 s (the
# weighted family; 20.0 s on reversals, 0.0 s on flapping, and 0.0 s at every
# threshold in all 1890 one-inverter runs). 185 s is 6.2x that.
# ASYMMETRIC SPLITS, plus a roster-of-ONE arm as the control. At the shipped
# threshold the worst CONTINUOUS legitimate episode is 35.0 s (the weighted
# family; 30.0 s on reversals, 0.0 s on flapping, and 0.0 s at every threshold
# in every one-inverter run). 185 s is 5.29x that.
#
# ⚠️ AND THE MARGIN IS SIZED ON A MEASUREMENT, NOT ON A FEELING, BECAUSE
# INVERTER B DOES NOT PHYSICALLY EXIST. The rig's B is A copied. Sweeping a
# DIFFERENT second unit through that campaign: the worst legitimate episode is
# insensitive to B's ramp (30.0 s at half A's ramp) and scales with its LAG -
# 45.0 s at twice A's measured 10 s lag, 90.0 s at four times it. 185 s is
# 2.06x even the four-times-slower case.
# ⚠️ AND THE CAMPAIGN IS RE-RUNNABLE NOW, WHICH IT WAS NOT WHEN THIS NUMBER
# SHIPPED. TEST-21 (2026-09-07) committed the harness to the OTHER repository,
# `admin/goodwe:sim/delivery_window.py` - it imports plantsim and frame_check,
# which that repo owns. From clean checkouts of both, side by side:
#
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
# python3 -B delivery_window.py --selftest --campaign --blag
#
# ⚠️ THE ORIGINAL RECORD SAID 30.0 s AND IT DID NOT REPRODUCE - the re-run
# says 35.0 s. 185 s still clears all four bounds and is unchanged; what
# changed is the claim. DECISIONS/SAFETY-42-unit-delivery.md §3.3 carries both
# tables and what did and did not reproduce.
#
# ⚠️ AND THE MARGIN IS A SENSITIVITY STUDY ON A HYPOTHESIS, NOT A MEASUREMENT,
# BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). The rig's B is A
# COPIED. Sweeping a DIFFERENT second unit through that campaign: the worst
# legitimate episode barely moves with B's ramp (35.0 s at half A's ramp) and
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 75.0 s at four
# times it. 185 s is 2.47x even the four-times-slower case. NO NUMBER HERE IS
# A MEASUREMENT OF INVERTER B.
#
# DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this is not it -
# and not 150 or 200. 37 control cycles.
@@ -621,6 +664,12 @@ schema:
# enforced per entry, a missing required key names the key, a scalar where the
# list belongs is refused, and the UI renders it as a repeatable section.
#
# ⚠️ AND OPT-09's `capacity_kwh` IS DECLARED OPTIONAL (`?`), UNLIKE
# `bms_soc_floor`, WHICH IS THE ARGUMENT IN THE OPTIONS BLOCK ABOVE: an
# optional cut-out is a defaulted one and a default is one number for two
# batteries, whereas an absent capacity has an honest answer - no opinion
# about the split, every unit weighs 1.0, which is what shipped.
#
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
# no `name` has no Store namespace and would share another unit's SAFETY-02
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
@@ -642,6 +691,7 @@ schema:
soc_entity: str?
batt_entity: str?
batt_invert: bool?
capacity_kwh: float(0.1,200)?
estop_fitted: bool
@@ -0,0 +1 @@
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1757455200,1757458800,1757462400,1757466000,1757469600,1757473200,1757476800,1757480400,1757484000,1757487600,1757491200,1757494800,1757498400,1757502000,1757505600,1757509200,1757512800,1757516400,1757520000,1757523600,1757527200,1757530800,1757534400,1757538000],"price":[73.0,61.31,64.32,62.49,67.0,64.48,94.52,149.26,132.43,91.61,84.8,79.61,69.23,66.75,68.91,69.31,80.29,66.91,105.2,161.05,116.73,84.2,64.1,42.41],"unit":"EUR / MWh","deprecated":false}
@@ -0,0 +1 @@
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1788904800,1788905700,1788906600,1788907500,1788908400,1788909300,1788910200,1788911100,1788912000,1788912900,1788913800,1788914700,1788915600,1788916500,1788917400,1788918300,1788919200,1788920100,1788921000,1788921900,1788922800,1788923700,1788924600,1788925500,1788926400,1788927300,1788928200,1788929100,1788930000,1788930900,1788931800,1788932700,1788933600,1788934500,1788935400,1788936300,1788937200,1788938100,1788939000,1788939900,1788940800,1788941700,1788942600,1788943500,1788944400,1788945300,1788946200,1788947100,1788948000,1788948900,1788949800,1788950700,1788951600,1788952500,1788953400,1788954300,1788955200,1788956100,1788957000,1788957900,1788958800,1788959700,1788960600,1788961500,1788962400,1788963300,1788964200,1788965100,1788966000,1788966900,1788967800,1788968700,1788969600,1788970500,1788971400,1788972300,1788973200,1788974100,1788975000,1788975900,1788976800,1788977700,1788978600,1788979500,1788980400,1788981300,1788982200,1788983100,1788984000,1788984900,1788985800,1788986700,1788987600,1788988500,1788989400,1788990300,1788991200,1788992100,1788993000,1788993900,1788994800,1788995700,1788996600,1788997500,1788998400,1788999300,1789000200,1789001100,1789002000,1789002900,1789003800,1789004700,1789005600,1789006500,1789007400,1789008300,1789009200,1789010100,1789011000,1789011900,1789012800,1789013700,1789014600,1789015500,1789016400,1789017300,1789018200,1789019100,1789020000,1789020900,1789021800,1789022700,1789023600,1789024500,1789025400,1789026300,1789027200,1789028100,1789029000,1789029900,1789030800,1789031700,1789032600,1789033500,1789034400,1789035300,1789036200,1789037100,1789038000,1789038900,1789039800,1789040700,1789041600,1789042500,1789043400,1789044300,1789045200,1789046100,1789047000,1789047900,1789048800,1789049700,1789050600,1789051500,1789052400,1789053300,1789054200,1789055100,1789056000,1789056900,1789057800,1789058700,1789059600,1789060500,1789061400,1789062300,1789063200,1789064100,1789065000,1789065900,1789066800,1789067700,1789068600,1789069500,1789070400,1789071300,1789072200,1789073100,1789074000,1789074900,1789075800,1789076700],"price":[150.93,151.41,150.99,147.59,153.25,147.26,144.04,141.53,147.79,146.53,143.55,139.93,145.58,139.56,139.11,138.06,138.92,138.52,138.04,138.65,137.29,139.03,144.59,150.34,154.12,174.5,178.43,186.62,199.35,200.21,200.11,197.43,220.07,202.0,196.01,174.03,198.32,167.49,152.54,142.05,161.5,145.45,128.61,100.85,139.18,115.83,94.41,80.39,111.25,89.61,84.99,74.38,80.53,70.58,62.27,57.1,62.24,64.95,68.99,71.19,66.9,87.32,109.0,123.12,68.48,125.53,155.99,161.9,138.77,160.57,167.35,173.0,142.2,172.83,196.19,211.26,205.85,218.77,217.08,222.0,210.98,208.46,208.88,210.02,209.77,202.61,200.27,191.49,194.39,195.04,197.26,190.92,188.39,198.05,203.05,191.84,216.08,216.08,214.92,200.0,223.37,201.43,196.32,190.0,199.53,194.83,180.53,172.23,179.88,175.77,167.1,173.89,163.29,165.99,171.41,178.73,170.27,177.37,187.71,201.07,193.62,207.61,218.27,226.77,227.17,234.05,236.99,229.94,248.93,227.5,216.08,195.67,215.99,195.01,177.1,155.09,174.91,152.71,139.59,123.83,140.77,123.19,114.96,94.5,102.77,96.63,94.57,116.34,114.63,126.82,129.89,123.95,105.0,119.11,121.0,136.79,123.41,135.01,135.0,156.68,146.88,152.16,162.02,181.78,154.76,184.28,194.3,215.76,197.23,234.15,253.03,296.58,345.32,389.16,482.99,559.0,551.11,446.41,358.97,295.9,271.39,268.06,241.43,238.01,239.7,226.41,223.05,211.56,218.21,210.61,197.78,171.79],"unit":"EUR / MWh","deprecated":false}
@@ -0,0 +1 @@
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1788991200,1788992100,1788993000,1788993900,1788994800,1788995700,1788996600,1788997500,1788998400,1788999300,1789000200,1789001100,1789002000,1789002900,1789003800,1789004700,1789005600,1789006500,1789007400,1789008300,1789009200,1789010100,1789011000,1789011900,1789012800,1789013700,1789014600,1789015500,1789016400,1789017300,1789018200,1789019100,1789020000,1789020900,1789021800,1789022700,1789023600,1789024500,1789025400,1789026300,1789027200,1789028100,1789029000,1789029900,1789030800,1789031700,1789032600,1789033500,1789034400,1789035300,1789036200,1789037100,1789038000,1789038900,1789039800,1789040700,1789041600,1789042500,1789043400,1789044300,1789045200,1789046100,1789047000,1789047900,1789048800,1789049700,1789050600,1789051500,1789052400,1789053300,1789054200,1789055100,1789056000,1789056900,1789057800,1789058700,1789059600,1789060500,1789061400,1789062300,1789063200,1789064100,1789065000,1789065900,1789066800,1789067700,1789068600,1789069500,1789070400,1789071300,1789072200,1789073100,1789074000,1789074900,1789075800,1789076700],"price":[216.08,216.08,214.92,200.0,223.37,201.43,196.32,190.0,199.53,194.83,180.53,172.23,179.88,175.77,167.1,173.89,163.29,165.99,171.41,178.73,170.27,177.37,187.71,201.07,193.62,207.61,218.27,226.77,227.17,234.05,236.99,229.94,248.93,227.5,216.08,195.67,215.99,195.01,177.1,155.09,174.91,152.71,139.59,123.83,140.77,123.19,114.96,94.5,102.77,96.63,94.57,116.34,114.63,126.82,129.89,123.95,105.0,119.11,121.0,136.79,123.41,135.01,135.0,156.68,146.88,152.16,162.02,181.78,154.76,184.28,194.3,215.76,197.23,234.15,253.03,296.58,345.32,389.16,482.99,559.0,551.11,446.41,358.97,295.9,271.39,268.06,241.43,238.01,239.7,226.41,223.05,211.56,218.21,210.61,197.78,171.79],"unit":"EUR / MWh","deprecated":false}
@@ -0,0 +1,82 @@
{
"card": "TARIFF-08, extended by TARIFF-09",
"what": "UNMODIFIED response bodies from the public EPEX BE day-ahead feed, captured so the SHIPPED parser can be replayed against what the source really publishes",
"why": "every price fixture in the suite was built from a `{\"data\": [{\"start\": ..., \"price_eur_mwh\": ...}]}` document that this feed has never published. The suite therefore graded `_points_from_json` against its own assumption - a closed loop that could not notice the assumption was wrong - and §11.4 turned the resulting failure into a silent fall back to the static profile. A captured real body is what breaks that loop, so these files are bytes, not a reconstruction: nothing here is pretty-printed, re-keyed or trimmed. TARIFF-09 adds a body from a request that names a DATE RANGE, for the same reason one layer out: the shipped plugin took `since`/`until` and asked for the bare default URL, so the feed answered ONE Brussels day and every hour past today was carried forward and dropped from the trade set as provisional. A two-day body captured from the range request is what makes \"asking for the range gets the range\" checkable against something the plugin did not shape.",
"source": "Energy-Charts API (Fraunhofer ISE), api.energy-charts.info",
"upstream": "Bundesnetzagentur | SMARD.de",
"licence": "CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)",
"licence_note": "the BE bidding zone is in the API's CC BY 4.0 list, so redistribution is permitted with attribution. DATA-02 established this against the API's own OpenAPI document before committing `epex-be-dayahead-2025-09-01_2026-09-01.json`; both bodies below carry the same `license_info` string, recorded per file and asserted by the replay test.",
"attribution": "Data licensed CC BY 4.0 by Bundesnetzagentur | SMARD.de, retrieved via the Energy-Charts API (Fraunhofer ISE), https://energy-charts.info. Published without changes.",
"retrieved_utc": "2026-09-10T06:35:00+00:00 (TARIFF-08's two), 2026-09-10T07:45:49+00:00 (TARIFF-09's)",
"retrieved_with": "curl, by hand; no code in `app/` fetches these",
"shape": "COLUMNAR: parallel `unix_seconds` (UTC epoch seconds) and `price` arrays, plus `unit`, `license_info` and `deprecated`. There is no `data` key and no per-point object.",
"replayed_by": "test_price.py sections 2b (TARIFF-08) and 2c (TARIFF-09)",
"files": {
"energy-charts-be-price-2026-09-10-quarterhourly.json": {
"url": "https://api.energy-charts.info/price?bzn=BE",
"note": "the SHIPPED default URL of `generic_dayahead`, verbatim and with no parameters, so the replay drives the bytes the add-on's own request produces",
"covers_local_day": "2026-09-10 (Europe/Brussels)",
"granularity_s": 900,
"intervals": 96,
"bytes": 1877,
"sha256": "176309d0020ad208320797385ef68b6a98ac248218539416cddf6579dd060487",
"unit_as_returned": "EUR / MWh",
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
"deprecated_as_returned": false,
"top_level_keys": [
"deprecated",
"license_info",
"price",
"unit",
"unix_seconds"
],
"also_returned_verbatim_by": {
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-10&end=2026-09-11",
"retrieved_utc": "2026-09-10T07:46:55+00:00",
"note": "TARIFF-09's PRE-PUBLICATION case, and it needed no second file because the answer was BYTE-IDENTICAL to the bare-URL capture above - the same 1877 bytes and the same sha256. Asked for two days at 09:46 CEST, before the ~13:00-14:00 CET auction publishes 2026-09-11, the feed returned the ONE day that exists. That is the short answer this card requires to stay a `series` of what exists rather than becoming `no_data`, and the replay re-derives the identity from the bytes rather than restating it."
}
},
"energy-charts-be-price-2025-09-10-hourly.json": {
"url": "https://api.energy-charts.info/price?bzn=BE&start=2025-09-10&end=2025-09-10",
"note": "a day BEFORE Belgium's 15-minute MTU (2025-10-01), so the same endpoint answers HOURLY. Kept because the parser must not assume either spacing, and a fixture at only one granularity cannot grade that",
"covers_local_day": "2025-09-10 (Europe/Brussels)",
"granularity_s": 3600,
"intervals": 24,
"bytes": 572,
"sha256": "03fe41690810df224611f4016899f856457f67a3c12c7654da17b1331ff2eff4",
"unit_as_returned": "EUR / MWh",
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
"deprecated_as_returned": false,
"top_level_keys": [
"deprecated",
"license_info",
"price",
"unit",
"unix_seconds"
]
},
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json": {
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-09&end=2026-09-10",
"retrieved_utc": "2026-09-10T07:45:49+00:00",
"note": "TARIFF-09. A RANGE request, and the counterfactual the ticket is built on: `start`/`end` are INCLUSIVE Brussels calendar dates and this one answers 192 quarter-hours across TWO local days, where the bare `?bzn=BE` of the same endpoint answers 96 across one. The shipped `generic_dayahead` now derives these two dates from the `since`/`until` it is handed.",
"covers_local_days": [
"2026-09-09",
"2026-09-10"
],
"granularity_s": 900,
"intervals": 192,
"bytes": 3579,
"sha256": "4d83f5efb4294a4a682562eac619bf35e45c95edde957bfa94c402e1777cc9b3",
"unit_as_returned": "EUR / MWh",
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
"deprecated_as_returned": false,
"top_level_keys": [
"deprecated",
"license_info",
"price",
"unit",
"unix_seconds"
]
}
}
}
+13 -4
View File
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
print()
print("ARB-03 2. the split: eight sources, each classified where it is BUILT")
print("ARB-03 2. the split: eleven sources, each classified where it is BUILT")
# The classification is graded against what each claim is ABOUT, restated here
# so that the table in arbiter.py and the expectation in a test are not the same
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
@@ -180,16 +180,25 @@ UNIT_SOURCES = {
"measured power (0x0518), so what it learns is about one "
"device. The site-wide half moved to `sign_site`",
"maintenance": "the monthly recalibration is a pack procedure",
# OPT-09, and it is the entry a reader will want to check against
# SAFETY-25's deferred loop-side reserve, because it has the same shape.
"pack": "THIS pack's own state of charge against THIS pack's own "
"`bms_soc_floor` - a fact about one battery in one cupboard, "
"exactly as `device`'s rating is a fact about one box. It is "
"not SAFETY-25's deferred `loop_soc_floor` reserve: no option "
"is added, `peak_soc_floor` is not consulted, the SITE target "
"is untouched, and main.py does not file it at all at a "
"roster of one",
}
for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}")
check("all TEN claim sources are classified, and only those ten",
check("all ELEVEN claim sources are classified, and only those eleven",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the six that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the four that are about a PACK, a DEVICE or one write are per-inverter",
check("the five that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
check("a source nobody classified cannot be routed at all - actuator_for() "
"raises rather than defaulting to the site, because defaulting to the "
@@ -207,7 +216,7 @@ check("with one inverter every source routes to the site, per-inverter ones "
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the four "
check("with TWO the identity breaks and the split becomes real: the five "
"per-inverter sources route to the unit this controller drives, the six "
"site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
+372
View File
@@ -0,0 +1,372 @@
"""UX-04 - the defaults pin UX-01 AC 4 promised and never built.
`python3 -B test_defaults_pin.py`
WHAT UX-01 AC 4 ASKED FOR, IN ITS OWN WORDS: "every `schema:` key absent from
`options:` has exactly one defaults-table entry ... the table changed goes red
... and NO MODULE CARRIES ITS OWN LITERAL FALLBACK FOR A SCHEMA DEFAULT - an AST
check, not a grep."
WHAT SHIPPED. Clause one is VACUOUS: `options:` and `schema:` declare the same
71 keys, so "every schema key absent from options" quantifies over the empty set
and is true of any tree, including one with no defaults at all. Clause two -
"the table changed goes red" - does work, six times over, once per mirror in its
own suite. Clause three, THE ONE WITH THE CONTENT, was never written. The only
literal scan in this repository before this file is
`test_validate.py`'s `re.findall(r'"(maintenance_[a-z_]+)"...')`, which is a
grep, is restricted to eight `maintenance_*` keys and three named files, and
predates UX-01.
⚠️ AND A CHECK THAT CANNOT GO RED IS NOT A CHECK, so this file does not repeat
clause one's mistake. Every part below states the size of the set it quantifies
over and grades that size against a floor derived from the tree, and part 5
plants a literal in a synthetic module and REQUIRES the scanner to catch it, so
the red direction is exercised on every run rather than attested in a commit
message. MUTATION.md habit 4.
--------------------------------------------------------------------------- #
WHAT AN "IN-CODE LITERAL FALLBACK" IS, STRUCTURALLY, so a reader can argue with
the definition rather than guess it. It is a call in `app/` that takes a STRING
LITERAL naming a `schema:` key and, in the next positional slot, a CONSTANT:
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
Two shapes, one rule, found by walking `ast.Call` nodes - not by matching text,
so `opts.get(\n "max_w",\n 2000,\n)` and a renamed `opts` are found too and
a `max_w` inside a comment or a docstring is not.
TWO EXCLUSIONS, both structural and both narrow:
* `getattr(obj, "max_w", 0.0)` reads an ATTRIBUTE off an object, not an option
off the operator's blob. Its third argument is an absent-attribute guard and
has no relationship to config.yaml. Excluded by function name.
* THE EMPTY STRING. `opts.get("static_profile", "")` is Python's "this key is
not stated", and every one of the six sites in `validate.py` immediately
normalises it - `str(opts.get(k, "") or "").strip()`. It cannot be a stale
copy of a NUMBER, which is the whole of the CFG-04 defect class, and
rewriting those six to index a mirror would replace a sentence that reads
"absent" with one that reads "the shipped default" at sites where absent is
what is meant. Excluded by value. `0` is NOT excluded: `slew_w: 0` and
`integrator_max_w: 0` are real shipped defaults with real meanings.
--------------------------------------------------------------------------- #
WHAT THIS FILE DOES *NOT* DO, said before the parts rather than after them.
It does not remove the 27 (module, key) pairs in part 4's ledger. That is
CFG-03's open ticket - option defaults living in four places - and it is a body
of behaviour change across `main.py`, `p1.py`, `inverter.py` and `validate.py`
that no single card should make in passing. What this file does instead is
FREEZE the ledger: the set is graded EXACTLY, so the twenty-eighth pair is red
on the commit that introduces it and the ledger can only ever shrink. A backlog
that cannot grow is a backlog being paid down; one that is merely written in a
comment is the state UX-01 left.
MUTATION.md is the method for the red-direction evidence in part 5, and
`tools/mutation_run.sh` is the harness.
"""
import ast
import io
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
APP = HERE / "app"
FAILED = 0
def check(what, ok):
global FAILED
print((" ok " if ok else " FAIL ") + what)
if not ok:
FAILED += 1
# --------------------------------------------------------------------------- #
# config.yaml, parsed by hand.
#
# ⚠️ NO PyYAML, AND NOT AS A PREFERENCE. The add-on image installs py3-aiohttp
# and py3-paho-mqtt only; `import yaml` raises in the checks container and a
# check that skips on ImportError is not a check. `test_maintenance.py` and
# `test_price.py` each carry their own two-line reader for the same reason, and
# this is the third - which is itself an instance of the defect this file
# grades, one level up. It is written down rather than tidied away because
# tidying it into a shared helper is a change to two suites that are green.
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
_SCHEMA_AT = CFG_TEXT.index("\nschema:")
_OPTS_BLOCK, _SCHEMA_BLOCK = CFG_TEXT[:_SCHEMA_AT], CFG_TEXT[_SCHEMA_AT:]
def _scalar(raw):
"""One `options:` value, as the yaml module this image lacks would read it."""
raw = raw.split("#", 1)[0].strip().strip('"').strip("'")
if raw in ("true", "false"):
return raw == "true"
if raw == "[]":
return []
for cast in (int, float):
try:
return cast(raw)
except ValueError:
pass
return raw
def _block_keys(block):
return [m.group(1) for m in re.finditer(r"^ ([a-z0-9_]+):", block, re.M)]
SHIPPED = {m.group(1): _scalar(m.group(2))
for m in re.finditer(r"^ ([a-z0-9_]+): *(.*)$", _OPTS_BLOCK, re.M)}
SCHEMA = set(_block_keys(_SCHEMA_BLOCK))
print("UX-04 - the defaults pin\n")
print("1. the surface this file quantifies over")
print(f" config.yaml: {len(SHIPPED)} keys under `options:`, {len(SCHEMA)} "
f"under `schema:`")
# The floor, for part 1's own reason: a reader that returned {} would make every
# check below vacuously true. 60 is the count of REQUIRED schema keys and is the
# number `tools/supervisor_shape_probe.py` section F now grades; any floor well
# inside the real size does the job, and this one cannot be reached by accident.
check(f"the hand reader found a real options block ({len(SHIPPED)} keys, floor 60)",
len(SHIPPED) >= 60)
check(f"...and a real schema block ({len(SCHEMA)} keys, floor 60)",
len(SCHEMA) >= 60)
check("every shipped default names a schema key, so a mirror pinned to one is "
"pinned to the other",
sorted(set(SHIPPED) - SCHEMA) == [])
# --------------------------------------------------------------------------- #
print()
print("2. the mirrors, DISCOVERED from the tree rather than listed here")
# ⚠️ WHY DISCOVERED. Six suites already pin their own mirror to config.yaml -
# test_maintenance, test_price, test_watchdog (twice), test_peakguard,
# test_delivery. Every one of those pins names its table by hand, so a SEVENTH
# table added tomorrow is pinned by nobody and nothing says so. This walks
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
# grades whatever it finds, so the seventh is covered on the commit that
# introduces it. That is the property the six hand-written pins cannot have.
MODULES = sorted(p.name for p in APP.glob("*.py"))
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
for name in MODULES}
MIRROR = {} # schema key -> (module, table name, value)
TABLES = []
for name in MODULES:
for node in TREES[name].body:
if not (isinstance(node, ast.Assign) and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id.endswith("OPTION_DEFAULTS")):
continue
table = node.targets[0].id
TABLES.append((name, table))
for k, v in zip(node.value.keys, node.value.values):
# A literal key with a literal value is the only shape a mirror
# has; anything else is not a table this file can grade, and
# saying so out loud beats skipping it silently.
MIRROR[k.value] = (name, table, ast.literal_eval(v))
print(f" {len(TABLES)} table(s): " + ", ".join(f"{m}:{t}" for m, t in TABLES))
print(f" {len(MIRROR)} mirrored key(s) of {len(SCHEMA)} in `schema:`")
check(f"the walk found the mirrors that exist ({len(TABLES)} tables, floor 6)",
len(TABLES) >= 6)
_unschemad = sorted(k for k in MIRROR if k not in SCHEMA)
check(f"every mirrored key is a real schema key {_unschemad or ''}",
_unschemad == [])
_unshipped = sorted(k for k in MIRROR if k not in SHIPPED)
check(f"...and is shipped in the options block {_unshipped or ''}",
_unshipped == [])
_drift = sorted(k for k, (m, t, v) in MIRROR.items()
if k in SHIPPED and v != SHIPPED[k])
check(f"...and reproduces config.yaml exactly, so ANY mirror - including one no "
f"suite names by hand - goes red on drift {_drift or ''}",
_drift == [])
# --------------------------------------------------------------------------- #
print()
print("3. the AST scan: no module carries its own literal fallback for a "
"MIRRORED schema default")
def literal_fallbacks(tree, name):
"""Every `f(..., "<schema key>", <constant>, ...)` in one module.
Yields (module, lineno, callee, key, value). See the header for the two
exclusions and the argument for each.
"""
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn = node.func
callee = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", "")
if callee == "getattr":
continue
for i, arg in enumerate(node.args[:-1]):
if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)
and arg.value in SCHEMA):
continue
default = node.args[i + 1]
if not isinstance(default, ast.Constant):
continue
if default.value is None or default.value == "":
continue
yield (name, node.lineno, callee, arg.value, default.value)
ALL_SITES = [s for name in MODULES for s in literal_fallbacks(TREES[name], name)]
MIRRORED_SITES = [s for s in ALL_SITES if s[3] in MIRROR]
UNMIRRORED_SITES = [s for s in ALL_SITES if s[3] not in MIRROR]
print(f" scanned {len(MODULES)} modules, {len(ALL_SITES)} literal fallback "
f"site(s): {len(MIRRORED_SITES)} over mirrored keys, "
f"{len(UNMIRRORED_SITES)} over unmirrored ones")
# ⚠️ THE SCANNER'S OWN FLOOR, and it is the line that stops this whole file
# rotting into clause one. A scanner that silently stopped matching - a renamed
# helper, an ast API change, a `SCHEMA` that came back empty - would report ZERO
# sites and every check below would pass. So the total is graded against a floor
# the tree already exceeds by a wide margin, and part 5 additionally feeds the
# scanner a source it MUST flag.
check(f"the scanner is not blind: it found {len(ALL_SITES)} sites (floor 20)",
len(ALL_SITES) >= 20)
for site in sorted(MIRRORED_SITES):
print(f" {site[0]}:{site[1]} {site[2]}({site[3]!r}, {site[4]!r})")
check(f"no literal fallback survives for any of the {len(MIRROR)} keys a mirror "
f"already answers for - those sites must index the mirror",
MIRRORED_SITES == [])
# --------------------------------------------------------------------------- #
print()
print("4. the CFG-03 ledger: the unmirrored keys that still carry a literal, "
"frozen at exactly today's set")
# ⚠️ WHY A FROZEN SET AND NOT A COUNT. A count goes green again the moment
# somebody deletes one site and adds another, which is the same-size drift this
# repository keeps finding. The pair (module, key) is stable under every edit
# that is not the one being guarded against: reformatting, moving the call
# within the file, renaming the local `opts`. It changes only when a module
# starts or stops carrying a literal for a key - which is exactly the event.
#
# LINE NUMBERS ARE DELIBERATELY NOT IN THE KEY. A ledger that goes red when an
# unrelated edit shifts a line is a ledger people learn to re-bless without
# reading, and a ledger re-blessed without reading grades nothing.
LEDGER = {
("inverter.py", "max_w"),
("main.py", "auto_start"),
("main.py", "cycle_floor_s"),
("main.py", "deadband_w"),
("main.py", "estop_fitted"),
("main.py", "gain"),
("main.py", "heartbeat_s"),
("main.py", "log_level"),
("main.py", "meter_freeze_s"),
("main.py", "meter_max_age_s"),
("main.py", "meter_phases"),
("main.py", "peak_cap_w"),
("main.py", "saturation_cycles"),
("main.py", "saturation_w"),
("main.py", "sign_disagree_s"),
("main.py", "sign_disagree_w"),
("main.py", "slew_w"),
("main.py", "stale_input_s"),
("main.py", "step_w"),
("main.py", "target_grid_w"),
("p1.py", "meter_poll_s"),
("p1.py", "meter_source"),
("validate.py", "max_w"),
("validate.py", "meter_max_age_s"),
("validate.py", "meter_phases"),
("validate.py", "sign_disagree_w"),
("validate.py", "slew_w"),
}
_found = {(s[0], s[3]) for s in UNMIRRORED_SITES}
_new = sorted(_found - LEDGER)
_gone = sorted(LEDGER - _found)
print(f" {len(_found)} (module, key) pair(s) carry a literal for a key no "
f"mirror answers for")
check(f"no NEW module/key pair has started carrying a literal fallback {_new or ''}",
_new == [])
check(f"...and none has been paid off without the ledger being shortened - "
f"shrink LEDGER when it has {_gone or ''}",
_gone == [])
# --- and every one of them is graded AGAINST config.yaml, which is the half
# that catches CFG-04's actual failure: two copies of one number, one moves.
DIVERGENT = {
# main.py:290 `target_grid_w=float(opts.get("target_grid_w", 0))`.
# config.yaml ships -10 W. MEASURED here for the first time, 2026-09-07,
# and it is a real divergence, not a sentinel: a dict handed to
# `Controller` WITHOUT the key holds net exchange at 0 W instead of the
# shipped -10 W bias into import. On a Supervisor install the key is always
# present - the deep-merge of `options:` guarantees it - so this cannot
# reach an operator; the paths that CAN hand over a partial dict are
# `sim/inject.py` and this repository's own fixtures. It is recorded rather
# than corrected because changing it is a behaviour change to the control
# loop's target, which belongs to a card that grades the loop.
("main.py", "target_grid_w"): (0, -10),
}
_diverge = {}
for mod, line, callee, key, value in UNMIRRORED_SITES:
want = SHIPPED.get(key)
same = (isinstance(value, bool) == isinstance(want, bool)) and value == want
if not same:
_diverge[(mod, key)] = (value, want)
for pair, (got, want) in sorted(_diverge.items()):
print(f" {pair[0]} {pair[1]}: code {got!r} vs config.yaml {want!r}")
check(f"every remaining literal still AGREES with config.yaml, except the "
f"{len(DIVERGENT)} divergence(s) recorded above it",
_diverge == DIVERGENT)
# --------------------------------------------------------------------------- #
print()
print("5. the red direction, RUN on every invocation")
# ⚠️ A CHECK THAT HAS NEVER BEEN SHOWN FAILING IS NOT A CHECK. Parts 3 and 4
# both pass on a clean tree, which is exactly what UX-01 AC 4's clause one did
# while grading nothing. So the scanner is fed a source that is a deliberate
# violation of each rule and is REQUIRED to flag it - in-process, against the
# same `literal_fallbacks()` the parts above call, so a scanner that stops
# working stops working here too.
_PLANTED = '''
def build(opts):
a = opts.get("maintenance_charge_w", 2000) # mirrored key -> part 3
b = _number(opts, "cycle_floor_s", 15) # unmirrored, main.py's key
c = opts.get("static_profile", "") # the "" sentinel: NOT a hit
d = getattr(unit, "max_w", 0.0) # attribute guard: NOT a hit
e = opts.get("peak_soc_floor", 20) # mirrored key -> part 3
return a, b, c, d, e
'''
_hits = list(literal_fallbacks(ast.parse(_PLANTED, "<planted>"), "<planted>"))
_keys = sorted(h[3] for h in _hits)
for h in _hits:
print(f" caught {h[2]}({h[3]!r}, {h[4]!r}) at line {h[1]}")
check("a planted literal for a MIRRORED key is caught - this is part 3's red "
"direction, exercised",
"maintenance_charge_w" in _keys and "peak_soc_floor" in _keys)
check("a planted literal for an unmirrored key is caught too - part 4's",
"cycle_floor_s" in _keys)
check('...and the two documented exclusions still hold: the "" sentinel and '
"getattr are not reported",
"static_profile" not in _keys and _keys.count("max_w") == 0)
check(f"exactly the three intended sites, no more {_keys}",
_keys == ["cycle_floor_s", "maintenance_charge_w", "peak_soc_floor"])
# ...and the mirror comparison's red direction, likewise executed rather than
# claimed: part 2 goes red when a table and config.yaml disagree by one.
_drifted = dict(MIRROR)
_pick = sorted(MIRROR)[0]
_mod, _tab, _val = _drifted[_pick]
_drifted[_pick] = (_mod, _tab, "###drifted###")
check(f"part 2's comparison rejects a mirror that has drifted (simulated on "
f"{_pick!r})",
any(v != SHIPPED.get(k) for k, (_, _, v) in _drifted.items()))
print()
if FAILED:
print(f"{FAILED} FAILED")
sys.exit(1)
print("all checks passed")
+213 -42
View File
@@ -33,8 +33,9 @@ from datetime import datetime, timedelta, timezone
from app.control import Tuning
from app.delivery import (
DELIVERY_OPTION_DEFAULTS, REASON_CODE, DeliveryConfig, DeliveryWatchdog,
UnitDelivery, account, delinquent, short_by,
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
DeliveryConfig, DeliveryWatchdog, UnitDelivery, account, delinquent,
short_by,
)
from app.inverter import LINK_STALE_S
from app.main import Controller
@@ -151,6 +152,77 @@ check("a unit given no share is not asked and cannot be named, and it is not "
"a peer either - with only one unit asked there is nothing to compare",
delinquent([U("a", 0, 0), U("b", 2000, 0)], 500) == {})
print()
print(" SAFETY-43: THE RELATIVE ARM - short OF ITS OWN ASK, not just short")
# ⚠️ `share_frac=None` IS SAFETY-42's RULE EXACTLY, and every check in this part
# above this line is run under BOTH rules below to show the card took nothing
# away. What it ADDS is one thing: a unit can now be named for delivering almost
# none of a SMALL share. OPT-09's weights make small shares ordinary - a
# low-headroom unit is weighted down to 125 W of a 1630 W site target - so
# before this card a wedged unit could be 100 % delinquent and 125 W short, and
# 125 W is under a 500 W threshold. test_packshare.py part 8b measures that end
# to end on the real loop; what is graded here is the predicate.
_ABS = None # SAFETY-42's rule
_tiny = [U("a", 1505, 1505), U("b", 125, 0)]
check("A UNIT DELIVERING 0 OF 125 W IS NAMED, AND UNDER THE OLD RULE IT WAS "
"NOT. Same three units, same `min_w`, one input changed: the absolute arm "
"alone answers {} because 125 W < 500 W, and the shipped rule names it "
"because 100 % of its ask is not arriving",
delinquent(_tiny, 500, _ABS) == {}
and list(delinquent(_tiny, 500)) == ["b"])
# ⚠️ `.get`, NOT `["b"]`, AND THAT IS NOT STYLE. A bare subscript here raises
# KeyError on any tree where the relative arm is broken or absent, which ABORTS
# THE FILE at part 1 and leaves parts 2-8 UNGRADED. The direction is safe - an
# abort is a red, and MUTATION.md's defect can only fabricate a green - but the
# recorded coverage of a mutation run then reads larger than it was, which is
# the shape M11's entry in test_packshare.py part 11 already discloses. Found
# in review of this card, on M12 and M13.
_tiny_said = delinquent(_tiny, 500).get("b", "(not named - see the check above)")
print(f" {_tiny_said}")
check("...and the sentence carries the PROPORTION as well as the watts, because "
"'125 W is not arriving' reads like a rounding error and '125 W (100 % of "
"it)' is the fault",
"125 W of its share (100 % of it) is not arriving" in _tiny_said)
check("THE ACCOUNT GATE IS UNTOUCHED BY IT - the same 0 of 125 W with the "
"shortfall EXPLAINED names nobody. A relative criterion that could talk "
"its way past `account()` would report every pack sitting at its own "
"cut-out on a small share, which is the crying-wolf failure this module "
"exists to avoid",
delinquent([U("a", 1505, 1505), U("b", 125, 0, "its pack is empty")],
500) == {})
check("...and so is the PROPER-SUBSET rule: both units delivering none of a "
"small share is a SITE condition and is still nobody's fault to name",
delinquent([U("a", 125, 0), U("b", 125, 0)], 500) == {})
check("...and the relative bound is STRICT, like the absolute one. A gap of "
"EXACTLY the fraction of the share is not over it; one watt more is",
delinquent([U("a", 2000, 2000),
U("b", 1000, 1000 * (1.0 - DELIVERY_SHARE_FRACTION))],
5000) == {}
and list(delinquent([U("a", 2000, 2000),
U("b", 1000,
1000 * (1.0 - DELIVERY_SHARE_FRACTION) - 1)],
5000)) == ["b"])
_both_rules = [(delinquent(u, w, _ABS), delinquent(u, w)) for u, w in (
([U("a", 2000, 2000), U("b", 2000, 0)], 500),
([U("a", 2000, 2000), U("b", 2000, 0, "its pack is empty")], 500),
([U("a", 2000, 0), U("b", 2000, 0)], 500),
([U("a", 2000, 2000), U("b", 2000, 0), U("c", 2000, 0)], 500),
([U("a", 2000, 0, "at its floor"), U("b", 2000, 0)], 500),
([U("a", 2000, 2000), U("b", 2000, 1500)], 500),
([U("a", 0, 0), U("b", 2000, 0)], 500),
)]
check(f"⚠️ AND IT TOOK NOTHING AWAY: all {len(_both_rules)} fixtures above this "
f"block answer IDENTICALLY under SAFETY-42's rule and the shipped one. "
f"The relative arm is an `or`, so it can only ever ADD a naming - and on "
f"every case this part already graded it adds none. That is the "
f"difference between adding a second way to be named and lowering "
f"`min_w`, which would have moved all seven",
all(a == b for a, b in _both_rules))
print()
print(" AC 5: AT ONE INVERTER IT IS INERT, AND BY ALGEBRA RATHER THAN BY A")
print(" ROSTER GUARD - `len(asked) < 2` is unreachable-by-construction there")
@@ -323,9 +395,14 @@ class _DeliveryHass:
same in both.
"""
def __init__(self, load_w=_LOAD_W, b_soc=50.0):
def __init__(self, load_w=_LOAD_W, b_soc=50.0, b_frac=0.0):
self.load_w = float(load_w)
self.b_soc = float(b_soc)
# SAFETY-43. What fraction of its command B actually delivers. 0.0 is a
# WEDGED unit and is every arm in this file but part 4b(a) - see there
# for why that one arm needs a unit that is short WITHOUT being 100 %
# short.
self.b_frac = float(b_frac)
self.writes = []
self.calls = []
self.ctrl = None
@@ -343,8 +420,11 @@ class _DeliveryHass:
# A obeys. B produces NOTHING, whatever it is told - and whether that is
# a wedged power stage or an empty pack is not a question its power
# register can answer.
b = self.ctrl.inverters[1]
b_cmd = float((b.last_write if b.last_write is not None else b.target)
or 0.0)
return [float((a.last_write if a.last_write is not None else a.target)
or 0.0), 0.0]
or 0.0), self.b_frac * b_cmd]
async def number(self, entity, invert=False):
m = self.measured()
@@ -475,13 +555,42 @@ def _power_domain(rows):
_pw, _pf = _power_domain(_sw), _power_domain(_sf)
check(f"THE TWO ARMS ARE BYTE-IDENTICAL IN THE POWER DOMAIN on all "
f"{len(_pw)} cycles - grid, site target, every share, every measured "
f"watt. No threshold on any of those numbers, at any N and any X, can "
f"separate a wedged unit from a pack at its floor, and the card's own "
f"proposed discriminator reads only those numbers",
len(_pw) == len(_pf) and _pw == _pf)
check("...and the ONLY thing that differs is the state of charge",
# ⚠️ OPT-09 CHANGED WHAT THIS PAIR OF ARMS LOOKS LIKE, AND THE CARD'S FINDING
# SURVIVES IT INTACT. UNTIL OPT-09 the two traces were byte-identical in every
# power number - the cold pack was ASKED for the same +2000 W the wedged one was
# asked for, and both produced 0 W - and that identity was this file's headline.
# OPT-09 closes a pack's `hi` at 0 W once its own state of charge is at or under
# its own `bms_soc_floor`, so the cold arm's B is now commanded 0 W and the two
# arms' SHARES differ. ⚠️ THAT IS A CHANGE IN WHAT THE SITE ASKS FOR, NOT IN
# WHAT ANY DETECTOR CAN SEE, and the card's argument is about the latter: the
# proposed discriminator reads SATURATION-PERSISTENCE and |GRID ERROR|, and on
# BOTH arms the loop is saturation-frozen on every cycle while ~1000 W stands at
# the meter. No (N, X) separates them, exactly as measured on the rig - so the
# claim is now stated on the two quantities the predicate actually reads rather
# than on the whole trace, which is the stronger form of the same sentence.
_ERR_W = 900.0
_frozen = [[r["reason"] for r in rows] for rows in (_sw, _sf)]
_settled = [[r for r in rows if abs(r["grid"]) > _ERR_W] for rows in (_sw, _sf)]
print(f" the two quantities the card's predicate reads, last 8 cycles: "
f"wedged grid {[round(r['grid']) for r in _sw[-8:]]}, "
f"cold grid {[round(r['grid']) for r in _sf[-8:]]}")
check(f"THE TWO ARMS ARE INSEPARABLE IN THE TWO QUANTITIES THE CARD'S PROPOSED "
f"DISCRIMINATOR READS. On both, the loop reports the SAME reason on the "
f"last {len(_sw[-8:])} cycles and |grid error| stands over "
f"{_ERR_W:.0f} W on every one of them. No threshold on saturation "
f"persistence and no threshold on grid error, at any N and any X, can "
f"separate a wedged unit from a pack at its floor",
[r["reason"] for r in _sw[-8:]] == [r["reason"] for r in _sf[-8:]]
and len(_settled[0]) >= 8 and len(_settled[1]) >= 8)
check("...and the SHARES now differ, which is OPT-09 and not a weakening of the "
"line above: the cold pack is commanded 0 W because its own SoC closed "
"its own envelope, while the wedged pack - whose SoC is healthy - is "
"still asked for its full share and still delivers nothing. The two "
"cards meet here from opposite sides: OPT-09 stops the ASK, this file "
"grades the ALARM, and neither can do the other's half",
_pw != _pf
and _sf[-1]["shares"][1] == 0.0 and _sw[-1]["shares"][1] == 2000.0)
check("...and the state of charge is still what tells them apart",
[r["soc"] for r in _sw] != [r["soc"] for r in _sf])
print(f" both arms, last cycle: grid {_sw[-1]['grid']:.0f} W, shares "
f"{tuple(round(v) for v in _sw[-1]['shares'])}, measured "
@@ -490,27 +599,48 @@ print(f" SoC: wedged arm {_sw[-1]['soc']} cold arm {_sf[-1]['soc']}")
check("AC 1: the WEDGED unit is NAMED, and only it",
_sw[-1]["named"] == ("inverter-b",))
check("AC 2: the COLD PACK AT ITS FLOOR IS NOT - on the identical trace",
check("AC 2: the COLD PACK AT ITS FLOOR IS NOT",
_sf[-1]["named"] == ())
print(f" {_cw.inverters[1].delivery_wd.reason}")
# ⚠️ THE VACUOUS-PASS GUARD, AND IT IS NOT DECORATION. MUTATION.md's third
# failure mode is a check whose data cannot reach the failure it names: "the
# cold arm named nobody" reads exactly the same against a fixture where nothing
# was short, nothing was asked, or the loop never ran. So the cold arm is
# asserted to have REACHED the branch and been turned away by the account, not
# to have missed it.
# was short, nothing was asked, or the loop never ran.
#
# ⚠️ AND OPT-09 MOVED WHICH OF THOSE IS TRUE, WHICH IS WORTH MORE THAN THE
# CHECK IT REPLACES. Until OPT-09 the cold pack WAS asked for its full share,
# was short of it, and was turned away by `account()` alone - the account was the
# only thing standing between an empty battery and an alarm. It is not any more:
# the pack's own envelope is closed, so it is never asked, `short_by()` answers
# None on a zero share, and AC 2 is delivered STRUCTURALLY - by the site not
# asking - rather than by a discriminator applied after the fact.
#
# BOTH HALVES ARE ASSERTED, because the second is the one that survives OPT-09
# being reverted or a pack whose numbers are absent: `account()` STILL turns this
# pack away when the ask IS present, and that is asked of it directly here on
# this arm's own measured SoC and configured cut-out. Parts 2 and 3 grade the
# predicate over its whole input space; this line pins it to THIS fixture's
# numbers so the end-to-end arm cannot pass for a reason unrelated to the pack.
_cold_b = _cf.inverters[1]
check("...and the cold arm REACHED that decision rather than falling short of "
"it: B was asked for a discharge share, was short of it by more than "
"saturation_w, and was turned away by `account()` alone",
float(_cold_b.standing_w()) > 0
and short_by(_cold_b.standing_w(), _cold_b.batt) > _cf.tuning.saturation_w
and account(live=True, parked=False, soc=_cold_b.soc,
bms_floor=_cold_b.bms_soc_floor) is not None)
check("...and the WEDGED arm's B differs from it in exactly that one place",
check("...and the cold arm reaches AC 2 the way OPT-09 makes it reach it: B's "
"own state of charge closed B's own envelope, so B is commanded 0 W and "
"`short_by()` has no discharge share to be short of. The ask never "
"arrives, which is a better answer than an alarm suppressed after it does",
float(_cold_b.standing_w()) == 0.0
and short_by(_cold_b.standing_w(), _cold_b.batt) is None)
check("...and `account()` STILL turns this pack away if the ask does arrive - "
"asked directly, on this arm's own measured SoC and its own configured "
"cut-out. That is the half that must not depend on OPT-09: an unreadable "
"SoC or an unstated floor is a case OPT-09 deliberately does NOT close on, "
"and the account is what keeps those quiet",
account(live=True, parked=False, soc=_cold_b.soc,
bms_floor=_cold_b.bms_soc_floor) is not None)
check("...and the WEDGED arm's B differs from it in exactly that one place - "
"which is why it is still asked for its share, and still named",
account(live=True, parked=False, soc=_cw.inverters[1].soc,
bms_floor=_cw.inverters[1].bms_soc_floor) is None)
bms_floor=_cw.inverters[1].bms_soc_floor) is None
and float(_cw.inverters[1].standing_w()) > 0)
# ⚠️ WHICH FLOOR, AND THIS IS A WIRING CHECK THAT THE PURE ONES ABOVE CANNOT
# MAKE. `peakguard.pack_floor_pct()` conjoins a pack's own cut-out with the
@@ -580,15 +710,27 @@ print(" schema. Same load, same wedged unit, two configured thresholds.")
# load is what puts B's shortfall BETWEEN the two: above the shipped threshold,
# below the raised one. The gap itself is printed by the run that produces it
# and is never written down here; what is asserted is the ORDERING.
#
# ⚠️ AND B IS DERATED HERE RATHER THAN WEDGED, WHICH SAFETY-43 MADE NECESSARY.
# `delinquent()` now names a unit whose gap exceeds `min_w` OR a fraction of its
# own share, so a unit delivering NONE of its ask is named at every `min_w`
# there is - which is the whole point of that card, and which would make the
# raised arm below fire for a reason that has nothing to do with the watts. A
# unit delivering HALF of what it is asked for is short in absolute watts and
# comfortably inside the fraction, so it is the fixture that still isolates
# `saturation_w`. `_SAT_B_FRAC` is asserted below to be inside the relative
# arm's silence, so a future change to the fraction turns this red rather than
# quietly turning the pin vacuous.
_SAT_RAISED = 1800
_SAT_LOAD_W = 1600.0
_SAT_B_FRAC = 0.5
_SAT_SHIPPED = int(read_option("saturation_w"))
_sat_lo, _sat_hi = (int(x) for x in re.match(
r"int\((\d+),(\d+)\)", read_option("saturation_w", "schema")).groups())
async def _at_saturation_w(sat):
hass = _DeliveryHass(load_w=_SAT_LOAD_W)
async def _at_saturation_w(sat, b_frac=_SAT_B_FRAC):
hass = _DeliveryHass(load_w=_SAT_LOAD_W, b_frac=b_frac)
c = _ctrl(hass, saturation_w=sat)
return c, await _drive(c, watch=_sample)
@@ -624,6 +766,17 @@ check("the raised arm REACHED the comparison: B was asked for a discharge "
and not _b_up.sign_wd.tripped
and account(live=True, parked=False, soc=_b_up.soc,
bms_floor=_b_up.bms_soc_floor) is None)
check("...and SAFETY-43's RELATIVE arm is silent on both arms of this fixture, "
"which is what leaves the watts as the only moving part. B delivers half "
"of what it is asked for, so its gap is half its share and that is "
"inside `DELIVERY_SHARE_FRACTION`. ⚠️ A FIXTURE WHERE THE RELATIVE ARM "
"FIRED WOULD NAME B AT EVERY `saturation_w` AND THIS PIN WOULD BE "
"VACUOUS, so it is asserted rather than assumed",
_gap_up / float(_b_up.standing_w()) <= DELIVERY_SHARE_FRACTION
and _gap_ship / float(_b_ship.standing_w()) <= DELIVERY_SHARE_FRACTION
and delinquent([U("a", 1000, 1000),
U("b", float(_b_up.standing_w()), _b_up.batt)],
_SAT_RAISED) == {})
check("...and its shortfall lands STRICTLY BETWEEN the two configured "
"thresholds, which is the one place the two answers can differ. Part 4's "
"fixtures are all above both, which is exactly why they cannot see this",
@@ -694,11 +847,17 @@ check("...and each arm is a config validate.py rule 8 accepts: its "
"peak_soc_floor is above EVERY unit's own cut-out",
all(float(c.o.get("peak_soc_floor")) > float(i.bms_soc_floor)
for c in (_c_deep, _c_shal) for i in c.inverters))
check("...and the two arms are IDENTICAL IN THE POWER DOMAIN on every cycle - "
"grid, site target, every share, every measured watt. The configured "
"cut-out is the only thing that differs, which is part 4's claim made "
"again at two cut-outs that are not the shipped one",
_power_domain(_r_deep) == _power_domain(_r_shal))
check("...and the two arms are INSEPARABLE IN THE QUANTITIES THE CARD'S "
"PROPOSED DISCRIMINATOR READS - the same loop reason and over 900 W of "
"grid error on every one of the last eight cycles - which is part 4's "
"claim made again at two cut-outs that are not the shipped one. ⚠️ THEIR "
"SHARES DIFFER, because OPT-09 closes the DEEP arm's envelope (15 % "
"against a 25 % cut-out) and leaves the SHALLOW arm's open (8 % against "
"5 %); that is the site declining to ask, not a detector telling them "
"apart, and it is why the pin below still needs the pack's OWN number",
[r["reason"] for r in _r_deep[-8:]] == [r["reason"] for r in _r_shal[-8:]]
and all(abs(r["grid"]) > 900.0
for rows in (_r_deep, _r_shal) for r in rows[-8:]))
check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is "
f"cold, and is NOT named - even though it is far above the "
@@ -706,11 +865,14 @@ check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is "
f"against, which would have called an empty pack a fault",
_r_deep[-1]["named"] == () and float(_bd.bms_soc_floor) == _FLOOR_DEEP
and _bd.soc == _DEEP_SOC)
check("...and it REACHED that decision: it was asked for a discharge share, "
"was short of it by more than saturation_w, and was turned away by "
"`account()` alone",
float(_bd.standing_w()) > 0
and short_by(_bd.standing_w(), _bd.batt) > _c_deep.tuning.saturation_w
check("...and it reaches that decision the way OPT-09 makes it reach it - the "
"pack's own 25 % cut-out closed its own envelope, so it is commanded 0 W "
"and has no discharge share to be short of - while `account()`, asked "
"directly on the same two numbers, STILL turns it away. Both halves, for "
"the reason part 4 gives: the second is what survives OPT-09 being "
"reverted, and it is what covers the absences OPT-09 will not close on",
float(_bd.standing_w()) == 0.0
and short_by(_bd.standing_w(), _bd.batt) is None
and account(live=True, parked=False, soc=_bd.soc,
bms_floor=_bd.bms_soc_floor) is not None)
check(f"a pack at {_SHAL_SOC:.0f} % against its OWN {_FLOOR_SHALLOW} % cut-out "
@@ -801,12 +963,21 @@ check("the saturation freeze: FIRES - and that is the point. It is the one "
print(f" saturation counter {_cw.sat_count} (freezes at "
f"{_cw.tuning.saturation_cycles}); site commanded "
f"{_cw.target:.0f} W against {_cw.site_batt_w:.0f} W measured")
check("...and it fires IDENTICALLY on the benign arm, on the identical trace, "
"at the same counter. A saturation-shaped alarm cannot tell these two "
"apart at any threshold and any duration",
_cf.sat_count == _cw.sat_count
and _cf.site_batt_w == _cw.site_batt_w
and _cf.target == _cw.target)
print(f" ...and on the benign arm: counter {_cf.sat_count}, site "
f"commanded {_cf.target:.0f} W against {_cf.site_batt_w:.0f} W measured")
check("...and it fires ON THE BENIGN ARM TOO, from the same site numbers. The "
"site is commanded the same watts against the same measured watts and is "
"frozen on both, many times over the configured cycle count. ⚠️ THE "
"COUNTERS DIFFER BY ONE CYCLE SINCE OPT-09, and that rescues nothing: the "
"cold arm reaches its steady state one cycle sooner because its healthy "
"sibling gets the whole share immediately instead of half of it. A "
"duration threshold that both arms exceed by an order of magnitude "
"cannot be set between them, which is the claim - and the two settled "
"states are identical to the watt",
_cf.site_batt_w == _cw.site_batt_w
and _cf.target == _cw.target
and min(_cf.sat_count, _cw.sat_count) >= _cw.tuning.saturation_cycles
and abs(_cf.sat_count - _cw.sat_count) <= 1)
print()
print(" ...and a unit the ALLOCATOR HAS ALREADY DROPPED is not reported twice")
+12 -3
View File
@@ -1529,14 +1529,23 @@ check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
"⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS "
"`int(0,50)?`, and the difference is the whole of that card: an optional "
"per-pack cut-out is a DEFAULTED one, and a default is one number for "
"two batteries - the shallower pack protected by the deeper pack's BMS",
"two batteries - the shallower pack protected by the deeper pack's BMS. "
"⚠️ OPT-09 ADDED `capacity_kwh` AND MADE THE OPPOSITE CHOICE ON PURPOSE: "
"it is OPTIONAL (`float(0.1,200)?`) because an absent capacity has an "
"honest answer - the add-on then has no opinion about the SPLIT and every "
"unit weighs 1.0, which is exactly what shipped - where an absent cut-out "
"would have to be invented. If you are here because you added a REQUIRED "
"key, read `Inverter.__init__` before changing this line: a required key "
"refuses every options blob written before it existed",
set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
"soc_entity", "batt_entity", "batt_invert"}
"soc_entity", "batt_entity", "batt_invert",
"capacity_kwh"}
and not any(_decl9[k].endswith("?")
for k in ("name", "setpoint_entity", "max_w",
"bms_soc_floor"))
and all(_decl9[k].endswith("?")
for k in ("soc_entity", "batt_entity", "batt_invert")))
for k in ("soc_entity", "batt_entity", "batt_invert",
"capacity_kwh")))
# ⚠️ NOTHING IS INHERITED, AND THIS IS DEV-02 RESTATED AS A CONFIG RULE.
# ⚠️ THE FLAT KEYS ARE SET TO REAL ENTITIES HERE, AND THAT IS THE WHOLE CHECK.
+196 -50
View File
@@ -21,6 +21,7 @@ Read MUTATION.md before you report a survivor.
import asyncio
import dataclasses
import logging
import re
import sys
from datetime import datetime, timedelta, timezone
@@ -34,7 +35,8 @@ from app.inverter import UNIT_A
from app.main import Controller
from app.store import (
DEFAULTS, MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION, MAINT_UNIT_KEYS,
migrate_maint_keys, unit_key,
SCHEMA_KEY, SCHEMA_VERSION, migrate_maint_keys, migrate_unit_keys,
unit_key,
)
fails = []
@@ -1411,45 +1413,39 @@ check("...while the SAME site with the house importing more than the drain "
# --------------------------------------------------------------------------- #
print()
print("13. RECORDED, NOT FIXED: SAFETY-02's migration has the same "
"wrong-namespace hole, and it is a LATCH")
print("13. FIXED (SAFETY-44): SAFETY-02's migration takes the roster too, and "
"the guard is no longer defeated by the key the defect wrote")
# ⚠️ FOUND WHILE FIXING THIS CARD'S OWN INSTANCE, AND WRITTEN DOWN AS A RUNNING
# CHECK RATHER THAN A COMMENT SO IT CANNOT BE FORGOTTEN OR QUIETLY "FIXED" BY AN
# UNRELATED EDIT. It is the SAME defect one module over, and it costs more.
# ⚠️ THIS SECTION USED TO BE THE SPECIFICATION OF A DEFECT. It was written while
# fixing MAINT-05's own instance, as a RUNNING check rather than a comment, with
# the instruction that "the card that fixes it should expect them to go red and
# should replace them". SAFETY-44 is that card and this is that replacement.
#
# WHAT IT IS. `Controller.__init__` calls `migrate_unit_keys(store, UNIT_A)` -
# a hardcoded constant, where the maintenance migration now passes the roster's
# own first name. It has to run BEFORE `build_roster()`, because each
# `Inverter` restores its sign latch in its constructor, so there is no roster
# to name yet and `_verify_moves()` is passed `roster=None`. On a DECLARED
# roster the pre-DEV-02 flat `sign_tripped: true` therefore lands under
# `unit/inverter-a/sign_tripped`, which no unit on this site reads.
# WHAT IT WAS. `Controller.__init__` called `migrate_unit_keys(store, UNIT_A)` -
# a hardcoded constant, where the maintenance migration already passed the
# roster's own first name. On a DECLARED roster the pre-DEV-02 flat
# `sign_tripped: true` landed under `unit/inverter-a/sign_tripped`, which no
# unit on the site reads, and `_verify_moves()` read it back under the name it
# had been handed and was satisfied.
#
# WHY THE EXISTING GUARD DOES NOT CATCH IT, WHICH IS THE PART WORTH PINNING.
# `_grade_store_migration()` re-arms the latch when the flat key says tripped
# and NOTHING NAMESPACED IS LATCHED. The misdirected write IS namespaced and IS
# latched, so `namespaced_latched` is True, the guard returns, and no inverter
# on the roster comes up tripped. The guard is defeated by the very key the
# defect wrote.
# AND THE GUARD DID NOT CATCH IT, WHICH WAS THE PART WORTH PINNING.
# `_grade_store_migration()` re-arms when the flat key says tripped and NOTHING
# NAMESPACED IS LATCHED - and the misdirected write IS namespaced and IS
# latched. The guard was defeated by the very key the defect wrote.
#
# WHY IT IS NOT FIXED HERE. The remedy is to name the migration's target from
# the config before any `Inverter` exists, which means factoring
# `build_roster()`'s naming rule out of `build_roster()` so there is still only
# ONE spelling of it - inverter.py and SAFETY-02's latch semantics, with its own
# mutation battery over test_inverter.py and test_watchdog.py. MAINT-05 moves
# the maintenance history and nothing about the sign latch.
#
# ⚠️ AND IT IS A DISARMED SAFETY GUARD, NOT LOST HISTORY. Read the checks below
# as the SPECIFICATION OF A DEFECT: every one of them is asserting the WRONG
# behaviour, on purpose, and the card that fixes it should expect them to go
# red and should replace them.
# ⚠️ THE REASON IT WAS LEFT - "there is no roster to name yet, because each
# `Inverter` restores its latch in its constructor" - WAS THE HALF-TRUTH THAT
# KEPT IT ALIVE. The keys must be written before the `Inverter`s read them; the
# NAMES do not come from the `Inverter`s. `roster_names(opts)` reads them off
# the options before anything is constructed, which is what it already existed
# to do for `MqttPublisher`. The factoring this section said the fix would need
# had been done a card earlier.
#
# ⚠️ NO `schema_version` IN THIS STORE, AND THAT IS THE PRE-DEV-02 SHAPE
# ITSELF: an install that has never run a version with namespaced keys, which is
# what makes `migrate_unit_keys()` run at all.
_lat_store = MemStore({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured pre-DEV-02"})
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
_lat, _ = controller(NAMED_OPTS, store=_lat_store)
print(f" pre-DEV-02 flat latch + declared roster {_C2_NAMES} -> "
f"{_lat.store_migration.explain()}")
@@ -1457,29 +1453,179 @@ print(f" latch keys in the store: "
f"{sorted(k for k in _lat_store.data if k.endswith('sign_tripped'))}")
print(f" roster came up tripped: "
f"{[(i.name, i.sign_wd.tripped) for i in _lat.inverters]}")
check("the flat pre-DEV-02 latch is migrated into `unit/inverter-a/` - a name "
"that is NOT on this roster, so no unit reads it. This is the same "
"wrong-namespace write `migrate_maint_keys()` now refuses, in the "
"migration that runs before there is a roster to grade against",
_lat_store.data.get(unit_key(UNIT_A, "sign_tripped")) is True
and unit_key(_C2_NAMES[0], "sign_tripped") not in _lat_store.data)
check("⚠️ AND NO INVERTER ON THE ROSTER COMES UP LATCHED, on a store whose file "
"still reads `sign_tripped: true`. That is DEV-02's own sentence for the "
"worst case - a disarmed safety guard on an install that believes it is "
"protected",
not any(i.sign_wd.tripped for i in _lat.inverters)
check("⭐ the flat pre-DEV-02 latch is migrated into the ROSTER'S OWN first "
"namespace and NOT into `unit/inverter-a/` - the same wrong-namespace "
"write `migrate_maint_keys()` refuses, now refused in the migration that "
"runs before the `Inverter` objects exist",
_lat_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True
and unit_key(UNIT_A, "sign_tripped") not in _lat_store.data)
check("⭐ AND AN INVERTER ON THE ROSTER COMES UP LATCHED, which is the whole "
"card: a store whose file reads `sign_tripped: true` no longer produces "
"a site that drives",
_lat.inverters[0].sign_wd.tripped
and _lat_store.data["sign_tripped"] is True)
check("...and `_grade_store_migration()` is DEFEATED BY THE KEY THE DEFECT "
"WROTE, which is why this needs a card rather than a tweak: its "
"'is anything namespaced latched' test is satisfied by the misdirected "
"value itself, so it returns instead of re-arming",
any(k.startswith("unit/") and k.endswith("/sign_tripped") and v
for k, v in _lat_store.data.items())
and not any(i.sign_wd.tripped for i in _lat.inverters))
check("...and the latch that came back carries the REASON, not just the "
"boolean - an operator meeting a FAILSAFE banner with no sentence under "
"it has been told the site stopped and not why",
"SIGN_DISAGREEMENT" in _lat.inverters[0].sign_wd.reason)
check("⚠️ ...and the OTHER units on the roster are NOT latched by it. The flat "
"key is one site-level boolean and re-arming every unit off it would be "
"DEV-02's defect in the opposite direction - a healthy pack parked "
"because its sibling once disagreed",
not any(i.sign_wd.tripped for i in _lat.inverters[1:]))
# ⚠️ AND THE OTHER PATH, WHICH THE MIGRATION FIX ALONE DOES NOT REACH: a store
# ALREADY STAMPED by the shipped version. `migrate_unit_keys()` returns at its
# `from_version >= SCHEMA_VERSION` check and never moves anything, so the
# stranded `unit/inverter-a/sign_tripped` just sits there. This is the STAGED
# shape - "upgrade now, declare the roster later" - and it is the one an
# operator actually produces. Only the GUARD repairs it.
_staged_store = MemStore({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
unit_key(UNIT_A, "sign_tripped"): True,
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
SCHEMA_KEY: SCHEMA_VERSION})
_staged, _ = controller(NAMED_OPTS, store=_staged_store)
print(f" already-stamped store + declared roster -> ran="
f"{_staged.store_migration.ran}, came up tripped: "
f"{[(i.name, i.sign_wd.tripped) for i in _staged.inverters]}")
check("⚠️ PRECONDITION: the migration really does NOT run on this store - the "
"marker is already there - so anything below is the guard's doing and "
"not the migration's",
not _staged.store_migration.ran)
check("⭐ and the roster's first unit STILL comes up latched: the guard no "
"longer counts a latch stranded under `UNIT_A`, which is not on this "
"roster, as protection an operator has",
_staged.inverters[0].sign_wd.tripped)
check("...and the re-armed latch is written where a machine reads it, so the "
"NEXT start finds it without needing the guard again",
_staged_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True)
# ⚠️ THE NEGATIVE CONTROL FOR THAT NARROWING, because "off the roster" on its
# own was too blunt and test_inverter.py's DEV-02 restart case caught it: a
# latch stranded under a name that is NOT `UNIT_A` is a real unit out of
# today's config, and it MUST still count as protection.
_other_store = MemStore({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: B disagreed, mirrored flat",
unit_key("inverter-b", "sign_tripped"): True,
unit_key("inverter-b", "sign_reason"): "SIGN_DISAGREEMENT: B disagreed",
SCHEMA_KEY: SCHEMA_VERSION})
_other, _ = controller(NAMED_OPTS, store=_other_store)
print(f" stranded latch under `inverter-b` -> came up tripped: "
f"{[(i.name, i.sign_wd.tripped) for i in _other.inverters]}")
check("⚠️ a latch stranded under a name that is not `UNIT_A` is NOT re-armed "
"onto the roster: it belongs to a unit the operator has taken out of "
"the config, the flat key is its own mirror, and re-arming a healthy "
"sibling off it is the defect DEV-02 removed",
not any(i.sign_wd.tripped for i in _other.inverters))
# ⭐ AND THE ARRANGEMENT NEITHER FIXTURE ABOVE HOLDS: **BOTH** KEYS AT ONCE.
# ⚠️ THIS IS INSTANCE #17 OF HABIT 4 AND IT WAS FOUND IN THIS CARD'S OWN FIX.
# The first version of the guard classified each key and then wrote ONE shared
# `namespaced_latched` flag for both "on the roster" and "off-roster but not
# UNIT_A". `_staged_store` above holds only an `inverter-a` latch and
# `_other_store` below holds only an `inverter-b` one, so the flag was never
# written twice with opposite intents in a single store - and a per-key check
# whose keys have never disagreed WITHIN ONE STORE is a check over whichever
# key happened to come last. Measured on that build, one added key:
#
# sign_tripped + unit/inverter-a/sign_tripped -> garage latched, repaired
# ...AND unit/old-shed/sign_tripped -> ⚠️ NOTHING latched
#
# A site DRIVING on a state file that reads `sign_tripped: true` - the sentence
# this card exists to make false - reached by an operator taking one unit out of
# `inverters:` after it had tripped. "A roster is configuration and it changes"
# is this module's own phrase, and the second inverter goes in this month.
_both_store = MemStore({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
unit_key(UNIT_A, "sign_tripped"): True,
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
unit_key("old-shed", "sign_tripped"): True,
unit_key("old-shed", "sign_reason"): "SIGN_DISAGREEMENT: old-shed, since removed",
SCHEMA_KEY: SCHEMA_VERSION})
_both, _ = controller(NAMED_OPTS, store=_both_store)
print(f" stranded `inverter-a` AND an unrelated off-roster `old-shed` -> "
f"{[(i.name, i.sign_wd.tripped) for i in _both.inverters]}")
check("⭐ A STRANDED `inverter-a` LATCH IS STILL REPAIRED WHEN AN UNRELATED "
"OFF-ROSTER LATCH SITS BESIDE IT. One shared flag let `old-shed` veto the "
"repair, and the site came up driving on a file that says it is latched",
_both.inverters[0].sign_wd.tripped)
check("...and the repair still lands on the roster's first unit, not on the "
"removed one",
_both_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True)
check("⚠️ ...and the reason carried across is the FLAT one - the misdirected "
"latch's own - and not `old-shed`'s, which belongs to a different "
"physical inverter",
"old-shed" not in _both.inverters[0].sign_wd.reason)
# ⚠️ AND THE OPERATOR MESSAGE IS ASSERTED, because review deleted it whole
# (`if stranded:` -> `if False:`) and 28/28 stayed green. `stranded` has no
# other consumer, so the list existed only to print a line nothing checked -
# and the line is the entire operator-facing half of this repair: it names the
# key that was stranded and the roster it was measured against. A repair an
# operator cannot see is a repair they cannot confirm.
_log_store = MemStore({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
unit_key(UNIT_A, "sign_tripped"): True,
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
SCHEMA_KEY: SCHEMA_VERSION})
_records = []
_h = logging.Handler()
_h.emit = lambda r: _records.append(r.getMessage())
logging.getLogger("goodwe").addHandler(_h)
try:
_logged, _ = controller(NAMED_OPTS, store=_log_store)
finally:
logging.getLogger("goodwe").removeHandler(_h)
_s44_lines = [m for m in _records if "SAFETY-44" in m]
print(f" SAFETY-44 operator lines logged: {len(_s44_lines)}")
check("⭐ the repair SAYS SO, at ERROR, naming SAFETY-44 - deleting this line "
"left the whole suite green, and it is how an operator learns their "
"latch was re-armed rather than never lost",
len(_s44_lines) == 1)
check("...and it names the STRANDED KEY, so the operator can find it in the "
"state file rather than being told something happened",
any(unit_key(UNIT_A, "sign_tripped") in m for m in _s44_lines))
check("...and the ROSTER it was measured against, which is the half that says "
"WHY no machine read it",
any("garage" in m and "shed" in m for m in _s44_lines))
# ⭐ AND `misdirected` IS MEASURED AT THE FUNCTION'S OWN LEVEL, BECAUSE IT IS
# UNREACHABLE FROM THE CALL SITE. `Controller.__init__` passes
# `roster_names(opts)[0]`, which `build_roster()` also names its first entry
# from, so `unit` is a member of `roster` by construction and the refusal can
# never fire there. Review measured that: dropping the `roster` argument from
# `_verify_moves()` inside `migrate_unit_keys()` survives 28/28. The argument is
# kept for the reason `_verify_moves()` gives for its own `roster=None` branch -
# it is what a NEW caller gets by default - and a guard nothing exercises is a
# guard nobody knows works, so it is exercised HERE rather than claimed.
_mis_store = MemStore({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
_mis = migrate_unit_keys(_mis_store, UNIT_A, ["garage", "shed"])
print(f" migrate_unit_keys(store, 'inverter-a', ['garage','shed']) -> "
f"ok={_mis.ok} misdirected={sorted(_mis.misdirected)}")
check("⭐ a WRONG `unit` - one that is not on the roster it was handed - comes "
"back `misdirected`, which is the refusal `migrate_maint_keys()` already "
"had and this one did not",
bool(_mis.misdirected) and not _mis.ok)
check("...and the schema marker is NOT stamped, so the migration runs again on "
"the next start rather than being recorded as finished - the one thing "
"store.py's header says must never happen",
_mis_store.data.get(SCHEMA_KEY) != SCHEMA_VERSION)
check("⚠️ ...while the SAME call with a unit that IS on the roster stamps the "
"marker - so the refusal is caused by the name and not by the fixture",
migrate_unit_keys(
MemStore({"sign_tripped": True, "sign_reason": "x"}),
"garage", ["garage", "shed"]).ok)
# The counterfactual, and it is the accepted baseline habit 7 asks for: the
# same store, the same migration, one thing different - the roster shape.
_flat_lat_store = MemStore({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured pre-DEV-02"})
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
_flat_lat, _ = controller(FLAT_OPTS, store=_flat_lat_store)
print(f" the same store on the FLAT config -> roster "
f"{[(i.name, i.sign_wd.tripped) for i in _flat_lat.inverters]}")
File diff suppressed because it is too large Load Diff
+78 -27
View File
@@ -2576,9 +2576,42 @@ check("...and so does the persistent notification, which outlives the page and "
# observe exactly {device, supervised, loop} at every SoC, because the shipped
# roster is one inverter and this claim cannot be filed there at all. See
# DECISIONS/SAFETY-30-per-unit-sign.md.
# ⚠️ OPT-09 ADDED THE ELEVENTH, AND IT IS THE ONE THIS TRIPWIRE WAS BUILT FOR
# THE SHAPE OF, SO IT NEEDS THE LONGEST PARAGRAPH. `pack` is a
# `Claim.limit(... P_SAFETY ...)` on ONE INVERTER'S actuator, filed only where it
# BINDS and only at a roster of TWO OR MORE, carrying that pack's own state of
# charge against that pack's own `bms_soc_floor`: `hi = 0` for a battery at or
# under its cut-out, `lo = 0` for one at 100 %.
#
# IT IS CONDITIONED ON A STATE OF CHARGE, WHICH IS EXACTLY WHAT THIS SET WATCHES
# FOR, so the argument has to be made rather than asserted. SAFETY-25's deferred
# Option B was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
# `loop_soc_floor` option: an EMS PREFERENCE, chosen by an operator, stopping the
# grid-following loop at a depth somebody picked, ON THE SITE. Four things
# separate this from it, and all four are graded elsewhere in this repository:
#
# 1. THE NUMBER IS NOT AN OPTION. It is `bms_soc_floor` - measured off
# register 0x0560 and required of every roster entry since SAFETY-32. No
# new option is added and `peak_soc_floor` is deliberately NOT consulted
# (test_packshare.py part 2 grades that a pack at 15 % with a 10 % cut-out
# is NOT closed, though a 20 % EMS floor would have closed it).
# 2. IT IS A UNIT CLAIM, SO IT CANNOT BOUND THE SITE TARGET. It changes WHICH
# INVERTER carries the target, never how big the target is.
# 3. IT IS NOT FILED AT ALL AT A ROSTER OF ONE, which is why the sweeps below
# still observe exactly {device, supervised, loop} at every state of charge
# from 0 to 100 % on the shipped install. Option A stands untouched there:
# ordinary self-consumption still discharges to the BMS cut-out.
# 4. AND THE MEMO ITSELF NAMES THIS AS THE REVISIT: "under ALLOC-01 the
# reserve becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a
# ninth claim here". Stage 1 is how an interval reaches the allocator, and
# stage 1's input is claims - so a unit-scoped claim is the shape that
# sentence asks for.
#
# If you are here because you added a TWELFTH, that argument is what this line
# wants from you, not an edit.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation",
"sign_site"}
"sign_site", "pack"}
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
# that a THIRD such indirection reads as an unrecognised append rather than as a
# silent gap in the scan.
@@ -2619,10 +2652,11 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
"- a claim filed through a shape it cannot parse has to fail here rather "
"than be counted as nothing, which is how a scan like this goes quietly "
"vacuous", len(_appends) >= 10 and None not in _filed)
check("...and the sources that cycle can file are EXACTLY these nine. THIS is "
"what makes part 10 a tripwire: replay_loop() hand-builds the claim list, "
"so it cannot see a claim main.py adds - and a loop-side SoC reserve is a "
"NINTH source. Filing one takes this line red on the line it is written",
check("...and the sources that cycle can file are EXACTLY these ELEVEN. THIS "
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
"reserve is a new source. Filing one takes this line red on the line it "
"is written, and OPT-09's `pack` had to argue its way past it above",
set(_filed) == CLAIM_SOURCES)
@@ -2749,8 +2783,8 @@ check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into "
# structurally impossible. The structure is `CLAIM_SCOPE`, so `CLAIM_SCOPE` is
# pinned to the AST-scanned set rather than trusted to agree with it.
check("every source main.py can file is CLASSIFIED site or per-inverter, and "
"nothing else is. A TENTH source now goes red twice - once here for "
"having no classification, once above for being a tenth - and it takes an "
"nothing else is. A TWELFTH source now goes red twice - once here for "
"having no classification, once above for being a twelfth - and it takes an "
"argument about what the claim is ABOUT to make either line green",
set(CLAIM_SCOPE) == CLAIM_SOURCES)
print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
@@ -2758,8 +2792,8 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
+ " | per-inverter: "
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
"SAFETY-30's additions: the six about the GRID CONNECTION are site "
"claims and the four about a pack, a device or one write are "
"SAFETY-30's and OPT-09's additions: the six about the GRID CONNECTION "
"are site claims and the five about a pack, a device or one write are "
"per-inverter. `circulation` is a site claim because its quantity is "
"defined over the roster and is identically zero for any one unit; "
"`sign_site` is one because the number that tripped it is the SITE "
@@ -2769,7 +2803,7 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance"})
== {"device", "supervised", "failsafe", "maintenance", "pack"})
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
# real cycle files is a site claim. Observed on the claims main.py's own resolve
@@ -2853,8 +2887,9 @@ _restore_b()
print(" with a second inverter on the roster, main.py routes: "
+ ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items())))
check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"inverter and its OWN routing method splits the ten: `device`, "
"`supervised`, `failsafe` and `maintenance` bind the unit this controller "
"inverter and its OWN routing method splits the eleven: `device`, "
"`supervised`, `failsafe`, `maintenance` and OPT-09's `pack` bind the "
"unit this controller "
"drives, `loop`, `peak`, `arbitrage`, `safety`, SAFETY-31's `circulation` "
"and SAFETY-30's `sign_site` stay on the grid connection. Swap any one of "
"them - route `peak` to the unit and the site keeps no floor; route "
@@ -2865,6 +2900,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"rest of the roster goes on pushing the same way - and this line goes red",
_routed == {"device": "inverter-a", "supervised": "inverter-a",
"failsafe": "inverter-a", "maintenance": "inverter-a",
"pack": "inverter-a",
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
@@ -2948,16 +2984,16 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all TEN of `Controller.cycle()`'s DIRECT appends route through "
check("all ELEVEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once",
len(_direct) == 10
len(_direct) == 11
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE TEN THE LITERAL IT ROUTES THROUGH IS THE "
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
"SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed "
"`actuator_of(...)` at a call site goes red on - all nine of them, on "
"the line the typo is written, at every roster, INCLUDING the six "
@@ -2971,14 +3007,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
"site-scoped sources and is the whole defect for a per-inverter one: a "
"`device` limit built inside the roster loop and routed without `inv` "
"files inverter B's rating on inverter A, where it binds the wrong "
"share. Five of the ten appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe` and maintenance's two - and all five pass one. "
"share. Six of the eleven appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two and OPT-09's `pack` - and "
"all six pass one. "
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
"the site, beside `circulation`, because the number that tripped it is "
"the site target",
_unit_sources_unrouted == []
and len([r for r in _routing
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 5)
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 6)
check("...and the only appends this pin does NOT reach are the two that file a "
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
"`actuator=` at the call site to mis-type. Their routing is pinned where "
@@ -2987,7 +3024,7 @@ check("...and the only appends this pin does NOT reach are the two that file a "
"arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the eleven appends account for exactly the nine "
check("...and between them the twelve appends account for exactly the eleven "
"sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
@@ -3027,10 +3064,19 @@ check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN "
"connection",
sorted((c.source, c.actuator) for c in _cl2)
== [("device", "inverter-a"), ("device", "inverter-b"),
("loop", ACTUATOR_SITE), ("supervised", "inverter-a"),
("loop", ACTUATOR_SITE),
# ⚠️ OPT-09, AND IT IS OBSERVED HERE RATHER THAN ONLY PINNED
# STATICALLY. This fixture leaves both packs at 100 % - the last value
# of the SoC sweep above - so each one's `pack` claim closes its `lo`
# at 0 W: a full battery is not asked to charge. It is on ITS OWN
# actuator, which is the half a `pack` limit filed on the site would
# fail, and it appears ONLY here: the one-inverter sweep above files
# no `pack` claim at any state of charge, which is AC 4.
("pack", "inverter-a"), ("pack", "inverter-b"),
("supervised", "inverter-a"),
("supervised", "inverter-b")])
check("...and naming those three is not an accident of this fixture but the "
"reason the static pin exists - SEVEN of the ten sources need a state "
"reason the static pin exists - SEVEN of the eleven sources need a state "
"no check in this file reaches. SAFETY-31's `circulation` is one of the "
"seven: it is filed only while the site is MEASURED to be circulating, "
"which no fixture here is. SAFETY-30's `sign_site` is another, and it "
@@ -3079,16 +3125,21 @@ check("...and so is each unit's `device` bound, which is the other number the "
"inverter's reported range bound nothing at all",
_dev2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W),
"inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)})
_n2_plans = {p.unit: (p.lo, p.hi) for p in (_n2_alloc.plans if _n2_alloc else ())}
print(f" the envelopes the ARBITER RESOLVED: {_n2_plans}")
check("...and the bounds the ARBITER RESOLVED for inverter B - not just the "
"claims filed at it - are B's own, because a claim filed correctly and "
"then resolved against the wrong envelope would be the same defect one "
"layer down",
"layer down. ⚠️ THE `hi` IS B'S OWN RATING AND THE `lo` IS 0 W, and the "
"second half is OPT-09 rather than a defect: both packs in this fixture "
"sit at 100 % - the last value of the SoC sweep above - so each one's "
"`pack` claim closes its charge end. The two units' envelopes are still "
"DIFFERENT NUMBERS, which is what this check exists to observe: a bound "
"copied from the first unit would read +5000 on B",
_n2_alloc is not None
and next((p for p in _n2_alloc.plans if p.unit == "inverter-b"), None)
is not None
and (lambda p: p.lo == -B_RATING_HYPOTHESIS
and p.hi == B_RATING_HYPOTHESIS)(
next(p for p in _n2_alloc.plans if p.unit == "inverter-b")))
and _n2_plans.get("inverter-b") == (0.0, B_RATING_HYPOTHESIS)
and _n2_plans.get("inverter-a") == (0.0, LIVE_MAX_W)
and _n2_plans["inverter-a"] != _n2_plans["inverter-b"])
# --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER ---
+623 -20
View File
@@ -45,20 +45,23 @@ Read MUTATION.md before you report a survivor.
import ast
import asyncio
import hashlib
import inspect
import io
import json
import logging
import os
import sys
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from textwrap import dedent
from app.price import (
ALL_IN, Absence, BANNED_IN_PLUGIN_CODE, Component, FrameworkOption,
MAX_PLAUSIBLE_EUR_KWH, code_leaks,
PRICE_OPTION_DEFAULTS, PROVIDERS, PluginOptions, PricePoint, PriceProduct,
PriceProvider, PriceRead, ProviderCapabilities, RawPoint, SourceUnavailable,
arbitrage_allowed, assemble, brussels, brussels_offset, check_egress,
arbitrage_allowed, assemble, brussels, brussels_date_range,
brussels_offset, check_egress, dated_feed_url,
price_option,
export_price_eur_kwh, load_prices, plan_is_provisional, register,
volatility_regime,
@@ -148,6 +151,7 @@ def derived(fn):
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
# ⚠️ NOT the shipped 0.03 / 0.06. See this module's docstring: 0.06 is exactly
# 2 x 0.03, so under the shipped pair "the framework added the markup twice and
@@ -283,10 +287,31 @@ RETAIL_EUR_KWH = [0.1341, 0.0872, 0.2610] # EUR/kWh, markup already inside
EDDY_EUR_MWH = [141.0, 96.2, 268.4] # EUR/MWh, all-in
def json_feed(key, values, provisional_at=1):
"""A feed body. `provisional_at` makes ONE point provisional and the rest
not, so `plan_is_provisional` is graded against a mixed series - an
all-true or all-false fixture would return the same answer under any bug."""
def synthetic_row_feed(key, values, provisional_at=1):
"""An INVENTED row-per-point body. `provisional_at` makes ONE point
provisional and the rest not, so `plan_is_provisional` is graded against a
mixed series - an all-true or all-false fixture would return the same
answer under any bug.
⚠️ THE NAME SAYS `synthetic` BECAUSE TARIFF-08 IS WHAT HAPPENS WHEN IT DOES
NOT. This shape - `{"data": [{"start": ..., "price_eur_mwh": ...}]}` - was
invented by whoever wrote `_points_from_json()`, and until 2026-09-10 it
was the ONLY shape any price check in this repository had ever fed a
parser, `generic_dayahead` included. The public EPEX feed publishes
parallel arrays and no `data` key at all, so every live fetch that plugin
ever made raised `SourceUnavailable` while the suite stayed green: the
checks were grading the parser against the parser's own assumption. §11.4
then turned the failure into a quiet fall back to the static profile, so a
site would have sat on it forever.
The three plugins still fed from here - `engie_dynamic`,
`totalenergies_dynamic`, `eneco_eddy` - have NO captured body, because
their endpoints are unverified (TARIFF-07 is the open decision on whether
they are withdrawn). This fixture therefore grades that their DECLARATIONS
and the framework arithmetic are right; it grades NOTHING about what those
feeds publish, and it must not be read as evidence that they parse.
`generic_dayahead` is replayed from real bytes instead - section 2b.
"""
return json.dumps({"data": [
{"start": (T0 + timedelta(hours=i)).isoformat(),
key: v,
@@ -294,6 +319,37 @@ def json_feed(key, values, provisional_at=1):
for i, v in enumerate(values)]}).encode()
def columnar_feed(values, provisional_at=None, unit="EUR / MWh",
step_min=60, start=None, prices=None):
"""A body in the shape `api.energy-charts.info` really publishes.
Parallel arrays and no per-point object; `unix_seconds` are UTC epoch
seconds. `prices` overrides the price column outright, which is how the
unequal-length case below is built without hand-writing JSON.
⚠️ THERE IS NO PER-POINT `provisional` IN THIS SHAPE, so `provisional_at`
exists only to be REFUSED - the columnar document cannot carry the flag and
the plugin supplies it. It is a keyword the callers do not pass; the
parameter is here so that a future caller who reaches for it gets a
TypeError rather than a silently ignored argument.
"""
if provisional_at is not None:
raise TypeError(
"the columnar document has no per-point provisional flag; "
"`generic_dayahead` declares it in fetch(). See "
"_points_from_columnar_json().")
t0 = start if start is not None else T0
secs = [int((t0 + timedelta(minutes=step_min * i)).timestamp())
for i in range(len(values))]
doc = {"unix_seconds": secs,
"price": list(values) if prices is None else list(prices),
"unit": unit,
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
"from Bundesnetzagentur | SMARD.de",
"deprecated": False}
return json.dumps(doc).encode()
CSV_BODY = ("start,eur_per_kwh,provisional\n"
"2026-01-15T00:00:00+01:00,0.1834,false\n"
"2026-01-15T01:00:00+01:00,-0.0210,true\n"
@@ -302,14 +358,18 @@ CSV_BODY = ("start,eur_per_kwh,provisional\n"
# The fixture, the transport that serves it, and - crucially - the ENERGY
# component of each point, computed here so the all-in expectation below is
# arithmetic this file did itself.
# ⚠️ `generic_dayahead` IS FED THE COLUMNAR SHAPE AND THE OTHER THREE ARE NOT,
# and the asymmetry is the honest one rather than an oversight: the columnar
# document is what the one endpoint anybody has ever reached actually returns.
FIXTURES = {
"generic_dayahead": (json_feed("price_eur_mwh", EPEX_EUR_MWH),
"generic_dayahead": (columnar_feed(EPEX_EUR_MWH),
[v / 1000 for v in EPEX_EUR_MWH]),
"engie_dynamic": (json_feed("price_eur_kwh", RETAIL_EUR_KWH),
"engie_dynamic": (synthetic_row_feed("price_eur_kwh", RETAIL_EUR_KWH),
list(RETAIL_EUR_KWH)),
"totalenergies_dynamic": (json_feed("price_eur_kwh", RETAIL_EUR_KWH),
"totalenergies_dynamic": (synthetic_row_feed("price_eur_kwh",
RETAIL_EUR_KWH),
list(RETAIL_EUR_KWH)),
"eneco_eddy": (json_feed("price_eur_mwh", EDDY_EUR_MWH),
"eneco_eddy": (synthetic_row_feed("price_eur_mwh", EDDY_EUR_MWH),
[v / 1000 for v in EDDY_EUR_MWH]),
"manual_csv": (None, [0.1834, -0.0210, 0.3115]),
}
@@ -396,6 +456,528 @@ check("the whole envelope round-trips through json.dumps",
for r in READS.values())) is True)
# --------------------------------------------------------------------------- #
print()
print("2b. TARIFF-08 - the SHIPPED parser, replayed against REAL captured "
"bodies")
#
# ⚠️ WHY THIS SECTION EXISTS, WHICH IS THE MORE IMPORTANT HALF OF THE TICKET.
# Until 2026-09-10 every price fixture in this repository - here, in
# test_price_history.py and in test_static_tariff.py - was a
# `{"data": [{"start": ..., "price_eur_mwh": ...}]}` document invented by
# whoever wrote `_points_from_json()`. `api.energy-charts.info` has never
# published that shape. So the suite validated the parser against the parser's
# own assumption: a closed loop that cannot notice the assumption is wrong, and
# it stayed green while `generic_dayahead` raised `SourceUnavailable` on every
# live fetch it ever made. §11.4 then made that silent - a parse error becomes
# `no_data` becomes the static profile with arbitrage off, which is the CORRECT
# degradation and says nothing at all about the cause.
#
# The only thing that breaks the loop is bytes nobody here wrote. Both fixtures
# below are unmodified response bodies, captured by hand with curl on
# 2026-09-10; `scenarios/energy-charts-be-price-captures.json` records the URL,
# the retrieval time, the licence as returned and a sha256 of each, and the
# checksums are RE-DERIVED here so a fixture edited to make a check pass stops
# being a capture and starts being a failure.
#
# ⚠️ THE OTHER THREE NETWORKED PLUGINS HAVE NO CAPTURE AND THIS FILE DOES NOT
# PRETEND OTHERWISE. `engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy`
# point at endpoints nobody has verified; TARIFF-07 is the open decision on
# whether they are withdrawn. Their fixtures are named `synthetic_row_feed` so
# no reader can mistake them for evidence that those feeds parse, and the check
# below states, derived from the shipped `PROVIDERS` registry, exactly which
# plugins are replayed from real bytes and which are not.
_CAPTURES = json.load(open(
os.path.join(HERE, "scenarios", "energy-charts-be-price-captures.json"),
encoding="utf-8"))
def _capture(name):
with open(os.path.join(HERE, "scenarios", name), "rb") as fh:
return fh.read()
_REAL = {n: _capture(n) for n in _CAPTURES["files"]}
_bad_sha = sorted(n for n, b in _REAL.items()
if hashlib.sha256(b).hexdigest()
!= _CAPTURES["files"][n]["sha256"])
check(f"all three captured bodies are the bytes the provenance file "
f"recorded {sorted(_REAL)}",
len(_REAL) == 3 and _bad_sha == [])
# The document really is columnar, derived from the bytes rather than asserted:
# if it ever grows a `data` key this check says so before any parser does.
_shapes = {n: sorted(json.loads(b.decode("utf-8"))) for n, b in _REAL.items()}
check(f"...and every one is COLUMNAR - parallel arrays, no 'data' key "
f"{sorted({tuple(v) for v in _shapes.values()})}",
all(v == ["deprecated", "license_info", "price", "unit", "unix_seconds"]
for v in _shapes.values()))
check("...which is precisely the shape the shipped parser used to demand a "
"'data' list from",
all("data" not in v for v in _shapes.values()))
# ⚠️ CC BY 4.0 IS WHY THESE BYTES MAY BE IN THE REPOSITORY AT ALL. DATA-02
# established that the BE zone is redistributable with attribution; the feed
# restates the licence per response, so a capture whose own licence line has
# changed is a capture that must not stay committed.
_lic = {n: json.loads(b.decode("utf-8"))["license_info"] for n, b in _REAL.items()}
check(f"...and each carries the CC BY 4.0 line the provenance block cites",
all("CC BY 4.0" in v for v in _lic.values())
and all(_lic[n] == _CAPTURES["files"][n]["licence_as_returned"]
for n in _REAL))
_QH = "energy-charts-be-price-2026-09-10-quarterhourly.json"
_H1 = "energy-charts-be-price-2025-09-10-hourly.json"
# ⚠️ THE TWO GRANULARITIES ARE NOT DECORATION. Belgium moved to a 15-minute MTU
# on 2025-10-01 and the same endpoint answers 96 points at 900 s for a day after
# it and 24 at 3600 s for a day before it. A fixture at one spacing cannot grade
# whether anything assumes the other, so both are replayed and the spacings are
# DERIVED from the bodies rather than typed.
_spacing = {}
for _n, _b in _REAL.items():
_u = json.loads(_b.decode("utf-8"))["unix_seconds"]
_spacing[_n] = sorted({_u[i + 1] - _u[i] for i in range(len(_u) - 1)})
check(f"the two captures really differ in granularity - {_spacing[_QH]} s over "
f"{len(json.loads(_REAL[_QH])['price'])} points and {_spacing[_H1]} s "
f"over {len(json.loads(_REAL[_H1])['price'])} points - or replaying both "
f"grades nothing more than replaying one",
_spacing[_QH] == [900] and _spacing[_H1] == [3600])
_REPLAY = {n: load("generic_dayahead", transport=canned(b))
for n, b in _REAL.items()}
_not_series = sorted(n for n, r in _REPLAY.items() if r.kind != "series")
check(f"the SHIPPED generic_dayahead parses both real bodies "
f"{[r.words for r in _REPLAY.values()]}",
_not_series == [])
# The point count is the source's own interval count, taken from the document.
_counts = derived(lambda: {n: len(r.points) for n, r in _REPLAY.items()})
_want_counts = {n: len(json.loads(b.decode("utf-8"))["price"])
for n, b in _REAL.items()}
check(f"...emitting one point per published interval, no decimation "
f"{_counts}", _counts == _want_counts)
check(f"...which for the quarter-hourly body is 96 points and not 24 - the "
f"15-minute MTU is the shape of the real feed, not a hypothetical",
_want_counts[_QH] == 96 and _want_counts[_H1] == 24)
# §9.3: unix_seconds are UTC instants, RENDERED at the Brussels offset. Both
# captured days are inside CEST, so the correct first start is local midnight at
# +02:00. ⚠️ A NAIVE READING - `datetime.fromtimestamp(t)` with no tzinfo - is
# what this grades: it would produce the host's local wall clock, and on a UTC
# host that is 22:00 of the previous day. The expectation is computed here from
# the raw epoch second by different arithmetic than price.py uses.
_first_raw = {n: json.loads(b.decode("utf-8"))["unix_seconds"][0]
for n, b in _REAL.items()}
_first_utc = {n: datetime(1970, 1, 1, tzinfo=UTC) + timedelta(seconds=t)
for n, t in _first_raw.items()}
_first_got = derived(lambda: {n: r.points[0].start
for n, r in _REPLAY.items()})
check(f"every start is the UTC instant the feed published "
f"{ {n: v.isoformat() for n, v in _first_utc.items()} }",
isinstance(_first_got, dict)
and all(_first_got[n] == _first_utc[n] for n in _REAL))
check(f"...rendered at the Brussels offset, so a September day starts at "
f"00:00+02:00 and NOT at the 22:00 a naive local reading gives "
f"{ {n: v.isoformat() for n, v in (_first_got if isinstance(_first_got, dict) else {}).items()} }",
isinstance(_first_got, dict)
and all(v.utcoffset() == timedelta(hours=2) for v in _first_got.values())
and all((v.hour, v.minute) == (0, 0) for v in _first_got.values()))
# EUR/MWh -> all-in EUR/kWh, recomputed here from the raw column.
_allin_want = {n: [round(v / 1000 + MARKUP + TAXES, 6)
for v in json.loads(b.decode("utf-8"))["price"]]
for n, b in _REAL.items()}
_allin_got = derived(lambda: {n: [p.eur_per_kwh for p in r.points]
for n, r in _REPLAY.items()})
check(f"...and every price is the published EUR/MWh figure over 1000 plus "
f"markup and taxes (first: {[v[0] for v in _allin_want.values()]})",
_allin_got == _allin_want)
# ⚠️ NON-DEGENERACY. If the captured prices happened to be all equal, the
# arithmetic check above would pass under a parser that emitted the same number
# for every interval.
_raw_spread = {n: round(max(v) - min(v), 6)
for n, v in ((k, json.loads(b.decode("utf-8"))["price"])
for k, b in _REAL.items())}
check(f"...on bodies whose prices actually vary, or that check grades a "
f"constant {_raw_spread}",
all(v > 1.0 for v in _raw_spread.values()))
# --- the refusals, driven from the real body by breaking one thing ---------- #
def _edited(name, **fields):
"""The captured body with one field replaced. Everything else is real."""
doc = json.loads(_REAL[name].decode("utf-8"))
doc.update(fields)
return json.dumps(doc).encode()
_full = json.loads(_REAL[_QH].decode("utf-8"))
_short = load("generic_dayahead",
transport=canned(_edited(_QH, price=_full["price"][:-4])))
check(f"a `price` array 4 entries short of `unix_seconds` is no_data, NOT a "
f"series truncated to {len(_full['price']) - 4} points - the missing "
f"tail of a Belgian day is the evening peak, and a short series is "
f"traded on where no data at all switches arbitrage off (§11.4)",
_short.kind == "no_data")
check("...and the reason names the two lengths, so an operator can see it was "
"a length mismatch and not an outage",
_short.kind == "no_data"
and str(len(_full["unix_seconds"])) in _short.reason
and str(len(_full["price"]) - 4) in _short.reason)
# The other direction, so the check is not a one-sided `len(price) < len(time)`.
_long = load("generic_dayahead",
transport=canned(_edited(_QH, price=_full["price"] + [1.0])))
check("...and a `price` array one entry LONGER is refused too",
_long.kind == "no_data")
# ⚠️ THE CONTROL. Without it, "refuses everything" passes both checks above.
check("...while the untouched body is still a series, so the refusal is the "
"length and not the editing",
load("generic_dayahead",
transport=canned(_edited(_QH))).kind == "series")
_unit = load("generic_dayahead", transport=canned(_edited(_QH,
unit="EUR / kWh")))
check("a feed that changes its unit to EUR/kWh is no_data, not every price "
"silently divided by 1000 - MAX_PLAUSIBLE_EUR_KWH only bounds prices "
"that are too LARGE, so nothing downstream could catch it",
_unit.kind == "no_data")
_nulls = _full["price"][:]
for _i in (3, 17):
_nulls[_i] = None
_nulled = load("generic_dayahead", transport=canned(_edited(_QH,
price=_nulls)))
check(f"a published null is DROPPED, leaving a hole rather than a zero - a "
f"missing quarter is not a free quarter",
_nulled.kind == "series"
and derived(lambda: len(_nulled.points)) == len(_full["price"]) - 2)
check("...and the dropped instants are the ones the feed left null, not the "
"first two",
derived(lambda: sorted(
int(p.start.timestamp()) for p in _nulled.points))
== sorted(t for i, t in enumerate(_full["unix_seconds"])
if i not in (3, 17)))
_allnull = load("generic_dayahead",
transport=canned(_edited(_QH,
price=[None] * len(_full["price"]))))
check("...but a body with a price for nothing at all is no_data, not an empty "
"series", _allnull.kind == "no_data")
# ⚠️ WHICH RULE REFUSED IT, not merely that something did. `_validate_raw()`
# would also refuse an empty list, one layer further out and with a sentence
# that says nothing about the feed - so asserting only `no_data` here would
# grade the same under a parser that had lost this refusal entirely.
check(f"...refused by the COLUMNAR parser, naming what arrived, rather than "
f"reaching the boundary validator as a generic empty list - "
f"{_allnull.reason!r}",
_allnull.kind == "no_data"
and f"{len(_full['price'])} instants" in _allnull.reason
and "a price for none of them" in _allnull.reason)
# ⚠️ THE TWO WARNINGS ARE GRADED, NOT ASSUMED. §11.4 is exactly what makes a
# price defect quiet - a parse failure becomes no_data becomes the static
# profile, correctly and silently - so an unverified diagnostic is the first
# thing here to rot. Both are captured off the shipped logger.
_wbuf = io.StringIO()
_wh = logging.StreamHandler(_wbuf)
logging.getLogger("goodwe.price").addHandler(_wh)
load("generic_dayahead", transport=canned(_edited(_QH, price=_nulls)))
_dep = load("generic_dayahead", transport=canned(_edited(_QH,
deprecated=True)))
logging.getLogger("goodwe.price").removeHandler(_wh)
_warn = _wbuf.getvalue()
_n_null = len([v for v in _nulls if v is None])
check(f"the dropped nulls are reported with a count ({_n_null} of "
f"{len(_nulls)}), so a series with holes in it is visible to an operator "
f"rather than merely short",
f"{_n_null} of {len(_nulls)}" in _warn)
check("...and a feed announcing deprecated=true says so WHILE IT STILL WORKS - "
"the day it stops, §11.4 makes its death look exactly like a quiet one",
"deprecated" in _warn and _dep.kind == "series")
# Which plugins are replayed from real bytes, derived from the registry so a
# sixth supplier cannot be added without this line noticing.
_networked = sorted(pid for pid, cls in PROVIDERS.items()
if cls.capabilities().egress_hosts)
_replayed = ["generic_dayahead"]
check(f"the networked plugins are {_networked}; {_replayed} is replayed from a "
f"captured real body and {sorted(set(_networked) - set(_replayed))} have "
f"no capture because their endpoints are unverified (TARIFF-07)",
_networked == sorted(["generic_dayahead", "engie_dynamic",
"totalenergies_dynamic", "eneco_eddy"]))
# --------------------------------------------------------------------------- #
print()
print("2c. TARIFF-09 - the RANGE the caller asked for, on the Brussels "
"calendar")
#
# ⚠️ WHAT WAS WRONG, AND WHY NO CHECK IN THIS FILE COULD SEE IT.
# `generic_dayahead.fetch()` accepted `since` and `until` and requested the
# bare `https://api.energy-charts.info/price?bzn=BE`, which answers ONE
# Brussels day. Everything past today was then filled from yesterday's profile
# by FCST-04, flagged provisional, and §8.3 dropped all of it from the trade
# set - so the optimiser never saw tomorrow as firm, at any hour of any day.
# Measured on the merged tree at 2026-09-10T09:00+02:00 over a 48 h horizon:
# 15 firm whole hours and 33 carried, both ends of the only permitted cycle
# inside ONE calendar day; and at 23:00, 1 firm hour and 47 carried, where the
# firm spread no longer clears the round-trip cost at all.
#
# The reason section 2b could not catch it is that section 2b fed the parser
# BYTES and never looked at the REQUEST. `canned()` has always recorded the URL
# it was asked for and nothing asserted on it - so the transport knew the
# plugin was ignoring its arguments and no check ever asked. The checks below
# assert on the URL, and the two-day body they replay is a capture of what that
# URL really answers.
_TD = "energy-charts-be-price-2026-09-09_2026-09-10-twoday.json"
# ⚠️ NON-DEGENERACY FIRST (habit 1): the two-day capture must really cover TWO
# Brussels days and the one-day capture ONE, or "the range reached the feed"
# is graded against two fixtures that are the same shape.
_bdays = {n: sorted({brussels(datetime.fromtimestamp(t, UTC)).date().isoformat()
for t in json.loads(b.decode("utf-8"))["unix_seconds"]})
for n, b in _REAL.items()}
check(f"the captures span the local-day counts this section needs - "
f"{ {n: len(v) for n, v in _bdays.items()} } - or the range check has "
f"nothing to distinguish {_bdays[_TD]} from {_bdays[_QH]}",
len(_bdays[_TD]) == 2 and len(_bdays[_QH]) == 1
and len(_bdays[_H1]) == 1)
_npts = {n: len(json.loads(b.decode("utf-8"))["price"])
for n, b in _REAL.items()}
check(f"...and the two-day body carries 192 quarter-hours where the one-day "
f"body carries 96, which is the counterfactual this card is built on "
f"{_npts}",
len(json.loads(_REAL[_TD].decode("utf-8"))["price"]) == 192
and len(json.loads(_REAL[_QH].decode("utf-8"))["price"]) == 96)
# --- the URL the SHIPPED plugin asks for ----------------------------------- #
def asked_url(since, until, extra=None):
"""The URL `generic_dayahead` really requests for a window. Behaviour,
not source text: it comes back off the injected transport."""
t = canned(_REAL[_TD])
o = opts(price_provider="generic_dayahead", **(extra or {}))
run(load_prices(o, since, until, transport=t))
return t.calls[0][0]
# The expectation is built HERE, from the window, by different arithmetic than
# price.py uses: a literal string with the two dates spelled out.
_w0 = datetime.fromisoformat("2026-09-09T09:17:43+02:00")
check(f"a 48 h horizon from 09:17 local asks the feed for the three Brussels "
f"days it touches -> {asked_url(_w0, _w0 + timedelta(hours=48))!r}",
asked_url(_w0, _w0 + timedelta(hours=48))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-11")
check("...and the shipped default no longer asks the bare URL that answers a "
"single day",
asked_url(_w0, _w0 + timedelta(hours=48))
!= "https://api.energy-charts.info/price?bzn=BE")
# ⚠️ THE BRUSSELS/UTC BOUNDARY, WHICH IS THE TRAP. `since`/`until` are
# INSTANTS and the API takes DATES. At 22:30 UTC in September Belgium is
# already on the NEXT calendar day, so a range derived from `since.date()` -
# or from `since.astimezone(utc).date()`, which is the same thing - asks for
# the day the controller has just left and drops the day it is standing in.
# This is `_bdate()`'s lesson one layer up: price_history.py MISFILES an hour
# when it gets this wrong; here the hours are never fetched at all.
_edge = datetime.fromisoformat("2026-09-10T22:30:00+00:00")
check(f"the precondition: {_edge.isoformat()} really is a different day in "
f"Brussels ({brussels(_edge).date().isoformat()}) than in UTC "
f"({_edge.date().isoformat()}), or the boundary check grades nothing",
brussels(_edge).date().isoformat() != _edge.date().isoformat())
check(f"...so the range starts on the BRUSSELS day, 2026-09-11, and not on "
f"the UTC day 2026-09-10 -> "
f"{asked_url(_edge, _edge + timedelta(hours=12))!r}",
asked_url(_edge, _edge + timedelta(hours=12))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-11&end=2026-09-11")
# ...and the SAME trap on the other end. `until` at 22:30 UTC is already
# 00:30 of the next Brussels day, so a UTC-derived END stops one day short and
# the last hours of the horizon are never fetched. Both ends of the range
# derivation are graded, or a mutation only has to get one of them right.
_edge2_a = datetime.fromisoformat("2026-09-09T12:00:00+00:00")
_edge2_b = datetime.fromisoformat("2026-09-10T22:30:00+00:00")
check(f"the precondition: {_edge2_b.isoformat()} is "
f"{brussels(_edge2_b).date().isoformat()} in Brussels and "
f"{_edge2_b.date().isoformat()} in UTC, so the two answers differ",
brussels(_edge2_b).date().isoformat() != _edge2_b.date().isoformat())
check(f"...so the range ENDS on the Brussels day 2026-09-11, not on the UTC "
f"day 2026-09-10 -> {asked_url(_edge2_a, _edge2_b)!r}",
asked_url(_edge2_a, _edge2_b)
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-11")
# ⚠️ `until` IS EXCLUSIVE, so local midnight belongs to the day BEFORE it. A
# 24 h horizon from local midnight is ONE day, not two.
_mid = datetime.fromisoformat("2026-09-09T00:00:00+02:00")
check(f"a horizon ending exactly at local midnight asks for one day, because "
f"`until` is exclusive -> {asked_url(_mid, _mid + timedelta(hours=24))!r}",
asked_url(_mid, _mid + timedelta(hours=24))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-09")
check(f"...while one second past midnight is two days, so the rule is the "
f"exclusive END and not a fixed subtraction -> "
f"{asked_url(_mid, _mid + timedelta(hours=24, seconds=1))!r}",
asked_url(_mid, _mid + timedelta(hours=24, seconds=1))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-10")
check(f"an empty or inverted window collapses to a single day rather than "
f"asking for a backwards range no feed has a defined answer for -> "
f"{asked_url(_mid, _mid - timedelta(hours=5))!r}",
asked_url(_mid, _mid - timedelta(hours=5))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-09")
# The helper on its own, over the DST transitions, where a fixed-offset
# assumption and a correct one disagree.
_mar = datetime.fromisoformat("2026-03-29T00:30:00+00:00") # 02:30 CEST
_bdr = derived(lambda: brussels_date_range(_mar, _mar + timedelta(hours=1)))
check(f"brussels_date_range() is right across the spring transition, where "
f"00:30 UTC is already 02:30 local -> {_bdr}",
_bdr == (date(2026, 3, 29), date(2026, 3, 29)))
# --- the two-day body, end to end through the SHIPPED parser --------------- #
_two = load("generic_dayahead", transport=canned(_REAL[_TD]))
check(f"the shipped generic_dayahead parses the real two-day body "
f"{_two.words}", _two.kind == "series")
def _series_days(read):
return derived(lambda: sorted({brussels(p.start).date().isoformat()
for p in read.points}))
_two_days = _series_days(_two)
check(f"...and the series it produces spans TWO Brussels local days "
f"{_two_days}, where the one-day body's spans one "
f"{_series_days(_REPLAY[_QH])}",
_two_days == ["2026-09-09", "2026-09-10"]
and len(_two_days) == 2)
check(f"...one point per published interval, 192 of them, no decimation and "
f"no day dropped ({derived(lambda: len(_two.points))})",
derived(lambda: len(_two.points)) == 192)
# --- the SHORT ANSWER, which is normal and must stay a series -------------- #
#
# ⚠️ ASKING FOR TOMORROW BEFORE THE ~13:00-14:00 CET AUCTION RETURNS FEWER DAYS
# THAN REQUESTED, AND THAT IS NOT AN ERROR. Measured: at 2026-09-10T07:46:55Z
# the request `?bzn=BE&start=2026-09-10&end=2026-09-11` came back BYTE-IDENTICAL
# to the bare-URL capture - one day, 96 quarter-hours. A `no_data` there would
# switch a working site's arbitrage off every morning, so the requirement is
# that the short body stays a `series` of what EXISTS and FCST-04's fill covers
# the rest with flagged, provisional hours. The identity is re-derived from the
# bytes rather than restated.
_prepub = _CAPTURES["files"][_QH]["also_returned_verbatim_by"]
check(f"the pre-publication range request {_prepub['url']!r} returned the "
f"SAME BYTES as the bare default URL - one day where two were asked for",
hashlib.sha256(_REAL[_QH]).hexdigest()
== _CAPTURES["files"][_QH]["sha256"]
and "start=2026-09-10&end=2026-09-11" in _prepub["url"])
check(f"...and the shipped parser answers that short body with a SERIES of "
f"what exists, never no_data - {_REPLAY[_QH].words}",
_REPLAY[_QH].kind == "series"
and derived(lambda: len(_REPLAY[_QH].points)) == 96)
# ⚠️ THE COUNTS ARE TAKEN THROUGH `derived()` AND COMPARED ONLY WHEN BOTH ARE
# NUMBERS. A mutation that turns the short answer into `no_data` makes
# `.points` raise `Absence` by design, and `_Raised < _Raised` is a TypeError -
# which takes the file down with a traceback instead of reporting a FAIL. A
# check whose failure mode is a crash has no name attached (habit 5's shape,
# with an exception in place of a hang).
_n_short = derived(lambda: len(_REPLAY[_QH].points))
_n_two = derived(lambda: len(_two.points))
check(f"...which is strictly fewer points than the two-day body "
f"({_n_short} < {_n_two}), so 'short' is a real property of this "
f"stimulus and not a name for it",
isinstance(_n_short, int) and isinstance(_n_two, int)
and _n_short < _n_two)
# --- price_feed_url: the exact URL, and the cost said out loud ------------- #
#
# ⚠️ THE DECISION. An override is used VERBATIM and this plugin appends
# nothing to it. Merging our `start`/`end` into an operator's query string
# would mean re-serialising a URL they wrote, and `start`/`end` are
# energy-charts' spelling - and this provider also DECLARES a SECOND host,
# `web-api.tp.entsoe.eu`, whose date parameters are not those and are not
# verified here, so appending our vocabulary there yields two ignored
# parameters and the short horizon anyway. The cost is
# that an overriding site silently gets the horizon this card exists to fix,
# so it is NOT silent: a warning names the consequence at fetch time.
_ovr = "https://api.energy-charts.info/price?bzn=BE&somethingelse=1"
check(f"an override is requested EXACTLY as given, with nothing appended -> "
f"{asked_url(_w0, _w0 + timedelta(hours=48), {'price_feed_url': _ovr})!r}",
asked_url(_w0, _w0 + timedelta(hours=48),
{"price_feed_url": _ovr}) == _ovr)
_obuf = io.StringIO()
_oh = logging.StreamHandler(_obuf)
logging.getLogger("goodwe.price").addHandler(_oh)
asked_url(_w0, _w0 + timedelta(hours=48), {"price_feed_url": _ovr})
_ovr_log = _obuf.getvalue()
_obuf2 = io.StringIO()
_oh2 = logging.StreamHandler(_obuf2)
logging.getLogger("goodwe.price").removeHandler(_oh)
logging.getLogger("goodwe.price").addHandler(_oh2)
asked_url(_w0, _w0 + timedelta(hours=48))
_dflt_log = _obuf2.getvalue()
logging.getLogger("goodwe.price").removeHandler(_oh2)
check(f"...and the operator is TOLD what it costs them, at fetch time, naming "
f"the horizon consequence rather than only the substitution",
"price_feed_url" in _ovr_log and _ovr in _ovr_log
and "provisional" in _ovr_log and "2026-09-11" in _ovr_log)
# ⚠️ THE CONTROL. Without it, "warns about everything" passes the line above.
check("...while the DEFAULT path says nothing, so the warning is about the "
"override and not about fetching",
"price_feed_url is set" not in _dflt_log)
# The builder refuses to append to a base that already has a query string,
# which is the "URL nobody intended" this decision is meant to make
# unreachable. Driven, not read off the source.
check(f"dated_feed_url() refuses a base that already carries a query string, "
f"rather than producing a second '?' - "
f"{_reason(lambda: dated_feed_url('https://h/p?a=1', b='2'))!r}",
raises(lambda: dated_feed_url("https://h/p?a=1", b="2"),
SourceUnavailable))
check("...and a clean base still builds, so the refusal is the existing query "
"string and not the builder",
dated_feed_url("https://h/p", b="2") == "https://h/p?b=2")
# --- the plugin contract: who consumes the range, pinned ------------------- #
#
# ⚠️ THIS IS A SOURCE-SHAPE PIN AND IS NAMED AS ONE (habit 4). The claim is
# about the CODE - which plugins reference the range arguments they are handed
# - which is the same kind of claim `code_leaks()` makes, and it is not
# evidence that any feed parses. `generic_dayahead`'s BEHAVIOUR is graded above
# by the URL it really requests.
#
# It exists because `fetch(self, opts, since, until, transport)` promises every
# plugin the range and the promise is NOT enforced. Enforcing it would oblige
# the three plugins whose endpoints nobody has verified (TARIFF-07) to invent a
# date-parameter vocabulary - TARIFF-08's defect moved from the response to the
# request. So the split is pinned instead, and a sixth supplier has to change
# this line rather than inherit the silence.
def _uses_range(cls):
src = dedent(inspect.getsource(cls.fetch))
tree = ast.parse(src)
names = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
return bool(names & {"since", "until"})
_uses = sorted(pid for pid, cls in PROVIDERS.items() if _uses_range(cls))
check(f"the plugins that CONSUME their `since`/`until` are {_uses}; the rest "
f"take the range and ignore it, which is what TARIFF-09 fixed for "
f"generic_dayahead and what TARIFF-07 has to decide for the others",
_uses == ["generic_dayahead"])
check("...and `manual_csv` is honestly among the ones that do not: it reads a "
"whole file and the fill windows it downstream, so a range parameter "
"would have nowhere to go",
"manual_csv" in sorted(set(PROVIDERS) - set(_uses)))
# --------------------------------------------------------------------------- #
print()
print("3. all-in: energy + markup + taxes, added exactly once (AC1, trap 1)")
@@ -444,12 +1026,29 @@ check(f"a negative day-ahead price passes through, all-in {_neg}",
_neg == [round(EPEX_EUR_MWH[1] / 1000 + MARKUP + TAXES, 6)])
# provisional is per point and comes from the feed, not from the series.
for pid, read in sorted(READS.items()):
#
# ⚠️ `generic_dayahead` IS EXCLUDED FROM THE "MIXED" REQUIREMENT, AND THE
# EXCLUSION IS THE POINT RATHER THAN A GAP. The columnar document the real feed
# publishes has no per-point provisional field - it is a settled auction result
# - so the plugin declares `False` for all of them. Demanding a mixed series
# from it would mean feeding it an invented body carrying an invented flag,
# which is exactly the closed loop TARIFF-08 exists to remove. It is graded on
# what its real body can grade instead, immediately below.
for pid in sorted(set(READS) - {"generic_dayahead"}):
read = READS[pid]
flags = derived(lambda r=read: [p.provisional for p in r.points])
check(f"{pid}: provisional is per point and mixed {flags}",
isinstance(flags, list) and any(flags) and not all(flags))
check(f"...so plan_is_provisional is derived from those points",
derived(lambda r=read: plan_is_provisional(r)) is True)
_gen_flags = derived(lambda: [p.provisional
for p in READS["generic_dayahead"].points])
check(f"generic_dayahead's settled auction comes out settled at every point "
f"{_gen_flags}",
isinstance(_gen_flags, list) and len(_gen_flags) == len(EPEX_EUR_MWH)
and not any(_gen_flags))
check("...so plan_is_provisional is False for it, derived from the same points",
derived(lambda: plan_is_provisional(READS["generic_dayahead"])) is False)
# ...and the same function says False when nothing is provisional. Without this
# arm, "always True" would pass every check above.
_settled = PriceRead.series(
@@ -759,7 +1358,7 @@ class _NewSupplier(PriceProvider):
_sixth = load("_test_sixth_supplier",
transport=canned(json_feed("price_eur_mwh", EPEX_EUR_MWH)))
transport=canned(synthetic_row_feed("price_eur_mwh", EPEX_EUR_MWH)))
check("a plugin invented outside price.py resolves through the registry",
_sixth.kind == "series" and _sixth.provider == "_test_sixth_supplier")
_sixth_regime = derived(lambda: _sixth.regime)
@@ -894,7 +1493,14 @@ _failures = {
"the source is unreachable": load("generic_dayahead", transport=_boom),
"the body is not JSON": load("generic_dayahead", transport=canned(b"<html>")),
"the feed is empty": load("generic_dayahead",
transport=canned(b'{"data": []}')),
transport=canned(columnar_feed([]))),
# ⚠️ THE ROW SHAPE IS A FAILURE FOR THIS PLUGIN, NOT A SECOND DIALECT. If
# `_points_from_columnar_json()` ever grows a sniff for `data`, this case
# goes from no_data to a series and TARIFF-08's scope rule is broken by a
# check rather than by a review.
"the body is the OTHER shape": load(
"generic_dayahead",
transport=canned(synthetic_row_feed("price_eur_mwh", EPEX_EUR_MWH))),
"the provider id is unknown": load("no_such_supplier"),
"no provider is configured": load("off"),
"the csv is missing": load("manual_csv",
@@ -1035,11 +1641,8 @@ check("the offset boundary is 01:00 UTC, not local midnight",
# A feed that publishes the duplicated hour must survive it. The 25-hour day is
# 25 strictly increasing instants; a plugin keyed on local hours would emit 24
# and be rejected by the boundary validator instead of silently mispricing.
_dst_feed = json.dumps({"data": [
{"start": (datetime(2026, 10, 24, 22, tzinfo=UTC)
+ timedelta(hours=i)).isoformat(),
"price_eur_mwh": 50.0 + i, "provisional": i == 24}
for i in range(25)]}).encode()
_dst_feed = columnar_feed([50.0 + i for i in range(25)],
start=datetime(2026, 10, 24, 22, tzinfo=UTC))
_dst_read = load("generic_dayahead", transport=canned(_dst_feed))
_dst_n = derived(lambda: len(_dst_read.points))
check(f"a 25-point autumn feed is accepted whole ({_dst_n} points)",
@@ -1164,7 +1767,7 @@ check("a userinfo prefix cannot smuggle a declared host past the check",
check("an operator-supplied URL to another supplier's host is refused",
load("generic_dayahead",
{"price_feed_url": "https://api.eneco.be/eddy/prices"},
transport=canned(json_feed("price_eur_mwh", EPEX_EUR_MWH))
transport=canned(synthetic_row_feed("price_eur_mwh", EPEX_EUR_MWH))
).kind == "no_data")
_declared = {pid: cls.capabilities().egress_hosts
for pid, cls in PROVIDERS.items()}
@@ -1176,7 +1779,7 @@ check("the timeout the plugin passes to the transport is the configured one, "
"not a hardcoded default",
(lambda t: (load("generic_dayahead", {"price_timeout_s": 7},
transport=t), t.calls[0][1] == 7.0)[1])(
canned(json_feed("price_eur_mwh", EPEX_EUR_MWH))))
canned(synthetic_row_feed("price_eur_mwh", EPEX_EUR_MWH))))
# --------------------------------------------------------------------------- #
+231 -2
View File
@@ -59,6 +59,7 @@ Read MUTATION.md before you report a survivor.
import ast
import inspect
import itertools
import os
import sys
from datetime import datetime, timedelta, timezone
@@ -272,16 +273,19 @@ check("the provenance audit REFUSES a carried hour flagged non-provisional - "
"the 'stale but plausible' point, which is SAFETY-18's frozen-but-fresh "
"meter one layer up",
raises(lambda: _fc._audit([_stale_copy], {}), ValueError))
# ⚠️ `published` MAPS AN HOUR TO A TUPLE, not to a point (FCST-04 H1). An hour
# may legitimately hold four published points - see the quarter-hourly block
# below - so the audit takes the whole set and checks membership by identity.
check("...and refuses a published hour that arrived as a COPY rather than as "
"the published object, which is where a flag gets lost",
raises(lambda: _fc._audit(
[PricePoint(start=TODAY_PART.points[0].start,
eur_per_kwh=TODAY_PART.points[0].eur_per_kwh,
provisional=TODAY_PART.points[0].provisional)],
{D0: TODAY_PART.points[0]}), ValueError))
{D0: (TODAY_PART.points[0],)}), ValueError))
check("...and never repairs a flag: the audit's only outcomes are silence and "
"an exception, so a series it passed was already correct",
_fc._audit(list(FILLED.points), {u: TODAY_PART.points[i]
_fc._audit(list(FILLED.points), {u: (TODAY_PART.points[i],)
for i, u in enumerate(DAY[:PUBLISHED_N])})
is None)
@@ -298,6 +302,231 @@ check("a PUBLISHED point that the feed itself flagged provisional keeps that "
and all(p.provisional for p in _eddy_filled.points[:2])
and _eddy_filled.points[0] is _eddy.points[0])
# ⚠️ AND A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR (FCST-04 H1).
# `eneco_eddy` settles per QUARTER and `_validate_raw()` accepts any
# strictly-increasing spacing, so a published hour can hold four points. The
# fill walks WHOLE HOURS to decide what needs carrying; that must not become a
# rule about what a published hour may contain. Measured on FCST-04's first
# draft, which keyed the published set `{_utc_hour(p.start): p ...}`: a
# 96-point feed came out 15 points long and its arbitrage spread fell from
# 0.143 to 0.014 EUR/kWh, on an ALL-FIRM, fully-published series.
_QN = 16 # four hours of quarters
_QUARTERS = PriceRead.series(
[PricePoint(start=brussels(D0 + timedelta(minutes=15 * n)),
eur_per_kwh=round(0.100000 + 0.011300 * n, 6),
provisional=False)
for n in range(_QN)],
PriceProduct.REALTIME, "eneco_eddy", D0)
_QHOURS = {_fc._utc_hour(p.start) for p in _QUARTERS.points}
check(f"the quarter-hourly fixture really is sub-hourly - {_QN} points across "
f"{len(_QHOURS)} hours - or every check below passes for an hourly feed "
f"too and grades nothing",
len(_QUARTERS.points) == _QN and len(_QHOURS) == _QN // 4)
_qfill = derived(lambda: fill_from_yesterday(_QUARTERS, yesterday=YESTERDAY,
since=D0, until=D1, stamp=D0))
_qfirm = derived(lambda: [p for p in _qfill.points if not p.provisional])
check(f"every one of the {_QN} published points survives the fill BY IDENTITY "
f"({len(_qfirm)} kept). One-per-hour keying would leave {_QN // 4}, and "
f"the spread over the hours the feed actually published would collapse "
f"with them",
len(_qfirm) == _QN
and all(any(p is q for q in _QUARTERS.points) for p in _qfirm))
check(f"...and the {len(DAY) - len(_QHOURS)} whole hours it published NOTHING "
f"for are carried, ONE point each: a carried hour is hourly whatever the "
f"feed's spacing is, because yesterday's profile is only known per "
f"wall-clock hour",
_qfill.kind == "series"
and len([p for p in _qfill.points if p.provisional])
== len(DAY) - len(_QHOURS))
check("...and the filled series is still ordered by instant across the two "
"kinds, so `at()` and `window()` are not reading a shuffled list",
_qfill.kind == "series"
and [p.start.astimezone(UTC) for p in _qfill.points]
== sorted(p.start.astimezone(UTC) for p in _qfill.points))
_PUB_Q = _fc._published_by_hour(_QUARTERS)
# ⚠️ `derived`, NOT A RAW CALL. Under the decimating mutation this check
# exists for, `_audit` RAISES here - and a harness that dies on the mutation it
# is grading reports a traceback instead of a FAIL, which MUTATION.md forbids.
check("the audit ACCEPTS four published points inside one hour, which is the "
"case the repeated-hour COUNT it replaced could not express",
derived(lambda: _fc._audit(list(_QUARTERS.points), _PUB_Q)) is None)
check("...and still REFUSES a repeated CARRIED hour, which is the DST "
"mispricing that count existed to catch",
raises(lambda: _fc._audit([_fc._carried(D0, 0.11),
_fc._carried(D0, 0.12)], {}), ValueError))
check("...and STILL refuses the SAME published point emitted twice - the "
"provider's points are strictly increasing, so a repeat is never a "
"second print. The old hour COUNT already refused this (one point twice "
"is one hour twice); what is graded here is that the relaxation did not "
"lose it, not that it added it",
raises(lambda: _fc._audit([_QUARTERS.points[0], _QUARTERS.points[0]],
_PUB_Q), ValueError))
check("...and STILL refuses a CARRIED point standing in an hour the feed DID "
"publish. The old COUNT never looked at it; the old IDENTITY rule did, "
"and raised - so this is preserved behaviour, not new strictness. "
"FCST-04's text called it new here and in `_audit`'s docstring, and a "
"measured differential says otherwise",
raises(lambda: _fc._audit([_fc._carried(D0, 0.11)], _PUB_Q), ValueError))
# ⚠️ AND WHAT THE AUDIT DOES NOT DO, STATED WHERE A READER OF THESE THREE
# CHECKS WOULD OTHERWISE ASSUME IT (FCST-05, MUTATION.md habit 16). `_audit` is
# a PROVENANCE post-condition, not a COMPLETENESS one: it grades every point
# that IS emitted and says nothing about points that are not. So it accepts the
# very decimation FCST-04 was raised for. That is not a regression - FCST-03's
# audit was equally blind - but it means the audit is NOT the thing that stops
# H1 coming back. The count and identity checks above this line are.
check("the audit ACCEPTS the H1 decimation itself - four published points in "
"one hour, only the last emitted - and accepts an EMPTY output. It is a "
"provenance check, not a completeness one, and the checks that guard "
"provenance completeness are the COUNT and IDENTITY ones above",
derived(lambda: _fc._audit([_QUARTERS.points[3]], _PUB_Q)) is None
and derived(lambda: _fc._audit([], _PUB_Q)) is None)
# --------------------------------------------------------------------------- #
# ⚠️ AND THE CLAIM "STRICTER" IS ITSELF A CHECK NOW (FCST-05).
#
# FCST-04 rewrote `_audit` and wrote, in the module and in the three checks
# above, that the result was STRICTER than FCST-03's. It is not. Two of the
# stated reasons were false - the old `seen` count already refused the same
# point emitted twice, and the old IDENTITY rule already refused a carried
# point in a published hour - and a right decision with a fabricated reason is
# a defect in this repository (MUTATION.md, and the reason DECISIONS files
# exist). So the claim is measured here rather than asserted in prose:
# FCST-03's audit is reproduced below from dd40c56 - its RULES verbatim, its
# message TEXT shortened, and nothing here reads a message - and the two are
# run over the whole domain `_audit` can express, in BOTH directions.
#
# THE DOMAIN, AND ITS SIZE. Every published mask over 3 hours (2**3) crossed
# with every output series of length 0-3 drawn from 3 hours x 5 point kinds.
# The nominal cross-product is 28 928; three of those kinds - published,
# published-provisional, copy-of-published - DO NOT EXIST for an hour the feed
# did not publish, and those combinations are SKIPPED rather than faked, which
# leaves 11 990 realisable comparisons. A fabricated "published point" for an
# unpublished hour is not in either audit's domain, and inventing one would
# grade the fixture rather than the code.
#
# ⚠️ THE OLD COPY IS A LITERAL, NOT A CALL. MUTATION.md habit 2: a differential
# whose two sides both come from the shipped module compares a thing against
# itself and moves with every edit. This side is frozen text.
def _audit_fcst03(points, published):
"""FCST-03's `_audit`, verbatim from dd40c56. Its `published` maps a UTC
hour to ONE point, which is exactly the expressiveness this ticket relaxed.
"""
seen = set()
for p in points:
u = _fc._utc_hour(p.start)
if u in seen:
raise ValueError("the filled series repeats " + u.isoformat())
seen.add(u)
src = published.get(u)
if src is None:
if p.provisional is not True:
raise ValueError(u.isoformat() + " is carried and is flagged "
"provisional=" + repr(p.provisional))
else:
if p is not src:
raise ValueError(u.isoformat() + " is published but the series "
"carries a COPY of that point")
def _verdict(fn, pts, pub):
try:
fn(list(pts), pub)
return "accept"
except ValueError:
return "raise"
_DH = [D0 + timedelta(hours=6 + i) for i in range(3)]
_weaker, _stronger, _compared = [], [], 0
for _mask in itertools.product((0, 1), repeat=3):
_pub = {u: PricePoint(start=u, eur_per_kwh=0.10 + i, provisional=False)
for i, (u, _on) in enumerate(zip(_DH, _mask)) if _on}
_old_pub = dict(_pub)
_new_pub = {u: (q,) for u, q in _pub.items()}
# The five shapes an output point can take at a given hour. Three of them
# do not exist for an hour the feed did not publish, and those combinations
# are skipped rather than faked - a fabricated "published point" for an
# unpublished hour is not in either audit's domain.
_kinds = []
for _u in _DH:
_src = _pub.get(_u)
_kinds.append(("published", _src))
_kinds.append(("published-provisional",
None if _src is None else
PricePoint(start=_src.start,
eur_per_kwh=_src.eur_per_kwh,
provisional=True)))
_kinds.append(("carried", _fc._carried(_u, 0.5)))
_kinds.append(("carried-unflagged",
PricePoint(start=_u, eur_per_kwh=0.5,
provisional=False)))
_kinds.append(("copy-of-published",
None if _src is None else
PricePoint(start=_src.start,
eur_per_kwh=_src.eur_per_kwh,
provisional=_src.provisional)))
for _n in range(4):
for _combo in itertools.product(_kinds, repeat=_n):
if any(k[1] is None for k in _combo):
continue
_pts = [k[1] for k in _combo]
_compared += 1
_o = _verdict(_audit_fcst03, _pts, _old_pub)
_nw = _verdict(_fc._audit, _pts, _new_pub)
if _o == "raise" and _nw == "accept":
_weaker.append((_mask, [k[0] for k in _combo]))
if _o == "accept" and _nw == "raise":
_stronger.append((_mask, [k[0] for k in _combo]))
# ⚠️ THE PRECONDITION FIRST (MUTATION.md habit 1). A differential over a
# domain that cannot reach the two disputed cases would score 0-0 for the wrong
# reason and read exactly like this one, so the two cases are driven against
# the OLD audit by name before the totals are graded.
_probe = PricePoint(start=_DH[0], eur_per_kwh=0.1, provisional=False)
check(f"the differential really covers the two claims it is here to settle, "
f"or it is {_compared} runs of nothing: FCST-03's own audit raises on "
f"BOTH 'the same published point twice' and 'a carried point in a "
f"published hour', which is what FCST-04 said it did not",
_compared > 3000
and _verdict(_audit_fcst03, [_probe, _probe], {_DH[0]: _probe}) == "raise"
and _verdict(_audit_fcst03, [_fc._carried(_DH[0], 0.5)],
{_DH[0]: _probe}) == "raise")
check(f"...and over all {_compared} of them the reworked audit is NOT WEAKER "
f"than FCST-03's: {len(_weaker)} case(s) the old one raised and this one "
f"accepts {_weaker[:2]}",
_weaker == [])
check(f"...and NOT STRONGER either: {len(_stronger)} case(s) the old one "
f"accepted and this one raises {_stronger[:2]}. FCST-04's docstring and "
f"two of these checks said it was stricter and named two reasons, and "
f"BOTH were already refused by the old count and the old identity rule. "
f"The only behavioural change is the RELAXATION below, which is what the "
f"rework was for",
_stronger == [])
_QP = [PricePoint(start=_DH[0] + timedelta(minutes=15 * i),
eur_per_kwh=0.1 + i, provisional=False) for i in range(4)]
check("...and the relaxation is real and is NOT expressible to the old audit: "
"four distinct published points inside one hour, which FCST-03's count "
"refused as a repeated hour and this one accepts",
_verdict(_fc._audit, _QP, {_DH[0]: tuple(_QP)}) == "accept"
and _verdict(_audit_fcst03, _QP, {_DH[0]: _QP[0]}) == "raise")
# ⚠️ THE HORIZON IS WHOLE HOURS AT BOTH ENDS (FCST-04 L8). `since` is floored
# and `until` was not, so a 48 h request made at any instant off the hour came
# back 49 hours long. Graded at an OFF-THE-HOUR instant, because on the hour
# the two implementations agree and the check would grade nothing.
_off = D0 + timedelta(hours=8, minutes=17, seconds=43)
for _h in (1, 12, 48):
_n = derived(lambda h=_h: len(fill_from_yesterday(
TODAY_PART, yesterday=YESTERDAY, since=_off,
until=_off + timedelta(hours=h), stamp=_off).points))
check(f"a {_h} h horizon asked for at {_off.isoformat()} - 17 min 43 s "
f"past the hour, the shape a running controller always has - is "
f"{_h} points long, not {_h + 1}",
_n == _h)
# The structural half: the constructor has nothing to pass, and the file is
# scanned so the pass-through idiom cannot come back looking ordinary.
check("_carried() has NO `provisional` parameter, so a caller has nothing to "
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23 -7
View File
@@ -69,6 +69,7 @@ from app.price import (
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
plan_is_provisional, required_spread, uncertainty_of,
)
from app.price_history import NoHistory
from app.static_tariff import (
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
parse_static_profile, resolve_tariff, static_plan, static_status,
@@ -742,11 +743,14 @@ def run(coro):
return asyncio.run(coro)
# ⚠️ NO `price_feed_url` (TARIFF-09): an override is used verbatim and the
# plugin appends no date range to it, so setting one - even to the feed's own
# address - exercises the override path rather than the shipped default.
_cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
price_timeout_s=1, price_markup_eur_kwh=0.03,
price_taxes_eur_kwh=0.06)
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead))
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead,
history=NoHistory()))
check("a dead feed falls back to the static profile rather than to nothing",
_fb.origin == "fallback" and _fb.has_prices)
check("...with the SAME prices the configured-product path produces, because "
@@ -769,15 +773,27 @@ check("...and the provisional plan has no cheap window either: §8.3 forbids an
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
_fb_none = run(resolve_tariff(opts(None, price_provider="generic_dayahead",
price_timeout_s=1),
THU, THU + timedelta(days=1), transport=_dead))
THU, THU + timedelta(days=1), transport=_dead,
history=NoHistory()))
check("a dead feed with NO static_profile has nothing to fall back to, and "
"says that rather than inventing a tariff",
_fb_none.origin == "none" and not _fb_none.has_prices)
# A live feed still wins over the profile.
_body = json.dumps({"data": [
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
for i in range(24)]}).encode()
# ⚠️ COLUMNAR (TARIFF-08). `api.energy-charts.info` publishes parallel
# `unix_seconds`/`price` arrays and no `data` key; the `{"data": [...]}` body
# this used to build was invented by the parser it was grading, which is how a
# provider that could never parse a live response shipped green. Real captured
# bodies are replayed in test_price.py section 2b - the prices here are
# synthetic, the SHAPE is not.
_body = json.dumps({
"unix_seconds": [int((THU + timedelta(hours=i)).timestamp())
for i in range(24)],
"price": [40 + i for i in range(24)],
"unit": "EUR / MWh",
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
"from Bundesnetzagentur | SMARD.de",
"deprecated": False}).encode()
async def _live(url, timeout_s):
@@ -785,7 +801,7 @@ async def _live(url, timeout_s):
_live_plan = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1),
transport=_live))
transport=_live, history=NoHistory()))
check("a live feed is used in preference to the static profile",
_live_plan.origin == "provider"
and _live_plan.product is PriceProduct.DAY_AHEAD)
+11 -4
View File
@@ -74,7 +74,7 @@ check(f"the option surface was actually read - {len(ORDER)} options, not zero",
check("...and the repeatable `inverters:` section's per-entry keys were read "
"too, which is the half a top-level-only reader would miss",
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
and len(NESTED["inverters"]) == 7)
and len(NESTED["inverters"]) == 8)
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
"languages")
@@ -248,10 +248,17 @@ for what, text in BAD:
check(f"every shape outside the accepted subset RAISES rather than being "
f"skipped. Silently accepted: {_accepted}", not _accepted)
check("...and the accepted subset really does round-trip the shipped files - "
"71 options and 7 per-entry keys came back out of each, which is what "
"says the strictness above is not just refusing everything",
# ⚠️ COUNTED, NOT TYPED. UX-02 corrected this sentence's option count
# 70 -> 71 while OPT-09 independently added an eighth per-entry key
# (`capacity_kwh`), and the two edits collided here on the rebase - each
# restating a number the other had moved. The counts now come from the
# same parse the assertion uses, so the sentence cannot drift from it
# again. PROC-01.
f"{len(ORDER)} options and {sum(len(v) for v in NESTED.values())} "
f"per-entry keys came back out of each, which is what says the "
f"strictness above is not just refusing everything",
all(len(TR[lg]["configuration"]) == len(ORDER)
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 8
for lg in LANGS))
print(" ⚠️ the licence for this hand parser is NOT this file. "
"tools/supervisor_shape_probe.py section F loads the same two files with "
+47
View File
@@ -264,6 +264,53 @@ check("...but the ENTRY is refused all the same, by rule 9 and not by the "
"setpoint_entity": " "}]),
-5000, 5000), "has no setpoint_entity"))
# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL ---------------
# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE
# RUN, which is the bar every clause of this rule is held to. A capacity that is
# not a usable number is a pack the split would quietly stop preferring while
# the operator can see a value in the box; a capacity on SOME entries is worse,
# because `packshare.weights()` answers "no opinion" for the WHOLE roster in
# that case - so every key the operator filled in does exactly nothing and
# nothing says so.
def _with_cap(*caps):
o = {k: v for k, v in _PER_INVERTER_OK.items()}
o["inverters"] = [dict(e) for e in _PER_INVERTER_OK["inverters"]]
for entry, cap in zip(o["inverters"], caps):
if cap is not None:
entry["capacity_kwh"] = cap
return o
check("a roster stating a capacity on EVERY entry is accepted - and the two "
"differ (15.36 / 20.48, FROM DATASHEET for B), because a fixture whose "
"two per-unit numbers are equal cannot show a check reading the wrong "
"unit's",
validate_startup_config(_with_cap(15.36, 20.48), -5000, 5000) == [])
check("...and a roster stating NONE is accepted too, which is what OPTIONAL "
"means: the add-on has no opinion about the split and every unit weighs "
"1.0, exactly as it did before this card",
validate_startup_config(_with_cap(None, None), -5000, 5000) == [])
check("a capacity that is not a usable number of kWh is REFUSED, and the "
"refusal names the entry - zero, negative and a word alike",
all(why(validate_startup_config(_with_cap(15.36, bad), -5000, 5000),
"capacity_kwh") for bad in (0, -3, "twenty", 0.0)))
check("⚠️ AND A PARTIAL ROSTER IS REFUSED - stated on one entry and left empty "
"on the other. It is the clause that costs an operator something, and it "
"is the one worth having: a pack measured in kWh beside a sibling "
"weighing a dimensionless 1.0 is not a comparison, so the key would "
"silently change nothing at all",
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
"not PARTIAL")
and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000),
"not PARTIAL"))
check("...and the refusal names WHICH entry is which, because an operator with "
"three inverters told 'one of them is wrong' is sent to the wrong "
"cupboard",
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
"inverter-a")
and why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
"inverter-b"))
# --------------------------------------------------------------------------- #
print("3. a meter that can never read")
# ⚠️ These predicates belong to p1.validate_meter_options(), which build_source()
+282
View File
@@ -0,0 +1,282 @@
"""PKG-09 - grade the check that refuses a no-op deploy. `python3 test_version_gate.py`
⚠️ THE POINT OF THIS FILE IS THE REFUSALS, NOT THE CLEARANCES. A pre-deploy
check that only ever says "CLEARED" is the defect `MUTATION.md` habit 4 names -
"a check whose DATA cannot reach the failure it names" - which this repository
has recorded twelve times. So every refusal below is DRIVEN: a repository is
built in which the failure is really present, and the check is required to say
so. A clearance is only meaningful next to a refusal from the same harness.
The third property is the one that is easy to lose: a SKIPPED check must not
look like a PASSED one. Omitting `--options` cannot exit 0.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
GATE = os.path.join(HERE, "tools", "predeploy_check.py")
sys.path.insert(0, os.path.join(HERE, "tools"))
fails = []
tmpdirs = []
def check(name, ok):
print(f" {'ok ' if ok else 'FAIL'} {name}")
if not ok:
fails.append(name)
def git(repo, *args):
return subprocess.run(
["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t", *args],
capture_output=True, text=True)
def make_repo(version="0.7.0"):
"""A repo shaped like this one: <root>/goodwe_controller/{config.yaml,app,tools}."""
root = tempfile.mkdtemp(prefix="test_version_gate.")
tmpdirs.append(root)
addon = os.path.join(root, "goodwe_controller")
os.makedirs(os.path.join(addon, "app"))
write(addon, "config.yaml", f'name: t\nversion: "{version}"\n')
write(addon, "app/control.py", "X = 1\n")
write(addon, "CHANGELOG.md", "# Changelog\n")
git(root, "init", "-q", ".")
git(root, "add", "-A")
git(root, "commit", "-q", "-m", f"initial at {version}")
return root, addon
def write(addon, rel, text):
p = os.path.join(addon, rel)
with open(p, "w", encoding="utf-8") as fh:
fh.write(text)
def run_gate(addon, root, deployed, options=None):
"""Run the REAL gate against a throwaway tree.
⚠️ The script is invoked where it SHIPS, not copied into the fixture. It
imports the validator from its own sibling `app/`, which is the behaviour
that matters: the gate must check a config against the validator it ships
beside, not against whatever happens to sit next to a copy. Copying it into
the fixture made every validator assertion silently SKIP - the first draft
of this file did exactly that and reported four green skips as passes.
"""
cmd = [sys.executable, GATE,
"--deployed-version", deployed, "--tree", addon, "--repo", root]
if options:
cmd += ["--options", options]
return subprocess.run(cmd, capture_output=True, text=True)
VALID = {"max_w": 2000, "slew_w": 0, "target_grid_w": -10, "gain": 0.6,
"inverters": [], "setpoint_entity": "number.goodwe_setpoint_w"}
def opts_file(d):
fd, p = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as fh:
json.dump(d, fh)
return p
print("classification - what forces a rebuild and what does not")
from predeploy_check import is_runtime, shipped_version # noqa: E402
for p in ("goodwe_controller/app/control.py", "goodwe_controller/config.yaml",
"goodwe_controller/translations/en.yaml", "goodwe_controller/run.sh"):
check(f"runtime: {p}", is_runtime(p))
for p in ("goodwe_controller/CHANGELOG.md", "goodwe_controller/DOCS.md",
"goodwe_controller/test_control.py", "goodwe_controller/tools/x.py",
"goodwe_controller/DECISIONS/a.md", "README.md"):
check(f"NOT runtime: {p}", not is_runtime(p))
print("\nTHE REFUSAL - a runtime change behind an unchanged version")
root, addon = make_repo("0.7.0")
write(addon, "app/control.py", "X = 2\n")
git(root, "commit", "-qam", "SAFETY-99: change behaviour, bump nothing")
r = run_gate(addon, root, "0.7.0", opts_file(VALID))
check("refuses (exit 1)", r.returncode == 1)
check("...and says the deploy would be a NO-OP", "NO-OP" in r.stdout)
check("...and names the offending commit", "SAFETY-99" in r.stdout)
check("...and does NOT say cleared", "CLEARED for deploy" not in r.stdout)
print("\nPOSITIVE CONTROL - the same check must NOT fire on a docs-only change")
root2, addon2 = make_repo("0.7.0")
write(addon2, "CHANGELOG.md", "# Changelog\n\n## notes\n")
git(root2, "commit", "-qam", "PKG-99: documentation only")
r2 = run_gate(addon2, root2, "0.7.0", opts_file(VALID))
check("does not refuse a docs-only tree", r2.returncode == 0)
check("...and says there is nothing to deploy",
"nothing to deploy" in r2.stdout)
print("\nTHE CLEARANCE - a bumped version with a valid config")
root3, addon3 = make_repo("0.7.0")
write(addon3, "app/control.py", "X = 3\n")
write(addon3, "config.yaml", 'name: t\nversion: "0.8.0"\n')
git(root3, "commit", "-qam", "PKG-99: change and bump together")
r3 = run_gate(addon3, root3, "0.7.0", opts_file(VALID))
check("clears (exit 0)", r3.returncode == 0)
check("...and says so", "CLEARED for deploy" in r3.stdout)
print("\n⭐ A SKIPPED CHECK MUST NOT LOOK LIKE A PASSED ONE")
r4 = run_gate(addon3, root3, "0.7.0") # no --options
check("omitting --options cannot exit 0", r4.returncode != 0)
check("...says SKIPPED", "SKIPPED" in r4.stdout)
check("...says the config was NEVER CHECKED", "NEVER CHECKED" in r4.stdout)
check("...and does NOT claim clearance", "CLEARED for deploy" not in r4.stdout)
print("\nTHE OTHER REFUSAL - a config this version's validator rejects")
bad = dict(VALID, slew_w=1000) # rule 10: > 2/18 x 2000 = 222 W
r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad))
check("refuses a config the validator rejects", r5.returncode == 1)
check("...and warns it is exit 78", "78" in r5.stdout)
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
check("...and prints the validator's own words", "slew_w is 1000 W" in r5.stdout)
print()
print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED")
# ⚠️ EVERY FIXTURE ABOVE BUILDS A FRESH REPOSITORY WHOSE `version:` LINE IS
# INTRODUCED ONCE AND NEVER EDITED. That is precisely the shape in which the
# original `git log -S 'version: "'` looked correct - the pickaxe finds commits
# that change a string's COUNT, and introducing the line changes it from 0 to 1.
# A real repository edits the line IN PLACE, the count never moves again, and
# the lookup silently returns the commit that first added it.
#
# MEASURED on admin/goodwe-addon before the fix: `-S` named `0a1e61d`
# (2026-08-23, the original packaging commit) as "the last bump", so a correctly
# bumped tree was reported as 87 commits behind an unbumped version - with
# PKG-06's and PKG-07's own bump commits listed among the offenders. The tool
# cried wolf on every run, and none of the fixtures above could see it, because
# a test that builds its own one-bump history is a test asserting against a
# structure it built itself. MUTATION.md habit 4, in the file written to
# prevent a recurrence.
from predeploy_check import last_version_change # noqa: E402
root4 = tempfile.mkdtemp(prefix="test_version_gate.hist.")
tmpdirs.append(root4)
addon4 = os.path.join(root4, "goodwe_controller")
os.makedirs(os.path.join(addon4, "app"))
write(addon4, "config.yaml", 'name: t\nversion: "0.5.0"\n')
write(addon4, "app/control.py", "X = 1\n")
git(root4, "init", "-q", ".")
git(root4, "add", "-A")
git(root4, "commit", "-q", "-m", "initial at 0.5.0")
_first = git(root4, "rev-parse", "HEAD").stdout.strip()
write(addon4, "app/control.py", "X = 2\n")
git(root4, "commit", "-qam", "runtime change between bumps")
write(addon4, "config.yaml", 'name: t\nversion: "0.6.0"\n')
git(root4, "commit", "-qam", "bump to 0.6.0")
write(addon4, "app/control.py", "X = 3\n")
git(root4, "commit", "-qam", "another runtime change")
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0"\n')
git(root4, "commit", "-qam", "bump to 0.7.0")
_last_bump = git(root4, "rev-parse", "HEAD").stdout.strip()
check("⭐ THE LAST BUMP IS FOUND, NOT THE FIRST. An in-place edit of the "
"version line does not change how many times the string occurs, so a "
"pickaxe search returns the commit that introduced it and every bump "
"after that is invisible",
last_version_change(root4) == _last_bump)
check("...and it is emphatically not the commit that introduced the line",
last_version_change(root4) != _first)
r6 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("a tree bumped with nothing after it clears, even with bumps in history",
r6.returncode == 0 and "CLEARED for deploy" in r6.stdout)
write(addon4, "app/control.py", "X = 4\n")
git(root4, "commit", "-qam", "SAFETY-98: runtime change after the last bump")
r7 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("and one runtime commit after that last bump is refused", r7.returncode == 1)
check("...naming only the commit since the LAST bump, not since the first",
"SAFETY-98" in r7.stdout and "runtime change between bumps" not in r7.stdout)
# ⚠️ AND THE FIXTURE ABOVE STILL COULD NOT TELL TWO IMPLEMENTATIONS APART.
# Review swapped `last_version_change` for "-G's newest raw hit, skip the value
# comparison entirely" and THE WHOLE SUITE STAYED GREEN, including the section
# above - because a linear history of real bumps never produces a commit that
# touches the version LINE without changing the version VALUE. So the docstring's
# central claim ("the VALUE is compared, and that is the only thing that answers
# it") was not pinned by anything, and `-G` alone would have shipped as if it
# were the fix. That is the third distinct shape of habit 4 in this one file.
#
# A cosmetic touch of the version line is the discriminator: `-G '^version: "'`
# matches it, and the value comparison does not.
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0" # pinned deliberately\n')
git(root4, "commit", "-qam", "COSMETIC: annotate the version line, same value")
_cosmetic = git(root4, "rev-parse", "HEAD").stdout.strip()
check("⭐ A COMMIT THAT TOUCHES THE VERSION LINE WITHOUT CHANGING THE VERSION "
"IS NOT A BUMP. `-G` matches it and a filter-only lookup would name it; "
"only comparing the VALUE against the parent's answers the question the "
"tool is actually asking",
last_version_change(root4) == _last_bump)
check("...and it is emphatically not the cosmetic commit",
last_version_change(root4) != _cosmetic)
r8 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("...and because config.yaml IS a runtime path, that cosmetic commit is "
"still counted as something unshipped - conservative, and correct: the "
"Supervisor rebuilds on the version string alone, so a changed file "
"behind an unchanged version is a no-op whatever changed in it",
r8.returncode == 1 and "COSMETIC" in r8.stdout)
print()
print("⭐ A GIT LOOKUP THAT GENUINELY FAILS - THE SHAPE NO FIXTURE COULD REACH")
# ⚠️ EVERY FIXTURE ABOVE CREATES ITS OWN REPOSITORY AS ITS OWN UID, so none of
# them could ever meet the failure that actually happened: the documented
# invocation runs the container as uid 0 against a checkout owned by the
# operator, git refuses with "detected dubious ownership", `_git` returns
# non-zero, and the no-op half reports SKIPPED. It degraded honestly - exit 2,
# never a false clearance - but the check this file exists for had never once
# run in its documented form, and nothing here could see that.
#
# `_git` now sets `safe.directory`, so ownership no longer breaks it. This
# section grades the REMAINING failure mode: git answering not at all.
root5 = tempfile.mkdtemp(prefix="test_version_gate.nogit.")
tmpdirs.append(root5)
addon5 = os.path.join(root5, "goodwe_controller")
os.makedirs(addon5)
write(addon5, "config.yaml", 'name: t\nversion: "0.7.0"\n') # NOT a git repo
check("a repository git cannot answer for reports no last-bump commit",
last_version_change(root5) is None)
r9 = run_gate(addon5, root5, "0.7.0", opts_file(VALID))
check("⭐ AND THE GATE DOES NOT CLEAR ON IT. An unanswerable git lookup is "
"INCONCLUSIVE, not clean - the one thing it must never do is call a "
"tree it could not inspect deployable",
r9.returncode != 0 and "CLEARED for deploy" not in r9.stdout)
check("...it says INCONCLUSIVE in those words", "INCONCLUSIVE" in r9.stdout)
# ⚠️ AND THE EXIT CODES ARE PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0`
# is satisfied by collapsing NOT-CLEARED into REFUSED, which review found the
# earlier draft could not tell apart. 1 and 2 mean different things to an
# operator: 1 says "this deploy is wrong", 2 says "nobody checked".
check("NOT CLEARED is exactly 2, not merely non-zero", r9.returncode == 2)
r10 = run_gate(addon3, root3, "0.7.0", opts_file(dict(VALID, slew_w=1000)))
check("REFUSED is exactly 1, not merely non-zero",
r10.returncode == 1 and "REFUSED" in r10.stdout)
print("\nthe shipped tree's own version is readable")
check("shipped_version() finds a version", bool(shipped_version(HERE)))
for d in tmpdirs:
shutil.rmtree(d, ignore_errors=True)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")
+185 -10
View File
@@ -722,18 +722,41 @@ print(f" e.g. {_sustained.reason!r}")
print()
print(" ⚠️ AND NOT ON THE TRANSIENT, WHICH IS THE FALSE-ALARM CASE")
# ⚠️ THE SHAPES BELOW ARE THE RIG'S, NOT INVENTED. Measured on TEST-19 over 924
# closed-loop runs in which every unit executed the share it was given: the
# worst continuous opposition above 700 W is 5.0 s - ONE control cycle - at a
# peak of 1700 W. That is the serialised write path: on a reversing cycle B's
# new number lands up to a whole cycle after A's, both slew through zero at
# 800 W/s behind a 10 s lag, and between the two crossings they genuinely
# oppose. A guard that trips on that is a false-alarm generator, and this
# project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19).
# THE SHAPE BELOW IS THE RIG'S: the serialised write path means that on a
# reversing cycle B's new number lands up to a whole control cycle after A's,
# both slew through zero behind a real ramp and a real lag, and between the two
# crossings they genuinely oppose. A guard that trips on that is a false-alarm
# generator, and this project's doctrine on crying wolf is explicit (SAFETY-15,
# SAFETY-19).
#
# ⚠️ THE MAGNITUDE IS A FIXTURE INPUT, NOT A MEASUREMENT, AND THIS COMMENT USED
# TO SAY OTHERWISE. It read "the worst single transient the rig can produce -
# 1700 W", sourced to a 924-run campaign whose harness IS NOT COMMITTED IN
# EITHER REPOSITORY (DECISIONS/SAFETY-31-circulating-power.md §3 says so in its
# own words). Three sweeps have now been run against that claim and no two agree:
#
# 924-run campaign, harness uncommitted peak 1700 W, 505.0 s at 100 W
# independent reviewer sweep, 2026-09-07 peak 2750 W, 15.0 s above 700 W
# goodwe `two_master_circulation`, 840 runs peak 1048 W, 5.0 s above 700 W
#
# ⚠️ THAT IS NOT EVIDENCE THAT ANY OF THEM IS WRONG. The three swept different
# grids, and the last one measured WHY the spread is so wide: on its own grid the
# worst legitimate peak is 1048 W on the modelled plant, 2250 W on `INSTANT`, and
# 0 W on `INSTANT` with no write stagger reaching a whole cycle. The plant model
# and the stagger set are TERMS of the number, and a worst case quoted without
# them is not a measurement. It is evidence that the claim cannot be adjudicated
# while the instrument is missing.
#
# So what this check now says is what it can support: 1700 W is LARGER than
# anything any of the three sweeps produced legitimately, and one control cycle
# of it does not trip. The committed, re-runnable version of the claim is
# `admin/goodwe`'s `sim/inject.py two_master_circulation`, which runs on every
# pull request there and requires ZERO of ~840 non-faulty runs to trip.
_one_cycle = [0.0] * 5 + [1700.0] + [0.0] * 30
_t1, _ = circ_drive(_one_cycle)
check("the worst single transient the rig can produce - 1700 W for ONE control "
"cycle - does not trip", not _t1.tripped)
check("a single transient of 1700 W - larger than the worst peak any committed "
"sweep of the legitimate family has produced - lasting ONE control cycle "
"does not trip", not _t1.tripped)
_repeated = ([0.0] * 3 + [1700.0]) * 20
_t2, _ = circ_drive(_repeated)
check("...and neither does it REPEATED every fourth cycle for 400 s, which is "
@@ -1091,6 +1114,158 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
"this' a different question from 'is this site tripped'",
len(_notes2) == 1)
print()
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
# ⚠️ THIS BLOCK EXISTS BECAUSE A MUTATION SURVIVED ALL 26 FILES. Replacing
#
# pos, neg = opposing([inv.batt for inv in self.inverters],
# min_w=self.circ_wd.cfg.min_w / 2.0)
#
# in `check_circulating()` with `pos, neg = ([], [])` (-102 B) silently deletes
# the "Right now X is discharging while Y charges" clause from the notification,
# and the whole suite stayed GREEN. The block above asserts three tokens -
# METER CANNOT SEE, CONTROL HAS BEEN STOPPED, isolator - and NEVER an inverter
# name, so the one sentence `circulation.py` calls "the first thing somebody
# woken by this alarm needs" and "impossible to reconstruct from the number
# alone" was graded by nothing at all.
#
# ⚠️ IT IS NOT DEAD CODE, WHICH IS THE PART THAT MAKES IT A CHECK GAP RATHER
# THAN A REACHABILITY ONE. Ledger M18 - the `await self.check_circulating()`
# call removed - goes red on four checks above, so this function runs. What
# nothing asserted was the ATTRIBUTION.
#
# ⚠️ AND THE ROSTER IS ["garage", "shed", "cellar"], WHICH IS THE OTHER HALF OF
# THE FIX. Every fixture above runs on `inverter-a`/`inverter-b`;
# test_maintenance.py section 11 records what that costs - on a fixture whose
# names are the module's own constants, a check cannot tell the roster from the
# constant, and `UNIT_A` is not even what a nameless entry falls back to. Here
# the names are an operator's, the two participants pull OPPOSITE ways so the
# sentence is not symmetric, and a third pack rests at 5 W. That makes three
# DIFFERENT mutations - the clause dropped, the two directions swapped, and the
# `min_w` floor dropped - into three DIFFERENT red checks. A fixture naming both
# units the same, or one in which the direction did not matter, would see the
# first and neither of the others.
_CIRC_ROSTER = [
dict(name="garage", setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
batt_entity="sensor.batt_a", max_w=5000),
# ⚠️ B_RATING_HYPOTHESIS, and part 4's note above says why it is not 4600:
# inverter B does not physically exist and a datasheet is not a measurement.
dict(name="shed", setpoint_entity="number.sp_b", soc_entity="sensor.soc_b",
batt_entity="sensor.batt_b", max_w=B_RATING_HYPOTHESIS),
# THE BYSTANDER. A third pack resting at standby draw, on the roster, read
# every cycle, and NOT a participant in anybody's loop.
dict(name="cellar", setpoint_entity="number.sp_c", soc_entity="sensor.soc_c",
batt_entity="sensor.batt_c", max_w=B_RATING_HYPOTHESIS),
]
_CIRC_NAMES = [e["name"] for e in _CIRC_ROSTER]
_REST_W = 5.0
class _NamedCircHass(_Hass):
"""Three named packs; the meter still reads exactly zero.
Same house as `_CircHass` - grid = load - sum(B) with two units cancelling -
with one extra pack idling at standby draw so that "who is named" and "who
is merely on the roster" are different questions.
"""
def __init__(self, a_w, b_w, c_w=_REST_W):
super().__init__()
self.vals = {"sensor.batt_a": a_w, "sensor.batt_b": b_w,
"sensor.batt_c": c_w}
async def number(self, entity, invert=False):
return {"sensor.grid": 0.0, "sensor.soc_a": 50.0,
"sensor.soc_b": 50.0, "sensor.soc_c": 50.0,
**self.vals}.get(entity)
def _named_trio(hass, store, *, hold_s=1, min_w=700):
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
"max_w": 5000, "gain": 0.6, "auto_start": True,
"circulation_w": min_w, "circulation_hold_s": hold_s,
"inverters": _CIRC_ROSTER}
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
c.auto = True
for inv in c.inverters:
inv.dev_min, inv.dev_max = -5000.0, 5000.0
return c
async def _circ_named(a_w, b_w):
hass = _NamedCircHass(a_w, b_w)
c = _named_trio(hass, _MemStore())
for _ in range(14):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.25)
notes = [x for x in hass.calls
if x[0] == "persistent_notification"
and x[2].get("notification_id") == "goodwe_circulating_power"]
return c, notes
_cg, _notes_g = asyncio.run(_circ_named(+3000.0, -3000.0))
_cs, _notes_s = asyncio.run(_circ_named(-3000.0, +3000.0))
_msg_g = _notes_g[0][2]["message"] if _notes_g else ""
_msg_s = _notes_s[0][2]["message"] if _notes_s else ""
print(f" roster : {_cg.units}")
print(f" garage discharging, measured: "
f"{dict(zip(_cg.units, [i.batt for i in _cg.inverters]))}")
print(f" -> {_msg_g.split(chr(10))[2] if _msg_g.count(chr(10)) > 2 else _msg_g!r}")
print(f" shed discharging, measured: "
f"{dict(zip(_cs.units, [i.batt for i in _cs.inverters]))}")
print(f" -> {_msg_s.split(chr(10))[2] if _msg_s.count(chr(10)) > 2 else _msg_s!r}")
# --- THE PRECONDITIONS, AS THEIR OWN CHECKS (MUTATION.md rule 1) ------------
check("PRECONDITION: the roster carries three DISTINCT operator-chosen names, "
f"none of them `UNIT_A` ({UNIT_A!r}) - so every claim below is a claim "
"about the roster and not about a constant that happens to equal it",
_cg.units == tuple(_CIRC_NAMES) and len(set(_CIRC_NAMES)) == 3
and UNIT_A not in _CIRC_NAMES)
check("PRECONDITION: the two runs are MIRROR IMAGES - same house, same meter at "
"0 W, same magnitudes, and the two participants swapped. Without this the "
"direction claim below could be satisfied by a sentence that ignores the "
"measurement entirely",
[i.batt for i in _cg.inverters] == [+3000.0, -3000.0, _REST_W]
and [i.batt for i in _cs.inverters] == [-3000.0, +3000.0, _REST_W]
and _cg.grid == 0.0 and _cs.grid == 0.0)
check("PRECONDITION: both runs actually tripped and actually raised the card, "
"so an absent sentence below is an absent sentence and not an absent alarm",
_cg.circ_wd.tripped and _cs.circ_wd.tripped
and len(_notes_g) == 1 and len(_notes_s) == 1)
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
_WHO_G = "Right now garage is discharging while shed charges."
_WHO_S = "Right now shed is discharging while garage charges."
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
"needs and impossible to reconstruct from the watts alone, and until this "
"line the sentence could be deleted with all 26 files green",
_WHO_G in _msg_g)
check("⚠️ AND WITH THE TWO SWAPPED, THE SENTENCE SWAPS. This is the half a "
"single fixture cannot have: `pos` and `neg` exchanged, or a hard-coded "
"sentence, satisfies the check above and fails this one. The operator is "
"being told which pack to walk to",
_WHO_S in _msg_s)
check("...and each run carries ONLY its own sentence - the mirrored alarm does "
"not also contain the other direction, so the clause is derived from this "
"cycle's measurement rather than assembled from the roster",
_WHO_S not in _msg_g and _WHO_G not in _msg_s)
check("...and the RESTING pack is not named. `cellar` is on the roster, is read "
"every cycle and is measured at 5 W of standby draw - `opposing()` is "
"called with a floor of half the trip threshold precisely so a pack idling "
"beside a 6 kW loop is not reported as one of its two participants",
"cellar" not in _msg_g and "cellar" not in _msg_s
and _cg.inverters[2].batt == _REST_W)
check("...and the sentence sits with the figure, not instead of it: the same "
"card still carries the watts, the window and the three things only this "
"alarm can say",
all("CIRCULATING_POWER" in m and "METER CANNOT SEE" in m
and "CONTROL HAS BEEN STOPPED" in m and "isolator" in m
for m in (_msg_g, _msg_s)))
print()
print(" the operator's press is the only way out, and it clears BOTH halves")
+306
View File
@@ -0,0 +1,306 @@
"""PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject.
python3 tools/predeploy_check.py --deployed-version 0.7.0 --options live-options.json
⚠️ RUN IT WHERE THE ADD-ON'S DEPENDENCIES EXIST. `app/validate.py` imports its
siblings and those need aiohttp, so on a bare host the validator half SKIPS -
loudly, and exit 2, never a silent pass. To get both halves:
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.7.0 --options /w/live-options.json
EXIT CODES. 0 cleared; 1 REFUSED, do not deploy; 2 NOT CLEARED - a check did not
run, and that is not the same as passing.
TWO FAILURES THIS ANSWERS, BOTH OF WHICH HAVE HAPPENED.
1. THE SILENT NO-OP. The Supervisor keys the built image by `version:` in
config.yaml. `ha apps update` against an unchanged version does nothing and
REPORTS SUCCESS. PKG-06 recorded this on 2026-09-05 with SIX merges behind
an unbumped version - its own commit message says so, and this line said
"four" through two review rounds; PKG-07 recorded it again on 2026-09-06 with six. Both
times a human noticed. Nothing else was ever going to.
2. THE REFUSED CONFIG. A config the shipped validator rejects is
`SystemExit(78)` - the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES
WITH IT, because the add-on serving it is not running, so recovery means the
Supervisor UI. SAFETY-39's rule 10 made this reachable by a config that
started fine on the previous version: it refuses an explicit `slew_w` above
SLEW_RAIL_FRAC x the site rail, and 0.7.0's own shipped default of 1000
exceeds the 555.6 W ceiling on a 5000 W rail.
⚠️ WHY THIS IS NOT A CI CHECK. The obvious version gate - "a pull request that
touches runtime code must bump the version" - is the WRONG shape for this
project, which deliberately batches many merges into one deploy. It would demand
a bump per pull request, put every branch in conflict over one line of
config.yaml, and still not prevent the actual failure, which happens at DEPLOY
and not at merge. The check belongs where the mistake is made.
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
reachable only through the Supervisor API, which needs a supervisor token, so
the operator exports it and passes `--options`. Without that flag the validator
half is SKIPPED and says so - a skipped check must never look like a passed one.
"""
import argparse
import json
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ADDON = os.path.dirname(HERE)
sys.path.insert(0, ADDON)
# Files that end up in the built image and can change what it DOES. A change to
# any of these needs a new `version:` or the Supervisor will not rebuild.
# CHANGELOG.md, DOCS.md, MUTATION.md, DECISIONS/, test_*.py, tools/ and
# scenarios/ are deliberately absent: they ship in the repo but cannot change
# runtime behaviour, and demanding a bump for a typo fix trains people to bump
# without reading, which is the habit this file exists to prevent.
RUNTIME = (
"goodwe_controller/app/",
"goodwe_controller/translations/",
"goodwe_controller/config.yaml",
"goodwe_controller/Dockerfile",
"goodwe_controller/run.sh",
"goodwe_controller/requirements.txt",
"goodwe_controller/build.yaml",
)
CONFIG_PATH = "goodwe_controller/config.yaml"
_VER = re.compile(r'^version:\s*"([^"]+)"', re.M)
def shipped_version(tree=ADDON):
with open(os.path.join(tree, "config.yaml"), encoding="utf-8") as fh:
m = _VER.search(fh.read())
return m.group(1) if m else None
def is_runtime(path):
return any(path == p or path.startswith(p) for p in RUNTIME)
def _git(*args, repo=None):
"""git, run so it answers regardless of who owns the checkout.
⚠️ WITHOUT `safe.directory` THIS TOOL SILENTLY DID HALF ITS JOB. The
invocation this file's own docstring documents runs the container as uid 0
against a checkout owned by the operator, and git refuses that:
fatal: detected dubious ownership in repository at '/w/goodwe-addon'
`_git` then returned non-zero, `last_version_change()` returned None, and
the no-op check reported SKIPPED - so **the silent-no-op half, which is the
reason this file exists, had never once run in its documented form.**
It degraded honestly: exit 2, INCONCLUSIVE, never a false clearance. But a
check that cannot fire is not a check, and the two documented environments
were mutually exclusive - in the container the validator ran and git
skipped; on a bare host git ran and the validator skipped on aiohttp.
Neither documented command ran both halves.
⚠️ AND IT WAS INVISIBLE IN THE ONLY CASE ANYONE HAD RUN. `main()` consults
the git result only when `shipped == deployed`; every real run so far
compared 0.7.0 against 0.9.0 and printed its verdict without needing git.
`test_version_gate.py` could not see it either: it invokes the gate with
`sys.executable` against fixtures it creates as its own uid, so no fixture
ever met the ownership mismatch. MUTATION.md habit 4, in a fourth shape,
inside the file written to prevent it.
`safe.directory` is the right fix rather than `--user`: it makes the tool
correct however it is invoked, instead of making every future caller
remember a flag. The read is read-only, on a repository the operator is
about to deploy from.
"""
where = repo or os.path.dirname(ADDON)
return subprocess.run(
["git", "-c", f"safe.directory={where}", "-C", where, *args],
capture_output=True, text=True)
def _version_at(sha, repo=None, path=CONFIG_PATH):
"""`version:` as of one commit, or None if the file or the key is absent."""
r = _git("show", f"{sha}:{path}", repo=repo)
if r.returncode != 0:
return None
m = _VER.search(r.stdout)
return m.group(1) if m else None
def last_version_change(repo=None):
"""The newest commit whose `version:` VALUE differs from its parent's.
⚠️ THIS USED TO BE `git log -S 'version: "'` AND IT WAS WRONG ON EVERY REAL
REPOSITORY. `-S` is the pickaxe: it finds commits that change the COUNT of
a literal string. A bump edits the line in place - `version: "0.7.0"` ->
`version: "0.8.0"` - so the substring still occurs exactly once before and
after, and `-S` never sees it. It found only the commit that first
INTRODUCED the line.
Measured on this repository: `-S` returned `0a1e61d` (2026-08-23, the
original packaging commit) as "the last bump", so the script reported the
correctly-bumped tree as 87 commits behind an unbumped version - listing
PKG-06's and PKG-07's own bump commits among the offenders. A tool whose
entire purpose is to be trusted before a deploy cried wolf on every run.
⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER. `-G'^version: "'`
matches a commit whose diff touches a line with that shape - which includes
a reformat, a quote change, or a comment moved onto the line, none of which
are bumps. So `-G` narrows the candidates and then the VALUE is compared
against the parent's. The question is "did the version change", and that is
the only thing that answers it.
⚠️ TWO GIT SHAPES THIS STILL GETS WRONG, NEITHER OF WHICH HAS EVER OCCURRED
IN THIS REPOSITORY - every bump here has been a clean, linear, forward-only
edit. Both were constructed in review rather than found:
1. A BUMP RESOLVED INSIDE A MERGE COMMIT IS INVISIBLE. `git log -G -- path`
does not diff merge commits at all without `-m`, so a version line whose
final value is decided by a conflict resolution is never a candidate.
The lookup then names the last ordinary bump, and the merge itself counts
as a runtime change since it - a FALSE REFUSAL of a tree that is in fact
correctly bumped.
2. REVERTING A BUMP WHILE KEEPING THE WORK MASKS THE WORK. Bump, change
`app/`, then revert only `config.yaml`: the version is back where it
started, the runtime change is still in the tree, and this reports
"nothing to deploy" - a FALSE CLEARANCE, which is the dangerous
direction.
Card PKG-11 carries both. They are recorded here rather than fixed because
a lookup that handles them costs more than the failure they represent, and
a caveat a reader can check beats a guarantee that is not true.
"""
r = _git("log", "--format=%H", "-G", r'^version: "', "--", CONFIG_PATH,
repo=repo)
if r.returncode != 0:
return None
shas = r.stdout.split()
for sha in shas: # newest first
here = _version_at(sha, repo)
there = _version_at(sha + "^", repo)
if here is not None and here != there:
return sha
return shas[-1] if shas else None
def runtime_commits_since_version_change(repo=None):
"""Commits touching RUNTIME since `version:` last changed. ([], None) if unknown.
Returns (commits, last_bump_sha). An empty list with a sha means the tree is
clean; an empty list with None means git could not answer and the caller
must NOT read that as a pass.
"""
last = last_version_change(repo)
if not last:
return [], None
r = _git("log", "--format=%H %s", f"{last}..HEAD", repo=repo)
if r.returncode != 0:
return [], None
out = []
for line in r.stdout.splitlines():
sha, _, subject = line.partition(" ")
f = _git("show", "--name-only", "--format=", sha, repo=repo)
if any(is_runtime(p) for p in f.stdout.split()):
out.append((sha[:10], subject))
return out, last[:10]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--deployed-version", required=True,
help="the version currently running ON THE BOX")
ap.add_argument("--options", help="path to the live options blob, exported "
"from the Supervisor. Omitted = that half is SKIPPED.")
ap.add_argument("--tree", default=ADDON)
ap.add_argument("--repo", default=None)
a = ap.parse_args()
problems, skipped = [], []
shipped = shipped_version(a.tree)
print(f" deployed on the box : {a.deployed_version}")
print(f" shipped in this tree: {shipped}")
commits, last = runtime_commits_since_version_change(a.repo)
if shipped is None:
problems.append("could not read `version:` out of config.yaml")
elif shipped == a.deployed_version:
if last is None:
skipped.append("could not ask git what changed since the last bump; "
"the no-op check is INCONCLUSIVE, not clean")
if commits:
problems.append(
f"THE DEPLOY WOULD BE A NO-OP. Version is {shipped} on both sides, and "
f"{len(commits)} commit(s) touching runtime files have landed since the "
f"version last changed ({last}). `ha apps update` will report success "
f"and install NOTHING. Bump `version:` first.")
for sha, subj in commits[:12]:
print(f" {sha} {subj[:76]}")
elif last is not None:
print(" no runtime change since the last bump - nothing to deploy")
if a.options:
# ⚠️ A TOOL RUN IN A HURRY BEFORE A DEPLOY MUST NOT ANSWER WITH A
# TRACEBACK. A missing or malformed export is the likeliest operator
# error here, and it is a REFUSAL, not a skip - the config was not
# checked and the script must not be re-run past it.
opts = None
try:
with open(a.options, encoding="utf-8") as fh:
opts = json.load(fh)
except FileNotFoundError:
problems.append(f"--options file not found: {a.options}")
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
problems.append(f"--options file could not be read as JSON "
f"({type(exc).__name__}: {exc}); the config was NOT checked")
if a.options and opts is not None:
try:
from app.validate import validate_startup_config
except Exception as exc: # pragma: no cover
skipped.append(
f"could not import the shipped validator ({exc}); the config check "
f"did NOT run. `app/validate.py` imports its siblings, and those need "
f"the add-on's runtime dependencies - so THIS SCRIPT ONLY CHECKS A "
f"CONFIG WHERE THOSE DEPENDENCIES EXIST: inside the add-on container, "
f"or in the CI image. Re-run it as:\n"
f" docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \\\n"
f" python3 /w/goodwe_controller/tools/predeploy_check.py \\\n"
f" --deployed-version <ver> --options /w/<options.json>")
else:
found = validate_startup_config(opts)
if found:
problems.append(
f"THE LIVE CONFIG IS REFUSED BY THIS VERSION'S VALIDATOR "
f"({len(found)} problem(s)). Deploying it is SystemExit(78): the "
f"add-on does not start, and the ingress panel goes with it because "
f"the add-on serving it is not running. Recovery is the Supervisor UI.")
for p in found:
print(f" - {p}")
else:
print(" live options pass this version's validator")
else:
skipped.append("--options was not given, so the live config was NEVER CHECKED "
"against this version's validator. A config refused at startup is "
"exit 78. Export the options and re-run before deploying.")
print()
for s in skipped:
print(f" ⚠️ SKIPPED: {s}")
if problems:
for p in problems:
print(f" ❌ {p}")
print("\n REFUSED - do not deploy.")
return 1
if skipped:
print(" NOT CLEARED - checks above did not run. Deploying now is a decision "
"made without them.")
return 2
print(" CLEARED for deploy.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,48 @@
# WHAT THIS BUILD IS SUPPOSED TO ANNOUNCE, recorded so that a change to the
# composition has something to disagree with. THREE COLUMNS:
#
# <entity_id> <unique_id> <device identifier>
#
# ⚠️ ALL THREE, AND THE SECOND AND THIRD ARE THE HALF THE FIRST DRAFT MISSED.
# A reference that carries only entity_ids cannot see the two changes that
# orphan an install without changing a single composed id:
#
# `BASE` renamed (`goodwe_ctl` -> anything) every unique_id changes
# `DEVICE["identifiers"]` changed HA sees a NEW device
#
# Home Assistant's MQTT integration keys its entity registry on `unique_id` and
# its device registry on the device identifier. Change either and HA creates a
# NEW entity beside the old one - and because discovery is published with
# `retain=True` and nothing publishes a removal payload, the OLD config stays on
# the broker and the dead entity stays in the registry, still named by every
# automation that referenced it, never updating again. The composed entity_id is
# identical on both, so an entity_id-only comparison reports a clean transition.
# That is the same shape as the habit-4 this file's own card already caught
# once: a reference that does not carry the thing that moved.
#
# ⚠️ AND `object_id` IS DELIBERATELY NOT A COLUMN. `app/mqtt.py` lines 36-42 and
# 61-76 record that HA 2026.8 does NOT honour `object_id` in a discovery
# payload - the id is composed from device name plus entity name - so renaming
# one is a true no-op and a column for it would make this file refuse a change
# that costs nothing. The check is graded on what HA actually keys on.
#
# PROVENANCE, stated exactly:
# - recorded 2026-09-08 from `release/1.0` at `meter_source: firmware`,
# roster of one, by reading `mqtt.discovery_payloads(units=[])`. It is what
# the code announces.
# - `sensor.goodwe_p1_sample_age` is additionally recorded in `app/mqtt.py` as
# VERIFIED AGAINST THE LIVE SYSTEM on 2026-08-26, and the ESP32's ingest
# guard subscribes to that exact string.
# - ⚠️ IT IS NOT A DUMP OF THE LIVE INSTALL'S ENTITY REGISTRY. That is what
# `--live-entities` is for. This file is about THE BUILD.
#
# Editing this file is how you say "the announcement changed on purpose". Do not
# edit it to make a check pass.
sensor.goodwe_setpoint goodwe_ctl_setpoint goodwe_rs485_controller
sensor.goodwe_grid_power goodwe_ctl_grid goodwe_rs485_controller
sensor.goodwe_battery_power goodwe_ctl_battery goodwe_rs485_controller
sensor.goodwe_battery_soc goodwe_ctl_soc goodwe_rs485_controller
sensor.goodwe_circulating_power goodwe_ctl_circulation goodwe_rs485_controller
sensor.goodwe_maintenance_phase goodwe_ctl_phase goodwe_rs485_controller
sensor.goodwe_controller_status goodwe_ctl_status goodwe_rs485_controller
sensor.goodwe_p1_sample_age goodwe_ctl_p1_age goodwe_rs485_controller
@@ -0,0 +1,22 @@
# ARCH-02 fixture: THE SHAPE THE LIVE BOX IS IN TODAY (a flat, single-inverter
# config). ⚠️ ILLUSTRATIVE ENTITY NAMES, NOT THE LIVE ONES - the owner runs the
# rehearsal against a copy of the real options blob, and this file exists so
# that the harness has a synthetic transition it can be graded on.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
inverters: []
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
@@ -0,0 +1,39 @@
# THE ENTITY IDS THE INSTALL HAS — a stand-in for the owner's real export.
#
# ⚠️ THIS FILE AND `announced-reference.txt` ANSWER DIFFERENT QUESTIONS AND ARE
# NOT INTERCHANGEABLE:
#
# announced-reference.txt what the BUILD announces. Loaded on EVERY run.
# Three columns, because HA keys on `unique_id` and
# on the device identifier as well as on the
# composed entity_id.
# this file (--live-entities)
# what the INSTALL has. One id per line. Orphans
# are graded against it: an id the install has and
# the AFTER shape does not announce is an orphan
# whatever the build's own history says.
#
# ⚠️ OMITTING `--live-entities` IS A REFUSAL (exit 2), NOT A QUIETER RUN. It
# used to be the other way round - an unreadable file refused, a missing flag
# passed with exit 0 and said nothing about the half it had not checked - and
# the owner runs this ONCE, at commissioning, under time pressure.
# `--no-live-entities` is the deliberate opt-out and it prints a standing
# banner in the report header and again on the exit line.
#
# PROVENANCE: these eight are what `release/1.0` announces at
# `meter_source: firmware`, roster of one, recorded 2026-09-08. They stand in
# for the export so the checks run in CI. ⚠️ THE REAL INSTALL CARRIES ROUGHLY 49
# `goodwe*` ENTITIES (card ARCH-02, read 2026-09-06); the other ~41 belong to
# the ESPHome firmware, to template sensors and to helpers, and this add-on
# announces none of them. The tool separates the ids it owns from the ids it
# does not rather than claiming orphans it cannot cause - which is the half of
# this file the eight-line stand-in cannot exercise, and the reason DOCS.md's
# owner step asks for the real export.
sensor.goodwe_battery_power
sensor.goodwe_battery_soc
sensor.goodwe_circulating_power
sensor.goodwe_controller_status
sensor.goodwe_grid_power
sensor.goodwe_maintenance_phase
sensor.goodwe_p1_sample_age
sensor.goodwe_setpoint
@@ -0,0 +1,42 @@
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
#
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
# every two-pack fixture in the suite named its first entry `inverter-a`, so
# `UNIT_A` and `inverters[0].name` were the same string in every check that
# existed and the defect was invisible. Names that are not `inverter-a` are what
# make a misdirected namespace something a rehearsal can SEE.
#
# `roster-inverter-a.yaml` beside this file is the same roster with the first
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
inverters:
- name: inverter-a
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
max_w: 5000
bms_soc_floor: 10
- name: inverter-b
setpoint_entity: number.goodwe_b_setpoint
soc_entity: sensor.goodwe_b_battery_soc_pct
batt_entity: sensor.goodwe_b_battery_power_w
max_w: 4600
bms_soc_floor: 10
@@ -0,0 +1,42 @@
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
#
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
# every two-pack fixture in the suite named its first entry `inverter-a`, so
# `UNIT_A` and `inverters[0].name` were the same string in every check that
# existed and the defect was invisible. Names that are not `inverter-a` are what
# make a misdirected namespace something a rehearsal can SEE.
#
# `roster-inverter-a.yaml` beside this file is the same roster with the first
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
inverters:
- name: garage
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
max_w: 5000
bms_soc_floor: 10
- name: shed
setpoint_entity: number.goodwe_b_setpoint
soc_entity: sensor.goodwe_b_battery_soc_pct
batt_entity: sensor.goodwe_b_battery_power_w
max_w: 4600
bms_soc_floor: 10
@@ -0,0 +1,11 @@
{
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a store that has ALREADY run the flat config under 0.7.0+, so DEV-02's `schema_version` is stamped and the SAFETY-02 latch is not tripped. NOT a copy of the live box's /data/state.json. This is the CLEAN CONTROL for the MAINT-05 half of the rehearsal: the flat->roster transition over it must come out with NO findings, which is what makes each deliberate mutation's RED mean something. A harness that is red on every input has not measured anything.",
"schema_version": 1,
"phase": "idle",
"phase_started": "2026-08-07T10:00:00+00:00",
"last_completed": "2026-08-07T18:42:00+00:00",
"last_start_attempt": "2026-08-07T10:00:00+00:00",
"sign_tripped": false,
"sign_reason": "",
"auto": true
}
@@ -0,0 +1,3 @@
{
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a FRESH INSTALL. No maintenance history, no latch, no schema markers: what /data/state.json looks like before the add-on has ever completed a cycle. It is a clean control, and it earned its place: the first draft of the harness reported this transition RED, because the DEV-02 migration writes both SAFETY-02 defaults under `inverter-a` before a roster exists and the tool called them stranded. Losing a default loses nothing."
}
@@ -0,0 +1,12 @@
{
"_comment": "INSTR-01 SYNTHETIC FIXTURE - `state-legacy.json` with a SECOND roster unit already latched. Not a copy of any live state file. It exists to restore M12 END-TO-END on the STAGED leg, which is the arrangement SAFETY-44 and INSTR-01's first draft both missed: `snapshot_over()` starts the after-leg over the file the BEFORE-leg wrote, so a transition-made destination is available there and the two questions the provenance clause and the narrowing ask are NOT incompatible. Every same-step arrangement makes them incompatible; that is what was measured and over-generalised to 'no fixture can'.",
"phase": "idle",
"phase_started": "2026-08-07T10:00:00+00:00",
"last_completed": "2026-08-07T18:42:00+00:00",
"last_start_attempt": "2026-08-07T10:00:00+00:00",
"sign_tripped": true,
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
"auto": true,
"unit/shed/sign_tripped": true,
"unit/shed/sign_reason": "SIGN_DISAGREEMENT: shed, latched before the switch"
}
@@ -0,0 +1,10 @@
{
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a PRE-DEV-02 flat state file. NOT a copy of the live box's /data/state.json; this session cannot read that one and did not try. It is built to carry, deliberately, the two shapes that state built by the happy path does not have: a LATCHED SAFETY-02 failsafe under the flat spelling, and a real maintenance history under the flat spelling. Both are LEGACY-SHAPED: there is no schema_version and no maint_schema_version, so this file was last written by a controller that had never heard of unit namespaces. Losing either one is silent.",
"phase": "idle",
"phase_started": "2026-08-07T10:00:00+00:00",
"last_completed": "2026-08-07T18:42:00+00:00",
"last_start_attempt": "2026-08-07T10:00:00+00:00",
"sign_tripped": true,
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
"auto": true
}
File diff suppressed because it is too large Load Diff
@@ -315,6 +315,114 @@ print(f" the Configuration tab renders {len(ui)} fields, in `schema:` order "
"(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE "
"SCHEMA BLOCK'S and not the options block's)")
# --------------------------------------------------------------------------- #
# ⚠️ UX-04: THAT COUNT IS NOW GRADED, AND IT IS WHY UX-01's AC 3 IS WITHDRAWN.
#
# UX-01 AC 3 said the visible count would drop to ~15, with the rest behind
# HA's "show unused optional configuration options" toggle. The line above has
# printed the refutation since the day it was written - 71, not 15 - and PRINTED
# IS NOT GRADED: this probe exited 0 through the whole of UX-01's review while
# displaying the number that contradicted the criterion it was evidence for.
# That is the defect, not the count.
#
# THE FRONTEND RULE, transcribed from the bundle in THIS image at the top of
# this section and replayed rather than paraphrased:
#
# _filteredSchema = (opts, schema) => schema.filter(e => e.name in opts
# || e.required)
#
# `opts` is the add-on's OPTIONS AS SUPERVISOR SERVES THEM - the operator's
# stored blob after `options:` has been deep-merged into it - so a key is
# hideable only when it is BOTH absent from `options:` AND not required.
#
# WHY THE CRITERION IS WITHDRAWN RATHER THAN IMPLEMENTED, in two measurements:
#
# 1. IT IS ARITHMETICALLY UNREACHABLE. Supervisor's own UiOptions marks
# `required` on every non-`?` key, and the filter above shows a required
# key UNCONDITIONALLY. So the floor - what the tab renders with `options:`
# EMPTIED ALTOGETHER, which is the most hiding that is even expressible -
# is the required count below. It is nowhere near 15. No arrangement of
# `options:` can reach the target; the criterion was never satisfiable.
#
# 2. THE 12 KEYS THAT *COULD* BE HIDDEN COST THE DEEP-MERGE DEFAULT. A key
# absent from `options:` has nothing for the Supervisor to merge into an
# existing box's stored blob. That is exactly the `delivery_hold_s`
# mechanism the 0.9.0 upgrade rests on: a key that 0.7.0's stored blob
# cannot contain resolves to its `options:` default instead of failing
# validation, and moving it out of `options:` is precisely how you get the
# exit 78 that mechanism exists to prevent. Worse, it would promote the 27
# unpinned in-code literal fallbacks that `test_defaults_pin.py` part 4
# ledgers into the LOAD-BEARING source of those defaults on a live install
# - which is the CFG-03 defect the same card's AC 4 exists to police.
#
# So: four fewer clicks, bought with the upgrade path and with the defaults
# pin. The decision is written up in
# DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md
# and the three assertions below are what keeps it a decision rather than an
# omission - if anyone empties `options:` to shorten the form, this goes red.
_hideable = sorted(set(schema) - set(defaults))
_required = [n["name"] for n in ui if n.get("required")]
def _filtered(opts):
"""The frontend's own `_filteredSchema`, replayed on this manifest."""
return [n for n in ui if n["name"] in opts or n.get("required")]
show(f"every one of the {len(schema)} `schema:` keys is ALSO in `options:`, so "
f"schema-only - the only set HA's toggle can hide - is EMPTY. UX-01 AC 3 "
f"is not partially met, it is unstarted. Hideable: {_hideable}",
not _hideable and len(schema) == len(defaults))
show(f"...so replaying the frontend's own _filteredSchema on the served options "
f"hides NOTHING: {len(_filtered(defaults))} of {len(ui)} fields render",
len(_filtered(defaults)) == len(ui))
# ⚠️ DERIVED, NOT TYPED, AND NOT A FLOOR. The assertion above read
# `len(_required) > 15` while the prose said 59 - so a manifest change dropping
# the required count to 20 would have passed while the sentence beside it stayed
# wrong. That is the same shape as the count this repo already fixed in
# `test_translations.py`: a number displayed but not graded.
#
# So the expected value is computed from config.yaml by Supervisor's OWN rule -
# a key is optional iff its schema string ends in `?`, plus `inverters`, which
# `UiOptions` marks optional because it is a list - and the assertion is an
# EQUALITY against that. Typing 59 here would rot the first time a key is added.
_OPTIONAL = {k for k, v in schema.items()
if isinstance(v, str) and v.rstrip().endswith("?")} | {"inverters"}
_EXPECT_REQ = len(schema) - len(_OPTIONAL)
show(f"...and that floor is DERIVED: {len(schema)} schema keys minus "
f"{len(_OPTIONAL)} optional ones (the `?` suffix, plus `inverters`) is "
f"{_EXPECT_REQ}, and Supervisor's own required flag agrees",
_EXPECT_REQ == len(_required))
# ⚠️ AND THE WITHDRAWAL'S OWN PREMISE, GRADED SEPARATELY. The equality above is
# deliberately NOT enough on its own: both sides read the same `?` convention,
# so marking a key optional moves BOTH and it stays green. That is correct - it
# makes the printed number impossible to leave stale - but it grades Supervisor
# agreeing with us, not the thing AC 3 was withdrawn for.
#
# AC 3 was withdrawn because the floor is FAR above the ~15 it asked for. If
# enough keys ever became optional that the floor approached 15, the withdrawal
# stops being true and this file should say so rather than keep printing a
# sentence that has quietly become wrong.
_AC3_ASKED = 15
show(f"...and the floor of {_EXPECT_REQ} is still far above the ~{_AC3_ASKED} "
f"AC 3 asked for, which is the whole reason it was withdrawn. If this ever "
f"goes red the withdrawal needs re-arguing, not re-asserting",
_EXPECT_REQ > 2 * _AC3_ASKED)
# ⚠️ AN ISOLATED COUNTERFACTUAL, and the number is Supervisor's own `required`
# flag rather than a count typed here. `options:` is emptied and NOTHING else
# varies, so the answer cannot be about anything but the merge.
show(f"...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is "
f"expressible - the tab still renders {len(_filtered({}))} required "
f"fields. THAT is the floor, and AC 3 asked for ~15. The criterion is "
f"withdrawn as unsatisfiable, not deferred.",
len(_filtered({})) == len(_required) and len(_required) == _EXPECT_REQ)
def _entry(lang, name, path=()):
node = TRANSLATIONS.get(lang, {}).get("configuration")
+5 -2
View File
@@ -78,6 +78,9 @@ configuration:
batt_invert:
name: "Battery power sign is reversed (optional)"
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
capacity_kwh:
name: "Usable battery capacity (kWh) (optional)"
description: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or its label. It is read for one purpose: dividing the site's target between the inverters by how much ENERGY each pack has left rather than by how many watts it is rated for. The two packs at the reference site are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so those two ways of dividing the work hand it to different batteries, and the energy one is the question worth asking. Optional: leave it empty and the add-on has no opinion, dividing the target evenly exactly as it did before. State it on EVERY inverter or on none of them - a pack measured in kWh next to one that is not is not a comparison, and the add-on refuses to start rather than ignore half of what you filled in."
estop_fitted:
name: "Hardware · RS485 emergency stop is fitted"
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
@@ -158,7 +161,7 @@ configuration:
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
price_feed_url:
name: "Tariff · Price feed URL override"
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere."
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere. WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN: the add-on adds nothing to it, not even the date range it normally asks its own feed for, so an override gets whatever horizon that URL returns - often a single day, with the rest of the plan carried over from yesterday and excluded from price arbitrage. A warning in the log says so on every price refresh. Clear this option to get the full horizon back."
price_horizon_h:
name: "Tariff · How far ahead to fetch prices (hours)"
description: "How many hours of prices to ask the source for."
@@ -236,7 +239,7 @@ configuration:
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
delivery_hold_s:
name: "Safety · Not-delivering report after (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus 1890 runs at one inverter as the control, which report nothing at any threshold): the worst legitimate episode those runs produce is 30.0 s, the floor of 30 s is the firmware's own link veto below which a unit is already being dropped from the split, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 6.2 times that, and the margin is sized on a measurement rather than a feeling: the second inverter in those tests is a copy of the first, and a second unit four times slower produces 90.0 s. Do not change it without re-deriving it. A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
auto_start:
name: "Advanced · Start controlling automatically on boot"
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
+5 -2
View File
@@ -58,6 +58,9 @@ configuration:
batt_invert:
name: "Teken van het batterijvermogen omkeren (optioneel)"
description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft."
capacity_kwh:
name: "Bruikbare capaciteit van de batterij (kWh) (optioneel)"
description: "De bruikbare capaciteit van DEZE batterij in kilowattuur, van het datablad of het typeplaatje. Ze wordt voor één ding gelezen: het verdelen van de doelwaarde van de installatie over de omvormers volgens hoeveel ENERGIE elke batterij nog heeft, in plaats van volgens het vermogen waarvoor ze gebouwd is. De twee batterijen op de referentiesite zijn 5000 W met 15,36 kWh en 4600 W met 20,48 kWh, dus die twee manieren van verdelen wijzen het werk aan verschillende batterijen toe, en de energieverdeling is de juiste vraag. Optioneel: laat leeg en de add-on heeft geen voorkeur en verdeelt gelijk, precies zoals voordien. Vul ze in bij ELKE omvormer of bij geen enkele - een batterij in kWh naast een batterij zonder waarde is geen vergelijking, en de add-on start liever niet op dan de helft van wat u invulde te negeren."
estop_fitted:
name: "Hardware · RS485-noodstop is geplaatst"
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
@@ -138,7 +141,7 @@ configuration:
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
price_feed_url:
name: "Tarief · Afwijkende URL voor de prijsfeed"
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen."
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen. WAT U HIER TYPT WORDT LETTERLIJK VERSTUURD: de add-on voegt er niets aan toe, ook niet het datumbereik dat ze normaal aan haar eigen feed vraagt. Een afwijkende URL levert dus de horizon op die die URL zelf teruggeeft - vaak één dag, waarna de rest van het plan uit de prijzen van gisteren wordt overgenomen en buiten de prijsarbitrage valt. Bij elke prijsvernieuwing verschijnt daarover een waarschuwing in het logboek. Maak dit veld leeg om de volledige horizon terug te krijgen."
price_horizon_h:
name: "Tarief · Hoe ver vooruit prijzen ophalen (uren)"
description: "Hoeveel uur aan prijzen er bij de bron opgevraagd wordt."
@@ -216,7 +219,7 @@ configuration:
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
delivery_hold_s:
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus 1890 tests met een omvormer als controlegroep, die bij geen enkele drempel iets melden): de langste terechte episode in die tests duurt 30.0 s, de ondergrens van 30 s is het eigen veto van de firmware waaronder een omvormer al uit de verdeling wordt gehaald, en de bovengrens is het factuurkwartier van 900 s. 185 s is 6,2 keer zoveel, en die marge is op een meting gebaseerd en niet op een gevoel: de tweede omvormer in die tests is een kopie van de eerste, en een tweede eenheid die vier keer trager is levert 90,0 s op. Verander dit niet zonder de afleiding opnieuw te maken. Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
auto_start:
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."