f0b4e68e492cafd25bf9a3b7785a2537f84c9018
46
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f0b4e68e49 |
SAFETY-118: read the peak forecast in its own unit
peak_forecast_entity is read through pv.watts() by its unit_of_measurement; the abs(raw) < 50 size rule is gone. No unit or a non-power unit means the forecast is not used, with one warning per cause. The validator no longer claims a kW sensor is taken as watts for this entity. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
c68fd275cc |
PRICE-01 round 3: replace the fixed-tariff notification with a log line and a settings note
G1: remove the false 'Fixed tariff not used' notification. One INFO line at start and an info note beside the setting (EN/NL) when price_service has a non-blank static_profile; the old notification id is dismissed at start. DOCS, migration step 5 and DECISIONS rule 7 now say what the code does. S05: whitespace-only static_profile counts as not set. G2: maintenance_enabled read through MaintConfig.from_options(). Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC |
||
|
|
0801776645 |
PRICE-01 round 2: the token on no page, fixed-tariff note, token length, F3 words, test gaps
F1: every password option is shown as ******** (set) or empty in /config/model (options, running, ui) and in a stale refusal; the classic page draws it as a password field; a save or validate that sends ******** or nothing keeps the stored value. F2: a static_profile beside price_service is said once at start (notification and log), not refused. F3 (owner, 2026-10-14): the no-price row, Home line and notification only while maintenance_enabled is on. F4: tests for the reviewer's R01 and R10. F5: price_service_token must be 16+ characters. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC |
||
|
|
9ac3a9d2bc |
PRICE-01: price_service provider, fallback chain, and the gate read from it
A new price source, `price_provider: price_service`: the owner's price server,
one fixed HTTPS host, a bearer token (new password option
`price_service_token`), wholesale energy only so markup and taxes are added as
for generic_dayahead.
The chain is server, then energy-charts, then the prices the history store
kept. A step counts only if it has a price for now. Each step down and back up
is one log line. With every source down a site plans from what it saved.
No price for now: one status row, one Home plan line and one notification, in
English and Dutch, cleared when a price returns. With this provider the
maintenance charge gate reads the provider's prices and keeps the Home
Assistant rule: the current 15-minute price is at or below today's average.
The token is hidden on the settings page, refused when missing, and taken out
of any error text. Other providers, `off` and the Home Assistant pair take the
road they always took (scripted-day probe against
|
||
|
|
9b1fa758ad |
OPT-17: the solar forecast can come from several entities, summed
New list options pv_forecast_entities and pv_forecast_tomorrow_entities
(same shape as pv_entities); the single-entity keys are unchanged.
- Each member is read in its own unit (Wh/kWh/MWh) in one gather and the
day is summed. Hourly splits are merged when every member has one, else
the total goes on the bell.
- A member missing, unavailable, stale, non-energy or bad makes that
day's forecast unusable (no partial sum). A caution row names it by
position; the action log names the entity once per change.
- Single key and list both set for one day: refused at start.
- A one-member list behaves exactly like the single key (test_opt17.py);
the single-key path is byte-identical to
|
||
|
|
fbd884797d |
OPT-11 P4 review: Cheap hours ignores the plan; midnight-stale forecast; stricter ids
- Cheap hours only fills to the ceiling with or without a forecast, as the design (section 1) says. Skipping the sun's share cost 0.31 EUR on real 2026 prices (29.72 against P3's 29.41) and saved nothing on the P3 set. - A forecast reading from before local midnight is stale: until the integration refreshes, "left today" is still yesterday's 0 and "tomorrow" is really today. - Forecast entity ids must be sensor.[a-z0-9_]+ (they go into the /api/states/<id> path). - House profile: a stored day that is not a date, or is after today, is dropped instead of kept forever. - Text: the forecast line says "for the rest of today"; the learning line no longer promises "tomorrow" (a plan needs one full day). - Tests for four surviving review mutants (hour sample floor, the dear run after the cheap run, share by room, daytime use) and the fixes above. - Replay tool: `--fc zero` (a forecast of no sun). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
eb10537aee |
OPT-11 P4: solar forecast and day plan
A Home Assistant solar forecast (Forecast.Solar, Solcast or Open-Meteo
shapes) and the add-on's own hourly house use decide, before each cheap
run, whether the sun will fill each battery before the expensive hours.
Leftover solar then buys from the grid only the shortfall; Cheap hours only
skips the part the sun covers; Solar first never grid-charges. No, stale or
failed forecast: P3 option A exactly (12 digests pinned from
|
||
|
|
7c4c8f5c66 |
SAFETY-79: peak guard keeps a margin below the limit
New option peak_margin_w (default 50 W, 0-500 W). The peak guard, the
forecast checks and the maintenance charge sizing aim at
peak_cap_w - peak_margin_w; the month peak, the bill, headroom, warnings
and every notice keep the real cap. Owner decision 2026-09-28.
peak_margin_w 0 is byte-identical to
|
||
|
|
159d7461d8 |
UX-15: setup-only mode, and the wizard fixes F1, F2, meter, step 1
A new install names no inverter. When that is the startup gate's only refusal, the add-on now serves the setup wizard in setup-only mode instead of exiting 78. It builds no controller, MQTT client or meter task, its HomeAssistant is read-only (every service call refuses before sending), Controller refuses a read-only HomeAssistant, and /action answers 409. Any other refusal still exits 78. DECISIONS/UX-15-setup-only-mode.md. Wizard fixes from the UX-13 P4 test: - F1: /config/validate converts what it can, so a conversion error blocks its own step and the live checks still run; save 422 carries steps. - F2: saves send the model the page opened (base); only keys this page changed are written onto the blob stored now. - A meter entity missing from Home Assistant is red on the last step. - Step 1 says the reading went down after a skip that followed a fall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3198f346fd |
UX-13 P4 review fixes (PR #146, comment 1887)
R1: first_start also needs a Store created at this boot (Store.fresh), so
old never-started sites (both TEST-23 add-ons, one in shadow) stay on Home.
R2: a kettle skip after a fall says on the last step that the reading went down.
R3: the shadow switch goes only on the model the wizard posts, never into V.
R4: Home Assistant not answering is red on the last step.
R5: meter + solar + battery below -300 W flags a reversed battery sensor.
R6: the live checks say what to change and where.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
be57b87c34 |
UX-13 P4: the setup wizard for installers
The Setup tab draws the Installer view's page model one step at a time, from each field's `wizard` step in app/uilayout.py: meter (with the kettle test), inverters and batteries, solar (optional), prices, peak limit (battery modes: "comes in a later update", OPT-11), check and start. It reuses P2's controls and saves through /config/validate and /config/save. No route was added. - First run: /config/model says `setup_needed` when the stored options name no power request entity, or on a new install (no `setup_done` and no `auto` in the Store, control not started). The page opens #setup only when opened with no route. The wizard's save writes `setup_done`. - Kettle test: GET ha/entities?entity= reads one entity now, in W. The change decides: up keeps meter_invert off, down turns it on; a net or import/export meter that goes down blocks the step with what to check. - Last step: validate.setup_checks() beside the refusals: the meter reads, each power request entity exists in HA (bad if not, SAFETY-77), battery levels and power with direction, solar sign, prices. Saves with shadow_mode on by default, then a confirmed restart naming the 0 W. - P2 test finding (a): a stale save lists only the keys the other surface moved (the blob handed out on open, by fingerprint, against stored now). - test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard can save; all accepted by Supervisor 2026.08.0 AppOptions (offline). - DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
15627c38a6 |
UX-13 P0: layout table, page<->options derivation, warning tier, entity list
The foundation for the new settings page. No visible UI change. - app/uilayout.py: screen -> group -> part -> field for every option in config.yaml (67 fields, 11 derived, 0 hidden), with control, unit, display scale, step, entity kind, show conditions, Simple/wizard placement and "applies after a restart". Ranges stay in the schema, words in translations and the new app/ui_text/en.json. - app/uimodel.py: options_to_ui()/ui_to_options() for the derived keys (one inverter vs the list, solar rows, static_profile from simple prices, a year-stamped region preset for the levies figure, and an OPT-11 hook for arbitrage_enabled that changes nothing yet). Unchanged derivations write the stored values back, so the round trip is exact. - validate.py: validate.Note, a warn/info tier. Each logged warning is also a note when a list is passed; config_notes() adds checks that need live data (missing entity, a kW sensor read as W, a limit below this month's peak). /config/validate returns them as "notes". No refusal changed: 261 recorded validator calls, 0 differ (tools/ux13_validator_calls.py). - GET ha/entities: Home Assistant's entity list for the picker, read through hass.states(), cached 30 s, 10 s timeout. When HA is down it answers 200 with the old list marked old, or an empty list that says why. - DECISIONS/UX-13-one-layout-table.md amends UX-02's "no option key in UI code": keys may appear in uilayout.py and uimodel.py only, both tested. - Tests: test_uilayout, test_uimodel (round trip over the recorded calls, the TEST-23 option files and config.yaml defaults), test_haentities, a warning-tier part in test_validate; test_messages scans notes and ui_text; test_pv and test_selfconfig part 7 widened on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
23a3a3f5b5 |
UX-10: the last plain-language leftovers: log lines, two table rows, 'Why: Other', red at 0 s
- test_messages.py part 6: every dynamic part of a WARNING+ line is classified as data or as a built text, and every built text is produced by the real code and checked. This is the gap the 2d scan had: it counted `%s` as one word, so reasons built by loop_clamp_note() and friends were never seen. - Quoted reasons rewritten to STYLE.md (red-alarm shape, codes kept): OUTPUT_CLAMP, METER_FROZEN, CIRCULATING_POWER, STALE_READING, the peak guard (codes now PEAK_GUARD*), the migration report, price feed, price history and gate reasons. Plurals via messages.times()/count(). - Top table: Price history "96 prices over 24 hours for 26 Sep", Tariff "one every 15 minutes or every hour", "not final yet" dropped. - Levels and noise: per-cycle shadow line DEBUG, shadow on/off INFO, aiohttp access log only at log level debug, settings warnings once per start, one plain line per Home Assistant outage (hass.Outage). - INFO jargon and error texts: no "§8.3", ISO times, "banked", "keys ARE", "NOT_OBSERVED", "did NOT land", "AND". - Display bugs: WHY_WINNER covers every winner and safety reason (main.why_key(), part 7 finds them in the code); the meter row is amber "waiting for the first reading" inside its limit after a start; the missing-reading age is counted when the page asks. - STYLE.md and DECISIONS/UX-08-short-notifications.md (UX-10 section). No change to safety behaviour, alarm levels, notification ids, triggers, cadence or the banner cascade. One row colour changes (meter at start), justified in DECISIONS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6a95447248 |
UX-08 2d: plain log lines, the rest of the status page, and DOCS
Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons; the reasoning of the ten longest is on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0). The status page: the month-peak and peak-guard rows, the stale-reading row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the Stopped banner and the line under the buttons, which now shows the plain hint of each event instead of the raw action log. The Supervisor question at start logs one line with a real reason. Refusals are counted as shown (labels expanded, long cases driven), seven were shortened, the card and parse errors lost their internals, a blank value reads "empty", and the panel no longer files inverters.max_w under the flat max_w. Option names lose "(JSON)", the NL texts are checked too. DOCS gets a glossary (control board, isolator, Developer Tools) and card ids move from headings to a "For installers" line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
c84d5fd14f |
UX-08 2c review: unit settings anchor to inverters, plainer texts
- messages.setting() for one inverter in the list reads “Inverter rating” for inverter-b (inverters.max_w), so the config panel files per-unit reasons under `inverters`, where the value is changed. - The tariff card and plausibility reasons name “Distribution, levies and VAT” (price_taxes_eur_kwh) again. - The wrong-direction reasons say "safety stop", the sign_disagree_w option says what it protects, and the roster, price-entity, integrator_max_w and capacity_kwh texts read more plainly. - NL: stuurprint -> stuurmodule, "De piekbeperking". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2c516a032c |
UX-08 2c: short refusals, error texts and option texts
Validator refusals name the setting by its label and key, say what is wrong and what to change, and keep the rejected value and limit. Option names and descriptions are short in en and nl; citations moved to DOCS "For installers" and the test_translations citation check now pins the instruction on the tab and the record in DOCS. No rule, key, schema, default or exit code changed. Allowlist 167 -> 43 (log lines only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
8fa4febfef |
SAFETY-10: the whole decision path runs and nothing reaches the inverter
checks / unit (pull_request) Successful in 7m58s
Every optimiser defect this project has found was invisible from outside while it was happening, and each was paid for at the meter first: SAFETY-45's 28 charge onsets an hour against a binding peak_cap_w, TARIFF-14's wrong quarter-hour for 45 minutes in every 60, SAFETY-46's 598 Wh spent to gain 310 Wh. There was no way to watch what the optimiser WOULD do without letting it do it. `shadow_mode` runs strategies, arbiter, clamps and guards, records the decision with its reason, and commands nothing. ⚠️ IT WRITES. It writes 0 W, on the ordinary heartbeat, through the ordinary single writer. The inverter holds its last command forever - it has no meter timeout - so "commands nothing" implemented as "stops writing" would leave whatever was standing when shadow mode came on standing for as long as it was left on, under a page saying nothing is being commanded. That is the failure this repository keeps finding. Refreshing 0 W is also what firmware/goodwe-master.yaml's 30 s stale-input watchdog requires of us; going quiet would keep that board tripped for the whole run, which is an alarm being used as a mode. ⚠️ IT IS NOT A SECOND WRITE PATH. No claim, no transport, no arithmetic of its own. It is a substitution inside Controller._write_unit() - the single choke point every write already reaches - in the same form as the three above it, and it is the LAST of them, so SAFETY-28's gate and SAFETY-03's clamp still grade the number the decision path produced. ⚠️ THE DEFAULT IS `auto`, NOT `true`, AND THAT IS THE CARD'S REAL CONTENT. The Supervisor composes options as {**config.yaml defaults, **stored user options}, so a new key with a new default reaches EVERY EXISTING INSTALL on the next update - including the one regulating a real battery. A boolean default-on would have stopped it silently. There is no mechanism by which one key can default differently for a new install and an existing one, so "default-on for new" is a value the add-on RESOLVES: `auto` is shadow while control is STOPPED and live once it is STARTED. A fresh install ships `auto_start: false` and comes up observing; an install that is controlling is started and does not change by one watt. THE FAILSAFES. Sign watchdogs (SAFETY-02/30): INERT - they grade Inverter.standing_w(), which shadow mode holds at 0 W. Fed the shadow decision they would disagree with a meter that never saw it and LATCH A TRIP THAT SURVIVES INTO LIVE OPERATION AND A RESTART. Clamp (SAFETY-03), write gate (SAFETY-28), meter freeze (SAFETY-18), peak guard: OBSERVE. Circulation (SAFETY-31) and delivery (SAFETY-42): observe and may legitimately latch, because their evidence is measured power. The operator stop is the one deliberate change: enforced at the wire rather than as a P_SAFETY claim, which would otherwise make every recorded decision read "stopped". ⚠️ RECORDED LIMITATION: _shadow_settle() re-seeds the carried command to what stands on the wire, which makes the exit safe by construction - and therefore the loop leg's shadow decision is ONE SLEW STEP, not a trajectory. A shadow run answers "what would it command now", not "what would it have done next". The strategies are unaffected; all three defects above are visible from one. Graded on the writes and on the rendered surface, never on the flag: 22 mutations, no survivors, including one that lets a write escape and one that goes silent instead of commanding 0 W. Closed loop on TEST-23 stage 3's rig, same hour twice: live drives the pack to -2000 W and SoC 50.000 -> 62.331 %; shadow commands 0 W throughout, SoC stays 50.0 exactly, and the arbitrage decision is identical in action, instant, signed power, reason and arbiter winner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
995da777ce |
TARIFF-16: the injection leg stops being one number
checks / unit (pull_request) Successful in 7m42s
`price.py` modelled what a site is PAID for export as ONE scalar. The market publishes a line, and the cards say so in their own words: Eneco Zon & Wind Dynamisch `0,1 x BELPEX-H - 1,188` c EUR/kWh - "het injectietarief is dynamisch en kan per uur wijzigen", identical on the January and the September 2026 cards; ENGIE Dynamic `(B x eSpot_15) - A`; Aspiravi `0,07 x Belpex - 2`. `price_export_formula` carries that line with its OWN coefficients. ⚠️ THIS IS NOT A WRONG-TRADE FIX, AND SAYING SO IS PART OF SHIPPING IT. `arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment says so, citing §9.3's near-zero Belgian injection price as the reason the economically sound trade on this market is charge-cheap-to-displace-expensive- IMPORT. Both hours it compares are all-in CONSUMPTION prices. No trade this add-on takes today is decided by an injection price and nothing here changes one. What it changes is what an operator is told, and what a future export-aware strategy rests on: OPT-06 and any export logic would otherwise inherit a scalar that cannot represent the market, and the 6% VAT asymmetry would be invisible in the arithmetic. test_price.py 17h parses every module under app/ and goes red the day something calls an injection reader, so that sentence is graded rather than attested. FOUR PROPERTIES A SCALAR CANNOT HOLD. Its own factor (Eneco 0,102 consumption against 0,100 injection; Aspiravi 0,07 against ~0,10 - not the consumption B with a sign flip). A negative offset, which `price_markup_eur_kwh: float(0,1)` cannot express at all. VAT-exemption - "injectietarieven zijn vrijgesteld van btw" - against 6% on consumption, representable only because `vat_pct` is PER SPEC. And a price that goes below zero with no floor, under 11,88 EUR/MWh of BELPEX-H, which is the middle of a sunny day. ⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, AND THAT NEEDED A DIFFERENT BAND RATHER THAN A LOWER FLOOR. `implausible_all_in()` floors the consumption leg at +0.02 EUR/kWh of energy; applied to injection that refuses the real market in the hours it matters most. Measured rather than argued: 17c puts ONE spec through BOTH bands and prints that the consumption band refuses -0.03008 EUR/kWh while the injection band accepts it. The floor was not decoration - it is what catches a card read a hundred times too small, the one unit error the card self-check cannot see because it cancels out of a comparison between the two sides - so the bound moves OFF the price and ONTO the coefficients, where it is index-free and sign-free: a pass-through band (Eneco's injection leg is 1.00x, Aspiravi's 0.70x; the band is 0.10-10x) and an offset magnitude bound (0.20 EUR/kWh against published 0.01188 and 0.02). Two rungs that can both answer a hundredfold error, so each is graded on its REASON, each gets a stimulus the other cannot reach with the precondition asserted as its own check, and they are mutated TOGETHER (TEST-16). ⚠️ THE CARD SELF-CHECK APPLIES WHERE THERE IS A CARD, AND WHERE THERE IS NOT THAT IS STATED RATHER THAN WORKED AROUND. `price_formula` can require a `card` block because a Belgian card prints the consumption formula, the reference index AND the resulting price. Eneco's INJECTION formula is printed with no price beside it. Requiring a block would make the published line inexpressible; inventing the estimate would grade the coefficients against arithmetic done with the same coefficients. So the block is OPTIONAL on this leg and unchanged on the other, the self-check runs at the same tolerance wherever a printed estimate exists, and validate.py says at startup - "its coefficients are UNGRADED" - when it does not, naming the two bounds that ran instead. This is TARIFF-15 demonstrating two tariffs end to end rather than three, one card later. 17e also MEASURES the size of that gap: a factor of 0.070 where the contract says 0.100 - 30% of every export payment - passes both bounds, because 0.70x is a pass-through somebody really publishes. ONE PARSER, NOT TWO. `parse_export_formula()` is `parse_price_formula()` with its OPTION NAME as an argument - TARIFF-17's move on `parse_static_profile()`, exactly - and everything that differs between the legs is a string in one `_LEGS` table. A second copy would be a second unit table, a second NaN rule, a second unknown-key rule and a second `to_resolution()`, diverging first on the 23- and 25-hour days. test_price.py 17b pins it two ways: all 32 grammar names are defined in exactly ONE module under app/, with the scope COMPUTED by parsing every file rather than listed; and twelve refusal sentences for the new option are DERIVED from the old option's own sentence rather than transcribed. The four sentences that deliberately differ are named, with what differs, and asserted to differ. NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships ""; `export_price_eur_kwh()`'s body is byte-identical, pinned on the AST rather than on the source text; `export_price_at()` falls through to it for every index value. 17f compares seven option dicts x five index values against the pre-TARIFF-16 expression with `==`. `price_export_formula` joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived from that set, and `code_leaks()` refuses a plugin that names it with nothing else edited. ALSO CORRECTED HERE: config.yaml's comment beside `export_price_eur_kwh` still read "no shipped supplier publishes a dynamic injection curve". TARIFF-07 struck that claim from app/price.py and DOCS.md and did not reach config.yaml, where it has been shipping since. 20 mutations, every one red on a named check, ledger in test_price.py below section 17 - including a ZERO-byte one (the two legs transposed in the VAT-asymmetry banner: no decision moves, only the sentence) and a ONE-byte one (`card_required=True` on the injection leg). Three run over all 29 files and red on test_price.py alone. 29/29 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
9c65ac2cf8 |
TARIFF-18: price_taxes_eur_kwh stops shipping at half the real figure
checks / unit (pull_request) Successful in 7m48s
`price_taxes_eur_kwh` shipped at 0.06 EUR/kWh with no recorded provenance -
no card, no date, no component list - and roughly HALF the true Flemish
all-in non-energy figure. It now ships at 0.11736, and the rows it is made
of are in the code rather than in this message:
Fluvius distribution (Antwerpen) 5,35 c EUR/kWh
Bijzondere accijns, "Alle verbruik" 4,876
Bijdrage groene stroom en WKK Vlaanderen 1,51
Energiebijdrage (0 EUR/MWh since 2026-08-01) 0
------
11,736 c EUR/kWh = 0.11736
Eneco residential card for Flanders, September 2026, incl. 6 % VAT.
`app/price.py`'s FLEMISH_TAXES_COMPONENTS_EUR_KWH carries the rows and
test_price.py section 1b re-adds them - and also compares the total against
the card's own figures typed in the test file, so an edit that moved the
rows and the default together is still caught.
WHAT THIS IS NOT. It is not an arbitrage-correctness fix and it cannot be.
This scalar is a LEVEL component: an additive constant cancels exactly in
`max - min` and leaves the argmin where it was. TARIFF-17 shipped that proof
and it is unchanged - test_price.py 16b drives six flat scalars spanning and
exceeding the whole per-DSO range and gets the identical cheapest hour and
the identical spread to the last digit. DOCS and the CHANGELOG say so in
those words.
WHAT IT IS. A display and guard fix:
* every number an operator reads, which at half the true value made the
add-on look considerably better than it is; and
* §17's negative-price guard in arbitrage.py - the one rung that tests an
ABSOLUTE all-in price rather than a difference. It fires when
`spot < -(markup + taxes)`, so its boundary moves -0.09 -> -0.14736
EUR/kWh of spot. test_arbitrage.py section 2c drives both sides through
the shipped decide() and pins the boundary from below as well.
The default is Antwerpen's real row, near the LOW end of the 0.117-0.131
per-DSO band, and that is deliberate: the error directions are not
symmetric. Too high carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal - §17's own case. Too low only
declines a discharge that would have paid.
TARIFF-17's INTERACTION, WHICH HAD TO BE DECIDED. Nothing in that rung
hardcoded 0.06; it read PRICE_OPTION_DEFAULTS, so moving the default moves
what it refuses. The Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`, so a saved blob carries the key whether it was
typed or not - every stored 0.06 became "non-default" with nobody editing
anything, and a working Walloon or Brussels site with a taxes table would
have met SystemExit(78) on the upgrade over an inert number. price.py now
carries SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) and validate.py reads a
superseded default as untouched too. The rung is NOT weakened: 0.1198 beside
a table is still refused by name, and the refusal now names both figures.
config.yaml and PRICE_OPTION_DEFAULTS moved together; CFG-03's tripwire
(test_price.py section 1, test_defaults_pin.py) stayed green by agreeing.
test_price.py section 0's fourth check was REWRITTEN rather than re-pointed:
it used to justify the file's own grading constants by observing that the
shipped pair was degenerate (0.06 is exactly twice 0.03). TARIFF-18 ends
that coincidence, so the sentence is no longer true and is not kept.
29/29 test files green. Mutation ledger on the PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
22c016fadd |
CFG-04 fix round: the probe now measures the key it is cited for
checks / unit (pull_request) Successful in 4m33s
Review blocked this on ONE finding, and it was in the EVIDENCE, not the code.
The design and the shipped behaviour are unchanged.
THE INSTRUMENT DID NOT MEASURE WHAT FOUR DOCUMENTS SAID IT MEASURED
tools/supervisor_shape_probe.py section B validated the RECONSTRUCTED LIVE
options blob - which is missing many required keys that have nothing to do with
this card. Its refusal therefore named whichever of THOSE the validator reached
first: the committed transcript recorded `meter_freeze_s`, the reviewer's run
printed `p1_phase_export_entities`, and a run today printed `sign_disagree_w`.
The string those documents quoted - `Missing option 'inverters' in root` - was
never produced by the instrument.
And section B asserted a bare `not ok`. Any refusal at all satisfied it, so
deleting `inverters: []` from config.yaml left it printing PASS: the probe could
not detect the one regression it is the committed evidence for.
THE UNDERLYING CLAIM IS TRUE AND IS NOW MEASURED PROPERLY
Section B is an ISOLATED COUNTERFACTUAL now: the full shipped `options:` block
(expect accept), and the SAME block with `inverters` removed and nothing else
varying (expect refusal). One key differs, so the refusal has nowhere else to
come from - and the assertion is on the KEY NAME IN THE REFUSAL STRING, so the
line goes red if the refusal is ever about a different key. The probe also exits
non-zero when any case fails; without that a transcript is only evidence to
someone who reads it.
The old live-blob case survives as section B2, relabelled for what it is: an
incomplete reconstruction whose RAW half is refused for unrelated missing keys,
kept because its MERGED half genuinely demonstrates what App.options is.
VERIFIED BY BREAKING IT, WHICH IS THE WHOLE POINT
`inverters: []` deleted from config.yaml, probe re-run against
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0:
== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
FAIL the shipped `options:` block validates AS IT STANDS - ...
Missing option 'inverters' in root in GoodWe RS485 Controller
...
== 2 CASE(S) FAILED ==
exit 1. Key restored, same image, same command: `== all cases passed ==`, exit
0. The transcript in DECISIONS is that restored run, re-run today, not the old
one.
THE FOUR MIS-CITATIONS
DECISIONS/CFG-04-inverter-roster.md section 4's "section B shows both halves"
sentence and its line-103 transcript; CHANGELOG.md; test_inverter.py's part-9
comment and its part-12 mutation row; and the PR description. All now attribute
the string to section B and say plainly what section B2 does not show. The
part-12 row additionally records that the probe was blind to that mutation until
review, because a mutation table that lists a defeat nothing catches is the
failure this repository keeps finding.
MUTATION.md gains it as the sixth instance of "the check's data cannot reach the
failure it names" - the first found OUTSIDE the suite, in a committed evidence
artifact, because nothing in that document had ever been pointed at tools/. Three
habits with it: assert on the CONTENT of a refusal rather than that one happened;
vary ONE thing, from an ACCEPTED baseline; and treat a committed transcript as a
check subject to the mutation battery.
TWO NON-BLOCKING FINDINGS, ALSO FIXED
validate.py rules 5 and 6 NAMED THE WRONG INVERTER. `unit_caps()` SKIPS an
entry with no usable `max_w`, so caps position i stops being roster position i
the moment one entry is malformed - and rule 5 fed `caps.index(weakest)` to
`_unit_label()`, which indexes the roster, while rule 6 paired `caps[0]` with a
hard-wired `_unit_label(opts, 0)`. Right number, another inverter's name: an
operator sent to the wrong device by the message that exists to stop that. New
`unit_caps_indexed()` carries the roster index with the cap. It is latent -
rule 9(d) refuses such a config in the same pass - but that was a property of
today's rule ordering, not of the code, and validate_startup_config() is also
sim/inject.py's entry point. Part 10 gains a check that asserts the fixture
really does desynchronise the two lists, then that both messages name the right
unit, then the counterfactual: the OLD expressions, on that same config, still
evaluate to the wrong names.
DOCS.md called a downgrade "safe". True of the SCHEMA - the older schema drops
the key with a warning - and incomplete about the ADD-ON: rule 9(e) requires the
flat `setpoint_entity` be cleared when a roster is declared, so a config that
downgrades cleanly is by construction one whose flat actuator is blank, and the
older version has nothing to write to. Confirmed by running release/1.0's own
validate_startup_config() on that blob: one refusal, naming setpoint_entity.
Recoverable and loud, but an outage until a field is refilled. The note says so
now, and tells the operator to write the entity down before downgrading.
19/19 green in the goodwe-addon-ci image.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
f000547518 |
SAFETY-30: grade each inverter against its own power, and the site's total against the meter
checks / unit (pull_request) Successful in 4m38s
THE DEFECT, in one line. `inv.sign_wd.evaluate(stood, self.grid, ...)` compared
the command THIS controller put on ONE actuator against what the WHOLE SITE is
exchanging with the network. At a roster of one those are two views of the same
thing - specs.md §6.2's identity - and that is what makes the check sound and
what earned it the best record in the safety table (NOTES.md: 93 % agreement
while healthy against 4 % during runaway, four observed episodes, zero false
positives). At two inverters they are not, and it fails in BOTH directions:
FALSE TRIP inverter A commanded -1500 W while the meter reads +1500 W is
evidence of nothing when inverter B is discharging 3000 W exactly
as told. Nothing is faulty and the highest-value check fires.
MISSED the converse, and it is worse. A unit commanded +1500 W and
MEASURED at -1500 W, beside a sibling at +1500 W, leaves P1
exactly where a healthy site leaves it. The summation destroys the
evidence and no threshold recovers it.
DEV-02 had already shipped the per-INSTANCE half - one SignWatchdog per
inverter, namespaced latch, a trip on B no longer latching A. What was left was
the COMPARISON, and it is two questions rather than one:
PER UNIT this unit's standing command against ITS OWN measured power
(`Inverter.batt`, holding register 0x0518, per-instance since
DEV-02). Conclusion: "the actuator I am driving is not doing what I
told it" - a fact about one device, so the remedy stays the per-unit
`failsafe` SET that arbiter.CLAIM_SCOPE has classified SCOPE_UNIT
from the day it was written. That entry read "the evidence is
site-wide and the remedy is not"; the first half is now simply true.
SITE the SUM of the standing commands against the meter, as a new
`sign_site` SET at P_SAFETY on the site actuator, latched under flat
`site_sign_tripped` / `site_sign_reason`. This is the 2026-08-20
fault day and NO per-unit check can see it, because every inverter
may be faithfully executing its share of a number that is itself
wrong. It stops every inverter and the banner names the site.
WHAT IT IS NOT, said here because two guards that overlap without saying so is
how a false-alarm generator gets built. It is not SAFETY-31: circulation.py
compares the units AGAINST EACH OTHER and catches them pushing power THROUGH
each other, which nets to zero at the meter and about which no single command is
evidence. This compares each unit against ITS OWN COMMAND. They overlap in
exactly one scenario - a unit reversing while its sibling pulls the other way -
and test_watchdog.py asserts on that fixture that both fire and what each
concludes. It is also not the saturation freeze, which is a MAGNITUDE test
(|command - measured| > 500 W) that shapes the loop and fires on a merely
saturated unit; this is a DIRECTION test that latches and parks one.
THE FOUR NUMBERS, RE-DERIVED AT TWO INVERTERS - one carried with a justification
and one refused. 1890 closed-loop runs on admin/goodwe's TEST-19 two-master rig
in which NOTHING is faulty, swept over the whole range config.yaml accepts for
`gain` and `slew_w`, across load reversals with write staggers and flapping
loads, and RUN AT BOTH ROSTERS so the N=1 arm is the control:
unit_sign_disagree_w 250 `saturation_w / 2`. The check gates on both sides,
opposed, so the smallest gap it can see is 500 W -
exactly where compute()'s own anti-windup test
already says the inverter is not following. It
cannot fire on a divergence the control law calls
ordinary tracking. Worst legitimate opposition at
250 W: 10.0 s; the curve is nearly flat, because a
unit's own power chases its own command.
unit_sign_disagree_s 85 8.5x that 10.0 s, 2.8x the firmware's own 30 s
link veto (inverter.LINK_STALE_S), under half the
180 s site hold.
site_sign_disagree_w 300 CARRIED. Its argument is the meter's noise floor
and the 15 W deadband; the roster touches neither,
and the campaign agrees - the gate opens in 994 of
1050 runs at two inverters and 955 of 1050 at one.
site_sign_disagree_s 325 NOT CARRIED, and this is the number the card
exists to not assume. The worst legitimate
crossing at 300 W is 110 s with one inverter and
215 s with TWO, because the command must travel a
rail that is the roster sum at a `slew_w` that is
not - reachable at gain 0.05, slew_w 50 and a
2.2 kW load reversal, all schema-legal. 180 s
would have shipped a false trip. 325 s is 1.51x
the measured worst, inside the 900 s billing
quarter and inside every episode of the fault day.
AT ONE INVERTER NOTHING CHANGES, and "nothing" is asserted rather than assumed.
The two questions are the same sentence there, so only the detector that shipped
is armed: same comparison against the meter, same 300 W / 180 s, same reason
string byte for byte, same namespaced latch and flat rollback mirror, same
`failsafe` claim, same banner, same single status row - and the site detector is
NEVER CALLED, counted on the object over twelve cycles.
VERIFIED BY BREAKING IT. Thirteen mutations on fresh per-mutation trees with no
__pycache__, mounted :ro, with the battery refusing to report at all unless the
baseline is green. Twelve red on the first pass. M4 - the site half armed at N=1
as well - SURVIVED, because the check read `disagree_since is None` and the unit
detector trips first, parks the inverter and leaves the site standing at 0 W:
the assertion ran, the sentence was true, and the data could not reach the
failure it named. Replaced with a call counter on the object; M4 is red now.
Recorded as a survivor in DECISIONS/SAFETY-30-per-unit-sign.md §5, with the
first battery's own RED BASELINE - a shared mutation directory read mid-copy -
discarded rather than reported.
RECORDED, NOT FIXED: the site comparison is defeated by a square-wave load whose
half period is about one control cycle, which holds it opposed for ~1000 s. The
N=1 control arm is what makes that interpretable - it is identical at the roster
this add-on ships, so it is a property of comparing a lagged command against a
meter and has nothing to do with the second inverter. It wants an alternation
test rather than a number, and a card of its own.
SAFETY-24's lo-only asymmetry proof re-run unchanged: 1296 combinations, 0
negative floors, `hi` +inf on every claim, every claim on the SITE actuator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
6b85acb0f1 |
CFG-04: an operator can declare a second inverter, and the shape was measured
checks / unit (pull_request) Successful in 4m30s
DEV-02 made every per-device value per-instance - rating, SoC, watchdogs, heartbeat, store keys - so the code stopped assuming one inverter. The options schema did not. `build_roster()` returned a list of exactly one and said so in its own docstring, and there was no `inverters:` key for an operator to write, so the roster could only ever be a roster of one. THE SHAPE, AND WHY IT WAS MEASURED RATHER THAN CHOSEN config.yaml had no list-of-objects anywhere, and `static_profile` carries a whole tariff structure as a JSON string in a `str?` precisely because a nested schema was doubted. That is a question about somebody else's software, so it was answered by running it: `tools/supervisor_shape_probe.py` (committed) runs Supervisor 2026.08.0's OWN `AppOptions` validator and `SCHEMA_APP_CONFIG`, out of the release image, against this repository's config.yaml. 2026.08.0 is the version the live install reports. The full transcript and the argument are in DECISIONS/CFG-04-inverter-roster.md. It accepts the list, enforces per-entry types, refuses a missing `name` / `setpoint_entity` / `max_w` BY NAME, renders the option in the UI as a repeatable section, and drops the key with a warning under an older schema - so a downgrade is not the forward trap in reverse. ⚠️ AND THE `inverters: []` DEFAULT IS LOAD-BEARING, WHICH IS THE MEASUREMENT THAT MATTERED MOST. Supervisor answers `Missing option 'inverters' in root` for an options blob that has no such key - which every existing install's has, since the key did not exist when they were written. `App.options` is the shipped `options:` block deep-merged with the operator's stored one, so the empty default is the difference between an upgrade nobody notices and an add-on that cannot start with a schema pinned to the version that would fix it. That is docs/deploy-rollback.md §4's trap in the FORWARD direction, found before it was sprung. The merger was checked too: lists override rather than concatenate, so a non-empty default could not have grown a roster behind the operator's back. NOTHING IS INHERITED, WHICH IS DEV-02 RESTATED AS A CONFIG RULE `name`, `setpoint_entity` and `max_w` are required of every entry. An entry that omits `soc_entity` has no SoC sensor; it does not borrow the flat one. A three-line fallback would have rebuilt the sibling-grading defect DEV-02 spent a card removing, in YAML. Defaults still live in exactly one place (CFG-03): there is no per-entry default anywhere, because there is no optional per-entry number. A flat `setpoint_entity` left set beside a declared roster is refused rather than ignored - it names an actuator nothing will write. TWO PERMISSIVE READERS IN SHIPPED CODE, AND THREE MORE IN THE CHECKS validate.py rules 5 and 6 compared their thresholds against the FLAT `max_w`. At a roster of two 2000 W units with `max_w: 5000` left over from a single-inverter install, `sign_disagree_w: 3000` passed rule 5 while SAFETY-02's watchdog could fire on NEITHER unit - rule 5's own failure mode, reached through rule 5. They read `unit_caps()` now, which at one inverter is `[max_w]` exactly. main.py's `run_control()` re-ran the device-rating check behind `if self.o.get("setpoint_entity")` - the flat key, which a roster leaves blank - so on a two-inverter site no inverter's `max_w` was graded against the device it is written to, and the skip was silent. `validate_unit_ranges()` grades each unit against its own ESPHome and runs unconditionally. test_peakguard.py's `read_option()` and `_schema_bound()` and test_inverter.py's `read_schema_max()` matched a STRIPPED line and took the first hit, so the nested per-inverter `max_w:` began outranking the flat one - silently, and correctly, because the two bounds are the same int(100,5000) today. A reader that is right by coincidence is this project's named failure mode. All three are anchored at exactly two spaces now; the file's two halves disambiguate `peak_cap_w` instead of first-match, and an ambiguous key raises rather than picking one. test_tariff.py's copy of the idiom went with them. THE AUDIT IS A CHECK, NOT A PROMISE test_inverter.py part 11 finds every config.yaml-reading function in the suite - transitively, because two of the readers CFG-04 had to change never name the file - EXECUTES each against a synthetic two-inverter config.yaml whose per-inverter numbers differ from its flat ones, and fails if any answers with the per-inverter value. The table is not maintained by hand: the signature decides, so the eleventh reader is named on its first run. config.yaml declares `inverters:` BEFORE `max_w:` on purpose and part 11 asserts that ordering, so the trap stays baited. SAFETY-28's `actuator_writers()` needed no change - it was already a structural, case-folding census - but until now there was no configuration in which two writers on one actuator could be WRITTEN DOWN. There is now, and it is refused. VERIFIED BY BREAKING IT Ten mutations run through tools/mutation_run.sh and observed red, listed in test_inverter.py part 12 with the check that bites each. Two survived the first run and the CHECKS were changed, not the table: the inheritance check had blank flat keys, so "inherited" and "not inherited" were the same empty string; and the `run_control()` check asserted a SOURCE LINE was present, which re-indenting it does not remove. Both are recorded in MUTATION.md's "the check's data cannot reach the failure it names" section, which now carries two more habits: assert on behaviour rather than on source text, and give a check whose failure mode is a hang a deadline. 19/19 green in the goodwe-addon-ci image. The one-inverter path is byte- identical: the whole existing suite runs unmodified against it. Version bumped to 0.6.0 - the Supervisor keys the built image by `version:`, so a schema change without a bump is a schema nobody reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
591c35ed01 |
SAFETY-28: gate the write path on the allocation record, not on key count
checks / unit (pull_request) Successful in 4m19s
specs §1.5 makes "any configuration in which two writers can reach one
actuator" a non-goal. That non-goal was enforced by counting config keys:
validate.py's ACTUATOR_WRITE_KEYS = ("setpoint_entity",), consumed at rule 1,
with a comment stating the guarantee outright - "there is exactly one such
key, so a second writer is not expressible in the options schema at all".
That was true and it graded THE SHAPE OF THE OPTIONS SCHEMA, not the writes.
Add CFG-04's per-inverter config - a roster of inverters each with its own
setpoint_entity - and there are two real writers with len() of that tuple
still equal to 1. The check stays green while the property it protects is
gone. sim/inject.py's two_writers scenario asserted on the same tuple, so it
went green too. That is this repository's named failure mode: a check that
observes something other than what it claims to observe.
Demonstrated rather than argued. test_validate.py part 2 now constructs the
hazardous per-inverter config, asserts that the OLD proxy is still green on
it, and then asserts the new check refuses it. Against release/1.0's
validator the updated sim scenario reports: "validate_startup_config()
ACCEPTED a configuration in which 3 declarations reach the ONE actuator
number.goodwe_setpoint ... the old proxy reports 1 write key(s) on this same
config and is therefore GREEN on it".
THE WRITE PATH IS NOW GATED ON THE RECORD
=========================================
Controller.write_refusal() asks Allocation.authorises() whether the single
allocator placed exactly this number, for this unit, on this tick.
write_setpoint() refuses anything else. A second writer with arithmetic of
its own cannot satisfy that - its number appears in no record - so it has to
file a Claim and go through resolve(), which is the one allocator. "Exactly
one allocator per shared resource" stops being a convention somebody can
forget and becomes something the write path checks.
The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in place of
the number nothing stands behind. Returning would leave the inverter latched
on its last command with the ESP32 heartbeat still being fed - holding an
unauthorised setpoint quietly, which is the outcome the gate exists to stop.
AC 1 is "refused at the write path, not merely logged", and test_clamp.py
part 7a grades the device's writes, not the log.
The gate sits BELOW the non-finite coercion and ABOVE the clamp. Below,
because a nan is loss of input and rule 1 has already turned it into 0 W.
Above, because the question is who chose this number, and the answer is only
meaningful about the number that was asked for - gating the CLAMPED value
would refuse exactly the cycles SAFETY-03 exists for, since a clamped write
differs from the allocator's share by construction. Mutation M7 moves the
gate below the clamp and turns nine checks red, part 6c among them.
A STALE RECORD CANNOT BE REPLAYED
=================================
Every Allocation now carries the `tick` it was made for. It is handed IN by
the caller - plan(..., tick=) - rather than counted inside the allocator,
because test_alloc.py part 10 grades that the module holds no state at all,
and a serial number incremented in there would be the cached-across-ticks
thing the envelope rule forbids wearing a different hat.
Controller.alloc_tick advances ONCE PER RESOLUTION, not once per loop
iteration, and both halves are load-bearing:
* a new allocation makes the previous record stale immediately, so a record
whose envelopes have moved cannot authorise a write. That is the replay
half (AC 2), graded by capturing a real record, running another real
cycle, putting the old one back and observing refusal.
* the "inputs missing, not yet stale" hold returns BEFORE the allocator
runs, exactly so one dropped meter reading does not zero the inverter,
and the heartbeat re-affirms self.target through this same gate while it
does. A tick incremented per loop iteration would refuse that heartbeat
and turn the hold into the outage it exists to prevent. Graded.
An UNSTAMPED record (tick None) authorises nothing: "I cannot say when I was
made" fails closed rather than reading as tick zero.
THE FAILSAFE CANNOT DEADLOCK BEHIND THE GATE, AND HERE IS WHY
=============================================================
write_setpoint() is also how layer 2 commands 0 W. cycle()'s two refusal
branches substitute a 0 W failsafe and write it like any other cycle;
shutdown() and the operator's stop button do the same. EVERY ONE OF THOSE
RUNS WHILE THE ALLOCATOR HAS REFUSED - ok is False and there are no shares -
so a gate that asked the allocator's permission to command zero would block
the safety write behind the very component whose failure the safety write
answers. A gate that can do that is worse than the hole it closes.
Resolved by authorising 0 W unconditionally, and on the VALUE rather than on
a failsafe=True flag: a flag is an assertion by the caller, which is exactly
what a second writer would pass; the value is a fact about what will be
written. The argument that this is safe and not merely convenient is that the
hazard §1.5 names is N-times loop gain from BROADCAST - handing every inverter
the same target so each supplies it in full (allocator.py's opening argument;
TEST-19 measured a permanent 4000 W p2p limit cycle at gain 1.0 with no guard
firing). At S = 0 the broadcast and the correct allocation COINCIDE: a_i = 1
for every unit gives every unit 0 W, which is exactly its share. There is no
over-command to make at zero. Said at the line, in write_refusal().
test_clamp.py part 7c drives it end to end through the real cycle() and the
real REFUSE_EMPTY branch: the allocator refuses every tick, the device still
sees 0 W, and the action log says ARBITER: REFUSED rather than WRITE REFUSED
so the two failures stay distinguishable to whoever has to debug them.
unmet_w IS STILL NOT ok
=======================
The gate reads ok and the unit's share and never unmet_w. allocator.py:179
says why: an allocation that could not place every watt is still safe to
write, and fusing them would let one saturated inverter park the whole site.
Graded on a record with 3800 W unmet and every share legal - mutation M5 adds
`or self.unmet_w` to the gate and that check goes red.
THE CONFIG CHECK, REPLACED RATHER THAN DELETED
==============================================
actuator_writers() counts WRITERS PER ACTUATOR instead of option keys. It
walks the options TREE rather than looking for a container called `inverters`,
because CFG-04 has not chosen that name yet and a census that guessed wrong
would go green while two writers existed - the same defect one step along.
Entity ids are folded to lower case so a second writer cannot be smuggled in
as Number.X versus number.x.
Two inverters with two entities are ACCEPTED - that is what the per-inverter
roster is for, and a validator that refuses the feature it guards is one
somebody switches off. Two declarations aimed at ONE entity are refused,
naming the actuator and every writer pointed at it. That includes the case an
author will meet first - keeping setpoint_entity as A's and listing A again -
because "they happen to be the same entity, so it is fine" is precisely the
reasoning that makes two writers invisible.
ACTUATOR_WRITE_KEYS survives ONLY as the enumeration a census needs, with its
guarantee comment deleted; nothing asserts on its length any more. Keeping the
name is also what stops admin/goodwe's current two_writers scenario failing on
an AttributeError between the two merges.
WHAT NEITHER CHECK OBSERVES, SAID OUT LOUD
==========================================
The census sees CONFIGURED writers. A second write path added in CODE - a
hass.set_number() call next to some new strategy - declares nothing and is
invisible to it, exactly as it was invisible to the tuple. That half is not
closed at startup and cannot be; it is closed at runtime by the record gate,
which does not care what the options look like or who called the method. The
two checks are deliberately different kinds, and only the runtime one survives
a schema this file has not seen yet.
EVIDENCE
========
* Full 18-file suite green on a purged tree (tools/mutation_run.sh).
* Verified by breaking it - seven mutations, each on a purged tree, each
reverted and re-verified:
M1 remove the gate -> test_clamp red (4 checks)
M2 make the zero exit an amnesty -> test_clamp red (12 checks)
M3 drop the tick comparison -> test_clamp red (5 checks)
M4 delete the census refusal -> test_validate red (3 checks)
M5 fuse unmet_w into ok -> test_clamp red (1 check)
M6 freeze the tick on the live path -> test_clamp red (2 checks)
M7 move the gate below the clamp -> test_clamp red (9 checks)
* AC 5: no existing check was weakened. The two edits to test_clamp.py are
fixtures, not assertions - parts 5 and 6c call write_setpoint() directly
with a number nothing allocated, which is now refused, so they say where
their number came from. Every assertion in both parts is unchanged, and
part 7f asserts that across part 3's sixty real cycles the gate refused
zero writes.
* admin/goodwe's TEST-19 norig scenarios (planner_binding, two_master_plant,
two_master_broadcast) still pass against this tree - they import the
add-on's control law across the repo boundary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d5b1ef19b3 |
CFG-03: give maintenance option defaults one source of truth
checks / unit (pull_request) Successful in 3m44s
Option defaults for the maintenance cycle were declared in three places that
could disagree, and MAINT-04 proved they already did. config.yaml shipped
`maintenance_charge_w: 2000`; main.py carried TWO `opts.get(..., 2500)`
fallbacks; MaintConfig's dataclass carried a third `2500.0`. So an options dict
with the key ABSENT was ACCEPTED by validate.py's rule 6 - which read 2000 -
and then built by main.py at 2500, against a 2000 W ceiling:
options dict with maintenance_charge_w ABSENT, max_w=2000
validate_startup_config -> [] (accepted, no problems)
MaintConfig().charge_w -> 2500.0
arbiter.resolve() -> 0.0 W, contradiction=True
That is the bug rule 6 exists to refuse, passing rule 6. It is unreachable on a
Supervisor install, which always supplies every key from config.yaml, and fully
reachable from sim/inject.py and from the test suite - which is where anyone
debugging maintenance actually works.
MAINT-04 shipped a TRIPWIRE and said so: test_validate.py pinned all three
literals and asserted the consequence through the real arbiter, so the
divergence could not silently widen or be half-closed. Its PASSING CONDITION
WAS THE BUG. Both it and validate.py's comment said to delete each other when
the divergence was closed. It is closed, so both are deleted.
THE DIVERGENCE WAS WIDER THAN ONE KEY. `maintenance_discharge_w` had the
identical three-way split (config.yaml 2000, MaintConfig 2500) and the card
named only the charge side. Closing one and leaving its twin would have
satisfied the numbers and left three sources of truth one line down, which is
the state that produced the bug. Both are closed, and so is the whole family:
all eight `maintenance_*` keys go through the same mechanism.
WHAT THE SINGLE SOURCE IS. config.yaml's `options:` block - it is what the
Supervisor reads and what the operator edits, so it wins by definition. There
is now exactly one in-code copy, `maintenance.MAINT_OPTION_DEFAULTS`, which is
TARIFF-02's `PRICE_OPTION_DEFAULTS` and SAFETY-24's `PEAK_OPTION_DEFAULTS`
pattern applied to the family that named the card - validate.py's rule 8 comment
already called that "CFG-03 read as a rule rather than as a complaint", so this
makes the rule hold where it was first broken rather than inventing a fourth
mechanism for one option family.
A MIRROR RATHER THAN A RUNTIME READ, and the reason is mechanical: the
Dockerfile copies `app/` and `run.sh` into the image and NOT config.yaml. The
running add-on cannot open the file it is configured by, so there is nothing to
read at 03:00 on a client's Pi, and a YAML-subset parser that could fail at
import is a new way for the add-on not to start. What makes the copy safe is
that test_maintenance.py pins every entry by READING the shipped file - drift is
a CI failure, not a comment.
EVERY OTHER SITE NOW DERIVES FROM IT OR HAS CEASED TO EXIST:
* MaintConfig's eight dataclass defaults are indexed out of the mirror. No
numeric literal survives in the class that held the third opinion.
* `MaintConfig.from_options()` is the ONLY place in the add-on that reads a
`maintenance_*` option. main.py's eight inline `opts.get(key, <literal>)`
calls are gone: moving the read next to the mirror means the fallback cannot
be anything but the mirror, because there is no literal in sight of it to
disagree with. It uses `key in opts` rather than truthiness, so a shipped
`false` or `0` is honoured instead of being quietly replaced by the default
- this card's own bug in a different costume.
* main.py's SECOND fallback site - the cheap-window charge floor at the
P_MAINT_SHAPE claim - reads `self.maint.cfg.charge_w` instead of re-opening
the options dict with its own literal. The Controller had already parsed
this once in __init__; asking the object that owns it cannot drift from it,
and the rate the shaping asks for can no longer be a different number from
the rate the state machine drains at on the same install.
* validate.py's rule 6 and its `maintenance_discharge_w` warning take their
fallbacks from the mirror.
WHERE THE RANGE LIVES, STATED AND CHECKED. config.yaml's `schema:` block carries
`maintenance_charge_w: int(500,5000)` - a fourth declaration site of a different
kind. It is a BOUND, not a default, so it is not this bug; but leaving the
question open is how a fifth site arrives. The decision recorded in config.yaml
and in maintenance.py is: bounds live in the schema block ONLY, because the
Supervisor is what enforces them and the Supervisor reads only that file, so a
Python copy of `int(500,5000)` would be a place to disagree and nothing else.
test_maintenance.py checks both halves of that: every mirrored key must have a
schema entry - without one the Supervisor drops the option and the add-on
silently runs on the mirror instead of the operator's value - and every shipped
default must lie INSIDE its own range, since a default the schema rejects is a
config that cannot be re-entered once it is edited. What the schema grammar
cannot express is a relation BETWEEN keys (`maintenance_charge_w` against
`max_w`), which is exactly why rule 6 is written in Python and why it does not
belong in the mirror either.
THE REPLACEMENT CHECKS, AND HOW THEY WERE VERIFIED. test_validate.py's new
section exercises the same four consumers the defect ran through, the same way:
the validator ACCEPTS a dict with every maintenance key missing; a REAL
Controller built from that same dict holds 2000 W (through main.py, not through
a regex over its source); `MaintConfig()` answers 2000 W with no dict at all;
and the real arbiter emits `-2000 W, limited by supervised(max_w)` instead of
`0 W, contradiction=True`. test_maintenance.py pins the mirror to config.yaml in
both directions - equal values, and full coverage of every `maintenance_*` key
the file ships, so a ninth key cannot arrive unmirrored - and asserts the
consumers move as a RELATION rather than against numbers, reading MaintConfig's
defaults back out of `dataclasses.fields()`.
Each new check was verified by BREAKING the thing it watches and observing red,
this project's rule:
1. config.yaml -> 1500, mirror left at 2000: test_maintenance 1 FAILED,
test_validate 4 FAILED.
2. config.yaml AND the mirror -> 1500: green again, and every consumer moved
together - MaintConfig() 1500 W, the Controller 1500 W, and the arbiter
`-1500 W, limited by maintenance(cheap-window charge)` where it had said
`-2000 W, limited by supervised(max_w)`. That is AC4: change the single
source and watch the consumers follow.
3. `charge_w: float = 2500.0` retyped into the dataclass: 2 FAILED in each of
the two files.
4. `opts.get("maintenance_charge_w", 2500)` restored in main.py: 1 FAILED,
naming the file and the literal it found.
5. `maintenance_hold_min` deleted from config.yaml's options block: 3 FAILED.
Full suite 17/17 in goodwe-addon-ci, twice, once under parallel load.
Stale prose that asserted the now-closed divergence as current is corrected in
app/watchdog.py, test_watchdog.py and test_peakguard.py - watchdog.py's example
still uses 1500 W, but because the point of the example is a charge strictly
inside the ceiling, not because 2500 is what ships.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
83911ed637 |
SAFETY-20: measure the freeze rail against the reachable ceiling, not max_w
checks / unit (pull_request) Successful in 3m37s
SAFETY-18's conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the
emitted command is clipped to the setpoint device's own [dev_min, dev_max]
range as well as to max_w - in clamp_to_rating()'s second stage and again as
the arbiter's `device` P_SAFETY limit claim. Measured on the shipped predicate
at the ticket's numbers, max_w 5000 on a +-4700 W inverter:
looks_frozen(unchanged_s=3600, commanded_w=4700, max_w=5000) -> False
The command is pinned at the hardware's rail, the meter has been frozen for an
hour, and term 2 is unsatisfiable by construction because 4700 < 4750. Any
device rating at or below 0.95 * max_w disarms the conjunction outright, and
the status page then printed `armed (... command is over 4750 W)` - a guard
that cannot fire, advertising that it can, which is the failure SAFETY-15,
SAFETY-19 and validate.py rule 5 all exist to prevent.
The rail is now measured against min(max_w, |dev_min|, |dev_max|), through a
new `rail_ceiling_w()` threaded into looks_frozen(), FreezeWatchdog.evaluate()
and both call sites in main.py. An unread rating leaves the old behaviour
exactly, and correctly: hass.limits() returns None whenever ESPHome is not up,
and on that path no device clamp is applied either, so max_w really is the
ceiling. A rating larger than max_w cannot raise the rail - this is a min(),
not a swap.
THE DELIBERATE CHOICE, since SAFETY-03 has landed. The arithmetic was kept
rather than substituting Decision.i_clamped / Decision.clamped. i_clamped is
the integrator bound (control.py:208, `bounded != i_w`), not "the command is
at the device rating", and `clamped` is the max_w output bound, unreachable on
a default install - neither is the device rail. More decisively, both answer
"did the LOOP ask for more than a bound", while the rail term asks "is the
command standing at the ceiling", and those coincide only when the loop is the
claimer that won. A maintenance SET or a peak-guard floor reaches the ceiling
through arbiter LIMIT claims with no clamp engaging at all, so a boolean rail
would go False exactly during a maintenance phase running against a frozen
meter - which freeze.py's own docstring names as the same fault with a worse
actuator behind it. The predicate is also public with two call sites that pass
DIFFERENT command values (self.target, and `standing` = last_write); a
per-cycle boolean cannot be passed per call site, so substituting it would
have silently decided the pre-clamp-versus-wire question that PM recorded as
still open. AC 1 governs, and it is satisfied literally.
The smaller of the two device bounds, not a per-sign pair. A per-sign rail is
tighter and is not wrong, but it is two rails needing two coverage verdicts on
a status row that carries one number, and the only configuration that
distinguishes them is refused at startup anyway. The symmetric ceiling can only
lower the rail, which widens term 2 rather than disarming it; term 1 carries
the whole no-false-positive argument and is untouched.
A ceiling of 0 W is NOT COVERED, not "armed at 0 W". A device that can command
nothing in one direction would make `abs(command) >= 0` true of every command
including a parked one - term 2 degenerately TRUE, collapsing the conjunction
into the bare unchanged_s > N threshold TEL-05 refused for false-tripping at
this controller's own operating point. rail_unreachable() returns the reason,
looks_frozen() stays silent, and the page uses the NOT COVERED row the
unsupported-transport case already has - a second reason to use that surface,
not a new mechanism.
AC 4, decided rather than assumed: NO NEW validate.py RULE. The configuration
is max_w above the device rating, which rule 2 (CFG-01) already refuses on the
identical predicate, re-run by run_control() once the real rating is known:
validate_startup_config({... max_w: 5000}, -4700, 4700)
-> ['max_w is 5000 W but number.sp accepts at most 4700 W - THE DEVICE
WINS ...']
A rail-specific rule would fire on a strict subset of that (0.95 * max_w >
dev_max implies max_w > dev_max) and could never be the rule that stopped a
start. The residue is the state where hass.limits() answered (None, None) -
ESPHome not up at boot, range checks skipped rather than guessed at, rating
never re-read - and there is nothing to refuse there by construction, which is
why the answer is a runtime row. Recorded in validate.py's "deliberately not
refused" list and pinned by four checks in test_validate.py, so the decision's
premise goes red if rule 2 is ever softened back to a warning.
HOW REACHABLE THIS IS, stated rather than glossed. Because rule 2 refuses the
configuration whenever the rating is known, min(max_w, dev_max) IS max_w on
every install that reaches the control loop with a rating it could read, and
this change is arithmetically dead there. That is clamp.py's argument for its
own second stage word for word: the case where it bites is the case where the
startup gate did not run or no longer holds, and a guard that quietly assumes
the gate ran is a guard that reports armed in exactly the state it cannot fire
in.
Verified by breaking it, per this project's rule. Reverting rail_w() to bare
max_w fails nine checks; deleting the NOT COVERED handling fails five. The one
that mattered: shipping the corrected predicate while NOT passing the rating
from check_meter_frozen() left the entire suite green, because nothing graded
the caller - so test_freeze.py part 5b now drives the real cycle() on a +-4700 W
device and asserts on the notification that leaves the process, and the
unwiring mutation fails three checks there. The peak-guard call site's wiring
is still ungraded (behind p1_enabled, needs a fed averager for `frozen` to have
any observable consequence) and that gap is named in the file rather than
implied.
Full suite: 15/15 in goodwe-addon-ci.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
9234016419 |
Merge release/1.0 (OPT-04) into SAFETY-24, and fix one grader the merge broke
checks / unit (pull_request) Successful in 3m36s
OPT-04 landed after the previous rebase. Conflicts were CHANGELOG.md and one IMPORT LINE in validate.py; both refusal blocks and both option sets survive untouched, and DOCS.md, main.py and config.yaml auto-merged. ⚠️ AND THE MERGE BROKE A CHECK THAT NEITHER CARD COULD HAVE SEEN ALONE. test_peakguard.py graded "the first `.decide(` call in main.py" and asserted there was exactly one. That was true when SAFETY-24 was written; OPT-04 added `arbitrage.decide(...)`, so the merged tree had two and the assertion failed. It failed loudly rather than grading the wrong call - the guard did its job - but it cannot ship red. Fixed by selecting the call by QUALIFIED NAME (`peakguard.decide`) instead of by position, and requiring exactly one of THAT. Other modules may now call their own decide(); this grader can no longer be moved by one of them. Verified still discriminating: a decoy `peakguard.decide` added to main.py turns it red on 7 assertions, and the `maintenance_soc_floor` swap still turns it red on 6. Also folded in the review's wording finding. An assertion read "a CONTROLLER whose SoC entity does not answer hands decide() None" while driving no Controller. The wiring property is stronger than the one claimed, and is now asserted on the SERVICE CALL: a Controller with no SoC never reaches the guard at all, because `inputs_ok` is False and cycle() stops at the safety claim first - so a `float(soc or 100)` defaulting line above the call site cannot disarm the floor. The original decide()-level check is kept, reworded to say what it actually grades. Full suite green on the merged tree: 15 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
bdd7f18a54 |
OPT-04: charge cheap, discharge expensive - and only when it pays
checks / unit (pull_request) Successful in 3m16s
Four cards built the price stack and nothing traded on it. app/arbitrage.py is §10.1's priority 3, and it consumes all four rather than re-deciding anything: whether a trade is permitted at all, on which two hours, against which widened threshold is TariffPlan.arbitrage()'s answer, and that answer cannot be obtained without first saying whether the peak guard is holding. §9.1's "peak shaving outranks arbitrage" is therefore honoured structurally - there is no path to a spread that skips the question - and again in the arbiter, where P_PEAK (50) clamps a claim filed at P_ARBITRAGE (30). A guard that never ran is not a guard that is clear. On meter_source off the peak guard is never evaluated; that third state is carried as None and refuses, because charging at the rail creates the billed quantity while nothing measures the quarter. validate.py refuses that configuration too, along with arbitrage armed with no price source and a SoC floor at or above the ceiling. §17: never export into a negative price. The guard is on the hour being sold into, not on the spread - a day priced -0.30 .. -0.05 clears any threshold and its dearest hour is still negative. Buying in a negative hour stays allowed. resolve_tariff() may open an HTTPS connection and the tick feeds the ESP32 heartbeat, so the fetch runs in its own task at a 30 min cadence and the tick reads an attribute and an age bound. test_arbitrage.py section 8 asserts that structurally on the shipped AST: a fetch put back on cycle() is red. AC4 is NOT met and is not restated - this repository holds no price history. Section 7 replays a synthetic year and asserts the property that does not need real prices: every executed round trip cleared its own widened requirement, and zero discharges landed in the 522 negative-priced hours. At the shipped 0.10 EUR/kWh threshold a realistic Belgian day/night site does not trade at all. Reported, not tuned: that number and FCST-03's widening factors are one product decision and it is the owner's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
e709554857 |
SAFETY-24: an energy floor under the peak guard's forced discharge
checks / unit (pull_request) Successful in 3m29s
SAFETY-07 can command a discharge to hold a billed quarter down, and nothing below it consulted the state of charge. On the 2026-08-20 replay it asks for 878 Wh in a day. Relying on the BMS is not absurd - it is a real floor and it works - but it is the pack's protection rather than the operator's, and a month of aggressive quarters leaves the battery repeatedly at that cut-out with no EMS-side record of why. The first symptom is a pack never available when wanted, which is indistinguishable from the SoC drift MAINT-01 exists to correct. decide() now takes soc and soc_floor, both required and neither defaulted, and discharge_permitted() is the one place the question is answered. Above the floor nothing changes. At or below it the guard files a 0 W floor instead of a sized one - one-directional, so charging stays blocked and no discharge is capped - and reports floor_limited with the reason it bound. It is not a trip: every stop in 11.2's table still outranks the claim, and the floor is never negative, so 9.1's asymmetry survives as a property of the object (0 negative floors over 1296 swept combinations, SoC included). The SoC is graded only after `wanted > 0` is established, so it is never evaluated where it governs nothing. An unreadable SoC is floor-limited, not a full battery - a full battery is exactly the value that would disarm the floor, and this project shipped that defaulting once already. Failing to shave is not silent: one notification per episode (goodwe_peak_soc_floor), distinct from the saturation alarm because the two ask for opposite operator actions, plus its own status row and an action-log line. Verified by replay. The 2026-08-20 capture is driven against a modelled pack sized from the day's own measured appetite, so it genuinely exhausts: without the floor it reaches the BMS cut-out and holds there for 1227 guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The energy withheld equals the energy between the two floors to within one cycle at the rail. The honest half is reported too - the worst quarter goes 4338 W -> 5030 W, because a floor-limited guard shaves less on purpose. Two new options, mirrored once in peakguard.PEAK_OPTION_DEFAULTS (TARIFF-02's pattern), imported by validate.py and main.py rather than typed as literals and pinned to config.yaml by a check that reads the file - so CFG-03 still tracks three places. validate.py rule 8 refuses peak_soc_floor <= bms_soc_floor at startup, in CFG-01/MAINT-04's manner. maintenance_soc_floor is deliberately not reused and not renamed: it is a drain target, not a spend limit, and the rename is recorded as a decision to revisit if a third floor appears. Eleven mutations of the shipped source were run against the checks; all eleven go red and there are no survivors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
303525cfc5 |
TARIFF-03: static day/night and fully-fixed tariffs, first-class
checks / unit (pull_request) Successful in 2m43s
§9.2 puts a large share of the Belgian installed base on a static profile, and
an EMS that only works under dynamic pricing is an EMS for a minority. A new
`static_profile` option describes a two-period day/night tariff - or a single
fixed price - and app/static_tariff.py turns it into the same canonical
{start, eur_per_kwh, provisional} series the fetched providers emit.
Not a sixth provider plugin, for two mechanical reasons. capabilities() is a
classmethod, so a plugin's PriceProduct is fixed when the class is written -
but §9.2 makes STATIC and FIXED two different products and which one a site is
on is a property of the operator's data; StaticProfile.product derives it from
the number of distinct prices. And the plugin framework exists to stop a
FETCHED figure being marked up twice, which a number typed as all-in cannot be.
So the profile sits where §11.4 already says it does - under the providers, as
the fallback. Configured product and feed-is-down are one generator differing
in one flag, so the degradation path cannot rot unused.
"No arbitrage" is a type. TariffPlan.arbitrage() returns a variant whose
ALLOWED arm carries the spread and the cheapest/dearest hours and whose REFUSED
arm carries a reason and nothing else - .spread_eur_kwh on a refusal raises
rather than answering 0.0, so an optimiser cannot compute a trade from one.
Four refusals, all derived: peak guard holding, no price data, plan
provisional, or the series is FLAT. The last is AC2: a fixed tariff disables
arbitrage by arithmetic, not by matching on an enum. peak_guard_active is a
required keyword argument, so §10.1's priority 2 over 3 cannot be skipped.
Time-window shifting reaches the one shiftable load that exists: main.py's
cheap-window flag falls back to the static profile, and the UI gained a Tariff
row. Cheap is STRICTLY below the seven-day mean - `<=` would call every hour of
a flat tariff cheap, and 48 h taken on a Friday is two-thirds weekend.
A profile must tile every day type exactly once, checked at startup; a gap is
refused with the range named rather than filled in. `22:00-07:00 mon-fri` does
not cover Saturday morning, and that trap is what the check catches.
validate.py rule 7's stated reason expired exactly as TARIFF-02 predicted: the
test_price.py section 12 tripwire went red naming static_tariff.py. The message
now says what is true instead, and the tripwire is re-aimed rather than removed
- consumer set pinned, expired phrase asserted gone. Rule 7 also grew a second
refusal for static_profile alongside the HA price pair, the same silent-loser
conflict and a live one.
CFG-03 is not widened: static_profile went into price.py's PRICE_OPTION_DEFAULTS
- the one in-code mirror pinned to config.yaml by a check - and the new module
carries no defaults dict of its own.
Measured on the two committed days of this house's real P1 meter. 2026-08-20
(Thursday): 15.220 kWh imported, 9.606 day / 5.614 night, EUR 4.7185 under
day/night vs EUR 4.6725 flat, shifting saves EUR 0.8069 = 9.606 kWh x the
0.0840 spread, derived twice. 2026-08-23 (Sunday): 0.728 kWh, all of it
night-priced, which is the weekend row proving itself. §17 names six Belgian
scenarios; this is the seventh, and it is two days rather than a season - the
file says so rather than implying an annual figure.
All 12 suites green. Eleven mutations of static_tariff.py were each caught by
at least one check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
|
||
|
|
e102cde841 |
TARIFF-02 review: close the three other doors to the markup, and stop overclaiming
checks / unit (pull_request) Successful in 2m34s
Review of PR #16 wrote a double-counting plugin four ways. PluginOptions blocked one. The one that decided it touched nothing but PRICE_OPTION_DEFAULTS - a public dict in the module every plugin must already import for register() - so "the number is not in scope" was simply false, and an absolute guarantee a reviewer trusts is worse than a stated limit, because they stop checking. register() now refuses, AT IMPORT, any plugin whose own code mentions a framework-owned name: FRAMEWORK_KEYS plus PRICE_OPTION_DEFAULTS, price_option and _o. code_leaks() parses the class and drops every docstring node before searching, because every plugin is REQUIRED to name its sourced components in prose - engie_dynamic says "markup" in its docstring and a raw text scan false-positives on the documentation the same file mandates. A check that fires on required documentation is a check somebody deletes. test_price.py asserts that a raw scan really would have false-positived, so the strip is proven load-bearing rather than asserted. It fails OPEN if inspect.getsource() cannot find the .py, which the add-on image always can. Refusing to start a battery controller because a static analysis could not run would trade a real outage for a hypothetical accounting error - so instead the checks assert source was found for all five shipped plugins, and a build that lost it is a red check rather than a quietly disarmed guard. All three reachable attacks are written out as plugins in the suite and each must be refused at registration. The cost is measured, not asserted: honest wholesale 0.188, every attack 0.219 - one markup high, every hour, invisible in every log this add-on writes. The guarantee is now stated at its true size everywhere it appears: the framework adds the complement exactly once, GIVEN A CORRECT sources DECLARATION. A plugin that declares {ENERGY} while its feed is all-in still doubles while touching no framework name, and no code can catch that - the declaration is an unverifiable claim about somebody else's tariff. Measured in the suite (0.276) so the limit is a number rather than a caveat. The docstring/declaration check verifies that a plugin's two claims AGREE, not that either is true; that now says so. The three surviving mutations: M3 - _NoData's reason taking a default. This is the identical survivor left open on RIG-01's _Unread.cause: the shape was borrowed from sim/riglock.py including its blind spot, which is what borrowing a shape does if nobody re-runs the mutations against the copy. Closed by SIGNATURE - no constructor or handler of an outcome type may carry a default - so the next one is red without anyone writing a new case. M5 - the ENERGY-required guard was untested. Its consequence is that adds becomes {ENERGY, TAXES} and the emitted price silently omits the energy it is pricing. M7 - deleting the TLS refusal went green AND the check named for it still passed, because the old parser stripped a fixed len("https://") and mangled http://api.energy-charts.info/p into the host pi.energy-charts.info, which then failed the ALLOW-LIST. An assertion that does not test what it names, inside a security check. Fixed on both sides: the scheme is parsed as a scheme, so an http URL now resolves to its real and declared host and only the TLS rule can refuse it; and the check asserts the REASON. Fixing it surfaced M7b - the no-scheme guard was survivable the same way - found by mutating my own fix, and closed the same way. Also carried forward: rule 7's refusal is correct forever but its stated reason ("the provider decides nothing") expires the moment TARIFF-03 or OPT-04 lands. test_price.py section 12 now greps app/ by AST for a load_prices consumer and goes red when one appears, telling whoever lands it to update the message in the same commit. The first draft of that tripwire used a substring and matched the comment in validate.py explaining the tripwire - the same false-positive class as the plugin docstrings, hit twice in one ticket, and fixed the same way. Documented rather than coded around: vars(read).get("points", []) and bool(read) both still get past the absence type, and neither is worth code - defining __bool__ would turn a line that reads like a guard into one that IS a guard by accident, which is how .get(k, default) got here in the first place. Stated plainly in DOCS/CHANGELOG/PR: every fixture is synthetic, so the five feed URLs and JSON shapes are verified against a belief about what those suppliers publish rather than against the feeds. A wrong shape degrades to no-price-data, not to a wrong price. Review's own DST verification recorded: 149,040 hourly instants, 2020-2036, zero mismatches against zoneinfo. The residual is political, not arithmetic, and the docstring now says so. 24 mutations driven through the suite, 0 survivors. Whole add-on suite green. No deleted lines outside this ticket's own files; main.py still untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
852fc7ced6 |
TARIFF-02: one canonical price format, and a seam that keeps supplier names out of the optimiser
checks / unit (pull_request) Successful in 2m33s
Five provider plugins - generic_dayahead, engie_dynamic,
totalenergies_dynamic, eneco_eddy, manual_csv - all emitting exactly
{"prices": [{"start", "eur_per_kwh", "provisional"}]}, tz-aware at the
Brussels offset, EUR/kWh, all-in. Nothing consumes it yet; the seam
lands first, per the PM's ordering.
It is built in goodwe_controller/app/ rather than at specs.md 2.2's
ems/ingest/prices/, per the product owner's decision of 2026-08-30. The
behaviour 9.3 / 8.3 / 11.4 specifies is unchanged.
Three things shape the design, and each is the mechanism rather than a
comment saying to be careful:
Double-counting the markup is unreachable. A plugin does not return a
price - it returns the figure its source published and declares which of
{energy, markup, taxes} that figure already contains. assemble() adds
the complement, once, in the framework. A plugin is never handed
price_markup_eur_kwh: PluginOptions raises FrameworkOption on it, so the
line that adds the margin a second time cannot be written. Engie and
TotalEnergies publish a retail figure with their margin inside; adding
the configured 3 c/kWh on top would have made every arbitrage decision
wrong by that amount with nothing reporting it.
The product type is declared, not inferred from the plugin's name.
eneco_eddy is real-time/imbalance and gets 8.3's widened thresholds
through capabilities().product and volatility_regime(), never through a
name comparison - which is AC2. test_price.py asserts mechanically that
no plugin id appears anywhere in the framework's code, and drives a
sixth provider defined inside the test file to the right regime with
price.py untouched.
"No price data" is a type, not an empty list - the same rework
sim/riglock.py took under TEST-04. A fetch returns PriceRead.series(...)
or PriceRead.no_data(reason), and only the first carries fields. An
empty series is not constructible; no_data has no .points, no .at(), and
.get("points", []) raises rather than returning the default. A day of
free electricity and a day with no prices are different instructions -
arbitrage is pointless under one and forbidden under the other - and an
implementation returning [] cannot tell them apart. Every site-level
failure returns no_data; nothing raises into the control tick.
Also: injection stays a separate scalar (export_price_eur_kwh, AC4),
negative prices survive the boundary validator (17's regression case),
egress is allow-listed against each plugin's own declaration (16), and
no tzdata is used - the shipped Alpine image has none, so Brussels
offsets come from the EU rule and instants are carried as UTC.
validate.py grows rule 7: an unknown provider id, manual_csv with no
path, and - the HA overlap the PM told us to decide about -
price_provider set alongside price_now_entity/price_avg_entity, which is
two price sources where the HA pair silently wins. The HA sensors are
kept and still work alone; TARIFF-03 and OPT-04 are what replace them.
CFG-03 is not widened: the price defaults have ONE in-code mirror,
PRICE_OPTION_DEFAULTS, which validate.py imports rather than restating
and which test_price.py pins to config.yaml by reading the file.
test_price.py is runnable, assert-free in the house style, and every
verdict it prints is derived. It grades the markup arithmetic with
0.031/0.057 rather than the shipped 0.03/0.06, because 0.06 is exactly
twice 0.03 and a degenerate pair would make double-counting
indistinguishable from correct addition - and it asserts that
non-degeneracy as its first section. The DST checks run on the 23-hour
and 25-hour days, not on a normal day where a fixed 24-slot
implementation and a correct one agree. Thirteen mutations were driven
through it; all thirteen go red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
|
||
|
|
ffb8ec6373 |
MAINT-04 review: pin the default divergence with a check, not a comment
validate.py's comment said "if config.yaml's value moves, move this and main.py's together" and nothing enforced it - a comment where a check belonged, in a PR that elsewhere proves it knows the difference. test_validate.py now reads main.py and maintenance.py directly and asserts the divergence EXACTLY AS IT STANDS: config.yaml/validate.py at 2000, main.py's two fallbacks and MaintConfig's dataclass default at 2500. It records the mismatch rather than demanding it be closed, so it passes today and goes red if either side moves - including when somebody closes it in main.py, at which point the check and validate.py's block are deleted together. Nothing in a file another ticket owns is touched. The consequence is asserted through the real code as well as the literals: with the key absent the validator accepts, MaintConfig builds 2500 W against a 2000 W ceiling, and the arbiter resolves it to 0 W as a contradiction - the bug rule 6 exists to catch, passing rule 6. Production-unreachable (the Supervisor always supplies every key) but fully reachable from sim/inject.py and the tests, which is where anyone debugging maintenance works. Also: a CHANGELOG section for the upgrade path, covering both the case where the Supervisor re-derives the default and the case where it persisted the old one, since which applies was not verifiable without probing the live install. And maintenance.py's timeout comment said 13.5 kWh where everything else says 13.8 (300 Ah x 51.2 V, DoD 90); SAFETY-18 does not touch that file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
878a6ce452 |
MAINT-04: refuse a maintenance charge the ceiling cannot deliver
config.yaml shipped max_w 2000 against maintenance_charge_w 2500. The cheap-window shaping files Claim.limit(hi = -maintenance_charge_w) against the supervised SAFETY limit's lo = -max_w, so lo > hi - the arbiter's clause 3, correctly resolved to 0 W and logged as a CONTRADICTION. Maintenance silently never charged in a cheap window on a default install, and the only evidence read like a bug in the arbiter rather than like the two numbers the operator set. validate_startup_config() now refuses maintenance_charge_w > max_w, the same shape as CFG-01's sign_disagree_w >= max_w. Equality is reachable (-max_w is a single valid point) and is not refused. Demonstrated by starting the add-on on the pre-fix pair: exit 78 and the refusal banner. maintenance_discharge_w was checked rather than assumed and is NOT the same case: the drain phase OWNS the actuator (Claim.set at P_MAINTENANCE), so the ceiling clamps it to +2000 W and the arbiter's explain() names the clamp every cycle. Loud, and it still moves energy - warned, not refused. The shipped maintenance rates drop to 2000 so the defaults pass the rule; max_w was deliberately not raised, because it is the supervised safety ceiling. test_validate.py now reads config.yaml itself, so a default the validator refuses cannot ship again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
9aed43990b |
SAFETY-02: a loop can be fresh, tracking and inside its rating and still push the wrong way
The stale-input watchdog asks whether the meter is still talking. The saturation freeze asks whether the inverter can follow. The output clamp asks whether the number is too big. A loop can pass all three while charging the battery out of the grid, because the only thing wrong is the sign. On 2026-08-20 a commercial controller did exactly that four times in one day, for up to 77 minutes at a stretch, reaching a commanded -14 768 W against a ±5000 W inverter. app/watchdog.py compares the emitted setpoint against the authoritative P1 figure and, when they oppose each other with both over 300 W for 180 s continuously, files a failsafe SET claim at P_SAFETY, commands 0 W and latches there until an operator starts control again. Validated against that day rather than against an argument: sim/replay_sign.py in the infra repo drives this module over the committed captures of both 2026-08-20 and the healthy 2026-08-23 and reaches 18 trips on the fault day - every one inside a window NOTES.md §4.7 accounts for - and 0 on the healthy day. The four episodes do share one signature: 0.3 / 0.9 / 10.5 / 0.0 % sign agreement inside them against 92.9 % healthy. Three decisions the ticket could not make: - It is DISARMED whenever the grid-following loop is not what is driving. Sign agreement is a property of a loop that follows the meter, and a maintenance grid charge opposes the meter by design - 2500 W of charge into a house drawing 500 W is +3000 W on the meter against a -2500 W command, for hours. The gate tests the emitted number, not the winning claim's name, because a limit claim can let the loop "win" and still replace its value. - The trip LATCHES. Commanding 0 W removes the command side of the comparison, so the disagreement always clears the moment the trip works. - sign_disagree_w at or above max_w is refused at startup. The arbiter never lets a command exceed max_w, so such a threshold leaves the watchdog incapable of firing while the status page still reports it armed. What it does not catch, said out loud: an inverter that ACTS opposite to its command moves the meter further the same way, leaving command and meter in agreement. That is the saturation freeze's job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
547d796c5d |
CFG-01 review: widen the rule, refuse the firmware-veto case, fix the stop footgun
Review findings, all confirmed in code before changing anything. THE RULE DID NOT AUTHORISE ITS OWN BEST REFUSAL. The stated test was "makes a safety mechanism inoperative, or makes a stated limit untrue", and a blank setpoint_entity does neither - layer 1 pinning the inverter at 0 W is the failsafe WORKING, as validate.py's own text admitted. The real justification is a second, separate category: the add-on is inert and indistinguishable from healthy. Stated as (a) and (b), with each refusal tagged, and with both halves explicitly bounded - (a) is not "any two numbers that could disagree", (b) is not "anything degraded". Left implicit, the gap is where trivia-creep comes back in. meter_max_age_s ABOVE THE FIRMWARE'S VETO IS NOW REFUSED. The schema allows int(5,300); the firmware's threshold is a compile-time `max_age_s: "30"` and it trips layer 1 at `p1_age_s >= 30`. At 120 the add-on believes a 119 s reading is fresh and keeps commanding while the board has already vetoed and is holding at 0 W - healthy meter, running controller, nothing moving. Structurally identical to the max_w case, and unlike max_w it is settable on this install, so the headline behaviour finally has a live demonstration. That firmware file's own comment says a lower bound on this "belongs in CFG-01, not here". Only ABOVE is refused: below the veto the add-on gives up first, which is fail-safe and visible on its own status page. Scoped to P1 being enabled, because meter_max_age_s governs nothing otherwise. FIRMWARE_INGEST_VETO_S cannot be imported from a compile-time YAML substitution, so it carries a note on the single direction in which it can go stale - and a warning that TEL-06 is editing `ingest_stale`, so a matching integer may stop meaning a matching rule. stop= WAS A LIVE FOOTGUN. It defaulted to None, which made a Controller that cannot stop itself the DEFAULT one. On the late-refusal path that meant: log the banner, return, and leave the process alive forever with no control loop and no exit - the exact "inert but looks healthy" failure this module exists to refuse. Now required and keyword-only, so the broken shape does not construct; test_p1.py's five call sites pass an Event. That whole path had no test and no live demo. It has one now: it drives run_control() against a device that only reports a narrower range once the loop starts, and asserts it refuses, signals, returns, and writes NOTHING - plus the converse, that an agreeing device leads to real writes. This also covers the max_w composition that cannot be reached through the Supervisor on the live install. max_w <= 0 is refused. Schema-blocked in production, but validate_startup_config() is the simulation suite's entry point too, and a non-positive max_w inverts the arbiter's supervised bounds into a contradiction that reads as a safety trip rather than a typo. Verified live 2026-08-28 (backup goodwe_controller-cfg01r2-1787940076): meter_max_age_s 120 refused with a message naming 120, 30, and THE FIRMWARE WINS; no traceback; add-on restored byte-exact and running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
fc9bd89dca |
CFG-01: refuse to start on a configuration that cannot be honoured
main.py used to read the setpoint device's own +-5000 W rating, notice a
configured max_w above it, log one WARNING, and run anyway with
write_setpoint() quietly clamping. The operator's stated ceiling was never
the real ceiling and the only evidence had scrolled off the log. specs
§1.5 is explicit that such a config is refused at startup, not survived at
runtime.
validate_startup_config() runs in amain() before the broker, the web
server or any task exists, so a refusal cannot leave half a controller
running behind a UI. It refuses exactly three things, all sharing one
property - continuing makes a safety mechanism inoperative or makes a
stated limit untrue:
1. max_w outside the setpoint device's own rating.
2. No setpoint_entity: every write is a no-op, the device rating is
never read, and the ESP32 heartbeat that holds off the layer-1
failsafe never happens. It ran, looked healthy, and moved nothing.
3. A meter_source that can never produce a reading. These were already
caught, but they only DISABLED ingestion, which leaves grid None
forever: permanent inputs-missing, 0 W, and a status page
indistinguishable from a healthy meter nobody has fed yet.
Those last predicates move into p1.validate_meter_options(), which
build_source() also calls - one definition, two callers, not two
competing validators. ha_dsmr with half its entities and mqtt_p1 with a
blank topic gained the check the other two transports already had.
Deliberately not refused, because untidy is not unsafe: integrator_max_w
above max_w (the arbiter still bounds the wire to +-max_w, so the cost is
unwind latency - warned), a missing soc/batt entity (fail-safe and
already a red NOT READY banner), a setpoint entity that has not appeared
on the Core API yet (ESPHome is routinely late; re-checked in
run_control() once the real rating arrives), and a thin meter_poll_s
margin.
A refusal is legible as one: a banner saying in words that this is
configuration and nothing is broken, numbered problems, no traceback, and
exit 78 (EX_CONFIG).
Verified on the live add-on 2026-08-28, not by reading the code: a blank
setpoint_entity and a meter_poll_s equal to meter_max_age_s each stopped
the add-on with the banner and no stack trace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
|