4eb88f7fdac6429ef2fd3ed7cdcf73dbda9983f0
79
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
065792aad3 |
Merge release/1.0 (e3e4ba6) into opt15-price-run-fixes
Conflicts (5 files, 9 hunks) resolved keeping both sides:
- p1.py: held_t (OPT-15) beside held_since (SAFETY-90).
- modetrade.py: chargers() takes targets (P4) and probe (OPT-15); decide()
clears the chargers, a probing refuser included, while a battery drains
(SAFETY-78); the docstring lists both rules.
- main.py: the roll goes into the SAFETY-90/91 projection chain
(`_peak_proj`); the SAFETY-95 drain-tail grid_w keeps the run-edge hold on
its other branch; the P4 forecast state and the hold state both stay.
- DOCS.md, DECISIONS: both lines kept, OPT-15 item refreshed.
Adaptations the merge needs:
- SAFETY-81 x F1: a rolled projection is not graded by the saturation alarm
until the quarter's first sample, as the old edge tick was not, so a
standing overload is still notified once per quarter.
- test_safety81, test_modes_p4: modes-on pins re-pinned (with a note), modes
off pinned to e3e4ba6; test_price_run_fixes: identity pins on
|
||
|
|
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
|
||
|
|
138e45b53f |
SAFETY-103: no swing on old battery readings after a drain ends
While a battery settles from its ended drain (SAFETY-95), the house point was grid + every battery's reading. A register can be 10 s older than the meter, so B swung 860 -> 1650 -> 870 W on A's old reading (rig check 5). Now the house point is held and moved only as far as the meter proves: each battery is between its last changed reading and its commands since (a reading that changed this cycle, or a refused side, as it reads). N=1 and 1 s registers are byte-identical to release/1.0. DECISIONS/SAFETY-103-old-readings-after-a-drain.md, test_safety103.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
08fb1235fc |
Merge release/1.0 (0.15.0 + SAFETY-90) into maint-10-local-start-hour
Conflicts: main.py's maintenance import (base added CHARGE, HOLD; branch added site_zone) - both kept. DOCS maintenance table: base's longer drain-rate row kept, start hour row at 19 / local. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6918e154c8 |
SAFETY-97: "gives nothing" is graded against each battery's idle reading
B's power reading shows -95 to -118 W at idle (standby draw), so the |reading| < 100 W refusal band never held a refusing B: never at N=2, and since SAFETY-94's shared rule not at N=1 either (review F1). Each unit now learns its idle reading (app/idle.py): only while control runs, the unit is not refused/stale/tripped and its command has stayed within 50 W for 120 s; first value a 120 s mean, then a 30 min follow; samples beyond 250 W dropped, value clamped to 250 W; saved per unit and restored. The shared refusal evidence, compute()'s N=1 engage and end, the unit refusal ends, the stale-path refusal and SAFETY-42's "parked" test are centred on it. Until learned it is 0 W, the rule as it was. Refusing B caught at N=1 28/28/65 s and N=2 24/24/60 s (1/2/5 s meters), before never; refusing A unchanged. Healthy B with its offset, SAFETY-94's scenarios, 96 runs: 0 stale, 0 refused. Live replay: 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr |
||
|
|
6759d21ea5 |
SAFETY-94: a healthy battery that lags or reverses is not flagged as frozen or refusing
The stale-reading gate counted its 35 s from the register's last change, so after a rest the first read after a new command tripped it (SAFETY-58; live 2026-10-03, four trips 1 s after the command). It now counts only the time the command asked the register to move. At N=1 the refusal engaged after 12 frozen cycles if the reading was under 100 W at that moment, which a battery reversing through its lag passes at a 1 s meter. The refusal now needs the N>1 evidence at every roster size: under 100 W, one way, for 12 cycles spanning 22 s, restarting when the reading moves 50 W towards the ask. DECISIONS/SAFETY-94-lagging-battery.md; test_safety94.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d0db5b8c39 |
SAFETY-87: peak guard uses every battery that can still give
At N>=2 a battery at or under its own peak reserve is held: it gives nothing for the peak (weight 0 while the site gives) and the guard's need is placed on the live, non-refusing batteries above theirs. The guard is floor-limited, and alarms, only when those cannot hold the limit at full power for ALARM_HOLD_S. The peak alarms no longer read "Not ready" or hold the Home page at 0 W, because control keeps running. N=1 is byte-identical. DECISIONS/SAFETY-87-guard-uses-able-batteries.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr |
||
|
|
43a52e7f60 |
SAFETY-84: maintenance charge at N=2 no longer stalls
At N>=2 a cheap-window maintenance charge files a site `hi` (`charge`, P_MAINT_SHAPE) equal to what the charging pack's own `hi` places, so the other pack never has to give against it. The charge is sized to the quarter's headroom under the peak aim so it yields to the peak guard, and to 0 W under a site safety stop. N=1 byte-identical (safety84_identity). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
79b94024cb |
Merge release/1.0 (OPT-11 P4, #156) into safety-83-peak-over-drain
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
02c0a3dd7b |
MAINT-10: maintenance start hour in local time, default 19:00
The start hour was compared with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer. It is now read in Home Assistant's time zone (fallback: the container's TZ, then UTC, one log line says which), and the default moves to 19. DST: a missing hour starts at the first hour after the jump; a repeated hour starts only in its first pass. Stored options are not rewritten: a saved 10 now means 10:00 local (DOCS, CHANGELOG). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d77f1b1fdf |
SAFETY-83: peak guard outranks the maintenance drain at N=1
At N=1 the drain is the site SET at P_MAINTENANCE (60), so the peak floor
(P_PEAK, 50) was not applied and the house imported what the drain left.
Where the floor asks for more than the drain rate, main.py now refiles the
peak claim at P_MAINTENANCE, so it wins; safety SETs still park everything.
Nothing else changes: safety78_identity --diff against
|
||
|
|
b171ada894 |
Merge release/1.0 (SAFETY-78, #154) into opt11-p4-solar-forecast
Conflicts: modetrade.decide() takes both the day plan (targets) and SAFETY-78's draining flag; the Why line keeps SAFETY-78's other_cycle ahead of the plan's word (test added). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a692560960 |
Merge release/1.0 (SAFETY-81, #155) into opt11-p4-solar-forecast
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
516bc8b4ec |
Merge release/1.0 into safety-78-drain-at-two
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f967073cfe |
SAFETY-81 review: 10 s hold, ERROR line per episode, more scenarios
- The alarm must hold for peakguard.ALARM_HOLD_S (10 s) on the projection's
clock. A load that tops cap + battery for ~5 s every 30 s alarmed at every
quarter start while the quarter landed under the aim (sine4500_fast).
- A cycle with no battery reading leaves the hold alone.
- Notified once per quarter, but the ERROR line is written per episode.
- New sim scenarios (sine4500_fast, twice7000, step6500_gap) with
|
||
|
|
fae6681e19 |
OPT-15: price-run fixes (quarter edge, charge-end overshoot, refusal probe)
F1: with battery modes on, the peak guard projects the quarter `now` is in
even before its first P1 sample (PeakTracker.project(roll=True)), and only
a measured stop latches the period. The edge tick no longer reads "no
seconds left", so a charge is not stopped and latched at a quarter edge.
F2: after a run edge the loop is held at E until the meter shows the step
(EDGE_HOLD_S, longer while the battery still moves). The replay gained a
lag model (dead time, reading age, P1 debounce, 1 s cycles) that shows
TEST-23's overshoot without the hold.
F3: a charge refuser's SAFETY-73 probe inside a cheap run is a charger
pinned at PROBE_W, the others rest, so the probe actually runs.
Modes off stays byte-identical to
|
||
|
|
6eb67c0f9c |
SAFETY-81: no false 'Load too high' alarms
The alarm now also needs peakguard.load_too_high(): with the battery at full power, the quarter still ends more than 10 W over the real limit. The battery reading is the lower of this cycle's and the last, so a meter sample from before the battery's last increase is not counted twice. At most one alarm per quarter. Control outputs unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5d27f8496f |
SAFETY-78: maintenance drain at N=2 no longer stalls
At two inverters the drain pins its pack and the site target is split around the pin. Below the pin the other pack had to charge, the allocator refused the opposite directions, and the site sat at 0 W with an ARBITER line every cycle (house below or above the drain rate, or a grid charge). main.py now files a site limit `drain` (lo = what the pin places, at P_MAINTENANCE) at N>=2 while a pack drains, so the other pack only adds power in the same direction. A drain that cannot give power waits with one log line. With battery modes on, no grid charge while a pack drains, and the other battery's card says why. N=1 is byte-identical. 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
|
||
|
|
902524ae5e |
OPT-11 P3: price runs per battery
Cheap and expensive hours as runs of consecutive firm price slots
(priceruns.py); each battery's role per tick from its mode (modetrade.py):
grid charging for Leftover solar / Cheap hours only, never Solar first;
holding for the expensive runs; selling per battery (pack_sell) above the
spread and the floor. The site trade is sized inside the modes (loop bound)
and the peak guard's headroom; a run edge reseeds the loop. Cheap hours only
and selling are enabled on the Batteries screen and in the HA select
(appended). The site Why row no longer says "held back" when the peak limit
pushes power up (F2). Modes off: byte-identical to
|
||
|
|
ce0aea30b6 |
SAFETY-79 review: margin under a banked month peak too; identity harness reaches headroom and maintenance
- quarter_target_w() is now the margin under quarter_ceiling_w(): a peak
banked between cap - margin and the cap was aimed at exactly, so one
overshoot crossed the cap; above the cap the same overshoot raised the bill.
- safety79_identity.py hashes charge_headroom_w() and the shaping suspension,
and adds n1_maint/n2_maint (cheap window, forecast across cap - 50 .. cap).
Digests re-printed on
|
||
|
|
0951cb227f |
Merge release/1.0 into safety-79-peak-margin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # goodwe_controller/app/uilayout.py |
||
|
|
a9f7d78527 |
UX-13 P3 review: truthful peak lines, a clock-proof test, Simple stays default
- The peak limit's Why and plan block need a real power request (floor above 0 W), not a blind or floor-limited guard. An Off battery gets the peak block only while it gives power. Why no longer says every battery gives. - test_batteries_screen pins the fixture clock: unpinned it failed every morning. New checks cover the trade and cycle filters, the blind guard and an Off battery that A covers alone. - Settings keeps only a picked view; a link to Installer is not remembered. - Browser check: badges, unsaved change across views, locked value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
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
|
||
|
|
2d2b6afb07 |
UX-13 P3: Simple view and Batteries screen
- Batteries: one card per battery with On/Off, the mode list (cheap hours and selling greyed out until OPT-11 P3), level, and Now / Today / Why lines built in homeview.py. Replaces the OPT-11 P1 minimal cards and uses the existing pack_use / pack_mode actions and store keys. - An Off battery the peak limit still needs says so with its figure; no line shows 0 W beside moving power (OPT-11 P1 test F2). Today says "Not planned yet" until OPT-11 P3/P4. - Home's plan leaves Off batteries out, except for the peak limit. - Settings opens on Simple: the layout's simple fields with an applies chip and a note, plus the battery switches and modes. Installer is one tap. - English and Dutch words in app/ui_text; test_batteries_screen.py; browser check tools/ux13_browser_check.py (CHECK=1 ux13_screenshots.sh); new and changed screenshots only. No control, allocator, arbiter or command module changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV |
||
|
|
ffc9a4d721 |
OPT-11 P1 review: a mode never beats the peak floor; SAFETY-03 kept at the rail
- arbiter: a mode now gives way only as far as a site limit above P_MODE
needs (_unit_modes at N>1, _site_mode at N=1). Before, the modes were
released only when the peak floor bound S, so a trade won above the floor
kept them and placed less than the floor (an Off battery at N=1 went to 0 W
under a 2000 W floor), and a capped grid charge released an Off battery.
While released, held batteries weigh 0 and carry only the shortfall.
- control: a side of the loop bound that reaches the rail is left to the
rail, so SAFETY-03's i_clamped still fires with every battery on.
- opt11_identity: add maintenance scenarios and a 6000-case arbiter digest
(peak floor, trades, maintenance, refusals);
|
||
|
|
d69fd8fbfd |
OPT-11 P1: per-battery mode limits, on/off, solar order
A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.
compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).
Off by default (battery_modes_enabled); off, commands are byte-identical
to
|
||
|
|
667b8e9145 |
UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor
- app/ui_text/nl.json and app/uitext.py: every page word in Dutch, English fallback per key; the page picks by browser language. Home's sentences move into ui_text `home_words`, so /status?lang=nl builds them in Dutch. - test_ui_text.py: EN/NL key and placeholder parity, STYLE limits, jargon lists for both languages, no inline details, untranslated Dutch caught. Shared rules moved unchanged to tools/style_rules.py. - tools/ux13_screenshots.sh/.py: DOCS screenshots from the demo fixtures in headless Chromium; docs/img/ux13/. Battery modes (P3): no screenshot. - "Other time windows": a list editor (ui.periods) that builds static_profile and names bad times, empty prices, overlaps and gaps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
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 |
||
|
|
7be09197d4 |
UX-13 P2 review fixes (PR #145, comment 1880)
1. Discard reloads /config/model: after a stale save the page kept the
new fingerprint with the old values, so Discard + Save overwrote the
other surface's change without a 409.
2. Removing cards back to one restores the stored inverter shape, so a
flat site stays flat after add + remove.
3. A choice the page can only keep ("Other time windows") stays offered
while it is the stored value.
4. A load locks every locked value again.
5. at_zero() needs an accepted 0 W write; before the first one Home says
"Going to 0 W" (the heartbeat writes every heartbeat_s, so it clears).
6. A field with a unit drops the translation's "(W)" from its label: the
peak limit read "Peak limit (W)" beside a kW value.
7. A mirrored choice lists the mirrored field's keys, so a search for
static_profile finds the contract on a fixed-price site.
8. test_settings: a save refused by ui_to_options writes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
c9ab48d6fc |
UX-13 P2: the Installer settings view
- Settings tab: the Installer view (app/static/settings.js), drawn from
app/uilayout.py through the new app/uipage.py: search, six collapsible
groups with "n to fix / n to check / n changed" badges and an "applies
after a restart" chip, radio cards, segmented choices, sliders with units
(the peak limit in kW), entity pickers with search and the live reading,
inverter cards, solar rows, locked measured values behind "Change", and
the add-on's refusals, warnings and info beside the field they name.
- Save path: the page posts its model to the existing config/validate and
config/save; uimodel turns it into options before the shipped validator,
so nothing it refuses is saved. No route added. A value equal to the
stored one is written as stored, so a save that changed nothing writes an
identical blob. Save bar, then the restart dialog that names 0 W.
- The region choice shows its preset year; Wallonia and Brussels are listed
as having no preset yet (TARIFF-22). "Classic settings" keeps /config.
- P1 test findings: (a) "Going to 0 W" until the last accepted power request
is 0 W; (b) the trade blocks and the price line name the feed's own points
("Lowest published price"), the hour bars say "hour average"; (c) the entity
list's WARNING gives a reason on a timeout.
- test_settings.py; test_home, test_haentities, test_uilayout extended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
|
||
|
|
f8d63cc614 |
UX-13 P1: page shell and Home screen on a structured /status
- app/static/{index.html,app.css,app.js}: tabs (Home, Batteries, Settings,
Setup), hash routes, phone bottom tabs, light/dark. Served inlined at "/";
the route list is unchanged, and every URL is relative.
- app/homeview.py: /status gains state, now, batteries, plan, attention,
month_peak and control beside the old fields. The plan is what the add-on
does today: the arbitrage verdict (the same plan.arbitrage() call as the
cycle), the monthly cycle, the peak limit, "follows the house".
- The Home plan chart has hover, tap and keyboard-focus tooltips: price bar =
hour and price; block = what, when, why, kWh and EUR.
- Region preset and year: in config/model (for P2) and on the Home price line.
- test_home.py; test_messages part 8 scans every Home sentence; STYLE plan line.
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 |
||
|
|
3c0f2b9669 |
SAFETY-52 review: a refusal is under 100 W, and any delivery ends it
- Engage (and seed from a stale stop) below REFUSED_BELOW_W, the probe's own 100 W answer, not below saturation_w: a battery derated to 100-499 W was held at 0 W ~93 % of the time. - A reading of 100 W or more in the held direction ends the refusal, probe or not: at a 1 s meter SETTLE_CYCLES is 12 s, shorter than a healthy reading's lag, and that held a healthy step at 0 W until the next probe. - test_refusal part 5b and a seed test; DOCS states the N>1 limit; the DECISIONS record carries the traced cause of the replay's extra trips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1f97c7652f |
SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min
At the BMS cut-out the saturation freeze held the last request for hours (+5000 W for 11.87 h on 09-06, +1940 W for 3 h 55 m on 09-19) until the grid error changed sign. compute() now carries a refused direction: after SETTLE_CYCLES of a ~0 W reading against a larger ask it holds that direction at 0 W, probes 200 W (below the gate's 250 W) every 15 minutes, and lets go on charge, an answered probe, stop/start or another writer. A stale-reading trip on a frozen ~0 W reading seeds the same hold, which ends SAFETY-59's ~70 s cycle. One inverter only; no SoC in the loop (SAFETY-25 Option A). test_refusal.py drives the real Controller and gate; tools/replay_refusal.py replays the 16-day capture. test_clamp.py and test_delivery.py re-baselined where they asserted the old hold. DECISIONS/SAFETY-52 has the record and what SAFETY-58/59 must take into account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
16c796fa30 | Merge pull request 'TEL-14: PV sign declared per entity (pv_invert_entities)' (#124) from TEL-14-pv-sign into release/1.0 | ||
|
|
7559d21eda |
TEL-14: PV sign declared per entity (pv_invert_entities)
checks / unit (pull_request) Successful in 8m39s
Some PV entities (kWh meters, ESPHome clamps in the PV circuit) read positive for consumption and negative for production. `pv_invert_entities` lists the ones to flip; the flip is applied per member inside pv.total(), before the sum, so the MQTT `pv` row and the status page carry the corrected figure. Default [] is byte-identical to 0.12.0. Shape measured in supervisor_shape_probe.py section H (Supervisor 2026.08.0): a parallel list validates the 0.12.0 blob unchanged and renders as its own translated repeatable field; dicts on pv_entities refuse 0.12.0's stored strings; a prefix is invisible syntax; a global bool cannot describe a mixed roster. Status page marks flipped entities and cautions per entity below -50 W, saying ADD or REMOVE. Telemetry only: control documents unchanged. Record: DECISIONS/TEL-14-pv-sign.md (reverses TEL-11 section 2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
b06f1fad5f |
SAFETY-62 review: DOCS exit 3 at the command block (R1); recognise the auto store key (R2)
checks / unit (pull_request) Successful in 8m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
f4f72211a9 |
SAFETY-60/62: operator tools refuse input they cannot read
checks / unit (pull_request) Successful in 8m31s
predeploy_check.py and rehearse_roster.py now assert a positive property of their input (a JSON object, most of whose top-level keys are known option or store keys) and refuse anything else with the shape named. The `ha apps info --raw-json` wrapper, a truncated or list-wrapped state file and a wrapped config no longer produce "CLEARED" / "NO FINDINGS" with exit 0. Also: a clean --no-live-entities rehearsal exits 3, not 0; alloc_sim.py exits 1 when split-invariance FAILS; decode_bus_capture.py takes several paths and exits 1 on a file that is mostly not frames. Graded by the new test_tool_inputs.py; DOCS.md says to pass .data.options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
0ef9565071 |
TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s
0.11.0 shipped `pv_entity` as a single string. The reference site's owner reported within minutes of it going live that they have several PV inverters and could configure one - which is ordinary for a multi-string roof, not a property of one house. `pv_entities` is the roster and the row carries the SITE total. ⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`, and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no irradiance" conclusion the channel exists to TEST. So `pv.total()` answers `None` the moment any configured entity is absent: the topic carries HA's own `None`, the entity goes to `unknown`, the recorder records a GAP. The alternative (the partial sum beside a contributor count) was implemented and run as mutation N1b; it loses because the MQTT row is read COLD a week later by an attribution that would believe it, while the qualifying count would live in a second entity with its own history. The partial information is not thrown away - the STATUS PAGE names every entity that reported, with its watts, and every one that did not, with its reason, because that surface is read LIVE by a human who wants the diagnostic. ⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is `is None`, never falsiness - a roster that blanked the site on a zero would report an absence on every clear evening. THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list: tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an 0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and deepmerge's list strategy is `override` so a `[]` default does not rescue it - the live add-on would not start. `match(.*)?` does accept both shapes and is rejected for a different, measured reason: it validates nothing (a string, a list and the integer 417 all pass) and renders as a plain text box. So `pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for `inverters:`, with a status caution saying which key won - a union would double-count the inverter the old key still names. STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than replaced and now grades five claims over three document sets. The control hash is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the roster control document is byte-identical between the two trees. 2/5 is a claim TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly as 0.11.0 does" is a real cross-tree identity over control AND telemetry. ⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit 24: a discriminator defined as "the baseline lacks this feature" dies on the release that ships the feature, and dies as a false refusal. Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B (`sum` -> `max`, the same three characters) and was run on a fresh copy with `cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO contributions in two units with absence driven at EVERY position - a member contributing 0 W could go absent without moving the sum of the rest, which is habit 23 with a coinciding value in the arithmetic rather than in the plant. 31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0, so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG entry is wanted and deliberately not written - 11b forbids `## Unreleased` and the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the material it needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
07ba3433ed |
TEL-11: PV production stops being invisible, and stays out of the control law
checks / unit (pull_request) Successful in 8m6s
OPT-10 measured that this site's pack reaches 90 % SoC on 2 days of 16 and that
20.5 kWh of surplus left the site while the pack had room - 12.6 kWh of it with
the battery not charging at all - and could not say WHY, because PV production
is recorded NOWHERE. Three causes need three different fixes (no irradiance:
nothing to fix; a charge-power limit; the optimiser declining to charge, which
is the only software defect) and guessing between them is how an optimiser gets
tuned to solve a cloud problem. This card adds the channel. It does not do the
attribution.
AN HA ENTITY, NOT A REGISTER, AND THAT IS A PRODUCT DECISION. This add-on's
RS485 bus carries the BATTERY inverter it drives; a household's PV inverter is
normally a different box from a different vendor reporting into Home Assistant
through its own integration. `pv_entity` takes whatever `sensor.*` the site's
own PV integration provides, any brand HA can read.
POSITIVE IS PRODUCTION, AND THERE IS NO `pv_invert`. The convention is derived
rather than assumed: `meter_entity` is positive when IMPORTING and `batt_entity`
is positive when DISCHARGING (DOCS.md, Sources), both of which are "power
arriving at the house's AC bus", and production arrives there too. Those two
keys carry invert flags because they are BIPOLAR and are READ BY THE CONTROL
PATH - `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor, so a reversed sensor hands the site a plausible number
it acts on. Production is unipolar and is read by nothing: a reversed sensor
announces itself as -4 kW at noon in the very history this card creates, and a
flag would only let an installer hide it. No negative-value alarm either - a PV
inverter's own standby draw makes a small negative reading at night ordinary, so
any threshold would be a number nobody measured.
ABSENCE IS NEVER ZERO, AND "DROPPED" IS NOT ABSENT. `publish()` SKIPS a None,
which is right for `circulation` and exactly wrong here: a skipped row leaves
Home Assistant holding the last number forever and the recorder draws a FLAT
LINE through the outage, which is indistinguishable from a measurement and
manufactures the "there was no surplus" conclusion this card exists to TEST. So
the absence is published, with HA's own payload: `mqtt/const.py` declares
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric branch,
so the entity's state becomes `unknown` and the recorder stores a GAP. Read out
of home-assistant/core `dev` on 2026-09-24 and quoted at the constant.
`ABSENT_AS_UNKNOWN` is a one-key set and the suite asserts `circulation: None`
is STILL dropped.
THE UNIT IS READ OFF THE ENTITY. `mW`, `W`, `kW`, `MW`, `GW`, `TW` - HA's own
`UnitOfPower`, every factor an exact power of ten. A sensor with no unit, or a
unit outside that table, is REFUSED by name and the status page says which.
`read_inputs()`'s `abs(raw) < 50` kW-or-W guess is safe for a quarter-hour
demand forecast and is NOT safe here: 30 W of dusk is an ordinary reading and
that heuristic would publish it as 30 kW.
GENUINELY OPTIONAL, AND NOT A SECOND `price_now_entity`. validate.py does not
mention the key - it cannot conflict with anything, because nothing else reads
it. The MQTT row lives in a new `OPTIONAL_SENSORS` list whose `include` argument
DEFAULTS TO EMPTY, so every caller that has never heard of `pv_entity` -
test_mqtt.py's fixtures, tools/rehearse_roster.py, anything written next year -
announces exactly what it announced before. Unset: no state call at all, no
entity, no topic, no status row, no page row. Set and failing: a CAUTION
(`ok=False, warn=True`), never a `bad` row, so a telemetry channel can never put
a battery site into NOT READY.
IT MUST NOT BECOME A CONTROL INPUT, AND THAT IS GRADED TWICE. `test_pv.py`
part 6 drives 40 cycles of the real `cycle()` and asserts the commands are
bit-identical across unset / no reading / 0 W / 8 kW, with a COMPUTED census
(33 modules under app/) showing the material is mentioned in exactly three:
pv.py, main.py, mqtt.py. `tools/tel11_pv_trace.py` is the cross-tree half -
`safety56_behaviour_trace.py`'s pattern with a DIFFERENT control clause, because
this card's claim is that nothing differs rather than that only sentences do: it
REFUSES unless the two `app/` fingerprints differ and unless exactly one side
reacts to the option. Measured on 41 stimulus cells:
control release/1.0 unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control TEL-11 unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control TEL-11 set 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
telemetry release/1.0 unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry TEL-11 unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry TEL-11 set 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
UNSET is byte-identical in BOTH documents, which is stronger than "the control
path is untouched": an install that does not set the option sees no change of
any kind, announcement and status rows included.
⚠️ AND THE CARD'S CENTRAL MUTATION WAS A SURVIVOR ON THE FIRST BATTERY.
`self.grid -= self.pv_w` in `read_inputs()` - +106 B, on disk, purged, genuinely
running - came back GREEN, because the fixture drove a 6 kW export over 12
cycles, which puts the loop AGAINST ITS RAIL: a command moving `slew_w` every
cycle produces the same ramp for -6000 W and for -14 000 W of apparent error.
MUTATION.md habit 23 with a saturated ACTUATOR in place of a coinciding value,
and it is in that file now as the habit's second instance. The fixture now
drives a 600 W import the loop SETTLES on, against a responsive meter, and asks
habit 23's own question as its own check: shift the METER by the same 8 kW and
confirm the commands really would have moved.
Evidence: 31/31 suites green in the CI image, checks.yml's exact command, purged
tree, `:ro`. Ten mutations, NO survivors, each on its own `cp -a` of the tree so
habit 18's revert hazard is closed by construction - table in
`DECISIONS/TEL-11-pv-production.md` §7 with byte deltas and sha256s.
⚠️ THIS IS A RUNTIME CHANGE BEHIND AN UNCHANGED VERSION, AND
`tools/predeploy_check.py` WILL REFUSE IT. `app/`, `config.yaml` and
`translations/` are all in its `RUNTIME` tuple and `config.yaml` still reads
0.10.1, so the gate reports a NO-OP deploy until a version is cut. That is PKG's
call and this card does not take it.
No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## ` heading
that is not config.yaml's `version:`, and opening one would mean taking that
same packaging decision. The operator account is in DOCS.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
8a6acd9fa2 |
SAFETY-56: the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can
checks / unit (pull_request) Successful in 8m0s
Text only. No behaviour change - same conjunction, same limits, same MISSING, same 0 W, same NOT READY. `tools/safety56_behaviour_trace.py` says so: 2390 capture reads, a 66-cell swept grid, and six driven controller worlds produce the byte-identical document `6030175e...` on origin/release/1.0 and on this branch, while the sentences differ. THE DEFECT. SAFETY-37's conjunction is satisfied by two disjoint worlds - the register has stopped reporting reality, or the SOURCE cannot comply with the command - and nothing `freshness.py` is handed separates them. Three sentences asserted the first. On the live 2026-09-21 episode it was the second: the pack read 11 % for the whole 850 s hold, every command in that hold was a discharge, and the register answered the first CHARGE command three seconds later. The readiness row was worse still, because it asserted a false fact about a NAMED entity that was present, numeric and updating - so the operator opened Developer Tools, found it working, and had every reason to conclude the diagnostics were broken. THE FIX. Each message states what was OBSERVED and names BOTH explanations. Three wordings, no shared helper: two are a log line about a behaviour, the third is a banner about an entity, and a helper serving both would drop whichever half the other caller does not need. SoC stays STATED, NOT WIRED - part 11b drives the two worlds at 50 % and 11 % and the sentence does not move one byte. The row does not branch on `batt_fresh.stale` either: `stale_note()` already rides `liveness_notes()` a few rows below, so the discriminator is "is that row there?", and part 11d asserts it is present in one world and absent in the other. THE CHECKS. test_freshness.py part 11 builds the pair of worlds with ground truth written down rather than derived from the published stream, asserts they are really two (40 of 40 reads differ) while the published stream is bit-identical, and only then grades the sentences. Eleven mutations, all red: each of the three sentences reverted (3, 2 and 2 named reds), the two explanations transposed at ZERO bytes (1), the second explanation deleted (1), the admission of ambiguity replaced by a ranking (1), all three reverted at once (5), and two aimed at this card's own check - collapsing the world pair (2) and emptying the accusation list, which a positive control against the three sentences as 0.10.1 shipped them catches (1). AND TWO OF THE ELEVEN FOUND DEFECTS IN THE CHECK RATHER THAN IN THE CODE. Transposing `label` and `entity` in the row made it unfindable and the run died on an IndexError - a red with no name and every later check ungraded, FCST-06's shape exactly; `_s56_row()` returns a named absence now, 0 named reds before, 3 after. And the first invariance run compared a tree with itself, because it archived a branch whose edits were not committed yet; the tool now REFUSES to report "behaviour unchanged" unless the sentences halves differ, and both of its refusals are driven. SAFETY-57's part 10h check was the handoff and is inverted rather than deleted: it required the accusation to still be present so that removing it had to be a deliberate act. This is that act. Its escape clause is untouched. Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
43c144b8d9 |
SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
checks / unit (pull_request) Successful in 7m58s
SAFETY-37's freshness gate reports a battery-power register as MISSING when it
stops following its own command, and a missing input is "command 0 W and keep
commanding" (§11.1 rule 1). The gate's only exit was "an arrival carrying a
DIFFERENT number" - and at 0 W nothing is asking the register to be anywhere.
The state had no exit reachable from inside it, and the log line promised the
opposite in the same sentence that announced the 0 W.
REPRODUCED FROM THE WIRE, NOT MODELLED. scenarios/safety57-2026-09-21/ is 3840
verbatim frames off the RS-485 pair, read from the passive sniffer, decoded by
tools/decode_bus_capture.py (0 unparsed, 0 bad CRC, 0 unknown shapes; the
mode->sign convention is graded off the capture, not trusted). The box was
running 0.10.0, so the 0 W in the trace IS the add-on's own claim:
10:51:24.7 0x0518 reaches -3 W. Pack at 11 %, the BMS refuses the discharge,
the inverter idles. THE REGISTER IS HEALTHY AND TRUTHFUL.
10:51:29.7 the command reaches +1060 W - 800 W of travel
10:52:00 the conjunction arms. Inverter.batt becomes None
10:52:21.7 the site commands 0 W, on the wire
... 0x0518 does not change value for 850.0 s. The setpoint does not
change value for 790.0 s. The house exports 2.4 kW throughout
~11:04 a human restarts the add-on. That is what ends it
AND IT HAD BEEN ESCAPING BY LUCK. Replaying the committed 16-day loop capture
through the shipped conjunction finds 93 trips whose wait for the BMS
keep-alive pulse that cleared them has a median of 143.4 s, a worst case of
21440.1 s, and 17 of 93 at or beyond this episode's 710 s. That distribution
belongs to the battery's firmware, not to this code.
THE CARD'S OPTION 2 ALREADY HELD AND IS NOT THE BUG. _grade() has required
|standing| >= MOVE_W since SAFETY-37. The trip was at +1060 W and the stuck
state is at 0 W; they are different moments, and an entry-side rule cannot
reach a state entered legitimately and then persisted. Nothing in the
conjunction is weakened here.
THE RULE, SAFETY-13's SHAPE ONE MODULE OVER - which latches get an exit, not
how long the block lasts:
the verdict is withdrawn when the obligation that produced it has been gone
for RELEASE_S, and the register is then re-graded from scratch.
RELEASE_S is BATT_OBLIGED_S, 17.0 s - the obligation window already derived in
the file (6.0 plant + 10.0 publish + 1.0 read), taken at the FLOOR of its band
for the mirror of MOVE_W's reason. Which half is safe is the opposite of
SAFETY-13's: being wrong long is the failure being fixed, being wrong short
costs one bounded re-grading window. Below 17 s the release pre-empts the
cheaper exit - the register answering by itself - and that is graded
behaviourally, not asserted.
The whole parked interval is now stale_input_s + RELEASE_S = 32 s. On the live
episode: telemetry back at 10:52:39 instead of 11:05:35, with 0x0518 still
answering the same -3 W. RED BEFORE (release_s=inf, a graded counterfactual and
not a second implementation), GREEN AFTER.
WHAT IT COSTS, AND IT IS NOT SMALL. A register dead for good is re-graded for
ever: over 1201 reads the held number reaches a consumer on 630 (52.5 %)
against 35 (2.9 %) before. Payable only because one window is at most 35 s and
the two consumers hold for 75 s and 85 s, so neither can reach a verdict inside
one and the re-trip clears both windows. No detection SAFETY-37 had is given
back - a permanent None clears the same windows for ever too - and the command
is derived from the P1 meter, never from batt.
Two clocks, deliberately: changed_mono/unchanged_s() stay facts about the
REGISTER, graded_from/graded_s() are the VERDICT's window. Collapsing them
either lies to the operator or defeats the re-grading.
main.py's stale line had its ESCAPE CLAUSE corrected - it promised a clearance
the state could not produce - and gained a STALE WITHDRAWN edge that says in
its own words that nothing has been learned about the register. The DIAGNOSIS
half is SAFETY-56's and is deliberately untouched, with a named check asserting
it is still there so that rewrite has to be deliberate.
Battery: 22 mutations, 21 red, 1 argued equivalent, 0 unexplained survivors.
Two findings kept in DECISIONS: _release()'s move_w test swapped for
unchanged_s SURVIVED at +5 B because every fixture rested at exactly 0 W (habit
11, closed by a 0/100/300 W fixture); and the code shipped a habit-22 comment
claiming the _release/_grade order was load-bearing, which the battery
falsified - the two act on mutually exclusive predicates, so the order is not
observable. MUTATION.md gains habit 23 for a mutation that ran, fingerprinted
clean, and was a no-op on the fixture because `0.0 or x` is x.
Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro, one
file at a time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
a6af332048 |
SAFETY-48: what the lead cap actually does to a frozen integrator, and what the peak figure depends on
checks / unit (pull_request) Successful in 7m55s
Four review findings, none of them in executable code. The control law, the tests and the instruments' arithmetic are untouched; every number below was re-measured against the committed 16-day capture before it was written down. R1 - app/control.py carried a justification the decision record and MUTATION.md habit 22 had already retracted, and it was false about the code it sits in. It said an unconditional placement "would also pull the integrator toward a command the SATURATION freeze is pinning", priced at 118 of 256 250 ticks. The 118 was measured against a different pair; on the pair the sentence names the two forms are bit-identical - 0 of 256 246 ticks, re-measured. And the narrow form that shipped DOES move a saturation-frozen integrator: it runs before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks the cap changes `i_w` on 1095, and on 679 of those it moves it further out on the side it was already on - putting back magnitude the freeze had just let fall, which the freeze's own rule forbids. Worst case 2424 W, at prev_w -3400 W, grid +4697 W, actual -4144 W, sat_count 2 -> 3: the freeze leaves the integrator at -176 W and the cap drags it to -2600 W. It never overrides a same-cycle refusal (the freeze clipped on 75 512 ticks, the slew bound on none of them), and that is stated too. It is also where SAFETY-13's settle values went: gating the cap on `not frozen` puts test_control.py part 5 back at -1190 W exactly, while part 4(b) does not come back - that one moved for the winding reason. R3 - the record read its own sensitivity table backwards. `fit_plant()` picks lag 7.5 s on battery-power RMS, which separates it from 2.5 s by 0.3 %, while the tool's own printed fidelity measure (export against the recorded 29.43 kWh) prefers 2.5 s by the whole 35 % gap. Swept properly: at every lag <= 5.0 s release/1.0's worst quarter is ALREADY 3434 W and this card changes it by exactly zero. The 3739 -> 3434 W headline exists at one plant. New section 3a carries the table; the export reduction now leads every summary and the peak is presented as lag-dependent. The form-A rejection is strengthened rather than weakened: form A is worse on the billed quarter at every lag where release/1.0 produces a peak at all, and at every top-N cut of the quarters. R2 - DOCS.md stated modelled watts to the owner as measurement. It now leads with the export reduction, says the figures are simulated, and says plainly that no capacity-tariff improvement is promised. R4 - tools/slew_sim.py --slew narrated "the OVER-ABSORPTION does not move, and at the slowest slews it is slightly WORSE" while printing a monotonically falling column. That sentence is TRUE on release/1.0 (reproduced: 958/1107/1133/1058/987/1029/1150/1143/1081) and false under this branch's law (777/734/663/563/553/507/435/358/321), so SAFETY-39 is not retracted: its record gets a forward reference, and the script now reads SLEW_LEAD_STEPS out of the law it imported and narrates from the column it just printed. Both branches of that narration were driven. R5 - three counts in the L table were decoration and are re-driven in the CI image on a `cp -a`, purged, `:ro`, one file at a time: L5 is 5+5 with the function names exchanged and 5+4 with the branch bodies exchanged (two mutations wearing one name, now listed separately), L6 is 6+3. The separable- state count is grid-dependent and three sweeps disagree, so all three are quoted with their grids and the load-bearing claim is WHERE they separate. R7 - "a large import answered too weakly" is not what the aggregate shows. Form A cuts import by 5.45 kWh and export by 5.18 kWh, and paired per tick delivers 8.56 kWh less offtake energy on the ticks release/1.0 was importing. The harm is a tail redistribution inside quarters that already net to offtake. The conclusion stands; the sentence is narrowed to it. Verified: the full suite in the CI image, purged tree, `:ro` mount, one file at a time, exactly the command in .gitea/workflows/checks.yml - 30/30, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
03ef61fa3b |
SAFETY-48: the slew step ceiling reaches the integrator, and what it bounds is the LEAD
`compute()` freezes the integrator with an asymmetric rule and the comment beside it states the requirement outright - "the requirement is that the INTEGRATOR stop accumulating, not only the command" - but `frozen` is computed from ONE signal, `abs(prev_w - actual_w) > saturation_w`, and slew-limiting never enters it. The integrator moves `gain * error` a cycle and the command moves at most `slew_w`, so the integrator outruns the command whenever `|error| > slew_w / gain`: 667 W at the live shape, against a measured median grid error of 1114 W at the onset of a recorded episode. SAFETY-39's retune made it MORE frequent, not less - a smaller step is a lower threshold. ⚠️ THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, WHICH IS WHY IT IS NOT WHAT SHIPPED. Reusing the asymmetric freeze on a carried slew flag was built from release/1.0's own source and measured over 16.00 days / 256 252 meter instants of the live install's recorder: export 39.61 -> 34.43 kWh, and the worst quarter-hour average OFFTAKE 3739 -> 3935 W with the mean quarter +2.73 W, in five of six plant and tuning variants. A frozen integrator stops at `prev + gain*error`, which undershoots by (1 - gain); for an export that is free and for an import it is offtake, for as long as the ramp lasts. The card said "do not invent a second rule"; the measurement says to, and both sentences are recorded. WHAT SHIPS BOUNDS THE LEAD AT ONE SLEW STEP, inside the branch where the slew actually bound. Same 16 days, same ticks, same disturbance, same plant, same seed: export 39.61 -> 34.07 kWh, wasted throughput 4.67 -> 3.19 kWh, battery sign reversals 2992 -> 2770, median recovery 232 -> 199 s, p99 849 -> 850 s, max 901 -> 898 s, unrecovered 53 -> 31, worst |i_w - prev_w| 9200 -> 800 W - and the worst 15-minute average offtake 3739 -> 3434 W, i.e. the billed quantity IMPROVES. `i_w = slewed` (a cap of zero) is better again at 32.02 kWh and is refused: it is the velocity form this module's docstring says `i_w=None` collapses to. TWO COMMITTED NUMBERS MOVED, DELIBERATELY, WITH THE REASON BESIDE THEM. SAFETY-13's released command settles at -800 W and -1200 W where release/1.0 settled at -1190 W and -2390 W, because there is less wound integrator to settle onto; AC 4's property - it stops at the frozen integrator, not the rail - is what is asserted and the second is now graded against `i_w` rather than a literal. And SAFETY-39's "the slew does not own the over-absorption" is now FALSE - INVERTED, on the independent first-order plant test_control.py writes itself: 1005/1109/1285/1330 W rising with a slower slew becomes 729/550/424/326 falling. Two plant models that share nothing agree on the sign. Evidence: `tools/replay_recorded_loop.py` plus the read-only capture it made (scenarios/loop-2026-09-02_2026-09-17). The "before" law is the shipped file with the block removed by a named substitution - never a transcription - and a missing anchor is a REFUSAL carrying "NOTHING HAS BEEN MEASURED". The instrument prints its own fidelity first: it reproduces 39.6 kWh of export where the install exported 29.4, i.e. it is ~35 % pessimistic about the loop's tracking, so only the delta is claimed. 30/30 suites green, including test_control.py's exhaustive i_w=None equivalence sweep against the transcribed release/1.0 law, untouched: the cap moves `i_w` and not `target`, and that sweep compares (target_w, sat_count, frozen, reason). Six law mutations, one instrument battery. L4 - the cap applied unconditionally, -92 B - was a SURVIVOR of all thirty files: it is bit-identical on the shipped configuration (0 of 256 246 ticks) and separable on 2050 of 257 400 swept states, all at `integrator_max_w` above `max_w`, which part 4 now drives. L5 and L6 are ZERO BYTES. AND TWO COMMENTS IN THIS CARD WERE INVENTED AND ARE CORRECTED IN PLACE. The one-sided spelling was justified by a two-sided form that would "drag a trailing integrator" - it does not, and the two agree on 205 920 of 205 920 swept states; and a "118 of 256 252 ticks" figure had been measured against a different pair. MUTATION.md gains habits 21 and 22 for both shapes. NOT DEPLOYED, NOT VERSIONED. The live install still runs release/1.0's law. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
b548525d10 |
TEL-10: the loop's integrator stops being invisible
`self.i_w` has been a separate quantity from the command since SAFETY-04 and has left the process nowhere at all - no entity, no MQTT attribute, no log line. The investigation behind SAFETY-48 could only RECONSTRUCT it from the grid trace plus a replay; the reconstruction reproduced about a third of the engagements it was checked against and was discarded, so the card it was gathered for had to be argued from a measurement nobody could stand behind. It could not have been made to work either: `i_w` depends on every cycle before it, including the ones where the arbiter overrode the loop and `main.py` re-seeded `self.i_w = self.target`, and that re-seed is invisible in the grid trace. THREE SITE ROWS, APPENDED AFTER release/1.0's EIGHT. `loop_i` is `Decision.i_w` - the accumulator after the cycle, before the output clamp, the slew limit and quantisation. `loop_lead` is `decision.i_w - prev_w` against the command `compute()` was HANDED, which is not a subtraction of the other two published rows: `Setpoint` is the arbiter's output and is this cycle's, so on any cycle the arbiter overrode the loop the two differ - and that is exactly the cycle on which the integrator was re-seeded. `loop_reason` is `compute()`'s own word; the arbiter's `explain()` prints the winning claim's NAME and has never carried the claim's reason, so `slew-limited` appears in neither the `Why` row nor `Controller status`. Measured on the same cycle: arbiter `'loop -> -5000 W'`, loop `'i-clamped'`. NOTHING IN THE CONTROL PATH MOVED. `app/control.py` is not in this diff. `compute()`, its returned `Decision` and every constant it reads are identical to release/1.0, and all nine of release/1.0's retained discovery messages are byte-identical and still FIRST in the announcement. Evidence: 30/30 suites green in the CI image. Nine mutations, ONE survivor, reported as one: `decision.i_w - self.target` for the lead is +2 B and green because `cycle()` has not reassigned `self.target` at that line - the name is kept for the coupling, not for a behaviour it buys, and M2b (+8 B) is the mutation that does change the value and is red. AND TWO OF THIS CARD'S OWN CHECKS COULD NOT REACH THE FAILURE THEY NAMED. "the published integrator reaches its bound" was GREEN under the card's named mutation (publish `prev_w`, -3 B), because the command reaches the bound too - twelve cycles later. It asserts the LEAD TIME now and is red on 5. "a stopped controller publishes not-running" was GREEN under deleting that branch's assignments (-45 B, -67 B), because the fixture STARTED stopped and `__init__` writes the same values; it stops the controller part-way through a run that has already wound the integrator to -5000 W. Both are MUTATION.md habit 4, found by the battery rather than by review. No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## ` heading that is not config.yaml's `version:`, and opening one would mean taking a packaging decision this card does not get to take. The operator account is in DOCS.md and the release note belongs to the next PKG ticket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
f6f3f227c1 |
INSTR-01: restore M12 on the leg I generalised past, and measure the clause that had no row
checks / unit (pull_request) Successful in 7m6s
Review found two things this PR asserted about itself that were false. Both are mine and both are worse than the defect the card was filed for. ⚠️ "NO FIXTURE CAN RESTORE IT, MEASURED BOTH WAYS" WAS NOT TRUE, AND TWO MEASUREMENTS ARE NOT AN IMPOSSIBILITY PROOF. I deleted M12 - SAFETY-44's working end-to-end control for the off-roster narrowing - on the argument that the provenance clause made it unrestorable. The structural claim behind it, that `run()` builds both snapshots from one state file so "already latched" and "made by this transition" are incompatible, IS TRUE OF SAME-STEP AND FALSE OF STAGED: `snapshot_over()` starts the after-leg over the file the BEFORE-leg wrote, so a transition-made destination is available there. Both arrangements I measured were same-step shaped. Review found the third. RESTORED, and it discriminates with all six clauses in: staged leg, legacy+shed, shipped _m12_new = VANISHED unit/shed/sign_tripped ...C5 narrowing removed _m12_new = [] ⚠️ suppressed ⚠️ AND MY FIRST RESTORATION DIDN'T DISCRIMINATE EITHER, for a reason worth recording: `data.pop(k, False)` RETURNS the value, so shed lost the KEY and kept the LATCH - `_grade_store_migration()` saw `on_roster_latched`, never re-armed garage, and there was no transition-made destination. The finding was reported whether or not C5 existed and the control looked restored. `and False` is the difference between "the key is gone" and "the LATCH is gone", and only the second reaches the re-arm this arrangement needs. ⚠️ WHAT ACTUALLY BROKE WAS M12's exit-0 PRECONDITION, NOT M12. A legacy-shaped fixture is never clean. That precondition is the one SAFETY-44's own commit called "belt-and-braces, load-bearing only if the exit-0 requirement is ever relaxed", saying what closed the gap is `_m12_new` BEING A SET DIFFERENCE - and a stable dirty baseline is fine for a set difference. So I deleted a control on the strength of a precondition the same commit had already called not load-bearing. That is removing coverage rather than failing to add it, which is the worse of the two directions. ⭐ AND THE BARE-KEY CLAUSE HAD NO ROW AT ALL. Review deleted `k.rsplit("/", 1)[-1] == bare` and the whole 28-file suite stayed GREEN. 5e mutated five clauses; the comprehension carries more, and this one is load-bearing: a vanished SAFETY-02 `sign_tripped` was suppressed by a SAFETY-42 `delivery_tripped` on a roster unit - a DIFFERENT FAILSAFE's key. The rule's own comment says "THE TEST IS NOT 'the name appears somewhere'. It is: the SAME bare key", and nothing measured that sentence. `unit/*/delivery_tripped` is machine-read, so it is reachable on any real two-latch store. R7 added; C2 now flips exactly R7. All six clause-removals now flip exactly their own row: C1 machine_read -> ['R6-machine-read'] C5 off-roster -> ['R2-narrowing'] C2 same bare key -> ['R7-bare-key'] C6 injectivity -> ['R5-injectivity'] C3 same value -> ['R4-value'] C7 provenance -> ['R3-provenance'] THREE DANGLING CITATIONS TO A CONTROL THIS PR HAD DELETED, one of them in the shipped tool eight lines below the paragraph condemning exactly that ("a citation is the worst possible carrier, because it reads as having been checked"). The tool no longer names a check; it states the property - every clause has a row, and each removal flips its own. A STALE SAFETY-44 BLOCK saying both fixes were "REVERTED OUT OF THIS CARD, because THIS SUITE CANNOT MEASURE EITHER ONE" - describing a tree this PR replaced. Rewritten as a map of where each clause is now measured. A comment that outlives its subject is the same defect as a citation that outlives its check. AND A SELF-CONTRADICTION about the driver, in the file and in the last commit message: "THE DRIVER IS WRITTEN INTO THE TREE UNDER TEST" eight lines above "THE DRIVER GOES IN A TEMPDIR, NOT IN THE TREE". The code does the tempdir; the first sentence stated the read-only-CI bug the same commit says it fixed, as if it were the design. It now says the driver IMPORTS the tree and is WRITTEN to a tempdir. ONE HOLE REMAINS OPEN AND IS STATED AS UNMEASURED: attribution. `sorted()` decides which vanished key claims a destination, so a deleted key can sort first and be named as the one that migrated while the key that really moved is reported VANISHED. Review measured that `sorted(` -> `list(` leaves the suite green - every 5e case has at most one candidate, so no row can see ordering. Still exit 1, so a wrong sentence rather than a hidden loss. Recorded rather than left looking guarded. 28/28 files pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |