- 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 5a4cdc2
digests; control still byte-identical. Unit checks for export and the
hold across a missing reading. test_peakguard's saturated fixture moves
the hold start back instead of waiting it out.
- DECISIONS and DOCS updated: costs of the hold, tolerance reasoning, the
double count in control left to a follow-up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Step 5 said "Choosing a mode for each battery comes in a later update",
stale since P1. It now points to battery modes on the Batteries screen
(EN/NL); test_setup checks the new line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner decision 2026-09-29. Selling is off until it can be judged on the price
the grid pays: packmode.SELLING_ENABLED = False makes read() answer
sell=False whatever the store holds, pack_sell is refused ("Selling is off
for now."), and the page has no selling switch; the sell code stays. Until
the P4 forecast, only Cheap hours only charges from the grid
(packmode.GRID_CHARGE_MODES); Leftover solar holds and gives in the
expensive runs. Help, Today and plan lines, DOCS and DECISIONS follow.
Replay, 16 days: Solar first + Leftover solar 19.44 EUR net (P2 19.33).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A sale block on the plan no longer says "Worth up to" at the import
price: a sale earns the lower injection price.
- The selling switch help (EN/NL) and DOCS say the grid pays less than
power costs; DECISIONS records why P3 loses on the replay.
- test_modes_p3 part 11: modes on ignore the site trade switch (R13), a
battery in maintenance takes no part (R10), a headroom stop latches for
the period (R8). All three mutants survived before and are red now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 7a00f12 on the trade path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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 ed739d4 and pinned; margin 0 still identical.
- test_margin_under_banked_peak; DECISIONS and DOCS updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A refusal past the log-once set or the 32 cap is counted in the log at
1, 10, 100 and so on, so a repeated attack stays visible.
- The logged topic is printable only (no forged log lines).
- mode_announcement(): the start-up store logic, now tested with a real
Store. A removed config topic stays stored, so a start with no broker
cannot forget it (rename case tested).
- A test ties each HA mode option to the page's English name.
- packmode.enabled() is tested for "true", 1 and "false".
- Retained-command text, DOCS and DECISIONS updated to match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
Each battery gets an MQTT-discovered Mode select and Use switch, only while
battery_modes_enabled is true. Commands go through handle_action(), the
/action path; refusals are logged once. Retained commands are never applied.
Turning modes off removes exactly the configs announced (kept in the store).
With modes off the announcement is byte-identical to ed739d4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 ed739d4 (tools/safety79_identity.py,
test_peak_margin.py part 1). validate.py rule 12 refuses a value outside
0-500, not whole, or not below the peak limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
- 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
Reviewed: a mode gives way to the peak floor only as far as it needs; SAFETY-03 kept at the rail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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); a5dddad digests pinned.
- tests: test_arbiter part 11, test_control (g), test_modes (0/1 refused,
loop bound counts live units only). DOCS and DECISIONS say the new rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
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 a5dddad at N=1 and N=2 (test_modes.py part 1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- test_ui_text.py: key parity, placeholder parity, English-left-in-Dutch,
semicolon, 25-word description and 2-sentence line each now fail on a
spoiled input (the English-left check was not a real mutation).
- Jargon lists now cover the whole 'Not' column of STYLE.md section 4, in
English, with the Dutch equivalents.
- nl.json: 'gewijzigd worden' where 'wijzigen' read as intransitive,
'Bezig met bewaren' for the saving state, 'Metingen kloppen niet'.
- test_uimodel.py: an overlap that changes partners is named piece by piece
(a surviving mutant of _tiling's partner check).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
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>
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>
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
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>
- 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
- REFUSED_SPAN_S: the 12 cycles of evidence must also span (SETTLE_CYCLES-1) *
LOOP_TICK_S = 22 s. At a 1 s meter, lagged healthy readings refused both
units 10-16 times an hour after load steps (site held 0 W). 2 s and slower
ticks are unchanged.
- REFUSES_ABOVE_CUT_OUT only for a refusal to give power: a full battery that
stops taking charge is always above its cut-out level.
- test_delivery: its wedged fixture (0.2 s cycles) no longer reaches a refusal;
the release/1.0 checks are restored and a span check added.
- test_refusal_units: lagged-reading and full-battery plants, two new checks,
U14/U15 in the mutation table. Stale "> 500 W" wording fixed. DOCS/DECISIONS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Trade blocks: none on a battery with no room, no sale at a negative price
or while the emptiest battery is at arbitrage's floor, nothing when a
level is unknown. A give after a charge may use what the charge put in.
- The refusal line names the real reason: prices not published vs gap.
- The plan's peak line uses the guard's ceiling (this month's peak once
above the cap), as the peak card does.
- House below zero: 0 W inside the idle band, no figure beyond it.
- Price bars are "Low price" / "High price": the add-on trades in one hour.
- Control toggle: one request in flight at a time.
- test_home: the new cases; the house-sign mutation now goes red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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
SAFETY-52's hold applied per unit at N>1 (owner, 2026-09-27, option R).
A unit that gives < 100 W for 12 cycles in one direction while asked
> 200 W, or trips its stale-reading gate on a ~0 W reading, gets a
P_SAFETY `refused` unit limit closing that direction, so the allocator
hands its share to the others within their own limits. Probe: a +-200 W
pin every 15 min, one unit at a time, only while the site asks 200 W that
way. All units refusing: the site law holds 0 W. Exits reseed the site
target from the wire. INFO plus one WARNING (REFUSES_ABOVE_CUT_OUT) above
the configured cut-out; SAFETY-42/43 no longer report a refusing unit.
N=1 unchanged.
test_refusal_units.py: cases a/b/c on flat and jitter registers against
the old behaviour, probes, recovery, exits; mutations U1-U13 all red.
TEST-19 no-fault campaign: 0 engagements in 5280 runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- uimodel: editing a listed inverter keeps the stored flat keys, and a
region of "own" keeps a stored price_taxes_profile (both were dropped).
- haentities: a failed read also waits CACHE_S, so polls while Home
Assistant is down do not queue one 10 s read each.
- ui_text: the Flanders preset names its DSO, and the help says
Wallonia and Brussels have no figures yet. REGION_PRESETS cites the card.
- tests: decode of a reversed solar row, both kept keys, the backoff.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>