During the night grid charge under the peak reserve the guard alarmed
(ERROR PEAK_GUARD_FLOOR_LIMITED, "Battery stopped limiting the peak") and the
Batteries "Why" said "The peak limit needs power" at quarter ends, while every
quarter landed on the aim. The charge lands the period 0-7 W over the aim and
excess_w()'s 900 / remaining turns that into tens of kW in the last second;
with nothing able to give, check_peak_floor_limited() alarmed on any
wanted > 0.
Each cycle the guard wants power, _peak_landing() asks misses_aim(): with the
batteries at 0 W, does the period end more than ALARM_MISS_W over the aim? If
not, the decision is within_aim: no reserve alarm, no red peak row, no "Why"
peak. The claim and the commands are unchanged. A real overrun under the
reserve alarms on the same cycle as before.
Co-Authored-By: Claude <noreply@anthropic.com>
Forecast.Solar's sensors stop updating in the evening, so at night every such
site had no forecast: the night cheap run needed 1.5x the cycle cost and
Leftover solar planned nothing. One missed poll also dropped it at once.
solarfc.Hold keeps the last fresh reading of each sensor, for the day it is
for. A missing or stale sensor is covered by it for up to FRESH_S (a short
outage), and from the end of the bell (20:00) until local midnight + 3 h
however old. A tomorrow figure is tomorrow's until midnight and then today's;
a today figure is only today's. Stored in the controller's store
(solar_fc_hold), so an evening restart keeps it. The forecast line says
'Solar forecast read 17:05, used for tonight's plan.' (EN, NL).
test_tariff24_weather.py runs with a hold that never covers: it tests what the
plan does when the forecast IS lost.
Co-Authored-By: Claude <noreply@anthropic.com>
Brings in 0.19.1 (TARIFF-24) and 0.19.2 (SAFETY-125). Git merged every file
without a text conflict. config.yaml's version becomes 0.19.2, the newest
CHANGELOG section: test_version_gate.py (PKG-14, 11b) requires the two to be
equal, and release/1.0 took 0.19.0 the same way (#207). The 0.19.1 and 0.19.2
CHANGELOG sections come in as history.
Co-Authored-By: Claude <noreply@anthropic.com>
Owner 2026-10-29: fix N=1 in this hotfix. _charge_headroom() no longer
returns early at one inverter, so the N=1 cheap-window maintenance charge
is held to charge_room_w() as at N=2; SAFETY-84's site-stop rule stays N>=2
only (at N=1 such a stop outranks the shaping anyway). The guard no longer
steps in and gives power at each quarter's end during that charge.
test_safety125.py: N=1 maintenance on 5 s and 10 s meters with late
readings, with and without a banked peak; the row in an N=2 maintenance
charge and in shadow mode; edge cases (blind past the hold, a start
mid-quarter, the summer-time change, the forecast in W, a 10 s meter, the
last second). test_safety84/87: three N=1 pins move. test_safety119: its
quarter-edge hand-back runs keep the old N=1 sizing as a fixture, plus a
check that with the new sizing nothing hands back. CHANGELOG, DECISIONS,
DOCS follow.
Co-Authored-By: Claude <noreply@anthropic.com>
A meter reading late near a quarter's end left seconds the projection did
not count (SAFETY-99), and charge_headroom_w() read them as room: the cheap
run (and price trading, and the N=2 maintenance charge) went back to full
power in the quarter's last seconds and the quarter could land over a banked
month peak or the cap. peakguard.charge_room_w() counts those seconds at the
held reading for the charge only; the guard's floor is unchanged.
The guard row said "idle, heading for 5 814 W, under this month's 5 413 W
peak". Over the limit at the current rate the row and the month-peak line
now say so, and that the grid charge is cut back.
Version 0.19.2, CHANGELOG, DECISIONS. test_safety125.py new; the lag pins
in test_price_run_fixes.py and test_opt20.py and the AST pin in
test_arbitrage.py follow the change (see the pull request).
Co-Authored-By: Claude <noreply@anthropic.com>
Losing the solar forecast mid-run used to widen the gap at once and stop a
grid charge (and a sensor that went unavailable for a minute stopped and
restarted it). A run under way when the forecast is lost now keeps the gap it
was planned with until it ends (Controller.weather_held(), recorded by
_wx_track() each cycle a forecast is read); the wider gap applies from the next
run. The plan key holds the hold state so the plan rebuilds when it ends. Home's
mirror (homeview.trade_verdict) asks the same weather_held(now).
Tests: test_tariff24_weather.py part 4 (going: the charge continues to the run's
end, the next run is not_worth; flap of one and two reads: no stop, no restart;
Home agrees) and a test_modes_p4.py scenario that runs a forecast day at a gap
between 1x and 1.5x the cycle cost. DECISIONS wording follows.
Co-Authored-By: Claude <noreply@anthropic.com>
With battery modes on, the price gap a mode needs was always widened x1.5 by
Uncertainty.NO_WEATHER, even with a solar forecast read, so a 0.08 day/night
gap never traded at the shipped cycle cost. The doubt is now added only when
no usable forecast is read (solar_fc is None, the day plan's own test), in the
run plan (_price_runs), the charge gate (_mode_roles) and Home's mirror
(homeview.trade_verdict). The rule is one helper, arbitrage.weather_uncertainty();
the modes-off trade path (main.py cycle) keeps its literal NO_WEATHER.
DOCS "Battery modes" sentence, CHANGELOG 0.19.1 line and the DECISIONS note
follow. Tests: test_tariff24_weather.py.
Co-Authored-By: Claude <noreply@anthropic.com>
With battery modes on, a day/night or fixed static_profile, no price feed and
arbitrage off, tariff_refresh_wanted() was false: no tariff task, no
tariff_plan, mode_runs None, so Leftover solar / Cheap hours never charged from
the grid and the Batteries screen said "No prices". packmode.enabled(opts) is
now a third term of the gate. With price_provider off the task is pure
arithmetic on static_profile, so no HTTPS call is added.
Also: version 0.19.1, CHANGELOG entry, DECISIONS note, test_tariff24.py, and a
shorter tariff_refresh_wanted() docstring (history stays in DECISIONS).
Co-Authored-By: Claude <noreply@anthropic.com>
A new tab beside Home and Batteries for today and tomorrow: the solar
forecast, the solar measured so far, the house's expected use and each
battery's expected level, with the plan's own charge and give blocks under it.
- Blocks are Home's: today's are the plan's `runs`, tomorrow's come from the
same builders (`homeview.runs_for()`), in the plan lines' words.
- The sun is what solarfc reads; a daily total is spread over the bell and the
tab says so. The house is HouseProfile.estimator(), as the plan reads it.
- The level is new: `daycurve.curve()` gives each battery the role
`modetrade.decide()` gives it and moves it hour by hour inside the plan's
floor and ceiling. It is an estimate and counts on `solar_trust_pct`.
- Built on request (`GET forecast`), no Home Assistant call. Measured solar is
kept per hour in memory from the reading control already takes.
- A part not known yet is a note: modes off, no forecast, learning, no
forecast for tomorrow, a monthly cycle, no level, stopped, shadow.
No decision, command, timing or option key changes. Found, not changed:
`dayplan.plan()` over-counts a state-only forecast (DECISIONS/UX-54).
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Modes on: Controller._mode_roles() files the trade gate it just asked as
mode_gate; while it refuses, Home draws no grid charge and marks no cheap
quarter. Modes off: a charge after the last dear period is asked of the same
gate at its own start (homeview.leg_taken) and dropped when refused there.
Words: "Lowest/Highest price left" (EN, NL); a flat remainder on a dynamic or
day/night site is no longer called a fully fixed product.
No change to control decisions, commands or options.
Co-Authored-By: Claude <noreply@anthropic.com>
TariffPlan.arbitrage(now=...) drops the points whose period has ended
(end <= now), so the cheapest and dearest point can still be traded.
decide() (modes off), the modes-on allowed() gate and the Home verdict
all pass their own now. Hourly feeds and the static tariff are unchanged.
The off-peak sentence and the charge/discharge sentences say "price
period". Tests: test_price02.py; test_arbitrage.py rolls its whole-day
plans to start at now; test_modes_p3.py part 1 digests leave the trade
sentence out (commands unchanged on 7a00f12, 90bc901 and this tree).
Co-Authored-By: Claude <noreply@anthropic.com>
Text only. "stroom te opnemen" -> "stroom op te nemen"; Developer Tools is
"Ontwikkelhulpmiddelen" in HA and ui_text; the green stuck-meter row no longer
starts "Vastgelopen meter:"; maintenance banner names the phase in Dutch;
double "dan" in the device note; plainer peak reserve, peak guard, refused
commands and missing-entity wording. Two stale comments now say the banner
and Home bodies follow the page's language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rows and banners are built once with their Dutch beside them (messages.Bi)
and status_payload() picks one by the page's language. Settings notes and the
wizard's checks carry a Dutch twin (Note.nl) that /config/validate picks by
the posted language. The peak forecast label drops "(W)" in EN and NL.
Co-Authored-By: Claude <noreply@anthropic.com>
TEST-23 F-1: on a 5 s meter a steady overload warned once in 70 min. The
loop was at its bound 4-5 cycles a quarter, all inside SAFETY-119's settle
of 12 cycles (60 s) and under its budget of 8 per 900 s. The simulation
fed the meter a cycle behind the battery reading, so the guard and the
charge chased each other and gave ~20 bound cycles a quarter.
The settle is now 4 cycles and 22 s (HANDBACK_CYCLES, SHORT_SPAN_S), and
the rolling budget one number, 11 per 900 s, at every cycle length. The
cycle length is no longer tracked. Commands unchanged; the re-pinned
digests of test_safety84/87 differ from bee7431 in OUTPUT_CLAMP rows only.
test_safety119 runs the rig's shape (meter in the same cycle) at 1-15 s
and checks a stop keeps the budget (review F2, X3).
Co-Authored-By: Claude <noreply@anthropic.com>
No single rolling budget met both cases in the sweep (8 false-alarmed on
2 s seed 25 and warned in 1 to 3 of 7 quarters at 15 s). The budget is
now 10 cycles up to a 3.5 s cycle, 8 up to 11 s, 6 up to 16 s, 4 beyond,
per rolling 15 minutes; 8 while the cycle length is unknown. A stop or a
gap over two minutes is not a cycle length. The unsourced "owner
accepted N=8" paragraph is removed from DECISIONS. test_safety119 part 4
now runs 2 s seed 25 and shows a fixed budget of 8 warns there; the
never case runs at 2 s and checks every other quarter at 15 s.
Co-Authored-By: Claude <noreply@anthropic.com>
The rolling budget now holds without a monotonic clock (it never refills).
Quarter-edge tests use seeds 1-3 at 2, 5, 10 and 15 s (no hand-picked seeds).
Re-pin comments point at part 6. DECISIONS lists the limits the owner
accepted with N=8 on 2026-10-20.
Co-Authored-By: Claude <noreply@anthropic.com>
At most 8 hidden bound cycles in any 900 s across all hand-backs, replacing
the per-run budget. Chosen from a sweep (N 3 to 10, 1 to 15 s meters, both
cases); DECISIONS has the table. Tests: never case at 5, 10, 15 s, quarter
edge at 2, 10, 15 s, the rolling budget at its boundaries.
Co-Authored-By: Claude <noreply@anthropic.com>
A hand-back used to restart the window, so a guard and a charge taking
turns hid the loop's integrator bound for good (0 warnings in 2 h; 28
before the fix). A run of hand-backs (the wire away less than a settle
between them) now hides the bound for at most 11 bound cycles and 22 s in
all, then it counts. The quarter-edge kick stays hidden. No command or text
changes. Tests for the two reviewer mutants that only unit checks caught
(a restart counted as a hand-back; span only), DECISIONS gives the delay at
2 s, 5 s and 60 s. test_safety87.py:543 shortened.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
When the peak guard or a maintenance charge hands the wire back, the loop's
integrator is re-seeded from that owner's command and kicked by a meter that
does not show it yet. It crossed its rail for a cycle or two and raised
goodwe_integrator_bound while the battery gave what it was told.
The loop's integrator bound is not counted until the plant's settle
(SHORT_CYCLES, SHORT_SPAN_S) has passed since the hand-back. An over-rating
always counts; a bound that lasts warns after the settle. No command
changes and no text changes. test_safety119.py; DECISIONS entry.
Co-Authored-By: Claude <noreply@anthropic.com>
The add-on is wired to the inverter's RS485 port labelled EMS. Reword
README, DOCS, config description, field guide source and HTML. The HA
manufacturer string is left unchanged: test_mqtt pins it byte for byte.
Co-Authored-By: Claude <noreply@anthropic.com>
A missing reading (unavailable, unknown, failed read, non-numeric) no longer
clears the fault: no false 'used again' INFO, no repeated WARNING, the status
row stays. Adds pv.has_reading(). DECISIONS wording fixed; tests added.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
The probe's trade was sized at the probing battery's rating. Its SAFETY-73
pin held 200 W of that, and the allocator put the rest on a battery with no
mode range (one in a maintenance phase): TEST-23 OPT-15 g7, -2300 W on A in
its maintenance charge for 34 s, grid 3100 W.
modetrade.decide() now asks PROBE_W for the probing battery plus what the
other chargers would have had without the probe; size() adds the probe on top
of their share, or files PROBE_W exactly, or nothing. _mode_trade() carries
the pin only on the ticks it is filed, so the window's first and last cycle
do not move another battery's share either. Modes off and modes on with no
probe are unchanged (bf9019e digests). Tests: test_opt20.py.
Co-Authored-By: Claude <noreply@anthropic.com>
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
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
- The notification, status row and Home line name an earlier day in the
language of the text ("13 okt om 21:00"); the Dutch title is "Prijzen
ontbreken", so the body no longer repeats it.
- The token request does not follow redirects: the host is fixed in code.
- A step back up to the saved prices is one INFO line.
- Tests: whole status payload free of the token, a redirect, the 5-decimal
edge of the old average, modes plan equal to generic_dayahead's.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
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 0ce7d35: one line differs,
the refusal that lists the known providers). See
DECISIONS/PRICE-01-own-price-server.md.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Two reviewer mutations survived the suite: the web layer routing only a
truthy unit (an empty unit then started the bare pick), and the page
showing the server's English instead of its own sentence by code. Both
are now checked. The bare maint_start comment that said a per-battery
unit would ship half a surface is replaced by a pointer to
_maint_unit_action(). No behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maint_start and maint_abort take an optional unit. With two or more batteries
each Batteries card has Start cycle and Stop cycle buttons. One slot per site
stays: a start while a battery cycles is refused and names it (no queue). A
battery that is switched off, an unknown unit, and a stop on a battery that is
not cycling are refused with a plain message. A request with no unit is the
old call. The scheduler and the phase machine are unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
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 e3e4ba6,
F1 reproduced at one inverter (SAFETY-91 already holds two), tests for the
drain and the day plan against the probe.
Co-Authored-By: Claude <noreply@anthropic.com>
The Supervisor proxy answers 502 to every add-on read, write and websocket
until Core is RUNNING (proxy.py api()/websocket() -> check_api_state()), so
the add-on cannot control while a slow integration holds HA's start, even
with its entities live. Measured on a safety112 repro stack with the
2026.08.0 proxy code: first write 1.0-4.4 s after RUNNING, 238-300 s after
live readings. Not routed around (DECISIONS/SAFETY-112-...).
While the API does not answer, the first status row is red and is the
banner ("Waiting for Home Assistant to finish starting"), Home shows a
"waiting" state in EN/NL instead of "Needs you now", and the log adds a
"still waiting" line every 5 minutes and the wait's length at the end.
A safety stop, a stalled loop or shadow mode keeps its own headline.
Control, writes, heartbeat and the stand-down are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 e36565f over 24 scenarios.
- Supervisor shape measured: tools/supervisor_shape_probe.py section J.
DECISIONS/OPT-17-several-forecast-entities.md has the rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
At one inverter SAFETY-52's "another writer" end tested target != loop_w.
A held refusal makes the loop ask 0 W, so any binding claim (maintenance
charge, peak guard, trade) ended it every cycle, and compute() learned it
again in the same cycle: "BATTERY REFUSES" / "ENDED: another writer" each
second on the rig. Now the held refusal is judged afresh against the
writer's ask (compute() gets the same inputs as before); it ends only if
compute() no longer finds it. Commands unchanged: 300-seed differential
trace against release/1.0, N=1 and N=2, every command and refusal state
identical. DECISIONS/SAFETY-107.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
- A price-provider site with no fixed tariff is not a gate (its plan never
sets `cheap`); DECISIONS §3 says so and a test pins it.
- The fixed-tariff check is worked out once an hour, not every tick.
- test_maint13: run() steps really are `step` minutes; the long loops use
5-minute steps (the per-tick cap); wall backstop checked "not before".
- test_safety93: the drain bound is the rig's configured drain rate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_static_tariff forbids main.py reading `static_profile`; a small
static_gated() in static_tariff.py answers it, and the Controller keeps the
answer in `_price_gated` beside `cheap`.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
- gate_open is true with no price gate set up, in a cheap window, or while the
pack is taking charge; the stall rule and timeout now work on a no-tariff site.
- reset the save timer on a phase change; exact 5 min gap-cap test.
- test_safety93: original timeout scenario kept (B finished) plus a case where a
due B takes the slot after A's failure.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
A charge is complete at SoC >= target, or at >= target - 3 once the SoC has
made no new high for 60 min of allowed charging, or when the charging time
runs out at >= target - 3. The 36 h timeout counts only the minutes the price
gate let the pack charge (plus a 7 day wall-clock backstop). A cycle that
fails (stuck drain, charge below target - 3) backs off 7 days, tells the
operator once (EN + NL), and no cycle starts on that pack in that time unless
the operator forces one. State survives a restart; old state files load.
No option added. DECISIONS/MAINT-13-full-pack-is-complete.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
F1: test_safety95 checks that a dropped draining battery is this rule's
(stale link, N=3, 4000 W: grid import 676 W vs base 786 W); kills R2.
F2: _charge_track tested directly with a contradicting drain (SAFETY-91's
pin as a floor against a closed pack); kills R3. Today a drain contradicts
only through a refusal; the clause stays, as a contradiction sends 0 W.
F3: a drain ends on a refusal only when the battery will not give
(refused > 0, as _held_back()); one that will not take still drains.
Tested both signs; kills R8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a battery's drain ended (done, Off, timeout, abort, stale link,
refusal), its drain floor stayed in the site target and landed on the
others: B 0 -> +1600 W in one cycle at a 1200 W house, exporting; under a
solar surplus B gave into the export.
Now, on that cycle, the loop starts from what the others stood at plus the
battery's new command, raised to the house's own point when the house needs
more (never above last cycle's start). While the battery settles, the loop
counts the batteries at its start instead of chasing the drain's tail on
the meter. Then one slew step a cycle. N=1 is unchanged.
test_safety95.py; DECISIONS/SAFETY-95-drain-end.md; one DOCS line.
test_modes.py: opt11 n2_maint re-pinned on purpose (its drain ends by a
refusal at 1655 s; B now stays at the house's 1420 W instead of 2500 W).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F1: resetting the count on each refused write hid a battery that really
refuses behind a link refusing 1 write in 3 (never learned; learned at
358 s with the count kept). F2: a board at 0 W before its link is stale
learned no refusal untested (mutation R7 survived); now a 1 s-lag case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At N>=2 a battery out of the split (stale link), with refused writes, or
settling after a step the loop did not make (a charge end, a rejoin) now
counts in the freeze test at its command, not its reading. Its real power
still reaches the loop through the meter. At a drop its share leaves the
loop's start point (the others take over one slew step a cycle); at a
rejoin it enters at its reading. No refusal is learned while a battery's
writes are refused. N=1 and the 46 identity digests are byte-identical.
test_safety78 part 2: the dropped-drain case now rests at B 1510 W (grid at
its -10 W aim) instead of 1500 W; the drop no longer sends B to 2500 W.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HA can take minutes to start (2026-10-05, 502 from the proxy). Then TZ holds,
which the Supervisor sets from HA's zone, so the hour stays right; only a TZ
of UTC there now logs a WARNING asking for a restart. A test pins the 3 s
budget on the zone request (a mutant without it survived).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the add-on is down the batteries are at 0 W (it sets 0 W when it
stops; the ESP32 watchdog after 30 s), so the import before its first
reading was the house, not the aim. Those seconds now count at
gap_w(limit, the import seen first), and after a restart the restored
reading's held 30 s is topped up the same way. Status row: "counted as
high use". Tests take the pre-start import as the house (batteries at
0 W) and add restarts of 10/60/150 s with a restored averager.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A meter gap past meter_max_age_s left seconds the quarter average never
counted, so the guard was blind for the rest of the quarter (rig case 7b:
3964.8 W against a 3450 W aim). Once readings are back, the missed seconds
now count at the higher of the limit and the import seen just before and
just after the gap with the batteries at 0 W (peakguard.gap_w/fill_gaps);
the gap's first meter_max_age_s is topped up to that too. Seconds before
the add-on's first reading count at the aim, and the page says part of the
period was not measured (EN/NL). The bill record is unchanged; no option.
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md, test_safety90.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
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>
A battery still in CHARGE but dropped by the allocator (stale link, or a
SAFETY-73 refusal contradicting its charge) left its -2000 W in the loop's
start point, and B went 0 -> -1600 W in one cycle. The end is now noted
after the unit claims are filed: in CHARGE, live and not contradicted.
Tests: part 6 (stale link, refusal) and a spy on the guard suspension.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At N>=2, when a battery's maintenance charge ended, its watts stayed in the
loop's start point and landed on the other battery (B 0 -> -1600 W in one
cycle, charging from the grid at a 6000 W house). On the cycle a charge
ends, the loop now starts from what the others stood at plus that
battery's new command. N=1 and all identity digests unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>