Compare commits

...
37 Commits
Author SHA1 Message Date
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
33 changed files with 2788 additions and 192 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, 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_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`.
+55
View File
@@ -1,5 +1,60 @@
# Changelog
## 0.19.2
**A patch bump: one fix for charging from the grid under the peak limit, 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,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.
@@ -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.
+10 -6
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,7 +548,9 @@ 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.
@@ -1708,7 +1710,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
@@ -3612,7 +3616,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:
+11 -8
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(
+81 -9
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,6 +517,24 @@ 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 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
@@ -540,6 +569,9 @@ def solar_block(c, now):
elif fc is not None:
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
out["updated"])
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
@@ -863,7 +895,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 +1062,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 +1194,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 +1330,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 +1363,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:
@@ -1395,6 +1450,12 @@ def line_solar(fc_kwh, count_kwh, at) -> str:
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 +1509,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 +1583,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 +1595,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,7 +1631,8 @@ 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"))]
+75 -69
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:
@@ -1001,6 +961,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
@@ -1259,6 +1222,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 +2239,28 @@ 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. 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)
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 +2274,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 +2287,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
@@ -2367,6 +2356,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 +2367,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:
@@ -3250,17 +3241,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.
@@ -4910,6 +4903,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 +4914,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 +4971,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 +5191,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())
@@ -7087,6 +7086,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 +8088,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))
+15
View File
@@ -1136,6 +1136,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?
+17 -5
View File
@@ -76,12 +76,20 @@ class Forecast:
tomorrow: Day | None
at: datetime # when the sensor last reported
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 +100,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 +132,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
+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.", "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."},
"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.", "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."},
"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)))
+48 -8
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} %")
@@ -648,7 +685,8 @@ check("forecast lost mid-run: Leftover solar was charging, and stops within "
# 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 +696,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 +757,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]
@@ -754,8 +794,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")
+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")
+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")
+506
View File
@@ -0,0 +1,506 @@
"""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 # 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 = []
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
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):