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>
The jittery-meter numbers depend on the draw (review: 4, 4, 5 of 11 at the
quarter edge; up to 4 quiet quarters at 20 s; first warning up to 61 min
later). Above about 40 s per cycle, a short bound can still use up the
budget when the wire changes hands every few cycles. Text only.
Co-Authored-By: Claude Opus 5.5 <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>
The quarter-edge numbers were measured with one cycle per reading at a
steady interval. With the default 15 s cycle floor, a meter that is
sometimes slower adds cycles on an old reading and the false alarm can
come back (reviewer's runs, 11 seeds). Text only.
Co-Authored-By: Claude Opus 5.5 <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
The delay is 11 cycles, so up to 11 min at a 60 s cycle. A loop that loses
the wire again within 11 cycles is never counted; review measured one such
case (N=2, cheap-window charge, no forecast, house just over the rating).
The over-rating rule applies on cycles the loop decided. Text only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
The re-pinned digests differ only in OUTPUT_CLAMP log rows; every command
and claim is the same as without the fix (test_safety119.py part 5).
DECISIONS: the delay at a 5 s meter and the open swap case.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
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>
- test_price_secret.py: the fixed-tariff note on the real /config/validate
route, EN and NL, and none for a spaces-only profile (web.py lang wiring).
- test_price_service.py: the start line is INFO, not WARNING; a dismiss that
raises does not stop the cycle.
- test_maint13/test_setup_only/test_watchdog: ignore only the dismiss of the
old card, so a create with that id is still counted.
- DECISIONS rule 7 quotes the settings note as it is; messages.py comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
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
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
Stop cycle is disabled on a battery that is not cycling. Start cycle is
disabled while any battery cycles and on a switched-off battery. Page only;
the add-on still refuses each of these. DECISIONS notes the one-battery case.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
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
Two survivors of the review's mutations get a check each in
test_price_run_fixes.py (X1: a held export credited negative in the rolled
quarter; X4: the hold extended by a reading already past E). DECISIONS: the
slow-battery quarter (3456 W, same on e3e4ba6) and the replay of a
maintenance phase starting during the hold.
Co-Authored-By: Claude Opus 5.5 <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>
- a 502 or refused read turns waiting on, the next good read off, and a
read timeout never (two mutants survived without this)
- the flag changes no write with live readings, not only with 502 ones
- the page script's amber dot and batteries-tab banner for "waiting"
- DECISIONS: about 5 minutes per start stage, not "at most"
Co-Authored-By: Claude Opus 5.5 <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>
The new status row's id `solar_forecast` had no Home title, so Home showed
it under the fallback "Inverter connection". Add "Solar forecast" /
"Zonneprognose" to ui_text, and checks for the title, the tomorrow-gap hint
and blank list rows (review mutations R7, R9, R14 now killed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
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
HOLD's time check moves into Maintenance._hold(), used by step() and
_no_soc(). A hold that loses its SoC now ends at hold_min, writes
last_completed and logs the same COMPLETE line. The 15 min HOLD line says
the hold still ends at its normal time. Behaviour with a reading is
unchanged (differential trace against db9e231).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
- 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>
The test table gives 1.5 kW at N=2 and 3.9 kW summed at N=3 under a
surplus, and about 1 kW after a lost link without one; not 1.9 kW.
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>
test_maint10.py part 10: at two inverters both packs get the site zone, the
real Controller is identical for 11 UTC and 13 Brussels, and two due packs
across 24/25 Oct 2026 and 27/28 Mar 2027 start at the local hour on
successive days; a pack freed in the repeated hour does not start there.
Part 3 adds 28 Mar 2027. DOCS: an existing install keeps its saved hour, now
read as local time; an hour set to make up for UTC goes back to 19.
DECISIONS: the SAFETY-83 gate is met; section on two inverters.
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>
Owner decision 2026-10-04 (review R1). A wrong idle reading more than
about 100 W off hid every refusal on that battery. Clamped under the band,
the band keeps 0 W +-25 W, so a battery reading about 0 W while refusing is
caught whatever was learned or restored. Samples are still taken to 250 W.
Beyond about +-170 W of idle a battery behaves as in 0.14.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>