Compare commits

..
55 Commits
Author SHA1 Message Date
glenn schrooyenandClaude bb57a85b35 OPT-34 round 3: the latch ends on time, with the run, with the mode, and with the night
checks / unit (pull_request) Successful in 20m15s
infra-pin / fresh (pull_request) Successful in 1s
- F1: an expired latch is dropped whatever the run state; plan_targets() and the
  store loader ignore one that has ended (no stale latch after a restart).
- F2: the "charge goes on" line only while a cheap run is on.
- F3: a battery that is off or no longer Leftover solar leaves the latch.
- F4: a run carries the latch on only if it starts by the bell start (06:00
  local) after the latch began (manager's call, 2026-10-31).
Tests: test_opt34 scenarios stale/stale_live and part 5; test_modes_p4 asks at a
moment inside the latch.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-31 23:04:36 +01:00
Claude (OPT-34 review) 57b0eb2f20 OPT-34 review: read the inverter in part 4 with a default
checks / unit (pull_request) Successful in 23m57s
infra-pin / fresh (pull_request) Successful in 2s
test_named_absence.py flagged next() without a default (CI run 1473).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:18:59 +01:00
Claude (OPT-34 review) 8f0af9f318 OPT-34 review: test that a battery leaving the plan leaves the latch
checks / unit (pull_request) Failing after 19m32s
infra-pin / fresh (pull_request) Successful in 1s
test_opt34.py part 4 drives Controller._latch_charge() directly: a battery
that leaves the plan is dropped at once, does not rejoin while nothing
charges, an empty latch is cleared from the store, and the screen says the
charge goes on only while the latched battery is below its level. Catches
two reviewer mutants that survived the round-2 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:50:02 +01:00
glenn schrooyenandClaude eee7616022 OPT-34 round 2: the latch follows the plan, up or down, and drops what left it
checks / unit (pull_request) Successful in 18m37s
infra-pin / fresh (pull_request) Successful in 1s
While a forecast is present and the cheap run holds, the night-charge latch is
rebuilt from the plan: a latched battery takes its current level whatever its
role this tick (a fresh figure may lower it below the SoC), one that left the
plan is dropped, a new one comes in only while it charges from the grid.
Test: a fresh brighter figure lowers A below its SoC, the sensors go quiet, A
does not charge back up. CHANGELOG summary says two fixes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-31 19:17:20 +01:00
glenn schrooyenandClaude 412e47f111 OPT-34: the night charge keeps its level when the kept forecast ends
checks / unit (pull_request) Successful in 18m59s
infra-pin / fresh (pull_request) Successful in 1s
With the forecast sensors silent from 17:05 the kept evening figure ended at
midnight + 3 h, in the middle of the night grid charge, and Leftover solar's
planned level went with it (rig: A 49.8 % against 79.9 %).

- A night grid charge that has started keeps its planned level when the forecast
  goes (Controller._latch_charge, store key night_charge_latch), and the narrow
  price gap with it (weather_held). A fresh figure still moves the level.
- The evening's tomorrow figure is today's until the bell starts (06:00 local),
  up to 15 h old but only if it was usable at midnight (solarfc.Hold.stand_in).
- Screen: "No solar forecast now. The charge under way goes on to its planned
  level." (EN/NL) while a latched battery is below its level.
- test_opt34.py (whole nights through the real control cycle, mutants), test_opt27
  and test_modes_p4 adjusted to the new window and the kept level.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-31 17:00:31 +01:00
admin deee98d352 Merge pull request 'SAFETY-126: no false peak reserve alarm during the night grid charge' (#221) from SAFETY-126 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-31 12:26:03 +01:00
glenn schrooyenandClaude a66a7f1a0f SAFETY-126 round 2: state what the 10 W tolerance costs; test the latch reset
checks / unit (pull_request) Successful in 18m11s
infra-pin / fresh (pull_request) Successful in 2s
Text only (DECISIONS, DOCS, CHANGELOG): a step load alarms on the same cycle
as before; a steadily rising load alarms a little later; with peak_margin_w 0
a period can end up to 10 W over the limit without this alarm, as for 'Load
too high' (SAFETY-81). Owner accepted 2026-10-31.

test_safety126.py part 2: after a real overrun ends, _peak_landing()'s latch
is reset and the night's later landings are quiet (review F3).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-31 11:52:27 +01:00
glenn schrooyenandClaude 8e2463bc32 SAFETY-126: test reads through named_absence; points at MUTATION.md
checks / unit (pull_request) Successful in 19m25s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-31 01:54:16 +01:00
glenn schrooyenandClaude 40595e42ae SAFETY-126: a landing error on the aim is not an overrun
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>
2026-10-31 01:26:02 +01:00
admin cc9bbeb73e Merge pull request 'OPT-27: the night cheap run is judged with the last fresh forecast' (#219) from OPT-27 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-30 22:16:32 +01:00
glenn schrooyenandClaude b9dea6d90d OPT-27 round 3: the kept-forecast wording states the 12 h limit (text only)
checks / unit (pull_request) Successful in 15m27s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-30 21:29:29 +01:00
glenn schrooyenandClaude 13b61d586a OPT-27 round 3: a kept figure is at most 12 h old; DST-safe midnight bound; pruning test
checks / unit (pull_request) Successful in 15m37s
infra-pin / fresh (pull_request) Successful in 1s
F1: the evening rule has an age limit (KEPT_MAX_AGE_S).
F2: the evening bound is the next local midnight (_bounds), right on 25 h and 23 h days.
F5: test that old days are dropped from the stored hold.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-30 20:41:34 +01:00
glenn schrooyenandClaude Opus 5.5 307cb47caf OPT-27 review: the kept forecast line says "kept for tonight"
checks / unit (pull_request) Successful in 17m20s
infra-pin / fresh (pull_request) Successful in 1s
With battery modes off the forecast is still read and shown (Settings,
Simple, and the Forecast tab), but no plan uses it, so "used for tonight's
plan" was not true there (UX-53). "Kept for tonight" is true in every
shape. NL "vanavond" read wrong after midnight; "vannacht" covers the
whole night. DECISIONS' "modes off: nothing reads the sensors" corrected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-30 19:59:29 +01:00
glenn schrooyenandClaude 60aa7f310c OPT-27: a kept figure sizes the night under OPT-28's shortfall, as a fresh one does
checks / unit (pull_request) Successful in 17m22s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-30 18:57:37 +01:00
glenn schrooyenandClaude bf842997c1 OPT-27 round 2: a kept tomorrow figure is bounded on both sides
Hold.stand_in(): day0 <= now < day0 + 3 h. A tomorrow sensor is asked with
day0 = the next midnight, so the upper bound alone was true all day and a
figure stood in at any hour. test_opt27.py covers daytime tomorrow outages.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-30 18:54:41 +01:00
glenn schrooyenandClaude 4fde0b177c OPT-27: test_modes_p4 says what a covered sensor does; test_opt27 guards
test_modes_p4.py: 'the forecast is lost mid-run' now loses it for good (a hold
that never covers); a second scenario is the same loss with the hold, which
keeps Leftover solar charging. test_opt27.py: a failing check no longer hides
the ones after it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-30 18:54:41 +01:00
glenn schrooyenandClaude 50b567d6d1 OPT-27: the night plan keeps the last fresh solar forecast
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>
2026-10-30 18:54:41 +01:00
admin f1b72443ff Merge pull request 'Sync 0.19.x into release/1.0: TARIFF-24 and SAFETY-125, with the forward-port fixes' (#220) from sync-0.19 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-30 18:51:53 +01:00
glenn schrooyenandClaude 60a3fa615e TARIFF-24 forward-port: part 6 reads its last row through nth() (test_named_absence)
checks / unit (pull_request) Successful in 28m5s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-30 08:30:25 +01:00
glenn schrooyenandClaude 1c9fefd9dd Sync 0.19: the Forecast tab extends a cheap run with control's gap (TARIFF-24)
UX-54's Forecast tab (release/1.0) re-plans tomorrow's cheap run as control
will, with the gap `required`. It still added the "no weather" x1.5 always,
while control (TARIFF-24, from master) drops it when a forecast is read. In
the UX-54 scenarios the tab asked 0.225 where control needs 0.15. It now asks
`arbitrage.weather_uncertainty(True, c.solar_fc)`, so the two agree again, as
they did on release/1.0 alone. Display only; no control change. The tab's
blocks in every UX-54 scenario, every 2 h, are identical to release/1.0.

test_ux54.py pins it (the old line fails it). The TARIFF-24 DECISIONS note
names the tab as the fourth place that reads the rule.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-30 08:11:42 +01:00
glenn schrooyenandClaude 8cca671a05 TARIFF-24 / SAFETY-125 forward-port: record text (review F2, F3, CHANGELOG nit)
- TARIFF-24 DECISIONS: the hold keeps a Cheap hours charge; a Leftover solar
  battery still stops without a forecast (it has no planned level, still true
  after OPT-28: `dayplan.plan()` returns None and `charge_to()` gives None).
  Adds "a run that has started runs to its end" for a give run begun in a hold.
- CHANGELOG 0.19.2: "when the period is over" -> "when the period would go
  over the limit".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-30 08:09:44 +01:00
glenn schrooyenandClaude 9dc22fb987 TARIFF-24 forward-port: pin the hold's edges (review F1, F2)
Part 6 of test_tariff24_weather.py, the review's probes R4 and R5:
- R4: a forecast lost while only the day's give run is under way holds to
  that run's end (22:00) and no further; the night's cheap run then needs the
  wider gap. Pins the `solar_fc is None` return in `_wx_track()`: without it
  the hold chains into the cheap run and B charges (mutant M2, now red).
- R5: a give run that starts during a hold runs to its end (`keep_started()`),
  with no grid charge after the hold. Behaviour unchanged; a give run not kept
  goes red.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-30 08:09:44 +01:00
glenn schrooyenandClaude 5ac1017eed UX-54 scenarios keep the gate they were written for (cycle cost 0.15 with a forecast)
With master merged in (TARIFF-24), a forecast replaces the 1.5x widening, so
the shipped cost 0.10 is now the whole gap with a forecast. test_ux54.py's
modes-on scenarios were written for 0.15 and 21 of its checks failed on
release/1.0 + master (they pass on release/1.0 alone). They now set the cost
to 0.15 where they have a forecast: the same gate as before. Scenarios with no
forecast (nofc, stale) and modes off keep 0.10, widened as before.
(cherry picked from commit 665653517c, branch OPT-27)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-30 08:09:44 +01:00
glenn schrooyenandClaude 8bf54ece3d Merge master (0.19.2, f1c9733) into sync-0.19: TARIFF-24 and SAFETY-125 reach release/1.0
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>
2026-10-30 08:09:44 +01:00
admin f1c9733c32 Merge pull request 'Release 0.19.2: SAFETY-125 grid charge stays under the month peak' (#217) from hotfix-0.19.2 into master
Merge hotfix-0.19.2 (SAFETY-125): release 0.19.2
2026-10-30 07:44:25 +01:00
admin b6be31286a Merge pull request 'OPT-23, OPT-28: the day plan counts the sun right, and the night charge covers the expensive-hour shortfall' (#218) from OPT-23-28 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-30 02:55:55 +01:00
admin 6be7e41de1 Merge pull request 'CI-15: run the add-on's test files in parallel' (#215) from CI-15 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-30 00:29:18 +01:00
glenn schrooyenandClaude Opus 5.5 ae0cd542c0 OPT-23-28 review: the unheld-shortfall line says another battery or the grid
checks / unit (pull_request) Successful in 50m6s
infra-pin / fresh (pull_request) Successful in 1s
With A on Leftover solar and B on Cheap hours (the dynamic scenario, and the
owner's planned setup), the line said "The batteries cannot hold it all. The
grid covers N kWh", while B holds a full charge for the same hours. It now
says "The expensive hours need more than Leftover solar can hold. The rest,
N kWh, comes from another battery or the grid." (NL likewise). DOCS,
DECISIONS and the dayplan/homeview docstrings say the same. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-29 23:21:31 +01:00
Claude a645d899f4 CI-15: test_safety87.py joins the serial group (real-clock meter freshness)
checks / unit (pull_request) Successful in 16m3s
infra-pin / fresh (pull_request) Successful in 2s
Its status page says "Meter: no reading for N seconds, limit 30 seconds" when
N passes 30; N is about half the file's run time. It failed alone under
--cpus 0.15 (94 s), in a loaded local parallel run (103 s) and in a loaded CI
run (112 s, run 1434). Idle it takes ~25 s. No test file changed, so it runs
alone after the parallel batch, like test_loop.py and test_freeze.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-29 21:52:58 +01:00
glenn schrooyenandClaude 995028bc5d OPT-23, OPT-28: the day plan counts the sun right, and the night charge covers the expensive-hour shortfall
checks / unit (pull_request) Successful in 55m9s
infra-pin / fresh (pull_request) Successful in 1s
OPT-23: a state-only forecast is read from now for every span
(Forecast.between since=), so the plan's per-hour sun adds up to the day's
figure, today and tomorrow. Hourly splits are unchanged. The Forecast tab
reads spans the same way.

OPT-28: a Leftover solar battery buys, from the floor, the sum over the
expensive hours of max(0, house - trust x sun), / EFF. Several Leftover
solar batteries hold it together by capacity, the ceiling passes the rest
on, and what none can hold is said (Plan.uncovered_kwh). Home's grid block,
the Batteries line and the mode help (EN + NL) say the level, what it
covers, and that Leftover solar buys only what the sun will not give.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-29 21:02:18 +01:00
Claude ffb0d61d5b CI-15: run the add-on's test files six at a time, the wall-clock ones alone after
checks / unit (pull_request) Successful in 19m52s
infra-pin / fresh (pull_request) Successful in 3s
The serial loop summed 3 557 s on SAFETY-125's run and passed the runner's
60-minute cap. The loop moves to .gitea/ci/run-tests.sh: xargs -P 6, longest
files first, then test_loop.py and test_freeze.py (TEST-30 / CI-06) alone on an
idle machine. Same PASS/FAIL lines, CI: echo, tail -40, :ro mount,
PYTHONDONTWRITEBYTECODE=1, GOODWE_REQUIRE_HWSIM=1 and NO test_*.py FOUND guard;
one output file per test file; any failing or unreported file fails the job.
timeout-minutes 70 -> 40, comment corrected. No test file changed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-29 20:39:20 +01:00
admin 8966867968 Merge pull request 'SAFETY-125: a grid charge stays under the peak aim (hotfix 0.19.2)' (#214) from SAFETY-125 into hotfix-0.19.2
checks / unit (pull_request) Successful in 1h3m7s
infra-pin / fresh (pull_request) Successful in 2s
2026-10-29 18:21:15 +01:00
glenn schrooyenandClaude 2929ece7da SAFETY-125 round 2: the one-inverter maintenance charge is sized to the room too
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 1h0m42s
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>
2026-10-29 14:57:39 +01:00
Claude (SAFETY-125 review) 6eebd4882b SAFETY-125 review: guard row and Home line within UX-08 limits; N=1 maintenance named
checks / unit (pull_request) Successful in 56m45s
infra-pin / fresh (pull_request) Successful in 1s
- The over-the-limit rows ran over STYLE's 15 words (EN 16/19, NL 16/21)
  and two Dutch Home sentences over 12 words; they were not in the samples,
  so test_messages/test_ux46 never saw them. Now:
  "Peak guard: grid charge limited, at this rate 5 814 W, over 5 413 W" /
  "Piekbewaking: laden beperkt, aan dit tempo 5 814 W, boven 5 413 W",
  "...idle, at this rate ..., over ..." / "...in rust, aan dit tempo ...",
  NL Home "Laadt minder van het net om onder 5,4 kW te blijven." and
  "Zou minder van het net laden om onder 5,4 kW te blijven."
  The rows and the line are added to the samples. The under row is unchanged.
- CHANGELOG known issues and the DECISIONS note: at N=1 the maintenance
  charge is not sized to the room, so SAFETY-99 still applies to it.
- DOCS: the peak guard row's new words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:56:01 +01:00
glenn schrooyenandClaude 88ab8a50e3 SAFETY-125: a grid charge counts a late meter reading's seconds; the guard row says "over" when it is
checks / unit (pull_request) Successful in 56m44s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-10-29 02:08:38 +01:00
admin 2932f3af39 Merge pull request 'Deploy 0.19.1' (#213) from hotfix-0.19.1 into master
Reviewed-on: #213
2026-10-28 23:29:06 +01:00
adminandClaude Opus 5.5 7e77c723b9 Merge pull request 'TARIFF-24: battery modes start the tariff task; a forecast replaces the 1.5x widening' (#212) from TARIFF-24 into hotfix-0.19.1
checks / unit (pull_request) Successful in 51m58s
infra-pin / fresh (pull_request) Successful in 2s
Reviewed head 9768631 (Opus review). CI run 1422 green on that sha.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:14:44 +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 d424b862d8 TARIFF-24 round 2: test_modes_p4 scenarios keep the 0.30 gate they were written for
checks / unit (pull_request) Successful in 49m18s
infra-pin / fresh (pull_request) Successful in 1s
With a forecast read the gap needed is the cycle cost (0.20), not 1.5 x (0.30),
so a midday 0.21 gap now plans a grid top-up that four scenarios (sunny, Cheap
hours partly, forecast too high, refusal) are not about. They ask for cost 0.30
with the forecast; the no-forecast leg keeps 0.20 (= 0.30 widened).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 15:42:50 +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 230ba0b57a TARIFF-24: test file follows the repo's suite rules (MUTATION.md pointer, named absence)
checks / unit (pull_request) Successful in 49m23s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 01:09:39 +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
admin 4a1f6cc2d9 Merge pull request 'UX-54: a Forecast tab with the sun, the house and each battery's level, per hour' (#210) from UX-54 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-27 21:07:00 +01:00
admin 793a67623c Merge pull request 'Deploy 0.19.0' (#208) from deploy-0.19.0 into master
Reviewed-on: #208
2026-10-26 08:47:42 +01:00
admin ad374ae208 Deploy 0.18.2 to master (#200) 2026-10-17 01:59:54 +02:00
admin ec2b0c3de4 Deploy 0.18.1 to master (#197) 2026-10-16 15:05:06 +02:00
admin 3b7d6b7f05 Deploy 0.18.0 to master (#193) 2026-10-16 00:42:54 +02:00
admin 1b6f16dba2 Deploy 0.17.0 to master (#186) 2026-10-11 01:37:01 +02:00
admin df154a26d1 Deploy 0.16.0 to master (#179) 2026-10-08 01:43:37 +02:00
admin 21599938ea Merge pull request 'Deploy 0.15.0 to master' (#174) from deploy-0.15.0 into master
Deploy 0.15.0 to master (#174)
2026-10-05 10:17:38 +02:00
admin 3a54b253cb Merge pull request 'Deploy 0.14.0 to master' (#170) from deploy-0.14.0 into master
Deploy 0.14.0 to master (#170)
2026-10-03 12:32:16 +02:00
admin 8b9680fc35 Merge pull request 'Deploy 0.13.0 to master' (#166) from deploy-0.13.0 into master
Reviewed-on: #166
2026-10-02 15:26:46 +02:00
admin 3ce18708b6 PKG-18: ship 0.12.0 to master, so TEL-12's pv_entities can reach an installed 0.11.0 (#118) 2026-09-24 23:22:34 +02:00
adminandClaude Opus 5 78acb558ff PKG-17: ship 0.11.0 to master, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1 (#115)
master is what the Home Assistant Supervisor clones, and the Supervisor
compares config.yaml version strings - so 0.11.0 could not be offered to an
installed 0.10.1 without this bump reaching master.

Carries SAFETY-56 (#112), TEL-11 (#113), the CI-9 follow-up (#102) and SEC-2's
docs/RELEASE.md (#108), by way of the 0.11.0 cut (#114).

NOT A FAST-FORWARD. master was not an ancestor of release/1.0: 0f14c2a, the
pre-SEC-2 direct push, exists only on master. Its tree is the same object as
the merge base b0b24e5 (c1fef26), so it carries no content delta, and
`git merge-tree --write-tree` over the two branches returns a tree bit-identical
to cccb2fa's own (6670502). This merge commit therefore contributes exactly
release/1.0's tree - master synthesises nothing.

Reviewed as the release decision, not a re-review of content. Verified from the
remote: slug goodwe_controller and every identity key unchanged (cross-checked
against the live box's Supervisor export), version 0.10.1 -> 0.11.0, and the
green is this PR's own - run 828 / job 1953, path checks.yml@refs/pull/115/head,
PRNUM 115, GRADED refs/pull/115/head merged into master, run_attempt 1, 31 test
files all passing in 8m01s of real work.

predeploy_check.py re-run against the owner's current options (exported
2026-09-24 20:02, 75 keys, pv_entity unset): CLEARED, exit 0. That retires the
stale-2026-09-07-export warning in the pull request body. Option surface
75 -> 76 with only pv_entity added, str? defaulting to "", no key removed, no
default moved, no rule added to validate.py - no SystemExit(78) on this hop.

Merging is not installing. No tag is cut here and `ha store reload` /
`ha addons update` have not been run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 20:28:35 +02:00
glenn schrooyenandClaude Opus 5 0f14c2a5de PKG-16: ship 0.10.1 to master, so the Supervisor can offer SAFETY-57's fix
master is what Home Assistant clones. SAFETY-57 fixed a deadlock that parked
the reference install's battery for ~13 minutes while the site exported
2.4 kW, and the Supervisor compares config.yaml version strings - so the fix
could not reach an installed 0.10.0 without this bump reaching master.

Brings SAFETY-57 (#104) and the 0.10.1 cut (#105). master was a strict
ancestor; no conflict was possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:54:29 +02:00
39 changed files with 5288 additions and 229 deletions
+125
View File
@@ -0,0 +1,125 @@
#!/bin/sh
# CI-15: run the add-on's test_*.py files, in parallel, inside the CI container.
# Called by the step "the add-on's own tests" of .gitea/workflows/checks.yml as
# sh -eu /w/goodwe-addon/.gitea/ci/run-tests.sh
# and runnable the same way from a clone (see TESTS_DIR below).
#
# WHY: the serial loop ran past the runner's 60-minute limit (SAFETY-125: 85 files,
# 3 557 s of test time, run 1430 cut off at file 84). The host has 12 cores.
#
# SHAPE
# 1. A parallel batch: JOBS files at a time (xargs -P), longest first.
# 2. THEN the serial group, one at a time, after the batch has finished, so
# each runs on a machine with nothing else of ours on it.
# Every file writes to its own $RUN_DIR/<file>.out (there is no shared output
# file) and prints its result as ONE block under a lock, so the PASS/FAIL
# lines and the `CI: ` / tail -40 text of two files never interleave.
#
# THE FILE LIST IS STILL DERIVED FROM THE TREE (see checks.yml). The two typed
# lists below only SORT or SET ASIDE files that exist; a new test_*.py that is
# in neither list is run, in the parallel batch. A name that no longer exists
# in a list is harmless. Nothing can make a file silently not run.
#
# SERIAL GROUP: files whose assertions depend on the wall clock, so CPU load
# from siblings could flip them. Name and reason, one each:
# test_loop.py drives the real run_control() against a real wall-clock
# floor (~93 s on purpose); worst inter-cycle gap 3.0 s
# against a 4.5 s bound (TEST-30, CI-06).
# test_freeze.py part 3 runs the real sim/hwsim.py over a transport with a
# 16 s window, a 10 s hold and an alarm measured at 11.0 s:
# one poll of margin, and "failed 1 run in 3" on the rig
# transport (TEST-30, CI-06).
# test_safety87.py builds a controller (CH65) whose meter reading is stamped
# on the REAL clock, and section 8 asks its status page,
# which says "Not ready - Meter: no reading for N seconds,
# limit 30 seconds" once N passes 30. Measured by CI-15: N is
# about half the file's run time (N = 45 s in a 94 s run, 48 s
# in a 103 s run). The file takes ~25 s idle and took 45 s in
# CI's first parallel run, so N was ~20 s of the 30. It FAILED
# alone under --cpus 0.15 (94 s) and in a loaded parallel run
# (103 s). No test file is changed here, so it runs alone.
# Add a file here only with a measured reason, and say it in this list.
SERIAL="test_loop.py test_freeze.py test_safety87.py"
#
# LONGEST FIRST (SAFETY-125's CI run, job 2580; then a local parallel run, seconds:
# modes_p4 758, safety126 634 (SAFETY-126, local, one core), safety125 508 on hotfix-0.19.2 only, modes_p3 364, safety119 328,
# price_run_fixes 184, safety90 160, safety103 134, safety94 114, peakguard 113,
# opt20 102, safety97 101, safety81 100). With JOBS workers pulling from one
# queue, starting the longest files first keeps the tail short: the batch takes
# about as long as the longest file. A sort order, not a selection.
HEAVY="test_modes_p4.py test_safety126.py test_safety125.py test_modes_p3.py test_safety119.py test_price_run_fixes.py test_safety90.py test_safety103.py test_safety94.py test_peakguard.py test_opt20.py test_safety97.py test_safety81.py"
#
JOBS=${CI_JOBS:-6}
cd "${TESTS_DIR:-/w/goodwe-addon/goodwe_controller}"
# one FILE: run it, keep its output in its own file, print its block whole.
if [ "${1:-}" = "--one" ]; then
f=$2
o="$RUN_DIR/$f.out"
start=$(date +%s)
if python3 "$f" > "$o" 2>&1; then rc=0; else rc=1; fi
secs=$(( $(date +%s) - start ))
{
if [ $rc = 0 ]; then
echo "PASS $f (${secs}s)"
grep "^CI: " "$o" || true
else
echo "FAIL $f (${secs}s)"
tail -40 "$o"
fi
} > "$RUN_DIR/$f.blk"
echo $rc > "$RUN_DIR/$f.rc"
flock "$RUN_DIR/print.lock" cat "$RUN_DIR/$f.blk"
exit 0
fi
set -- test_*.py
if [ "$1" = "test_*.py" ]; then
echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked."
exit 1
fi
echo "$# test file(s) to run: $JOBS at a time, then the serial group"
RUN_DIR=$(mktemp -d /tmp/ci-tests.XXXXXX)
export RUN_DIR
: > "$RUN_DIR/par"
: > "$RUN_DIR/ser"
for f in $HEAVY; do
case " $SERIAL " in *" $f "*) continue ;; esac
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/par"; fi
done
for f in "$@"; do
case " $SERIAL $HEAVY " in *" $f "*) continue ;; esac
echo "$f" >> "$RUN_DIR/par"
done
for f in $SERIAL; do
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/ser"; fi
done
echo "parallel: $(wc -l < "$RUN_DIR/par") file(s); serial: $(wc -l < "$RUN_DIR/ser") file(s)"
t0=$(date +%s)
rc=0
# `exit 0` in the worker: a failing FILE is recorded in its .rc, so a non-zero
# from xargs itself means the machinery broke, which is a failure too.
xargs -n 1 -P "$JOBS" sh -eu "$0" --one < "$RUN_DIR/par" || rc=1
echo "parallel batch done in $(( $(date +%s) - t0 ))s; serial group next"
while read -r f; do
sh -eu "$0" --one "$f" || rc=1
done < "$RUN_DIR/ser"
# Every file that was meant to run has a verdict, and every verdict is PASS. A
# worker that died before writing its .rc leaves no file, which counts as a fail.
ran=0
bad=0
for f in "$@"; do
ran=$((ran + 1))
if [ ! -f "$RUN_DIR/$f.rc" ]; then
echo "FAIL $f (no result: the run never reported)"
bad=$((bad + 1))
elif [ "$(cat "$RUN_DIR/$f.rc")" != 0 ]; then
bad=$((bad + 1))
fi
done
echo "$ran file(s), $bad failed, $(( $(date +%s) - t0 ))s wall"
if [ "$bad" = 0 ] && [ "$rc" = 0 ]; then exit 0; fi
exit 1
+15 -23
View File
@@ -209,7 +209,7 @@ on:
jobs:
unit:
runs-on: [self-hosted, goodwe-rig]
timeout-minutes: 70 # ~4 to run; the rest is queueing for the single slot
timeout-minutes: 40 # ~15 to run (CI-15: 6 files at a time; the serial suite was ~60 and hit the runner cap); the rest is margin for a busy host
env:
# ⚠️ UNDER /home/homelab, NOT $GITHUB_WORKSPACE (ENV-01, and re-measured
# during CI-01). act's workspace is `/root/.cache/act/<id>/hostexecutor`, a
@@ -534,6 +534,19 @@ jobs:
# shorter timeout - a 60 s limit was tried during CI-01 and reported
# exactly the false hang the docstring predicts.
#
# ⚠️ CI-15: THE FILES RUN SIX AT A TIME, NOT ONE AFTER ANOTHER. The serial
# loop summed 3 557 s on SAFETY-125's run (job 2580) and ran past the
# runner's 60-minute cap. The loop now lives in .gitea/ci/run-tests.sh (it
# comes from the merged tree, like the Dockerfile), so the same script runs
# locally and here. It runs the longest files first, then the files whose
# assertions depend on the wall clock - test_loop.py and test_freeze.py
# (TEST-30 / CI-06) and test_safety87.py (CI-15) - ALONE, after the
# parallel batch, so no sibling can steal their CPU. The reasons are in
# the script. Its output stays one
# PASS/FAIL block per file, in a file per file (never one shared /tmp/out).
# A file that is not in the script's two typed lists still runs, in the
# parallel batch: the list is a sort order, not a selection.
#
# test_freeze.py part 3 runs against the real `sim/hwsim.py` from the
# checkout above; GOODWE_REQUIRE_HWSIM=1 makes it fail rather than use its
# stub (CI-12). Lines a test prints with a `CI: ` prefix are echoed on PASS.
@@ -552,28 +565,7 @@ jobs:
- name: the add-on's own tests
run: |
set -eu
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
cd /w/goodwe-addon/goodwe_controller
set -- test_*.py
if [ "$1" = "test_*.py" ]; then
echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked."
exit 1
fi
echo "$# test file(s) to run"
rc=0
for f in "$@"; do
start=$(date +%s)
if python3 "$f" > /tmp/out 2>&1; then
echo "PASS $f ($(( $(date +%s) - start ))s)"
grep "^CI: " /tmp/out || true
else
echo "FAIL $f ($(( $(date +%s) - start ))s)"
tail -40 /tmp/out
rc=1
fi
done
exit $rc
'
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu /w/goodwe-addon/.gitea/ci/run-tests.sh
- name: clean up
if: always()
+1 -1
View File
@@ -107,7 +107,7 @@ on real hardware, and the comments in `control.py` say which.
### CI and the infra pin
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py`, with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py` (six at a time, the wall-clock ones alone afterwards: `.gitea/ci/run-tests.sh`), with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
The pin tracks `admin/goodwe` **release/1.0**, not master. Two non-required checks go red when release/1.0 has moved past the pin: `infra-pin / fresh` here, on any add-on PR or push to release/1.0, and `addon-pin / fresh` in `admin/goodwe`, on every push to its release/1.0. Nothing re-runs `addon-pin` after a bump, so it stays red on that admin/goodwe commit; `infra-pin / fresh` green on the bump is the all-clear.
**Bump the pin** whenever either is red. Set `.gitea/infra-pin` to the new release/1.0 SHA (`git ls-remote <gitea>/admin/goodwe.git refs/heads/release/1.0`) in a PR, and let `checks / unit` grade the tests against it. CI reads `admin/goodwe` with the Actions secret `INFRA_READ_TOKEN`.
+60
View File
@@ -1,5 +1,65 @@
# Changelog
## 0.19.2
### Not in a release yet
- The night grid charge no longer raises "Battery stopped limiting the peak" when the 15-minute period stays on the aim. A steadily rising load now alarms a little later, and with a peak margin of 0 a period can end up to 10 W over the limit without this alarm ([[SAFETY-126]]).
- A night grid charge for *Leftover solar* no longer stops at 03:00 when the solar forecast sensors went quiet in the evening. The evening's figure is used until 06:00, and a charge that has started goes on to its level ([[OPT-34]]).
**A patch bump: two fixes, one for charging from the grid under the peak limit and one for the night charge of Leftover solar, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options).
**Existing installs need no option change:** options that start 0.19.1 also
start 0.19.2. The change below applies with one inverter or two.
**Charging from the grid now stays under this month's peak.** A late meter reading near a period's end could push a grid charge to full power. That could take the 15-minute period over the peak. Now the charge is cut back, with one inverter too. The peak guard line no longer says "under" when the period would go over the limit.
Contents of this release, in merge order since 0.19.1 (`2932f3a`):
| | |
|---|---|
| `SAFETY-125` | A grid charge (a battery mode's cheap run, price trading or the maintenance charge, with one inverter or two) counts the seconds since a late meter reading at that reading, so it is cut back near the end of a 15-minute period instead of going back to full power. With one inverter the maintenance charge is now held to the room left in the period, as with two, so the peak guard no longer steps in and gives power at the period's end. Over the limit at the current rate, the peak guard row and the month peak line say so, and that the grid charge is cut back ([[SAFETY-125]]). |
### Known issues
The list under `## 0.19.0` below still applies. The SAFETY-99 line there now
covers the guard's discharge only, not a grid charge.
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.19.1
**A patch bump: two fixes for battery modes, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options).
**Existing installs need no option change:** options that start 0.19.0 also
start 0.19.1. The changes below apply with one inverter or two.
**Battery modes now see a day/night or fixed tariff.** With battery modes on, a day/night or fixed tariff and no price service, the batteries never got the prices: "Cheap hours" never charged from the grid at the cheap rate, and the Batteries screen said "No prices". They get them now, with no price service and no internet call added.
**A solar forecast now counts for the price gap.** With battery modes on and a solar forecast read, a price mode needs a gap of at least *Battery cycle cost*, not 1.5 times that. Without a forecast it is still 1.5 times. A day/night gap of 0.08 now works at a cycle cost of 0.07 or less.
Contents of this release, in merge order since 0.19.0 (`793a676`):
| | |
|---|---|
| `TARIFF-24` | With battery modes on, the add-on now also resolves the tariff when no price service and no price trading are set, so a day/night or fixed tariff gives the batteries their price runs ([[TARIFF-24]]). With battery modes on and a solar forecast read, the price gap a mode needs is *Battery cycle cost* (widened only for a real-time feed), not 1.5 times that; Home and the Batteries screen use the same rule ([[AUDIT-02]] F1). |
### Known issues
The list under `## 0.19.0` below still applies.
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.19.0
**A minor bump: price trading behaves differently, plus a fix for a false warning, the Home price chart in 15-minute bars, more Dutch text and EMS-port wording, and no option changes.**
@@ -0,0 +1,11 @@
# OPT-23 and OPT-28 - the day plan counts the sun right, and the night charge covers the expensive-hour shortfall
**Status: for review.** Tests: `test_opt23_28.py`.
`dayplan.plan()` now buys, for Leftover solar batteries, what the house uses in the expensive hours less the sun counted for each hour, shared by capacity, and reads a state-only forecast from now for every span (OPT-23).
- **Shortfall.** `sum_h max(0, house_h - trust x sun_h)` over every expensive hour, `/ EFF`, from the floor. It replaces the use over the run with no sun counted, which on a day and night tariff bought for the whole day (G1, `reports/UX-69-design-report.md`). The sun before the run is unchanged. A run with its dear hours after sunset gives the old number.
- **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun.
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by another battery (Cheap hours, Solar first) or the grid, as the screen says.
- **Cheap hours** still fills up and is not counted in the share; **Solar first** still never charges from the grid. The mode help says so.
- **Not done:** the run length (OPT-25); netting what a Cheap hours or Solar first battery will hold against the shortfall.
@@ -0,0 +1,46 @@
# OPT-27 - the night plan keeps the last fresh forecast
**Status: done on `release/1.0`, not released.** Tests: `test_opt27.py`.
**The defect.** A today sensor is stale after 3 h (`FRESH_S`) or when it is from
before local midnight. Forecast.Solar stops updating in the evening, so at night
every Forecast.Solar site had no forecast: a price mode's night cheap run needed
1.5x the cycle cost (TARIFF-24 round 2) and Leftover solar planned nothing.
Separately, one missed poll (`unavailable`) dropped the forecast at once.
**Where: `solarfc`, not the plan.** The last fresh reading of each sensor is
kept in `solarfc.Hold` and `read_sum()` uses it when a sensor has none. A hold
at the plan (`_wx_track`/`weather_held`) can only keep the 1.5x gap off. The
day plan, Leftover solar's levels, Home and the Forecast tab all read
`Controller.solar_fc`, so one kept forecast makes control and every screen agree
(UX-53) with no second rule to keep in step. `weather_held()` is unchanged: it
still keeps a run under way when a read is lost.
**The rule** (`Hold.stand_in()`). A kept reading is for one day. It covers a
sensor that is missing or stale, never one with a bad value or unit, and:
- for up to `FRESH_S` when the sensor is missing: a short outage, any time of day;
- from the end of the bell (20:00 local, `BELL_TO_H`) until local midnight, if the
reading is at most `KEPT_MAX_AGE_S` (12 h) old when used; after midnight the
tomorrow figure is today's until the bell starts (06:00), up to 15 h old
(OPT-34 changed this from midnight + 3 h);
- the day it is for: a today figure only on that day, a tomorrow figure as
tomorrow until midnight and, if the today sensors are still stale, as today's
until 06:00 (all sensors of the sum are needed, as for any sum);
- until the next fresh reading, which replaces it at once.
So a figure is never used for another day and never older than 12 hours (`KEPT_MAX_AGE_S`), except the morning case above: a 17:05 reading serves until 06:00 (12 h 55 min), a 09:05 reading only until 21:05. Until OPT-34 the figure ended at 03:00, and a charge under way lost its level there; OPT-34 closes that limit.
Daytime is as before: a sensor that stopped is stale after 3 h, and no forecast
all day is still no forecast (the night run still needs 1.5x).
**Where it is stored.** The controller's store, key `solar_fc_hold`, so an
evening restart keeps it. It is written when a reading's figure changes or
every 15 min (`SAVE_EVERY_S`), not on every poll. A damaged entry is dropped.
**The screens.** `Forecast.kept` is true when a reading older than `FRESH_S` is
in use; the forecast line then reads "Solar forecast read 17:05, kept for
tonight." (`home_words.solar.kept`, EN and NL). The event log says
"read, kept from 17:05".
**Not changed.** Control with modes off: it does not use the forecast. The
screens still show the line, so it says "kept", not "used for the plan". A
forecast that stays fresh. The hold does not look at the weather, only at the clock and the day.
@@ -0,0 +1,62 @@
# OPT-34 - the night charge keeps its level when the kept forecast ends
**Status: done on `release/1.0`, not released.** Tests: `test_opt34.py`.
**The defect** (OPT-27 rig test, F-1). The kept evening figure ended at midnight + 3 h.
With the sensors silent from 17:05 the Leftover solar battery's level went with
it in the middle of the charge: A ended at 49.8 % against 79.9 % with a fresh
figure. Whether Forecast.Solar refreshes after midnight on a live site is not
known, so the add-on does not depend on it.
**The rule.**
1. *A charge under way keeps its level* (`Controller._latch_charge()`). While
there is a forecast (fresh or kept), each battery that charges from the grid in the
cheap run takes its planned level into a latch (store key `night_charge_latch`).
With no forecast, `plan_targets()` gives the latched level, and the wide
no-forecast gap does not refuse the next slice of the run (`weather_held()`,
as TARIFF-24 does for the run under way). While a
forecast is there the latch follows the plan, up or down as a fresh figure moves
it. It lasts through every cheap run on and one price slot after the last, and it
ends at once when its end time passes, whatever the run state (it is also
ignored at a restart if it has ended). A battery that is switched off or
changed to another mode leaves it.
*Night bound (manager's call, 2026-10-31):* a run carries the latch on only if
it starts by the bell start (06:00 local) after the latch began; that run
finishes, plus one slot. Chosen over a fixed 12 h because the night tariff
(22:00-07:00) needs the 06:00-07:00 slice and 12 h from a late start would
reach into the day. With dynamic prices a chain of cheap slots cannot carry a
night's level into the next day.
2. *The evening's tomorrow figure is today's until the bell starts*, 06:00 local
(`BELL_FROM_H`, `solarfc._bell_start()`), not until midnight + 3 h.
Nothing has been produced yet, so it is still the whole day.
3. *Up to 15 h old for that case only* (`KEPT_MORNING_MAX_AGE_S`): a 17:05
reading is 12 h 55 min old at 06:00. It must have been at most 12 h old at
midnight, so a 09:05 reading, unusable since 21:05, does not come back for five
minutes after midnight. Same-day figures and the daytime rule keep 12 h and 3 h.
4. A fresh figure replaces the kept one at once, as before.
**Step 1 alone is enough for the owner's shape, steps 2-3 are still needed.**
With the window put back to midnight + 3 h the owner's night still ends as a
fresh figure ends it (A 86.9 %, B 95.0 %), because the charge had started at
22:00. But a charge that has not started by 03:00 (the price dips at 04:00, or
the headroom is taken) has no level to latch: A ends at 38 % against 78 %. The
plan, the screens and a restart also need the figure itself.
**Screens.** "Solar forecast read {at}, kept for tonight." is still true until
06:00 and unchanged. After the figure ends, with a charge still going on: "No
solar forecast now. The charge under way goes on to its planned level." /
"Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau."
(`home_words.solar.latched`, only while a cheap run is on and a latched battery is below its level).
**Not used.** A figure over 15 h old; a morning figure at night (12 h); a figure
for another day; a bad value or unit; and no latch for a charge that has not
started, for a battery that is not charging, or after the cheap runs have ended
(plus one slot).
**Not changed.** Charge sizing, the peak guard, Cheap hours, modes off, arbitrage.
A fresh figure that LOWERS the level during a charge still lowers it, as before.
**Mutants** (all red in `test_opt34.py`): no latch; window back to midnight + 3 h;
15 h without a limit; no "usable at midnight" rule; the latch without the narrow
gap, without its save, or not kept through the next run; the window 6 h after
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level.
@@ -0,0 +1,10 @@
# SAFETY-125 - a grid charge under the month peak
**Status: FIXED in 0.19.2 (hotfix from 0.19.1).** Tests: `test_safety125.py`.
A grid charge is sized by `peakguard.charge_room_w()`, the guard's headroom
with the seconds since a late meter reading counted at that reading (the
projection leaves them out, SAFETY-99, the bill does not), and the guard's own
floor is unchanged. At N=1 the maintenance charge is now sized too (owner
2026-10-29; SAFETY-84 had left it out). Evidence (repro on 0.19.1, sweep,
digests): the SAFETY-125 pull request.
@@ -0,0 +1,47 @@
# SAFETY-126 - a landing error on the aim is not an overrun
**Status: FIXED on release/1.0 (after 0.19.2).** Tests: `test_safety126.py`.
**Cause.** A grid charge under the peak limit is sized to land the 15-minute
period on the aim (SAFETY-125), so the period ends 0-7 W over it. In the last
second `peakguard.excess_w()` multiplies that by 900 / remaining (3 000 at
0.3 s left, 90 000 at 0.01 s), so `decide()` wants tens of kW
(`wanted > 0`, peakguard.py `decide()`). With every battery under the peak
reserve that is floor-limited at 0 W, and `check_peak_floor_limited()`
alarmed on it at once: SAFETY-87 graded only the case where another battery
could give. With one battery able, the guard asked it for its rating for that
cycle and the Batteries "Why" said "The peak limit needs power". The
add-on's own charge is not the cause: `wanted` already counts it as
stoppable.
**Rule.** Each cycle the guard wants power, `Controller._peak_landing()`
asks `peakguard.misses_aim()`: with the batteries at 0 W from now on, does
the period end more than `ALARM_MISS_W` (10 W) over the aim? This is
`load_too_high()` with nothing to give, without `decide()`'s gain. If not, and
no real miss was seen since the guard took hold, the decision is
`within_aim`. Such a decision raises no reserve alarm (ERROR,
notification, red row), no red "Load too high" row, no "Why" peak and
no "Battery stopped limiting the peak" event hint. The claim, the floor and the
commands are unchanged.
**Still alarms.** House load the batteries cannot cover under the reserve:
the period would end over the aim with the batteries at 0 W. A step load
alarms on the same cycle as before. SAFETY-87's case and SAFETY-81's 'Load too
high' notification are unchanged.
**What the 10 W tolerance costs** (review, owner accepted 2026-10-31):
- A steadily rising load alarms a little later, once the projected miss passes
10 W (3 000 -> 4 000 W over 45 min: 56 s later). With the default 50 W
margin the alarm still comes before the limit.
- With `peak_margin_w` 0, a period can end up to 10 W over the limit without
this alarm, the tolerance 'Load too high' already accepts (SAFETY-81).
`misses_aim()` takes the house as meter plus batteries, so an export nets
off (`decide()`'s projection counts offtake only). A re-alarm while the loop's
draw on held batteries covers the house is gone too (test_safety126.py part 2,
modes off: 2 notifications, was 4).
**Not changed.** The one-cycle floor itself: at a period's last instant the
guard still asks an able battery for its rating, or stops a charge, for a
landing error. That is control, outside the alarm path. Raise a card if it
should go.
@@ -39,7 +39,8 @@ was fine.
**Not changed.**
- At N=1 the charge is not sized to the headroom: the guard still suspends it
and the battery gives until the quarter is safe. The same sizing would fit
there; that is an owner question, not this fix.
there; that is an owner question, not this fix. (SAFETY-125, 0.19.2: the
owner said yes, and N=1 is sized the same way.)
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
@@ -0,0 +1,70 @@
# TARIFF-24 - battery modes need the tariff task
**Status: FIXED in 0.19.1 (hotfix from 0.19.0, owner go 2026-10-28).**
Follows TARIFF-10 (fetching is not trading). Tests: `test_tariff24.py`.
**The defect.** The control cycle gets prices only from
`Controller.tariff_plan`, which only `run_tariff_refresh()` stores. `amain()`
starts that task when `tariff_refresh_wanted(opts)`, which was
`provider_configured(opts) or arbitrage.enabled(opts)`. Battery modes read the
plan but were not a term. With battery modes on, a day/night or fixed
`static_profile`, `price_provider: off` and `arbitrage_enabled: false` (the
site trade switch is not read with modes on) no task ran. Then
`fresh_tariff_plan()` was None, `_price_runs()` was None and `mode_runs` was
None, so Leftover solar and Cheap hours never charged from the grid. The
Batteries screen said "No prices, so it follows the house" while Home priced
the day from `static_plan()` itself (`homeview.build_plan()`).
**The fix** (`main.py`, `tariff_refresh_wanted()`). `packmode.enabled(opts)`
is a third term. The gate stays a widening: nothing that started the task
before stops. No HTTPS call is added: with `price_provider: off`,
`resolve_tariff()` is the pure `static_plan()`.
**Not changed.** Modes off, arbitrage off, no feed still starts no task. The
control law, the price runs and every message are untouched.
**One rule, not two.** Gating on "modes on and a static profile" was
considered. It saves one arithmetic pass every 30 min on a site with modes on
and no prices at all, and it adds a second condition that has to track what
`resolve_tariff()` accepts. With no profile and no feed the plan is `no_data`,
exactly what the cycle sees without the task.
**UX-55.** Home said "no prices have arrived" (`mode_runs` None) with every
battery on Solar first. The same cause, and this fix clears it for a site with
a static profile (`test_tariff24.py` part 5). A site with no profile and no
feed still has no prices, and Home still says so.
## Round 2 (AUDIT-02 F1): the forecast replaces the "no weather" doubt
**Decided 2026-10-28 (owner), in the same hotfix.** The price gap a mode needs
was `cycle_cost_threshold_eur` x 1.5 (`Uncertainty.NO_WEATHER`) whether or not
a solar forecast was read. At the shipped cost 0.10 that is 0.15, so a
day/night gap (0.08) and many dynamic days never traded, and the screens said
only "Prices differ too little".
**Why the doubt goes with a forecast.** The widening stands in for not knowing
the weather. The forecast is that knowledge, and the day plan already trusts it
for how much to charge. Trusting it for the plan but doubting it for the gap
was one input judged twice.
**The rule** (`arbitrage.weather_uncertainty()`). With battery modes on and a
usable forecast (`solar_fc` not None, the day plan's own test) the doubt is
not added: the gap needed is the cycle cost, widened by the price product only.
No forecast, or a stale or unusable one: 1.5 x, as before. It is read in four
places that must agree: the run plan (`_price_runs()`), the charge gate
(`_mode_roles()`), Home's mirror (`homeview.trade_verdict()`) and the Forecast
tab's longer cheap run (`dayview.py`, from release/1.0). The forecast
is read at most every 60 s and the plan key already holds "forecast or not", so
the gap follows in the cycle the read changes, both ways, with one exception:
a run planned with a forecast that is under way keeps that gap to its end
(`Controller.weather_held()`), so a sensor that goes unavailable does not stop
or restart a Cheap hours charge (a Leftover solar battery still stops: without
a forecast it has no planned level). The wider gap applies from the next run.
A run that has started runs to its end: a give run that starts during the hold
is kept after it (`keep_started()`), with no grid charge or sale.
**Not changed.** Modes off (the site trade) keeps the doubt always. The
shipped cost 0.10 still needs a gap of 0.10 even with a forecast: a 0.08
day/night site must set *Battery cycle cost* to 0.08 or less. Showing the two
numbers on the screens (AUDIT-02 F1 option b) is a separate card.
+12 -7
View File
@@ -530,15 +530,15 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen (and Se
- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
- **Mode.**
- *Solar first* charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells.
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). It saves its power for the expensive hours and covers the house then.
- *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then.
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but it buys only what the sun will not give in the expensive hours (see below). It saves its power for those hours and covers the house then.
- *Cheap hours only* fills up from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then. It does not look at the forecast.
- **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A).
With battery modes on, these choices replace the *Charging and selling on price* switch (`arbitrage_enabled`); that switch is only read with modes off.
**Cheap and expensive hours.** They come from the published day-ahead prices, as runs of consecutive 15-minute periods, not a single period.
- The cheap run is the cheapest stretch long enough to fill the batteries that may charge, at their power.
- An expensive hour costs at least *Battery cycle cost* (`cycle_cost_threshold_eur`, widened as for trading) more than the cheap run. The batteries hold their power until then.
- An expensive hour costs more than the cheap run by at least *Battery cycle cost* (`cycle_cost_threshold_eur`) when a solar forecast is read, and by 1.5 times that when none is (a real-time price feed widens it further). The batteries hold their power until then.
- The sale uses the most expensive stretch inside those hours, and never a period below 0.
- A carried (not yet published) price is never used. A run that has started runs to its end.
- With no prices, or too small a gap, every battery follows the house.
@@ -548,13 +548,16 @@ With battery modes on, these choices replace the *Charging and selling on price*
- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
- *Leftover solar* then charges from the grid only the shortfall: for each expensive hour, what the house uses less the sun counted for that hour (never below 0), added up over all the expensive hours, early morning and evening. With a day and night tariff that is 07:00 to 22:00. Not up to the top level.
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. In the expensive hours the rest comes from another battery (*Cheap hours only* or *Solar first*) or from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
- A forecast that gives only one figure for the day (Forecast.Solar) is spread over the hours from now, so the hours add up to that figure.
- *Cheap hours only* ignores the forecast and fills to the top level.
- *Solar first* never charges from the grid.
- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan.
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
- **At night.** Some forecasts stop updating in the evening (Forecast.Solar does). The add-on keeps the last reading of each sensor and uses it for that night's plan: from 20:00 until 06:00, or until a newer reading comes. After midnight the evening's figure for tomorrow is today's, whole, because nothing has been produced yet. A sensor that is missing for up to 3 hours is covered the same way. A figure is never used for another day, and never when it is more than 12 hours old (15 hours after midnight, for a figure that was still in use at midnight). A charge from the grid that has started keeps its level when the forecast ends, and goes on to it; the Batteries screen says so. The Batteries screen says "Solar forecast read 17:05, kept for tonight." With no reading all day, the night run still needs the wider gap.
**What always comes first.**
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
@@ -1708,7 +1711,9 @@ DECISIONS/MAINT-13-full-pack-is-complete.md.
On a capacity-tariff site the maintenance charge is capped by the headroom left
under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
is dropped so the battery can shave the peak instead. Money outranks the
maintenance schedule.
maintenance schedule. With one battery or several, the charge also stays
within the room left in the 15-minute period, so a late meter reading does
not let it run at full power.
With two or more batteries, the one being emptied gives its drain rate and the
others only add power when the house needs more; none charges while it drains
@@ -3453,7 +3458,7 @@ Charging from the grid stays blocked meanwhile. With several inverters the
notification names the battery at its limit (SAFETY-32), each graded against
its own cut-out level. That battery is held (weight 0 while the site gives)
and the guard's need goes to the others, up to their rating; the alarm fires
only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). ⚠️ **The reserve binds the peak guard only
only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). With no battery able to give it fires at once, but only if the period would end more than 10 W over the aim with the batteries at 0 W. A grid charge the add-on runs counts as one it can stop, and the last second's landing error on the aim is not an alarm. A steadily rising load therefore alarms a little later. With `peak_margin_w` 0 a period can end up to 10 W over the limit without this alarm, as for “Load too high” (SAFETY-126). ⚠️ **The reserve binds the peak guard only
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
does not protect*.
@@ -3612,7 +3617,7 @@ for a new install*.
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *At this rate … over …* means it would go over at the current rate. With *grid charge limited*, a charge from the grid is cut back so it does not. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
+16 -1
View File
@@ -147,7 +147,7 @@ from dataclasses import dataclass
from datetime import datetime
from .arbiter import ACTUATOR_SITE, P_ARBITRAGE, Claim
from .price import Absence, price_option
from .price import Absence, Uncertainty, price_option
_LOG = logging.getLogger("goodwe.arbitrage")
@@ -267,6 +267,21 @@ def threshold_eur_kwh(opts: dict) -> float:
return v
def weather_uncertainty(modes_on: bool, solar_fc, held: bool = False) -> frozenset:
"""The "no weather" doubt (x1.5) a trade must clear. TARIFF-24.
Battery modes read a solar forecast; with one read (`solar_fc` not None) the
doubt is gone, and `held` (a run planned with a forecast is under way, see
`Controller.weather_held()`) keeps it gone until that run ends. Everything
else, and the modes without a forecast, keep it. One rule for the run plan,
the charge gate and Home's mirror. See
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
"""
if modes_on and (solar_fc is not None or held):
return frozenset()
return frozenset({Uncertainty.NO_WEATHER})
def soc_bounds(opts: dict) -> tuple:
"""(floor, ceiling) in percent. The reserve arbitrage may not spend."""
return (float(price_option(opts, "arbitrage_soc_floor")),
+69 -17
View File
@@ -7,20 +7,31 @@ turns the level into permission to charge from the grid; main.py rebuilds the
plan with the price runs (at most every 15 minutes) and keeps a level unless
it moves by `HYST_KWH` or `HYST_FRAC` (`hold()`).
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers):
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers;
OPT-28 for the second):
* `sun`: trust x the solar the house will not use, between now and the
expensive run the cheap run is for, x the charge efficiency:
`trust x sum_h max(0, forecast_h - house_h) x EFF`.
* Solar first batteries take it first, up to the ceiling. What is left is
shared by the Leftover solar batteries by their room.
* `short` (OPT-28): what the house draws in the expensive run that the sun
will not give, `sum_h max(0, house_h - trust x forecast_h)` over every
hour of the run, early morning and evening. The Leftover solar batteries
hold it together: `short / EFF` above the floor, shared by capacity, and
a battery that reaches the ceiling passes the rest to the others. What
all of them cannot hold (`Plan.uncovered_kwh`) is not bought: another
battery (Cheap hours, Solar first) or the grid carries it.
* Cheap hours only ignores the plan and fills to the ceiling, as in P3 and
the design (section 1). Skipping the sun's share cost money in the replay
(PR #156 review: 29.72 against 29.41 EUR on real 2026 prices).
* A Leftover solar battery charges only up to what the house will draw from
it in the expensive runs before the next cheap run, less its share:
`min(ceiling, floor + house use / EFF) - share`. So it buys only the
* A Leftover solar battery charges from the grid up to the level it needs
for its part of `short`, less its share of the sun before the run:
`min(ceiling, floor + its part / EFF) - share`. So it buys only the
shortfall, not up to the ceiling.
* Solar first never charges from the grid (owner, 2026-09-29).
* A state-only forecast is read from now for every span (`Forecast.between`
`since=`, OPT-23), so the hours add up to the day's figure.
DECISIONS/OPT-23-28-the-night-charge-covers-the-dear-hours.md has the record.
The house profile is the add-on's own measurement, grid + battery power, per
local hour, over the last `PROFILE_DAYS` days; the estimate for an hour is the
@@ -31,7 +42,7 @@ control decision.
"""
import statistics
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
@@ -154,6 +165,11 @@ class Plan:
targets: dict # name -> level % (Leftover solar only)
need_kwh: dict # name -> kWh from the grid (0: none)
share_kwh: dict # name -> kWh of solar counted for it
# OPT-28: the expensive hours' shortfall, the part of it each battery
# covers, and what no battery can hold. All kWh on the house's side.
short_kwh: float = 0.0
cover_kwh: dict = field(default_factory=dict)
uncovered_kwh: float = 0.0
def _hour_slices(t0, t1):
@@ -171,6 +187,31 @@ def _hour_slices(t0, t1):
return out
def _spread(amount, room, weight):
"""Share `amount` over the keys of `room` by `weight`, none past its
room, what one cannot take passed to the others. Returns `(taken,
left)`: what each took and what none could."""
got = {k: 0.0 for k in room}
left = float(amount)
live = {k for k, r in room.items() if r > 1e-9 and weight.get(k, 0) > 0}
while left > 1e-9 and live:
total = sum(weight[k] for k in live)
used, full = 0.0, set()
for k in sorted(live):
part = left * weight[k] / total
free = room[k] - got[k]
if part >= free - 1e-9:
part = free
full.add(k)
got[k] += part
used += part
left -= used
if not full:
break
live -= full
return got, max(0.0, left)
def _room(p, to_pct):
if p.capacity_kwh is None or p.soc is None:
return 0.0
@@ -194,7 +235,7 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
trust = max(0.0, min(1.0, float(trust_pct) / 100.0))
sun = fc_sum = 0.0
for s, e, h in _hour_slices(now, until.start):
pv = forecast.between(s, e)
pv = forecast.between(s, e, since=now)
if pv is None:
return None # the span reaches a day with no forecast
hrs = (e - s).total_seconds() / 3600.0
@@ -212,30 +253,40 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
rooms = {p.name: _room(p, ceiling_pct) for p in price}
total_room = sum(rooms.values())
# What the house draws in the expensive runs from the one this plan is
# for until the next cheap run (or a day), from the profile.
# OPT-28: what the house draws in the expensive runs, from the one this
# plan is for until the next cheap run (or a day), that the sun will not
# give. An hour with sun is read at no less than the night base, as the
# sun sum above reads it (the profile is net of the sun the house used);
# an hour without is read as measured. A run hour with no forecast counts
# no sun.
horizon = until.start + timedelta(hours=24)
use_kwh = 0.0
short = 0.0
for g in runs.give:
if g.start < until.start or g.start >= horizon:
continue
for s, e, h in _hour_slices(g.start, g.end):
use_kwh += max(0.0, house_w(h)) * (
e - s).total_seconds() / 3600.0 / 1000.0
pv = forecast.between(s, e, since=now) or 0.0
w = max(0.0, house_w(h))
if pv > 0.0:
w = max(w, base_w)
short += max(0.0, w * (e - s).total_seconds() / 3.6e6
- trust * pv)
caps = {p.name: float(p.capacity_kwh) for p in price}
cap_sum = sum(caps.values()) or 1.0
span = max(0.0, float(ceiling_pct) - float(floor_pct))
held, left = _spread(short / EFF,
{n: span / 100.0 * c for n, c in caps.items()},
caps)
targets, need, share = {}, {}, {}
targets, need, share, cover = {}, {}, {}, {}
for p in price:
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
sh_pct = sh / caps[p.name] * 100.0
want = use_kwh * caps[p.name] / cap_sum / EFF
top = min(float(ceiling_pct),
float(floor_pct) + want / caps[p.name] * 100.0)
top = float(floor_pct) + held[p.name] / caps[p.name] * 100.0
level = max(0.0, top - sh_pct)
targets[p.name] = round(level, 2)
need[p.name] = round(_room(p, level), 3)
share[p.name] = round(sh, 3)
cover[p.name] = round(held[p.name] * EFF, 3)
if sun < sf_room:
verdict = DARK
elif any(v > 0.05 for v in need.values()):
@@ -243,7 +294,8 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
else:
verdict = SUNNY
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
targets, need, share)
targets, need, share, round(short, 3), cover,
round(left * EFF if price else 0.0, 3))
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
+13 -9
View File
@@ -23,7 +23,7 @@ from . import arbitrage, dayplan, homeview as hv, modetrade, priceruns
from . import solarfc
from .daycurve import Batt, curve
from .packmode import SOLAR_FIRST
from .price import Uncertainty, brussels, required_spread, uncertainty_of
from .price import brussels, required_spread, uncertainty_of
BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry
LEVEL_DP = 1 # the tab shows a level to 0.1 %
@@ -42,12 +42,12 @@ def build(c, status: dict, now: datetime, lang: str = "en") -> dict:
def sun_kwh(fc, now, a, b):
"""kWh the forecast gives in `[a, b)`, `a` at or after `now`, or None.
Both ends are measured from `now`: `Forecast.between()` reads its start
as the moment the "left today" figure was taken, so a span that starts
later would be spread over what is left of the bell, not over its own
hours (state-only forecasts, DECISIONS/UX-54-forecast-tab.md)."""
hi, lo = fc.between(now, b), fc.between(now, a)
return None if hi is None or lo is None else max(0.0, hi - lo)
Every span is measured from `now` (`Forecast.between(since=)`, OPT-23),
as `dayplan.plan()` measures them: a state-only "left today" figure is
the sun from the moment it was taken, so a span that starts later must
not be spread over what is left of the bell from its own start
(DECISIONS/UX-54-forecast-tab.md)."""
return fc.between(a, b, since=now)
def _times(run):
@@ -204,9 +204,12 @@ def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow,
end_t = None
if plan_on and cheap is not None and rp.charge_useful() \
and n0 <= cheap.start < n1 and plan is not None:
# The gap control's run plan needs (TARIFF-24): no x1.5 with a
# forecast read.
required = required_spread(
arbitrage.threshold_eur_kwh(c.o),
uncertainty_of(plan.read.product) | {Uncertainty.NO_WEATHER})
uncertainty_of(plan.read.product)
| arbitrage.weather_uncertainty(True, c.solar_fc))
end_t = cheap.end
for _ in range(24):
ext = dataclasses.replace(rp, cheap=priceruns.Run(
@@ -368,7 +371,8 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
if fc is not None:
if k == 0:
line = hv.line_solar(fc.today.kwh, fc.today.kwh * trust,
hv.hhmm(fc.at))
hv.hhmm(fc.at),
kept=getattr(fc, "kept", False))
elif fc.tomorrow is not None:
line = hv.day_note("tomorrow_total",
kwh=hv._kwh(fc.tomorrow.kwh),
+112 -18
View File
@@ -20,10 +20,11 @@ import contextlib
from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard, uitext
from .dayplan import EFF
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE, next_slot_start
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels, _cover_table
from .price import Absence, brussels, _cover_table
from .static_tariff import static_plan
from .tariff import BILLING_TZ
from .uimodel import REGION_PRESETS, options_to_ui
@@ -279,7 +280,11 @@ def trade_verdict(c, plan, now):
return None, "no_prices"
verdict = plan.arbitrage(peak_guard_active=guard,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
uncertainty=arbitrage.weather_uncertainty(
bool(getattr(c, "modes_on", False)),
getattr(c, "solar_fc", None),
bool(getattr(c, "weather_held",
lambda _n: False)(now))),
now=now)
return verdict.match(
allowed=lambda v: (v, "allowed"),
@@ -471,9 +476,15 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
continue
price = run.mean_eur_kwh
if kind == "grid":
cov = plan_cover(c, inv.name)
what, why, amount = (run_grid_what(kwh, shadow),
run_cheap_why(price),
run_grid_amount(kwh * price))
if cov is not None:
# OPT-28: with a day plan the block says how far it
# charges and what that covers.
what = run_grid_what_to(kwh, cov[0], shadow)
why += " " + run_covers(cov[1])
elif kind == "sell":
# No money line: the price is what the house pays, and a sale
# earns the lower injection price (OPT-11 P3 review).
@@ -506,15 +517,47 @@ def plan_need(c, name, soc=None):
inv.capacity_kwh)
def plan_cover(c, name):
"""OPT-28: `(level %, kWh)` for a battery the day plan charges from the
grid: the level it charges to, and the expensive-hour use of the house
that charge covers and the sun will not. Read from the level control
holds (`plan_targets()`), so Home says what control does. None when the
plan buys nothing for it."""
dp = getattr(c, "day_plan", None)
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
inv = next((i for i in c.inverters if i.name == name), None)
if dp is None or not targets or name not in targets or inv is None \
or not inv.capacity_kwh or targets[name] <= 0.0:
return None
floor, _ceil = arbitrage.soc_bounds(c.o)
cap = float(inv.capacity_kwh)
top = targets[name] + dp.share_kwh.get(name, 0.0) / cap * 100.0
return targets[name], max(0.0, top - floor) / 100.0 * cap * EFF
def _latched_below(c) -> bool:
"""OPT-34: no forecast, but a cheap run is on and a battery is still below
the level its night charge keeps (`Controller.plan_targets()`), so control
goes on charging."""
rp = getattr(c, "mode_runs", None)
if rp is None or rp.cheap is None or not rp.cheap.holds(
datetime.now(timezone.utc)):
return False
targets = getattr(c, "plan_targets", lambda: None)() or {}
return any(inv.soc is not None and inv.name in targets
and inv.soc < targets[inv.name] for inv in c.inverters)
def plan_state(c):
"""P4: `"plan"` (a day plan is made), `"learning"` (no house profile
yet), `"no_forecast"` (set but missing or stale), or None (not set, or
battery modes off)."""
yet), `"no_forecast"` (set but missing or stale), `"latched"` (OPT-34:
the same, but a night charge under way is still going on to its level),
or None (not set, or battery modes off)."""
if not getattr(c, "modes_on", False) or \
getattr(c, "fc_entity", None) is None:
return None
if getattr(c, "solar_fc", None) is None:
return "no_forecast"
return "latched" if _latched_below(c) else "no_forecast"
hp = getattr(c, "house_profile", None)
if hp is not None and not hp.ready():
return "learning"
@@ -535,11 +578,15 @@ def solar_block(c, now):
out.update(fc_kwh=round(fc.today.kwh, 1),
count_kwh=round(fc.today.kwh * trust, 1),
updated=hhmm(fc.at))
if st in ("no_forecast", "learning"):
if st in ("no_forecast", "learning", "latched"):
out["line"] = _t("solar." + st)
elif fc is not None:
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
out["updated"])
out["updated"],
kept=getattr(fc, "kept", False))
dp = getattr(c, "day_plan", None)
if st == "plan" and dp is not None and dp.uncovered_kwh >= MIN_KWH:
out["line"] += " " + line_uncovered(dp.uncovered_kwh)
return out
@@ -785,9 +832,11 @@ def refusing(c, inv) -> int:
def peak_engaged(c) -> bool:
"""The peak guard asks the batteries for power this cycle. Not when it is
blind or floor-limited: those are engaged with a 0 W floor (no charging
only), so no battery gives power for it."""
only), so no battery gives power for it. Nor when the quarter ends on
the aim anyway (SAFETY-126, `within_aim`)."""
pg = getattr(c, "peak_guard", None)
return bool(pg is not None and pg.engaged and (pg.floor_w or 0.0) > 0.0)
return bool(pg is not None and pg.engaged and (pg.floor_w or 0.0) > 0.0
and not pg.within_aim)
def battery_rows(c, shadow, running, held=False):
@@ -863,7 +912,8 @@ def battery_rows(c, shadow, running, held=False):
# knows this cycle. Price and forecast reasons are OPT-11 P3/P4.
"why_key": why,
"why": why_words(why, name=cycling if why == "other_cycle"
else other, at=at),
else other, at=at,
**plan_words(c, inv.name, why)),
})
if modes_on:
# OPT-11 P1: the battery's switch and mode, for the Batteries
@@ -1029,7 +1079,8 @@ def month_peak(c, now):
ceiling = pg.ceiling_w or limit_w
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
> 0.0)
> 0.0,
cut=getattr(c, "room_charge", False))
return out
@@ -1160,6 +1211,10 @@ def _kwh(v) -> str:
return _dec(f"{float(v):.1f}") + " kWh"
def _pct(v) -> str:
return _dec(f"{float(v):.0f}") + " %"
# Row id -> the title on Home. The row's own sentence is the body, in the
# page's language (UX-46, `messages.pick`). Five titles are the notification's
# own title, which test_ui_text.py checks.
@@ -1292,11 +1347,18 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
return _t("now.resting")
def why_words(key, name=None, at="") -> str:
def why_words(key, name=None, at="", pct="", kwh="") -> str:
"""One battery's "why" line (UX-13 P3), from `battery_why()`'s key.
`name`: the battery that refuses, for `other_refuses`; `at`: the
give run's time, for `saves`."""
return _t("why." + key, name=name or "", at=at)
give run's time, for `saves`; `pct` and `kwh`: the planned level and
what it covers, for `tops_up` (OPT-28)."""
return _t("why." + key, name=name or "", at=at, pct=pct, kwh=kwh)
def plan_words(c, name, why) -> dict:
"""OPT-28: the figures of a `tops_up` line, or nothing."""
cov = plan_cover(c, name) if why == "tops_up" else None
return {} if cov is None else {"pct": _pct(cov[0]), "kwh": _kwh(cov[1])}
def today_words(key) -> str:
@@ -1318,6 +1380,16 @@ def run_grid_what(kwh, shadow) -> str:
return _w(shadow, "run.grid_what", kwh=_kwh(kwh))
def run_grid_what_to(kwh, pct, shadow) -> str:
"""OPT-28: a grid block of the day plan, with the level it charges to."""
return _w(shadow, "run.grid_what_to", kwh=_kwh(kwh), pct=_pct(pct))
def run_covers(kwh) -> str:
"""OPT-28: what a planned grid charge covers."""
return _t("run.covers", kwh=_kwh(kwh))
# The trade legs and the price line use the feed's own points (a 15-minute
# period on a quarter-hourly feed), so both name the same price and time.
def run_grid_why(price) -> str:
@@ -1391,10 +1463,20 @@ def day_note(key, **values) -> str:
return _t("day." + key, **values)
def line_solar(fc_kwh, count_kwh, at) -> str:
def line_solar(fc_kwh, count_kwh, at, kept=False) -> str:
"""The forecast line. OPT-27: a reading kept past three hours (the
evening's, used at night) says so instead of "Updated"."""
if kept:
return _t("solar.kept", at=at)
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
def line_uncovered(kwh) -> str:
"""OPT-28: the Leftover solar batteries cannot hold the expensive hours'
shortfall. The rest comes from another battery or the grid."""
return _t("solar.uncovered", kwh=_kwh(kwh))
def line_no_trade(key) -> str:
return "" if key == "off" else _t("no_trade." + key)
@@ -1448,13 +1530,19 @@ def line_peak_not_covered() -> str:
return _t("peak_not_covered")
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
def line_period(period_w, limit_w, shadow, unmeasured=False,
cut=False) -> str:
step = _w(shadow, "step_in", kw=_kw(limit_w))
if unmeasured:
# SAFETY-90: the add-on started during this period.
return _t("period_unmeasured") + " " + step
if period_w is None:
return step
if period_w > limit_w:
# SAFETY-125: the figure is the current rate, and a grid charge is
# what gets cut back.
return _t("period_over", kw=_kw(period_w)) + " " + (
_w(shadow, "charge_cut", kw=_kw(limit_w)) if cut else step)
return _t("period", kw=_kw(period_w)) + " " + step
@@ -1516,6 +1604,7 @@ def _samples() -> list:
for k in VERDICT:
out.append(("plan", plan_head(k, sh)))
out += [("plan", run_grid_what(12.34, sh)),
("plan", run_grid_what_to(12.34, 82, sh)),
("plan", run_give_what(9.6, sh)),
("plan", run_cycle_what(M_IDLE, sh)),
("plan", run_cycle_what(M_DRAIN, sh)),
@@ -1527,9 +1616,12 @@ def _samples() -> list:
("plan", line_period(1240, 3500, sh)),
("plan", line_period(None, 3500, sh)),
("plan", line_period(1240, 3500, sh, unmeasured=True)),
("plan", line_period(5814, 5413, sh)),
("plan", line_period(5814, 5413, sh, cut=True)),
("plan", control_text(True, sh)),
("plan", control_text(False, sh))]
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
("plan", run_covers(9.4)), ("plan", line_uncovered(2.5)),
("plan", run_give_why(0.3612)), ("plan", run_give_amount(3.47)),
("plan", run_cycle_why()), ("plan", run_cycle_why(True)),
("plan", run_cycle_amount(None)),
@@ -1560,10 +1652,12 @@ def _samples() -> list:
out.append(("plan", battery_now(62, w, phase=M_IDLE,
shadow=False, running=True,
off=off, peak=peak)))
out += [("plan", why_words(k, name="Battery A")) for k in words["why"]]
out += [("plan", why_words(k, name="Battery A", pct="82 %",
kwh="9.4 kWh")) for k in words["why"]]
out += [("plan", today_words(k)) for k in words["today"]]
out += [("plan", line_solar(9.2, 6.4, "10:15")),
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning"))]
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning")),
("plan", _t("solar.latched"))]
out += [("plan", day_note(k, pct=70, name="Battery A", at="08:15",
kwh="9.2 kWh", count="6.4 kWh"))
for k in words["day"]]
+230 -78
View File
@@ -175,57 +175,17 @@ TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
def tariff_refresh_wanted(opts: dict) -> bool:
"""TARIFF-10. Does this install need the background tariff refresh at all?
"""Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24)
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
only caller of `price_history.carry()` - so on a shipped install with a
provider configured NOTHING was ever fetched, no plan was ever resolved,
`/data/price_history.json` was never created, the *Price history* row read
`empty` forever, and TARIFF-09's short-horizon warning could not fire
because there was no fetch to warn about. Measured over a 40 h soak of two
instances side by side: zero feed requests and `present: false` across all
802 samples on the shipped default, against a resolved plan from the same
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
it, and the sentence saying a configured provider still fetches, all in one
change. config.yaml has said it since TARIFF-02 (852fc7c).
Two terms, and they answer two different questions:
provider_configured(opts) -> "the operator asked for a price feed", so
fetch it, validate it and record the day
(§8.3). This is the term TARIFF-10 ADDS.
arbitrage.enabled(opts) -> "the operator armed trading". This term is
what the gate USED to be, kept verbatim.
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
that started this task before still starts it; none that did not start it
stops anything. That matters because the second term is not redundant:
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
that site forever and `decide()` would answer "no tariff plan has been
resolved yet" on every cycle - silently disarming a strategy the UI says is
running, which is the exact failure validate.py's OPT-04 rules exist to
refuse.
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
arithmetic, no socket - so the second term starts a task that fetches
nothing. The true shipped default (no provider, no arbitrage) starts no
task at all. What is fetched is decided by the FIRST term only.
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
Whether they move energy is `arbitrage.enabled()` at the one call site in
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
means no trade, ever, on any configuration.
Yes with a price feed, with trading on price armed, or with battery modes
on: the modes read `self.tariff_plan` for their price runs. With
`price_provider` off the refresh is pure arithmetic on `static_profile`, so
no term here adds an HTTPS call. It decides whether prices are looked up,
never whether energy moves. See DECISIONS/TARIFF-10-fetching-is-not-trading.md
and DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
"""
return provider_configured(opts) or arbitrage.enabled(opts)
return (provider_configured(opts) or arbitrage.enabled(opts)
or packmode.enabled(opts))
def load_options() -> dict:
@@ -248,9 +208,44 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and
# how often the forecast sensors are read (they change hourly at most).
HOUSE_PROFILE_KEY = "house_profile"
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
# evening restart keeps the figure the night plan uses.
FC_HOLD_KEY = "solar_fc_hold"
# OPT-34: the level of a night grid charge that has started, so it does not
# drop when the forecast goes (`Controller._latch_charge()`).
CHARGE_LATCH_KEY = "night_charge_latch"
# OPT-34: a latch outlives the cheap run it was last used in by one price
# slot, because the next run is planned at the slot edge, in the cycle after
# the old one ended.
CHARGE_LATCH_GRACE_S = 900.0
FORECAST_READ_S = 60.0
def _load_latch(data, now=None):
"""`(until, {name: level %}, born)` from the store, or None if it is not a
well-formed latch or has ended (OPT-34)."""
try:
until = datetime.fromisoformat(data["until"])
born = datetime.fromisoformat(data.get("born") or data["until"])
levels = {str(n): float(v) for n, v in data["levels"].items()}
except (TypeError, KeyError, ValueError, AttributeError):
return None
if until.tzinfo is None or born.tzinfo is None or not levels or not all(
math.isfinite(v) and 0.0 <= v <= 100.0 for v in levels.values()):
return None
if now is not None and now >= until:
return None
return until, levels, born
def _night_limit(born):
"""OPT-34: a run that starts after this cannot carry the latch on: the
first bell start (`solarfc.BELL_FROM_H`, local) after `born`."""
d0, d1 = solarfc._bounds(born)
bell = solarfc._bell_start(d0)
return bell if bell > born else solarfc._bell_start(d1)
def why_raised(resolution, claims) -> bool:
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
ABOVE what the winner asked (the peak floor asking for more power)? Then
@@ -987,6 +982,9 @@ class Controller:
# SAFETY-87: the reserve alarm's hold, as SAFETY-81's above.
self._reserve_batt_prev = None
self._reserve_high_since = None
# SAFETY-126: `_peak_landing()`'s last battery reading and latch.
self._land_batt_prev = None
self._peak_needed = False
self._maint_shape_suspended = False
# OPT-11. Battery modes, off by default so an install that has not
# turned them on behaves exactly as before (SAFETY-10 section 1). Each
@@ -1001,6 +999,9 @@ class Controller:
# and whether last tick filed a mode trade (a run edge reseeds).
self.mode_runs = None
self._runs_key = None
# TARIFF-24: the end of the run that was under way while a forecast
# was read; until then losing the forecast does not widen the gap.
self._wx_free_to = None
self.mode_tick = None
# UX-53: why the trade gate refused a grid charge this tick (`peak`,
# `not_worth`, `not_covered`), or None when it allowed one. Home reads
@@ -1032,8 +1033,14 @@ class Controller:
self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None
else solarfc.MISSING)
self._fc_read_mono = None
self.fc_hold = solarfc.Hold(
(getattr(store, "data", None) or {}).get(FC_HOLD_KEY))
self.day_plan = None
self._plan_targets = None
# OPT-34: `(until, {name: level %})` of a night charge under way.
self._charge_latch = _load_latch(
(getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY),
datetime.now(timezone.utc))
self.house_profile = None
if self.fc_entity is not None:
self.house_profile = dayplan.HouseProfile(
@@ -1259,6 +1266,8 @@ class Controller:
# because `self.peak_guard` is a different cycle's decision by the time
# the status page renders.
self.arb_headroom_w = None
# SAFETY-125: a grid charge sized to the quarter's room this cycle.
self.room_charge = False
# UX-13 P1: today's prices as the Home chart has seen them. Display
# only; nothing reads it back into a decision.
self.home_prices = homeview.DayPrices()
@@ -2274,6 +2283,32 @@ class Controller:
probing=probing))
return out
def _wx_track(self, now) -> None:
"""TARIFF-24, once per cycle, before the plan: with a forecast read,
remember when the run now under way (cheap, give or sell) ends."""
if self.solar_fc is None:
return
rp = self.mode_runs
ends = []
if rp is not None and rp.on:
ends = [r.end for r in (rp.cheap, rp.sell)
if r is not None and r.holds(now)]
g = rp.give_at(now)
if g is not None:
ends.append(g.end)
self._wx_free_to = max(ends) if ends else None
def weather_held(self, now) -> bool:
"""TARIFF-24: no forecast now, but a run planned with one is under
way: it keeps its gap to its end. The wider gap applies from the next
run. OPT-34: a night charge that keeps its level (`_latch_charge()`)
keeps the gap too, run after run, or the next slice of it would be
refused. Pure; Home asks the same (`homeview.trade_verdict()`)."""
return self.solar_fc is None and (
(self._wx_free_to is not None and now < self._wx_free_to)
or (self._charge_latch is not None
and now < self._charge_latch[0]))
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
"""The kept run plan, rebuilt at most once per price slot (15 min) or
when the prices or a battery's choice change; a run under way is
@@ -2287,9 +2322,11 @@ class Controller:
slot = int(now.timestamp() // 900)
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
for p in packs))
held = self.weather_held(now)
if self.fc_entity is not None:
# P4: a forecast arriving or going rebuilds the plan at once.
key += (self.solar_fc is None,)
# P4: a forecast arriving or going rebuilds the plan at once (and
# so does the end of a run that held the gap, TARIFF-24).
key += (self.solar_fc is None, held)
if key != self._runs_key:
done_c = self._run_done.get("charge", (None, set()))[1]
done_s = self._run_done.get("sell", (None, set()))[1]
@@ -2298,7 +2335,7 @@ class Controller:
required = price_required_spread(
arbitrage.threshold_eur_kwh(self.o),
price_uncertainty_of(plan.read.product)
| {Uncertainty.NO_WEATHER})
| arbitrage.weather_uncertainty(True, self.solar_fc, held))
ref = self._ref_cheap
if ref is not None and now - ref.end > REF_CHEAP_KEEP:
ref = self._ref_cheap = None
@@ -2355,11 +2392,73 @@ class Controller:
def plan_targets(self):
"""P4: each battery's planned level, or None (no day plan). Dropped
at once when the forecast goes (stale or missing)."""
at once when the forecast goes (stale or missing), except the level
of a night charge that has started (OPT-34): it keeps that level."""
if self.solar_fc is None:
return None
lt = self._charge_latch
if lt is None or datetime.now(timezone.utc) >= lt[0]:
return None
return dict(lt[1])
return self._plan_targets
def _latch_charge(self, now, runs, targets, packs=None) -> None:
"""OPT-34, once per cycle after `modetrade.decide()`. A night grid
charge that has started keeps its planned level when the forecast goes
mid-charge (the kept figure ends and the sensors are still silent).
While there is a forecast, fresh or kept, the latch is rebuilt each
cycle from the plan: a battery that charges from the grid in the cheap
run comes in with its planned level, and stays in it at whatever level
the plan gives it, up or down as a fresh figure moves it (also when it
has stopped, at a lowered level), until it leaves the plan. While there
is none, `plan_targets()` gives the latched levels, so the charge goes
on to them, and `weather_held()` keeps the narrow price gap. The latch
lasts through every cheap run on and one price slot after the last
(`CHARGE_LATCH_GRACE_S`), but a run is carried on only if it starts
by the bell start of the night the latch began in (`_night_limit()`;
the run under way then finishes). It is dropped when `now` passes its
end whatever the run state, and a battery that is off or no longer in
a mode with a planned level (`packs`) leaves it. A charge that never
started has none."""
old = lt = self._charge_latch
tick = self.mode_tick
run = None if runs is None else runs.cheap
in_run = run is not None and run.holds(now)
grace = timedelta(seconds=CHARGE_LATCH_GRACE_S)
if lt is not None and now >= lt[0]:
lt = None
if lt is not None and packs is not None:
ok = {p.name for p in packs
if p.use and p.mode in packmode.PLAN_CHARGE_MODES}
lv = {n: v for n, v in lt[1].items() if n in ok}
lt = (lt[0], lv, lt[2]) if lv else None
# A run may carry the latch on only if it starts by the night's bell.
carry = in_run and (lt is None or run.start <= _night_limit(lt[2]))
if in_run and self.solar_fc is not None:
# Rebuilt from the plan: a battery already latched follows its
# level up or down whatever its role this tick (a fresh figure may
# lower it below the SoC), one that left the plan is dropped, and
# a new one comes in only while it charges from the grid.
tg = targets or {}
lv = {n: float(tg[n]) for n in (lt[1] if lt else {}) if n in tg}
if tick is not None and tick.action == modetrade.CHARGE:
lv.update({n: float(tg[n]) for n, r in tick.roles.items()
if r == modetrade.CHARGE and n in tg})
if not lv:
lt = None
elif lt is None:
lt = (run.end + grace, lv, now)
else:
lt = (max(run.end + grace, lt[0]) if carry else lt[0], lv,
lt[2])
elif lt is not None and carry:
lt = (max(lt[0], run.end + grace), lt[1], lt[2])
if lt != old:
self._charge_latch = lt
self.store.set(CHARGE_LATCH_KEY, None if lt is None else {
"until": lt[0].isoformat(), "levels": lt[1],
"born": lt[2].isoformat()})
def _mode_roles(self, now, maint_results):
"""OPT-11 P3: each battery's role and range this tick. Grid charging
and selling need a fresh plan that `TariffPlan.arbitrage()` allows
@@ -2367,6 +2466,7 @@ class Controller:
quarter), exactly the gate `arbitrage.decide()` has."""
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
packs = self._mode_packs(maint_results)
self._wx_track(now)
runs = self._price_runs(now, packs, floor_pct, ceiling_pct)
guard = arbitrage.guard_state(self.peak_guard)
plan = self.fresh_tariff_plan(now)
@@ -2377,7 +2477,8 @@ class Controller:
return plan.arbitrage(
peak_guard_active=active,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
uncertainty=arbitrage.weather_uncertainty(
True, self.solar_fc, self.weather_held(now)),
now=now).match(
allowed=lambda _v: True, refused=lambda _v: False)
if guard is not None and plan is not None and runs is not None:
@@ -2430,6 +2531,7 @@ class Controller:
blocked=self._charge_blocked_q == quarter,
done_charge=done_c, done_sell=done_s, targets=targets,
draining=any(r.phase == DRAIN for r in maint_results or ()))
self._latch_charge(now, runs, targets, packs)
# A charge the guard or the headroom stopped stays off this period.
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
# (None) in a quarter stops this tick only, so a gap cannot cost the
@@ -2993,6 +3095,27 @@ class Controller:
soc=min(float(p.soc) for p in able),
soc_floor=peakguard.binding_soc_floor(able, floor))
def _peak_landing(self, decision):
"""SAFETY-126: `decision` marked `within_aim` while the guard wants
power but the batteries at 0 W would end the quarter within
`ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). Once a real miss
is seen, the need stands until the guard lets go. The claim and the
commands are unchanged; only the alarms and the "Why" read it.
DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md."""
batt, prev = self.site_batt_w, self._land_batt_prev
self._land_batt_prev = batt
if not (decision.engaged and decision.blind is None
and (decision.wanted_w or 0.0) > 0.0):
self._peak_needed = False
return decision
if batt is not None and prev is not None:
batt = min(float(batt), float(prev))
self._peak_needed = self._peak_needed or (
self._peak_proj is not None and peakguard.misses_aim(
self._peak_proj, batt_w=batt, target_w=decision.target_w))
return (decision if self._peak_needed
else dataclasses.replace(decision, within_aim=True))
def _peak_reserve(self) -> frozenset:
"""SAFETY-87: the batteries that give nothing for the peak this
cycle: under their reserve while the others carry a peak floor."""
@@ -3250,17 +3373,19 @@ class Controller:
return floor
def _charge_headroom(self, floor: float, claims) -> float:
"""SAFETY-84: at N>=2 a cheap-window charge pulls no more than the
quarter's headroom under the peak aim (`charge_headroom_w()`), so it
yields just enough to the peak guard, and nothing under a site stop
(its 0 W would need the other pack to give). N=1 is unchanged."""
if len(self.inverters) < 2:
return floor
if any(c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_MAINT_SHAPE for c in claims):
"""SAFETY-84: a cheap-window charge pulls no more than the quarter's
room under the peak aim (`charge_room_w()`), so it yields just enough
to the peak guard; at N>=2 nothing under a site stop (its 0 W would
need the other pack to give). SAFETY-125: at N=1 too, owner
2026-10-29; the stop rule stays N>=2 only."""
if len(self.inverters) > 1 and any(
c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_MAINT_SHAPE for c in claims):
return 0.0
room = peakguard.charge_headroom_w(self.peak_guard)
return floor if room is None else min(floor, room)
room = peakguard.charge_room_w(self.peak_guard, self._peak_proj)
floor = floor if room is None else min(floor, room)
self.room_charge = self.room_charge or floor > 0.0
return floor
def _charge_ceiling(self, claims):
"""SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None.
@@ -3417,17 +3542,25 @@ class Controller:
now = datetime.now(timezone.utc)
fc, why, gaps = solarfc.read_sum(
list(zip(self.fc_entities, states[:n])),
list(zip(self.fc_tomorrow_entities, states[n:])), now)
list(zip(self.fc_tomorrow_entities, states[n:])), now,
hold=self.fc_hold)
if self.fc_hold.changed:
self.fc_hold.changed = False
self.store.set(FC_HOLD_KEY, self.fc_hold.dump())
# A member is named only in a sum of several; one entity reads as
# before. Logged once per episode: on a change, never every read.
gaps = tuple(g for g in gaps if len(
self.fc_entities if g[0] == "today"
else self.fc_tomorrow_entities) > 1)
was_kept = bool(self.solar_fc is not None and self.solar_fc.kept)
if (fc is None) != (self.solar_fc is None) \
or why != self.solar_fc_why or gaps != self.solar_fc_gaps:
or why != self.solar_fc_why or gaps != self.solar_fc_gaps \
or bool(fc is not None and fc.kept) != was_kept:
self.log_event(
"solar forecast: " + ("read" if fc is not None else
f"none ({why})")
"solar forecast: " + (
"none (" + str(why) + ")" if fc is None else
f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept
else "read")
+ "".join(f", {d} {e} not usable ({w})"
for d, _i, e, w in gaps),
hint=messages.hint(
@@ -4839,6 +4972,7 @@ class Controller:
if self.peak_guard is not None:
self.peak_guard = self._peak_on_able(self.peak_guard,
maint_results)
self.peak_guard = self._peak_landing(self.peak_guard)
# SITE, and peakguard.claim() says so on the object it builds. The
# quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION,
# which is what the capacity tariff bills; there is one quarter-hour
@@ -4863,6 +4997,7 @@ class Controller:
self.log_event(_pg.explain(), hint=messages.hint(
"peak_off" if not _pg.engaged else
"peak_blind" if _pg.blind else
"peak_on" if _pg.within_aim else
"peak_reserve" if _pg.floor_limited else
"peak_saturated" if _pg.saturated else "peak_on"))
@@ -4910,6 +5045,7 @@ class Controller:
# decision presented as current.
self.arb, self.arb_at = None, now
self.arb_headroom_w = None
self.room_charge = False
# OPT-11 P3: with battery modes on, each battery's mode decides who
# trades (`_mode_roles()`, `_mode_trade()`, below, which read the
# same headroom), and this site-wide trade is not run.
@@ -4920,7 +5056,9 @@ class Controller:
# `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the
# caveat on the recorded watts can never be answered from a
# different cycle's guard than the watts were.
self.arb_headroom_w = peakguard.charge_headroom_w(self.peak_guard)
# SAFETY-125: a late meter reading's seconds count, as the bill.
self.arb_headroom_w = peakguard.charge_room_w(self.peak_guard,
self._peak_proj)
if site_trade:
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
@@ -4975,6 +5113,7 @@ class Controller:
arb_claim = arbitrage.claim(self.arb)
if arb_claim is not None:
claims.append(arb_claim)
self.room_charge = self.arb.action == arbitrage.CHARGE
# Edge-logged, for the peak guard's reason: the action log holds 40
# lines and a trade lasts a whole hour, so a line per cycle would
# flush the context that explains it. The transition is what an
@@ -5194,6 +5333,8 @@ class Controller:
mode_trade, trade_was = False, self._mode_trade_was
if self.modes_on:
mode_trade = self._mode_trade(claims, now)
self.room_charge = self.room_charge or (
mode_trade and self.arb.action == arbitrage.CHARGE)
self._mode_trade_was = mode_trade
self._charge_track(claims, maint_results)
_live96 = set(self.live_units())
@@ -6154,6 +6295,9 @@ class Controller:
limited = self._reserve_short(g)
else:
self._reserve_batt_prev = self._reserve_high_since = None
# SAFETY-126: nothing can give, so it alarms at once, but only
# when the quarter really misses the aim (`_peak_landing()`).
limited = limited and not g.within_aim
was, self._peak_floor_limited = self._peak_floor_limited, limited
if not limited or was:
return
@@ -7031,12 +7175,13 @@ class Controller:
if _pg is None:
out.append({"ok": True, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_waiting()})
elif _pg.saturated:
elif _pg.saturated and not _pg.within_aim:
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"text": messages.row_peak_saturated(_pg.ceiling_w)})
elif _pg.floor_limited and (not (_pg.floor_w or 0.0) > 0.0
or self._peak_floor_limited):
elif _pg.floor_limited and (
not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim)
or self._peak_floor_limited):
# SAFETY-24. NOT ok, like saturation: in both states the money
# is going to be spent and a human is the only actuator left. A
# separate row from saturation because the two ask for opposite
@@ -7087,6 +7232,15 @@ class Controller:
"text": messages.row_peak_guard_on(
_pg.floor_w or 0.0, _pg.ceiling_w,
shadow=self.shadow)})
elif _pg.projected_w is not None:
# SAFETY-125: "under" only when it is; over the limit at this
# rate, a grid charge is cut back to the quarter's room.
out.append({"ok": True, "warn": False, "id": "peak_guard",
"text": messages.row_peak_guard_idle(
_pg.projected_w, _pg.ceiling_w,
month_peak=(_pg.ceiling_from
== peakguard.CEILING_MONTH_PEAK),
cut=self.room_charge and not self.shadow)})
else:
out.append({"ok": True, "warn": False, "id": "peak_guard",
"text": _pg.explain()})
@@ -8080,11 +8234,9 @@ async def amain() -> None:
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
#
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
# enabled` ships `false`, so a site that had selected a price feed
# fetched nothing at all; the whole argument, the measurement and what
# the `or` is protecting are in `tariff_refresh_wanted()` above.
# ⚠️ STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES:
# see `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and
# TARIFF-24 for why each term is there.
#
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
# two decisions; this is the first, and `cycle()`'s
+17 -2
View File
@@ -1170,8 +1170,18 @@ def row_periods_measured(n) -> str:
f"Gemeten kwartieren deze maand: {getoond}")
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool) -> str:
"""The projection first and the limit second (SAFETY-38's ordered pair)."""
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool,
cut: bool = False) -> str:
"""The projection first and the limit second (SAFETY-38's ordered pair).
SAFETY-125: over the limit at this rate it says so, and `cut` that a
grid charge is limited to the room left."""
if float(projected_w) > float(limit_w):
doing, doet = (("grid charge limited", "laden beperkt") if cut
else ("idle", "in rust"))
return Bi(f"Peak guard: {doing}, at this rate {watts(projected_w)}, "
f"over {watts(limit_w)}",
f"Piekbewaking: {doet}, aan dit tempo "
f"{watts(projected_w)}, boven {watts(limit_w)}")
limit = (f"this month's {watts(limit_w)} peak" if month_peak
else f"the {watts(limit_w)} peak limit")
grens = (f"de maandpiek van {watts(limit_w)}" if month_peak
@@ -1563,6 +1573,11 @@ def status_samples() -> list:
("row_periods_measured", GREEN, row_periods_measured("unreadable")),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(5814, 5413, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(5814, 5413, True,
cut=True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(3814, 3500, False,
cut=True)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
+32
View File
@@ -504,6 +504,11 @@ class PeakDecision:
# when they are not asked). See `place_on_able()`.
held: tuple = ()
able_w: float | None = None
# SAFETY-126: the guard wants power, but with the batteries at 0 W the
# quarter still ends within ALARM_MISS_W of the aim (`misses_aim()`), as
# for a landing error in its last second. Set by main.py. No alarm and no
# "Why" peak while it holds; the floor and the claim are unchanged.
within_aim: bool = False
def explain(self) -> str:
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
@@ -997,6 +1002,18 @@ def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool:
return ends_w - float(ceiling_w) > ALARM_MISS_W
def misses_aim(projection, *, batt_w, target_w) -> bool:
"""SAFETY-126. With the batteries at 0 W from now on, does the quarter
end more than `ALARM_MISS_W` over the aim (`target_w`)? `decide()`'s
`wanted > 0` asks the same question with no margin and with its gain, so a
1 W landing error in a quarter's last second reads as tens of kW. A grid
charge the add-on runs counts as stoppable: it is in `batt_w`. The caller
passes the lower of two cycles' battery readings, as for
`load_too_high()`. DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md."""
return load_too_high(projection, batt_w=batt_w, ceiling_w=target_w,
achievable_w=0.0)
def charge_headroom_w(decision) -> float | None:
"""SAFETY-45. The hardest this SITE may charge without putting the billed
quarter over the ceiling, in W, positive - or None when nothing measured it.
@@ -1136,6 +1153,21 @@ def charge_headroom_w(decision) -> float | None:
return max(0.0, -float(decision.wanted_w))
def charge_room_w(decision, projection) -> float | None:
"""SAFETY-125: `charge_headroom_w()` with the seconds since the last
meter reading counted at that reading, so a late reading near a
quarter's end is not read as room for a grid charge (SAFETY-99).
DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md."""
room = charge_headroom_w(decision)
if not room or projection is None or projection.block_start is None:
return room
left = QUARTER_S - float(projection.elapsed_s)
if left <= 0.0:
return room
late_ws = max(0.0, float(projection.held_w)) * uncovered_s(projection)
return max(0.0, room - late_ws / left)
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
"""Must the maintenance charge-only shaping stand down this cycle?
+217 -16
View File
@@ -28,6 +28,20 @@ A reading older than `FRESH_S`, or from before local midnight, is stale,
and a stale or missing forecast is no forecast: the day plan is off and
every mode acts as in P3 option A.
OPT-27: but the last fresh reading of each sensor is kept (`Hold`), so the
night plan is not made blind. Forecast.Solar's sensors stop changing in the
evening and api.forecast.solar is sometimes out for a poll. A kept reading
stands in for its sensor (a) for up to `FRESH_S` when the sensor is missing
or unavailable, as a sensor that merely stopped updating is already
tolerated, and (b) from the end of the bell (`BELL_TO_H`) until local
midnight, if it is at most `KEPT_MAX_AGE_S` old when used.
Never beyond that, and never for another day: a today figure is only today's,
a tomorrow figure is tomorrow's until midnight and then, until the bell starts
(`BELL_FROM_H`, 06:00 local), today's (OPT-34: at most `KEPT_MORNING_MAX_AGE_S`
old, and it must have been usable at midnight).
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md,
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
OPT-17: each day may instead name a list (`pv_forecast_entities`,
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
not usable makes that day's sum unusable, never a partial sum.
@@ -50,6 +64,19 @@ FRESH_S = 3 * 3600.0
# The bell a state-only forecast is spread over, in local hours.
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
# OPT-27: a kept reading serves the night plan from the end of the bell until
# local midnight, and a reading older than this when used is not kept (a
# morning figure must not drive the night).
KEPT_MAX_AGE_S = 12 * 3600.0
# OPT-34: the evening's "tomorrow" figure, once midnight has passed, is today's
# until the bell starts (`BELL_FROM_H`): nothing has been produced yet, so it
# is still the whole day. A 17:05 reading is 12 h 55 min old at 06:00, so this
# case may be older than `KEPT_MAX_AGE_S`, up to this, but only a figure that
# was usable at midnight (at most `KEPT_MAX_AGE_S` old then), so one that has
# not been used all evening does not come back at 00:00.
KEPT_MORNING_MAX_AGE_S = 15 * 3600.0
SAVE_EVERY_S = 900.0
# Why there is no forecast (the page words are in ui_text `home_words`).
NOT_SET, MISSING, UNIT, STALE, BAD = (
"not_set", "missing", "unit", "stale", "bad")
@@ -75,13 +102,25 @@ class Forecast:
today: Day
tomorrow: Day | None
at: datetime # when the sensor last reported
# OPT-27: a reading kept past `FRESH_S` is in it (the evening's figure at
# night). The screens say so. Last, so `Forecast(today, tomorrow, at)` is
# as before.
kept: bool = False
def between(self, t0: datetime, t1: datetime) -> float | None:
def between(self, t0: datetime, t1: datetime,
since: datetime | None = None) -> float | None:
"""kWh the sun should give between `t0` (now or later) and `t1`.
None when the span reaches the sunny hours of a day with no
forecast (tomorrow, when its sensor is not set)."""
forecast (tomorrow, when its sensor is not set).
`since` is the moment a state-only "left today" figure counts from
(OPT-23): `t0` itself when not given, so a span that starts later
than now would be spread over what is left of the bell from its own
start. Pass now for every span of a plan, and the spans add up to
the figure. An hourly split ignores it."""
if t1 <= t0:
return 0.0
since = t0 if since is None else since
last = self.tomorrow or self.today
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
return None
@@ -92,7 +131,7 @@ class Forecast:
a = max(t0, day.day0)
b = min(t1, day.day0 + timedelta(days=1))
if b > a:
total += _day_between(day, a, b, t0)
total += _day_between(day, a, b, since)
return total
@@ -124,8 +163,12 @@ def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float:
out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds()
return out
# State only: `kwh` is what is left from `max(now, day0)` to the day's
# end, spread over the bell.
h_from = _hours_since(day.day0, max(now, day.day0))
# end, spread over the bell. Nothing of it is before that moment.
start = max(now, day.day0)
a = max(a, start)
if b <= a:
return 0.0
h_from = _hours_since(day.day0, start)
left = 1.0 - _bell(h_from)
if left <= 1e-9:
return 0.0
@@ -234,6 +277,112 @@ def _reported(state):
return max(ts) if ts else None
class Hold:
"""OPT-27: the last fresh reading of each forecast sensor, per day it is
for. `read_sum()` remembers every fresh reading here and, when a sensor
has none now, asks it (`stand_in()`).
Keyed `(kind, entity, day0)`: `kind` is "today" (energy still to come on
`day0`) or "tomorrow" (all of `day0`), and `day0` is the day the figure is
FOR. A today figure therefore never answers for another day, and a
tomorrow figure answers for its own day only: tomorrow until midnight,
then today until the bell starts (OPT-34). Only the newest reading of a
key is kept, so the next fresh reading ends the hold at once.
`dump()` / `load()` are what the controller keeps in its store, so an
evening restart does not lose it (`load()` drops what is bad).
`changed` is set when `dump()` would differ."""
def __init__(self, data=None):
self._r = {}
self._saved = {} # key -> the `at` last flagged for saving
self.changed = False
if isinstance(data, dict):
self.load(data)
def remember(self, kind, entity, day: "Day", at: datetime) -> None:
key = (kind, entity, day.day0)
old = self._r.get(key)
if old is not None and at <= old[1]:
return
self._r[key] = (day, at)
# A sensor that reports every minute with the same figure must not
# rewrite the store every minute: the saved age may lag by
# `SAVE_EVERY_S` (the bounds are hours).
mark = self._saved.get(key)
if old is None or old[0].kwh != day.kwh or mark is None \
or (at - mark).total_seconds() >= SAVE_EVERY_S:
self._saved[key] = at
self.changed = True
def forget_before(self, day0: datetime) -> None:
"""Drop what is for a day before `day0` (the local midnight now)."""
gone = [k for k in self._r if k[2] < day0]
for k in gone:
del self._r[k]
self._saved.pop(k, None)
if gone:
self.changed = True
def stand_in(self, kind, entity, day0, now, *, as_today=False):
"""`(Day, at, kept)` for a sensor that has no fresh reading, or None.
The kept reading must be for `day0`, and either (a) at most `FRESH_S`
old, or (b) used at night: from the end of the bell to local midnight,
at most `KEPT_MAX_AGE_S` old. `kept` is true when (b) was needed,
or `as_today` (OPT-34: a tomorrow figure standing in for today, after
midnight and until the bell starts, `KEPT_MORNING_MAX_AGE_S`)."""
rec = self._r.get((kind, entity, day0))
if rec is None:
return None
day, at = rec
age = (now - at).total_seconds()
if age <= FRESH_S and not as_today:
return day, at, False
if as_today:
ok = (day0 <= now < _bell_start(day0)
and age <= KEPT_MORNING_MAX_AGE_S
and (day0 - at).total_seconds() <= KEPT_MAX_AGE_S)
return (day, at, True) if ok else None
if age > KEPT_MAX_AGE_S:
return None
# A tomorrow sensor is asked with day0 = the next midnight, which is
# in the future all day, so the evening ends at that day's end.
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
return (day, at, True) if evening else None
def dump(self) -> dict:
return {"v": 1, "r": [
[kind, ent, d.day0.isoformat(), d.kwh, at.isoformat(),
[[s.isoformat(), e.isoformat(), k] for s, e, k in d.periods]]
for (kind, ent, _d0), (d, at) in sorted(
self._r.items(), key=lambda kv: (kv[0][0], kv[0][1],
kv[0][2]))]}
def load(self, data: dict) -> None:
rows = data.get("r") if isinstance(data, dict) else None
if not isinstance(rows, list):
return
for row in rows:
try:
kind, ent, d0, kwh, at, per = row
day0, at = _parse_time(d0), _parse_time(at)
kwh = float(kwh)
periods = tuple((_parse_time(s), _parse_time(e), float(k))
for s, e, k in per)
ok = (kind in ("today", "tomorrow") and isinstance(ent, str)
and day0 is not None and at is not None
and math.isfinite(kwh) and kwh >= 0
and all(s is not None and e is not None and e > s
and math.isfinite(k) and k >= 0
for s, e, k in periods))
except (TypeError, ValueError):
continue
if ok:
self._r[(kind, ent, day0)] = (Day(day0, kwh, periods), at)
self._saved[(kind, ent, day0)] = at
def entities(opts: dict, *, tomorrow=False) -> tuple:
"""The forecast entities one day names, in configured order: the list
if it has any entry, else the single key, else (). A repeated id is
@@ -255,6 +404,15 @@ def _bounds(now):
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
def _bell_start(day0):
"""The moment the bell starts on the local day that begins at `day0`
(`BELL_FROM_H` local, so 06:00 on a 23 h or 25 h day too)."""
here = day0.astimezone(BILLING_TZ)
t = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ) \
+ timedelta(hours=BELL_FROM_H)
return t.astimezone(timezone.utc)
def _today(state, day0, now):
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
today, why = _day(state, day0, now, today=True)
@@ -270,14 +428,14 @@ def _today(state, day0, now):
def _tomorrow(state, day0, day1, now):
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
"""`(Day, at, None)` or `(None, None, why)` for one tomorrow sensor."""
t, why = _day(state, day1, now, today=False)
if t is None:
return None, why
return None, None, why
t_at = _reported(state)
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
return None, STALE
return t, None
return None, None, STALE
return t, t_at, None
def read(today_state, tomorrow_state, now: datetime, *,
@@ -293,7 +451,7 @@ def read(today_state, tomorrow_state, now: datetime, *,
return None, why
tomorrow = None
if tomorrow_state is not None:
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
tomorrow, _at, _w = _tomorrow(tomorrow_state, day0, day1, now)
return Forecast(today, tomorrow, at), None
@@ -309,7 +467,12 @@ def _sum(days):
return Day(days[0].day0, sum(d.kwh for d in days), periods)
def read_sum(today, tomorrow, now: datetime):
# Why a sensor may be covered by a kept reading: it has none now, or it is
# too old. A bad value or a wrong unit is a set-up fault and is never covered.
_COVERABLE = (MISSING, STALE)
def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
`(entity, state)`, one per configured member, in configured order.
@@ -322,29 +485,67 @@ def read_sum(today, tomorrow, now: datetime):
`gaps` is `((day, position, entity, why), ...)` for every unusable
member (`day` "today" or "tomorrow", `position` 1-based), so the page
and the log can name it. With one member per day the forecast is what
`read()` gives (test_opt17.py proves it)."""
`read()` gives (test_opt17.py proves it).
OPT-27: with a `hold`, every fresh reading is remembered in it, and a
member that is missing or stale is covered by its last fresh reading
where `Hold.stand_in()` allows it (the forecast then has `kept` set when
that took a reading older than `FRESH_S`). After midnight, if the today
side is still short, the tomorrow figures read before midnight are
today's, until the bell starts (`stand_in()`). Without a `hold` this is
exactly as before."""
if not today:
return None, NOT_SET, ()
day0, day1 = _bounds(now)
gaps, days, ats, why0 = [], [], [], None
if hold is not None:
hold.forget_before(day0)
gaps, days, ats, why0, kept = [], [], [], None, False
for i, (ent, st) in enumerate(today, 1):
d, at, why = _today(st, day0, now)
if d is not None and hold is not None:
hold.remember("today", ent, d, at)
elif hold is not None and why in _COVERABLE:
r = hold.stand_in("today", ent, day0, now)
if r is not None:
d, at, k = r
kept = kept or k
if d is None:
gaps.append(("today", i, ent, why))
why0 = why0 or why
else:
days.append(d)
ats.append(at)
tdays = []
tdays, tats, tkept = [], [], False
for i, (ent, st) in enumerate(tomorrow, 1):
t, why = _tomorrow(st, day0, day1, now)
t, t_at, why = _tomorrow(st, day0, day1, now)
if t is not None and hold is not None:
hold.remember("tomorrow", ent, t, t_at)
elif hold is not None and why in _COVERABLE:
r = hold.stand_in("tomorrow", ent, day1, now)
if r is not None:
t, t_at, k = r
tkept = tkept or k
if t is None:
gaps.append(("tomorrow", i, ent, why))
else:
tdays.append(t)
tats.append(t_at)
if why0 is not None and hold is not None and tomorrow and all(
g[3] in _COVERABLE for g in gaps if g[0] == "today"):
# Past midnight the sensors may still show yesterday's "left today".
# The figure each tomorrow sensor gave before midnight is for this
# day; all of them are needed, as for any sum.
subs = [hold.stand_in("tomorrow", ent, day0, now, as_today=True)
for ent, _st in tomorrow]
if all(r is not None for r in subs):
days = [r[0] for r in subs]
ats = [r[1] for r in subs]
gaps = [g for g in gaps if g[0] != "today"]
why0, kept = None, True
gaps = tuple(gaps)
if why0 is not None:
return None, why0, gaps
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
kept = kept or (tday is not None and tkept)
# The oldest member's report is when the sum was last fully known.
return Forecast(_sum(days), tday, min(ats)), None, gaps
return Forecast(_sum(days), tday, min(ats), kept), None, gaps
+14 -8
View File
@@ -103,18 +103,18 @@
"batteries": {"name": "Batteries", "description": "To choose a mode for each battery, turn on battery modes in the settings.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings",
"on": "On", "off": "Off",
"choice": {
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."},
"leftover_solar": {"name": "Leftover solar", "description": "Gets solar the others cannot take, and gives power in expensive hours."},
"cheap_hours": {"name": "Cheap hours only", "description": "Charges in the cheapest hours and gives power in the expensive ones."},
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries, covers the house and never charges from the grid."},
"leftover_solar": {"name": "Leftover solar", "description": "Gets the solar the others cannot take, buys from the grid only what the sun will not give, and gives power in expensive hours."},
"cheap_hours": {"name": "Cheap hours only", "description": "Fills up from the grid in the cheapest hours and gives power in the expensive ones."},
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
},
"today": "Today", "why": "Why",
"cycle": {"start": "Start cycle", "abort": "Stop cycle", "start_dialog": {"name": "Start a cycle on {name}?", "description": "{name} empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"}, "abort_dialog": {"name": "Stop the cycle on {name}?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}, "toast": {"started": "Cycle started on {name}.", "stopped": "Cycle stopped on {name}."}, "unit": {"description": "There is no battery with that name."}, "busy": {"description": "Only one battery can cycle at a time, and {name} is cycling now."}, "off": {"description": "{name} is switched off, so it cannot start a cycle."}, "idle": {"description": "{name} is not running a cycle."}},
"modes_off": {"name": "Battery modes are off"},
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
"leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, and saves its power for expensive hours."},
"cheap_hours": {"description": "Cheap hours only charges in the cheapest hours and gives power in the expensive ones."},
"solar_first": {"description": "Solar first gets spare solar before the other batteries, covers the house at any hour and never charges from the grid."},
"leftover_solar": {"description": "Leftover solar gets the solar Solar first cannot take, buys only what the sun will not give, and saves its power for expensive hours."},
"cheap_hours": {"description": "Cheap hours only fills up from the grid in the cheapest hours and gives power in the expensive ones."},
"off": {"description": "Off rests the battery and starts no monthly cycle, but the peak limit may still use it."},
"rules": {"description": "The peak limit and the safety stops come first, and while one battery charges from the grid the others rest."}},
"toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}."}},
@@ -357,10 +357,10 @@
"no_prices": "No prices, so it follows the house.",
"no_spread": "Prices differ too little, so it follows the house.",
"sun_fills": "Enough sun is forecast, so it does not charge from the grid.",
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
"no_forecast": "No solar forecast, so it charges from solar only."
},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"today": {
"not_planned": "Not planned yet.",
"no_runs": "No cheap or expensive hours left today.",
@@ -385,6 +385,9 @@
"run": {
"grid_what": "Charges up to {kwh} from the grid.",
"grid_what_would": "Would charge up to {kwh} from the grid.",
"grid_what_to": "Charges up to {kwh} from the grid, to {pct}.",
"grid_what_to_would": "Would charge up to {kwh} from the grid, to {pct}.",
"covers": "It covers {kwh} of expensive hours the sun will not.",
"grid_why": "Lowest price left, {eur} per kWh.",
"grid_amount": "Costs up to {eur}.",
"give_what": "Gives up to {kwh}, to the house first.",
@@ -434,6 +437,9 @@
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
"period_unmeasured": "Part of this 15-minute period was not measured.",
"period_over": "At this rate this 15-minute period reaches {kw}.",
"charge_cut": "The grid charge is cut back to stay under {kw}.",
"charge_cut_would": "The grid charge would be cut back to stay under {kw}.",
"prices": {
"now": "Price now {eur} per kWh, all in.",
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
+14 -8
View File
@@ -100,18 +100,18 @@
"batteries": {"name": "Batterijen", "description": "Zet batterijmodi aan in de instellingen om per batterij een modus te kiezen.", "level": "Laadniveau", "now": "Nu", "stored": "Opgeslagen", "mode": "Modus", "settings": "Instellingen van omvormers wijzigen",
"on": "Aan", "off": "Uit",
"choice": {
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."},
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Krijgt zonne-energie die de andere niet opnemen, en levert in dure uren."},
"cheap_hours": {"name": "Alleen goedkope uren", "description": "Laadt in de goedkoopste uren en levert in de dure uren."},
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen, dekt het huis en laadt nooit van het net."},
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Krijgt zonne-energie die de andere niet opnemen, koopt van het net alleen wat de zon niet geeft, en levert in dure uren."},
"cheap_hours": {"name": "Alleen goedkope uren", "description": "Vult zich vol van het net in de goedkoopste uren en levert in de dure uren."},
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
},
"today": "Vandaag", "why": "Waarom",
"cycle": {"start": "Cyclus starten", "abort": "Cyclus stoppen", "start_dialog": {"name": "Cyclus starten op {name}?", "description": "{name} loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"}, "abort_dialog": {"name": "Cyclus stoppen op {name}?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"}, "toast": {"started": "Cyclus gestart op {name}.", "stopped": "Cyclus gestopt op {name}."}, "unit": {"description": "Er is geen batterij met die naam."}, "busy": {"description": "Er kan maar één batterij tegelijk een cyclus doen, en {name} is nu bezig."}, "off": {"description": "{name} staat uit en kan dus geen cyclus starten."}, "idle": {"description": "{name} doet nu geen cyclus."}},
"modes_off": {"name": "Batterijmodi staan uit"},
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, en spaart haar stroom voor dure uren."},
"cheap_hours": {"description": "Alleen goedkope uren laadt in de goedkoopste uren en levert in de dure uren."},
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, dekt het huis op elk uur en laadt nooit van het net."},
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat Eerst zonne-energie niet opneemt, koopt alleen wat de zon niet geeft, en spaart haar stroom voor dure uren."},
"cheap_hours": {"description": "Alleen goedkope uren vult zich vol van het net in de goedkoopste uren en levert in de dure uren."},
"off": {"description": "Uit laat de batterij rusten en start geen maandelijkse cyclus, maar de piekgrens mag haar nog gebruiken."},
"rules": {"description": "De piekgrens en de veiligheidsstops gaan voor, en terwijl een batterij van het net laadt rusten de andere."}},
"toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}."}},
@@ -297,10 +297,10 @@
"no_prices": "Geen prijzen, dus ze volgt het huis.",
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis.",
"sun_fills": "Er is genoeg zon voorspeld, dus ze laadt niet van het net.",
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
"tops_up": "Ze laadt van het net tot {pct}. Dat dekt {kwh} dat de zon in dure uren niet levert.",
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"today": {
"not_planned": "Nog niet gepland.",
"no_runs": "Vandaag geen goedkope of dure uren meer.",
@@ -325,6 +325,9 @@
"run": {
"grid_what": "Laadt tot {kwh} van het net.",
"grid_what_would": "Zou tot {kwh} van het net laden.",
"grid_what_to": "Laadt maximaal {kwh} van het net, tot {pct}.",
"grid_what_to_would": "Zou maximaal {kwh} van het net laden, tot {pct}.",
"covers": "Dat dekt {kwh} dat de zon in dure uren niet levert.",
"grid_why": "Laagste prijs die nog over is, {eur} per kWh.",
"grid_amount": "Kost tot {eur}.",
"give_what": "Levert tot {kwh}, eerst aan het huis.",
@@ -369,6 +372,9 @@
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
"period_over": "Aan dit tempo komt dit kwartier op {kw}.",
"charge_cut": "Laadt minder van het net om onder {kw} te blijven.",
"charge_cut_would": "Zou minder van het net laden om onder {kw} te blijven.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
"day": {
+1 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.19.0"
version: "0.19.2"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
+4 -3
View File
@@ -936,6 +936,8 @@ def _s45_assigned(name, clear=("None",)):
_s45_head_src = _s45_assigned("arb_headroom_w")
# SAFETY-125: the guard's headroom with a late reading's seconds counted.
S125_ROOM = "peakguard.charge_room_w(self.peak_guard, self._peak_proj)"
print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); "
f"charge_headroom_w= is {_s45_arg}, and self.arb_headroom_w is assigned "
f"from {_s45_head_src}")
@@ -944,7 +946,7 @@ check("main.py passes the guard's OWN measured headroom at its one "
"transcription of it, so a mutation back to a literal or to max_w is red "
"rather than silent",
_s45_arg == ["self.arb_headroom_w"]
and _s45_head_src == ["peakguard.charge_headroom_w(self.peak_guard)"])
and _s45_head_src == [S125_ROOM])
# ⚠️ AND THE HOIST DID NOT MOVE IT OUT OF THE CYCLE. An `arb_headroom_w` set
# once in `__init__` and never refreshed would satisfy the pin above and would
# size every trade from the guard decision of whatever cycle happened to run
@@ -960,8 +962,7 @@ check("...and the assignment is INSIDE the same `cycle()` that makes the call, "
"and not one carried from an arbitrary earlier one",
any(isinstance(n, ast.Assign)
and [ast.unparse(t) for t in n.targets] == ["self.arb_headroom_w"]
and ast.unparse(n.value)
== "peakguard.charge_headroom_w(self.peak_guard)"
and ast.unparse(n.value) == S125_ROOM
for n in ast.walk(_s45_cycle)))
+94 -14
View File
@@ -540,6 +540,12 @@ rp.Hass.state = _sensor
rp_start = [UTC0]
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
"cycle_cost_threshold_eur": 0.20}
# TARIFF-24 round 2: with a forecast read, a price mode needs the cycle cost
# itself (0.20 here), no longer 1.5 times it (0.30). These scenarios were
# written for a 0.30 gap, and a midday 0.21 gap now plans a grid top-up they
# are not about, so they ask for 0.30 with the forecast; the no-forecast leg
# (`rows_nf`) keeps 0.20, which is 0.30 widened: the same gate on both sides.
GATE_030 = {"cycle_cost_threshold_eur": 0.30}
def seed_profile(c):
@@ -597,7 +603,8 @@ def invariants(tag, rows, c):
# Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing.
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0},
extra=GATE_030)
charge = invariants("sunny", rows, c)
_last = nth(rows, -1, "a cycle")
print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, "
@@ -607,6 +614,35 @@ check("sunny: a day plan is made, and says sunny",
for r in rows))
check("sunny: no grid charge at all", not charge)
# TARIFF-24 round 2: the same day at the cycle cost 0.20 itself. With the
# forecast read the gap needed is 0.20 (not 1.5 x = 0.30), so the midday
# quarter 0.339 against the evening 0.551 (gap 0.21, between 1x and 1.5x) is
# a planned top-up for B (Leftover solar) up to its planned level.
rows_g, st_, c_g = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
charge_g = invariants("gap between 1x and 1.5x", rows_g, c_g)
_first_g = nth(charge_g, 0, "a charge cycle")
_gaps = [min(g.mean_eur_kwh for g in r["runs"].give)
- r["runs"].cheap.mean_eur_kwh for r in charge_g
if r["runs"] is not None and r["runs"].give]
print(f" gap 0.20 <= gap < 0.30: {len(charge_g)} charge cycles, run gap "
f"{min(_gaps, default=0):.3f}, B to "
f"{max((r['soc']['b'] for r in rows_g), default=0):.1f} %")
check("a forecast day whose price gap lies between 1x and 1.5x the cycle "
"cost (0.20 < 0.21 < 0.30) plans a cheap run: B charges from the grid "
"inside it, only B, and only up to its planned level",
present(_first_g) and len(_gaps) == len(charge_g)
and all(0.20 <= g < 0.30 for g in _gaps)
and all(r["runs"].cheap.holds(r["now"]) for r in charge_g)
and all(r["roles"][B] == modetrade.CHARGE for r in charge_g)
and all(r["roles"][A] != modetrade.CHARGE for r in charge_g)
and max(r["soc"]["b"] for r in rows_g) <= 45.64 + 2.0)
check("...and the same day with no forecast read does not (it needs 0.30): "
"the old gate, kept for a day without a forecast",
not [r for r in run_day(SF_LS, fc=None,
soc0={"a": 60.0, "b": 30.0})[0]
if r["action"] == arbitrage.CHARGE
and r["now"].astimezone(L).hour == 12])
# Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0})
charge = invariants("dark", rows, c)
@@ -623,7 +659,8 @@ check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
for r in charge))
# Cheap hours on a partly sunny day: ignores the plan, fills as in P3.
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0})
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0},
extra=GATE_030)
charge = invariants("cheap hours, partly", rows, c)
top = max(r["soc"]["b"] for r in rows)
print(f" cheap hours partly: B reached {top:.1f} %")
@@ -634,21 +671,49 @@ check("Cheap hours with a sun forecast charges as with none (P3)",
charge and abs(max(r["soc"]["b"] for r in rows_nf) - top) < 1.0
and top > 80.0)
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
# The forecast goes away mid-run: a charge under way goes on to its level
# (OPT-34; it stopped at once, P3 option A, before). With no charge under way
# the levels go with the forecast (below, and test_opt27.py).
# OPT-27: a sensor that is unavailable is covered by its last fresh reading
# for up to 3 h (`solarfc.Hold`), so "lost" here is a hold that never covers;
# the next scenario is the same loss with the hold (test_opt27.py).
DROP = UTC0 + timedelta(hours=1, minutes=30)
class _NoHold(solarfc.Hold):
def stand_in(self, *a, **k):
return None
def seed_profile_no_hold(c):
seed_profile(c)
c.fc_hold = _NoHold()
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
soc0={"a": 60.0, "b": 10.0})
soc0={"a": 60.0, "b": 10.0}, patch=seed_profile_no_hold)
invariants("forecast lost mid-run", rows, c)
before = [r for r in rows if r["now"] < DROP and r["action"]
== arbitrage.CHARGE]
late = [r for r in rows if r["now"] > DROP + timedelta(
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
check("forecast lost mid-run: Leftover solar was charging, and stops within "
"a forecast read (60 s) and a cycle", before and not late)
check("forecast lost mid-run (OPT-34): Leftover solar was charging, and goes "
"on charging past a forecast read (60 s) and a cycle, to its level",
before and late)
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
soc0={"a": 60.0, "b": 10.0})
invariants("forecast unavailable mid-run, covered", rows, c)
check("forecast unavailable mid-run (OPT-27): the last reading covers it, so "
"Leftover solar goes on charging past the point where it was lost",
[r for r in rows if r["now"] < DROP and r["action"]
== arbitrage.CHARGE]
and [r for r in rows if r["now"] > DROP + timedelta(
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE])
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
# charge, so B covers less of the evening - a money risk, never a safety one.
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0})
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0},
extra=GATE_030)
invariants("forecast too high", rows, c)
check("forecast too high: no grid charge, and B still keeps its power for "
"the dear run", not [r for r in rows if r["action"]
@@ -658,7 +723,7 @@ check("forecast too high: no grid charge, and B still keeps its power for "
# A refusal wins over the plan.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
refuse=("b",))
refuse=("b",), extra=GATE_030)
invariants("refusal", rows, c)
check("refusal: B refuses to charge, so the plan's charge ends and B is "
"never a charger after it", rows and not [
@@ -719,8 +784,10 @@ check("the forecast line names the forecast, what is counted and when",
and "we count on" in sb["line"] and sb["state"] == "plan")
why = homeview.day_key(c, B)
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
check("...and says so in plain words", homeview.why_words(why)
== "Too little sun is forecast, so it charges some from the grid.")
check("...and says so in plain words, with the level and what it covers "
"(OPT-28)", homeview.why_words(why, pct="82 %", kwh="9.4 kWh")
== "It charges from the grid to 82 %. That covers 9.4 kWh of "
"expensive hours the sun will not.")
_d0, _d1 = homeview.local_day(c.solar_fc.at)
_blocks, _ = homeview.mode_runs(c, _d0, _d1, c.solar_fc.at, False)
_grid = [b for b in _blocks if b["kind"] == "grid" and b["battery"] == 1]
@@ -734,12 +801,25 @@ _m1 = c._run_mark("charge", _run, _packs, lambda p: True, ((B, 40.0),))
_m2 = c._run_mark("charge", _run, _packs, lambda p: False, ((B, 50.0),))
check("a level the hysteresis moved opens the run to that battery again",
_m1 and not _m2)
latch, c._charge_latch = c._charge_latch, None
c.solar_fc = None
check("the levels are dropped as soon as the forecast goes, even before "
"the plan is rebuilt", c._plan_targets and c.plan_targets() is None)
check("with no charge under way, the levels are dropped as soon as the "
"forecast goes, even before the plan is rebuilt",
c._plan_targets and c.plan_targets() is None)
check("a lost forecast says so: no forecast, Leftover solar solar only",
homeview.solar_block(c, datetime.now(timezone.utc))["state"]
== "no_forecast" and homeview.day_key(c, B) == "no_forecast")
c._charge_latch = latch
# the latch ends at its end time; this replay ran in September, so ask at a
# moment inside it
_real_dt = rp.m.datetime
rp.m.datetime = type("_DT", (datetime,), {"now": classmethod(
lambda cls, tz=None: latch[0] - timedelta(hours=1))})
check("OPT-34: with a charge under way (B charged in this run), the level "
"stays: plan_targets() gives it (the line: test_opt34.py)",
latch is not None and c.plan_targets() is not None
and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B])
rp.m.datetime = _real_dt
check("the Why line wins over 'saves its power' only while holding",
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
reading=True, phase="idle", shadow=False,
@@ -754,8 +834,8 @@ check("the Why line wins over 'saves its power' only while holding",
== "grid_charge")
for lang in ("en", "nl"):
homeview._LANG[0] = lang
t = [homeview.why_words(k) for k in ("sun_fills", "tops_up",
"no_forecast")]
t = [homeview.why_words(k, pct="82 %", kwh="9.4 kWh")
for k in ("sun_fills", "tops_up", "no_forecast")]
t += [homeview._t("solar.no_forecast"), homeview._t("solar.learning"),
homeview.line_solar(9.2, 6.4, "10:15")]
check(f"{lang}: every P4 sentence is there and short (<= 2 x 12 words)",
+9 -4
View File
@@ -243,12 +243,16 @@ check("no opposite-direction split on any of these cycles",
# ============================================================================ #
print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
# Printed by tools/opt20_scen.py on release/1.0 bf9019e (2026-10-15),
# goodwe-addon-checks:1.
# goodwe-addon-checks:1. SAFETY-125 (0.19.2) moved the two runs that charge
# against the peak aim: the rig's P1 sample is 0.35 s old, and a grid charge
# now counts those seconds, so ch_ch's quarters land on 3450.0 W where they
# landed 1.3-1.4 W over it (bf9019e: 15fc25dd...), and ch_ch_maint's first,
# partial quarter charges a little less at its end (bf9019e: e46e3c66...).
AT_BASE = {
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
"ch_ch": "15fc25dd34186e56d34b752d37c9e8613295ba737938c92454acca08c83080fb",
"ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa",
"ch_ch_maint":
"e46e3c666dd63ab33b56e83c0a560c07cec7774a9a70560f55c62a6ae6f55920",
"e56eddabdb785f81793e7afa525f8e6da237b0b2165860aa8e0f8a6499b275a9",
}
_got = sc.no_probe_digests()
for _k in AT_BASE:
@@ -256,7 +260,8 @@ for _k in AT_BASE:
f"{_got[_k][:16]}")
check("with no refusal every share, grid, trade sentence, role and plant "
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
"charging, A in maintenance charge)", _got == AT_BASE)
"charging, A in maintenance charge; the last two as SAFETY-125 moved "
"them)", _got == AT_BASE)
print()
if fails:
+556
View File
@@ -0,0 +1,556 @@
"""Runnable check for OPT-23 and OPT-28. `python3 test_opt23_28.py`
Both change how `dayplan.plan()` counts the sun.
1. OPT-23: a state-only forecast ("left today" or "tomorrow", no hourly
split) is read from now for every span, so the plan's per-hour sun adds up
to the day's figure. Hourly splits are unchanged. The Forecast tab and the
plan count the same sun.
2. OPT-28, the owner's shape at 20:00 with tomorrow 2, 6 and 12 kWh: the
night level follows what the sun will not give in the expensive hours.
3. Several Leftover solar batteries: one total, shared by capacity, the rest
passed on; more than all can hold is said, not bought. Solar first never
charges from the grid, Cheap hours fills up.
4. A dynamic shape with the expensive run after sunset is unchanged.
5. The screens: the Batteries line, the Home block and the mode help, in
English and Dutch, say the same as control.
Mutations (MUTATION.md, purged copies): listed in the pull request.
"""
import json
import os
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import logging # noqa: E402
logging.disable(logging.WARNING)
from named_absence import nth, present # noqa: E402
import ux54_scenarios as S # noqa: E402
from app import dayplan, dayview, homeview, modetrade, packmode # noqa: E402
from app import priceruns, solarfc, uitext # noqa: E402
from app.tariff import BILLING_TZ # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
UTC = timezone.utc
EFF = dayplan.EFF
A, B = "inverter-a", "inverter-b"
def loc(day, hour, minute=0):
"""Local time on 2026-10-`day` (CET from the 25th), as UTC."""
return datetime(2026, 10, day, hour, minute,
tzinfo=BILLING_TZ).astimezone(UTC)
def span_sum(fc, now, end):
"""What the plan sums: every hour slice from `now`, each read from now."""
return sum(fc.between(s, e, since=now)
for s, e, _h in dayplan._hour_slices(now, end))
# ============================================================================ #
print("1. OPT-23: a state-only forecast adds up to the day's figure")
# ============================================================================ #
for hh, mm in ((7, 0), (10, 7), (13, 0), (16, 0), (19, 30)):
now = loc(29, hh, mm)
fc = S.forecast(now, 18.0, 16.0)
d1 = solarfc._bounds(now)[1]
per = span_sum(fc, now, d1)
old = sum(fc.between(s, e) for s, e, _h in dayplan._hour_slices(now, d1))
print(f" {hh:02d}:{mm:02d} left {fc.today.kwh:6.2f}, per-hour sum "
f"{per:6.2f} (without `since`: {old:6.2f})")
check(f"{hh:02d}:{mm:02d}: today's hours add up to what is left "
f"({fc.today.kwh:.2f} kWh)", abs(per - fc.today.kwh) < 1e-6)
check(f"{hh:02d}:{mm:02d}: today and tomorrow add up to both figures",
abs(span_sum(fc, now, d1 + timedelta(days=1))
- (fc.today.kwh + fc.tomorrow.kwh)) < 1e-6)
now = loc(29, 7, 0)
fc = S.forecast(now, 18.0, 16.0)
check("...and the old per-hour call, with no `since`, still reads each span "
"from its own start (74 kWh for 18 at 07:00): the plan must pass it",
sum(fc.between(s, e) for s, e, _h in dayplan._hour_slices(
now, solarfc._bounds(now)[1])) > 3 * fc.today.kwh)
now = loc(29, 23, 30)
fc = S.forecast(now, 18.0, 16.0)
d1 = solarfc._bounds(now)[1]
check("tomorrow, planned at 23:30: its 24 hours add up to tomorrow's figure",
abs(span_sum(fc, now, d1 + timedelta(days=1)) - fc.tomorrow.kwh) < 1e-6
and fc.today.kwh < 0.5)
now = loc(29, 20, 0)
fc = S.forecast(now, 18.0, 16.0)
check("tomorrow, planned at 20:00: 07:00 to 22:00 is tomorrow's figure less "
"its first hour (the bell until 07:00)",
abs(fc.between(loc(30, 7), loc(30, 22), since=now)
- 16.0 * (1.0 - solarfc._bell(7.0))) < 1e-6)
check("a span before the figure's moment is not counted (nothing of a "
"'left today' figure is before now)",
fc.between(loc(29, 8), loc(29, 12), since=loc(29, 13)) == 0.0)
now = loc(29, 10, 7)
fp = S.forecast(now, 18.0, 16.0, split=True)
d1 = solarfc._bounds(now)[1]
same = all(abs(fp.between(s, e, since=now) - fp.between(s, e)) < 1e-12
for s, e, _h in dayplan._hour_slices(now, d1 + timedelta(days=1)))
check("hourly periods are unchanged: `since` changes no span", same
and fp.today.periods != ())
check("...and they add up to the figure as before",
abs(span_sum(fp, now, d1) - fp.today.kwh) < 1e-6)
now = loc(29, 10, 7)
fc = S.forecast(now, 18.0, 16.0)
ok = True
for a, z in ((loc(29, 11), loc(29, 14)), (loc(29, 17), loc(30, 9)),
(loc(30, 8), loc(30, 12))):
hi, lo = fc.between(now, z), fc.between(now, a)
ok = ok and abs(dayview.sun_kwh(fc, now, a, z) - (hi - lo)) < 1e-9 \
and dayview.sun_kwh(fc, now, a, z) == fc.between(a, z, since=now)
check("the Forecast tab and the plan read a span the same way "
"(`dayview.sun_kwh()` = `between(since=now)`)", ok)
check("a day with no forecast is still None, with or without `since`",
fc.between(loc(29, 12), loc(31, 12), since=now) is None
and fc.between(loc(29, 12), loc(31, 12)) is None)
# ---- the UX-54 review's three Leftover solar cases (check 3) ---------------
print(" the UX-54 review's cases: 'dynamic' at 00:18, A Leftover solar, "
"20 % to 95 %, sun counted until the dear run at 17:00")
DAY0 = datetime(2026, 10, 26, 23, 0, tzinfo=UTC)
now = DAY0 + timedelta(minutes=18, seconds=5)
cases = {}
for day_kwh in (6.0, 12.0, 20.0):
c = S.make("dynamic", now)
c.solar_fc = S.forecast(now, day_kwh, day_kwh)
rp = c.mode_runs
pl = dayplan.plan(c.solar_fc, c.house_profile.estimator(), rp, now,
c._mode_packs(), trust_pct=70, floor_pct=20.0,
ceiling_pct=95.0)
cases[day_kwh] = (c, rp, pl)
SHIPPED = {6.0: 11.39, 12.0: 22.77, 20.0: 37.95} # the review, as shipped
for day_kwh, want_fc, want_sun, want_lvl, want_gc in (
(6.0, 5.34, 0.14, 68.5, 7.44), (12.0, 10.69, 3.05, 49.5, 4.53),
(20.0, 17.82, 7.40, 21.2, 0.18)):
c, rp, pl = cases[day_kwh]
cap = 15.36
use = 4 * 1.8 # the dear run 17:00-21:00, 1.8 kW
share = pl.share_kwh[A] if pl is not None else 0.0
lvl = 20.0 + use / EFF / cap * 100.0 - share / cap * 100.0
gc = max(0.0, lvl - 20.0) / 100.0 * cap
check(f"{day_kwh:.0f} kWh day: the forecast summed is {want_fc} kWh "
f"(shipped: {SHIPPED[day_kwh]}) and the sun counted {want_sun}",
pl is not None and abs(pl.fc_kwh - want_fc) < 0.01
and abs(pl.sun_kwh - want_sun) < 0.01)
check(f"{day_kwh:.0f} kWh day: the corrected sun gives the review's "
f"level {want_lvl} % and grid charge {want_gc} kWh (the "
"sun-before-the-run part, with the old use sum)",
abs(lvl - want_lvl) < 0.05 and abs(gc - want_gc) < 0.01)
# ============================================================================ #
print("2. OPT-28: the owner's shape at 20:00 (tomorrow 2, 6 and 12 kWh)")
# ============================================================================ #
# Example house (the brief: about 1 kW, evenings more): 0.6 kW at night,
# 1.2 kW 06-10, 0.9 kW by day, 1.8 kW 17-22. 24.9 kWh a day.
SHAPE = {h: (600.0 if h < 6 or h >= 22 else 1200.0 if h < 10
else 900.0 if h < 17 else 1800.0) for h in range(24)}
FLOOR, CEIL = 10.0, 95.0
CAP_A, CAP_B = 15.36, 20.48
NOW = loc(29, 20, 0)
def profile_of(shape):
day = NOW.astimezone(BILLING_TZ).date()
return dayplan.HouseProfile(
{(day - timedelta(days=k)).isoformat():
{str(h): w for h, w in shape.items()} for k in (1, 2, 3)})
def owner_runs():
"""Day 07:00-22:00 at 0.2801, night at 0.2333 (the brief's tariff)."""
return priceruns.RunPlan(
priceruns.Run(loc(29, 22), loc(30, 7), 0.2333),
(priceruns.Run(loc(29, 7), loc(29, 22), 0.2801),
priceruns.Run(loc(30, 7), loc(30, 22), 0.2801)),
None, 0.2801, None, True)
def pk(name, mode, soc=FLOOR, cap=CAP_A, rating=5000.0):
return modetrade.Pack(name, True, mode, False, rating, soc, cap)
def forecast_tomorrow(kwh, now=NOW, left=0.0):
"""A state-only forecast: `left` still to come today, `kwh` tomorrow."""
d0, _d1 = solarfc._bounds(now)
day1 = d0 + timedelta(days=1)
return solarfc.Forecast(solarfc.Day(d0, left), solarfc.Day(day1, kwh),
now)
def owner_plan(kwh, packs, shape=SHAPE, runs=None, now=NOW, fc=None):
return dayplan.plan(fc or forecast_tomorrow(kwh, now),
profile_of(shape).estimator(), runs or owner_runs(),
now, packs, trust_pct=70, floor_pct=FLOOR,
ceiling_pct=CEIL)
def expect_short(kwh, shape=SHAPE, hours=range(7, 22), trust=0.7, lift=True):
"""The brief's sum, written out: per expensive hour max(0, house -
trust x sun). The sun of an hour is the bell's share of tomorrow's
figure; an hour with sun is read at no less than the night base."""
base = sorted(shape[h] for h in (0, 1, 2, 3, 4, 5, 22, 23))
base = (base[3] + base[4]) / 2.0
tot = 0.0
for h in hours:
pv = kwh * (solarfc._bell(h + 1.0) - solarfc._bell(float(h)))
use = (max(shape[h], base) if pv > 0 and lift else shape[h]) / 1000.0
tot += max(0.0, use - trust * pv)
return tot
def expect_level(short, cap, share_pct=0.0):
return max(0.0, min(CEIL, FLOOR + short / EFF / cap * 100.0) - share_pct)
res = {}
for kwh in (2.0, 6.0, 12.0):
pl = owner_plan(kwh, [pk(A, packmode.LEFTOVER_SOLAR),
pk(B, packmode.SOLAR_FIRST, cap=CAP_B,
rating=4600.0)])
res[kwh] = pl
sh = expect_short(kwh)
lv = pl.targets.get(A) if pl is not None else None
print(f" tomorrow {kwh:4.1f} kWh: dear-hour shortfall "
f"{pl.short_kwh:5.2f} kWh (check {sh:5.2f}), A to {lv} %, grid "
f"{pl.need_kwh[A]:5.2f} kWh, unheld {pl.uncovered_kwh:4.2f} kWh")
check(f"{kwh:g} kWh: the shortfall is the sum of max(0, house - 0.7 x "
f"sun) over 07:00-22:00 ({sh:.2f} kWh)",
pl is not None and abs(pl.short_kwh - sh) < 0.005)
check(f"{kwh:g} kWh: A's level is floor + shortfall / 0.95, from the "
"floor, capped at the ceiling",
present(lv) and abs(lv - expect_level(sh, CAP_A)) < 0.05)
check("the target follows the shortfall: more sun, a lower level",
res[2.0].targets[A] >= res[6.0].targets[A] > res[12.0].targets[A])
check("12 kWh: below 95 % because the sun covers part (was 95 % for any "
"forecast)", res[12.0].targets[A] < 90.0
and res[12.0].need_kwh[A] < res[2.0].need_kwh[A])
check("2 kWh: more than A holds is not bought: A to 95 %, the rest said "
"(`uncovered_kwh`)", res[2.0].targets[A] == CEIL
and res[2.0].uncovered_kwh > 1.0)
check("12 kWh: nothing is left over", res[12.0].uncovered_kwh == 0.0)
check("B on Solar first never gets a level (never charges from the grid)",
B not in res[6.0].targets and modetrade.charge_to(
pk(B, packmode.SOLAR_FIRST, cap=CAP_B), CEIL, res[6.0].targets)
is None)
more = owner_plan(6.0, [pk(A, packmode.LEFTOVER_SOLAR),
pk(B, packmode.CHEAP_HOURS, cap=CAP_B,
rating=4600.0)])
check("B on Cheap hours fills up and is no part of A's share: A's level is "
"the same, B is not in the plan",
more.targets == res[6.0].targets and B not in more.targets
and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, cap=CAP_B), CEIL,
more.targets) == CEIL)
# The profile is net of the sun the house used, so an hour with sun is read
# at no less than the night base (0.6 kW), as the sun sum reads it.
NET = dict(SHAPE)
for h in range(7, 18):
NET[h] = 100.0
pnet = owner_plan(6.0, [pk(A, packmode.LEFTOVER_SOLAR)], shape=NET)
print(f" a profile net of the sun (0.1 kW by day): shortfall "
f"{pnet.short_kwh:.2f} kWh, read at the night base; read as measured "
f"it would be {expect_short(6.0, NET, lift=False):.2f}")
check("an hour with sun is read at no less than the night base, an hour "
"without as measured",
abs(pnet.short_kwh - expect_short(6.0, NET)) < 0.005
and expect_short(6.0, NET) > expect_short(6.0, NET, lift=False) + 1.0)
# The sun before the run still fills first, as before: at 12:00 the rest of
# today's sun beyond the house (0.6 kW) is counted, x trust x 0.95.
N12 = loc(29, 12, 0)
flat = {h: 600.0 for h in range(24)}
fc12 = forecast_tomorrow(6.0, N12, left=9.0)
runs12 = owner_runs()
pl12 = dayplan.plan(fc12, profile_of(flat).estimator(), runs12, N12,
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70,
floor_pct=FLOOR, ceiling_pct=CEIL)
left_share = 1.0 - solarfc._bell(12.0)
excess = sum(max(0.0, 9.0 * (solarfc._bell(h + 1.0) - solarfc._bell(float(h)))
/ left_share - 0.6) for h in range(12, 20))
sun_share = 0.7 * EFF * excess
print(f" 12:00, 9 kWh left today, house 0.6 kW: the sun before the run "
f"is {sun_share:.2f} kWh, the plan counts {pl12.sun_kwh:.2f}")
check("the sun before the run keeps filling: trust x 0.95 x the sun the "
"house will not use, read from now for every hour (OPT-23)",
sun_share > 0.5 and abs(pl12.sun_kwh - sun_share) < 0.01
and abs(pl12.share_kwh[A] - sun_share) < 0.01)
check("...and it is taken off the level the grid charges to",
abs(pl12.targets[A] - expect_level(
expect_short(6.0, flat), CAP_A, sun_share / CAP_A * 100.0)) < 0.05
and pl12.targets[A] < expect_level(expect_short(6.0, flat), CAP_A))
# ============================================================================ #
print("3. several batteries: one total, shared by capacity")
# ============================================================================ #
two = [pk(A, packmode.LEFTOVER_SOLAR),
pk(B, packmode.LEFTOVER_SOLAR, cap=CAP_B, rating=4600.0)]
p2 = owner_plan(6.0, two)
held = sum((p2.targets[n] - FLOOR) / 100.0 * c
for n, c in ((A, CAP_A), (B, CAP_B)))
sh = expect_short(6.0)
print(f" two Leftover solar, 6 kWh: {p2.targets}, together "
f"{held:.2f} kWh above the floor (shortfall / 0.95 = "
f"{sh / EFF:.2f})")
check("the total is what counts: A and B hold the shortfall / 0.95 "
"together", abs(held - sh / EFF) < 0.01)
check("...shared by capacity: the same level, so B holds more kWh than A",
abs(p2.targets[A] - p2.targets[B]) < 0.01
and p2.cover_kwh[B] > p2.cover_kwh[A]
and abs(p2.cover_kwh[B] / p2.cover_kwh[A] - CAP_B / CAP_A) < 0.01)
check("...and covers it between them (cover_kwh)",
abs(sum(p2.cover_kwh.values()) - sh) < 0.01 and p2.uncovered_kwh == 0)
# A shortfall more than ONE holds: a house of 1.8 kW all day.
big = {h: 1800.0 for h in range(24)}
p3 = owner_plan(2.0, two, shape=big)
sh3 = expect_short(2.0, big)
print(f" 1.8 kW house, 2 kWh: shortfall {sh3:.1f} kWh = {sh3 / EFF:.1f} "
f"kWh stored; A holds {(CEIL - FLOOR) / 100 * CAP_A:.1f}, B "
f"{(CEIL - FLOOR) / 100 * CAP_B:.1f}; levels {p3.targets}")
check("a shortfall of more than one battery holds, less than both: both "
"take their share by capacity, the total is met",
sh3 / EFF > (CEIL - FLOOR) / 100 * CAP_B
and sh3 / EFF < (CEIL - FLOOR) / 100 * (CAP_A + CAP_B)
and p3.targets[A] < CEIL and p3.targets[B] < CEIL
and abs(sum((p3.targets[n] - FLOOR) / 100.0 * c
for n, c in ((A, CAP_A), (B, CAP_B))) - sh3 / EFF) < 0.01
and p3.uncovered_kwh == 0.0)
# More than both hold.
huge = {h: 4000.0 for h in range(24)}
p4 = owner_plan(2.0, two, shape=huge)
sh4 = expect_short(2.0, huge)
rooms = (CEIL - FLOOR) / 100 * (CAP_A + CAP_B)
print(f" 4 kW house, 2 kWh: shortfall {sh4:.1f} kWh; both hold "
f"{rooms * EFF:.1f}; unheld {p4.uncovered_kwh:.2f} kWh; "
f"levels {p4.targets}")
check("more than all of them hold: both go to the ceiling, never above",
p4.targets[A] == CEIL and p4.targets[B] == CEIL)
check("...and the rest is said, not bought: another battery or the grid "
"carries it (`uncovered_kwh` = shortfall less what they cover)",
abs(p4.uncovered_kwh - (sh4 - rooms * EFF)) < 0.01
and abs(sum(p4.cover_kwh.values()) - rooms * EFF) < 0.01)
# The pass-through itself: a battery that reaches its ceiling passes the rest.
got, left = dayplan._spread(10.0, {"x": 2.0, "y": 20.0}, {"x": 1.0, "y": 1.0})
check("a battery that reaches its ceiling passes the rest on: one fills, "
"the other takes the rest",
abs(got["x"] - 2.0) < 1e-9 and abs(got["y"] - 8.0) < 1e-9
and left == 0.0)
got, left = dayplan._spread(30.0, {"x": 2.0, "y": 20.0}, {"x": 1.0, "y": 3.0})
check("...by weight, and what none can take is returned",
abs(got["x"] - 2.0) < 1e-9 and abs(got["y"] - 20.0) < 1e-9
and abs(left - 8.0) < 1e-9)
got, left = dayplan._spread(6.0, {"x": 10.0, "y": 10.0}, {"x": 1.0, "y": 3.0})
check("...and shares by weight when none is full (1:3)",
abs(got["x"] - 1.5) < 1e-9 and abs(got["y"] - 4.5) < 1e-9)
check("a weight of 0 or a room of 0 takes nothing",
dayplan._spread(5.0, {"x": 0.0, "y": 9.0}, {"x": 1.0, "y": 0.0})
== ({"x": 0.0, "y": 0.0}, 5.0))
# Off, in maintenance, or with no level: no part in it.
off = [pk(A, packmode.LEFTOVER_SOLAR),
modetrade.Pack(B, False, packmode.LEFTOVER_SOLAR, False, 4600.0,
FLOOR, CAP_B)]
check("a battery that is off takes no part: A carries it alone",
owner_plan(6.0, off).targets == res[6.0].targets)
# ============================================================================ #
print("4. a dear run after sunset is unchanged")
# ============================================================================ #
# Cheap 01:00-05:00, dear 20:00-23:00 (no sun after 20:00). The house is
# below the night base in the dear hours (0.4 kW against 0.6): measured
# as the profile says, as before. An hourly split, so OPT-23 changes nothing.
T0 = loc(29, 0, 30)
DYN = priceruns.RunPlan(
priceruns.Run(loc(29, 1), loc(29, 5), 0.08),
(priceruns.Run(loc(29, 20), loc(29, 23), 0.34),), None, 0.34, None, True)
LOW = dict(SHAPE)
for h in range(20, 23):
LOW[h] = 400.0
def old_plan_level(pl, runs, shape, caps, now):
"""The old sum, written out: the house's use over the dear runs of a
day from the one the plan is for, shared by capacity, from the floor."""
until = nth([g for g in runs.give if g.start >= runs.cheap.end], 0)
use = 0.0
for g in runs.give:
if until.start <= g.start < until.start + timedelta(hours=24):
for s, e, h in dayplan._hour_slices(g.start, g.end):
use += max(0.0, shape[h]) * (e - s).total_seconds() / 3.6e6
out = {}
for n, cap in caps.items():
top = min(CEIL, FLOOR + use * cap / sum(caps.values()) / EFF
/ cap * 100.0)
out[n] = round(max(0.0, top - pl.share_kwh[n] / cap * 100.0), 2)
return out
for kwh in (2.0, 12.0, 40.0):
fcs = S.forecast(T0, kwh, kwh, split=True, members=1)
for label, packs, caps in (
("A alone", [pk(A, packmode.LEFTOVER_SOLAR)], {A: CAP_A}),
("A + B", two, {A: CAP_A, B: CAP_B})):
pl = dayplan.plan(fcs, profile_of(LOW).estimator(), DYN, T0, packs,
trust_pct=70, floor_pct=FLOOR, ceiling_pct=CEIL)
want = old_plan_level(pl, DYN, LOW, caps, T0)
check(f"{kwh:g} kWh, {label}: the levels are the old sum's "
f"{want}", pl is not None and pl.targets == want
and pl.short_kwh == round(0.4 + 0.4 + 0.4, 3))
fcs = S.forecast(T0, 12.0, 12.0, split=True, members=1)
pl = dayplan.plan(fcs, profile_of(LOW).estimator(), DYN, T0,
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70,
floor_pct=FLOOR, ceiling_pct=CEIL)
pr = dayplan.plan(
fcs, profile_of(LOW).estimator(), DYN, T0,
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70, floor_pct=FLOOR,
ceiling_pct=CEIL)
check("the same call twice gives the same plan (pure)", pl == pr)
# The same shape with the dear run at 17:00 (sun until 20:00): lower.
DYN17 = priceruns.RunPlan(
priceruns.Run(loc(29, 1), loc(29, 5), 0.08),
(priceruns.Run(loc(29, 17), loc(29, 21), 0.34),), None, 0.34, None, True)
fcs = S.forecast(T0, 12.0, 12.0, split=True, members=1)
p17 = dayplan.plan(fcs, profile_of(SHAPE).estimator(), DYN17, T0,
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70,
floor_pct=FLOOR, ceiling_pct=CEIL)
o17 = old_plan_level(p17, DYN17, SHAPE, {A: CAP_A}, T0)
check("a dear run with sun in it (17:00-21:00, 12 kWh): the level is lower "
"than the old sum's, by the sun the house will not use",
p17.targets[A] < o17[A] - 3.0)
# ============================================================================ #
print("5. the screens say what control does (EN and NL)")
# ============================================================================ #
now = DAY0 + timedelta(minutes=18, seconds=5)
c = S.make("dynamic", now, soc=(20.0, 20.0))
c.solar_fc = S.forecast(now, 3.0, 3.0)
c._runs_key = None
c._mode_roles(now, None)
tg = c.plan_targets()
dp = c.day_plan
cover = homeview.plan_cover(c, A)
print(f" plan: {tg}, cover {cover}, shortfall {dp.short_kwh}")
check("the controller holds a level for A (Leftover solar) and a cover "
"figure that matches the plan", present(tg) and A in tg
and cover is not None and abs(cover[0] - tg[A]) < 1e-9
and abs(cover[1] - dp.cover_kwh[A]) < 0.01)
out = {}
for lang in ("en", "nl"):
st = homeview.build(c, level="ok", checks=[], now=now, lang=lang)
blk = nth([r for r in st["plan"]["runs"]
if r["battery"] == 0 and r["kind"] == "grid"], 0)
blk_b = nth([r for r in st["plan"]["runs"]
if r["battery"] == 1 and r["kind"] == "grid"], 0)
row = nth(st["batteries"], 0)
out[lang] = (st, blk, blk_b, row)
print(f" {lang}: {blk['what']} | {blk['why']}")
print(f" {lang}: {row['why']}")
with homeview.in_lang(lang):
pct = homeview._pct(tg[A])
kwh = homeview._kwh(cover[1])
need = homeview._kwh(homeview.plan_need(c, A))
check(f"{lang}: Home's grid block names the level and what it covers",
present(blk) and pct in blk["what"] and kwh in blk["why"])
check(f"{lang}: the Batteries line names the same level and figure",
present(row) and pct in row["why"] and kwh in row["why"])
check(f"{lang}: Home's block is the control's grid charge "
"(plan_need)", present(blk) and need in blk["what"])
check(f"{lang}: B on Cheap hours has no plan, so its block keeps the "
"old words", present(blk_b) and "%" not in blk_b["what"]
and kwh not in blk_b["why"])
en, nl = out["en"], out["nl"]
check("EN: the Batteries line says it plainly",
"charges from the grid to" in en[3]["why"]
and "expensive hours the sun will not" in en[3]["why"])
check("NL: and the same in Dutch (a natural sentence)",
"laadt van het net tot" in nl[3]["why"]
and "dure uren" in nl[3]["why"] and "{" not in nl[3]["why"])
check("Dutch uses a decimal comma", "," in nl[1]["what"]
and "," in nl[1]["why"] and "." not in nl[1]["what"].rstrip("."))
# The words of the plan lines are STYLE.md plan lines: two short sentences.
for lang in ("en", "nl"):
w = uitext.words(lang)["home_words"]
for key in ("run.covers", "run.grid_what_to", "why.tops_up",
"solar.uncovered"):
node = w
for k in key.split("."):
node = node[k]
sentences = [s for s in node.replace("%", "").split(".") if s.strip()]
check(f"{lang}: '{key}' is at most 2 sentences of at most 12 words",
len(sentences) <= 2 and all(
len(s.split()) <= 12 for s in sentences))
# More than all hold: the Batteries screen says another battery or the grid
# carries the rest (here B is on Cheap hours: "the grid" alone is not true).
c2 = S.make("dynamic", now, soc=(20.0, 20.0))
c2.solar_fc = S.forecast(now, 1.0, 1.0)
c2.house_profile = profile_of({h: 3500.0 for h in range(24)})
c2._runs_key = None
c2._mode_roles(now, None)
dp2 = c2.day_plan
sol = homeview.solar_block(c2, now)
print(f" a 3.5 kW house: shortfall {dp2.short_kwh:.1f} kWh, unheld "
f"{dp2.uncovered_kwh:.1f} kWh")
check("the plan finds more than the battery holds", dp2.uncovered_kwh > 1.0
and c2.plan_targets()[A] == 95.0)
check("EN: the line under the Batteries cards says Leftover solar cannot "
"hold it all and the rest comes from another battery or the grid",
"more than Leftover solar can hold" in sol["line"]
and "another battery or the grid" in sol["line"]
and homeview._kwh(dp2.uncovered_kwh) in sol["line"])
with homeview.in_lang("nl"):
sol_nl = homeview.solar_block(c2, now)
check("NL: and in Dutch", "meer dan Overschot zonne-energie kan opslaan"
in sol_nl["line"] and "andere batterij of het net" in sol_nl["line"]
and "{" not in sol_nl["line"])
check("a covered plan has no such sentence",
"can hold" not in homeview.solar_block(c, now)["line"])
# The mode help.
en_w, nl_w = uitext.words("en"), uitext.words("nl")
def help_of(w, mode):
b = w["home"]["batteries"]
return (b["choice"][mode]["description"],
b["how"][mode]["description"])
for lang, w, want in (
("EN", en_w, {"leftover_solar": "only what the sun will not give",
"cheap_hours": "Fills up",
"solar_first": "never charges from the grid"}),
("NL", nl_w, {"leftover_solar": "alleen wat de zon niet geeft",
"cheap_hours": "vult zich vol",
"solar_first": "nooit van het net"})):
for mode, phrase in want.items():
d1_, d2_ = help_of(w, mode)
check(f"{lang}: the {mode} help says '{phrase}' (list and "
"'How the modes work')",
phrase.lower() in d1_.lower() and phrase.lower() in d2_.lower())
for lang, w in (("EN", en_w), ("NL", nl_w)):
for mode in ("solar_first", "leftover_solar", "cheap_hours"):
for d in help_of(w, mode):
check(f"{lang}: {mode} help is under 30 words", len(d.split()) <= 30)
print()
if fails:
print(f"FAILED: {len(fails)}")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")
+655
View File
@@ -0,0 +1,655 @@
"""Runnable check for OPT-27. `python3 test_opt27.py`
Forecast.Solar's sensors stop changing in the evening, so at night every
Forecast.Solar site was "no forecast": a price mode's night cheap run needed
1.5x the cycle cost and Leftover solar bought nothing. And one missed poll
(`unavailable`) dropped the forecast at once. `solarfc.Hold` now keeps the last
fresh reading of each sensor, for the day it is FOR:
* up to `FRESH_S` when the sensor is missing or unavailable (a short outage);
* from the end of the bell (20:00) until local midnight, if the reading is at
most `KEPT_MAX_AGE_S` (12 h) old when used (the night plan); after midnight
the evening's tomorrow figure is today's until the bell starts (06:00, OPT-34,
test_opt34.py); never beyond, never for another day.
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
1. `solarfc` alone: the evening, the outage, the bounds, the day a figure is
for, restart (dump/load), and no hold = as before.
2. The real Controller on a day/night tariff (0.34/0.26, gap 0.08, cycle cost
0.07), forecast sensors that stop updating at 17:05 local:
a. the 22:00 night run is planned with the cycle-cost gap and charges;
b. no forecast all day: the night run still needs 1.5x and does not charge;
c. an evening restart keeps it (a new Controller on the saved store);
d. a one-hour outage at 18:47 does not drop it;
e. from 06:00 (the bell) the figure is not used, before it it is (OPT-34).
3. The screens: the forecast line says when it was read and that it is kept
for tonight (EN and NL), and Home agrees with control.
Mutations (MUTATION.md, purged copies, graded over this file): see the
DECISIONS note and the dev report.
"""
import asyncio
import json
import logging
import os
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import app.main as m # noqa: E402
from app import homeview, modetrade, solarfc # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
def at(day, hh, mm=0):
"""Local time on 2026-11-`day` (CET) as UTC."""
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
def st(kwh, when, *, unit="kWh"):
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
"last_updated": when.isoformat(),
"last_reported": when.isoformat()}
GONE = {"state": "unavailable", "attributes": {}}
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
# --------------------------------------------------------------------------- #
print("1. solarfc: the hold")
D1_READ = at(3, 17, 5) # the last fresh reading, 17:05
EVE = at(3, 22, 0)
def rs(today, tomorrow, now, hold):
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
h = solarfc.Hold()
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 17, 10), h)
check("a fresh reading is a forecast, not kept", fc is not None
and not fc.kept and fc.tomorrow.kwh == 12.0 and fc.today.kwh == 0.4)
check("it is remembered (the hold is flagged for saving)", h.changed)
fc_no, why_no, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, None)
check("with no hold, 22:00 is no forecast (as before)",
fc_no is None and why_no == solarfc.STALE)
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, h)
print(f" 22:00, sensors still show 17:05: {fc and fc.kept=}, {gaps=}")
check("22:00 with the sensors stale since 17:05: the 17:05 figures are used",
fc is not None and fc.kept and fc.tomorrow.kwh == 12.0
and fc.at == D1_READ and gaps == ())
check("... today has no sun left in it, tomorrow's is counted from 06:00",
fc is not None and fc.between(EVE, at(4, 7, 0)) is not None
and 0.0 < fc.between(EVE, at(4, 7, 0)) < 0.5
and fc.today.kwh == 0.4)
fc2, _w, _g = rs(GONE, GONE, EVE, h)
check("the same when the sensors are unavailable at 22:00",
fc2 is not None and fc2.kept and fc2.tomorrow.kwh == 12.0)
# A new fresh reading replaces the old one at once.
fc3, _w, _g = rs(st(0.0, at(3, 22, 20)), st(14.5, at(3, 22, 20)),
at(3, 22, 30), h)
check("a newer fresh reading is used and is not 'kept'",
fc3 is not None and not fc3.kept and fc3.tomorrow.kwh == 14.5)
fc3b, _w, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 23, 0), h)
check("... and later the newest reading is the one kept, not the old one",
fc3b is not None and fc3b.tomorrow.kwh == 14.5)
# A short outage: unavailable for one or two polls, any time of day.
h2 = solarfc.Hold()
rs(st(1.1, at(3, 18, 5)), st(12.0, at(3, 18, 5)), at(3, 18, 6), h2)
for label, now in (("18:47", at(3, 18, 47)), ("19:20", at(3, 19, 20)),
("19:47", at(3, 19, 47)), ("20:55", at(3, 20, 55))):
fo, _w, _g = rs(GONE, GONE, now, h2)
check(f"unavailable at {label}: the 18:05 forecast is still used",
fo is not None and fo.tomorrow.kwh == 12.0)
fo, _w, _g = rs(GONE, GONE, at(3, 20, 55), solarfc.Hold())
check("(nothing was ever read: still no forecast)", fo is None)
# Daytime is as before: an outage longer than FRESH_S drops it.
h3 = solarfc.Hold()
rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h3)
d_in = rs(GONE, GONE, at(4, 11, 50), h3)[0]
d_out = rs(GONE, GONE, at(4, 12, 10), h3)[0]
d_out2 = rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 14, 0), h3)[0]
check("daytime: an outage under 3 h is covered, one over 3 h is not",
d_in is not None and not d_in.kept and d_out is None)
check("daytime: a sensor that stopped is stale after 3 h, as before "
"(the hold does not start before the bell ends)", d_out2 is None)
# Round 2: today fresh, tomorrow's sensor stopped or down in the daytime.
h9 = solarfc.Hold()
rs(st(5.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h9)
for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)):
fd = rs(st(3.0, at(4, 13, 55)), tm, at(4, 14, 0), h9)[0]
check(f"14:00, today fresh, tomorrow {label} since 09:05 (over 3 h): "
"no tomorrow, nothing kept",
fd is not None and fd.tomorrow is None and not fd.kept)
fn = rs(st(0.1, at(4, 20, 55)), tm, at(4, 21, 0), h9)[0]
check(f"21:00, tomorrow {label}: the 09:05 figure covers it, kept",
fn is not None and fn.tomorrow is not None
and fn.tomorrow.kwh == 12.0 and fn.kept)
fs = rs(st(3.0, at(4, 10, 55)), tm, at(4, 11, 0), h9)[0]
check(f"11:00, tomorrow {label} for under 3 h: still covered, not kept",
fs is not None and fs.tomorrow is not None and not fs.kept)
# The bound: the bell start (06:00), and the day a figure is for.
fm = rs(st(0.0, at(3, 23, 0)), st(0.0, at(3, 23, 0)), at(4, 1, 0), h2)[0]
check("00:30 is covered by the 18:05 tomorrow figure, as TODAY's",
rs(GONE, GONE, at(4, 0, 30), h2)[0] is not None)
fmid = rs(GONE, GONE, at(4, 2, 59), h2)[0]
print(f" 02:59: {fmid and (fmid.today.kwh, fmid.tomorrow, fmid.kept)}")
check("02:59: tomorrow's 12.0 kWh is now today's, there is no tomorrow",
fmid is not None and fmid.today.kwh == 12.0 and fmid.tomorrow is None
and fmid.kept and fmid.today.day0 == at(4, 0, 0))
check("... its sun is counted on the day it is for (06:00-20:00 on the 4th)",
fmid is not None and fmid.between(at(4, 3, 0), at(4, 7, 0)) is not None
and fmid.between(at(4, 3, 0), at(4, 7, 0)) > 0.0)
check("03:01: still used (OPT-34: until the bell starts at 06:00)",
rs(GONE, GONE, at(4, 3, 1), h2)[0] is not None
and rs(GONE, GONE, at(4, 5, 59), h2)[0] is not None)
check("06:00 (the bell starts): not used any more",
rs(GONE, GONE, at(4, 6, 0), h2)[0] is None)
check("... and not the next evening either (past 06:00)",
rs(GONE, GONE, at(4, 22, 0), h2)[0] is None)
fe = rs(st(0.1, at(4, 21, 50)), GONE, at(4, 22, 0), h2)[0]
check("... the next evening, with today fresh and tomorrow's sensor down, "
"there is no tomorrow: the 4th's figure is not the 5th's",
fe is not None and fe.today.kwh == 0.1 and fe.tomorrow is None)
# Never today's figure for tomorrow.
h4 = solarfc.Hold()
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h4)
# Next day 00:30: today's sensor is fresh again (4.0), tomorrow's is gone.
fx = rs(st(4.0, at(4, 0, 20)), GONE, at(4, 0, 30), h4)[0]
check("00:30: today is the fresh 4.0, and there is NO tomorrow "
"(the 3rd's 'today' 3.0 and 'tomorrow' 12.0 are not the 5th's)",
fx is not None and fx.today.kwh == 4.0 and fx.tomorrow is None
and not fx.kept)
fy = rs(st(4.0, at(4, 0, 20)), st(9.0, at(4, 0, 20)), at(4, 0, 30), h4)[0]
check("00:30 with both fresh: tomorrow is the fresh 9.0, not the old 12.0",
fy is not None and fy.tomorrow.kwh == 9.0 and not fy.kept)
# A tomorrow sensor that is down while today's is fresh, in the evening: the
# hold gives the tomorrow figure for tomorrow, never today's figure.
h5 = solarfc.Hold()
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h5)
fz = rs(st(0.2, at(3, 21, 30)), GONE, at(3, 21, 40), h5)[0]
check("21:40: today fresh, tomorrow down: tomorrow is the kept 12.0 (not "
"today's 0.2 or 3.0)", fz is not None and fz.tomorrow.kwh == 12.0
and fz.today.kwh == 0.2 and fz.kept)
# A set-up fault is never covered.
h6 = solarfc.Hold()
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h6)
fb = rs(st("lots", at(3, 21, 30)), st(12.0, at(3, 21, 30)),
at(3, 21, 40), h6)
fu = rs(st(0.2, at(3, 21, 30), unit="W"), st(12.0, at(3, 21, 30)),
at(3, 21, 40), h6)
check("a bad value or a wrong unit is not covered by the hold",
fb[0] is None and fb[1] == solarfc.BAD and fu[0] is None
and fu[1] == solarfc.UNIT)
# Several sensors: all or nothing.
h7 = solarfc.Hold()
two_t = [("sensor.t1", st(1.0, at(3, 17, 0))),
("sensor.t2", st(2.0, at(3, 17, 0)))]
two_m = [("sensor.m1", st(5.0, at(3, 17, 0))),
("sensor.m2", st(6.0, at(3, 17, 0)))]
solarfc.read_sum(two_t, two_m, at(3, 17, 5), h7)
gone2 = [("sensor.t1", GONE), ("sensor.t2", GONE)]
f2 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
at(3, 22, 0), h7)
check("three or two sensors: the kept figures are summed",
f2[0] is not None and f2[0].tomorrow.kwh == 11.0
and f2[0].today.kwh == 3.0 and f2[2] == ())
h8 = solarfc.Hold()
solarfc.read_sum(two_t[:1] + [("sensor.t2", GONE)], two_m, at(3, 17, 5), h8)
f3 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
at(3, 22, 0), h8)
check("one sensor never read fresh: no partial sum, the gap is named",
f3[0] is None and any(g[:3] == ("today", 2, "sensor.t2") for g in f3[2]))
# Restart: what the store holds comes back.
saved = json.loads(json.dumps(h.dump()))
hr = solarfc.Hold(saved)
fr = rs(GONE, GONE, at(3, 23, 0), hr)[0]
check("dump/load through JSON: the kept figures come back (40 min old: "
"inside FRESH_S, so not 'kept' in the screens' sense)",
fr is not None and not fr.kept and fr.tomorrow.kwh == 14.5
and fr.at == at(3, 22, 20))
junk = solarfc.Hold({"r": [["today", 7, "x", "y", "z", []],
["tomorrow", T_ENT, "2026-11-04T00:00:00+00:00",
-1, "2026-11-03T16:00:00+00:00", []],
"bad", None]})
check("a damaged store entry is dropped, not raised",
rs(GONE, GONE, at(3, 22, 0), junk)[0] is None
and solarfc.Hold(None).dump()["r"] == [] and solarfc.Hold(5).changed
is False)
hp = solarfc.Hold()
rs(st(8.0, at(3, 9, 0), ), st(12.0, at(3, 9, 0)), at(3, 9, 5), hp)
periods = [{"period_start": f"2026-11-04T{hh:02d}:00:00+01:00",
"pv_estimate": 1.0} for hh in range(8, 16)]
s_per = st(10.0, at(3, 17, 0))
s_per["attributes"]["detailedForecast"] = periods
rs(st(0.0, at(3, 17, 0)), s_per, at(3, 17, 5), hp)
hq = solarfc.Hold(json.loads(json.dumps(hp.dump())))
fq = rs(GONE, GONE, at(3, 22, 0), hq)[0]
check("an hourly split (Solcast shape) survives the store",
fq is not None and fq.tomorrow.periods
and abs(fq.between(at(4, 8, 0), at(4, 16, 0)) - 7.5) < 1e-6)
# Round 3. F1: a kept reading is at most KEPT_MAX_AGE_S old when used.
hz = solarfc.Hold()
rs(st(0.4, at(3, 0, 5)), st(12.0, at(3, 0, 5)), at(3, 0, 10), hz)
print(f" 00:05 reading, sensors dead: 19:59 "
f"{rs(GONE, GONE, at(3, 19, 59), hz)[0]}, 20:00 "
f"{rs(GONE, GONE, at(3, 20, 0), hz)[0]}")
check("F1: a 00:05 reading with the sensors dead all day is not kept at "
"20:00 (20 h old): no forecast",
rs(GONE, GONE, at(3, 20, 0), hz)[0] is None
and rs(GONE, GONE, at(3, 23, 0), hz)[0] is None)
check("... nor after midnight until 03:00 (as today's, 26 h old)",
rs(GONE, GONE, at(4, 0, 30), hz)[0] is None
and rs(GONE, GONE, at(4, 2, 30), hz)[0] is None)
hy = solarfc.Hold()
rs(st(0.4, at(3, 17, 5)), st(12.0, at(3, 17, 5)), at(3, 17, 10), hy)
check("F1: a 17:05 reading is kept at 22:00 (5 h) ...",
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(3, 22, 0), hy)[0]))
check("... and at 02:59 next day (9 h 54 min) ...",
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 2, 59),
hy)[0]))
check("... and at 03:00 and 05:59 next day (OPT-34: the bell starts at 06:00)",
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 3, 0),
hy)[0])
and (lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 5, 59),
hy)[0]))
check("... and at 06:00 it is not (12 h 55 min old, the bell has started)",
rs(GONE, GONE, at(4, 6, 0), hy)[0] is None)
hm = solarfc.Hold()
rs(st(0.9, at(3, 9, 5)), st(12.0, at(3, 9, 5)), at(3, 9, 10), hm)
check("F1: a 09:05 reading is kept at 20:00 (10 h 55 min) but not at 22:00",
rs(GONE, GONE, at(3, 20, 0), hm)[0] is not None
and rs(GONE, GONE, at(3, 22, 0), hm)[0] is None)
# F2: the 25 h autumn day (2026-10-25) and the 23 h spring day (2027-03-28).
BRU = solarfc.BILLING_TZ # Europe/Brussels, or the encoded EU rule
def bru(y, mo, d, hh, mm=0):
return datetime(y, mo, d, hh, mm, tzinfo=BRU).astimezone(timezone.utc)
for (y, mo, d), label in (((2026, 10, 25), "25 h autumn day 2026-10-25"),
((2027, 3, 28), "23 h spring day 2027-03-28")):
hd = solarfc.Hold()
t17 = bru(y, mo, d, 17, 5)
rs(st(0.4, t17), st(12.0, t17), t17 + timedelta(minutes=5), hd)
# real hours from 20:00 local to the next local midnight, each hour
start = bru(y, mo, d, 20, 0)
end = bru(y, mo, d + 1, 0)
t, miss = start, []
while t < end:
if rs(GONE, GONE, t, hd)[0] is None:
miss.append(t.astimezone(BRU).strftime("%H:%M"))
t += timedelta(minutes=30)
print(f" {label}: not kept at {miss}")
check(f"F2: {label}: the 17:05 figure is kept every half hour "
"from 20:00 to local midnight", not miss)
check(f"... and just before the next local midnight",
rs(GONE, GONE, bru(y, mo, d + 1, 0) - timedelta(minutes=1),
hd)[0] is not None)
# F5: old days are dropped from the stored hold, so it does not grow.
hp5 = solarfc.Hold()
sizes = []
for dd in range(3, 13):
t0_ = at(dd, 17, 5)
rs(st(0.4, t0_), st(12.0, t0_), t0_ + timedelta(minutes=5), hp5)
sizes.append(len(hp5.dump()["r"]))
day0s = {row[2] for row in hp5.dump()["r"]}
print(f" F5: stored entries per day over 10 days: {sizes}")
check("F5: the stored hold does not grow day after day (at most today's and "
"tomorrow's entry, with yesterday's tomorrow)", max(sizes) <= 3
and len(day0s) <= 2)
check("... and a day before today is gone",
all(row[2] >= at(12, 0, 0).isoformat() for row in hp5.dump()["r"]))
# --------------------------------------------------------------------------- #
print("\n2. The real Controller: day/night 0.34 / 0.26, cycle cost 0.07")
A, B = "inverter-a", "inverter-b"
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.26},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
DAY3 = 3
def opts(cost=0.07):
o = dict(rp.options(True, False, 2))
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE,
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT)
return o
def stops_at(last, *, down=()):
"""Sensors that update until `last` and then show that reading for
good. `down`: (from, to) windows in which they are unavailable."""
def f(now, ent):
for a, z in down:
if a <= now < z:
return GONE
when = min(now, last)
return st(0.4 if ent == T_ENT else 12.0, when)
return f
def never(now, ent):
return st(0.0 if ent == T_ENT else 12.0, now - timedelta(hours=9))
def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
keep_store=True, snap=False):
"""-> (controller, rows, pre_rows). `pre`: the times the forecast is read
before the first cycle (the evening). `restart`: a time in `pre` at which
the add-on restarts: a new Controller on the saved store."""
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
clock = [pre[0] if pre else start]
rows, pre_rows = [], []
cur = [store]
class H(rp.Hass):
async def state(self, e):
return sensors(clock[0], e)
def build(store):
hass = H(("a", "b"), lambda t: 300.0, {"a": 40.0, "b": 40.0})
tmp = tempfile.mkdtemp(prefix="o27-")
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
# A house profile a few days old (300 W round the clock), so the day
# plan is made as it is on a site that has run for a week.
c.house_profile.load({d: {str(hh): 300.0 for hh in range(24)}
for d in ("2026-10-31", "2026-11-01",
"2026-11-02")})
return c, hass
async def go():
c, hass = build(store)
real_dt, real_hv = m.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
task = None
try:
for t_pre in pre:
clock[0] = t_pre
if restart is not None and t_pre == restart:
# The saved file, through JSON; `keep_store=False` loses
# the hold only (the control for the test).
data = json.loads(json.dumps(cur[0].data))
if not keep_store:
data.pop(m.FC_HOLD_KEY, None)
new = rp._Store()
new.data.update(data)
cur[0] = new
c, hass = build(new)
await c._read_forecast()
pre_rows.append((t_pre, c.solar_fc is not None,
bool(c.solar_fc and c.solar_fc.kept)))
clock[0] = start
task = asyncio.create_task(c.run_tariff_refresh())
t = 0.0
while t < minutes * 60:
now = start + timedelta(seconds=t)
hass.t = t
clock[0] = now
if t == 0.0:
for _ in range(500):
if c.tariff_plan is not None and (
c.tariff_at is not None and c.tariff_at >= now):
break
await asyncio.sleep(0)
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(5.0, 10.0)
rp_ = c.mode_runs
rows.append(dict(
t=t, fc_none=c.solar_fc is None,
kept=bool(c.solar_fc and c.solar_fc.kept),
runs_on=None if rp_ is None else rp_.on,
reason=None if rp_ is None else rp_.reason,
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
charging=hass.plants["b"].p < -200.0,
soc_b=hass.plants["b"].soc))
t += 5.0
keep = c.o
c.o = dict(keep, arbitrage_enabled=True)
try:
c.mirror, c.mirror_why = homeview.trade_verdict(
c, c.fresh_tariff_plan(clock[0]), clock[0])
finally:
c.o = keep
c.cards = homeview.battery_rows(c, False, True)
c.block = homeview.solar_block(c, clock[0])
c.hold_saved = json.loads(json.dumps(cur[0].data.get(
m.FC_HOLD_KEY)))
finally:
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
return c
return asyncio.run(go()), rows, pre_rows
def why_of(c, name):
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
return card["why_key"]
EVENING = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
LAST = at(DAY3, 17, 5)
NIGHT_START = at(DAY3, 22, 0)
# a. a forecast that stops updating at sunset
ca, ra, pa = drive27(opts(), stops_at(LAST), pre=EVENING + [at(DAY3, 21, 55)],
start=NIGHT_START)
la = nth(ra, -1, "last cycle")
print(f" a. 22:00 with the 17:05 forecast: kept={la['kept']} on="
f"{la['runs_on']} reason={la['reason']}; B charging "
f"{sum(r['charging'] for r in ra)} of {len(ra)}; Why A {why_of(ca, A)} "
f"B {why_of(ca, B)}; Home mirror {ca.mirror}")
check("the forecast was read fresh before 17:05 and kept after it",
any(not k for _t, ok, k in pa if _t <= LAST + timedelta(minutes=30))
and all(ok for _t, ok, _k in pa)
and all(k for _t, _ok, k in pa if _t >= at(DAY3, 20, 15)))
check("22:00 night run: planned with the cycle-cost gap (needs 0.07 < 0.08)",
present(la) and not la["fc_none"] and la["kept"]
and la["runs_on"] is True and la["reason"] != "not_worth")
check("... and B (Cheap hours) charges from the grid in the night period",
sum(r["charging"] for r in ra) > len(ra) // 2
and la["roles"].get(B) == modetrade.CHARGE
and why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread")
check("the day plan is made from the kept forecast (levels for Leftover "
"solar), as it is from a fresh one",
ca.day_plan is not None and ca.plan_targets() is not None
and ca.block["state"] == "plan")
check("Home's mirror of the gate allows it, as control does",
ca.mirror is not None and ca.mirror_why == "allowed")
# b. no forecast all day
cb, rb, pb = drive27(opts(), never, pre=EVENING + [at(DAY3, 21, 55)],
start=NIGHT_START)
lb = nth(rb, -1, "last cycle")
print(f" b. no forecast all day: on={lb['runs_on']} reason={lb['reason']}; "
f"B charging {sum(r['charging'] for r in rb)}; Why A {why_of(cb, A)} "
f"B {why_of(cb, B)}")
check("with no forecast all day the night run still needs 1.5x (0.105 > 0.08)"
": not worth, no charge, 'Prices differ too little'",
present(lb) and lb["fc_none"] and lb["runs_on"] is False
and lb["reason"] == "not_worth"
and not any(r["charging"] for r in rb)
and why_of(cb, A) == "no_spread" and why_of(cb, B) == "no_spread"
and not any(ok for _t, ok, _k in pb))
check("... and there is no day plan (Leftover solar takes solar only)",
cb.day_plan is None and cb.plan_targets() is None
and cb.block["state"] == "no_forecast")
check("... Home's mirror refuses it too, for the gap ('not_worth')",
cb.mirror is None and cb.mirror_why == "not_worth")
check("... and nothing is saved for the night (no fresh reading was made)",
cb.hold_saved is None or cb.hold_saved.get("r") == [])
# c. an evening restart
cc, rc, pc = drive27(opts(), stops_at(LAST),
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
restart=at(DAY3, 21, 55))
lc = nth(rc, -1, "last cycle")
check("an evening restart (new Controller on the saved store) keeps the "
"forecast: the night run is planned and charges",
present(lc) and lc["kept"] and lc["runs_on"] is True
and sum(r["charging"] for r in rc) > len(rc) // 2
and present(nth(pc, -1, "last read")) and pc[-1][1] and pc[-1][2])
cc0, rc0, pc0 = drive27(opts(), stops_at(LAST),
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
restart=at(DAY3, 21, 55), keep_store=False)
lc0 = nth(rc0, -1, "last cycle")
check("(control: the same restart with the store lost is blind, 1.5x)",
present(lc0) and lc0["fc_none"] and lc0["runs_on"] is False
and present(nth(pc0, -1, "last read")) and not pc0[-1][1])
# d. a one-hour outage at 18:47
OUT = (at(DAY3, 18, 47), at(DAY3, 19, 47))
quarter = [at(DAY3, 16, 0) + timedelta(minutes=15 * i) for i in range(24)]
cd, rd, pd = drive27(opts(), stops_at(at(DAY3, 18, 5), down=[OUT]),
pre=quarter + [at(DAY3, 21, 55)], start=NIGHT_START)
ld = nth(rd, -1, "last cycle")
inside = [(t_, ok) for t_, ok, _k in pd if OUT[0] <= t_ < OUT[1]]
print(f" d. reads inside the outage: {len(inside)}, with a forecast: "
f"{sum(ok for _t, ok in inside)}")
check("a one-hour outage at 18:47 does not drop the forecast "
"(every read from 16:00 to 22:00 has one)",
present(pd) and len(inside) >= 4 and all(ok for _t, ok, _k in pd))
check("... and the 22:00 night run is planned and charges",
present(ld) and ld["runs_on"] is True
and sum(r["charging"] for r in rd) > len(rd) // 2)
# e. the bound: until the bell starts (06:00, OPT-34; it was midnight + 3 h)
mid = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
ce, re_, pe = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 5, 30)],
start=at(4, 5, 30))
le = nth(re_, -1, "last cycle")
check("05:30 (before the bell starts): the figure from 17:05 is still used, "
"as today's; the night run is planned",
present(le) and le["kept"] and le["runs_on"] is True)
cf, rf, pf = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 6, 30)],
start=at(4, 6, 30))
lf = nth(rf, -1, "last cycle")
check("06:30 (the bell has started): a figure from before the night is not "
"used: no forecast, 1.5x, not worth, no charge",
present(lf) and lf["fc_none"] and lf["runs_on"] is False
and lf["reason"] == "not_worth" and not any(r["charging"] for r in rf))
# g. OPT-28's shortfall sizing on a kept figure: Leftover solar (A) is sized
# from the kept forecast exactly as from a fresh one, not "no forecast"
def fresh_all(now, ent):
return st(0.4 if ent == T_ENT else 12.0, now)
cg, rg, pg = drive27(opts(), fresh_all, pre=EVENING + [at(DAY3, 21, 55)],
start=NIGHT_START)
kp, fp_ = ca.day_plan, cg.day_plan
print(f" g. kept : short={kp and kp.short_kwh} uncovered="
f"{kp and kp.uncovered_kwh} targets={kp and kp.targets} "
f"need={kp and kp.need_kwh} sun={kp and kp.sun_kwh}")
print(f" fresh : short={fp_ and fp_.short_kwh} uncovered="
f"{fp_ and fp_.uncovered_kwh} targets={fp_ and fp_.targets} "
f"need={fp_ and fp_.need_kwh} sun={fp_ and fp_.sun_kwh}")
check("a kept figure sizes the night by OPT-28's shortfall, with a level for "
"Leftover solar (A): not 'no forecast', which has no plan at all",
kp is not None and A in kp.targets and kp.short_kwh > 0.0
and cb.day_plan is None and not cg.solar_fc.kept)
check("... and the level, the grid need, the shortfall and the sun counted "
"are those a fresh figure gives (counted once, from the right time)",
fp_ is not None and kp.targets == fp_.targets
and kp.need_kwh == fp_.need_kwh and kp.share_kwh == fp_.share_kwh
and abs(kp.short_kwh - fp_.short_kwh) < 1e-9
and abs(kp.uncovered_kwh - fp_.uncovered_kwh) < 1e-9
and abs(kp.sun_kwh - fp_.sun_kwh) < 1e-9)
# --------------------------------------------------------------------------- #
print("\n3. The screens")
blk = ca.block
print(f" forecast line: {blk['line']!r} (state {blk['state']})")
check("the forecast line says when it was read and that it is kept for "
"tonight", blk["line"] == "Solar forecast read 17:05, kept for tonight.")
check("the same line comes from the Forecast tab (dayview) on today",
homeview.line_solar(0.4, 0.3, "17:05", kept=True) == blk["line"]
and homeview.line_solar(0.4, 0.3, "17:05").startswith(
"Solar still to come today"))
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
encoding="utf-8"))
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
encoding="utf-8"))
k_en = en["home_words"]["solar"]["kept"]
k_nl = nl["home_words"]["solar"]["kept"]
print(f" EN {k_en!r}\n NL {k_nl!r}")
check("EN and NL both say it, with the time, and the NL is not the English",
"{at}" in k_en and "{at}" in k_nl and k_en != k_nl
and "tonight" in k_en and "vannacht" in k_nl)
check("a daytime reading keeps the old line (daytime unchanged)",
"Updated 09:05" in homeview.line_solar(1.0, 0.7, "09:05")
and "Bijgewerkt" in json.dumps(nl["home_words"]["solar"]["line"]))
check("no forecast: the old 'No solar forecast now' line, no 'kept'",
cb.block["state"] == "no_forecast"
and "tonight" not in cb.block["line"])
check("control and Batteries screen agree on the 22:00 run: not 'no_spread'",
why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread"
and la["runs_on"] is True)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")
+747
View File
@@ -0,0 +1,747 @@
"""Runnable check for OPT-34. `python3 test_opt34.py`
OPT-27 keeps the last fresh Forecast.Solar figure over the night. With the
sensors silent from 17:05 it ended at local midnight + 3 h, in the middle of the
night grid charge: Leftover solar's planned level went with it and the charge
stopped (rig, 2026-10-31: battery A 49.8 % at 07:00 against 79.9 % with a fresh
figure). Forecast.Solar may not refresh after midnight on a live site, so the
add-on must not depend on it. OPT-34:
1. a night charge that has started keeps its planned level when the forecast
goes (`Controller._latch_charge()`), and the next slice of the run is planned
with it (the wide no-forecast gap does not refuse it);
2. the evening's "tomorrow" figure is today's until the bell starts (06:00
local, `BELL_FROM_H`) instead of until midnight + 3 h;
3. for that case only, a reading may be up to 15 h old (a 17:05 reading is
12 h 55 min old at 06:00), but only if it was at most 12 h old at midnight;
4. a fresh figure replaces the kept one at once.
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
Mutations (MUTATION.md, purged copies, graded over this file): see the
DECISIONS note and the dev report.
1. `solarfc` alone: the window, the age limits, the day, DST, restart.
2. The real Controller, the owner's shape (modes on, arbitrage off, A Leftover
solar + B Cheap hours, both 10 % at the start of the night, the night rate
22:00-07:00, tomorrow 6 kWh, a 2.5 kW peak limit so the charge takes hours),
sensors silent from 17:05, whole nights at 5 s per cycle (run in parallel
processes, about 2.5 minutes each):
a. silent from 17:05: both batteries end the night where a fresh figure
ends them (the base left A at 57 % against 87 %), and the screens agree;
b. restarts at 01:00 and 04:00 change nothing;
c. a charge that cannot start before 03:00 (the price dips at 04:00) still
starts at 04:00 on the kept figure;
d. a fresh figure at 02:00 replaces the level at once, up, during the charge;
and before a charge has started, up and down;
e. the last hour, after the kept figure has ended at 06:00: the charge under
way goes on to its level, also over a restart; the screens say so.
3. The text, EN and NL.
4. `_latch_charge()` alone: a battery that leaves the plan leaves the latch.
"""
import asyncio
import json
import logging
import os
import sys
import tempfile
import time
from concurrent.futures import ProcessPoolExecutor
from datetime import datetime, timedelta, timezone
from multiprocessing import get_context
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
import app.main as m # noqa: E402
from app import homeview, solarfc # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
def at(day, hh, mm=0):
"""Local time on 2026-11-`day` (CET) as UTC."""
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
def st(kwh, when, *, unit="kWh"):
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
"last_updated": when.isoformat(),
"last_reported": when.isoformat()}
GONE = {"state": "unavailable", "attributes": {}}
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
BRU = solarfc.BILLING_TZ
def bru(y, mo, d, hh, mm=0):
return datetime(y, mo, d, hh, mm, tzinfo=BRU).astimezone(timezone.utc)
def rs(today, tomorrow, now, hold):
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
def near(a, b, tol):
"""`|a - b| < tol`, and False (not a crash) when `a` is missing."""
return a is not None and abs(a - b) < tol
def used(hold, now):
f = rs(GONE, GONE, now, hold)[0]
return f is not None
def hold_of(read_at, today=0.4, tomorrow=12.0):
h = solarfc.Hold()
rs(st(today, read_at), st(tomorrow, read_at),
read_at + timedelta(minutes=5), h)
return h
# --------------------------------------------------------------------------- #
print("1. solarfc: the evening figure serves until the bell starts")
h17 = hold_of(at(3, 17, 5))
check("a 17:05 reading is kept at 20:00, 22:00, 00:00, 03:00 and 05:59",
all(used(h17, t) for t in (at(3, 20, 0), at(3, 22, 0), at(4, 0, 0),
at(4, 3, 0), at(4, 5, 59))))
f_late = rs(GONE, GONE, at(4, 5, 59), h17)[0]
check("... at 05:59 it is tomorrow's 12.0 kWh as TODAY's, the whole day",
f_late is not None and f_late.kept and f_late.today.kwh == 12.0
and f_late.tomorrow is None and f_late.at == at(3, 17, 5)
and f_late.between(at(4, 6, 0), at(4, 20, 0)) is not None
and abs(f_late.between(at(4, 6, 0), at(4, 20, 0)) - 12.0) < 1e-6)
check("... and at 06:00 (12 h 55 min old, the bell starts) it is not",
not used(h17, at(4, 6, 0)) and not used(h17, at(4, 6, 30))
and not used(h17, at(4, 12, 0)))
check("... not the next evening either, and not on the 5th",
not used(h17, at(4, 22, 0)) and not used(h17, at(5, 3, 0)))
# The age limit of this case: 15 h, for a figure that was usable at midnight.
h1230 = hold_of(at(3, 12, 30))
check("a 12:30 reading (11 h 30 min old at midnight): used until 03:29 "
"(14 h 59 min old) ...",
used(h1230, at(4, 0, 0)) and used(h1230, at(4, 3, 29)))
check("... and not from 03:31 (15 h 01 min), though it is before 06:00",
not used(h1230, at(4, 3, 31)) and not used(h1230, at(4, 5, 30)))
h1600 = hold_of(at(3, 16, 0))
check("a 16:00 reading: the 06:00 window ends it, not the 15 h limit "
"(14 h old at 06:00)", used(h1600, at(4, 5, 59))
and not used(h1600, at(4, 6, 0)))
# Not used: a morning figure at night (12 h), and no come-back after midnight.
h0905 = hold_of(at(3, 9, 5))
check("a 09:05 reading: kept at 20:00 (10 h 55 min), not at 22:00 (12 h 55 min)",
used(h0905, at(3, 20, 0)) and not used(h0905, at(3, 22, 0)))
check("... and it does not come back after midnight (14 h 55 min at 00:00, "
"under the 15 h of the evening-to-morning case): it was not usable "
"at midnight",
not used(h0905, at(4, 0, 0)) and not used(h0905, at(4, 0, 2))
and not used(h0905, at(4, 2, 0)))
h1105 = hold_of(at(3, 11, 59))
check("the same for a reading that is 12 h 01 min old at midnight",
not used(h1105, at(4, 0, 0)) and not used(h1105, at(4, 0, 30)))
h1201 = hold_of(at(3, 12, 1))
check("... and one that is 11 h 59 min old at midnight is used",
used(h1201, at(4, 0, 0)) and used(h1201, at(4, 3, 0)))
check("a same-day figure past 12 h is not used: a 07:05 reading at 20:00 "
"(12 h 55 min)", not used(hold_of(at(3, 7, 5)), at(3, 20, 0)))
check("a figure over 15 h old is not used: 17:05 read, 08:06 next day "
"(15 h 01 min, bell started)", not used(h17, at(4, 8, 6)))
check("(a figure from the day before is not used either)",
not used(hold_of(at(2, 17, 5)), at(3, 22, 0))
and not used(hold_of(at(2, 17, 5)), at(4, 3, 0)))
# Today's figure is not tomorrow's, and the figure is for its own day only.
hd = solarfc.Hold()
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), hd)
fx = rs(GONE, GONE, at(4, 4, 0), hd)[0]
check("04:00: today's sensor is gone: the 4th gets the 3rd's TOMORROW "
"figure (12.0), never the 3rd's today figure (3.0)",
fx is not None and fx.today.kwh == 12.0)
fy = rs(st(5.0, at(4, 3, 50)), st(9.0, at(4, 3, 50)), at(4, 4, 0), hd)[0]
check("a fresh figure replaces the kept one at once (04:00: 5.0 today, "
"9.0 tomorrow; not kept)", fy is not None and not fy.kept
and fy.today.kwh == 5.0 and fy.tomorrow.kwh == 9.0)
fy2 = rs(st(5.0, at(4, 7, 55)), GONE, at(4, 8, 0), hd)[0]
check("... and at 08:00, today fresh and tomorrow's sensor down for over 3 h: "
"no tomorrow (the 3rd's figure is not the 5th's)",
fy2 is not None and fy2.tomorrow is None and fy2.today.kwh == 5.0)
# Restart: the saved hold keeps the figure.
for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
back = solarfc.Hold(json.loads(json.dumps(hold_of(at(3, 17, 5)).dump())))
fr = rs(GONE, GONE, now, back)[0]
check(f"restart at {label}: the saved hold (FC_HOLD_KEY) gives the "
"17:05 figure again", fr is not None and fr.kept
and fr.today.kwh == 12.0 and fr.at == at(3, 17, 5))
# DST: the 25 h autumn day and the 23 h spring day. The window ends at 06:00
# LOCAL, not 6 h after midnight.
for (y, mo, d), label in (((2026, 10, 25), "25 h day 2026-10-25"),
((2027, 3, 28), "23 h day 2027-03-28")):
t17 = bru(y, mo, d - 1, 17, 5)
hs = hold_of(t17)
ok_in = used(hs, bru(y, mo, d - 1, 23, 59)) and all(
used(hs, bru(y, mo, d, hh, mm)) for hh, mm in
((0, 0), (1, 30), (4, 0), (5, 59)))
check(f"{label}: the 17:05 figure serves 23:59 to 05:59 local", ok_in)
check(f"... and not at 06:00 local", not used(hs, bru(y, mo, d, 6, 0))
and not used(hs, bru(y, mo, d, 6, 30)))
# --------------------------------------------------------------------------- #
print("\n2. The real Controller, whole nights (parallel processes)")
A, B = "inverter-a", "inverter-b"
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.26},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
# The price dips at 04:00 (a dynamic site): the cheap run starts after 03:00.
PROFILE_LATE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00",'
' "to": "04:00", "eur_per_kwh": 0.28},'
'{"name": "dawn", "days": "all", "from": "04:00",'
' "to": "07:00", "eur_per_kwh": 0.20},'
'{"name": "day", "days": "all", "from": "07:00",'
' "to": "22:00", "eur_per_kwh": 0.34}]}')
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
LAST = at(3, 17, 5)
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
+ [at(3, 21, 55)]
def opts(late=False, cap=2500):
o = dict(rp.options(True, False, 2))
o.update(cycle_cost_threshold_eur=0.07,
static_profile=PROFILE_LATE if late else PROFILE,
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT,
peak_cap_w=cap)
return o
def sensors(*, silent=True, fresh_from=None, fresh=(1.0, 5.0),
fresh_until=None):
"""Sensors that update until 17:05 (or all night) and then show that
reading for good; from `fresh_from` a new reading every call."""
def f(now, ent):
if fresh_from is not None and now >= fresh_from:
when = now if fresh_until is None else min(now, fresh_until)
return st(fresh[0] if ent == T_ENT else fresh[1], when)
when = min(now, LAST) if silent else now
return st(0.4 if ent == T_ENT else 6.0, when)
return f
def run_night(spec):
"""One scenario in this process -> a plain dict. `spec`: start (UTC),
hours, silent, fresh_from, fresh, restarts, soc, load_until_s, marks."""
start = spec["start"]
soc0 = spec.get("soc", {"a": 10.0, "b": 10.0})
house_w = spec.get("house_w", 700.0)
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
if spec.get("latch"):
store.data[m.CHARGE_LATCH_KEY] = spec["latch"]
sens = sensors(silent=spec.get("silent", True),
fresh_from=spec.get("fresh_from"),
fresh=spec.get("fresh", (1.0, 5.0)),
fresh_until=spec.get("fresh_until"))
clock = [EVENING[0]]
class H(rp.Hass):
async def state(self, e):
return GONE if spec.get("gone") else sens(clock[0], e)
hass = H(("a", "b"), lambda t: 300.0, soc0)
out = dict(marks={}, soc_at={})
def build(sto):
tmp = tempfile.mkdtemp(prefix="o34-")
c = Controller(dict(opts(spec.get("late", False),
spec.get("cap", 2500))), hass, sto,
rp._Pub(),
stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
c.house_profile.load({d: {str(hh): house_w for hh in range(24)}
for d in ("2026-10-31", "2026-11-01",
"2026-11-02")})
return c
async def go():
cur = store
c = build(cur)
real_dt, real_hv = m.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
try:
for t_pre in EVENING:
clock[0] = t_pre
await c._read_forecast()
pending = sorted(spec.get("restarts", ()))
marks = sorted(spec.get("marks", ()))
first, t = True, 0.0
hz = spec["hours"] * 3600.0
while t < hz:
now = start + timedelta(seconds=t)
clock[0] = now
if pending and now >= pending[0]:
pending.pop(0)
data = json.loads(json.dumps(cur.data))
cur = rp._Store()
cur.data.update(data)
c = build(cur)
first = True
if first or int(t) % 600 == 0:
# The tariff, resolved here at its own pace: the real
# refresh task sleeps on the wall clock.
hor = float(m.price_option(c.o, "price_horizon_h"))
c.tariff_plan = await m.resolve_tariff(
c.o, now, now + timedelta(hours=hor), now=now,
history=c.price_history, watch=c.price_watch)
c.tariff_at = now
first = False
hass.t = t
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(5.0, 10.0)
if int(t) % 300 == 0:
out["soc_at"][now.astimezone(L).strftime("%H:%M")] = (
hass.plants["a"].soc, hass.plants["b"].soc,
hass.plants["a"].p, c.solar_fc is None,
bool(c.solar_fc and c.solar_fc.kept),
None if c.plan_targets() is None
else c.plan_targets().get(A))
while marks and now >= marks[0]:
marks.pop(0)
cards = homeview.battery_rows(c, False, True)
blk = homeview.solar_block(c, now)
a_card = next(b for b in cards if b["name"] == A)
out["marks"][now.astimezone(L).strftime("%H:%M")] = dict(
line=blk["line"], state=blk["state"],
why_a=a_card["why_key"],
fc_none=c.solar_fc is None,
kept=bool(c.solar_fc and c.solar_fc.kept),
target_a=(c.plan_targets() or {}).get(A),
soc_a=hass.plants["a"].soc,
p_a=hass.plants["a"].p,
charging=hass.plants["b"].p < -200.0)
t += 5.0
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
out["latch"] = getattr(c, "_charge_latch", None) is not None
finally:
m.datetime, homeview.datetime = real_dt, real_hv
return out
return asyncio.run(go())
NIGHT = at(3, 22, 0)
SCEN = {
"fresh": dict(start=NIGHT, hours=9, silent=False,
marks=[at(4, 4, 30)]),
"silent": dict(start=NIGHT, hours=9,
marks=[at(4, 1, 0), at(4, 4, 30), at(4, 6, 30)]),
"restart": dict(start=NIGHT, hours=9,
restarts=[at(4, 1, 0), at(4, 4, 0)],
marks=[at(4, 1, 30), at(4, 4, 30)]),
# c. the charge cannot start before 03:00 (the price dips at 04:00)
"late": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0,
soc={"a": 40.0, "b": 70.0}, marks=[at(4, 3, 30)]),
"late_fresh": dict(start=at(4, 2, 30), hours=4.5, late=True,
house_w=500.0, soc={"a": 40.0, "b": 70.0},
silent=False),
# d. a fresh, darker figure at 02:00 during the charge (a lighter house
# use, so the level has room to rise)
"dark": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
house_w=500.0, fresh_from=at(4, 2, 0), fresh=(1.0, 5.0),
marks=[at(4, 1, 59), at(4, 2, 5)]),
"kept": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
house_w=500.0, marks=[at(4, 1, 59), at(4, 2, 5)]),
# before the charge starts: a fresh figure replaces the kept one, up and
# down, at once
"pre_kept": dict(start=at(4, 2, 30), hours=0.25, late=True,
house_w=500.0, marks=[at(4, 2, 44)]),
"pre_dark": dict(start=at(4, 2, 30), hours=0.25, late=True,
house_w=500.0, fresh_from=at(4, 2, 40),
fresh=(1.0, 5.0), marks=[at(4, 2, 44)]),
"pre_bright": dict(start=at(4, 2, 30), hours=0.25, late=True,
house_w=500.0, fresh_from=at(4, 2, 40),
fresh=(30.0, 30.0), marks=[at(4, 2, 44)]),
# d. the last hour, after the kept figure ended at 06:00
"tail": dict(start=at(4, 5, 40), hours=80 / 60.0,
soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)],
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]),
# a fresh, brighter figure at 03:10 lowers A's level below its SoC (A
# stops, B goes on), then the sensors go quiet: the forecast ends at 06:10
"lowered": dict(start=at(4, 2, 30), hours=4.5, house_w=500.0,
soc={"a": 30.0, "b": 30.0}, fresh_from=at(4, 3, 10),
fresh_until=at(4, 3, 10), fresh=(30.0, 30.0),
marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]),
# F1: the add-on was down when the latch of the night before ended; it is
# back inside a cheap run, with the sensors unavailable (no forecast)
"stale": dict(start=at(3, 22, 0), hours=0.5, gone=True,
latch={"until": at(3, 7, 15).isoformat(),
"born": at(2, 23, 0).isoformat(),
"levels": {A: 80.0, B: 80.0}},
marks=[at(3, 22, 20)]),
"stale_live": dict(start=at(3, 22, 0), hours=0.5, gone=True,
latch={"until": at(4, 7, 15).isoformat(),
"born": at(3, 22, 0).isoformat(),
"levels": {A: 80.0, B: 80.0}},
marks=[at(3, 22, 20)]),
# the same, B already full and a 1.5 kW limit: the run is sized at the
# battery's 2.5 kW rating and ends at 06:33, before A is there, so the
# next slice is planned after 06:00 and is only A's
"tail_bfull": dict(start=at(4, 5, 40), hours=80 / 60.0, cap=1500,
soc={"a": 65.0, "b": 95.0},
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 45)]),
}
t0 = time.time()
with ProcessPoolExecutor(max_workers=min(6, os.cpu_count() or 2),
mp_context=get_context("fork")) as pool:
futs = {k: pool.submit(run_night, v) for k, v in SCEN.items()}
R = {k: f.result() for k, f in futs.items()}
print(f" {len(R)} scenarios in {time.time() - t0:.0f} s")
def show(k):
r = R[k]
print(f" {k:10} A {r['soc'][0]:.2f} % B {r['soc'][1]:.2f} % "
f"marks {', '.join(sorted(r['marks']))}")
for k in R:
show(k)
fa, si, rr = R["fresh"], R["silent"], R["restart"]
# a. silent from 17:05
check("a fresh figure all night: A ends at its planned level (87 %), B "
"full", abs(fa["soc"][0] - 86.9) < 0.5 and fa["soc"][1] > 94.5)
check("sensors silent from 17:05: A ends where the fresh run ends it "
f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %)",
abs(si["soc"][0] - fa["soc"][0]) < 0.3)
check("... and so does B (Cheap hours; the wide no-forecast gap does not "
f"stop its slices): {si['soc'][1]:.1f} against {fa['soc'][1]:.1f} %",
abs(si["soc"][1] - fa["soc"][1]) < 0.3)
check("A charges through 03:00 without a break (no step at 03:00)",
si["soc_at"]["03:30"][0] > si["soc_at"]["03:00"][0] + 4.0
and si["soc_at"]["03:00"][0] > si["soc_at"]["02:55"][0] + 0.3)
m1, m4 = si["marks"]["01:00"], si["marks"]["04:30"]
check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
"line says so (EN)",
not m1["fc_none"] and m1["kept"] and not m4["fc_none"] and m4["kept"]
and m4["state"] == "plan"
and m4["line"] == "Solar forecast read 17:05, kept for tonight."
and m4["target_a"] is not None
and near(m4["target_a"], fa["marks"]["04:30"]["target_a"], 1e-9))
check("... and the level is the fresh run's (the kept figure counts as a "
"fresh one)", near(m4["target_a"], 86.87, 0.2))
m6 = si["marks"]["06:30"]
check("06:30: the kept figure has ended (the bell started) and the screen "
"does not say 'kept' (the charge was done by then in this shape)",
m6["fc_none"] and "kept" not in m6["line"])
# b. restarts
check("restarts at 01:00 and 04:00 (a new Controller on the saved store): "
f"A {rr['soc'][0]:.1f}, B {rr['soc'][1]:.1f} % as without them",
abs(rr["soc"][0] - si["soc"][0]) < 0.3
and abs(rr["soc"][1] - si["soc"][1]) < 0.3)
check("... with the figure kept right after each restart",
all(rr["marks"][k]["kept"] and not rr["marks"][k]["fc_none"]
and rr["marks"][k]["target_a"] is not None
for k in ("01:30", "04:30")))
# c. a charge that cannot start before 03:00
la, lf = R["late"], R["late_fresh"]
ml = la["marks"]["03:30"]
check("the cheap hours start at 04:00: nothing charges at 03:30",
ml["soc_a"] < 40.1 and not ml["charging"])
check("... the kept figure still stands at 03:30 (no charge had started, so "
"no level to latch): the plan is kept, not 'no forecast'",
not ml["fc_none"] and ml["kept"] and ml["target_a"] is not None)
check("... and the charge starts at 04:00 on it: A ends where a fresh "
f"figure ends it ({la['soc'][0]:.1f} against {lf['soc'][0]:.1f} %)",
lf["soc"][0] > 50.0 and abs(la["soc"][0] - lf["soc"][0]) < 0.3
and abs(la["soc"][1] - lf["soc"][1]) < 0.3)
# d. a fresh figure replaces the kept one at once
dk, kp = R["dark"], R["kept"]
check("a fresh, darker figure at 02:00 during the charge: the level goes "
"UP at once (02:05)",
kp["marks"]["02:05"]["target_a"] is not None
and dk["marks"]["02:05"]["target_a"] is not None
and dk["marks"]["02:05"]["target_a"] > kp["marks"]["02:05"]["target_a"]
+ 3.0 and not dk["marks"]["02:05"]["kept"]
and dk["marks"]["01:59"]["kept"])
check("... and A charges on to it (more than on the kept figure: "
f"{dk['soc'][0]:.1f} against {kp['soc'][0]:.1f} %)",
dk["soc"][0] > kp["soc"][0] + 3.0)
pk, pd, pb = R["pre_kept"], R["pre_dark"], R["pre_bright"]
t_k, t_d, t_b = (x["marks"]["02:44"]["target_a"] for x in (pk, pd, pb))
print(f" level before any charge, 02:44: kept {t_k}, dark {t_d}, "
f"bright {t_b}")
check("before a charge has started (nothing charges at 02:44): a darker "
"fresh figure raises the level and a brighter one lowers it, at once",
all(not x["marks"]["02:44"]["charging"] for x in (pk, pd, pb))
and t_k is not None and t_d is not None and t_b is not None
and t_d > t_k + 3.0 and t_b < t_k - 3.0
and not pd["marks"]["02:44"]["kept"]
and not pb["marks"]["02:44"]["kept"])
# e. the last hour, after the kept figure ended
tl = R["tail"]
t5, t6, t7 = (tl["marks"][k] for k in ("05:50", "06:10", "06:40"))
check("05:50: charging on the kept figure; 06:10: the figure has ended",
t5["kept"] and not t5["fc_none"] and t6["fc_none"])
check("06:10 and 06:40 (the second after a restart at 06:30): A still "
"charges to its level, 'charges from the grid' is the Why",
t6["p_a"] < -500.0 and t7["p_a"] < -500.0
and t6["why_a"] == "grid_charge" and t7["why_a"] == "grid_charge")
check("... and ends the hour higher than it was (+8 % or more from 06:00)",
tl["soc_at"]["06:55"][0] > tl["soc_at"]["06:00"][0] + 8.0)
lo = R["lowered"]
l0, l1, l2 = (lo["marks"][k] for k in ("03:05", "03:20", "06:30"))
check("a fresh, brighter figure lowers A's level below its SoC during the "
f"charge ({l0['target_a']} -> {l1['target_a']}, SoC "
f"{l1['soc_a']:.1f} %), and A stops",
l0["target_a"] is not None and l1["target_a"] is not None
and l1["target_a"] < l1["soc_a"] - 2.0
and l1["target_a"] < l0["target_a"] - 10.0
and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5)
check("the sensors go quiet, the forecast ends (06:30): the latch holds the "
"LOWERED level, so A does not charge back up to the old one "
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), "
"while B goes on",
l2["fc_none"] and l2["target_a"] is not None
and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0
and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0)
st_, sl_ = R["stale"], R["stale_live"]
check("F1: a latch from the night before (ended 07:15) loaded at a restart "
"inside a cheap run, with no forecast: nothing grid-charges "
f"(A {st_['soc'][0]:.1f}, B {st_['soc'][1]:.1f} %) and the line does "
"not say the charge goes on",
st_["soc"][0] < 10.2 and st_["soc"][1] < 10.2
and st_["marks"]["22:20"]["state"] == "no_forecast"
and not st_["latch"])
check("(control: the same latch, still valid until tomorrow 07:15, does "
f"charge both: A {sl_['soc'][0]:.1f}, B {sl_['soc'][1]:.1f} %)",
sl_["soc"][0] > 11.0 and sl_["soc"][1] > 11.0
and sl_["marks"]["22:20"]["state"] == "latched")
tb = R["tail_bfull"]
check("B full: the run ends at 06:33 before A is at its level, so the next "
"slice is only A's, planned after the figure has ended with A's "
"latched level: A goes on charging (06:10, 06:45; "
f"{tb['soc_at']['06:00'][0]:.1f} to {tb['soc_at']['06:55'][0]:.1f} %)",
tb["marks"]["06:10"]["fc_none"] and tb["marks"]["06:10"]["p_a"] < -500.0
and tb["marks"]["06:45"]["p_a"] < -500.0
and tb["soc_at"]["06:55"][0] > tb["soc_at"]["06:00"][0] + 8.0)
check("the screens say what control does (EN): 'No solar forecast now. The "
"charge under way goes on to its planned level.'",
t6["state"] == "latched" and t7["state"] == "latched"
and t6["line"] == "No solar forecast now. The charge under way goes "
"on to its planned level.")
# --------------------------------------------------------------------------- #
print("\n3. The text (EN and NL)")
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
encoding="utf-8"))
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
encoding="utf-8"))
l_en, l_nl = (x["home_words"]["solar"]["latched"] for x in (en, nl))
print(f" EN {l_en!r}\n NL {l_nl!r}")
check("the new line is short and plain in both languages and the NL is not "
"the English", l_en != l_nl and len(l_en.split()) <= 24
and len(l_nl.split()) <= 24 and "{" not in l_en + l_nl)
check("'kept for tonight' is unchanged (EN and NL)",
en["home_words"]["solar"]["kept"]
== "Solar forecast read {at}, kept for tonight."
and nl["home_words"]["solar"]["kept"]
== "Zonneprognose van {at}, bewaard voor vannacht.")
# --------------------------------------------------------------------------- #
print("\n4. The latch alone: a battery that leaves the plan leaves the latch")
from types import SimpleNamespace # noqa: E402
from app import modetrade, priceruns # noqa: E402
CLOCK = [at(4, 1, 0)]
_dt0 = m.datetime
class _FixedDT(_dt0):
@classmethod
def now(cls, tz=None):
return CLOCK[0] if tz is None else CLOCK[0].astimezone(tz)
m.datetime = homeview.datetime = _FixedDT
_tmp = tempfile.mkdtemp(prefix="o34-")
lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0,
{"a": 40.0, "b": 40.0}),
rp._Store(), rp._Pub(), stop=asyncio.Event(),
peak_db_path=os.path.join(_tmp, "tariff.db"),
price_history_path=os.path.join(_tmp, "ph.json"))
n0 = at(4, 1, 0)
lruns = SimpleNamespace(cheap=priceruns.Run(at(3, 22, 0), at(4, 7, 0), 0.26))
charging = SimpleNamespace(action=modetrade.CHARGE,
roles={A: modetrade.CHARGE, B: modetrade.CHARGE})
resting = SimpleNamespace(action=None, roles={A: modetrade.HOLD,
B: modetrade.HOLD})
def latch_step(fc, tick, targets, now=n0, runs=None, packs=None):
CLOCK[0] = now
lc.solar_fc, lc.mode_tick = fc, tick
lc._latch_charge(now, runs or lruns, targets, packs)
return lc._charge_latch, lc.store.data.get(m.CHARGE_LATCH_KEY)
FC = object() # a forecast is read (fresh or kept)
lt, saved = latch_step(FC, charging, {A: 80.0, B: 70.0})
check("both batteries charge with a forecast: both are latched at their "
"levels, to the run's end + one slot, and saved",
lt is not None and lt[1] == {A: 80.0, B: 70.0}
and lt[0] == at(4, 7, 15) and saved is not None
and saved["levels"] == {A: 80.0, B: 70.0})
lt, saved = latch_step(FC, charging, {B: 70.0})
check("A leaves the plan (its mode or use changed): A is dropped from the "
"latch at once, B stays", lt is not None and lt[1] == {B: 70.0}
and saved["levels"] == {B: 70.0})
lc.solar_fc = None
check("... and with the forecast then gone, A has no latched level (it is "
"not charged back to 80 %)", lc.plan_targets() == {B: 70.0})
lt, saved = latch_step(FC, resting, {A: 80.0, B: 70.0})
check("A back in the plan while nothing charges: it does not rejoin",
lt is not None and lt[1] == {B: 70.0})
lt, saved = latch_step(FC, resting, {A: 80.0})
check("B leaves the plan too: the latch is empty, so it is gone and "
"cleared from the store", lt is None and saved is None
and lc.plan_targets() is None)
latch_step(FC, charging, {A: 80.0})
lc.solar_fc = None
ia = next((i for i in lc.inverters if i.name == A), None)
st_below = st_at = st_after = None
lc.mode_runs = lruns
if ia is not None:
ia.soc = 50.0
st_below = homeview.plan_state(lc)
CLOCK[0] = at(4, 7, 5) # the run has ended, the latch has not
st_after = homeview.plan_state(lc)
CLOCK[0] = n0
ia.soc = 80.0
st_at = homeview.plan_state(lc)
check("the screen says the charge goes on only while the latched battery is "
f"below its level ({st_below!r} at 50 %, {st_at!r} at 80 %)",
st_below == "latched" and st_at == "no_forecast")
check("F2: and only while a cheap run is on: at 07:05, the run over and the "
f"latch in its grace slot, it does not ({st_after!r})",
st_after == "no_forecast")
print("\n5. The latch ends: expiry, a changed mode, the night's bell")
long_run = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2))
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
lt, saved = latch_step(None, resting, None, now=at(4, 7, 20), runs=long_run)
check("F1: past its end (07:15) the latch is dropped even with a cheap run "
"on and no forecast (it was only dropped when no run held)",
lt is None and saved is None)
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
CLOCK[0] = at(4, 7, 20)
lc.solar_fc = None
check("... plan_targets() ignores a latch past its end", lc.plan_targets()
is None)
CLOCK[0] = at(4, 1, 0)
check("... and _load_latch() does not bring one back from the store",
m._load_latch({"until": at(4, 7, 15).isoformat(), "born":
at(4, 1, 0).isoformat(), "levels": {A: 80.0}},
at(4, 7, 20)) is None
and m._load_latch({"until": at(4, 7, 15).isoformat(), "born":
at(4, 1, 0).isoformat(), "levels": {A: 80.0}},
at(4, 6, 0)) is not None)
# F3: the mode or use of a latched battery changes
def packs_of(a_mode, a_use=True):
return [modetrade.Pack(A, a_use, a_mode, False, 2500.0, 50.0, 10.0),
modetrade.Pack(B, True, "cheap_hours", False, 2500.0, 50.0, 10.0)]
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
lt, _s = latch_step(None, resting, None, packs=packs_of("leftover_solar"))
check("F3: A is still Leftover solar and on: it stays latched",
lt is not None and lt[1] == {A: 80.0})
lt, saved = latch_step(None, resting, None, packs=packs_of("solar_first"))
check("F3: the user changes A to Solar first (no forecast): it leaves the "
"latch, which is then gone and cleared, so the narrow gap goes too",
lt is None and saved is None)
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
lt, _s = latch_step(None, resting, None,
packs=packs_of("leftover_solar", a_use=False))
check("F3: or switches A off", lt is None)
# F4: the night's bell
lc._charge_latch = None
lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0))
check("F4: the latch began at 01:00, so its night's bell is 06:00 the same "
"day", m._night_limit(lt[2]) == at(4, 6, 0)
and m._night_limit(at(3, 23, 0)) == at(4, 6, 0)
and m._night_limit(at(4, 6, 0)) == at(5, 6, 0))
r1 = SimpleNamespace(cheap=priceruns.Run(at(4, 0, 0), at(4, 7, 0), .2))
r2 = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2))
r3 = SimpleNamespace(cheap=priceruns.Run(at(4, 6, 0), at(4, 9, 0), .2))
lc._charge_latch = None
lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0), runs=r1)
check("F4: first run 00:00-07:00 sets the end at 07:15", lt[0] == at(4, 7, 15))
lt, _s = latch_step(None, resting, None, now=at(4, 6, 50), runs=r1)
lt2, _s = latch_step(None, resting, None, now=at(4, 7, 5), runs=r2)
check("F4: a back-to-back run that starts after the bell (07:00-23:00) does "
"NOT carry it on: still 07:15", lt2 is not None
and lt2[0] == at(4, 7, 15))
lt3, _s = latch_step(None, resting, None, now=at(4, 7, 20), runs=r2)
check("F4: and at 07:20 it has ended, though the run goes on", lt3 is None)
lc._charge_latch = (at(4, 6, 10), {A: 80.0}, at(4, 1, 0))
lt, _s = latch_step(None, resting, None, now=at(4, 6, 5), runs=r3)
check("F4: a run that starts AT the bell (06:00-09:00) does carry it on, "
"to its end + one slot", lt is not None and lt[0] == at(4, 9, 15))
m.datetime = homeview.datetime = _dt0
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")
+10 -5
View File
@@ -609,6 +609,10 @@ print("\n5. modes off: byte-identical to release/1.0 e3e4ba6")
# AT_BASE the same rows through `ident.trade_digest()`, equal to what
# e3e4ba6 gives through it;
# AT_NOW this tree's own digests with nothing hidden.
# SAFETY-125 (0.19.2) moved the two lag pins: their P1 sample is 0.35 s old on
# every cycle, and a grid charge now counts those seconds at the held reading,
# so 16 of 1080 cycles charge about 1.7 W less (before: n1_lag f6c67b35...
# masked, 78aac892... full; n2_lag as n2). n1 and n2 still equal e3e4ba6.
AT_BASE_OLD = {
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
@@ -618,14 +622,14 @@ AT_BASE_OLD = {
AT_BASE = {
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
"n1_lag": "f6c67b351bd732fb69e62cafbfdb2bcff6032f5d8845836659d096b8c677d3c0",
"n2_lag": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
"n1_lag": "28e3182ae73c52dae0e65b2495bc04b2b71003889bf9de1a394b047d2c0e0edf",
"n2_lag": "45bfb095b5707a91316f56afc408dba616afc729ce4c83a10b337f36948fa3f2",
}
AT_NOW = {
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
"n1_lag": "78aac892975be81a7ceac0c000ed441a731bd4f42ff9f729996cbee53d6b7f02",
"n2_lag": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
"n1_lag": "1827918d570bfa0cc371c35dbbb64378b4937557061e7e1a396aba7663e68117",
"n2_lag": "6acc1d1b5a11f3481c7a30d384612f37b59f8e5e9cf89204edd07d09a5b90d8d",
}
_cmd_digest = ident.trade_digest
_rows5 = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_BASE}
@@ -636,7 +640,8 @@ for _k in AT_BASE:
f"{_got[_k][:16]}")
check("with battery modes off every command, integrator, allocation and "
"trade action equals e3e4ba6's (the sentence masked as above), also when "
"every quarter boundary's cycle still sees the old P1 block",
"every quarter boundary's cycle still sees the old P1 block (there, as "
"SAFETY-125 moved it)",
_got == AT_BASE)
check("...the mask is needed and nothing more: unmasked, the digests differ "
"from e3e4ba6's printed ones, and equal this tree's own pins, so a "
+33 -3
View File
@@ -19,7 +19,9 @@ DECISIONS/SAFETY-121-bound-every-quarter.md.
1. The rig's shape (1 s meter that lags the battery, 1 s battery lag, house
800 W, rating 2500 W, limit 2500 W, margin 50, charge 2000 W, no
forecast), 2 h, three seeds: no notification, the cap held, every command
the same as without the fix; without the fix it warns.
the same as without the fix; without the fix it warns. SAFETY-125 sized
the N=1 charge to the room, which removed this hand-back; these runs keep
the old sizing, and one run checks that with it nothing hands back.
2. A genuine integrator bound still warns: the loop in charge with the house
over the rating (at once, as before), and the loop at its rail after a
hand-back (after the settle).
@@ -140,10 +142,29 @@ def house(seed, base=800.0, step=None):
return load
_SIZED_CH = Controller._charge_headroom
def _n1_unsized(self, floor, claims):
"""The N=1 cheap-window charge as before SAFETY-125: not sized to the
quarter's room, so the guard steps in at each quarter's end and hands
back - the shape this file grades the settle on."""
return floor if len(self.inverters) < 2 \
else _SIZED_CH(self, floor, claims)
def run(load, *, n=1, dur=7200.0, delay=1, extra=None, cheap=True, cut=None,
charging=True, blind=0.0):
charging=True, blind=0.0, sized=False):
"""One run of `n` inverters of 2500 W; `charging=False`: no maintenance,
the loop in charge; `blind`: no meter reading until that second."""
the loop in charge; `blind`: no meter reading until that second.
SAFETY-125 sizes the N=1 charge to the room, so it no longer hands back
at a quarter's end; an N=1 charge here keeps the old sizing unless
`sized`, so the hand-back this file is about still happens."""
if n == 1 and charging and not sized:
return patched(Controller, "_charge_headroom", _n1_unsized)(
lambda: run(load, n=n, dur=dur, delay=delay, extra=extra,
cheap=cheap, cut=cut, charging=charging,
blind=blind, sized=True))
extra = dict(extra or {})
s84.OPT = dict(OPT0, peak_cap_w=int(CAP * n), peak_margin_w=50,
maintenance_charge_w=2000)
@@ -231,6 +252,15 @@ check("non-vacuous: without the fix every run warns, at a quarter's edge",
all(len(notes(c)) >= 1 for _r, c in OLD.values())
and all(min(t % QUARTER, QUARTER - t % QUARTER) <= 40
for _r, c in OLD.values() for t in notes(c)))
_sz = run(house(1), sized=True)
print(f" SAFETY-125's sizing, seed 1: guard step-ins "
f"{guard_steps(_sz[1])}, notifications {notes(_sz[1])}, highest "
f"quarter {max(quarters(_sz[1]), default=0.0):.0f} W")
check("SAFETY-125: with the N=1 charge sized to the room the guard does not "
"step in at the quarter's end, nothing warns and the cap holds",
not any(r[-1][1] and r[-1][1][0] for r in _sz[0])
and notes(_sz[1]) == [] and any(r[1] < -1000.0 for r in _sz[0])
and len(quarters(_sz[1])) == 8 and max(quarters(_sz[1])) < CAP)
# ============================================================================ #
print("\n2. a genuine integrator bound still warns")
+565
View File
@@ -0,0 +1,565 @@
"""Runnable check for SAFETY-125. `python3 test_safety125.py`
A meter reading that is late near a quarter's end left seconds the
projection did not count (SAFETY-99), and `charge_headroom_w()` read them as
room: a grid charge went back to full power in the quarter's last seconds and
the billed quarter went over the aim, above a banked month peak or the cap.
And the peak guard's row said "under" while the period was over the limit at
the current rate. DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md.
Drives the REAL Controller (read_inputs + cycle, the tariff task on a
day/night profile, a P1 meter on a simulated clock) in the live shape: modes
on, inverter-a Cheap hours at 5000 W charging harder than asked (as it did
live), inverter-b Solar first, a house of 1 kW, cap 3500 W, margin 50 W.
1. `charge_room_w()` on its own.
2. A reading 30 s late at each quarter's end: every quarter at or under the
aim, with a month peak banked above the cap and with none, with the P1
projection and with a `peak_forecast_entity`; the charge is cut late in
the quarter and back in the next one.
3. The same for a price trade with modes off, and a maintenance charge at N=2.
4. A healthy meter: the quarters as before, and the row and the month-peak
line say what is true.
5. The words on their own.
6. One inverter: the maintenance charge is sized to the room too (owner
2026-10-29), on a 5 s and a 10 s meter, with and without a banked peak
and with late readings; the guard no longer steps in during it.
7. The row during an N=2 maintenance charge and in shadow mode.
8. Edge cases: a reading past its 30 s hold (blind), a start mid-quarter,
the summer-time change, the forecast entity in W, a 10 s meter, the
quarter's last second.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import asyncio
import logging
import os
import sys
import tempfile
import types
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import opt11p3_replay as rp # noqa: E402
import safety84_identity as s84 # noqa: E402
import app.main as m # noqa: E402
import app.p1 as p1mod # noqa: E402
from named_absence import first, nth, present # noqa: E402
from app import homeview, messages, peakguard, tariff # noqa: E402
from app import price as _price, static_tariff as _st # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import QUARTER_S, Projection # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
PEAK = 5413.17 # October's banked peak on the live site
AIM_PEAK = PEAK - 50.0
AIM_CAP = 3500.0 - 50.0
TOL = 10.0 # W over the aim a quarter may land (SAFETY-81)
FC = "sensor.piek_prognose_kwartier"
L = timezone(timedelta(hours=1))
START = datetime(2026, 10, 29, 0, 0, tzinfo=L).astimezone(timezone.utc)
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.2333},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.2801}]}')
def opts(*, fc=False, modes=True, arb=False):
o = dict(rp.options(modes, arb, 2))
o.update(cycle_cost_threshold_eur=0.03, static_profile=PROFILE,
peak_cap_w=3500, peak_margin_w=50)
o["inverters"][0].update(max_w=5000, capacity_kwh=15.36)
o["inverters"][1].update(max_w=4600, capacity_kwh=20.48)
if fc:
o["peak_forecast_entity"] = FC
return o
class Harder(rp.Plant):
"""Charges harder than asked, as inverter-a did live (asked -4271 W,
measured about -4750 W; asked -5000 W, about -5210 W)."""
def step(self, dt, floor):
c = self.cmd
if c < 0:
self.cmd = max(c * 1.11, -5216.0)
try:
super().step(dt, floor)
finally:
self.cmd = c
async def _no_fetch(*a, **k):
raise AssertionError("the tariff task reached for a price feed")
class OnItsOwn(rp.Plant):
"""Charges 5000 W whatever it is asked (shadow mode: nothing is
written, the inverter charges on its own)."""
def step(self, dt, floor):
self.cmd = -5000.0
super().step(dt, floor)
def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
period=0.0, fc_unit="kW", plant=Harder):
"""The real Controller from `start` (00:00 local) for `minutes` at a 1 s
cycle (OPT-15's lag). `late`: no P1 telegram in each quarter's last
`late` s; `period`: one telegram every `period` s (0: every cycle).
-> (controller, rows, {quarter start: true grid average}, samples), the
samples taken every `every` cycles from the second quarter (the first
starts before the first reading): (projection, ceiling, engaged,
peak_guard row, month-peak line)."""
store = rp._Store()
for u, mode in (("a", "cheap_hours"), ("b", "solar_first")):
store.data[unit_key(f"inverter-{u}", "pack_use")] = True
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
store.data[unit_key(f"inverter-{u}", "pack_sell")] = False
rows, samples, sums = [], [], {}
t0 = start or START
async def go():
lag = rp.Lag()
hass = rp.Hass(("a", "b"), lambda t: 1000.0, {"a": 11.0, "b": 10.0},
limits={"a": (-5000.0, 5000.0),
"b": (-4600.0, 4600.0)}, lag=lag)
hass.plants["a"].__class__ = plant
fc_kw = [0.0]
async def state(e):
# The HA quarter projection, in kW like the live template.
return None if e != FC else {
"state": (f"{fc_kw[0]:.3f}" if fc_unit == "kW"
else f"{fc_kw[0] * 1000.0:.0f}"),
"attributes": {"unit_of_measurement": fc_unit}}
hass.state = state
tmp = tempfile.mkdtemp(prefix="s125-")
db = os.path.join(tmp, "tariff.db")
if peak is not None:
tariff.PeakStore(db)._db.execute(
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
(tariff.month_key(t0), peak,
"2026-10-21T20:15:00+00:00")).connection.commit()
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
peak_db_path=db,
price_history_path=os.path.join(tmp, "ph.json"))
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
c.auto = True
real = m.datetime, homeview.datetime, p1mod.time
clock, simt = [t0], [0.0]
class _DT(real[0]):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
task = asyncio.create_task(c.run_tariff_refresh())
try:
hass.advance(0.0, 10.0)
t, n = 0.0, 0
while t < minutes * 60:
now = t0 + timedelta(seconds=t)
hass.t, simt[0], clock[0] = t, t, now
if t == 0.0:
for _ in range(500):
if c.tariff_plan is not None:
break
await asyncio.sleep(0)
q = int(now.timestamp() // QUARTER_S)
acc = sums.setdefault(q, [0.0, 0])
g_now = hass.grid()
fc_kw[0] = (acc[0] + g_now * (QUARTER_S - acc[1])) \
/ QUARTER_S / 1000.0
if now.timestamp() % QUARTER_S < QUARTER_S - late \
and t >= lag.p1_s \
and (not period or t % period < 1e-9):
imp, exp = split_signed(hass._past(lag.meter_s
+ lag.p1_s)[0])
c.p1.submit(make_sample(
"ha_signed", imp, exp, phases=1,
ingest_ts=now - timedelta(seconds=lag.p1_s),
ingest_mono=1e6 + t))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(lag.tick, 10.0)
acc[0] += max(0.0, hass.grid())
acc[1] += 1
rows.append((now, c.target, hass.plants["a"].p,
getattr(c.arb, "action", None)))
pg = c.peak_guard
if every and n % every == 0 and t >= QUARTER_S \
and pg is not None and pg.projected_w is not None:
row = first((r["text"] for r in c.checks(now)
if r["id"] == "peak_guard"), "guard row")
samples.append((pg.projected_w, pg.ceiling_w, pg.engaged,
row, homeview.month_peak(c, now)["line"]))
t += lag.tick
n += 1
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime, p1mod.time = real
_price.load_prices, _st.load_prices = real_load
return c
c = asyncio.run(go())
full = {datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"):
s / k for q, (s, k) in sorted(sums.items()) if k >= QUARTER_S}
return c, rows, full, samples
def into(now):
return now.timestamp() % QUARTER_S
# ============================================================================ #
print("1. charge_room_w(): a late reading's seconds count at that reading")
_dec = peakguard.decide(
Projection(5800.0, 5800.0, 450.0, 1.0, True, START, 5800.0),
batt_w=-4800.0, cap_w=3500, margin_w=50,
reading=types.SimpleNamespace(is_observed=True, peak_w=PEAK),
achievable_w=9600.0, soc=50.0, soc_floor=20.0)
_room = peakguard.charge_headroom_w(_dec)
_late = Projection(5800.0, 5800.0, 450.0, 430.0 / 450.0, False, START,
5800.0)
print(f" headroom {_room:.0f} W; 20 s late, 450 s left: "
f"{peakguard.charge_room_w(_dec, _late):.0f} W")
check("with every second counted it is charge_headroom_w() exactly",
peakguard.charge_room_w(
_dec, Projection(5800.0, 5800.0, 450.0, 1.0, True, START, 5800.0))
== _room and _room > 0.0)
check("20 s late at a 5800 W reading with 450 s left: 5800 x 20 / 450 W "
"less room",
abs(peakguard.charge_room_w(_dec, _late)
- (_room - 5800.0 * 20.0 / 450.0)) < 1e-6)
check("never below 0 W, and an export held counts nothing",
peakguard.charge_room_w(_dec, Projection(
5800.0, 5800.0, 880.0, 850.0 / 880.0, False, START, 5800.0)) == 0.0
and peakguard.charge_room_w(_dec, Projection(
5800.0, 5800.0, 450.0, 430.0 / 450.0, False, START, -900.0))
== _room)
check("None, blind (0 W) and no projection pass through",
peakguard.charge_room_w(None, _late) is None
and peakguard.charge_room_w(_dec, None) == _room
and peakguard.charge_room_w(
peakguard.PeakDecision(engaged=True, floor_w=0.0, ceiling_w=3500.0,
ceiling_from="cap", blind="frozen"),
_late) == 0.0)
# ============================================================================ #
def graded(label, full, aim):
print(f" {label}: quarters "
f"{ {k: round(v, 1) for k, v in full.items()} } W, aim {aim:.0f}")
return len(full) >= 2 and all(v <= aim + TOL for v in full.values())
def cut_and_back(rows):
"""The charge is off in each quarter's last 5 s, and back (A charging
over 2 kW) within the next quarter's first minute."""
ends = [r for r in rows if into(r[0]) >= QUARTER_S - 5]
starts = {}
for r in rows:
if into(r[0]) < 60:
q = int(r[0].timestamp() // QUARTER_S)
starts[q] = starts.get(q, False) or r[2] < -2000.0
return (present(first(iter(ends), "a quarter end"))
and all(r[1] > -1.0 for r in ends)
and len(starts) >= 2 and all(starts.values()))
print("\n2. a reading 30 s late at each quarter's end, the live shape")
GAP = {}
for name, kw, aim in (
("month peak 5413 W banked, P1 projection",
dict(peak=PEAK), AIM_PEAK),
("month peak banked, peak_forecast_entity (kW)",
dict(peak=PEAK, fc=True), AIM_PEAK),
("no peak banked (aim = cap - margin), P1 projection",
dict(), AIM_CAP),
("no peak banked, peak_forecast_entity", dict(fc=True), AIM_CAP)):
fc = kw.pop("fc", False)
_c, _rows, _full, _s = drive(opts(fc=fc), late=30.0, **kw)
GAP[name] = (_c, _rows, _full)
check(f"{name}: every quarter at or under the aim "
f"({aim:.0f} W, +{TOL:.0f} W landing)", graded(name, _full, aim))
check(f"{name}: the charge is off in the quarter's last seconds and "
f"back in the next quarter", cut_and_back(_rows))
_c_fc = GAP["month peak banked, peak_forecast_entity (kW)"][0]
check("the forecast entity is read, in kW, as the live template (SAFETY-118), "
"and the cheap run's charge does not depend on it",
_c_fc.peak_fc is not None and _c_fc.peak_fc > 1000.0
and [r[1] for r in GAP["month peak banked, peak_forecast_entity (kW)"]
[1]] == [r[1] for r in GAP[
"month peak 5413 W banked, P1 projection"][1]])
# ============================================================================ #
print("\n3. the same for a price trade (modes off) and a maintenance charge")
_ca, _ra, _fa = drive(opts(modes=False, arb=True), peak=PEAK, late=30.0)[:3]
check("modes off, price trading on, a reading 30 s late: every quarter at "
"or under the aim", graded("trade", _fa, AIM_PEAK)
and any(r[3] == "ARBITRAGE_CHARGE" for r in _ra))
def late_feed(gap, every=0.0, look=None):
"""safety84's cheap-window feed, no telegram in each quarter's last
`gap` s, and one only every `every` s (0: every cycle). `look(c, t)`
runs first, on the state the previous cycle left."""
feed = s84.feed(True)
def before(c, t):
if look is not None:
look(c, t)
if ((rig.BASE.timestamp() + t) % QUARTER_S < QUARTER_S - gap
and (not every or t % every < 1e-9)) \
or not getattr(c.hass, "_s84", False):
feed(c, t)
return before
def rig_quarters(rows, load):
"""The safety84 rig's whole quarters: the true average import (W)."""
qs = {}
for r in rows:
q = int((rig.BASE.timestamp() + r[0]) // QUARTER_S)
a = qs.setdefault(q, [0.0, 0])
a[0] += max(0.0, load(r[0]) - sum(v for _u, v in r[2]))
a[1] += 1
return {datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"):
s / k for q, (s, k) in sorted(qs.items())
if k * rig.TICK >= QUARTER_S}
for peak, house, aim in ((5413.0, 4000.0, 5363.0), (None, 2500.0, AIM_CAP)):
_mr, _mc = s84.run(2, load=lambda t, h=house: h, dur=2700.0,
month_peak=peak, before=late_feed(30.0))
_mf = rig_quarters(_mr, lambda t, h=house: h)
check(f"N=2 maintenance charge, house {house:.0f} W, "
f"{'peak banked' if peak else 'no peak banked'}, a reading 30 s "
f"late: every quarter at or under the aim",
graded("maintenance", _mf, aim)
and any(mm.phase == "charge" for mm in _mc.maints))
# ============================================================================ #
print("\n4. a healthy meter: the quarters, and the words")
for name, kw, aim, kw_text in (("month peak banked", dict(peak=PEAK),
AIM_PEAK, "5.4 kW"),
("no peak banked", dict(), AIM_CAP, "3.5 kW")):
_c, _rows, _full, _s = drive(opts(), every=10, **kw)
check(f"{name}: every quarter at or under the aim",
graded(name, _full, aim))
over = [s for s in _s if not s[2] and s[0] > s[1]]
under = [s for s in _s if not s[2] and s[0] <= s[1]]
_o, _u = first(iter(over), "an over sample"), \
first(iter(under), "an under sample")
print(f" over the limit at this rate ({len(over)} samples): "
f"{_o[3] if present(_o) else _o!s} / "
f"{_o[4] if present(_o) else _o!s}")
print(f" under it ({len(under)}): "
f"{_u[3] if present(_u) else _u!s} / "
f"{_u[4] if present(_u) else _u!s}")
check(f"{name}: over the limit at this rate the guard row never says "
f"'under'; it says the grid charge is limited",
present(_o) and all(
"under" not in s[3] and "Peak guard: grid charge limited, "
"at this rate" in s[3] and " over " in s[3] for s in over))
check(f"{name}: ...and the month-peak line says the rate, and that the "
f"grid charge is cut back to stay under {kw_text}",
present(_o) and all(
s[4].startswith("At this rate this 15-minute period reaches")
and s[4].endswith("The grid charge is cut back to stay "
f"under {kw_text}.") for s in over))
check(f"{name}: under the limit both read as before",
present(_u) and all(
s[3].startswith("Peak guard: idle, heading for")
and " under " in s[3]
and s[4].startswith("This 15-minute period is heading for")
and s[4].endswith(f"The batteries step in above {kw_text}.")
for s in under))
# ============================================================================ #
print("\n5. the words on their own")
_en = messages.row_peak_guard_idle(5814.0, 5413.0, month_peak=True)
_cut = messages.row_peak_guard_idle(5814.0, 5413.0, month_peak=True,
cut=True)
_ok = messages.row_peak_guard_idle(5200.0, 5413.0, month_peak=True)
print(f" {_en}\n {_cut}\n {_ok}")
check("over, nothing cut: 'idle, at this rate ..., over ...'",
_en == "Peak guard: idle, at this rate 5 814 W, over 5 413 W")
check("over, a grid charge cut",
_cut == "Peak guard: grid charge limited, at this rate 5 814 W, "
"over 5 413 W")
check("under: the 0.19.1 sentence, unchanged",
_ok == "Peak guard: idle, heading for 5 200 W, under this month's "
"5 413 W peak")
check("the Dutch forms say the same",
_cut.nl == "Piekbewaking: laden beperkt, aan dit tempo 5 814 W, "
"boven 5 413 W"
and _en.nl.startswith("Piekbewaking: in rust, aan dit tempo"))
check("the month-peak line in shadow mode says 'would'",
homeview.line_period(5814.0, 5413.0, True, cut=True).endswith(
"The grid charge would be cut back to stay under 5.4 kW.")
and homeview.line_period(5814.0, 5413.0, False).endswith(
"The batteries step in above 5.4 kW."))
# ============================================================================ #
print("\n6. one inverter: the maintenance charge is sized to the room too")
# The start-up quarter on a 10 s meter: the charge's first step is not seen
# until the second reading, so it may land up to START_TOL over the aim; the
# same at N=2 and before this fix. Every later quarter is graded at TOL.
START_TOL = 15.0
N1 = {}
for peak, house, aim in ((5413.0, 4000.0, 5363.0), (None, 2500.0, AIM_CAP)):
for late, every in ((0.0, 0.0), (30.0, 0.0), (20.0, 10.0)):
_r, _c = s84.run(1, load=lambda t, h=house: h, dur=2700.0,
month_peak=peak, before=late_feed(late, every))
_q = rig_quarters(_r, lambda t, h=house: h)
N1[peak, late, every] = (_r, _c)
_vals = list(_q.values())
_tag = (f"N=1, {'peak banked' if peak else 'no peak banked'}, "
f"{'10 s' if every else '5 s'} meter, "
f"{'late ' + format(late, '.0f') + ' s' if late else 'healthy'}")
print(f" {_tag}: quarters "
f"{ {k: round(v, 1) for k, v in _q.items()} } W, aim {aim:.0f}")
check(f"{_tag}: every quarter at or under the aim",
len(_vals) == 3
and all(v <= aim + TOL for v in _vals[1:])
and nth(_vals, 0, "a first quarter")
<= aim + (START_TOL if every else TOL)
and any(mm.phase == "charge" for mm in _c.maints))
_hr, _hc = N1[5413.0, 0.0, 0.0]
_eng = sum(1 for r in _hr if r[-1][1] and r[-1][1][0])
print(f" healthy, peak banked: guard engaged on {_eng} of {len(_hr)} "
f"cycles; A charges on "
f"{sum(1 for r in _hr if dict(r[2])['a'] < -1.0)} cycles")
check("N=1: the guard no longer steps in during the charge (no hand-back), "
"and the battery charges on every cycle, at no more than its rate",
_eng == 0 and all(-2000.0 <= dict(r[2])["a"] < -1.0 for r in _hr)
and not any("suspended" in e for e in _hc.events))
# ============================================================================ #
print("\n7. the row during an N=2 maintenance charge and in shadow mode")
_rows7 = []
def _look(c, t):
pg = c.peak_guard
if t >= QUARTER_S and pg is not None and pg.projected_w is not None \
and not pg.engaged and pg.projected_w > pg.ceiling_w:
_rows7.append(first((r["text"] for r in c.checks()
if r["id"] == "peak_guard"), "guard row"))
def _steps(t):
"""4000 W, 800 W more for the first 30 s of every minute."""
return 4000.0 + (800.0 if t % 60.0 < 30.0 else 0.0)
_m2r, _m2c = s84.run(2, load=_steps, dur=1800.0, month_peak=5413.0,
before=late_feed(0.0, look=_look))
_m2q = rig_quarters(_m2r, _steps)
print(f" N=2 maintenance charge, house steps 4000/4800 W: "
f"{len(_rows7)} over-the-limit rows, e.g. "
f"{first(iter(_rows7), 'a row')}; quarters "
f"{ {k: round(v, 1) for k, v in _m2q.items()} } W")
check("N=2 maintenance charge, over the limit at this rate: the row says the "
"grid charge is limited",
present(first(iter(_rows7), "a row")) and all(
r.startswith("Peak guard: grid charge limited, at this rate")
for r in _rows7)
and all(v <= 5363.0 + TOL for v in _m2q.values()))
_sh_o = dict(opts(), shadow_mode="on")
_csh, _rsh, _fsh, _ssh = drive(_sh_o, peak=PEAK, minutes=17, every=10,
plant=OnItsOwn)
_sh_over = [s for s in _ssh if not s[2] and s[0] > s[1]]
_sh1 = first(iter(_sh_over), "an over sample")
print(f" shadow mode, the inverter charging 5000 W on its own: "
f"{len(_sh_over)} over samples, e.g. "
f"{_sh1[3] if present(_sh1) else _sh1!s} / "
f"{_sh1[4] if present(_sh1) else _sh1!s}")
check("shadow mode: nothing is cut on the wire, so the row says 'idle', "
"never 'grid charge limited', and the line says 'would'",
_csh.shadow and present(_sh1) and all(
s[3].startswith("Peak guard: idle, at this rate")
and "would" in s[4] for s in _sh_over))
# ============================================================================ #
print("\n8. edge cases")
_e45 = drive(opts(), peak=PEAK, late=45.0)
check("a reading 45 s late, past the 30 s hold (the guard goes blind): every "
"quarter at or under the aim", graded("late 45 s", _e45[2], AIM_PEAK))
_e07 = drive(opts(), peak=PEAK, late=30.0, minutes=24,
start=START + timedelta(minutes=7))
check("a start at 00:07, mid-quarter, then 30 s late: the next quarter at or "
"under the aim",
len(_e07[2]) == 1 and all(v <= AIM_PEAK + TOL
for v in _e07[2].values()))
print(f" start at 00:07: {_e07[2]}")
_dst = drive(opts(), peak=PEAK, late=30.0,
start=datetime(2026, 10, 25, 0, 45, tzinfo=timezone.utc))
check("across the end of summer time (2026-10-25 01:00 UTC), 30 s late: "
"every quarter at or under the aim", graded("DST", _dst[2], AIM_PEAK))
_ew = drive(opts(fc=True), peak=PEAK, late=30.0, fc_unit="W")
check("the forecast entity in W: read as W, and the charge as with kW",
_ew[0].peak_fc is not None and _ew[0].peak_fc > 1000.0
and [r[1] for r in _ew[1]] == [r[1] for r in GAP[
"month peak banked, peak_forecast_entity (kW)"][1]])
_e10 = drive(opts(), peak=PEAK, period=10.0)
_e10v = list(_e10[2].values())
print(f" 10 s meter: {_e10[2]}")
check("a 10 s meter: every quarter after the start-up one at or under the "
"aim, and that one within START_TOL",
len(_e10v) == 2 and all(v <= AIM_PEAK + TOL for v in _e10v[1:])
and nth(_e10v, 0, "a first quarter") <= AIM_PEAK + START_TOL)
_p_end = Projection(5800.0, 5800.0, 899.5, 898.5 / 899.5, False, START,
5800.0)
check("0.5 s left and 1 s late: finite, the formula, at its floor of 0 W",
peakguard.charge_room_w(_dec, _p_end) == max(
0.0, _room - 5800.0 * 1.0 / 0.5))
_d_end = peakguard.decide(
Projection(5800.0, 5800.0, 900.0, 1.0, True, START, 5800.0),
batt_w=-4800.0, cap_w=3500, margin_w=50,
reading=types.SimpleNamespace(is_observed=True, peak_w=PEAK),
achievable_w=9600.0, soc=50.0, soc_floor=20.0)
check("no second left: no room is measured (None), and nothing divides by 0",
peakguard.charge_room_w(_d_end, Projection(
5800.0, 5800.0, 900.0, 890.0 / 900.0, False, START, 5800.0))
is None)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+473
View File
@@ -0,0 +1,473 @@
"""Runnable check for SAFETY-126. `python3 test_safety126.py`
During the night grid charge, with both batteries under the peak reserve,
the add-on logged ERROR PEAK_GUARD_FLOOR_LIMITED and raised "Battery stopped
limiting the peak" at quarter ends, and the Batteries "Why" blinked "The peak
limit needs power from the batteries.", while every quarter landed on the aim.
The cause: the charge lands the quarter on the aim, a 1 W landing error in
the quarter's last second becomes tens of kW through `excess_w()`'s
900 / remaining, and the alarm fired on any `wanted > 0` when no battery can
give. DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md.
Drives the REAL Controller (read_inputs + cycle, the tariff task on a
day/night profile, a P1 meter on a simulated clock, OPT-15's lag), as
test_safety125.py does, with the cycle a fraction of a second off the
quarter's edge as on the rig.
1. The night grid charge under the reserve: no ERROR, no notification, no
red peak row, no "Why" peak, every quarter on the aim; the guard did want
power at the quarter's end (not vacuous); with the fix reverted the same
run alarms. The commands are the same with and without the fix.
2. A real overrun under the reserve still alarms, a step load at the same
cycle as before, and names both batteries. After it, the latch is reset:
the night's later landings are quiet again.
3. `misses_aim()` on its own.
4. The page and the "Why" on a decision within the aim.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import asyncio
import dataclasses
import io
import logging
import os
import sys
import tempfile
import types
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import app.main as m # noqa: E402
import app.p1 as p1mod # noqa: E402
from app import homeview, messages, peakguard, tariff # noqa: E402
from app import price as _price, static_tariff as _st # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import QUARTER_S, Projection # noqa: E402
logging.disable(logging.NOTSET)
logging.getLogger().setLevel(logging.CRITICAL)
_ERRORS = io.StringIO()
_h = logging.StreamHandler(_ERRORS)
_h.setLevel(logging.ERROR)
logging.getLogger("goodwe").addHandler(_h)
logging.getLogger("goodwe").setLevel(logging.ERROR)
logging.getLogger("goodwe").propagate = False
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
PEAK = 5413.17 # October's banked peak on the live site
AIM_CAP = 3500.0 - 50.0
AIM_PEAK = PEAK - 50.0
TOL = 10.0 # W over the aim a quarter may land (SAFETY-81)
L = timezone(timedelta(hours=1))
NIGHT = datetime(2026, 10, 30, 22, 0, tzinfo=L).astimezone(timezone.utc)
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.2333},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.2801}]}')
OWNER = {"a": "leftover_solar", "b": "cheap_hours"}
def opts(*, modes=True, arb=False):
o = dict(rp.options(modes, arb, 2))
o.update(cycle_cost_threshold_eur=0.03, static_profile=PROFILE,
peak_cap_w=3500, peak_margin_w=50)
o["inverters"][0].update(max_w=5000, capacity_kwh=15.36)
o["inverters"][1].update(max_w=4600, capacity_kwh=20.48)
return o
class Harder(rp.Plant):
"""Charges harder than asked, as inverter-a did live (test_safety125.py:
asked -4271 W, measured about -4750 W; asked -5000 W, about -5210 W), up
to `cap` W. So a charge sized to the aim lands a few W over it, as on the
rig (worst +7 W)."""
cap = -5216.0
def step(self, dt, floor):
c = self.cmd
if c < 0:
self.cmd = max(c * 1.11, self.cap)
try:
super().step(dt, floor)
finally:
self.cmd = c
async def _no_fetch(*a, **k):
raise AssertionError("the tariff task reached for a price feed")
def without_fix(fn):
"""release/1.0 cc9bbeb: no decision is ever within the aim."""
real = getattr(Controller, "_peak_landing", None)
if real is None:
return fn()
Controller._peak_landing = lambda self, decision: decision
try:
return fn()
finally:
Controller._peak_landing = real
def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
start=NIGHT, off=0.7, peak=None):
"""The real Controller from `start` + `off` s for `minutes` at a 1 s
cycle with OPT-15's lag, so a cycle lands `1 - off` s before each
quarter's end. `house` is W or a function of t. Returns a dict: the
quarters' true grid averages, the commands, the reserve notifications,
the ERROR lines, and per cycle where the guard engaged: (t, floor
limited, within the aim, battery "Why" peak, red reserve row, red
saturated row, alarm latched)."""
store = rp._Store()
for u, mode in (modes or {}).items():
store.data[unit_key(f"inverter-{u}", "pack_use")] = True
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
store.data[unit_key(f"inverter-{u}", "pack_sell")] = False
load = house if callable(house) else (lambda t: float(house))
t0 = start + timedelta(seconds=off)
out = {"cmds": [], "notes": [], "seen": [], "sums": {}}
_ERRORS.seek(0)
_ERRORS.truncate()
async def go():
lag = rp.Lag()
hass = rp.Hass(("a", "b"), load, {"a": socs[0], "b": socs[1]},
limits={"a": (-5000.0, 5000.0),
"b": (-4600.0, 4600.0)}, lag=lag)
for u, cap in (("a", -5216.0), ("b", -4800.0)):
hass.plants[u].__class__ = Harder
hass.plants[u].cap = cap
real_set = hass.set_number
async def set_number(e, v):
out["cmds"].append((round(hass.t, 3), e, float(v)))
return await real_set(e, v)
async def call(*a):
if a[:2] == ("persistent_notification", "create") \
and a[2]["notification_id"] == messages.ID_PEAK_RESERVE:
out["notes"].append((hass.t, a[2]["message"]))
return True
hass.set_number, hass.call = set_number, call
tmp = tempfile.mkdtemp(prefix="s126-")
db = os.path.join(tmp, "tariff.db")
if peak is not None:
tariff.PeakStore(db)._db.execute(
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
(tariff.month_key(t0), peak,
"2026-10-21T20:15:00+00:00")).connection.commit()
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
peak_db_path=db,
price_history_path=os.path.join(tmp, "ph.json"))
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
c.auto = True
real = m.datetime, homeview.datetime, p1mod.time
clock, simt = [t0], [0.0]
class _DT(real[0]):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
task = (asyncio.create_task(c.run_tariff_refresh())
if o.get("battery_modes_enabled") or o.get("arbitrage_enabled")
else None)
try:
hass.advance(0.0, 10.0)
t = 0.0
while t < minutes * 60:
now = t0 + timedelta(seconds=t)
hass.t, simt[0], clock[0] = t, t, now
if t == 0.0 and task is not None:
for _ in range(500):
if c.tariff_plan is not None:
break
await asyncio.sleep(0)
q = int(now.timestamp() // QUARTER_S)
acc = out["sums"].setdefault(q, [0.0, 0])
if t >= lag.p1_s:
imp, exp = split_signed(hass._past(lag.meter_s
+ lag.p1_s)[0])
c.p1.submit(make_sample(
"ha_signed", imp, exp, phases=1,
ingest_ts=now - timedelta(seconds=lag.p1_s),
ingest_mono=1e6 + t))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(lag.tick, 10.0)
acc[0] += max(0.0, hass.grid())
acc[1] += 1
g = c.peak_guard
if g is not None and g.engaged and g.blind is None:
rows = {r["id"]: r for r in c.checks(now)}
row = rows.get("peak_guard", {})
red = row.get("ok") is False and not row.get("warn")
out["seen"].append((
t, g.floor_limited, getattr(g, "within_aim", False),
homeview.peak_engaged(c),
red and "banner" in row,
red and "banner" not in row,
c._peak_floor_limited))
t += lag.tick
finally:
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime, p1mod.time = real
_price.load_prices, _st.load_prices = real_load
return c
out["c"] = asyncio.run(go())
out["quarters"] = {
datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"): s / k
for q, (s, k) in sorted(out["sums"].items()) if k >= QUARTER_S - 1}
out["errors"] = [x for x in _ERRORS.getvalue().splitlines()
if "PEAK_GUARD_FLOOR_LIMITED" in x]
return out
def show(tag, r):
print(f" {tag}: quarters "
f"{ {k: round(v, 1) for k, v in r['quarters'].items()} }, "
f"guard cycles {len(r['seen'])}, floor-limited "
f"{sum(1 for s in r['seen'] if s[1])}, within-aim "
f"{sum(1 for s in r['seen'] if s[2])}, Why peak "
f"{sum(1 for s in r['seen'] if s[3])}, notes {len(r['notes'])}, "
f"errors {len(r['errors'])}")
def quiet(r):
"""No reserve alarm in any form: ERROR, notification, red row, latch,
"Why" peak, and no red saturated row."""
return (not r["errors"] and not r["notes"]
and not any(s[3] or s[4] or s[5] or s[6] for s in r["seen"]))
def on_aim(r, aim):
q = r["quarters"]
return len(q) >= 3 and all(v <= aim + TOL for v in q.values())
# ============================================================================ #
print("1. the night grid charge under the peak reserve")
NIGHTS = {}
# The owner's shape at the cap's aim. At the banked peak's aim it is
# test_safety125.py's live shape (A Cheap hours, B Solar first), because
# B's 4 600 W alone stays under that aim.
LIVE = {"a": "cheap_hours", "b": "solar_first"}
for tag, kw, aim in (
("aim 3450, cycle 0.3 s before the edge", dict(off=0.7), AIM_CAP),
("aim 3450, cycle 0.01 s before the edge", dict(off=0.99), AIM_CAP),
("month peak 5413, cycle 0.01 s before the edge",
dict(off=0.99, peak=PEAK, modes=LIVE, socs=(11.0, 10.0)),
AIM_PEAK)):
head = drive(opts(), **kw)
base = without_fix(lambda: drive(opts(), **kw))
NIGHTS[tag] = (head, base)
show("head " + tag, head)
show("fix reverted", base)
check(f"⭐ {tag}: no ERROR, no notification, no red peak row and no "
f"'Why' peak", quiet(head))
check(f"{tag}: every quarter at or under the aim ({aim:.0f} W + "
f"{TOL:.0f} W)", on_aim(head, aim))
check(f"{tag}: not vacuous, the guard wanted power under the reserve "
f"at a quarter's end (floor-limited, within the aim)",
any(s[1] and s[2] for s in head["seen"]))
check(f"{tag}: with the fix reverted the same run alarms",
not quiet(base))
check(f"{tag}: the commands are the same with and without the fix",
head["cmds"] == base["cmds"] and len(head["cmds"]) > 100)
_h, _b = NIGHTS["aim 3450, cycle 0.01 s before the edge"]
check("fix reverted, at 0.01 s: the ERROR, the notification and the "
"'Why' peak all come back (the rig's three symptoms)",
_b["errors"] and _b["notes"] and any(s[3] for s in _b["seen"]))
# ============================================================================ #
print("\n2. a real overrun under the reserve still alarms")
def evening(t):
"""600 W, then an oven and a car charger from 20 minutes on."""
return 600.0 if t < 1200 else 6500.0
for tag, onset, kw in (
("night grid charge, then 6500 W", 1200.0, dict(house=evening)),
("modes off, 5500 W all along", 0.0,
dict(house=5500.0, modes=None, socs=(15.0, 12.0)))):
o = opts(modes=kw.get("modes", OWNER) is not None)
head = drive(o, minutes=35, **kw)
base = without_fix(lambda: drive(o, minutes=35, **kw))
show("head " + tag, head)
show("fix reverted", base)
# From the load's onset: before it, the fix reverted shows the night's
# false alarm (part 1).
first_h = next((s[0] for s in head["seen"] if s[6] and s[0] >= onset),
None)
first_b = next((s[0] for s in base["seen"] if s[6] and s[0] >= onset),
None)
check(f"⭐ {tag}: the ERROR and the notification, naming both "
f"batteries",
head["errors"] and head["notes"]
and all("inverter-a and inverter-b" in n for _t, n in head["notes"]))
_th = [t for t, _n in head["notes"]]
_tb = [t for t, _n in base["notes"] if t >= onset]
print(f" notifications at {_th} s; with the fix reverted {_tb} s")
check(f"{tag}: the first at the same cycle as before the fix "
f"({first_h} s, {first_b} s), and none that was not raised before",
first_h is not None and first_h == first_b
and present(nth(_th, 0)) and nth(_th, 0) == nth(_tb, 0)
and set(_th) <= set(_tb))
check(f"{tag}: the red peak row and its banner while it stands",
any(s[4] for s in head["seen"]))
def burst(t):
"""600 W, a 6 500 W load from 20 to 25 minutes, then 600 W again while
the night charge goes on."""
return 6500.0 if 1200 <= t < 1500 else 600.0
# F3 (review): `_peak_landing()` resets its latch when the guard lets go, so
# a real overrun does not keep the later landing errors alarming.
_bh = drive(opts(), house=burst, off=0.99, minutes=61)
_bb = without_fix(lambda: drive(opts(), house=burst, off=0.99,
minutes=61))
show("head real overrun, then the night again", _bh)
show("fix reverted", _bb)
# The burst banked a new month peak, which the later quarters stay under.
_new_peak = _bh["c"].peak_guard.ceiling_w
def _late(r):
"""From the next quarter on: the overrun's own quarter ends over."""
return [x for x in r["seen"] if x[0] >= 1800]
print(f" notifications at {[t for t, _n in _bh['notes']]} s; with the fix "
f"reverted {[t for t, _n in _bb['notes']]} s; limit after the burst "
f"{_new_peak:.0f} W")
check("⭐ after a real overrun ends, the latch is reset: the alarm came "
"during the overrun, and the later quarters' landings are quiet "
"(no notification, no 'Why' peak, no red row) and under the new peak",
any(1200 <= t < 1500 for t, _n in _bh["notes"])
and not [t for t, _n in _bh["notes"] if t >= 1800]
and not any(x[3] or x[4] or x[5] for x in _late(_bh))
and present(nth([v for k, v in _bh["quarters"].items()
if k >= "22:30"], 0))
and all(v <= _new_peak for k, v in _bh["quarters"].items()
if k >= "22:30"))
check("...not vacuous: the guard wanted power at those landings, and with "
"the fix reverted they show the 'Why' peak or an alarm",
any(x[2] for x in _late(_bh))
and (any(x[3] or x[4] or x[5] for x in _late(_bb))
or bool([t for t, _n in _bb["notes"] if t >= 1800])))
# ============================================================================ #
print("\n3. misses_aim() on its own")
START = datetime(2026, 10, 30, 21, 15, tzinfo=timezone.utc)
def proj(projected, elapsed, held):
return Projection(projected, projected, elapsed, 1.0, True, START, held)
check("the rig's case: 3452 W against 3450 W, 0.3 s left, charging "
"1250 W from the grid: not a miss",
not peakguard.misses_aim(proj(3452.0, 899.7, 3452.0), batt_w=-1250.0,
target_w=AIM_CAP))
check("...although decide() wants power there (its gain, 900 / 0.3 s)",
(peakguard.decide(proj(3452.0, 899.7, 3452.0), batt_w=-1250.0,
cap_w=3500, margin_w=50, reading=None,
achievable_w=0.0, soc=10.0, soc_floor=20.0)
.wanted_w or 0.0) > 1000.0)
check("a quarter heading 11 W over the aim with the batteries at 0 W is a "
"miss", peakguard.misses_aim(proj(3461.0, 899.7, 3461.0), batt_w=0.0,
target_w=AIM_CAP))
check("...and 10 W is not (ALARM_MISS_W, as for 'Load too high')",
not peakguard.misses_aim(proj(3460.0, 899.7, 3460.0), batt_w=0.0,
target_w=AIM_CAP))
check("the add-on's own grid charge counts as stoppable: 4000 W on the "
"meter mid-quarter, 3000 W of it charging, is not a miss",
not peakguard.misses_aim(proj(2500.0, 450.0, 4000.0), batt_w=-3000.0,
target_w=AIM_CAP))
check("...and a house of 6000 W the batteries are covering is one",
peakguard.misses_aim(proj(1000.0, 450.0, 0.0), batt_w=6000.0,
target_w=AIM_CAP))
check("no battery reading, or no seconds left: not a miss (decide() files "
"nothing there)",
not peakguard.misses_aim(proj(9000.0, 450.0, 9000.0), batt_w=None,
target_w=AIM_CAP)
and not peakguard.misses_aim(proj(9000.0, 900.0, 9000.0), batt_w=0.0,
target_w=AIM_CAP))
# ============================================================================ #
print("\n4. the page and the 'Why' on a decision within the aim")
_c = NIGHTS["aim 3450, cycle 0.01 s before the edge"][0]["c"]
_dec = peakguard.PeakDecision(
engaged=True, floor_w=5000.0, ceiling_w=3500.0,
ceiling_from=peakguard.CEILING_CAP, projected_w=3451.0, excess_w=9e4,
wanted_w=9e4, target_w=AIM_CAP, saturated=True, soc=30.0, soc_floor=20.0)
def page(d):
"""(the peak row is red, the "Why" says peak) for decision `d`."""
_c.peak_guard, _c._peak_floor_limited = d, False
row = first((r for r in _c.checks(datetime.now(timezone.utc))
if r["id"] == "peak_guard"), "peak row")
if not present(row):
return {"red": None}, None
return dict(row, red=row["ok"] is False and not row.get("warn")), \
homeview.peak_engaged(_c)
_r1, _w1 = page(_dec)
_r2, _w2 = page(dataclasses.replace(_dec, within_aim=True))
check("a saturated decision: red 'Load too high' row and the 'Why' peak, "
"as before", _r1["red"] and _w1)
check("...within the aim: neither", _r2["red"] is False and _w2 is False)
_lim = dataclasses.replace(_dec, floor_w=0.0, saturated=False,
floor_limited=True, soc=10.0,
soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR)
_r3, _w3 = page(_lim)
_r4, _w4 = page(dataclasses.replace(_lim, within_aim=True))
check("floor-limited at 0 W: the red reserve row with its banner, as "
"before", _r3["red"] and "banner" in _r3 and not _w3)
check("...within the aim: no red row",
_r4["red"] is False and _w4 is False)
# ============================================================================ #
print()
if fails:
print(f"{len(fails)} SAFETY-126 check(s) FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all SAFETY-126 checks passed")
+10 -4
View File
@@ -476,13 +476,18 @@ print("\n8. the N=1 and N=2 digests")
# loop's bound just after the guard hands back are gone; every command and
# claim is the same (test_safety119.py part 6). SAFETY-121 re-pinned all six
# again: only OUTPUT_CLAMP rows (test_safety119.py part 6).
# SAFETY-125 (0.19.2, owner 2026-10-29) re-pinned n1_charge and
# n1_charge_banked: at N=1 the cheap-window charge is now sized to the
# quarter's room, as at N=2, so the guard no longer steps in and discharges.
# The banked quarters go 3461.9 -> 3450.0 W (aim 3450) and 3938.9 -> 3950.0
# W (aim 3950). n1_charge_dear (no cheap window) is unchanged.
PINNED = {
"n1_charge":
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5",
"00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9",
"n1_charge_dear":
"c25340b56060d4581f7204e200b07e7c8618711be2baca3b1667b7ebeb600dc3",
"n1_charge_banked":
"2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609",
"09a08298d35195380ff427cdd2448d9212119be4692db26b2b1a45957fdf3300",
"n2_charge":
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
"n2_charge_dear":
@@ -496,8 +501,9 @@ for k in PINNED:
f"{_now[k][:16]}")
check("⭐ at N=1 a charge in and out of a cheap window and under a banked "
"month peak is byte-identical to 9d5b7d1 (commands, reasons and every "
"claim; the log as re-pinned by SAFETY-121), and N=2 outside a cheap "
"window matches its SAFETY-85 pin",
"claim; the log as re-pinned by SAFETY-121; in a cheap window as "
"SAFETY-125 re-pinned it), and N=2 outside a cheap window matches its "
"SAFETY-85 pin",
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
"n1_charge_banked",
"n2_charge_dear")))
+6 -4
View File
@@ -522,7 +522,9 @@ print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
# log. The loop's bound just after a hand-back is not counted, so OUTPUT_CLAMP
# rows are gone or count fewer; every command and claim is the same
# (test_safety119.py part 6). SAFETY-121 re-pinned the same three again:
# only OUTPUT_CLAMP rows (test_safety119.py part 6).
# only OUTPUT_CLAMP rows (test_safety119.py part 6). SAFETY-125 (0.19.2)
# re-pinned n1_charge_low: the N=1 cheap-window charge is sized to the
# quarter's room, so the banked quarter goes 3458.9 -> 3450.0 W (aim 3450).
PINNED = { # tools/safety87_identity.py on b8ddfd7
"n1_under":
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
@@ -533,7 +535,7 @@ PINNED = { # tools/safety87_identity.py on b8ddfd7
"n1_under_banked":
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
"n1_charge_low":
"91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32",
"c3af6218cc14c072e08b09550b153502dafec806009a92ba9032738b9ac9fa61",
"n1_hold":
"0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d",
}
@@ -542,8 +544,8 @@ for k in PINNED:
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
"log) equals b8ddfd7's (the log as re-pinned by SAFETY-121), under, "
"crossing and above the reserve, in a charge and a HOLD, and under a "
"banked peak",
"crossing and above the reserve, in a charge (as SAFETY-125 re-pinned "
"it) and a HOLD, and under a banked peak",
_now == PINNED)
# ============================================================================ #
+374
View File
@@ -0,0 +1,374 @@
"""Runnable check for TARIFF-24. `python3 test_tariff24.py`
Battery modes on, a day/night `static_profile`, no price feed and the site trade
switch off used to start no tariff task: `Controller.tariff_plan` stayed None,
`mode_runs` stayed None, Leftover solar and Cheap hours never charged from the
grid, and the Batteries screen said "No prices" while Home priced the day from
the profile itself. DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
1. The startup gate: `tariff_refresh_wanted()` by configuration, and the guard
`amain()` really puts on the task (read from its source, not retyped).
2. End to end, on the real Controller and the real `run_tariff_refresh()` task,
started only if `amain()`'s own guard says so: night period, one battery
Leftover solar and one Cheap hours.
3. The same with a gap below the cycle cost.
4. Home and the Batteries screen agree.
5. UX-55: with every battery Solar first Home no longer says "no prices have
arrived" on a static profile, and still does with no profile and no feed.
6. Nothing else moved: modes off starts no task; the gate rows in part 1 keep
the arbitrage and feed cases as before.
Mutations (MUTATION.md, purged copies, graded over this file): the gate
reverted to `provider or arbitrage` fails 18 checks in parts 1 to 5; `True`
fails the modes-off rows; `bool(opts.get(...))` fails the exact-true row;
dropping the arbitrage term fails the arbitrage rows.
"""
import ast
import asyncio
import logging
import os
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import app.main as m # noqa: E402
from app import homeview, modetrade, packmode # noqa: E402
from app import price as _price, static_tariff as _st # noqa: E402
from app.main import Controller, tariff_refresh_wanted # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
A, B = "inverter-a", "inverter-b"
# A Belgian-style day/night tariff, example values: dear 07:00-22:00, cheap
# 22:00-07:00, every day. The gap is 0.08 EUR/kWh.
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.22},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.30}]}')
# A Friday in winter time (+01:00), 23:00 local: inside the night period.
L = timezone(timedelta(hours=1))
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
def opts(*, modes=True, arb=False, profile=PROFILE, provider=None,
cost=0.05):
o = dict(rp.options(modes, arb, 2))
o["cycle_cost_threshold_eur"] = cost
if profile is not None:
o["static_profile"] = profile
if provider is not None:
o["price_provider"] = provider
return o
# --------------------------------------------------------------------------- #
# The guard amain() puts on the tariff task, read from amain()'s source.
# --------------------------------------------------------------------------- #
def _guards_of(node, guards=()):
"""[(guards, coroutine name)] for every create_task(...) under `node`."""
out = []
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.If):
test = ast.unparse(child.test)
for stmt in child.body:
out += _guards_of(stmt, guards + (test,))
for stmt in child.orelse:
out += _guards_of(stmt, guards + (f"not ({test})",))
continue
if isinstance(child, ast.Call) and \
ast.unparse(child.func).endswith("create_task") and child.args:
arg = child.args[0]
name = (arg.func.attr if isinstance(arg, ast.Call)
and isinstance(arg.func, ast.Attribute)
else ast.unparse(arg))
out.append((guards, name))
continue
out += _guards_of(child, guards)
return out
_TREE = ast.parse(open(os.path.join(HERE, "app", "main.py"),
encoding="utf-8").read())
_AMAIN = first((n for n in ast.walk(_TREE)
if isinstance(n, ast.AsyncFunctionDef) and n.name == "amain"),
"amain() in main.py")
_TASK_GUARDS = [g for g, n in _guards_of(_AMAIN) if n == "run_tariff_refresh"]
def task_started(o) -> bool:
"""Would amain() start the tariff refresh task for options `o`?"""
if len(_TASK_GUARDS) != 1:
return False
return all(bool(eval(g, vars(m), {"opts": o})) for g in _TASK_GUARDS[0])
# --------------------------------------------------------------------------- #
print("1. the startup gate")
check("amain() starts the tariff refresh from exactly one guard, and that "
"guard can be decided from the options alone",
len(_TASK_GUARDS) == 1 and len(_TASK_GUARDS[0]) >= 1
and all(isinstance(eval(g, vars(m), {"opts": opts()}), bool)
for g in _TASK_GUARDS[0]))
GATE = [
# label, options, task wanted
("modes on + static day/night only", opts(), True),
("modes on + a fixed price only",
opts(profile='{"periods": [{"name": "all", "days": "all", "from": '
'"00:00", "to": "24:00", "eur_per_kwh": 0.27}]}'), True),
("modes on, nothing else", opts(profile=None), True),
("modes off + arbitrage off + no feed (static profile)",
opts(modes=False), False),
("modes off + arbitrage off + no feed, no profile",
opts(modes=False, profile=None), False),
("modes off + arbitrage on", opts(modes=False, arb=True), True),
("modes off + a feed", opts(modes=False, provider="generic_dayahead"),
True),
("modes on + a feed", opts(provider="generic_dayahead"), True),
("modes on + arbitrage on", opts(arb=True), True),
]
for label, o, want in GATE:
got = tariff_refresh_wanted(o)
print(f" wanted={got!s:5} started={task_started(o)!s:5} {label}")
check(f"{label}: tariff_refresh_wanted() is {want}", got is want)
check(f"{label}: amain() starts the task exactly as that says",
task_started(o) is want)
check("battery_modes_enabled has to be exactly true (packmode.enabled()): "
"'true' as text or 1 does not start the task",
not tariff_refresh_wanted(dict(opts(), battery_modes_enabled="true"))
and not tariff_refresh_wanted(dict(opts(), battery_modes_enabled=1)))
# --------------------------------------------------------------------------- #
# The real Controller over a few minutes of a night: the task starts only if
# amain()'s own guard says so; then read_inputs() + cycle() as the control
# loop runs them.
# --------------------------------------------------------------------------- #
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
FETCHES = []
async def _no_fetch(*a, **k):
FETCHES.append(a)
raise AssertionError("the tariff task reached for a price feed")
def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
house_w=300.0, tick=5.0):
"""-> (controller, rows, task_was_started)."""
store = rp._Store()
for u, (use, mode, sell) in choice.items():
store.data[unit_key(f"inverter-{u}", "pack_use")] = use
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
store.data[unit_key(f"inverter-{u}", "pack_sell")] = sell
rows = []
async def go():
hass = rp.Hass(("a", "b"), lambda t: house_w,
soc0 or {"a": 40.0, "b": 40.0})
tmp = tempfile.mkdtemp(prefix="t24-")
c = Controller(dict(o), hass, store, rp._Pub(),
stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, real_hv = m.datetime, homeview.datetime
clock = [start]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
_real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
started = task_started(o)
task = (asyncio.create_task(c.run_tariff_refresh())
if started else None)
try:
t = 0.0
while t < minutes * 60:
now = start + timedelta(seconds=t)
hass.t = t
clock[0] = now
if t == 0.0 and started:
for _ in range(500): # let the task do its first pass
if c.tariff_plan is not None:
break
await asyncio.sleep(0)
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(tick, 10.0)
rows.append(dict(
now=now, runs=c.mode_runs, tick=c.mode_tick,
plan=c.fresh_tariff_plan(now),
action=getattr(c.arb, "action", None),
p={u: p.p for u, p in hass.plants.items()},
soc={u: p.soc for u, p in hass.plants.items()}))
t += tick
# What the two screens say at the end, while the clock is still
# the simulated one.
c.home_plan = homeview.build_plan(c, clock[0], False, True)
c.battery_cards = homeview.battery_rows(c, False, True)
finally:
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
_price.load_prices, _st.load_prices = _real_load
return c, started
c, started = asyncio.run(go())
return c, rows, started
def charging_b(rows):
"""Cycles in which the Cheap hours battery was charging (grid: no sun and
a 300 W house in this scenario)."""
return [r for r in rows if r["p"]["b"] < -200.0]
def why_of(c, name):
card = first((b for b in c.battery_cards if b["name"] == name),
f"a card for {name}")
return card["why_key"]
# --------------------------------------------------------------------------- #
print("\n2. end to end, night, modes on, static day/night, no feed, no arbitrage")
c1, r1, started1 = drive(opts())
_last = nth(r1, -1, "the last cycle")
_first1 = nth(r1, 0, "the first cycle")
print(f" tariff task started by amain's guard: {started1}")
print(f" tariff_plan: {None if c1.tariff_plan is None else c1.tariff_plan.words}")
print(f" mode_runs: {_last['runs']}")
print(f" mode_tick reason / roles: "
f"{None if _last['tick'] is None else (_last['tick'].why, dict(_last['tick'].roles))}")
print(f" Batteries screen why_key: A {why_of(c1, A)}, B {why_of(c1, B)}")
print(f" cycles with B charging: {len(charging_b(r1))} of {len(r1)}, "
f"B soc {_first1['soc']['b']:.1f} -> {_last['soc']['b']:.1f}")
check("the tariff refresh task is started", started1)
check("...and it adds no network call: with price_provider off the feed "
"loader is never reached (it is replaced by one that fails)",
not FETCHES and not _price.provider_configured(opts()))
check("a fresh tariff plan with prices is held", present(_last)
and _last["plan"] is not None and _last["plan"].has_prices)
check("the price runs exist (mode_runs is not None) and carry a cheap run "
"that holds in the night period",
present(_last) and _last["runs"] is not None and _last["runs"].on
and _last["runs"].cheap is not None
and _last["runs"].cheap.holds(_last["now"]))
check("the Cheap hours battery charges from the grid in the night period",
len(charging_b(r1)) > len(r1) // 2
and present(_last, _first1)
and _last["soc"]["b"] > _first1["soc"]["b"] + 0.3)
check("...and the Leftover solar battery does not (no sun, no forecast)",
not any(r["p"]["a"] < -200.0 for r in r1))
check("...the reason is not 'no prices': neither battery reads no_prices",
why_of(c1, A) != "no_prices" and why_of(c1, B) != "no_prices")
check("...only the Cheap hours battery got the grid charge role",
present(_last) and _last["tick"] is not None
and _last["tick"].roles.get(B) == modetrade.CHARGE
and _last["tick"].roles.get(A) != modetrade.CHARGE)
# --------------------------------------------------------------------------- #
print("\n3. the same with a gap below the cycle cost")
c3, r3, started3 = drive(opts(cost=0.20))
_l3 = nth(r3, -1, "the last cycle")
print(f" started {started3}; mode_runs {_l3['runs']}; "
f"why A {why_of(c3, A)}, B {why_of(c3, B)}")
check("the task is started and the plan is there, so mode_runs is not None",
started3 and present(_l3) and _l3["plan"] is not None
and _l3["runs"] is not None)
check("the reason is the gap: no_spread ('Prices differ too little'), "
"never no_prices, on both batteries",
why_of(c3, A) == "no_spread" and why_of(c3, B) == "no_spread"
and present(_l3) and _l3["runs"] is not None
and not _l3["runs"].on)
check("...and no battery charges from the grid",
not charging_b(r3) and not any(r["p"]["a"] < -200.0 for r in r3))
# --------------------------------------------------------------------------- #
print("\n4. Home and the Batteries screen agree")
hp = c1.home_plan
_bars = [b for b in hp["prices"] if b["eur_kwh"] is not None]
_words = {b["why_key"]: b["why"] for b in c1.battery_cards}
print(f" Home: has_prices {hp['has_prices']}, {len(_bars)} priced bars, "
f"verdict {hp['verdict_key']}, lines {hp['lines']}")
print(f" Batteries: {[(b['name'], b['why_key'], b['why']) for b in c1.battery_cards]}")
check("Home prices the day, and the Batteries screen does not say it has no "
"prices", hp["has_prices"] and _bars
and all(b["why_key"] != "no_prices" for b in c1.battery_cards))
check("Home's plan has the grid charge block for the Cheap hours battery "
"(the 'trade' verdict) and no 'no prices have arrived' line",
hp["verdict_key"] == "trade"
and any(r["kind"] == "grid" for r in hp["runs"])
and not any("no prices have arrived" in ln for ln in hp["lines"]))
hp3 = c3.home_plan
check("with the gap too small both say so: Home 'price gap is too small', "
"the batteries 'differ too little'; neither says no prices",
any("price gap is too small" in ln for ln in hp3["lines"])
and all(b["why_key"] == "no_spread" for b in c3.battery_cards))
# --------------------------------------------------------------------------- #
print("\n5. UX-55: every battery Solar first, static day/night, no feed")
SF = {"a": (True, "solar_first", False), "b": (True, "solar_first", False)}
c5, r5, s5 = drive(opts(), choice=SF, minutes=5)
print(f" Home lines: {c5.home_plan['lines']}")
check("Home no longer says 'no prices have arrived' with every battery Solar "
"first on a static profile",
s5 and not any("no prices have arrived" in ln
for ln in c5.home_plan["lines"]))
c5b, r5b, s5b = drive(opts(profile=None), choice=SF, minutes=5)
print(f" no profile, no feed: started {s5b}, Home lines "
f"{c5b.home_plan['lines']}")
check("...and with no profile and no feed there are truly no prices, and "
"Home still says so",
any("no prices have arrived" in ln for ln in c5b.home_plan["lines"]))
# --------------------------------------------------------------------------- #
print("\n6. nothing else moved")
c6, r6, s6 = drive(opts(modes=False), choice={}, minutes=5)
check("modes off, arbitrage off, no feed: no task, no plan, no price runs "
"(as before)", not s6 and c6.tariff_plan is None
and all(r["runs"] is None for r in r6))
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+518
View File
@@ -0,0 +1,518 @@
"""Runnable check for TARIFF-24 round 2 (AUDIT-02 F1). `python3 test_tariff24_weather.py`
With battery modes on, the price gap a mode needs was always widened x1.5
(`Uncertainty.NO_WEATHER`) even with a solar forecast read. Now the doubt is
added only when no usable forecast is read (`arbitrage.weather_uncertainty()`),
in the run plan (`_price_runs()`), the charge gate (`_mode_roles()`) and Home's
mirror (`homeview.trade_verdict()`). Modes off is untouched.
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
1. The rule itself, and that modes off keeps the doubt.
2. Day/night 0.34/0.26 (gap 0.08), modes on, real Controller, night period:
with a forecast at cycle cost 0.07 the cheap run is planned and the Cheap
hours battery charges; without one, or with a stale one, the same cost needs
0.105 and the reason is `not_worth` / "Prices differ too little".
3. A dynamic day with a 0.12 gap at cost 0.10: on with a forecast, not worth
without.
4. The forecast coming (the gate follows in the same cycle) and going mid-run:
a run under way keeps its gap to its end, the next run needs the wider one;
a forecast unavailable for one or two reads mid-run does not stop or
restart the charge.
5. Home agrees with control in each case (Batteries Why, Home line, and
`trade_verdict()`, Home's mirror of the gate).
6. The hold's edges (TARIFF-24 review R4, R5): a forecast lost while only the
day's give run is under way holds to that run's end and no further, so the
night's cheap run needs the wider gap; and a give run that starts during a
hold runs to its end (`keep_started()`), with no grid charge after the hold.
Mutations (MUTATION.md, purged copies, graded over this file): the doubt always
added (the old code) fails parts 2 to 5; never added fails the without-forecast
rows; Home's mirror left on the old rule fails part 5; `_wx_track()` also
recording without a forecast (its `solar_fc is None` return dropped) fails part 6.
"""
import asyncio
import logging
import math
import os
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import app.main as m # noqa: E402
from app import arbitrage, homeview, modetrade, solarfc # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
Uncertainty, brussels)
from app.static_tariff import TariffPlan # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
class _NoHold(solarfc.Hold):
"""OPT-27: a hold that never stands in for a lost sensor."""
def stand_in(self, *a, **k):
return None
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
A, B = "inverter-a", "inverter-b"
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.26},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
L = timezone(timedelta(hours=1))
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
FC_TODAY, FC_TOMORROW = "sensor.fc_today", "sensor.fc_tomorrow"
TARIFF_REFRESH_S = m.TARIFF_REFRESH_S
def opts(cost, *, arb=False, sensors=True):
o = dict(rp.options(True, arb, 2))
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE)
if sensors:
o.update(pv_forecast_entity=FC_TODAY,
pv_forecast_tomorrow_entity=FC_TOMORROW)
return o
def dynamic_points(start):
"""A dynamic day-ahead shape, 0.16 .. 0.28 (gap 0.12), 15-minute points."""
out, t = [], start.replace(minute=0, second=0, microsecond=0)
for _ in range(48 * 4):
h = brussels(t).hour + brussels(t).minute / 60
p = 0.22 + 0.06 * math.cos((h - 19) / 24 * 2 * math.pi)
out.append(PricePoint(start=brussels(t), eur_per_kwh=round(p, 4),
provisional=False))
t += timedelta(minutes=15)
return out
def sensor(kwh, at):
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": "kWh"},
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
def forecast_mode(mode, t, now):
"""What the two forecast sensors read: 'fresh', 'stale', 'gone', or a
function of the simulated seconds that returns one of those."""
if callable(mode):
mode = mode(t)
if mode == "gone":
return {"state": "unavailable", "attributes": {}}
at = now if mode == "fresh" else now - timedelta(hours=5)
return at
def take(c, now):
"""What Home and the Batteries screen say now, and Home's mirror of the
gate (it reads the trade switch, so ask it as a site that has it on next
to the modes)."""
keep = c.o
c.o = dict(keep, arbitrage_enabled=True)
try:
mirror, why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now)
finally:
c.o = keep
return dict(home=homeview.build_plan(c, now, False, True),
cards=homeview.battery_rows(c, False, True),
mirror=mirror, mirror_why=why)
def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
house_w=300.0, soc0=None, snap_at=(), t0=NIGHT):
"""-> (controller, rows). `points`: a feed-shaped plan injected every
cycle (no task); else the static profile via the real tariff task. `t0`:
the first cycle's instant."""
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
rows = []
snaps = {}
class H(rp.Hass):
async def state(self, e):
now = t0 + timedelta(seconds=self.t)
if fc is None:
return None
at = forecast_mode(fc, self.t, now)
if isinstance(at, dict):
return at
return sensor(0.0 if e == FC_TODAY else 12.0, at)
async def go():
hass = H(("a", "b"), lambda t: house_w, soc0 or {"a": 40.0, "b": 40.0})
tmp = tempfile.mkdtemp(prefix="t24w-")
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
# OPT-27: a sensor that is missing is covered for up to 3 h by its last
# fresh reading. This file is about what the plan does when the
# forecast IS lost, so it runs with a hold that never covers
# (test_opt27.py tests the cover).
c.fc_hold = _NoHold()
real_dt, real_hv = m.datetime, homeview.datetime
clock = [t0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
pts = None if points is None else points
task = None
if pts is None:
task = asyncio.create_task(c.run_tariff_refresh())
try:
t = 0.0
while t < minutes * 60:
now = t0 + timedelta(seconds=t)
hass.t = t
clock[0] = now
if pts is not None:
c.tariff_plan = TariffPlan(
PriceRead.series(pts, PriceProduct.DAY_AHEAD,
"generic_dayahead", now), None,
"provider")
c.tariff_at = now
elif t == 0.0 or t % TARIFF_REFRESH_S == 0.0:
# The task's own 30 min wait is wall clock, so the
# simulated clock restarts it: one refresh per period.
if task is not None and t > 0.0:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
task = asyncio.create_task(c.run_tariff_refresh())
for _ in range(500):
if c.tariff_plan is not None and (
c.tariff_at is not None and c.tariff_at >= now):
break
await asyncio.sleep(0)
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(tick, 10.0)
rp_ = c.mode_runs
rows.append(dict(
t=t, now=now, fc_none=c.solar_fc is None,
runs_on=None if rp_ is None else rp_.on,
reason=None if rp_ is None else rp_.reason,
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
free_to=c._wx_free_to, held=c.weather_held(now),
why=None if c.mode_tick is None else c.mode_tick.why,
charging=hass.plants["b"].p < -200.0,
soc_b=hass.plants["b"].soc,
cheap_end=None if rp_ is None or rp_.cheap is None
else rp_.cheap.end))
if t in snap_at:
snaps[t] = take(c, now)
t += tick
last = take(c, clock[0])
c.home_plan, c.cards = last["home"], last["cards"]
c.mirror, c.mirror_why = last["mirror"], last["mirror_why"]
c.snaps = snaps
finally:
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
return c
return asyncio.run(go()), rows
def why_of(c, name):
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
return card["why_key"]
# --------------------------------------------------------------------------- #
print("1. the rule")
NW = frozenset({Uncertainty.NO_WEATHER})
check("modes on with a forecast read: no doubt", arbitrage.weather_uncertainty(
True, object()) == frozenset())
check("modes on with no forecast (None): the doubt", arbitrage.weather_uncertainty(
True, None) == NW)
check("modes off, forecast or not: the doubt, as before",
arbitrage.weather_uncertainty(False, object()) == NW
and arbitrage.weather_uncertainty(False, None) == NW)
# --------------------------------------------------------------------------- #
print("\n2. day/night 0.34 / 0.26 (gap 0.08), night period")
c1, r1 = drive(opts(0.07), fc="fresh")
l1 = nth(r1, -1, "last cycle")
print(f" forecast read, cost 0.07: mode_runs on={l1['runs_on']} "
f"reason={l1['reason']}; B charging {sum(r['charging'] for r in r1)} of "
f"{len(r1)}; Why A {why_of(c1, A)} B {why_of(c1, B)}")
check("with a forecast at cost 0.07 the cheap run is planned (needed 0.07 < "
"0.08) and the Cheap hours battery charges in the night period",
present(l1) and not l1["fc_none"] and l1["runs_on"] is True
and sum(r["charging"] for r in r1) > len(r1) // 2
and l1["roles"].get(B) == modetrade.CHARGE)
for label, mode in (("no forecast sensor reads", "gone"),
("a stale forecast", "stale")):
c, rr = drive(opts(0.07), fc=mode)
ll = nth(rr, -1, "last cycle")
print(f" {label}, cost 0.07: on={ll['runs_on']} reason={ll['reason']}; "
f"B charging {sum(r['charging'] for r in rr)}; Why A "
f"{why_of(c, A)} B {why_of(c, B)}")
check(f"{label}: the same cost needs 0.105 > 0.08, so not_worth and "
f"'Prices differ too little', no grid charge",
present(ll) and ll["fc_none"] and ll["runs_on"] is False
and ll["reason"] == "not_worth"
and why_of(c, A) == "no_spread" and why_of(c, B) == "no_spread"
and not any(r["charging"] for r in rr))
c0, r0 = drive(opts(0.07, sensors=False), fc=None)
check("no forecast sensor configured at all: not_worth too (as 0.19.0)",
present(nth(r0, -1)) and r0[-1]["runs_on"] is False)
c2, r2 = drive(opts(0.10), fc="fresh")
check("at the shipped cost 0.10 even a forecast does not make 0.08 enough "
"(needs 0.10): still not_worth, honestly",
present(nth(r2, -1)) and r2[-1]["runs_on"] is False
and r2[-1]["reason"] == "not_worth")
# --------------------------------------------------------------------------- #
print("\n3. a dynamic day, gap 0.12, cost 0.10")
pts = dynamic_points(NIGHT - timedelta(hours=1))
cd, rd = drive(opts(0.10), fc="fresh", points=pts)
cn, rn = drive(opts(0.10), fc="gone", points=pts)
_ld, _ln = nth(rd, -1, "last cycle"), nth(rn, -1, "last cycle")
print(f" with forecast: on={_ld['runs_on']} reason={_ld['reason']}; "
f"without: on={_ln['runs_on']} reason={_ln['reason']}")
check("with a forecast the run plan is on (needs 0.10 < 0.12)",
present(nth(rd, -1)) and rd[-1]["runs_on"] is True
and why_of(cd, A) != "no_spread")
check("without one it is not_worth (needs 0.15 > 0.12) and says so",
present(nth(rn, -1)) and rn[-1]["runs_on"] is False
and rn[-1]["reason"] == "not_worth"
and why_of(cn, B) == "no_spread")
# --------------------------------------------------------------------------- #
print("\n4. the forecast going and coming mid-run (a run under way keeps its gap)")
CUT = 600.0 # 10 min in
SOC = {"a": 40.0, "b": 60.0} # B's cheap run is then about 2 h
def going(t):
return "fresh" if t < CUT else "gone"
def coming(t):
return "gone" if t < CUT else "fresh"
def flap(n_reads):
"""Unavailable for `n_reads` forecast reads (60 s apart) from CUT."""
return lambda t: "gone" if CUT <= t < CUT + 60.0 * n_reads else "fresh"
MID, AFTER = 3000.0, 7800.0
cg, rg = drive(opts(0.07), fc=going, minutes=135, soc0=SOC,
snap_at=(MID, AFTER))
cc, rc = drive(opts(0.07), fc=coming, minutes=25)
_end = nth([r["cheap_end"] for r in rg if r["cheap_end"] is not None], 0,
"the cheap run's end")
_end_t = (_end - NIGHT).total_seconds() if present(_end) else 1e9
lost = [r for r in rg if r["fc_none"]]
print(f" going: cheap run ends at t={_end_t:.0f} s; forecast lost from "
f"t={lost[0]['t'] if lost else None}")
check("going: the run planned with the forecast ends before the snapshot "
"taken after it (the scenario is long enough)",
present(_end) and CUT + 300.0 < _end_t < AFTER - 300.0 and bool(lost)
and lost[0]["t"] - CUT <= 60.0)
check("going: the forecast read is lost mid-run and the run under way KEEPS "
"its gap: the plan stays on and B keeps charging, cycle after cycle, "
"until B is full or the run ends",
all(r["runs_on"] is True and (r["roles"].get(B) == modetrade.CHARGE
or r["soc_b"] >= 94.0)
for r in rg if r["fc_none"] and r["t"] < _end_t - 60.0)
and all(r["charging"] or r["soc_b"] >= 94.0 for r in rg
if 60.0 <= r["t"] < _end_t - 60.0)
and max(r["soc_b"] for r in rg if r["fc_none"]) >= 90.0)
check("going: from the run's end the wider gap is back: the next run is "
"not_worth (needs 0.105 at 0.07), no new run, and no grid charge",
all(r["runs_on"] is False and r["reason"] == "not_worth"
for r in rg if r["t"] >= _end_t + 30.0)
and not any(r["charging"] for r in rg if r["t"] >= _end_t + 60.0))
_sm, _sa = cg.snaps.get(MID), cg.snaps.get(AFTER)
check("going, Home agrees mid-run: its mirror still allows, the Batteries "
"screen says it charges from the grid, and Home does not say the gap "
"is too small",
_sm is not None and _sm["mirror"] is not None
and first((b for b in _sm["cards"] if b["name"] == B),
"a card")["why_key"] == "grid_charge"
and not any("price gap is too small" in ln
for ln in _sm["home"]["lines"]))
check("going, Home agrees after the run: its mirror refuses, the Batteries "
"screen says 'differ too little' and Home says the gap is too small",
_sa is not None and _sa["mirror"] is None
and first((b for b in _sa["cards"] if b["name"] == B),
"a card")["why_key"] == "no_spread"
and any("price gap is too small" in ln for ln in _sa["home"]["lines"]))
for n in (1, 2):
cf, rf = drive(opts(0.07), fc=flap(n), minutes=30, soc0=SOC,
snap_at=(CUT + 30.0,))
gone = [r for r in rf if r["fc_none"]]
print(f" flap, {n} read(s) lost: {len(gone)} cycles without a "
f"forecast; B charging in {sum(r['charging'] for r in rf)} of "
f"{len(rf)}")
check(f"flap: the forecast is unavailable for {n} read(s) mid-run: the "
f"plan stays on and B charges without a break (no stop, no "
f"restart)",
bool(gone) and all(r["runs_on"] is True for r in rf)
and all(r["charging"] and r["roles"].get(B) == modetrade.CHARGE
for r in rf if r["t"] >= 60.0))
sf = cf.snaps.get(CUT + 30.0)
check(f"flap, {n} read(s): Home agrees while the read is lost (mirror "
f"allows, Batteries 'charges from the grid')",
sf is not None and sf["mirror"] is not None
and first((b for b in sf["cards"] if b["name"] == B),
"a card")["why_key"] == "grid_charge")
def flips(rows):
"""Cycles where the forecast read changed, and whether the plan and the
charge gate changed in that very cycle."""
out = []
for i in range(1, len(rows)):
if rows[i]["fc_none"] != rows[i - 1]["fc_none"]:
out.append((i, rows[i]["t"], rows[i]["runs_on"],
rows[i]["charging"], rows[i - 1]["charging"]))
return out
fcm = flips(rc)
print(f" coming: forecast read arrives at cycle/t {fcm}")
check("coming (no run under way before): not_worth until the read is back, "
"then on in the same cycle, and the battery charges",
len(fcm) == 1 and fcm[0][1] - CUT <= 60.0 and fcm[0][2] is True
and all(r["runs_on"] is False for r in rc if r["fc_none"])
and all(r["runs_on"] is True for r in rc if not r["fc_none"])
and any(r["charging"] for r in rc if r["t"] >= fcm[0][1] + 40.0))
# --------------------------------------------------------------------------- #
print("\n5. Home agrees with control")
CASES = (("forecast, cost 0.07", c1, r1, True),
("no forecast, cost 0.07", *drive(opts(0.07), fc="gone"), False),
("dynamic, forecast", cd, rd, True),
("dynamic, no forecast", cn, rn, False))
for label, cx, rx, on in CASES:
last = nth(rx, -1, "last cycle")
hp = cx.home_plan
mirror_ok = cx.mirror is not None
print(f" {label}: control on={last['runs_on']}; Home mirror "
f"{'allows' if mirror_ok else 'refuses'} ({cx.mirror_why}); "
f"lines {hp['lines'][:1]}; Why B {why_of(cx, B)}")
check(f"{label}: Home's mirror of the gate allows exactly when control "
f"plans", present(last) and mirror_ok is on
and last["runs_on"] is on)
check(f"{label}: the Batteries screen and the Home line say the gap is "
f"too small exactly when control does",
(why_of(cx, B) == "no_spread") is (not on)
and any("price gap is too small" in ln for ln in hp["lines"])
is (not on))
if label.startswith("dynamic"):
continue # its cheap run is tomorrow morning: not on today's chart
check(f"{label}: Home draws a grid charge block exactly when control "
f"charges", any(r["kind"] == "grid" for r in hp["runs"]) is on)
# --------------------------------------------------------------------------- #
print("\n6. the hold's edges (review probes R4, R5)")
T_R4 = datetime(2026, 10, 30, 21, 40, tzinfo=L).astimezone(timezone.utc)
TEN = datetime(2026, 10, 30, 22, 0, tzinfo=L).astimezone(timezone.utc)
c4, r4 = drive(opts(0.07), fc=lambda t: "fresh" if t < 300.0 else "gone",
minutes=60, t0=T_R4)
_held4 = [r for r in r4 if r["held"]]
_l4 = nth(r4, -1, "last cycle")
print(f" R4: forecast lost 21:45; held {len(_held4)} cycles, to "
f"{_l4['free_to']}; after 22:00 on={ {r['runs_on'] for r in r4 if r['now'] >= TEN} } "
f"charging {sum(r['charging'] for r in r4)}")
check("R4, forecast lost before the next run: while only the day's give run "
"was under way the hold is that run's end, 22:00, and stays there",
present(_l4) and all(r["free_to"] == TEN for r in r4
if r["t"] >= 5.0)
and bool(_held4) and all(r["now"] < TEN for r in _held4))
check("R4: from 22:00 the night's cheap run needs the wider gap: "
"not_worth, and no grid charge at any time",
all(r["runs_on"] is False and r["reason"] == "not_worth"
for r in r4 if r["now"] >= TEN)
and not any(r["charging"] for r in r4))
def r5_points(t0):
"""04:00-07:00 at 0.20, 07:00-10:00 at 0.30, else 0.25 (local)."""
out, t = [], t0.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
for _ in range(4 * 30):
h = brussels(t).hour
p = 0.20 if 4 <= h < 7 else 0.30 if 7 <= h < 10 else 0.25
out.append(PricePoint(start=brussels(t), eur_per_kwh=p,
provisional=False))
t += timedelta(minutes=15)
return out
T_R5 = datetime(2026, 10, 30, 5, 0, tzinfo=L).astimezone(timezone.utc)
GIVE_END = datetime(2026, 10, 30, 10, 0, tzinfo=L).astimezone(timezone.utc)
c5, r5 = drive(opts(0.07), fc=lambda t: "fresh" if t < 3600.0 else "gone",
points=r5_points(T_R5), minutes=320, t0=T_R5,
soc0={"a": 40.0, "b": 30.0})
_ft5 = nth([r["free_to"] for r in r5 if r["t"] == 3600.0], 0, "the hold")
_in = [r for r in r5 if present(_ft5) and _ft5 <= r["now"] < GIVE_END]
_out = [r for r in r5 if r["now"] >= GIVE_END + timedelta(seconds=30)]
print(f" R5: forecast lost 06:00, hold to {_ft5}; after it, until 10:00, "
f"on in {sum(r['runs_on'] is True for r in _in)} of {len(_in)}, B "
f"{sorted({str(r['roles'].get(B)) for r in _in})}; after 10:00 "
f"{ {r['reason'] for r in _out} }")
check("R5: the hold is the cheap run's end, before the give run's end",
present(_ft5) and T_R5 < _ft5 < GIVE_END)
check("R5, a run that has started runs to its end: the give run that began "
"during the hold stays on after it, B gives, and nothing charges from "
"the grid",
len(_in) > 100 and all(r["runs_on"] is True
and r["roles"].get(B) == modetrade.GIVE
and not r["charging"] for r in _in))
check("R5: when the give run ends the wider gap decides: not_worth, no grid "
"charge",
bool(_out) and all(r["runs_on"] is False and r["reason"] == "not_worth"
and not r["charging"] for r in _out))
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+32 -2
View File
@@ -684,8 +684,9 @@ check("Dutch: the notes, the totals and the blocks are in Dutch, with a "
c = S.make("dynamic", at(0, 18))
nl_b = tab(c, at(0, 18), "nl")["days"][0]["batteries"][0]["blocks"]
check("...and so are the plan's words on a block",
nl_b[0]["what"].startswith("Laadt tot") and nl_b[0]["why"].startswith(
"Goedkoopste uren") and nl_b[1]["what"].startswith("Levert tot"))
nl_b[0]["what"].startswith("Laadt maximaal") and " %" in nl_b[0]["what"]
and nl_b[0]["why"].startswith("Goedkoopste uren")
and nl_b[1]["what"].startswith("Levert tot"))
check("the language goes back to English after a Dutch build",
homeview.pill("running") == en["home_words"]["pill"]["running"])
c = S.make("dynamic", at(10, 7))
@@ -1455,6 +1456,35 @@ hi_ = tab(c_t, at(10, 7))["days"][0]["batteries"][0]["levels"]
check("on a controller the curve is built with the plan's trust "
"(`solar_trust_pct`)", hi_ and max(v for _h, v in hi_) > hi_[0][1])
# TARIFF-24 (sync 0.19): the tab extends tomorrow's cheap run as control
# re-plans it, so it asks control's gap: the cycle cost with a forecast read,
# not 1.5 x it.
from app.price import required_spread, uncertainty_of # noqa: E402
gaps, _plan_real = [], dayview.priceruns.plan
def _spy(*a, **k):
gaps.append(k.get("required_eur_kwh"))
return _plan_real(*a, **k)
c_g = S.make("dynamic", at(17, 0))
dayview.priceruns.plan = _spy
try:
tab(c_g, at(17, 0))
finally:
dayview.priceruns.plan = _plan_real
_p = c_g.fresh_tariff_plan(at(17, 0))
want = required_spread(arbitrage.threshold_eur_kwh(c_g.o),
uncertainty_of(_p.read.product)
| arbitrage.weather_uncertainty(True, c_g.solar_fc))
print(f" dynamic 17:00: the tab's extension asks {gaps}, control needs {want}")
check("the tab extends tomorrow's cheap run with control's gap (with a "
"forecast: the cycle cost, not 1.5 x it)",
c_g.solar_fc is not None and bool(gaps)
and all(abs(g - want) < 1e-9 for g in gaps)
and abs(want - arbitrage.threshold_eur_kwh(c_g.o)) < 1e-9)
print()
if fails:
print(f"{len(fails)} FAILED")
@@ -247,6 +247,12 @@ def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first",
tomorrow = False
if name == "sunny":
sun_today, sun_tomorrow = 40.0, 40.0
if name not in ("nofc", "off", "stale") and \
opts.get("battery_modes_enabled"):
# TARIFF-24: with a forecast the gap needed is the cycle cost, not
# 1.5 x it. These scenarios were written for the old gate (0.15 at
# the shipped 0.10), so they keep it: the cost is 0.15.
opts["cycle_cost_threshold_eur"] = 0.15
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
c.auto = True
for i, inv in enumerate(c.inverters):