2fac0aaf06d69adafce99b145d2ee3ad1fdd1afd
115
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2fac0aaf06 |
TARIFF-17: a price component that varies by hour has somewhere to live
checks / unit (pull_request) Successful in 7m25s
`price.py` modelled the whole non-energy part of a Belgian bill as ONE flat scalar. This adds `price_taxes_profile`, which carries the TAXES component as a period table, and makes `assemble()` evaluate it at each point's own instant. ⚠️ THE FLAT SCALAR WAS NOT A WRONG NUMBER, AND THIS IS NOT A CORRECTION OF ONE. The optimiser reads exactly two quantities off a series - the spread `max - min` over the firm points, and which hour is cheapest - and an additive constant identical in every hour cancels out of the first and leaves the second where it was. It is provably inert. Measured, and printed by test_price.py 16b on every run: with a flat taxes figure of 0.00, 0.0739, 0.1198, 0.1327 or 1.00 EUR/kWh the same 24-hour feed is cheapest at 13:00 in all five cases and its spread is 0.1500 EUR/kWh in all five cases. What was missing is anywhere for an HOUR-VARYING non-energy component to live. This is a missing capability, not a bad constant. Belgium has one. Fluvius distribution is a single rate - its tariff-card row reads `Normaal | Uitsl. nacht`, where exclusive night is a separate physical circuit and not a night register - so nothing changes for the reference site in Flanders. The Walloon DSOs and Sibelga are on the same card with four columns, `Enkelvoudig | Dag | Nacht | Uitsl. nacht`, and the gap is real money (c/kWh): ORES 13,27 / 7,39; AIESH 15,17 / 8,21; Tecteo RESA 12,20 / 7,01; Sibelga 9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090. ⚠️ AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO. The cheap network hours are the cheap spot hours, so flattening them does not shift the curve - it UNDERSTATES the spread and declines cycles that would have paid. Same feed, same markup, the only difference being the taxes argument: flat 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 -> cheapest hour 13, spread 0.1500 ORES dag 0.1327 / nacht 0.0739 -> cheapest hour 03, spread 0.1788 The regression check grades WHICH HOUR IS CHEAPEST, not the spread: an implementation that reads the table once and adds one number to every point is the LEVEL bucket wearing a period table - right magnitude, wrong shape - and a spread assertion passes on it. Measured: hoisting the lookup out of `assemble()`'s loop (+17 B) is red on six named checks in test_price.py and GREEN in the other 28 files. ONE GRAMMAR, NOT TWO. `static_tariff.py` already shipped the periods, the day selectors, the tiling rule and the DST handling, so `parse_taxes_profile()` is the same parser with the option name and the "what this number means" clause as arguments. A second tiling rule would be a second set of 23- and 25-hour-day bugs. test_static_tariff.py section 10 pins that two ways: by parsing every module under `app/` and asserting the grammar is defined in exactly one of them, and by deriving each of thirteen refusal sentences for the new option from the old option's own sentence rather than transcribing them. `Component.TAXES` semantics, `code_leaks()` and the double-counting guarantee are untouched. The new key joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived from that set, and a plugin that names it is refused at `register()`. It reaches both plugins that take taxes - `custom_formula` ({ENERGY, MARKUP}) and `generic_dayahead` ({ENERGY}) - and not `manual_csv`, which is all-in: the table is routed through `capabilities().adds`, exactly as the scalar is. ⚠️ A MALFORMED TABLE IS REFUSED AT STARTUP, NOT AT THE NEXT FETCH. It is read on the ~30 s planning path, and a table that first fails at 03:00 is a site that stops pricing at 03:00, which §11.4 turns into a quiet fallback indistinguishable from a healthy fixed-tariff day. validate.py refuses three things: a table that does not tile the day; a table set beside a NON-DEFAULT `price_taxes_eur_kwh`, because the table wins and the scalar would then be a number the operator typed that decides nothing; and a whole card typed in CENTS, which is how these figures are printed. That last rung is not a restatement of the parser's +-10 EUR/kWh bound - ORES's 13,27 trips the parser, but Sibelga's entire card, 9,39 and 7,10, is under it and would otherwise have started as a 9.39 EUR/kWh levy. Both rungs are graded by the REASON each gives, and mutating them together (TEST-16) moves three rows. NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "", and the scalar path is unchanged arithmetically rather than merely in intent: test_price.py 16f reproduces the pre-TARIFF-17 expression and compares 12 capability/scalar combinations point for point with `==`. 13 mutations, every one red on a named check, ledger in test_price.py below section 16k with byte deltas and the one equivalent mutant named. 29/29 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
a11597e0b3 |
Merge remote-tracking branch 'origin/release/1.0' into TARIFF-11
checks / unit (pull_request) Successful in 7m20s
# Conflicts: # goodwe_controller/CHANGELOG.md |
||
|
|
610725bed9 |
TARIFF-11: the plan's account of itself, corrected and put on the page
Four defects of one shape - the add-on's report of its own tariff plan was wrong, always in the direction that flattered it - and one check, which is the deliverable. 1. `TariffPlan.words` counted POINTS and called them HOURS. The live add-on logged `dynamic_dayahead from generic_dayahead, 132 h` for 132 quarter-hourly points = 33 hours. Belgium moved to a 15-minute MTU on 2025-10-01, so this was wrong every time it was printed, and NOT by a constant factor: a 40-hour soak measured 1.79x .. 5.33x, because §8.3's carried points are hourly while the firm ones are quarter-hourly. `words` now reports both quantities, plus the firm/carried split and the spacing, in FCST-04's *Price history* wording so the two rows can be compared. 2. §8.3's firm-pair refusal and the flat-tariff refusal did the same, and they reach an operator through `self.arb.explain()`. This is exactly the point<->hour conflation TARIFF-14 removed from `at()`, left standing in the prose. `price_forecast.py`'s §8.3 carry-forward log line carried the third instance - a carried-HOUR count over a POINT count - and DOCS quotes it verbatim, so it is corrected here too. 3. The plan reached no HTTP surface at all. `rows["Tariff"]` was `static_status()`'s 30 s-tick sentence and never saw the provider plan - it read 'no static profile' at all 802 soak samples on a site whose feed was answering. It now carries the plan's shape. ⚠️ The two sources are kept distinguishable: the plan half is stamped with its own age, measured from `tariff_at`, because it can be up to TARIFF_MAX_AGE_S (5400 s) old. FCST-04 pins that row to DOCS as a pair; DOCS moves in this commit. 4. `main.py`'s "if and only if" was false. `cycle()` returns at the input hold before `self.arb = None`, so an armed site read `off (arbitrage_enabled)` - naming an option that is on - and, after one completed cycle, showed a decision up to 15 s stale as a current one. The row now reads the OPTION and separates three facts; `cycle_at` and `arb_at` make "this cycle reached no decision" an observation. THE DELIVERABLE IS THE CHECK. All four survived 29 green suites because nothing graded the strings a human is shown. `test_price.py` section 12 renders through the shipped `status_payload()`, parses each number out of its OWN SLOT and compares it against a figure computed from the fixture's construction parameters - not from the property the row was built out of (habit 4), and not as a substring of a sentence the same file wrote (habit 14). The fixture set is four plan shapes and the check computes and prints the field pairs no fixture separates. The *Arbitrage* row's states are driven through real cycles with a sensor answering None and graded by INEQUALITY. No behaviour changed: no threshold, refusal rung, claim or setpoint moved. MUTATIONS: 16, no survivors, one at +0 B (the risky delta) and red. Two were found by the battery INSIDE THIS CARD'S OWN CHECK: a -190 B survivor whose fixture varied two things at once (habit 4), and a mutation whose red was a TRACEBACK in an f-string operand and therefore named nothing (FCST-06). Both closed; the table is on the PR. All 29 test files green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
a991438e40 |
TARIFF-13: the coverage figure beside the peak stops resetting at a restart
checks / unit (pull_request) Successful in 7m27s
The capacity-tariff status row printed a peak read from SQLite next to a count
of banked quarters read from PROCESS MEMORY - `PeakTracker.recorded`, an `int`
set to 0 in `__init__` and counted up inside `sync()`. A 40-hour soak caught
what that means at a driven restart, on both instances independently:
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
Reproduced here before anything changed, through the real Controller and a real
restart - a second Controller object against the same file on disk - at 12 -> 0
with 12 rows standing in `quarter_blocks`.
The peak is durable and was never wrong. The COUNT is what says how much of the
billing month that peak covers, and 365 W over 120 quarters and 365 W over 0
quarters are different claims about the same number. Mid-month, after every
restart, the page made the weaker claim while displaying the stronger one's
value. That is TARIFF-01's own argument - an unreadable store must not read as a
zero peak, because a zero peak is disarming - one column over.
`PeakStore.banked_count(month)` asks the database, and `recorded` is a property
over it. Two more defects in the same variable fell out once it was driven, and
neither was in the ticket: it was never scoped to a MONTH (on 1 October the row
still showed September's tally beside October's empty month - measured), and it
missed the block `restore()` banks from the checkpoint, which never passes
through `sync()`.
⚠️ NOT 0, AND NOT None, WHEN THE STORE WILL NOT ANSWER. `banked_count()` returns
None and the row renders `an unreadable number of quarter(s) banked`.
`f"{None} quarter(s) banked"` was rejected because it reads as the same zero.
`0 quarter(s) banked` stays true and reachable - it is what a month nothing has
been written to says, and the two are asserted to be different text.
⚠️ THE DEDUPE IS NOT REMOVED WITH THE COUNTER. `_seen` still stops `cycle()`'s
once-a-second `sync()` re-banking a block `P1Ingest.blocks` keeps for a day. The
check that graded it asserted `recorded == 1` - the dedupe and the figure
reporting it were the same variable - and now grades `sync()`'s RETURN, because
counting rows cannot see a re-bank at all (PRIMARY KEY no-op).
THE TIE RULE, DECIDED RATHER THAN EMERGENT: at equal wattage the EARLIEST
quarter keeps the attribution. No bill and no guard moves - `peak_w` is still
only ever raised, the `>` term is untouched, SAFETY-07 reads the same watts.
The two readers DID disagree: the upsert's strict `>` kept whichever tied block
was WRITTEN first while `recompute_month()` has always kept the earliest, the
same only while blocks arrive in time order, and measured to diverge (09:45
against 09:00) fed newest-first.
⚠️ AND THE SOAK'S DRIFTING QUARTER WAS NOT A TIE. Six exactly equal 365.0 W
quarters re-attributed zero times on the shipped tree - `>` is strict. What
moved it was a real new maximum BELOW THE ROW'S OWN RESOLUTION: the row renders
whole watts, so 365.0000001 W is a new peak to the database and the same 365 W
to the reader. Left alone deliberately, and pinned so it is not re-diagnosed.
test_tariff.py parts 11 and 12. 12 mutations, all red, listed on the PR; M2 -
`banked_count()`'s sqlite3.Error arm returning 0 - was a SURVIVOR at -3 B on the
first battery, because a handle that never opened short-circuits before any SQL
runs and nothing reached the except arm. Closed with an open store whose table
is dropped under it, with a precondition that the same call answered 4 first.
main.py is NOT touched - TARIFF-11 holds its status rows. The row picks this up
without editing because `recorded` is an attribute access and is now a property.
The residual is stated in DECISIONS §3: `recorded` answers for now() while the
peak follows `checks(when=...)`, and the shipped install has exactly one call
site - `self.checks()` with no argument, main.py:4359 - where they are the same
month, so the live page is correct and durable. `recorded_in(when)` is shipped
and the row's move onto it is one token, for whoever holds main.py next; part 10
pins the present behaviour against a row count taken from `quarter_blocks` so
the gap cannot be quietly forgotten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
ac9854886b |
TARIFF-15: your own tariff card, as two coefficients the card itself grades
checks / unit (pull_request) Successful in 7m14s
A Belgian dynamic contract prices energy as `B x index + A`, and the two
coefficients exist on exactly one artefact: the customer's own tariff card.
There is no machine-readable source for them anywhere - CREG's consumer
tariff-card pages 404, CREG Scan is a JS wizard, the annual report has the
supplier list without per-product coefficients, VREG was unreachable, and
every coefficient found in research came off a supplier's own PDF, one
image-only. So nothing can be scraped and a per-supplier plugin would have no
source for its numbers, which is why TARIFF-07 withdrew three of them. The
customer is the only party who can see their own coefficients; they type them.
New provider `custom_formula`, one new option `price_formula` (a JSON string,
the `static_profile` precedent exactly), and a new module
`app/custom_formula.py`. The index path is `generic_dayahead`'s, not a second
one: the URL/override decision is now one shared `dayahead_feed_url()` both
plugins call, same feed, same Brussels-calendar range, same columnar parser,
same declared egress hosts.
It declares {ENERGY, MARKUP}, NOT ALL_IN. A card's Tariefformule line is the
energy component alone - Eneco tabulates network tariffs and levies
separately, ENGIE states the formula excludes network, distribution,
transport, levies and excise - so ALL_IN would lie for every operator who
typed what their card says and would drop the taxes from every hour
(0.2125 -> 0.1555 EUR/kWh, measured).
THE CARD IS THE GRADER, and that is why this is safe to ship. The framework
already mechanises that a PLUGIN cannot double-count. This introduces a risk
none of those guards can see because it is config, not code: the OPERATOR
double-counting, by folding network charges into `offset` while
price_taxes_eur_kwh carries them too. So validate.py recomputes the card's own
printed price from the operator's coefficients at startup and refuses to start
unless the two agree to within 0.01, naming the discrepancy. An inflated
offset cannot pass: 15.55 becomes 28.27. The same check catches VAT omitted,
VAT doubled, a transposed digit and a unit mismatch between the two sides.
And the limit, in the same sentence as the claim: it grades the coefficients
against the card, NOT the card against reality. A consistent-but-wrong pair
passes. A second refusal covers the one unit error the first cannot see - the
one applied to both sides at once - by bounding the card's own point to
0.02-1.00 EUR/kWh. Its FLOOR is applied to the energy figure rather than the
all-in one because the shipped 0.06 of taxes is three times that floor and
would carry a hundredfold error over it alone; found by writing the mutation,
not by review (M10, +0 B).
The market's own arithmetic, reproduced through the shipped code:
Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.5471 card prints 15,55
ENGIE (0.1 x 61.96 + 0.9) x 1.06 = 7.5218 card prints 7,522
NO EXPRESSION EVALUATION OF ANY KIND. The string is settable over HTTP through
the add-on's own ingress page, so eval on it is RCE in a root container
holding SUPERVISOR_TOKEN and driving an inverter over RS485 - but the deciding
argument is that an expression is UNVERIFIABLE: two coefficients can be graded
against the card, an arbitrary expression cannot be graded against anything,
so it would make the invisible-wrong-coefficient problem strictly worse. An
unknown field is REFUSED rather than ignored, so `"expr"` is told it does not
exist. Future shapes become named fields, never a grammar. test_price.py 15f
walks the module's AST for eval/exec/compile/__import__/literal_eval and
exercises the scanner's red direction on every run.
Refused at startup: card absent or out by >0.01; `units` absent (no default);
factor == 0 (every spread 0, arbitrage silently never fires - the message
points at static_profile); factor < 0 (inverts the market); vat_pct outside
0-30 (0 stays legitimate); non-finite or non-numeric; unknown field, index or
resolution; the plausibility band. A card older than ~6 months WARNS and does
not refuse - ENGIE freezes coefficients for a contract's duration. Negative
output prices are not refused anywhere.
A startup preview logs the next 24 h as `index EUR/MWh -> all-in EUR/kWh` with
min/median/max and the day's spread, through a new default-silent
`PriceProvider.startup_preview()` hook - only the plugin knows what went in to
its own arithmetic. Emitted once per process, and a preview that raises cannot
cost the site its prices.
`custom_formula` joins the config.yaml enum through the DERIVED set
PROVIDERS | RETIRED_PROVIDERS | {"off"}, not a hardcoded list. Both
translations describe the option and the new id. DOCS.md replaces the
"planned" sentence with the shape, a worked example from a real card, a real
startup transcript and the limit of the check stated beside the claim.
22 mutations, one per check added, no survivors - MUTATION.md carries the
ledger. M13 (the preview's index column) was a -13 B survivor of the whole
suite on the first attempt because the check graded the HEADING and not the
row; it is graded on a rendered row now. All 29 suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
f620f7cf80 |
TARIFF-07: three plugins that never fetched a price are withdrawn, and a site configured on one is told so
checks / unit (pull_request) Successful in 7m12s
engie_dynamic, totalenergies_dynamic and eneco_eddy are removed from PROVIDERS and from every surface that listed them. They did not stop working - they never worked. Re-verified by hand on 2026-09-13 before deleting anything: 404 with Kong's "no Route matched with those values"; a TLS handshake that fails because api.totalenergies.be is absent from the certificate's SAN list; 404 with an empty body. All three are also structurally impossible - the coefficients are per-contract and behind a customer login, there is no regulator index, and "Eddy" is not an Eneco product at all. ⚠️ BACKWARD-INCOMPATIBLE. A config legal on the current version is refused on the next. That is the point of the change: §11.4 turned every failed fetch into a silent fall back, so a site on one of these ids has been running on static_profile - or on nothing - since the day it was set, with nothing on its status page ever differing from a working provider that never found a cheap hour. validate.py now refuses a retired id with a message naming the id, the endpoint, what that endpoint answers, which tariff has really been in force, and what to set instead. No version number is picked here; that depends on a fact about the live box. ⚠️ config.yaml's price_provider enum deliberately KEEPS all three ids. The Supervisor validates the options file against that enum before the add-on starts, so removing them would make an existing config schema-invalid and the refusal above would never be reached on exactly the sites that need it. PROVIDERS is two; the enum stays six; test_price.py asserts the schema offers exactly PROVIDERS | RETIRED_PROVIDERS | {"off"}, derived rather than typed, and register() refuses to re-register a retired id. PriceProduct.REALTIME, _WIDE, Uncertainty.REALTIME_PRODUCT and WIDEN_FACTOR's 2.5 are KEPT with no shipped declarer - it is the AC2 seam, and a reviewer finding a mechanism with zero callers would reasonably delete it. Recorded in DECISIONS/TARIFF-07-withdrawing-three-plugins.md and pinned by a check. False claims corrected: every "Belgian imbalance product" claim about eneco_eddy (DOCS.md, price_forecast.py, price_history.py, static_tariff.py, FCST-04, CHANGELOG - the two released entries annotated in place rather than rewritten); the quarter-hourly examples now cite Belgian day-ahead, which really does settle per quarter since the 15-minute MTU; and export_price_eur_kwh's "no shipped supplier publishes a dynamic injection curve", which is false - Eneco publishes 0,1 x BELPEX-H - 1,188 and ENGIE (B x eSpot_15) - A, same two-coefficient shape with its own coefficients and its own VAT treatment, so the scalar is a simplification and not the market. DOCS.md's provider table is two rows and now says honestly what a Belgian dynamic customer should do today, including that generic_dayahead cannot express B x spot + A and that price_markup_eur_kwh: float(0,1) could not hold a negative offset. The formula provider is referenced as planned, not built. _points_from_json() went with the plugins it served: it read a shape no feed anybody here has ever reached publishes. The one place it survives is a negative stimulus, named row_shape_body(). Nine mutations driven, all red; table in the decision record. 29/29 suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
028c3e53fb |
Merge release/1.0 (TARIFF-12) into TARIFF-14
checks / unit (pull_request) Successful in 7m13s
Second base move while this card was open: |
||
|
|
435e1b6f7c |
Merge release/1.0 (TARIFF-10) into TARIFF-14
checks / unit (pull_request) Successful in 7m10s
CI on PR #80 was NOT a test failure. The workflow's "grade the merge, not the branch" step refused the PR because it does not apply, and said so itself: "REFUSED: refs/pull/80/head does not merge into release/1.0. NOTHING HAS BEEN TESTED. This is not a test failure - no test ran." Conflicts were CHANGELOG.md and test_price.py; app/price.py auto-merged. CONFLICTS RESOLVED * CHANGELOG.md - both entries belong under ## Unreleased. TARIFF-10's table row and section come first because it landed first. No version number is picked here; PKG-06/07's note stands untouched. * test_price.py - BOTH cards appended a section "13". Nothing is dropped: TARIFF-10's stays 13, this card's is renumbered 14, and TARIFF-10's temp-directory teardown stays at the end of the file. The two share no fixture, option dict or helper name. WHAT TARIFF-10 CHANGES FOR THIS CARD - nothing in the reasoning, and the check is recorded rather than asserted (decision note §7). In app/price.py the two do not meet: TARIFF-10 added provider_id() and provider_configured() beside price_option() and routed load_prices() through them - the option-reading layer, touching nothing in _Series, at(), covers(), window() or _cover_table(). The merged at() is byte-identical to the one the mutations were driven against. Behaviourally TARIFF-10 makes run_tariff_refresh() run with arbitrage OFF, so a provider-configured site now has a standing plan - and therefore a _Series - where before it had none. It does NOT add an at() consumer to the 30 s tick: static_status() builds a STATIC plan (hourly, immune), main.py calls only plan.words on the fetched plan, and price_forecast.price_permits()/begin_phase() have no caller in main.py at all. So TARIFF-10 neither creates nor closes any exposure this card is about - it enlarges the defect's reach the moment such a site arms trading, which strengthens the fix without changing it. RE-GRADED ON THE MERGED TREE, not carried over * all 29 test files green; * all EIGHT mutations re-driven, purged before each run, each reverted with the fingerprint re-checked. Same verdicts and same red-check counts as before the merge: M1 (the shipped one-hour scan, +68 B) red on 24 named checks across both files; M3 (the ZERO-BYTE tail rule) red on 4; M2/M4/M6 on 1 each, M5 on 2, M7 on 16, M8 on 14. * Clean app/price.py on the merged tree: 90d8ca31f7c0109d, 85953 B. * None of TARIFF-10's 650 new lines of checks moves under any of the eight - they grade resolve_tariff()'s reachability from a configuration, not the lookup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
f5ea39784d |
Merge release/1.0 (TARIFF-10) into TARIFF-12
checks / unit (pull_request) Successful in 7m16s
CHANGELOG.md was the only conflict: both entries belong under the same
`## Unreleased` heading, TARIFF-10's first because it merged first. No
version number picked.
app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed
WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` ->
`tariff_refresh_wanted(opts)`); this card changed what that task does on
a failed fetch. Nothing in the keep/replace reasoning changed, and the
two newly reachable configurations are now DRIVEN in test_arbitrage.py
8b rather than reasoned about:
* a provider configured with trading DISARMED is the case the guard
was designed for, and needs no second fixture because nothing on
this path reads `arbitrage_enabled` at all - asserted over the AST's
symbols and non-docstring constants, after two drafts of that check
went red on a CORRECT tree for matching the word "arbitrage" in a
docstring and in a log line;
* `price_provider: off` + a `static_profile` resolves to the
CONFIGURED product (`origin: "static"`), so `standing.from_feed` is
false on every refresh and the guard never latches - driven twice in
a row, because a guard that latched there would freeze a static
site's plan at its first window for the life of the install.
Also: the kept-plan log line said "arbitrage stops with it", which after
TARIFF-10 is false on a site that fetches without trading; it now says
"arbitrage, where it is armed, stops with it". The keep decision is
strictly downstream of `resolve_tariff()`, so the §8.3 carry into
price_history - TARIFF-10's whole point - is unaffected either way.
All 29 suites green on the merged tree, including TARIFF-10's new
test_price.py section 13. All eleven mutations RE-DRIVEN on the merged
tree rather than carried over: eleven red, no survivors, every revert
fingerprinted. M11 is new - making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B) is red on five named checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
7f2c4e20e1 |
TARIFF-14: the point whose own interval covers the instant
checks / unit (pull_request) Failing after 2s
`_Series.at()` asked `s <= u < s + 1 h` of every point IN LIST ORDER and
returned the FIRST match. On the hourly shape every fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium
moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
returns 96 points at 900 s, where it is wrong twice over - measured
against the shipped code at
|
||
|
|
c81289527f |
TARIFF-12: a returned degradation is not a refresh
checks / unit (pull_request) Successful in 7m12s
`run_tariff_refresh()` promised in its own docstring that "a failure does not clear the plan", and enforced it with `except Exception` - a branch a supplier outage never reaches. `load_prices()` states the reason in its own words: it "RETURNS, NEVER RAISES, for every failure a site can actually have", because a degradation arriving as a traceback into the planning tick would stop the controller regulating over an expired certificate. So a dead feed RETURNS a §11.4 plan, the `try` succeeds, and the assignment below it overwrote the good plan unconditionally. With `static_profile` unset - the shipped default, and the normal state of a dynamic-tariff site - `_degrade()` returns `TariffPlan(no_data, None, "none")`: a complete 48-hour plan replaced by nothing, at the FIRST failed fetch, ~27 minutes after the last good one. `TARIFF_MAX_AGE_S` (5400 s) never governed anything and there was no serve-stale window at all. Fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's HIGH (`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and now `except Exception` only, for a path that never raises. The question is now asked once, after the attempt, from the evidence that exists - FCST-06's answer, one module over. The evidence is `TariffPlan.from_feed` (did this resolution observe the configured feed) and the shipped `fresh_tariff_plan()`, reused rather than transcribed. A degradation replaces a standing observation only once that observation has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it reads like. A §11.4 static fallback does not replace a fresh provider plan either, although it carries real prices: every point `_degrade()` builds is `provisional=True`, so `firm_points()` is empty, `arbitrage()` refuses and `cheap_now()` is False - everything a consumer may act on is already off - while a day-ahead price for a given hour does not change once published. The rule is deliberately not symmetrical: a standing plan that is itself a degradation IS replaced, or an outage would freeze the operator's static profile at the window it was first generated for. `tariff_at` does not move on a kept plan. It is the moment of the fetch that produced the plan standing, `fresh_tariff_plan()` judges the age from it, and an unmoved timestamp is the only thing that tells a kept plan from a freshly fetched one. test_arbitrage.py section 8b drives the whole chain with nothing stubbed but the socket: the transport raises the way aiohttp does, load_prices() returns no_data, _degrade() builds the §11.4 plan, and the shipped run_tariff_refresh() decides. A check that patched resolve_tariff to RAISE would have passed against the defect. Ten mutations, ten reds, no survivors; 29/29 suites green. Out of scope and unchanged: the persisted price store still does not back the plan during an outage (FCST-03), and the status banner and the Arbitrage row's lag are separately carded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
e6c45a61e7 |
TARIFF-10: configuring a price provider is what fetches prices
checks / unit (pull_request) Successful in 7m19s
amain() started the background tariff refresh only when arbitrage_enabled was
on, and that option ships false. run_tariff_refresh is the only caller of
resolve_tariff(), which is the only caller of price_history.carry() - so on a
shipped install with a price provider configured nothing was ever fetched: no
plan resolved, /data/price_history.json never created, the Price history status
row reading `empty` for the life of the install, and TARIFF-09's short-horizon
warning unable to fire because there was no fetch to warn about. Measured over
a 40 h two-instance soak on one feed: 0 requests and `present: false` across all
802 samples, against a resolved 132-point plan on the instance with arbitrage
armed.
git log -S puts the refresh task, its arbitrage.enabled() guard AND the DOCS.md
sentence saying a configured provider still fetches in the same commit - OPT-04,
bdd7f18; config.yaml has carried the claim since TARIFF-02,
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 ( |
||
|
|
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
|
||
|
|
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
|
||
|
|
2bb07ccdde |
UX-02: the panel was weaker than the startup gate for one field
checks / unit (pull_request) Successful in 6m9s
Applying review finding N1, which was non-blocking because it fails closed, and
N2, which is a fabricated number.
N1. Clearing the `max_w` box answered HTTP 500 on a non-JSON body, which the
page rendered as "cannot reach the add-on" - untrue, and for the most prominent
limit field's most likely transient state. The cause was a disagreement between
two files that `inverter.py`'s own docstring says to keep in step:
validate.py `_number()` reads "" as "use the default", so rule 9 saw 2000
and was content
inverter.py line 515 did a bare `float(opts.get("max_w", 2000))`, which
raises ValueError on ""
So a shape rule 9 TOLERATED could not be composed at all - the docstring's rule
inverted. The roster path never had this: `_entry_max_w` already guards. Only
the flat single-inverter path did. `_flat_max_w()` now reads empty as the
default exactly as `_number()` does, and unparseable as 0.0 exactly as
`_entry_max_w` does, where rule 9 refuses it by name at line 790.
⚠️ THE FIRST ATTEMPT AT THIS MADE IT WORSE, AND THE PROBE CAUGHT IT. Guarding
`build_roster` in `selfconfig` with `except: return validate(opts), []` turned
the 500 into ZERO PROBLEMS for `max_w: ""` - the validator has nothing to say
about it, so an empty list came back and the page would have ENABLED SAVE. A
crash became a silent acceptance. The guard is kept as a backstop for shapes
neither file anticipates, but it can no longer return an empty list: if the
roster cannot be composed and the validator names no reason, that disagreement
is itself reported.
MEASURED after the fix, `problems_and_ranges` with no HA:
max_w 5000, slew_w 400 0 problems (the live shape)
max_w "", slew_w 400 1 problem rule 10, "against a site rail of
2000 W" - clearing the box FAILS
CLOSED on the live install rather
than silently halving the rail
max_w "", slew_w 0 0 problems accepted at the 2000 default
max_w "abc" 1 problem "max_w 'abc' is not a number"
Before the fix, both "" and "abc" raised ValueError instead.
N2. `translations/en.yaml:7` said "all 71 keys ... so ALL SEVENTY are rendered".
The 70->71 correction had been made in one half of the sentence and not the
other. 59-of-71 required is correct and was re-measured.
Suite: 24 files, all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
fdaf3fb54f |
UX-02: the validator's refusals, next to the field, before the save
checks / unit (pull_request) Successful in 6m17s
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.
WHAT IT ADDS
app/configui.py the field model, built from `GET /addons/self/info`'s own
`schema` + `translations`. No option key, no label, no
rule of its own. UX-01's `Group · ` label prefix becomes a
real section heading, in whichever language resolved.
app/selfconfig.py the Supervisor conversation and the write gate.
app/config_page.py the page. Its longest function decides which HTML input a
type deserves; it contains no rule.
app/web.py five routes. Still the only file a reviewer has to read to
answer "does anything here reach the inverter".
THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
1. A blob the SHIPPED validator refuses is never written - not warned about,
not written. An invalid blob plus a restart is exit 78, and the panel is
gone with the add-on that serves it.
2. A blob that moved underneath is refused, not clobbered. Read hands out a
sha256 of the canonical blob, save hands it back, and the stored blob is
re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
applied to configuration - "a blob reaches the Supervisor only if it was
derived from the blob stored right now" - with the honest weakening stated
in the decision record: a second writer is DETECTED, not made
inexpressible.
3. Every message is the validator's own, byte for byte. test_selfconfig.py
part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
under app/ to show validate.py is the only one that differs, and serves the
real /config/validate route out of that copy in a subprocess. The new
sentence arrives, filed under the key it names.
4. Nothing on the surface commands an inverter or clears a latch. Saving does
not restart: applying costs a control interruption - the wind-down commands
0 W on every inverter - so that is a separate, confirmed button that says
so first.
MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
- the three self-paths are permitted, and `/addons/self/info` is admitted by
the `default` role too while `options` and `restart` rest on the bypass
ALONE. The probe's first draft claimed the role admitted none of the three
and went red.
- `/addons/self/options/validate` is NOT reachable from inside an add-on, so
the only validator this panel can call is ours - which is the one with the
cross-field rules.
- a POST that omits a key does not preserve it: the key reverts to
config.yaml's default. Hence whole-blob read-modify-write.
- the Configuration tab CAN be removed, and doing so drops 0-of-71 options
through and takes the add-on down. The card said it could not be; it stays
by decision, which is a better reason.
DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.
Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
---
⚠️ REBASED ONTO release/1.0 @
|
||
|
|
090a8ecc35 |
SAFETY-42 round 2: pin the two thresholds at a config that is not the shipped one
checks / unit (pull_request) Successful in 6m7s
THE BLOCKER. At the `delinquent()` call site, `self.tuning.saturation_w` -> `250.0` (-19 B) was a survivor of all 23 suites. It is not an equivalent mutant: two copies of inverter A, B wedged, `saturation_w: 1800` (inside its own schema int(100,2000)) and a load of 1600 W put B's shortfall strictly between the literal and the configured value - head names nobody, the mutant names B. At the shipped 500 both name it, which is exactly why every fixture in part 4 was blind: their gaps are all above both thresholds. It matters beyond a mutation score. `delivery.py`'s MIN_W_IS_SATURATION_W note and the `delivery_hold_s` block in config.yaml both promise an operator, in shipped prose, that there is no fourth copy of a threshold to keep in step and no way for the two to drift - raise `saturation_w` and this check moves with you. A literal at the call site removes exactly that promise, silently, and the promise is why the design was chosen over a `delivery_w` key. ASKING THE SAME QUESTION OF EVERY OTHER NUMBER THIS CHECK READS FOUND A SECOND SURVIVOR OF ALL 23 SUITES: `bms_floor=inv.bms_soc_floor` -> `10.0` (-13 B), invisible because config.yaml's own example states 10 % and every fixture in this file used it. Two arms bracket it: a pack at 15 % against its own 25 % cut-out is cold and must stay unnamed (the literal names an empty pack), and a pack at 8 % against its own 5 % cut-out is wedged and must be named (the literal explains the fault away). One arm on each side leaves no constant at all. `delivery_hold_s` and `is_live()`'s window were ALREADY pinned - the fixtures had to move them to run - and `LINK_STALE_S` has no option behind it, so "pinned across a changed config" is not available for it and is not claimed. DECISIONS section 9 enumerates all of it with the deltas and which check kills which. Verified in the CI image, tree mounted :ro, `python3 -B`, `__pycache__` purged and counted before every run (TEST-15): R5 saturation_w -> 250.0 -19 B killed by part 4b(a) R6 bms_soc_floor -> 10.0 -13 B killed by part 4b(b), both arms R7 arm(self.delivery_cfg) -> DeliveryConfig() -1 B killed (already) R8 is_live(now) -> is_live(now, 3600.0) +8 B killed (already) R9 pack cut-out -> conjoined site floor +105 B killed twice head: 23/23 test files PASS, no bytecode written during the run. No shipped behaviour changes: the only edits to app/ are comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
c7eb8c6b97 |
SAFETY-42: close two mutation gaps, and correct what makes N=1 inert
The battery is 12 mutations, one freshly copied tree each, purged, `:ro`,
`python3 -B`, byte deltas measured. First round: 9 red, 3 survivors. All three
are now accounted for and the ledger is in DECISIONS §8.
M5 - `share <= 0` relaxed to `share == 0`, +0 B - WAS A REAL GAP AND THE TWO
OBVIOUS CHECKS DID NOT CLOSE IT. `short_by(-2000, 0) is None` and
`short_by(-2000, -100) is None` both stayed GREEN under the mutant: for a charge
share that is UNDER-delivered `share - measured` is negative and the `gap > 0`
test answers None anyway, so neither input can separate the two spellings. The
one that can is a unit moving MORE than its charge share - -2000 W told,
-3000 W measured, `share - measured` = +1000 W - which the mutant reports as a
shortfall. It is not one; it is something else driving that pack, which is
SAFETY-30's and SAFETY-31's question. That input is now the check, and M5 is RED.
MUTATION.md's third failure mode in miniature: the assertion ran, the sentence
was true, and the data could not reach the failure it named.
M11 - `inv.is_live(now)[0]` replaced by a literal `True`, -15 B - was a WIRING
gap the pure checks structurally could not see. `account(live=False, ...)` was
graded as a FUNCTION; nothing graded that `cycle()` asks the question, because no
fixture had a unit whose writes were being rejected. Such a unit still has its
last ACCEPTED command standing on the wire (`write_setpoint` leaves `last_write`
alone on a rejected write) and still measures nothing, so it looks exactly like a
wedged one to every number this check reads - and reporting it would be the
second, louder alarm about a state the allocator has already handled and logged.
`test_delivery.py` part 6 now builds that unit, with `rejected_since` back-dated
past LINK_STALE_S because the fixture runs in two seconds and the timer is
test_inverter.py's to grade. M11 is RED.
M3 - `len(asked) < 2` relaxed to `< 1`, +0 B - IS STILL A SURVIVOR, and it is an
EQUIVALENT MUTANT. With one asked unit `unaccounted` is a subset of a one-element
set, so it is either empty (`not unaccounted` returns {}) or equal to `asked`
(`len(unaccounted) == len(asked)` returns {}); with zero asked units both
spellings return {} as well. No input separates them, so no check can.
⚠️ AND FINDING IT CORRECTED THE MODULE'S OWN PROSE, WHICH IS THE MORE USEFUL
HALF. `delinquent()`'s docstring claimed `len(asked) < 2` was what makes N=1
inert. It is not - the PROPER-SUBSET rule is, and that line is a fast path and a
signpost. A false attribution of the mechanism in the file's own prose is exactly
what this repository keeps finding, and a mutation that survives is how it
surfaced. The line stays, with the equivalence written down beside it the way
circulation.circulating_w() records its own max(0.0, ...).
Also recorded, rather than left implicit: `account()`'s `parked` branch is graded
as a function but its WIRING is not mutated on its own; part 6's reversed-unit
fixture exercises it end to end, which is weaker, and the ledger says so.
Baseline 23/23 green before and after. app/main.py is byte-identical to the
previous commit - both closures are a test and a comment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
706e625d99 |
SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist
A unit standing at a non-zero share and producing nothing is seen by NOTHING. SAFETY-30's per-unit sign gate needs OPPOSITION on both sides of 250 W and a unit at 0 W is not opposing, it is ABSENT. The site sum still agrees with the meter in direction. Circulating power is exactly zero when one unit does nothing. The only trace left is `reason == "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an operator learns to ignore. It is HW-01's shape, which ran 6 h 25 m with all three layers spectators. ⚠️ THE CARD'S OWN DISCRIMINATOR WAS BUILT FIRST AND FALSIFIED, AND THAT IS THE MAIN CONTENT. SAFETY-42 asks for "saturation persisting past N while |grid error| > X, both derived". Run on the two-master rig it fails in BOTH directions, and no (N, X) fixes either: FALSE POSITIVE `plantsim.Plant.limit()` returns 0 W for a discharge command into a pack at its own floor, and a wedged unit produces 0 W too. Load 2000 W, equal weights, 2000 W per-unit cap, 120 cycles: THE TWO ARMS ARE IDENTICAL ON ALL 120 in grid, site target, every share and every measured watt. Both saturated-freeze on 116 of them, both hold |grid error| over 300 W on all 120 and settle at 1200 W, both read circulating 0.0 W. The predicate's inputs do not differ, so no threshold on them can differ. And "a cold pack at its floor must not alarm" is the card's own AC 2. FALSE NEGATIVE the standing grid error is what a HEALTHY SIBLING ERASES. Load 800 W, same config, B wedged: over the last 30 of 200 cycles the loop is frozen on 30 of 30 while the worst |grid error| is 30 W, with B standing at +770 W and delivering 0 W. Any X above 30 W is silent for the whole run on the exact fault the card describes. WHAT REPLACES IT. Per unit, for a discharge share, `share - measured`; readable because of a reduction that only exists at N>=2: a unit is named while it is short by more than `saturation_w` with NO ACCOUNT for it, and the units in that state are a PROPER SUBSET of the units asked to discharge. THE ACCOUNT keeps the cold pack out and is the only thing that can: the two cases are identical in the power domain, so the discriminator has to be a non-power fact, and there is exactly one - the pack's own SoC against its own `bms_soc_floor`, asked through `peakguard.discharge_permitted()` rather than transcribed. ⚠️ `bms_soc_floor` ALONE, not `pack_floor_pct()`'s conjunction with `peak_soc_floor`: that floor bounds a money-saving discharge only and using it would explain away a wedged unit anywhere between the two. THE PROPER SUBSET keeps a whole-site cause out - a derate this add-on cannot see hits every unit; one box that stopped listening does not - and it is what makes this INERT AT N=1, by algebra rather than by a roster guard. IT REPORTS. IT DOES NOT ACT, and the strongest argument is for acting. Dropping the wedged unit from `live` genuinely recovers a site the shipped path does not: at load 1500 W, kept in the split the site settles at 800 W with a 700 W error and NEVER comes inside 100 W in 150 cycles (the freeze pins the target, so the sibling's SHARE never grows); dropped, it delivers 1500 W from cycle 14. What refuses it is that the only re-allocation this add-on can express is all-or-nothing: a unit derated to 50 % is short by more than saturation_w with nothing to explain it, so this detector names it too, and at load 3000 W dropping it leaves the site delivering 2000 W against 2400 W kept - 400 W WORSE. A partial re-allocation is what would make acting safe and the allocator cannot express it. That is a card. control.py is NOT MODIFIED. The freeze shapes the loop; this reports on it. NUMBERS, MEASURED. The watts side is `Tuning.saturation_w`, read at runtime, so there is no fourth CFG-03 copy and it cannot drift from the number the control law itself calls "not following". `delivery_hold_s: 185` comes off 2640 closed-loop two-inverter runs in which nothing is faulty (6 gains x 5 slews over the whole schema range, across load reversals, a flapping load and ASYMMETRIC SPLITS - a family SAFETY-30's campaign did not have and the one that dominates here) plus 1890 runs at a roster of ONE as the control: threshold reversals flapping weighted N=1 control * 500 W 20.0 s 0.0 s 30.0 s 0.0 s The N=1 column is 0.0 s at EVERY threshold in all 1890 runs, and the flapping column - the family that defeated SAFETY-30's site window - is zero too, because a common stimulus makes both units short at once and the subset stops being proper. ⚠️ The curve is NOT monotone (55.0 s at 250 W against 40.0 s at 100 W): lowering the threshold can turn a proper subset into the whole roster and BREAK the run. ⚠️ THE MARGIN IS MEASURED, NOT FELT, BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST. `plantsim.INVERTER_B` is A COPIED, `measured=False`, so every two-inverter figure above is two copies of A. Sweeping a different second unit: 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, 90.0 s at four times it. 185 s is 6.2x the measured worst, 2.06x the four-times-slower case, 6.2x LINK_STALE_S and 4.9x inside the 900 s quarter. Deliberately not 180, which is SAFETY-02's site hold. SAFETY-34. `unit/<name>/delivery_tripped` in the store, one notification id per unit, re-raised on the first cycle of every process, cleared by the Start press. No flat mirror: a rollback lands on a version that drives one actuator, where this check is inert. ⚠️ SCHEMA CHANGE - `delivery_hold_s` joins options: and schema:, so this needs a version bump before it ships. Additive and defaulted, so an existing options blob stays valid. STATED GAPS: charge shares are not graded (no configured charge ceiling exists, so a constant-voltage taper cannot be explained and arming it would fire on every maintenance charge); every unit wedged at once is not caught (the subset is not proper and no instrument here separates that from a plant that cannot). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
f156fadac0 |
SAFETY-13: the bound came from the wrong half of a two-sided measurement
checks / unit (pull_request) Successful in 5m34s
NOTES.md 5.10 measured the 1.78 kW step BOTH ways - "Up: ~18 s from step to deadband. Down: ~23 s" (NOTES.md:1550). SAFETY-39 takes 18 correctly: its bound is a REFUSAL and the looser number refuses less. This branch reused PLANT_SETTLE_S for the opposite job - how long the plant may still be the EXPLANATION for a divergence - where the conservative half is the longer one. ceil(23/2) = 12, not 9, and being short there does not refuse anything, it accuses. The false positive is real and is measured here. One inverter, one compute(), one plant, no second writer anywhere in the harness: the command falls through zero while the pack is still coasting the old way, unasked_power() calls our own power "unasked", the counter reaches 9 while it is still coasting, and the law emits "freeze-released". At the plant's own measured 18 s/23 s shape, over 272 load-step pairs, the 9-cycle bound fires in 62 and the 12-cycle bound in 2. PLANT_SETTLE_DOWN_S = 23.0 is added beside PLANT_SETTLE_S = 18.0, because the plant's settling time is a PAIR and each bound has to say which half it means and why. SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) = 12. It is no longer the reciprocal of SLEW_RAIL_FRAC; that was a coincidence of both reading the same half, and test_control.py now asserts the inequality. _SAT_COUNT_CAP moves with it, 10 -> 13. config.yaml accepts saturation_cycles as int(1,10), so the whole accepted range is now strictly BELOW the bound and the degenerate configuration this branch used to document no longer exists - pinned against config.yaml's own schema line. Three shipped statements were falsified by that measurement and are rewritten, not renumbered - at 12 the residual is smaller, not zero: unasked_power()'s docstring said anything outside the interval "was put there by something other than this loop". It was our own power: eight cycles after a +1990 W command the command is 0 and the pack still reads +522.3 W. control.py said "SO THE BOUND BELONGS TO (b) AND ONLY TO (b)". It fired in case (a). DOCS.md told an operator "if you see freeze-released, look for the second writer" - on a single-inverter install, hunting something that does not exist. Replaced by a section that gives the innocent causes first (a large load or PV swing; a plant slower than the one measured, 8/320 scenarios at 23 s rising to 268/320 at 50 s) and the second writer only when the state STANDS. Round 1's own results are kept and re-graded, not weakened. The release/1.0 sweep had to widen to reach the new bound (3780 cases): 3267 bit-identical, 432 releases, 27 command changes - all still charge starts from prev_w == 0 - and 81 differing in the saturation counter alone, which nothing downstream can read. The card's case releases on the twelfth saturated cycle instead of the ninth and settles at the same -1190 W against the same frozen integrator. Also corrected, because they were claims and did not reproduce: the coverage table's "output freeze" row was 11, and the mutation form its own note describes gives 6 on this tree, 6 on the first SAFETY-13 commit and 2 on release/1.0; the detector row was left at 11 and is 15; the frozen = False footnote was left at 10 and is 13. And section 6 reported 107 625 as "released states" when it is the number of states SWEPT - 67 500 of them release, and both are counted now. Nothing here reached the inverter. 22/22 test files pass in goodwe-addon-ci, tree rsynced to a plain path, mounted :ro, python3 -B, __pycache__ purged and printed first (0 dirs / 0 .pyc; the worktree carries none). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
6c4565659b |
SAFETY-13: the output freeze gets an exit, and only where it needs one
checks / unit (pull_request) Successful in 5m30s
While the loop is frozen the command's magnitude may fall but never rise. At prev_w == 0 there is no magnitude left to fall, so a charge cannot START. That refusal is deliberate and it is kept: the inverter reporting power we did not command means another writer is on the actuator, which is the §1.5 non-goal. What it did not have was an END - nothing cleared it but the other writer leaving. Reproduced on release/1.0 @ |
||
|
|
8e241a3243 |
SAFETY-37: correct a wall-clock tick and a false size claim
checks / unit (pull_request) Successful in 5m32s
Two blocking findings from review, both in prose, both corrected at every site. 1. "trips on tick 7" was not what the fixture prints. It prints 11, five runs out of five, and the developer's own report says 11. ⚠️ But the deeper problem is that the number should never have been quoted: the tick is counted on the WALL CLOCK inside cycle() (hold_s 2 s, ticks 0.20 s apart), so it is load-dependent - 7 is what a box slow enough to stretch each tick to ~0.29 s would print - and nothing pins it. The DECISIONS transcript now says 11 because it is a transcript of a run; the CHANGELOG stops quoting a tick at all and states the checkable thing instead: it trips without the gate and never with it, which part 3 grades. 2. ⚠️ SURVIVOR 2's SIZE CLAIM WAS FALSE FOR ONE OF THE TWO CONSUMERS THIS CARD EXISTS TO PROTECT. It read "one contributing at most 250 W to a loop whose reporting floor is 700 W" at three sites. That bounds the COMMAND. Once the register is frozen its REPORTED value is unrelated to the command - which is exactly HW-01's shape: commanded ~0 W, register stuck on the last large number. Measured in review: a unit frozen at -1200 W beside a healthy +900 W sibling yields circulating_w = 1800 W against the 700 W floor, 2.6x OVER, with no gate armed. So the scoping is TRUE for the per-unit sign check and FALSE for circulation. Corrected to say exactly that, and the circulation consumer's exposure below MOVE_W is now recorded as a KNOWN GAP rather than a covered one. It survived because part 9's S2 check asserts only `not stale` and part 4 pairs the frozen unit with a RESTING sibling (circulating_w([A_FROZEN_W, 0.0]) == 0.0) - neither grades the size sentence. No behaviour change. Full suite: 22 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
6864109a01 |
SAFETY-37: a stale reading is None, and arrival cannot be what says so
checks / unit (pull_request) Successful in 5m34s
The architect pass made SAFETY-37, TEL-06 and TEL-08 one systemic problem - "a stale reading holds its last value and reads healthy" - and this builds the single policy. Both consumers the card names are gated by one change, and neither consumer needed a line: `SignWatchdog.evaluate` already CLEARS its window on a None measurement and `circulating_w` already returns None for a roster with a missing term. Neither could ever receive one. The gate is the piece that was missing BETWEEN the shipped read and the shipped checks. ⚠️ ONE CRITERION IN THE CARD COULD NOT BE IMPLEMENTED AS WRITTEN, AND SAYING SO IS THE MAIN CONTENT. The card says "only arrival separates them". At this layer arrival cannot: `read_inputs()` polls Home Assistant's state cache about once a second and HA answers 200 with a number whether or not the publisher behind the entity is alive, so both members of the pair arrive identically for ever. test_freshness.py part 1a BUILDS the arrival-age gate the card asks for and grades it failing - the age is 0.0 s at every instant of both runs, a threshold that can never fire, which is TEL-06's own defect one layer up. The half of the rule that survives is honoured absolutely: nothing here reads a Home Assistant timestamp, and the check asserts the module contains no `last_updated`. So the criterion became: a reading is stale when it has not moved through a change it was OBLIGED to follow. STALE <=> unchanged >= 35 s AND |standing| >= 250 W AND |standing - standing when the reading last changed| >= 250 W Latched until a different value arrives. `Inverter.batt` becomes None - never a held number - which is a missing input, which §11.1 rule 1 answers with 0 W. WHY A CONJUNCTION AND NOT A THRESHOLD, MEASURED 2026-09-06. Replaying the committed 30-day capture of this house's own 1 W power entity (485,841 samples out of HA's recorder, where a row exists only on a CHANGE, so the gap between rows IS the unchanged duration every consumer saw): the longest legitimate unchanged run is 263.2 s, held at -899.0 W. A false-positive-free threshold would have to exceed that, and 263 s is 3.5x too slow for a consumer that trips at 75 s. There is no admissible threshold. Every one of those long runs is under an UNMOVING load, which is what the travel term keys on. THE BOUND, derived at both ends and graded against the shipped config.yaml. Floor 17.0 s = 6.0 plant settle (NOTES §5.9, measured 2026-08-22) + 10.0 ESPHome publish (firmware/goodwe-master.yaml:181) + 1.0 read period (run_control's asyncio.sleep(1)). Ceiling 75.0 s = min(circulation_hold_s 75, unit_sign_disagree_s 85). Band [34.0, 37.5]; 35 is a CHOICE inside a DERIVED band and the check says so - the first draft claimed the band held one integer and went red on its own first run. MOVE_W is the LARGEST admissible value, unit_sign_disagree_w, because below it the consumer cannot speak at all. SoC is STATED, NOT WIRED, and BOUNDS carries it with wired=False and the reason. Its obligation term is energy through the pack, not command travel - a rested pack holds a percent for hours under a 0 W command - and K needs a usable capacity nobody has measured. That is TEL-08's; the record says so. ⚠️ BEHAVIOUR CHANGES AT THE SHIPPED ROSTER OF ONE, and it is named rather than hidden: a FROZEN register now stops the site where it used to read as a healthy measurement for ever. That is HW-01, which was a one-inverter site. A healthy install is unchanged, asserted over sixty reads of a dithering register under a reversing command. A false verdict fails toward 0 W behind the existing stale_input_s hold and clears itself on the register's next move. ⚠️ BOTH UNITS IN EVERY TWO-INVERTER FIXTURE ARE INVERTER A with different numbers passed in; inverter B does not physically exist (~2026-09-12). MUTATION: 16 mutations against a purged, :ro, python3 -B tree, fingerprinted before and after every revert. One survivor, and it is the finding - dropping the magnitude term left every check green because no fixture separated "the command travelled 250 W" from "the command ended up above 250 W". Closed, and recorded as MUTATION.md habit 17. A second process defect is habit 18: a mutation run that timed out between the edit and the revert left the module mutated on disk and nothing announced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
834e7ca44b |
SAFETY-39: correct the gain figures, which were not the measured ones
checks / unit (pull_request) Successful in 5m15s
Review found validate.py:288-289 carrying numbers that appear nowhere else in the PR's evidence and could not be reproduced: over-absorption "1233 W at slew 1000 and 1344 W at slew 250 ... 1495 W at 0.6, 961 W at 0.3, ~0 W at 0.2". A grep for 1233/1344/1495 found them ONLY in that comment - the DECISIONS record, the PR body, test_control.py's audit table and slew_sim's own output all carried the correct ones. The measured values, from re-running tools/slew_sim.py on the real plant model at the stated conditions (max_w 5000, 2 s tick, 51 export steps 1.0-4.2 kW): slew column 958 (slew 1000) and 1081 (slew 250); gain column 987 (0.6), 876 (0.3), 435 (0.2). ⚠️ "~0 W at 0.2" WAS WRONG IN THE UNSAFE DIRECTION. It invited a future reader to conclude that dropping gain to 0.2 removes over-absorption. It does not: 435 W remains, which is 44 % of the figure at 0.6, not zero. The corrected text says so at the line rather than leaving a round number to be trusted. The conclusion the paragraph draws is unchanged and still holds on the measured figures: over-absorption is flat in slew_w and falls in gain, so `gain` is the constant that would have to move, and SAFETY-39 deliberately does not move it. No behaviour change; comment only. Full suite: 21 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
5d7e275df4 |
SAFETY-39: slew_w is a ratio to the rail, and gain is what is still wrong
checks / unit (pull_request) Successful in 5m16s
`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where "capping how far the loop can jump ahead of the inverter" was true because the rail itself bounded the excursion. The live install went to max_w 5000 and the constant did not move, so the loop crosses its whole output range in 10 s against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five 2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved. REPRODUCED FIRST, and one half of it is not reproducible at all. On plantsim.Plant and on an independent in-repo model: the setpoint stepping a full 1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from export into import. The -5154 W figure is NOT reproducible in either model, because both clamp the plant at its rating - so the card's AC "battery no longer exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met. THE DELIVERABLE IS THE RELATIONSHIP. SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9 slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as integrator_max_w = 0 already means follow max_w. It is the shipped default: 222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. validate.py rule 10 refuses an explicit value faster than that, graded against sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a second inverter moves the bound with no operator action. VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the refusal names the rail, the crossing time, the measurement and what to do. Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating all still start. GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51 export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250) and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W rail in under three ticks whatever the slew is. Two control-law constants moving in one release on a running house makes the next measurement uninterpretable. ⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant did - both stated in the record rather than left to be discovered. Nothing reached the inverter. The live install still runs the interim slew_w 400 applied by hand on 2026-09-05, which rule 10 accepts unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
3527c8a04c |
SAFETY-41: escalate a sustained engagement, so a runaway is never held
checks / unit (pull_request) Successful in 4m59s
Review found the case I asked it to construct, and it is not a corner case. The rate limit alone DELAYS A RUNAWAY: a benign one-cycle clamp that spends the budget, then a continuous §10.3-shaped runaway, held the runaway's first notification for up to 140 s - one REASSERT_S minus a cycle. On the live install, where ordinary railing spends the budget essentially all the time, a runaway arriving during normal operation would be delayed with near-certainty. That is the "benign noise suppresses the bug signal" shape this module's docstring is written against, and the family the first attempt at this card died of. THE ESCALATION. An episode that has not yet spoken because an EARLIER, unrelated episode spent the budget speaks anyway once it has engaged SUSTAINED_CYCLES = 3 times in a row. The discriminator is run length and the two populations genuinely differ: the live flood was runs of length ONE (engage, de-assert, engage), while a runaway engages every cycle. Measured, and it costs nothing it was protecting: live scatter flood (305 cycles, 1-in-6) 11 -> 11 notifications chatter 1-engaged/3-clean over 8000 s 50 -> 50 notifications pinned 900 s quarter (floor is 5) 6 -> 6 alarms runaway delay after a benign episode 140 s -> 10 s ⚠️ MY COUNTERFACTUAL CHECK COULD NOT FAIL FOR THE DEFECT IT NAMED, and review caught that too. It read `ClampWatchdog().note(True, ...) is True` - a FRESH object, which only exercises the `last_alarm is None` branch, the trivially-true one. A fresh watchdog has no earlier episode to be held back BY. It now SPENDS THE BUDGET FIRST: benign clamp, gap past CLEAR_CYCLES, then a continuous runaway, and asserts the delay is well under REASSERT_S. That is the third instance in this file of the family recorded at line 216 and at the 224 s sweep, and it was guarding the most safety-critical property in the change. Also pinned: `last_alarm` SURVIVING the de-assert. Review built the mutation - if the de-assert also refreshed it, chatter collapses from 50 notifications to 1 and the alarm never fires again, with the suite green. That is the "alarm that never fires" class, a different bug from one that fires later. Now caught. Both new checks verified against the mutations they name: removing the escalation -> 1 FAILED; refreshing last_alarm on de-assert -> 1 FAILED. Full suite: 21 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
8790c4d38f |
SAFETY-41: rate-limit the RISING EDGE, which is where the flood came from
checks / unit (pull_request) Successful in 5m9s
Review falsified the first version of this change, and the correction is the substance of it. Raising REASSERT_S 30 -> 150 removed NONE of the owner's notifications. The re-assert interval governs only the still-engaged path; the rising edge fired unconditionally. At the measured ~5 s cycle the live pattern - 48 engagements in ~25 minutes, about one cycle in six - leaves ~25 s of clean cycles between engagements, past CLEAR_CYCLES x 5 s = 15 s. So the alarm DE-ASSERTED between almost every engagement and each one returned as a fresh rising edge. Measured on the shipped class over that window: 48 episodes, 48 notifications at REASSERT_S = 30, and still 48 at 150. The interval now bounds the NOTIFICATION RATE rather than the repeat rate within one episode: `last_alarm` survives a de-assert, and a new episode speaks only if the interval has elapsed. Same window afterwards: 51 episodes, 51 engagements, 11 notifications. ⚠️ NOTHING IS DROPPED, AND THE TWO PROPERTIES THAT MATTER ARE PINNED: - a PINNED loop still speaks 6 times in the 900 s billing quarter, against a floor of 5 (part 2, unchanged); - a rising edge from a COLD watchdog still fires IMMEDIATELY, so a runaway beginning from quiet is never held back by a budget spent on an earlier, unrelated episode. That check is the counterfactual and it is the half that would go red if the limit ignored elapsed silence. Every engagement is still counted, every episode still increments, and the action-log row is still written on every engaged cycle. The budget is on notifications, never on evidence. The new pin is written in wall-clock seconds and in the shape the field produced, so it does not move when the constant does. Verified by reverting the rising edge to unconditional: 2 checks go RED naming it. Full suite: 21 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
1d393ced22 |
SAFETY-41: correct the comment, and fix the check it cites
Review found two false statements in the comment and one weak check. All three are fixed here; the constant itself is unchanged at 150 s and was never in question. 1. "That caps this constant at 900/5 = 180 s" was FALSE ABOUT THE CHECK IT CITED. test_clamp.py part 2 sweeps i = 0..899 and the rising edge at i=0 is free, so it measured `1 + floor(899/R)` - one alarm more than a real quarter earns. Measured: 224 s was GREEN across the whole suite. Anyone raising this to 200 s would have had a green board and a requirement quietly unmet - the same defect recorded at test_clamp.py:216, one notch weaker. The check now grades floor(QUARTER_S / REASSERT_S) directly, so it enforces the ceiling the comment claims for it. Verified: 150 PASS, 180 PASS, 181 FAIL, 224 FAIL. 180 is also the true requirement edge - on the worst sliding 900 s window 180 earns 5 alarms and 181 earns 4. 2. The "60 cycles of the §10.3 runaway" note was attributed to this module and truncated. It is main.py:2479 and reads "60 cycles of the §10.3 runaway produce 0 write-path engagements and 2 loop-side ones". Quoted in full, attributed correctly. 3. "48 engagements in 25 minutes" was left implying a pinned rail. It is roughly one cycle in six - real, and far above the 2-in-60 the guard was sized for, but not continuous. Restated, and labelled as a live-log reading rather than a measurement in this repo. Also recorded: the 1500 W tuning derivation is in the OTHER repo (admin/goodwe NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)"), so this repo cannot corroborate it and a reader should not go looking here; and MIN_PER_QUARTER = 5 is a test-local integer, not a spec figure - the 900 s quarter is system-wide but the 5 is not, so the 180 s ceiling rests on one number a test author chose. Full suite: 20 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
3516947b5e |
SAFETY-41: raise the clamp alarm's re-assert 30 s -> 150 s
The owner reported the OUTPUT_CLAMP notification as a persistent annoyance on the live install. It re-asserted every 30 s, all day. ⚠️ THIS IS A REGIME CHANGE, NOT A CHANGE OF MIND ABOUT THE ALARM. 30 s was chosen when a loop-side engagement was rare - clamp.py's own note records "60 cycles of the §10.3 runaway produce 2 loop-side engagements". The live install produced 48 IN 25 MINUTES of ordinary operation, because `max_w` is 5000 W where the loop's tuning was derived at a 1500 W clamp (SAFETY-39). The integrator therefore reaches its bound constantly and the alarm narrated it every 30 s. An alarm that speaks that often about the normal case is how an operator learns to ignore the one that matters, which is the exact failure clamp.py's docstring is written against. ⚠️ 150 s IS THE REQUIREMENT'S OWN EDGE WITH ONE STEP OF MARGIN, not a comfort choice. test_clamp.py part 2 pins the cadence in WALL-CLOCK terms rather than against this constant: a loop pinned on the rail must speak at least MIN_PER_QUARTER = 5 times inside the 900 s quarter that bills the capacity tariff. That caps the constant at 900/5 = 180 s. Measured: 180 s -> 5 alarms per quarter (exactly the required 5, no margin) 150 s -> 6 alarms per quarter 300 s -> 3, and the floor check goes RED 180 s would sit on the boundary, so any later change to the firing logic would tip it red for a reason unrelated to that change. The FLOOR is the thing that must not move, and it does not. ⚠️ WHAT THIS DOES NOT DO. It changes how often the alarm repeats. It does NOT make the alarm correct: the message still calls the integrator bound a BUG SIGNAL and still pairs a request from one moment with a `self.target` read at another. That is SAFETY-40. And it does not stop the loop railing in the first place; that is SAFETY-39. This is a stop-gap and says so at the line. Nothing was suppressed: every engagement is still counted, the action-log row is unchanged, and the §10.3 runaway still raises the alarm - an earlier attempt at this that split the alarm so only `clamped` notified was REVERTED because test_clamp.py:398 proved it would have disabled runaway detection entirely (a runaway presents as `i_clamped`, and control.py:78-81 says `clamped` is unreachable on a default install). Full suite: 20 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
26c5ee8adc |
TEL-07: a second MQTT device for inverter B, and A keeps the one it has
checks / unit (pull_request) Successful in 4m58s
MQTT discovery announced ONE device and hung every entity off one unique_id prefix, so at two inverters B's per-unit telemetry had nowhere to go. It has somewhere now, and not one entity that already exists moved. ⚠️ THE ASYMMETRY IS DELIBERATE. The first roster entry's per-unit rows are announced on THE DEVICE IT ALREADY HAS; every later entry gets its own device, via_device the controller. "One device per inverter" is tidier and is the change that renames every entity on the live install - and because HA keeps the id it composed at FIRST discovery, a rename produces a NEW entity beside a DEAD one: the history stays behind and every dashboard, template and automation naming it goes on pointing at a sensor that quietly stopped updating, including the three live watchdog automations. The composed ids come out symmetric anyway (sensor.goodwe_inverter_a_battery_power / ..._inverter_b_...), so the tidy design's only real benefit is bought here without its cost. * four per-unit rows - setpoint, battery power, SoC, SAFETY-02 sign latch - announced only from a roster of two. At N=1 nothing is announced and nothing is published: no second device, no renamed entity, no added topic. * grid and p1_sample_age_s stay SITE-scoped and are announced ONCE, at every roster length 1-6. One P1, one grid connection, and p1_sample_age_s is a watchdog input - a second copy is a second answer to "is the meter fresh?". * inverter.roster_names() is the one answer to what the units are called, read by build_roster() and by the publisher, which must know the roster before a Controller exists because announcing happens on the CONNACK. * DECISIONS/TEL-07-second-mqtt-device.md records the two designs side by side. VERIFIED BY DIFFING THE ANNOUNCED PAYLOADS, not by reading the code. The release/1.0 baseline in test_mqtt.py was generated by running that commit's module, not transcribed. Roster of one: 9 topics, 0 missing, 0 changed, 0 added. Roster of two: 0 missing, 0 changed, 8 added. MEASURED, NOT ASSERTED. Sixteen mutations through tools/mutation_run.sh; twelve are in the file as a running table, each named with the property it breaks. Three of those properties exist because the table found their ABSENCE - dropping the unit from the state topic, moving `grid` into the per-unit set, and `units=()` in main()'s publisher call were all GREEN on the first run, as was a roster fallback naming two nameless entries `inverter-a`. Part 9 enumerates what the file still cannot see, with sizes. ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every roster of two here is SIMULATED, from the datasheet and serial in NOTES.md §1.1b. Nothing about B has been measured and no entity described here has been seen on a broker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
19decf433f |
SAFETY-32 round 5: stop closing sites, build an instrument that finds its own scope
checks / unit (pull_request) Successful in 4m59s
Rounds 2, 3 and 4 each closed exactly the survivor they were handed and each time
the reviewer found the same shape one step over: the reduction, the call site, the
sibling argument, and now the sibling MODULE. Round 4's enumeration stopped at the
`Controller` class boundary; round 5's four blockers were reductions in validate.py,
over a list DERIVED from the roster three calls away from it. Closing four more sites
by hand would have produced a fifth rejection, so this round widens the instrument
instead and lets it enumerate.
⚠️ THE BLOCKERS ARE NOT EQUIVALENT MUTANTS - a safety refusal disappears, verified by
calling the shipped validator both ways on the real tree:
roster max_w 5000 (inverter-a) beside 2000 (inverter-b), sign_disagree_w 3000
clean -> 1 problem, "...max_w (inverter-b) is 2000 W, so the
SAFETY-02 sign-disagreement watchdog can never fire"
min(caps[:1]) +4 B -> 0 problems. The site STARTS.
caps_ix[:1] +4 B -> 0 problems.
the SAME roster with the small cap FIRST (2000, 5000)
clean -> 1 problem, naming inverter-a
min(caps[-1:]) +5 B -> 0 problems.
⚠️ AND THE ONE-ROSTER FIXTURE COULD NOT HAVE SEEN THAT. On [5000, 2000] the `[-1:]`
mutant still refuses, because last IS min there. A fixture of N members cannot separate
two reductions that agree on N members - which is why the behavioural half below is a
SET and not a roster.
THE INSTRUMENT (test_peakguard.py part 12j, widened past `Controller`)
Every `.py` under app/ is read off disk and parsed - NO MODULE IS NAMED IN THE CHECK -
and the roster is found by transitive closure from ONE seed, the `inverters` option key:
through returns, through lists ACCUMULATED in a loop over a roster (which is exactly
what `caps`, `roster` and `problems` are, and what round 4's rule could not express),
through attributes and properties, and THROUGH CALL ARGUMENTS, which is how it walks
into peakguard, arbiter, allocator, circulation, maintenance and store without being
told they exist. A slice of a roster stays a roster so a mutation cannot cut the taint;
an index is one unit and does not.
183 roster reductions across 9 modules, out of 23 files parsed:
main 96, allocator 24, validate 19, arbiter 14, circulation 10, maintenance 10,
peakguard 4, inverter 3, store 3. 14 modules reduce over no roster at all.
⚠️ THE HAND-WRITTEN MODULE LIST WOULD HAVE BEEN WRONG IN BOTH DIRECTIONS. It named
watchdog.py, which reduces over no roster at all (SAFETY-30's sign watchdogs are
strictly per-unit), and omitted maintenance.py and store.py, which both do. A vacuous
claim about one module and no claim at all about two others - which is the argument for
computing the scope, made with data.
The pin rediscovers ALL FOUR blockers without being told where to look, and finds a
FIFTH beside them (validate.py:783, rule 5's own message) that no review round named.
Its counterfactual runs at two depths: every enumerated site's expression mutated with
each of the four cuts this card has been defeated by (183 x 4 = 732 spellings, all
reported), and `[:1]` written into app/validate.py at each blocker position with the
WHOLE analysis re-run over the mutated source.
THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE
All 183 sites mutated to `[:1]`, +4 B each, against the full 19-file suite in
goodwe-addon-ci (:ro, -B, fresh rsync per mutant, 0 .pyc before and after - a read-only
mount cannot write one): 183/183 RED. ⚠️ That is TAUTOLOGICAL on its own, because the
pin makes every slice red. So the 51 whose first red was test_peakguard.py were re-run
with the pin NEUTRALISED:
145 of 183 a behavioural fixture separates the site from a slice of itself
38 of 183 ONLY the structural claim. Behaviourally UNGRADED.
All 38 are listed by site with what a `[:1]` there would cost, in the DECISIONS record
and in part 12j itself: 15 are rows and counts on the operator's page, 3 are
`len(targets)` arithmetic beside a write path that IS graded, 6 are allocator internals
ALLOC-01 reaches but not with enough units to separate. NONE of the 38 is closed
behaviourally by this card and none is counted as covered.
THE BEHAVIOURAL HALF, because a shape pin grades an identity and never a meaning
test_validate.py grows a ROSTER SET for the three rules that reduce over
`unit_caps_indexed()`: widths 2, 3, 4, 5 with the one small cap at EVERY position,
14 rosters x 3 rules = 42 runs of the real validator, each asserting the refusal fires
AND names the unit the number came off. Beside it a COMPUTED family table of twelve
candidates - max, first, last, the mean, [:1], [-1:], [:2], [:3], [1:], [:-1], [::2] -
each compared against what the shipped validator actually answered on the same roster.
Only `min` agrees everywhere; a member no roster kills is printed as a NAMED SURVIVOR.
⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
Found by reading the three branches side by side, NOT by the pin - and said that way
because it is the shape of what the pin cannot see. `unit_sign_disagree_w` was graded
against `_number(opts, "max_w", 2000.0)`, three lines under a branch that had already
stopped doing that. Both directions were measured before the fix:
two 2000 W units + a leftover `max_w: 5000` + unit_sign_disagree_w 3000 -> 0 problems.
The site started, the status page said `armed`, and the per-inverter watchdog could
not trip on EITHER unit. Identical sentence to the blocker, one branch over.
two 5000 W units with NO flat key -> REFUSED against a 2000 W default the operator
never wrote, for a threshold both units can reach.
It takes the same `weakest` the branch above takes and names the same unit. At a roster
of one `unit_caps()` is `[max_w]`, so the shipped single-inverter refusal is the same
comparison, the same boundary and the same sentence.
MUTATION PROOF (each: bytecode purged and counted 0 before and 0 after, the edit
re-read FROM DISK with the new text present and the old absent, byte delta printed)
B1 validate min(caps) -> min(caps[:1]) +4 B RED test_validate.py
B2 validate min(caps) -> min(caps[-1:]) +5 B RED test_validate.py
B3 validate caps_ix -> caps_ix[:1] +4 B RED test_validate.py
B4 validate rule 6 min -> min(caps_ix[-1:]) +5 B RED test_validate.py
B5 validate rule 5 msg -> min(caps_ix[:1]) +4 B RED test_validate.py
B6 validate rule 5b msg -> min(caps_ix[-1:]) +5 B RED test_validate.py
B7 CONTROL: weakest -> max(0.0, weakest) +10 B GREEN, as intended
R3a peakguard max(floors) -> min(floors) 0 B RED test_peakguard.py
R3b peakguard max(floors) -> max(floors[-2:]) +5 B RED test_peakguard.py
R3c main call site packs -> self.packs[:2] +4 B RED test_peakguard.py
R4a main pack_floor_soc -> vals[:2] +4 B RED test_peakguard.py
R4b main pack_floor_soc -> vals[-2:] +5 B RED test_peakguard.py
R4c main pack_floor_soc -> vals[:3] +4 B RED test_peakguard.py
R4d main cycle() any(...) -> self.inverters[:1] +4 B RED test_watchdog.py
B1-B6 are caught by the BEHAVIOURAL set in test_validate.py, before the pin is reached.
B7 is a deliberate no-op control: `max(0.0, weakest)` is `weakest` for weakest > 0, and
it stays green, so the battery is not merely red for everything.
R3a is a ZERO-byte delta, so the shipped function was called both ways as well:
packs floors [15.0, 30.0, 20.0], peak_soc_floor 15
binding_soc_floor(...) = 30.0 the 0-byte mutant would answer 15.0 (+15.0 pp)
-> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %.
⚠️ PRODUCTION IS DEGENERATE ON THE FLOOR AXIS. SPIKE-04: pack A and pack B BOTH floor
at 10 %, so a differing floor exists ONLY in the fixtures. That is the argument for
keeping them, not for simplifying them - collapsing them deletes the only data in this
repository that can see the reduction at all. Restated in config.yaml's example, in the
probe and in the fixtures.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every second-unit figure here -
4600 W FROM ITS DATASHEET, 15 % illustrative, the whole roster SET - is SIMULATED and
labelled. Only inverter A's 10 % is read off hardware.
REBASED onto
|
||
|
|
f9e7fbf86d |
SAFETY-32: bms_soc_floor per pack, rule 8 per unit
`bms_soc_floor` describes A BATTERY, not a site. At two packs of different models the cut-outs differ, and one number for both graded the shallower pack against the deeper one's BMS - so a site with cut-outs of 10 % and 30 % under `peak_soc_floor: 20` STARTED, and the guard would command a forced discharge that pack B's BMS, not the operator's floor, is the thing that stops. That is rule 8's own failure mode, reached through rule 8. Scoped to the card's third bullet, per its PM PROOFREAD: the aggregate anti-windup (DEV-02) and per-instance `achievable_w` (CFG-04) were verified as already shipped and are untouched. * config.yaml: `bms_soc_floor: int(0,50)` REQUIRED per `inverters:` entry. Nothing is inherited; the flat key stays the single-inverter shape's own cut-out. A per-entry DEFAULT was declined - a default is one number for every pack, which is this card's defect - and the decision is recorded. * inverter.py: `Inverter.bms_soc_floor`; an entry that states none gets None, not a borrowed number and not 0.0 (the most permissive value the key has). * peakguard.py: `Pack`, `pack_floor_pct()`, `pack_permissions()`, `binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the one place the question is answered. * validate.py: `unit_bms_floors_indexed()` carries the ROSTER index; rule 8 grades EVERY unit and NAMES the one it refuses. Graded with the offender SECOND and with the same two numbers in the other order. * main.py: `Controller.packs`; `decide(soc_floor=...)` is the per-pack reduction of the same option; the floor-limited notification names the pack that stopped the site. `pack_floor_soc` - the emptiest pack, and its None-on-missing behaviour - is untouched. * tools/supervisor_shape_probe.py: RE-RUN and repaired. The required key broke §C and left §D printing PASS on a refusal about a different key. §D is now an isolated counterfactual over an accepted merged baseline, one key varied, asserting the key name. `all cases passed` on 2026.08.0. Stated rather than hidden: under any config rule 8 accepts the per-pack FLOOR reduces to `peak_soc_floor` for every pack. The reduction is written for `unit_caps_indexed()`'s reason and graded on both sides - 1775 rule-8-valid pairs answer the option, the pair rule 8 refuses answers the cut-out. `peak_soc_floor` is still one number for the site, so the "deeper pack needlessly withheld" half is filed as an open question, not closed. Full 19-file suite green. Twelve source mutations through tools/mutation_run.sh: eleven red, one argued survivor. The battery also found `_flat_bms_floor()` defaulting in two places, each covering for the other. No measurement of inverter B appears anywhere: it does not exist yet. Every second-pack figure is simulated and labelled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
905b6fd2b3 |
MAINT-05 fix round 2: the refusal has to survive the NEXT start, and both marker guards have to be visible
checks / unit (pull_request) Successful in 5m14s
REJECTED because the fix round's own load-bearing sentence was FALSE, and the
whole suite was blind to that. `_verify_moves()` produced the red the reviewer
reproduced - that half stood - but DECISIONS §2 claimed "`ok` gates the marker,
so a misdirected migration is not stamped and the next start runs it again ...
a re-run is recoverable". THE REFUSAL LASTED EXACTLY ONE START.
MEASURED, Controller level, ONE persisted store, roster ["garage","shed"], flat
last_completed 2026-08-07, call site still handing `inverter-a` on both starts:
construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent>
construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1
history parked under unit/inverter-a/...; marker stamped after ONE restart.
WHY. `_verify_moves()` graded only `rep.moved`. On start 2 the same wrong call
site finds the four keys already present, so rule 1 routes every one of them to
`rep.kept`, `rep.moved` is EMPTY, the roster loop iterates over nothing,
`misdirected` is empty and `ok` is True. Start 3 then takes the
`from_version >= MAINT_SCHEMA_VERSION` early return FOREVER - including after
the call site is fixed. Loud-and-recoverable became silent-and-permanent after
one restart: the exact state store.py's header says must never happen, reached
by the fix written to prevent it.
WHY THE CHECK MISSED IT - the recurring defect, this time INSIDE the remedy.
Part 9b's recovery check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` - the
CORRECTED name. It measures recovery along the one path where the operator has
ALREADY fixed the bug. Nothing in the 19 files called `migrate_maint_keys()`
twice with the SAME WRONG `unit`. The check was true and passed for the wrong
reason.
WHAT CHANGED
1. app/store.py `_verify_moves()` grades `set(rep.moved) | set(rep.kept)`.
Sound by construction: every entry in both lists is a `unit_key(unit,
legacy)` built from the SAME `unit` argument, so grading `kept` grades the
same namespace decision `moved` does. A correct call site's `kept` entries
sit under a roster name and pass; a wrong one cannot launder its namespace by
having already written it. `migrate_unit_keys()` passes `roster=None` and
returns before the loop, so it is untouched.
2. test_maintenance.py part 9c - THE CHECK THAT WAS MISSING. Two starts, SAME
wrong unit, ONE store: the marker is still withheld on the second, the
refusal names the same roster, the flat history is still there to recover
from, and a third start with the CORRECTED name still gets a run (which a
stamped marker would have made impossible). Its PRECONDITION asserts the
SHAPE of the second report - `moved` empty, `kept` full - so it cannot
quietly revert to grading 9b's path. Counterfactual beside it: the same two
calls with the right name are accepted, then skipped as `already at schema`.
3. test_maintenance.py part 9d - THE OTHER GUARD ON THE SAME LINE, and a
survivor nothing in the repository could see. `if rep.ok:` is
`not lost and not misdirected`; every check that reached the marker did so on
a store whose `kept` was empty, so
if rep.ok: -> if rep.ok or rep.kept: (+12 B)
stamped the marker on a migration that LOST A MONTH OF HISTORY - defeating
the older, settled `lost` guard as well as the new one - and ALL 19 SUITES
WERE GREEN on it (measured at
|
||
|
|
65cddf35f1 |
MAINT-05 fix round: grade the migration against the ROSTER, not against the name it was handed
checks / unit (pull_request) Successful in 4m47s
REJECTED because a survivor undoes AC 5, and it survived for the recurring
reason: two fixture values that must be distinguishable were EQUAL.
THE SURVIVOR. app/main.py:282 mutated from `self.inverters[0].name` to `UNIT_A`
left the full 19-file suite green. `inverter.UNIT_A == "inverter-a"`, and every
two-pack fixture in test_maintenance.py named its first roster entry
"inverter-a" - so the two expressions were the same string in every check that
existed. main.py's own comment made the distinction load-bearing and nothing in
the repository could see whether that comment was true.
THE FAILURE IS REAL. `build_roster()` names a declared roster's entries from the
config and numbers a nameless one POSITIONALLY (`inverter-1`), so "inverter-a"
is not even the fallback. On ["garage","shed"] with a flat last_completed of
2026-08-07 the mutant migrates into unit/inverter-a/, pack 0 reads back None,
and both packs come up believing they have never been recalibrated.
AND IT FAILED SILENTLY, which was the worse half: `_verify_moves()` read back
the keys it had just WRITTEN, under the namespace it had just been HANDED, was
satisfied, and MAINT_SCHEMA_KEY was stamped - so the next start skipped the
migration too. A postcondition that reads back the namespace it was handed
cannot detect being handed the wrong one.
WHAT CHANGED
1. THE POSTCONDITION IS GRADED AGAINST THE ROSTER. `_verify_moves()` takes the
names the MACHINES read and reports a namespace that is not among them as
`misdirected` - a new, differently-named failure from `lost`:
* `lost` is "the store took the write and dropped it" - a storage fault,
with a harmless-default case (a dropped `last_completed: None` loses
nothing, because absent and never-recalibrated are the same statement);
* `misdirected` is "the store did exactly as it was told, by a caller that
was wrong". There is no harmless-default version of it - a write nobody
reads is not information whatever its value - so it fails `ok` alone.
`ok` gates the marker, so a misdirected migration is NOT stamped and the next
start re-runs it. Asymmetric on purpose: a re-run is recoverable (rule 1
makes it a no-op, rule 2 left the flat keys to recover from), a silent skip
is not. Part 9b MEASURES the recovery rather than asserting it.
2. `migrate_maint_keys()` TAKES THE ROSTER IN PLACE OF AN `n_units` COUNT. The
count was `len(self.inverters)` at the only call site, so the roster carries
it, and a count beside the names is a second opinion about how many packs
there are.
3. THE FIXTURES CAN NOW REACH THE FAILURE THEY NAME. Parts 9 and 11 run on
["garage","shed"], each behind a PRECONDITION check that the first name is
not UNIT_A - so an edit that makes them equal again goes red instead of
making the claims vacuous. Part 11's fixture date is relative to `now`, not
a literal, so "history preserved" and "history lost" cannot converge as the
file ages past `interval_days`.
MUTATION LEDGER - all red, purged tree, none at +0 B, full detail on the PR:
M1 main.py inverters[0].name -> UNIT_A -16 B 4 checks red
M2 store.py drop `roster=names` from _verify_moves -14 B 5 checks red
M3 store.py drop the misdirected.append -37 B 5 checks red
M4 store.py ok: drop `and not self.misdirected` -25 B 3 checks red
M5 fixture garage -> inverter-a (the trap itself) +4 B 5 checks red
M6 store.py parts[1] -> parts[-1] +1 B 1 check red
FOUND AND NOT FIXED - `migrate_unit_keys()` HAS THE SAME HOLE AND IT IS A LATCH.
It runs BEFORE `build_roster()` (each Inverter restores its latch in its
constructor), so it is handed the constant UNIT_A with no roster to grade
against. On a declared roster a pre-DEV-02 `sign_tripped: true` lands under
unit/inverter-a/sign_tripped, which no unit reads - and
`_grade_store_migration()`, whose whole job is to re-arm exactly this, is
DEFEATED BY THE KEY THE DEFECT WROTE: its "is anything namespaced latched" test
is satisfied by the misdirected value itself. Measured, with the flat-config
counterfactual, and pinned as a running check (test_maintenance.py part 13)
whose assertions state the WRONG behaviour on purpose. It wants its own card:
the remedy is in inverter.py and SAFETY-02's semantics, and a silent expansion
into a safety latch is how a rejected PR becomes a broken one.
Suite: 19/19 PASS, purged tree, bind-mounted :ro in goodwe-addon-ci.
No hardware was touched; every fixture here is SIMULATED and pack B's 3000 W
remains a marked hypothesis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
1aef19e995 |
MAINT-05: one recalibration per pack, and only one pack moves power at a time
checks / unit (pull_request) Successful in 4m48s
§12's monthly cycle drains a pack to empty and refills it to true its coulomb
counter. There was exactly one `Maintenance` and one un-namespaced history, so
at two packs only the first was ever recalibrated - and nothing prevented the
cycle draining pack A while the loop charged pack B, which is two power-moving
phases on one grid connection pulling against each other.
* ONE `Maintenance` PER ROSTER ENTRY, each with its own schedule, phase and
history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
install's flat history into the first pack's namespace under
`migrate_unit_keys()`'s three rules - shared in code via `_move_flat_keys()`
and `_verify_moves()`, not restated - on its own marker, because unlike
DEV-02's it runs only where the roster has more than one pack. The flat keys
are kept LIVE as the rollback view.
* ONE MAINTENANCE SLOT PER SITE (`maintenance.arbitrate()`). At most one pack
in a power-moving phase - drain, charge AND hold. The holder is DERIVED from
the packs' own stored phases, never persisted, so a restart mid-drain
re-derives it. Several packs due in the same hour: the slot goes to the one
that has gone longest without a completed cycle; roster order breaks a
numeric tie and nothing else. Nothing preempts a cycle in flight.
* THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT. A deferral does not
stamp `last_start_attempt` and does not move the phase, so neither the 12 h
drain nor the 36 h charge timeout is reachable by waiting.
* The claim is filed on the pack whose state machine produced it, and stays
SCOPE_UNIT - ARCH-01's rule.
* `validate.py` rule 6 grades `maintenance_charge_w` against the WEAKEST cap on
the roster now, as its own comment said it must once `maint_unit` stopped
being `inverters[0]`.
* At a roster of one nothing changes, and it is asserted: no new store key, the
migration does not run, the four cycle events are the shipped strings, the
banner is the bare phase.
* RECORDED NOT FIXED: a drain pins its pack while the loop's target is a site
claim, so below the pin the allocator refuses the split and the site parks at
0 W. Pre-existing at
|
||
|
|
ae21b71824 |
Merge release/1.0 into SAFETY-30
checks / unit (pull_request) Successful in 4m39s
CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved as the union of the two sides: - app/main.py: an import-block collision. CFG-04 added validate_unit_ranges to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither side dropped anything, so the resolution keeps both lists whole. - CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept. Both sides also independently rewrote large parts of test_peakguard.py (60 and 47 insertions against the merge base) and git merged them textually, so the full suite was run on the MERGE RESULT rather than inferred from the two parents: 19 passed, 0 failed, twice. One caveat recorded rather than hidden: test_peakguard.py failed once during the first sequential full-suite run and then passed 10/10 in isolation and in two further full runs on this same tree. It passes on both parents too, so this is the pre-existing flakiness CFG-04 already disclosed, not a merge defect - it is timing- or load-sensitive. It sits on the required CI context and can therefore block an unrelated merge at random, which wants a card of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |