Commit Graph
273 Commits
Author SHA1 Message Date
glenn schrooyenandClaude 7433786a37 Merge master (0.19.1, TARIFF-24) into OPT-27 so the work has the weather hold to build on
release/1.0 (4a1f6cc) does not yet contain TARIFF-24 (PR #212, merged into
hotfix-0.19.1, then master 2932f3a). OPT-27 is about that code (the 1.5x
no-weather widening, _wx_track/weather_held), so it needs it. This is the
planned master -> release/1.0 merge's content, taken at 2932f3a; clean merge,
no conflicts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-29 20:21:32 +01:00
glenn schrooyenandClaude 97686319f3 TARIFF-24 round 3: a run planned with a forecast keeps its gap to its end
checks / unit (pull_request) Successful in 51m57s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-10-28 17:17:56 +01:00
glenn schrooyenandClaude 382b0ea411 TARIFF-24 round 2: a usable solar forecast replaces the "no weather" x1.5 (AUDIT-02 F1)
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>
2026-10-28 15:08:21 +01:00
glenn schrooyenandClaude d667f3f3a8 TARIFF-24: battery modes start the tariff task (hotfix 0.19.1)
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>
2026-10-28 01:02:32 +01:00
glenn schrooyenandClaude a0ace389a4 UX-54 round 6: the floor cut in one step, never from an empty battery
checks / unit (pull_request) Successful in 50m21s
infra-pin / fresh (pull_request) Successful in 1s
daycurve._floor_cut() set a cut from a battery already at the floor (its
cap 0 is below its rating), so a give run with one battery empty and the
other held to its rating was cut until MAX_FLOOR_CUTS ran out: 2,800 cuts
and 460 ms a build, and give blocks back at the hour edge after that.

The shortfall in a step is now shared as control shares it (by what each
battery holds above the floor, packshare.weights(), each at most its
rating, the rest passed on), in the step and in the cut. A battery giving
v kW that holds h kWh is empty at h / v; one at the floor holds nothing and
never sets a cut. The cut lands where the step then comes out exact, so it
is placed once. curve(stats=) reports the floor cuts and whether the bound
stopped one.

test_ux54.py: _floor_cut() checked to the second against floor times worked
out by hand (one battery, a group with one held to its rating, the second
group, the split by what they hold), a battery at the floor sets no cut,
the bound is reported; own_walk() with the reviewer's two cases (30/80 %,
guard, 7 kW house; 2 x 3 kW, 3.5 kW house) and, per build, the floor cuts,
the level points and the time.

No decision, command, timing or option key changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-27 19:03:56 +01:00
glenn schrooyenandClaude 8dbf831854 UX-54 round 5: give blocks end at the floor point, the tab on the clock-change days
checks / unit (pull_request) Failing after 50m11s
infra-pin / fresh (pull_request) Successful in 1s
daycurve.curve() puts a point where a giving battery reaches the floor
inside a step (_floor_cut(), as it already does for a grid charge reaching
its level), so _stops() ends a give block in the quarter control stops. A
walk where control moves the packs with its own model (not the curve's
levels) matches the tab quarter by quarter in 11 cases; 25/45 % with the
peak guard: control 17:00-20:15, block 17:00-20:06.

SolarToday keys an hour by its start taken back from the UTC instant, so
the two 02:00 hours of the autumn change stay apart. test_ux54_dst.py builds
the tab (forecast_payload()) at every quarter of 27-29 March and
30 October-1 November 2027, modes on and off, in the no-tzdata zone and,
where the host has tzdata, ZoneInfo, every call under a time limit raised
as a BaseException that forecast_payload() cannot swallow.

give_later: "No block shows while a battery has no energy to give." /
"Er verschijnt geen blok zolang een batterij niets kan leveren."
DECISIONS: what the walks prove, the modes-off charge drawn on a sunny day,
the clock-change days.

No decision, command, timing or option key changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-27 15:54:33 +01:00
glenn schrooyenandClaude 00c8ae354c Merge release/1.0 (f5afb42, SAFETY-123) into UX-54
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-27 15:30:52 +01:00
glenn schrooyenandClaude d2820e8b9b SAFETY-123: the day plan's hour loop steps in UTC and always ends
checks / unit (pull_request) Successful in 48m40s
infra-pin / fresh (pull_request) Successful in 2s
dayplan._hour_slices() added an hour to the local wall clock and read it
back. Without tzdata (tariff._EUTimezone) 02:00 on the spring change reads
as 00:00 UTC again, so the loop never advanced and the list grew until the
process died. Each step now adds the time left in the local hour to the UTC
instant. Ordinary days give the old slices; the spring day gives 23 slices
and the autumn day 25. No option, no text change.

test_safety123.py: every quarter of 27-29 March and 30 Oct-1 Nov 2027 (and
the US change days) for the slices, dayplan.plan() and
Controller._mode_roles(), each call under a hard time limit.

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
2026-10-27 08:58:54 +01:00
glenn schrooyenandClaude 4256bd3eda UX-54 round 4: modes off reads tomorrow at Home's count, no relabel in the walk
checks / unit (pull_request) Successful in 48m51s
infra-pin / fresh (pull_request) Successful in 2s
Modes off has no curve (no forecast is read, no house profile kept), so
dayview._levels() never ran there and tomorrow's legs were drawn from the
bound. homeview.trade_runs(ahead=f) now reads each leg at f(name, kind,
start), as mode_runs() does: no give while the emptiest battery is at the
floor, each share sized from that level. dayview._counted() gives a give
the level by Home's own count (the level now, plus what the blocks before
it put in, less what they take out); a charge is read from the floor,
because a full battery covers the house before the cheap hour.

The battery line says a give is planned when one is and the battery has
no give block, also with modes off.

test_ux54.py: the modes-on walk no longer relabels a GIVE role at the
floor; it asks control's own discharge envelope (packshare.envelope())
with each pack's cut-out at the floor. The peak-guard 20 % case asserts
the curve is at the floor at the 17:00 give start. A modes-off walk on a
cloudy day (arbitrage.decide() with the cycle's inputs, the packs moved by
what control does and by the house), cases A-D: control charges and gives
in exactly the quarters the tab has a block. trade_runs(ahead=f) unit
check. _stops() ends a give block where the level the tab shows is at the
floor (LEVEL_DP).

No decision, command, timing or option key changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-27 03:06:12 +01:00
glenn schrooyenandClaude 5ab8622265 UX-54 round 3: tomorrow's blocks last as long as control acts, give blocks checked at run start
checks / unit (pull_request) Successful in 48m22s
infra-pin / fresh (pull_request) Successful in 1s
- Tomorrow's grid block is drawn to where control stops charging. A cheap run
  is sized for all batteries together; a slower one is still below
  `charge_to()` when it ends and control plans the next cheap slots. The tab
  asks `modetrade.energy()` and `priceruns.plan()` with the packs at the
  curve's level and extends the run, so the curve charges as long.
- Each battery's block ends where it is at its own level (grid) or at the
  floor (give). The curve puts a point where a battery reaches its charge
  level inside a step.
- `homeview.mode_runs(ahead=f, rp=...)` reads each block at the level the
  curve has when it starts, with Home's own filter and MIN_KWH: no give block
  at the floor, no grid block at the planned level. Home never passes either.
- `no_tomorrow` names the hour from `solarfc.BELL_FROM_H`, where the curve
  stops.
- test_ux54.py part 7 compares control (`_mode_roles()` every 15 minutes) with
  the tab with no tolerance, and adds the cloudy / floor case.

No decision, command, timing or option key changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-27 00:53:58 +01:00
glenn schrooyenandClaude 75195f7073 UX-54 round 2: tomorrow's blocks where control will act, trust as the plan counts it, strings
checks / unit (pull_request) Successful in 48m25s
infra-pin / fresh (pull_request) Successful in 1s
F1: tomorrow's blocks no longer depend on the battery's level now. Home sizes
a block from the level now (`room()`, `plan_need()` in mode_runs, the leg
sizing in trade_runs) and draws none when there is no room or energy. For a
day that has not started the builders take `ahead=True` (Home never passes
it): a charge is read from the floor, a give from the ceiling. A grid block is
then kept only where `modetrade.chargers()` would charge at the curve's level
when the run starts. The UX-53 gate is unchanged. Today still equals Home; a
give run in the plan with no block (empty battery) has its own sentence
instead of "No charge or give blocks planned".

F2: test_ux54.py part 7 walks the real `_mode_roles()` every 15 minutes
through tomorrow and compares the roles with the tab's blocks (modes on, the
peak gate, part-full batteries), and the control cycle's verdict at 07:00
(modes off).

F3: the curve counts spare solar as trust x max(0, sun - house), stored at
EFF, as `dayplan.plan()` does (10 kWh sun, 2 kWh house, 70 %: 5.32 kWh).

Strings: cycle, no_tomorrow, ahead, no_level, measured; new give_later.

No decision, command, timing or option key changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-26 22:48:48 +01:00
glenn schrooyenandClaude b6b2d1b184 UX-54: a Forecast tab with the sun, the house and each battery's level, per hour
checks / unit (pull_request) Successful in 48m29s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-26 19:38:59 +01:00
glenn schrooyenandClaude 242cf434f4 UX-53: Home draws a grid charge only where the add-on will take it
checks / unit (pull_request) Successful in 48m7s
infra-pin / fresh (pull_request) Successful in 2s
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>
2026-10-26 11:15:29 +01:00
glenn schrooyenandClaude 891ed7bdd7 PRICE-02 round 2: require now, undo the test loosening, analyse ties
checks / unit (pull_request) Successful in 47m49s
infra-pin / fresh (pull_request) Successful in 2s
- TariffPlan.arbitrage(now=...) is required (keyword-only, no default); so are
  homeview.trade_verdict/refusal_key's now. Existing test calls pass one.
- test_arbitrage.py: no module-level decide wrapper. Fixtures that probed a
  one-day plan after its extremes now hold two days (as a site does); the
  flap day gets a unique cheapest and dearest hour; the replay breaks its
  ties without direction. The flap bound is <= 4 and the replay check is
  len(_trades) < 365 again. 228 checks pass on the old and the new code.
- test_price02.py part 6: ties (equal quarters, a real 15-minute day,
  alternating hourly prices) and the required now.
- Identity pins: the masked digest is only for the older-tree comparison;
  this tree's own digests are pinned with nothing hidden.
- DECISIONS: the tie analysis with numbers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 10:37:43 +02:00
glenn schrooyenandClaude 4297f7f3f1 PRICE-02: the trading verdict skips price periods that have ended
checks / unit (pull_request) Failing after 48m38s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-10-22 07:19:28 +02:00
glenn schrooyenandClaude Opus 5.5 ae0ef4296b UX-46 review: Dutch wording fixes on the NL rows and notes
checks / unit (pull_request) Successful in 45m44s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-10-22 04:56:30 +02:00
glenn schrooyenandClaude 7a392a5367 UX-46: Dutch status rows, banners, notes and wizard lines on the NL pages
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>
2026-10-22 03:21:41 +02:00
glenn schrooyenandClaude 8bf25696cb SAFETY-121: a recurring overload warns every quarter
checks / unit (pull_request) Successful in 44m51s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-10-21 10:09:14 +02:00
glenn schrooyenandClaude 6fd0fa05f1 SAFETY-119 round 3c: the hidden-bound budget follows the cycle length
checks / unit (pull_request) Successful in 43m29s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-10-21 00:57:43 +02:00
glenn schrooyenandClaude e27e62ff72 SAFETY-119 round 3b: no-clock budget, fixed-seed tests, accepted limits
checks / unit (pull_request) Successful in 43m1s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-10-20 20:46:14 +02:00
glenn schrooyenandClaude 103db2fcda SAFETY-119 round 3: rolling 15-minute budget for the hidden integrator bound
checks / unit (pull_request) Successful in 42m33s
infra-pin / fresh (pull_request) Successful in 2s
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>
2026-10-20 17:27:43 +02:00
glenn schrooyenandClaude e76c70ecb0 SAFETY-119 round 2: one settle window per run of hand-backs
checks / unit (pull_request) Failing after 42m32s
infra-pin / fresh (pull_request) Successful in 2s
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
2026-10-20 00:52:24 +02:00
glenn schrooyenandClaude c4e15ec4cd SAFETY-119: no "not keeping up" when the loop takes the wire back
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>
2026-10-19 14:14:08 +02:00
glenn schrooyenandClaude eb8375157d UX-45: count priced hours in the wizard, fill chart holes by the add-on's cover rule, mark only for price-mode batteries
checks / unit (pull_request) Successful in 40m20s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 10:01:58 +02:00
glenn schrooyenandClaude 17c6048417 UX-45: Home price chart draws one bar per price step the add-on uses
checks / unit (pull_request) Successful in 40m15s
infra-pin / fresh (pull_request) Successful in 1s
With 15-minute prices the chart now has one bar per quarter (92/96/100),
not hour averages; hourly and static tariffs still draw one bar per hour.
Cheap and dear marks come from the kept priceruns result (battery modes
on) or the arbitrage verdict's two points (off), not a thirds split.
A missing quarter is an empty bar. Removes plan.average_tip (EN/NL).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 00:35:29 +02:00
glenn schrooyenandClaude 4584502adb DOC-8: HA manufacturer says EMS port; rebuild field guide PDF
checks / unit (pull_request) Failing after 41m1s
infra-pin / fresh (pull_request) Successful in 1s
Device id (identifiers) is unchanged; only the display text moves.
test_mqtt baseline updated for that one string.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 01:53:33 +02:00
glenn schrooyenandClaude fd08090360 DOC-8: say EMS port, not meter bus
checks / unit (pull_request) Successful in 40m22s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-10-17 01:09:42 +02:00
glenn schrooyenandClaude 7d52dc1c0f SAFETY-118: a missing peak forecast reading keeps the unit-fault state
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 40m19s
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>
2026-10-16 18:19:44 +02:00
glenn schrooyenandClaude f0b4e68e49 SAFETY-118: read the peak forecast in its own unit
checks / unit (pull_request) Successful in 40m20s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-10-16 16:00:34 +02:00
glenn schrooyenandClaude 1bd2ba1397 UX-44: contract label says every 15 minutes, not hourly
checks / unit (pull_request) Successful in 40m37s
infra-pin / fresh (pull_request) Successful in 2s
The settings page contract choice read "Dynamic, hourly" / "Dynamisch,
per uur". Belgian day-ahead prices are per 15 minutes since 2025-10-01.
Now "Dynamic, every 15 minutes" / "Dynamisch, per kwartier". One DOCS
line that called dynamic pricing "hourly pricing" now says dynamic.
test_ui_text.py part 7 fails if either label says hourly again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 05:26:32 +02:00
glenn schrooyenandClaude 8c954acb36 UX-43: keep each field description to one sentence (STYLE)
checks / unit (pull_request) Successful in 40m31s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-16 02:29:31 +02:00
glenn schrooyenandClaude 8f72ab00d7 UX-43: tariff price fields take five decimals
ui.fixed_price, ui.day_price, ui.night_price and ui.periods step 0.00001
EUR/kWh (the period editor's price input took a hard-coded 0.01). A short
c-EUR/kWh hint is added to the four field descriptions, EN and NL.

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
2026-10-16 01:40:40 +02:00
glenn schrooyenandClaude 06339a2446 OPT-20: a refusal probe asks only PROBE_W, not the whole trade
checks / unit (pull_request) Failing after 40m47s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-10-15 13:08:59 +02:00
adminandClaude Opus 5.5 6862dea52a PRICE-01 review 3: tests for the round 3 note and dismiss; doc wording
checks / unit (pull_request) Successful in 39m4s
infra-pin / fresh (pull_request) Successful in 2s
- 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
2026-10-14 22:07:09 +02:00
glenn schrooyenandClaude c68fd275cc PRICE-01 round 3: replace the fixed-tariff notification with a log line and a settings note
checks / unit (pull_request) Successful in 39m6s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-14 20:27:15 +02:00
glenn schrooyenandClaude 0801776645 PRICE-01 round 2: the token on no page, fixed-tariff note, token length, F3 words, test gaps
checks / unit (pull_request) Successful in 39m0s
infra-pin / fresh (pull_request) Successful in 2s
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
2026-10-14 18:56:51 +02:00
glenn schrooyenandClaude 87ad958842 PRICE-01: Dutch dates, no redirects with the token, more checks
checks / unit (pull_request) Successful in 39m2s
infra-pin / fresh (pull_request) Successful in 1s
- 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
2026-10-14 16:54:11 +02:00
glenn schrooyenandClaude 9ac3a9d2bc PRICE-01: price_service provider, fallback chain, and the gate read from it
checks / unit (pull_request) Successful in 39m31s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-14 16:22:24 +02:00
glenn schrooyenandClaude 5ee1061398 MAINT-11 round 2: disable the cycle buttons that cannot work
checks / unit (pull_request) Successful in 38m43s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-13 23:18:21 +02:00
adminandClaude Opus 5.5 fd059126cb MAINT-11 review: test an empty unit at the web layer and the page's own refusal words; drop a stale comment
checks / unit (pull_request) Successful in 38m45s
infra-pin / fresh (pull_request) Successful in 2s
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>
2026-10-13 23:06:47 +02:00
glenn schrooyenandClaude 21d8a944bc MAINT-11: start or stop the maintenance cycle per battery
checks / unit (pull_request) Successful in 38m46s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-13 21:37:36 +02:00
glenn schrooyenandClaude 065792aad3 Merge release/1.0 (e3e4ba6) into opt15-price-run-fixes
infra-pin / fresh (pull_request) Successful in 3s
checks / unit (pull_request) Successful in 39m30s
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>
2026-10-13 12:15:44 +02:00
adminandClaude Opus 5.5 d94cb0c263 SAFETY-112: say plainly that control waits for Home Assistant to start
checks / unit (pull_request) Successful in 36m38s
infra-pin / fresh (pull_request) Successful in 2s
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>
2026-10-13 01:07:51 +02:00
adminandClaude Opus 5.5 92a80c09b2 OPT-17 review: title the forecast row on Home; test the hint and blank rows
checks / unit (pull_request) Successful in 36m20s
infra-pin / fresh (pull_request) Successful in 2s
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
2026-10-11 05:12:35 +02:00
glenn schrooyenandClaude Opus 5.5 9b1fa758ad OPT-17: the solar forecast can come from several entities, summed
checks / unit (pull_request) Successful in 36m13s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-11 02:55:58 +02:00
adminandClaude Opus 5.5 cd1bf2a694 MAINT-15: the balancing hold ends on time with no SoC reading
checks / unit (pull_request) Successful in 35m22s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-10-09 23:56:29 +02:00
glenn schrooyenandClaude Opus 5.5 d12228d986 SAFETY-107: a refusal held under another writer is learned once
checks / unit (pull_request) Successful in 35m8s
infra-pin / fresh (pull_request) Successful in 2s
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
2026-10-09 03:03:06 +02:00
glenn schrooyenandClaude Opus 5.5 ef35f4b925 MAINT-14: accurate no-level wording per phase; name the limit that ended the charge
checks / unit (pull_request) Successful in 34m54s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:25:35 +02:00
glenn schrooyenandClaude Opus 5.5 f39e52efd3 MAINT-14: maintenance with no SoC reading: charge times out, drain holds at 0 W
checks / unit (pull_request) Successful in 35m13s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:37:01 +02:00
glenn schrooyenandClaude Opus 5.5 6836f61bd4 MAINT-13 round 3: provider site, hourly fixed-tariff check, faster tests
checks / unit (pull_request) Successful in 34m52s
infra-pin / fresh (pull_request) Successful in 1s
- 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>
2026-10-08 12:03:55 +02:00