Compare commits

..
17 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
32 changed files with 1794 additions and 967 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`.
+27
View File
@@ -1,5 +1,32 @@
# 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.**
@@ -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.
@@ -1,43 +0,0 @@
# OPT-27 - the night plan keeps the last fresh forecast
**Status: done on `release/1.0`, not released.** Tests: `test_opt27.py`.
**The defect.** A today sensor is stale after 3 h (`FRESH_S`) or when it is from
before local midnight. Forecast.Solar stops updating in the evening, so at night
every Forecast.Solar site had no forecast: a price mode's night cheap run needed
1.5x the cycle cost (TARIFF-24 round 2) and Leftover solar planned nothing.
Separately, one missed poll (`unavailable`) dropped the forecast at once.
**Where: `solarfc`, not the plan.** The last fresh reading of each sensor is
kept in `solarfc.Hold` and `read_sum()` uses it when a sensor has none. A hold
at the plan (`_wx_track`/`weather_held`) can only keep the 1.5x gap off. The
day plan, Leftover solar's levels, Home and the Forecast tab all read
`Controller.solar_fc`, so one kept forecast makes control and every screen agree
(UX-53) with no second rule to keep in step. `weather_held()` is unchanged: it
still keeps a run under way when a read is lost.
**The rule** (`Hold.stand_in()`). A kept reading is for one day. It covers a
sensor that is missing or stale, never one with a bad value or unit, and:
- for up to `FRESH_S` when the sensor is missing: a short outage, any time of day;
- from the end of the bell (20:00 local, `BELL_TO_H`) until local midnight + 3 h
(`KEPT_UNTIL_H`), however old;
- the day it is for: a today figure only on that day, a tomorrow figure as
tomorrow until midnight and, if the today sensors are still stale, as today's
until midnight + 3 h (all sensors of the sum are needed, as for any sum);
- until the next fresh reading, which replaces it at once.
So a figure is never used for another day and never older than one night.
Daytime is as before: a sensor that stopped is stale after 3 h, and no forecast
all day is still no forecast (the night run still needs 1.5x).
**Where it is stored.** The controller's store, key `solar_fc_hold`, so an
evening restart keeps it. It is written when a reading's figure changes or
every 15 min (`SAVE_EVERY_S`), not on every poll. A damaged entry is dropped.
**The screens.** `Forecast.kept` is true when a reading older than `FRESH_S` is
in use; the forecast line then reads "Solar forecast read 17:05, used for
tonight's plan." (`home_words.solar.kept`, EN and NL). The event log says
"read, kept from 17:05".
**Not changed.** Modes off (nothing reads the sensors). A forecast that stays
fresh. The hold does not look at the weather, only at the clock and the day.
@@ -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
@@ -50,14 +50,18 @@ 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 three
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()`) and Home's mirror (`homeview.trade_verdict()`). The forecast
(`_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 charge. The wider gap applies from the next run.
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
+9 -6
View File
@@ -530,8 +530,8 @@ 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.
@@ -548,14 +548,15 @@ With battery modes on, these choices replace the *Charging and selling on price*
- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
- *Leftover solar* then charges from the grid only the shortfall: for each expensive hour, what the house uses less the sun counted for that hour (never below 0), added up over all the expensive hours, early morning and evening. With a day and night tariff that is 07:00 to 22:00. Not up to the top level.
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. In the expensive hours the rest comes from another battery (*Cheap hours only* or *Solar first*) or from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
- A forecast that gives only one figure for the day (Forecast.Solar) is spread over the hours from now, so the hours add up to that figure.
- *Cheap hours only* ignores the forecast and fills to the top level.
- *Solar first* never charges from the grid.
- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan.
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
- **At night.** Some forecasts stop updating in the evening (Forecast.Solar does). The add-on keeps the last reading of each sensor and uses it for that night's plan: from 20:00 until 03:00, or until a newer reading comes. A sensor that is missing for up to 3 hours is covered the same way. A figure is never used for another day, and never older than that night. The Batteries screen says "Solar forecast read 17:05, used for tonight's plan." With no reading all day, the night run still needs the wider gap.
**What always comes first.**
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
@@ -1709,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
@@ -3613,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. |
+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:
+12 -10
View File
@@ -23,7 +23,7 @@ from . import arbitrage, dayplan, homeview as hv, modetrade, priceruns
from . import solarfc
from .daycurve import Batt, curve
from .packmode import SOLAR_FIRST
from .price import Uncertainty, brussels, required_spread, uncertainty_of
from .price import brussels, required_spread, uncertainty_of
BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry
LEVEL_DP = 1 # the tab shows a level to 0.1 %
@@ -42,12 +42,12 @@ def build(c, status: dict, now: datetime, lang: str = "en") -> dict:
def sun_kwh(fc, now, a, b):
"""kWh the forecast gives in `[a, b)`, `a` at or after `now`, or None.
Both ends are measured from `now`: `Forecast.between()` reads its start
as the moment the "left today" figure was taken, so a span that starts
later would be spread over what is left of the bell, not over its own
hours (state-only forecasts, DECISIONS/UX-54-forecast-tab.md)."""
hi, lo = fc.between(now, b), fc.between(now, a)
return None if hi is None or lo is None else max(0.0, hi - lo)
Every span is measured from `now` (`Forecast.between(since=)`, OPT-23),
as `dayplan.plan()` measures them: a state-only "left today" figure is
the sun from the moment it was taken, so a span that starts later must
not be spread over what is left of the bell from its own start
(DECISIONS/UX-54-forecast-tab.md)."""
return fc.between(a, b, since=now)
def _times(run):
@@ -204,9 +204,12 @@ def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow,
end_t = None
if plan_on and cheap is not None and rp.charge_useful() \
and n0 <= cheap.start < n1 and plan is not None:
# The gap control's run plan needs (TARIFF-24): no x1.5 with a
# forecast read.
required = required_spread(
arbitrage.threshold_eur_kwh(c.o),
uncertainty_of(plan.read.product) | {Uncertainty.NO_WEATHER})
uncertainty_of(plan.read.product)
| arbitrage.weather_uncertainty(True, c.solar_fc))
end_t = cheap.end
for _ in range(24):
ext = dataclasses.replace(rp, cheap=priceruns.Run(
@@ -368,8 +371,7 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
if fc is not None:
if k == 0:
line = hv.line_solar(fc.today.kwh, fc.today.kwh * trust,
hv.hhmm(fc.at),
kept=getattr(fc, "kept", False))
hv.hhmm(fc.at))
elif fc.tomorrow is not None:
line = hv.day_note("tomorrow_total",
kwh=hv._kwh(fc.tomorrow.kwh),
+77 -14
View File
@@ -20,6 +20,7 @@ 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
@@ -475,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).
@@ -510,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
@@ -543,8 +568,10 @@ def solar_block(c, now):
out["line"] = _t("solar." + st)
elif fc is not None:
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
out["updated"],
kept=getattr(fc, "kept", False))
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
@@ -868,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
@@ -1034,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
@@ -1165,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.
@@ -1297,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:
@@ -1323,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:
@@ -1396,14 +1446,16 @@ def day_note(key, **values) -> str:
return _t("day." + key, **values)
def line_solar(fc_kwh, count_kwh, at, kept=False) -> str:
"""The forecast line. OPT-27: a reading kept past three hours (the
evening's, used at night) says so instead of "Updated"."""
if kept:
return _t("solar.kept", at=at)
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)
@@ -1457,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
@@ -1525,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)),
@@ -1536,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)),
@@ -1569,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"))]
+34 -28
View File
@@ -208,9 +208,6 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and
# how often the forecast sensors are read (they change hourly at most).
HOUSE_PROFILE_KEY = "house_profile"
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
# evening restart keeps the figure the night plan uses.
FC_HOLD_KEY = "solar_fc_hold"
FORECAST_READ_S = 60.0
@@ -998,8 +995,6 @@ class Controller:
self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None
else solarfc.MISSING)
self._fc_read_mono = None
self.fc_hold = solarfc.Hold(
(getattr(store, "data", None) or {}).get(FC_HOLD_KEY))
self.day_plan = None
self._plan_targets = None
self.house_profile = None
@@ -1227,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()
@@ -3244,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.
@@ -3411,25 +3410,17 @@ class Controller:
now = datetime.now(timezone.utc)
fc, why, gaps = solarfc.read_sum(
list(zip(self.fc_entities, states[:n])),
list(zip(self.fc_tomorrow_entities, states[n:])), now,
hold=self.fc_hold)
if self.fc_hold.changed:
self.fc_hold.changed = False
self.store.set(FC_HOLD_KEY, self.fc_hold.dump())
list(zip(self.fc_tomorrow_entities, states[n:])), now)
# A member is named only in a sum of several; one entity reads as
# before. Logged once per episode: on a change, never every read.
gaps = tuple(g for g in gaps if len(
self.fc_entities if g[0] == "today"
else self.fc_tomorrow_entities) > 1)
was_kept = bool(self.solar_fc is not None and self.solar_fc.kept)
if (fc is None) != (self.solar_fc is None) \
or why != self.solar_fc_why or gaps != self.solar_fc_gaps \
or bool(fc is not None and fc.kept) != was_kept:
or why != self.solar_fc_why or gaps != self.solar_fc_gaps:
self.log_event(
"solar forecast: " + (
"none (" + str(why) + ")" if fc is None else
f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept
else "read")
"solar forecast: " + ("read" if fc is not None else
f"none ({why})")
+ "".join(f", {d} {e} not usable ({w})"
for d, _i, e, w in gaps),
hint=messages.hint(
@@ -4912,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.
@@ -4922,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)
@@ -4977,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
@@ -5196,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())
@@ -7089,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()})
+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?
+28 -181
View File
@@ -28,17 +28,6 @@ A reading older than `FRESH_S`, or from before local midnight, is stale,
and a stale or missing forecast is no forecast: the day plan is off and
every mode acts as in P3 option A.
OPT-27: but the last fresh reading of each sensor is kept (`Hold`), so the
night plan is not made blind. Forecast.Solar's sensors stop changing in the
evening and api.forecast.solar is sometimes out for a poll. A kept reading
stands in for its sensor (a) for up to `FRESH_S` when the sensor is missing
or unavailable, as a sensor that merely stopped updating is already
tolerated, and (b) from the end of the bell (`BELL_TO_H`) until local
midnight + `KEPT_UNTIL_H` however old it is. Never beyond that, and never
for another day: a today figure is only today's, a tomorrow figure is
tomorrow's until midnight and then, until midnight + 3 h, today's.
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
OPT-17: each day may instead name a list (`pv_forecast_entities`,
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
not usable makes that day's sum unusable, never a partial sum.
@@ -61,11 +50,6 @@ FRESH_S = 3 * 3600.0
# The bell a state-only forecast is spread over, in local hours.
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
# OPT-27: a kept reading serves the night plan from the end of the bell until
# this many hours after local midnight (the card: "local midnight + 3 h").
KEPT_UNTIL_H = 3.0
SAVE_EVERY_S = 900.0
# Why there is no forecast (the page words are in ui_text `home_words`).
NOT_SET, MISSING, UNIT, STALE, BAD = (
"not_set", "missing", "unit", "stale", "bad")
@@ -91,17 +75,21 @@ class Forecast:
today: Day
tomorrow: Day | None
at: datetime # when the sensor last reported
# OPT-27: a reading kept past `FRESH_S` is in it (the evening's figure at
# night). The screens say so. Last, so `Forecast(today, tomorrow, at)` is
# as before.
kept: bool = False
def between(self, t0: datetime, t1: datetime) -> float | None:
def between(self, t0: datetime, t1: datetime,
since: datetime | None = None) -> float | None:
"""kWh the sun should give between `t0` (now or later) and `t1`.
None when the span reaches the sunny hours of a day with no
forecast (tomorrow, when its sensor is not set)."""
forecast (tomorrow, when its sensor is not set).
`since` is the moment a state-only "left today" figure counts from
(OPT-23): `t0` itself when not given, so a span that starts later
than now would be spread over what is left of the bell from its own
start. Pass now for every span of a plan, and the spans add up to
the figure. An hourly split ignores it."""
if t1 <= t0:
return 0.0
since = t0 if since is None else since
last = self.tomorrow or self.today
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
return None
@@ -112,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
@@ -144,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
@@ -254,108 +246,6 @@ def _reported(state):
return max(ts) if ts else None
class Hold:
"""OPT-27: the last fresh reading of each forecast sensor, per day it is
for. `read_sum()` remembers every fresh reading here and, when a sensor
has none now, asks it (`stand_in()`).
Keyed `(kind, entity, day0)`: `kind` is "today" (energy still to come on
`day0`) or "tomorrow" (all of `day0`), and `day0` is the day the figure is
FOR. A today figure therefore never answers for another day, and a
tomorrow figure answers for its own day only: tomorrow until midnight,
then today until midnight + `KEPT_UNTIL_H`. Only the newest reading of a
key is kept, so the next fresh reading ends the hold at once.
`dump()` / `load()` are what the controller keeps in its store, so an
evening restart does not lose it (`load()` drops what is bad).
`changed` is set when `dump()` would differ."""
def __init__(self, data=None):
self._r = {}
self._saved = {} # key -> the `at` last flagged for saving
self.changed = False
if isinstance(data, dict):
self.load(data)
def remember(self, kind, entity, day: "Day", at: datetime) -> None:
key = (kind, entity, day.day0)
old = self._r.get(key)
if old is not None and at <= old[1]:
return
self._r[key] = (day, at)
# A sensor that reports every minute with the same figure must not
# rewrite the store every minute: the saved age may lag by
# `SAVE_EVERY_S` (the bounds are hours).
mark = self._saved.get(key)
if old is None or old[0].kwh != day.kwh or mark is None \
or (at - mark).total_seconds() >= SAVE_EVERY_S:
self._saved[key] = at
self.changed = True
def forget_before(self, day0: datetime) -> None:
"""Drop what is for a day before `day0` (the local midnight now)."""
gone = [k for k in self._r if k[2] < day0]
for k in gone:
del self._r[k]
self._saved.pop(k, None)
if gone:
self.changed = True
def stand_in(self, kind, entity, day0, now, *, as_today=False):
"""`(Day, at, kept)` for a sensor that has no fresh reading, or None.
The kept reading must be for `day0`, and either (a) at most `FRESH_S`
old, or (b) used at night: from the end of the bell to local midnight,
or within `KEPT_UNTIL_H` after it. `kept` is true when (b) was needed,
or `as_today` (a tomorrow figure standing in for today, after
midnight)."""
rec = self._r.get((kind, entity, day0))
if rec is None:
return None
day, at = rec
if (now - at).total_seconds() <= FRESH_S and not as_today:
return day, at, False
# Both sides: a tomorrow sensor is asked with day0 = the next
# midnight, which is in the future all day (round 2).
after_midnight = day0 <= now < day0 + timedelta(hours=KEPT_UNTIL_H)
evening = _local_hour(now) >= BELL_TO_H and now < day0 + timedelta(
days=1)
if as_today:
return (day, at, True) if after_midnight else None
return (day, at, True) if (evening or after_midnight) else None
def dump(self) -> dict:
return {"v": 1, "r": [
[kind, ent, d.day0.isoformat(), d.kwh, at.isoformat(),
[[s.isoformat(), e.isoformat(), k] for s, e, k in d.periods]]
for (kind, ent, _d0), (d, at) in sorted(
self._r.items(), key=lambda kv: (kv[0][0], kv[0][1],
kv[0][2]))]}
def load(self, data: dict) -> None:
rows = data.get("r") if isinstance(data, dict) else None
if not isinstance(rows, list):
return
for row in rows:
try:
kind, ent, d0, kwh, at, per = row
day0, at = _parse_time(d0), _parse_time(at)
kwh = float(kwh)
periods = tuple((_parse_time(s), _parse_time(e), float(k))
for s, e, k in per)
ok = (kind in ("today", "tomorrow") and isinstance(ent, str)
and day0 is not None and at is not None
and math.isfinite(kwh) and kwh >= 0
and all(s is not None and e is not None and e > s
and math.isfinite(k) and k >= 0
for s, e, k in periods))
except (TypeError, ValueError):
continue
if ok:
self._r[(kind, ent, day0)] = (Day(day0, kwh, periods), at)
self._saved[(kind, ent, day0)] = at
def entities(opts: dict, *, tomorrow=False) -> tuple:
"""The forecast entities one day names, in configured order: the list
if it has any entry, else the single key, else (). A repeated id is
@@ -392,14 +282,14 @@ def _today(state, day0, now):
def _tomorrow(state, day0, day1, now):
"""`(Day, at, None)` or `(None, None, why)` for one tomorrow sensor."""
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
t, why = _day(state, day1, now, today=False)
if t is None:
return None, None, why
return None, why
t_at = _reported(state)
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
return None, None, STALE
return t, t_at, None
return None, STALE
return t, None
def read(today_state, tomorrow_state, now: datetime, *,
@@ -415,7 +305,7 @@ def read(today_state, tomorrow_state, now: datetime, *,
return None, why
tomorrow = None
if tomorrow_state is not None:
tomorrow, _at, _w = _tomorrow(tomorrow_state, day0, day1, now)
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
return Forecast(today, tomorrow, at), None
@@ -431,12 +321,7 @@ def _sum(days):
return Day(days[0].day0, sum(d.kwh for d in days), periods)
# Why a sensor may be covered by a kept reading: it has none now, or it is
# too old. A bad value or a wrong unit is a set-up fault and is never covered.
_COVERABLE = (MISSING, STALE)
def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
def read_sum(today, tomorrow, now: datetime):
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
`(entity, state)`, one per configured member, in configured order.
@@ -449,67 +334,29 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
`gaps` is `((day, position, entity, why), ...)` for every unusable
member (`day` "today" or "tomorrow", `position` 1-based), so the page
and the log can name it. With one member per day the forecast is what
`read()` gives (test_opt17.py proves it).
OPT-27: with a `hold`, every fresh reading is remembered in it, and a
member that is missing or stale is covered by its last fresh reading
where `Hold.stand_in()` allows it (the forecast then has `kept` set when
that took a reading older than `FRESH_S`). After midnight, if the today
side is still short, the tomorrow figures read before midnight are
today's, until midnight + `KEPT_UNTIL_H`. Without a `hold` this is
exactly as before."""
`read()` gives (test_opt17.py proves it)."""
if not today:
return None, NOT_SET, ()
day0, day1 = _bounds(now)
if hold is not None:
hold.forget_before(day0)
gaps, days, ats, why0, kept = [], [], [], None, False
gaps, days, ats, why0 = [], [], [], None
for i, (ent, st) in enumerate(today, 1):
d, at, why = _today(st, day0, now)
if d is not None and hold is not None:
hold.remember("today", ent, d, at)
elif hold is not None and why in _COVERABLE:
r = hold.stand_in("today", ent, day0, now)
if r is not None:
d, at, k = r
kept = kept or k
if d is None:
gaps.append(("today", i, ent, why))
why0 = why0 or why
else:
days.append(d)
ats.append(at)
tdays, tats, tkept = [], [], False
tdays = []
for i, (ent, st) in enumerate(tomorrow, 1):
t, t_at, why = _tomorrow(st, day0, day1, now)
if t is not None and hold is not None:
hold.remember("tomorrow", ent, t, t_at)
elif hold is not None and why in _COVERABLE:
r = hold.stand_in("tomorrow", ent, day1, now)
if r is not None:
t, t_at, k = r
tkept = tkept or k
t, why = _tomorrow(st, day0, day1, now)
if t is None:
gaps.append(("tomorrow", i, ent, why))
else:
tdays.append(t)
tats.append(t_at)
if why0 is not None and hold is not None and tomorrow and all(
g[3] in _COVERABLE for g in gaps if g[0] == "today"):
# Past midnight the sensors may still show yesterday's "left today".
# The figure each tomorrow sensor gave before midnight is for this
# day; all of them are needed, as for any sum.
subs = [hold.stand_in("tomorrow", ent, day0, now, as_today=True)
for ent, _st in tomorrow]
if all(r is not None for r in subs):
days = [r[0] for r in subs]
ats = [r[1] for r in subs]
gaps = [g for g in gaps if g[0] != "today"]
why0, kept = None, True
gaps = tuple(gaps)
if why0 is not None:
return None, why0, gaps
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
kept = kept or (tday is not None and tkept)
# The oldest member's report is when the sum was last fully known.
return Forecast(_sum(days), tday, min(ats), kept), None, gaps
return Forecast(_sum(days), tday, min(ats)), None, gaps
+14 -8
View File
@@ -103,18 +103,18 @@
"batteries": {"name": "Batteries", "description": "To choose a mode for each battery, turn on battery modes in the settings.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings",
"on": "On", "off": "Off",
"choice": {
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."},
"leftover_solar": {"name": "Leftover solar", "description": "Gets solar the others cannot take, and gives power in expensive hours."},
"cheap_hours": {"name": "Cheap hours only", "description": "Charges in the cheapest hours and gives power in the expensive ones."},
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries, covers the house and never charges from the grid."},
"leftover_solar": {"name": "Leftover solar", "description": "Gets the solar the others cannot take, buys from the grid only what the sun will not give, and gives power in expensive hours."},
"cheap_hours": {"name": "Cheap hours only", "description": "Fills up from the grid in the cheapest hours and gives power in the expensive ones."},
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
},
"today": "Today", "why": "Why",
"cycle": {"start": "Start cycle", "abort": "Stop cycle", "start_dialog": {"name": "Start a cycle on {name}?", "description": "{name} empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"}, "abort_dialog": {"name": "Stop the cycle on {name}?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}, "toast": {"started": "Cycle started on {name}.", "stopped": "Cycle stopped on {name}."}, "unit": {"description": "There is no battery with that name."}, "busy": {"description": "Only one battery can cycle at a time, and {name} is cycling now."}, "off": {"description": "{name} is switched off, so it cannot start a cycle."}, "idle": {"description": "{name} is not running a cycle."}},
"modes_off": {"name": "Battery modes are off"},
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
"leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, and saves its power for expensive hours."},
"cheap_hours": {"description": "Cheap hours only charges in the cheapest hours and gives power in the expensive ones."},
"solar_first": {"description": "Solar first gets spare solar before the other batteries, covers the house at any hour and never charges from the grid."},
"leftover_solar": {"description": "Leftover solar gets the solar Solar first cannot take, buys only what the sun will not give, and saves its power for expensive hours."},
"cheap_hours": {"description": "Cheap hours only fills up from the grid in the cheapest hours and gives power in the expensive ones."},
"off": {"description": "Off rests the battery and starts no monthly cycle, but the peak limit may still use it."},
"rules": {"description": "The peak limit and the safety stops come first, and while one battery charges from the grid the others rest."}},
"toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}."}},
@@ -357,10 +357,10 @@
"no_prices": "No prices, so it follows the house.",
"no_spread": "Prices differ too little, so it follows the house.",
"sun_fills": "Enough sun is forecast, so it does not charge from the grid.",
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
"no_forecast": "No solar forecast, so it charges from solar only."
},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "kept": "Solar forecast read {at}, used for tonight's plan.", "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}.", "kept": "Zonneprognose van {at}, gebruikt voor het plan van vanavond.", "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.1"
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)))
+7 -29
View File
@@ -672,24 +672,9 @@ check("Cheap hours with a sun forecast charges as with none (P3)",
and top > 80.0)
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
# OPT-27: a sensor that is unavailable is covered by its last fresh reading
# for up to 3 h (`solarfc.Hold`), so "lost" here is a hold that never covers;
# the next scenario is the same loss with the hold (test_opt27.py).
DROP = UTC0 + timedelta(hours=1, minutes=30)
class _NoHold(solarfc.Hold):
def stand_in(self, *a, **k):
return None
def seed_profile_no_hold(c):
seed_profile(c)
c.fc_hold = _NoHold()
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
soc0={"a": 60.0, "b": 10.0}, patch=seed_profile_no_hold)
soc0={"a": 60.0, "b": 10.0})
invariants("forecast lost mid-run", rows, c)
before = [r for r in rows if r["now"] < DROP and r["action"]
== arbitrage.CHARGE]
@@ -697,15 +682,6 @@ late = [r for r in rows if r["now"] > DROP + timedelta(
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
check("forecast lost mid-run: Leftover solar was charging, and stops within "
"a forecast read (60 s) and a cycle", before and not late)
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
soc0={"a": 60.0, "b": 10.0})
invariants("forecast unavailable mid-run, covered", rows, c)
check("forecast unavailable mid-run (OPT-27): the last reading covers it, so "
"Leftover solar goes on charging past the point where it was lost",
[r for r in rows if r["now"] < DROP and r["action"]
== arbitrage.CHARGE]
and [r for r in rows if r["now"] > DROP + timedelta(
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE])
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
# charge, so B covers less of the evening - a money risk, never a safety one.
@@ -781,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]
@@ -816,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")
-548
View File
@@ -1,548 +0,0 @@
"""Runnable check for OPT-27. `python3 test_opt27.py`
Forecast.Solar's sensors stop changing in the evening, so at night every
Forecast.Solar site was "no forecast": a price mode's night cheap run needed
1.5x the cycle cost and Leftover solar bought nothing. And one missed poll
(`unavailable`) dropped the forecast at once. `solarfc.Hold` now keeps the last
fresh reading of each sensor, for the day it is FOR:
* up to `FRESH_S` when the sensor is missing or unavailable (a short outage);
* from the end of the bell (20:00) until local midnight + 3 h however old it
is (the night plan); never beyond, never for another day.
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
1. `solarfc` alone: the evening, the outage, the bounds, the day a figure is
for, restart (dump/load), and no hold = as before.
2. The real Controller on a day/night tariff (0.34/0.26, gap 0.08, cycle cost
0.07), forecast sensors that stop updating at 17:05 local:
a. the 22:00 night run is planned with the cycle-cost gap and charges;
b. no forecast all day: the night run still needs 1.5x and does not charge;
c. an evening restart keeps it (a new Controller on the saved store);
d. a one-hour outage at 18:47 does not drop it;
e. past midnight + 3 h the figure is not used, before it it is.
3. The screens: the forecast line says when it was read and that it is used
for tonight's plan (EN and NL), and Home agrees with control.
Mutations (MUTATION.md, purged copies, graded over this file): see the
DECISIONS note and the dev report.
"""
import asyncio
import json
import logging
import os
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import app.main as m # noqa: E402
from app import homeview, modetrade, solarfc # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
def at(day, hh, mm=0):
"""Local time on 2026-11-`day` (CET) as UTC."""
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
def st(kwh, when, *, unit="kWh"):
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
"last_updated": when.isoformat(),
"last_reported": when.isoformat()}
GONE = {"state": "unavailable", "attributes": {}}
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
# --------------------------------------------------------------------------- #
print("1. solarfc: the hold")
D1_READ = at(3, 17, 5) # the last fresh reading, 17:05
EVE = at(3, 22, 0)
def rs(today, tomorrow, now, hold):
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
h = solarfc.Hold()
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 17, 10), h)
check("a fresh reading is a forecast, not kept", fc is not None
and not fc.kept and fc.tomorrow.kwh == 12.0 and fc.today.kwh == 0.4)
check("it is remembered (the hold is flagged for saving)", h.changed)
fc_no, why_no, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, None)
check("with no hold, 22:00 is no forecast (as before)",
fc_no is None and why_no == solarfc.STALE)
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, h)
print(f" 22:00, sensors still show 17:05: {fc and fc.kept=}, {gaps=}")
check("22:00 with the sensors stale since 17:05: the 17:05 figures are used",
fc is not None and fc.kept and fc.tomorrow.kwh == 12.0
and fc.at == D1_READ and gaps == ())
check("... today has no sun left in it, tomorrow's is counted from 06:00",
fc is not None and fc.between(EVE, at(4, 7, 0)) is not None
and 0.0 < fc.between(EVE, at(4, 7, 0)) < 0.5
and fc.today.kwh == 0.4)
fc2, _w, _g = rs(GONE, GONE, EVE, h)
check("the same when the sensors are unavailable at 22:00",
fc2 is not None and fc2.kept and fc2.tomorrow.kwh == 12.0)
# A new fresh reading replaces the old one at once.
fc3, _w, _g = rs(st(0.0, at(3, 22, 20)), st(14.5, at(3, 22, 20)),
at(3, 22, 30), h)
check("a newer fresh reading is used and is not 'kept'",
fc3 is not None and not fc3.kept and fc3.tomorrow.kwh == 14.5)
fc3b, _w, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 23, 0), h)
check("... and later the newest reading is the one kept, not the old one",
fc3b is not None and fc3b.tomorrow.kwh == 14.5)
# A short outage: unavailable for one or two polls, any time of day.
h2 = solarfc.Hold()
rs(st(1.1, at(3, 18, 5)), st(12.0, at(3, 18, 5)), at(3, 18, 6), h2)
for label, now in (("18:47", at(3, 18, 47)), ("19:20", at(3, 19, 20)),
("19:47", at(3, 19, 47)), ("20:55", at(3, 20, 55))):
fo, _w, _g = rs(GONE, GONE, now, h2)
check(f"unavailable at {label}: the 18:05 forecast is still used",
fo is not None and fo.tomorrow.kwh == 12.0)
fo, _w, _g = rs(GONE, GONE, at(3, 20, 55), solarfc.Hold())
check("(nothing was ever read: still no forecast)", fo is None)
# Daytime is as before: an outage longer than FRESH_S drops it.
h3 = solarfc.Hold()
rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h3)
d_in = rs(GONE, GONE, at(4, 11, 50), h3)[0]
d_out = rs(GONE, GONE, at(4, 12, 10), h3)[0]
d_out2 = rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 14, 0), h3)[0]
check("daytime: an outage under 3 h is covered, one over 3 h is not",
d_in is not None and not d_in.kept and d_out is None)
check("daytime: a sensor that stopped is stale after 3 h, as before "
"(the hold does not start before the bell ends)", d_out2 is None)
# Round 2: today fresh, tomorrow's sensor stopped or down in the daytime.
h9 = solarfc.Hold()
rs(st(5.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h9)
for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)):
fd = rs(st(3.0, at(4, 13, 55)), tm, at(4, 14, 0), h9)[0]
check(f"14:00, today fresh, tomorrow {label} since 09:05 (over 3 h): "
"no tomorrow, nothing kept",
fd is not None and fd.tomorrow is None and not fd.kept)
fn = rs(st(0.1, at(4, 20, 55)), tm, at(4, 21, 0), h9)[0]
check(f"21:00, tomorrow {label}: the 09:05 figure covers it, kept",
fn is not None and fn.tomorrow is not None
and fn.tomorrow.kwh == 12.0 and fn.kept)
fs = rs(st(3.0, at(4, 10, 55)), tm, at(4, 11, 0), h9)[0]
check(f"11:00, tomorrow {label} for under 3 h: still covered, not kept",
fs is not None and fs.tomorrow is not None and not fs.kept)
# The bound: midnight + 3 h, and the day a figure is for.
fm = rs(st(0.0, at(3, 23, 0)), st(0.0, at(3, 23, 0)), at(4, 1, 0), h2)[0]
check("00:30 is covered by the 18:05 tomorrow figure, as TODAY's",
rs(GONE, GONE, at(4, 0, 30), h2)[0] is not None)
fmid = rs(GONE, GONE, at(4, 2, 59), h2)[0]
print(f" 02:59: {fmid and (fmid.today.kwh, fmid.tomorrow, fmid.kept)}")
check("02:59: tomorrow's 12.0 kWh is now today's, there is no tomorrow",
fmid is not None and fmid.today.kwh == 12.0 and fmid.tomorrow is None
and fmid.kept and fmid.today.day0 == at(4, 0, 0))
check("... its sun is counted on the day it is for (06:00-20:00 on the 4th)",
fmid is not None and fmid.between(at(4, 3, 0), at(4, 7, 0)) is not None
and fmid.between(at(4, 3, 0), at(4, 7, 0)) > 0.0)
check("03:01 (midnight + 3 h): not used any more",
rs(GONE, GONE, at(4, 3, 1), h2)[0] is None)
check("... and not the next evening either (older than one night)",
rs(GONE, GONE, at(4, 22, 0), h2)[0] is None)
fe = rs(st(0.1, at(4, 21, 50)), GONE, at(4, 22, 0), h2)[0]
check("... the next evening, with today fresh and tomorrow's sensor down, "
"there is no tomorrow: the 4th's figure is not the 5th's",
fe is not None and fe.today.kwh == 0.1 and fe.tomorrow is None)
# Never today's figure for tomorrow.
h4 = solarfc.Hold()
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h4)
# Next day 00:30: today's sensor is fresh again (4.0), tomorrow's is gone.
fx = rs(st(4.0, at(4, 0, 20)), GONE, at(4, 0, 30), h4)[0]
check("00:30: today is the fresh 4.0, and there is NO tomorrow "
"(the 3rd's 'today' 3.0 and 'tomorrow' 12.0 are not the 5th's)",
fx is not None and fx.today.kwh == 4.0 and fx.tomorrow is None
and not fx.kept)
fy = rs(st(4.0, at(4, 0, 20)), st(9.0, at(4, 0, 20)), at(4, 0, 30), h4)[0]
check("00:30 with both fresh: tomorrow is the fresh 9.0, not the old 12.0",
fy is not None and fy.tomorrow.kwh == 9.0 and not fy.kept)
# A tomorrow sensor that is down while today's is fresh, in the evening: the
# hold gives the tomorrow figure for tomorrow, never today's figure.
h5 = solarfc.Hold()
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h5)
fz = rs(st(0.2, at(3, 21, 30)), GONE, at(3, 21, 40), h5)[0]
check("21:40: today fresh, tomorrow down: tomorrow is the kept 12.0 (not "
"today's 0.2 or 3.0)", fz is not None and fz.tomorrow.kwh == 12.0
and fz.today.kwh == 0.2 and fz.kept)
# A set-up fault is never covered.
h6 = solarfc.Hold()
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h6)
fb = rs(st("lots", at(3, 21, 30)), st(12.0, at(3, 21, 30)),
at(3, 21, 40), h6)
fu = rs(st(0.2, at(3, 21, 30), unit="W"), st(12.0, at(3, 21, 30)),
at(3, 21, 40), h6)
check("a bad value or a wrong unit is not covered by the hold",
fb[0] is None and fb[1] == solarfc.BAD and fu[0] is None
and fu[1] == solarfc.UNIT)
# Several sensors: all or nothing.
h7 = solarfc.Hold()
two_t = [("sensor.t1", st(1.0, at(3, 17, 0))),
("sensor.t2", st(2.0, at(3, 17, 0)))]
two_m = [("sensor.m1", st(5.0, at(3, 17, 0))),
("sensor.m2", st(6.0, at(3, 17, 0)))]
solarfc.read_sum(two_t, two_m, at(3, 17, 5), h7)
gone2 = [("sensor.t1", GONE), ("sensor.t2", GONE)]
f2 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
at(3, 22, 0), h7)
check("three or two sensors: the kept figures are summed",
f2[0] is not None and f2[0].tomorrow.kwh == 11.0
and f2[0].today.kwh == 3.0 and f2[2] == ())
h8 = solarfc.Hold()
solarfc.read_sum(two_t[:1] + [("sensor.t2", GONE)], two_m, at(3, 17, 5), h8)
f3 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
at(3, 22, 0), h8)
check("one sensor never read fresh: no partial sum, the gap is named",
f3[0] is None and any(g[:3] == ("today", 2, "sensor.t2") for g in f3[2]))
# Restart: what the store holds comes back.
saved = json.loads(json.dumps(h.dump()))
hr = solarfc.Hold(saved)
fr = rs(GONE, GONE, at(3, 23, 0), hr)[0]
check("dump/load through JSON: the kept figures come back (40 min old: "
"inside FRESH_S, so not 'kept' in the screens' sense)",
fr is not None and not fr.kept and fr.tomorrow.kwh == 14.5
and fr.at == at(3, 22, 20))
junk = solarfc.Hold({"r": [["today", 7, "x", "y", "z", []],
["tomorrow", T_ENT, "2026-11-04T00:00:00+00:00",
-1, "2026-11-03T16:00:00+00:00", []],
"bad", None]})
check("a damaged store entry is dropped, not raised",
rs(GONE, GONE, at(3, 22, 0), junk)[0] is None
and solarfc.Hold(None).dump()["r"] == [] and solarfc.Hold(5).changed
is False)
hp = solarfc.Hold()
rs(st(8.0, at(3, 9, 0), ), st(12.0, at(3, 9, 0)), at(3, 9, 5), hp)
periods = [{"period_start": f"2026-11-04T{hh:02d}:00:00+01:00",
"pv_estimate": 1.0} for hh in range(8, 16)]
s_per = st(10.0, at(3, 17, 0))
s_per["attributes"]["detailedForecast"] = periods
rs(st(0.0, at(3, 17, 0)), s_per, at(3, 17, 5), hp)
hq = solarfc.Hold(json.loads(json.dumps(hp.dump())))
fq = rs(GONE, GONE, at(3, 22, 0), hq)[0]
check("an hourly split (Solcast shape) survives the store",
fq is not None and fq.tomorrow.periods
and abs(fq.between(at(4, 8, 0), at(4, 16, 0)) - 7.5) < 1e-6)
# --------------------------------------------------------------------------- #
print("\n2. The real Controller: day/night 0.34 / 0.26, cycle cost 0.07")
A, B = "inverter-a", "inverter-b"
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.26},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
DAY3 = 3
def opts(cost=0.07):
o = dict(rp.options(True, False, 2))
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE,
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT)
return o
def stops_at(last, *, down=()):
"""Sensors that update until `last` and then show that reading for
good. `down`: (from, to) windows in which they are unavailable."""
def f(now, ent):
for a, z in down:
if a <= now < z:
return GONE
when = min(now, last)
return st(0.4 if ent == T_ENT else 12.0, when)
return f
def never(now, ent):
return st(0.0 if ent == T_ENT else 12.0, now - timedelta(hours=9))
def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
keep_store=True, snap=False):
"""-> (controller, rows, pre_rows). `pre`: the times the forecast is read
before the first cycle (the evening). `restart`: a time in `pre` at which
the add-on restarts: a new Controller on the saved store."""
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
clock = [pre[0] if pre else start]
rows, pre_rows = [], []
cur = [store]
class H(rp.Hass):
async def state(self, e):
return sensors(clock[0], e)
def build(store):
hass = H(("a", "b"), lambda t: 300.0, {"a": 40.0, "b": 40.0})
tmp = tempfile.mkdtemp(prefix="o27-")
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
# A house profile a few days old (300 W round the clock), so the day
# plan is made as it is on a site that has run for a week.
c.house_profile.load({d: {str(hh): 300.0 for hh in range(24)}
for d in ("2026-10-31", "2026-11-01",
"2026-11-02")})
return c, hass
async def go():
c, hass = build(store)
real_dt, real_hv = m.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
task = None
try:
for t_pre in pre:
clock[0] = t_pre
if restart is not None and t_pre == restart:
# The saved file, through JSON; `keep_store=False` loses
# the hold only (the control for the test).
data = json.loads(json.dumps(cur[0].data))
if not keep_store:
data.pop(m.FC_HOLD_KEY, None)
new = rp._Store()
new.data.update(data)
cur[0] = new
c, hass = build(new)
await c._read_forecast()
pre_rows.append((t_pre, c.solar_fc is not None,
bool(c.solar_fc and c.solar_fc.kept)))
clock[0] = start
task = asyncio.create_task(c.run_tariff_refresh())
t = 0.0
while t < minutes * 60:
now = start + timedelta(seconds=t)
hass.t = t
clock[0] = now
if t == 0.0:
for _ in range(500):
if c.tariff_plan is not None and (
c.tariff_at is not None and c.tariff_at >= now):
break
await asyncio.sleep(0)
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(5.0, 10.0)
rp_ = c.mode_runs
rows.append(dict(
t=t, fc_none=c.solar_fc is None,
kept=bool(c.solar_fc and c.solar_fc.kept),
runs_on=None if rp_ is None else rp_.on,
reason=None if rp_ is None else rp_.reason,
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
charging=hass.plants["b"].p < -200.0,
soc_b=hass.plants["b"].soc))
t += 5.0
keep = c.o
c.o = dict(keep, arbitrage_enabled=True)
try:
c.mirror, c.mirror_why = homeview.trade_verdict(
c, c.fresh_tariff_plan(clock[0]), clock[0])
finally:
c.o = keep
c.cards = homeview.battery_rows(c, False, True)
c.block = homeview.solar_block(c, clock[0])
c.hold_saved = json.loads(json.dumps(cur[0].data.get(
m.FC_HOLD_KEY)))
finally:
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
return c
return asyncio.run(go()), rows, pre_rows
def why_of(c, name):
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
return card["why_key"]
EVENING = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
LAST = at(DAY3, 17, 5)
NIGHT_START = at(DAY3, 22, 0)
# a. a forecast that stops updating at sunset
ca, ra, pa = drive27(opts(), stops_at(LAST), pre=EVENING + [at(DAY3, 21, 55)],
start=NIGHT_START)
la = nth(ra, -1, "last cycle")
print(f" a. 22:00 with the 17:05 forecast: kept={la['kept']} on="
f"{la['runs_on']} reason={la['reason']}; B charging "
f"{sum(r['charging'] for r in ra)} of {len(ra)}; Why A {why_of(ca, A)} "
f"B {why_of(ca, B)}; Home mirror {ca.mirror}")
check("the forecast was read fresh before 17:05 and kept after it",
any(not k for _t, ok, k in pa if _t <= LAST + timedelta(minutes=30))
and all(ok for _t, ok, _k in pa)
and all(k for _t, _ok, k in pa if _t >= at(DAY3, 20, 15)))
check("22:00 night run: planned with the cycle-cost gap (needs 0.07 < 0.08)",
present(la) and not la["fc_none"] and la["kept"]
and la["runs_on"] is True and la["reason"] != "not_worth")
check("... and B (Cheap hours) charges from the grid in the night period",
sum(r["charging"] for r in ra) > len(ra) // 2
and la["roles"].get(B) == modetrade.CHARGE
and why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread")
check("the day plan is made from the kept forecast (levels for Leftover "
"solar), as it is from a fresh one",
ca.day_plan is not None and ca.plan_targets() is not None
and ca.block["state"] == "plan")
check("Home's mirror of the gate allows it, as control does",
ca.mirror is not None and ca.mirror_why == "allowed")
# b. no forecast all day
cb, rb, pb = drive27(opts(), never, pre=EVENING + [at(DAY3, 21, 55)],
start=NIGHT_START)
lb = nth(rb, -1, "last cycle")
print(f" b. no forecast all day: on={lb['runs_on']} reason={lb['reason']}; "
f"B charging {sum(r['charging'] for r in rb)}; Why A {why_of(cb, A)} "
f"B {why_of(cb, B)}")
check("with no forecast all day the night run still needs 1.5x (0.105 > 0.08)"
": not worth, no charge, 'Prices differ too little'",
present(lb) and lb["fc_none"] and lb["runs_on"] is False
and lb["reason"] == "not_worth"
and not any(r["charging"] for r in rb)
and why_of(cb, A) == "no_spread" and why_of(cb, B) == "no_spread"
and not any(ok for _t, ok, _k in pb))
check("... and there is no day plan (Leftover solar takes solar only)",
cb.day_plan is None and cb.plan_targets() is None
and cb.block["state"] == "no_forecast")
check("... Home's mirror refuses it too, for the gap ('not_worth')",
cb.mirror is None and cb.mirror_why == "not_worth")
check("... and nothing is saved for the night (no fresh reading was made)",
cb.hold_saved is None or cb.hold_saved.get("r") == [])
# c. an evening restart
cc, rc, pc = drive27(opts(), stops_at(LAST),
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
restart=at(DAY3, 21, 55))
lc = nth(rc, -1, "last cycle")
check("an evening restart (new Controller on the saved store) keeps the "
"forecast: the night run is planned and charges",
present(lc) and lc["kept"] and lc["runs_on"] is True
and sum(r["charging"] for r in rc) > len(rc) // 2
and present(nth(pc, -1, "last read")) and pc[-1][1] and pc[-1][2])
cc0, rc0, pc0 = drive27(opts(), stops_at(LAST),
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
restart=at(DAY3, 21, 55), keep_store=False)
lc0 = nth(rc0, -1, "last cycle")
check("(control: the same restart with the store lost is blind, 1.5x)",
present(lc0) and lc0["fc_none"] and lc0["runs_on"] is False
and present(nth(pc0, -1, "last read")) and not pc0[-1][1])
# d. a one-hour outage at 18:47
OUT = (at(DAY3, 18, 47), at(DAY3, 19, 47))
quarter = [at(DAY3, 16, 0) + timedelta(minutes=15 * i) for i in range(24)]
cd, rd, pd = drive27(opts(), stops_at(at(DAY3, 18, 5), down=[OUT]),
pre=quarter + [at(DAY3, 21, 55)], start=NIGHT_START)
ld = nth(rd, -1, "last cycle")
inside = [(t_, ok) for t_, ok, _k in pd if OUT[0] <= t_ < OUT[1]]
print(f" d. reads inside the outage: {len(inside)}, with a forecast: "
f"{sum(ok for _t, ok in inside)}")
check("a one-hour outage at 18:47 does not drop the forecast "
"(every read from 16:00 to 22:00 has one)",
present(pd) and len(inside) >= 4 and all(ok for _t, ok, _k in pd))
check("... and the 22:00 night run is planned and charges",
present(ld) and ld["runs_on"] is True
and sum(r["charging"] for r in rd) > len(rd) // 2)
# e. the bound: midnight + 3 h
mid = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
ce, re_, pe = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 2, 30)],
start=at(4, 2, 30))
le = nth(re_, -1, "last cycle")
check("02:30 (before midnight + 3 h): the figure from 17:05 is still used, "
"as today's; the night run is planned",
present(le) and le["kept"] and le["runs_on"] is True)
cf, rf, pf = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 3, 30)],
start=at(4, 3, 30))
lf = nth(rf, -1, "last cycle")
check("03:30 (after midnight + 3 h): a figure from before the night is not "
"used: no forecast, 1.5x, not worth, no charge",
present(lf) and lf["fc_none"] and lf["runs_on"] is False
and lf["reason"] == "not_worth" and not any(r["charging"] for r in rf))
# --------------------------------------------------------------------------- #
print("\n3. The screens")
blk = ca.block
print(f" forecast line: {blk['line']!r} (state {blk['state']})")
check("the forecast line says when it was read and that it is used for "
"tonight's plan", blk["line"] == "Solar forecast read 17:05, used for "
"tonight's plan.")
check("the same line comes from the Forecast tab (dayview) on today",
homeview.line_solar(0.4, 0.3, "17:05", kept=True) == blk["line"]
and homeview.line_solar(0.4, 0.3, "17:05").startswith(
"Solar still to come today"))
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
encoding="utf-8"))
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
encoding="utf-8"))
k_en = en["home_words"]["solar"]["kept"]
k_nl = nl["home_words"]["solar"]["kept"]
print(f" EN {k_en!r}\n NL {k_nl!r}")
check("EN and NL both say it, with the time, and the NL is not the English",
"{at}" in k_en and "{at}" in k_nl and k_en != k_nl
and "plan" in k_en and "vanavond" in k_nl)
check("a daytime reading keeps the old line (daytime unchanged)",
"Updated 09:05" in homeview.line_solar(1.0, 0.7, "09:05")
and "Bijgewerkt" in json.dumps(nl["home_words"]["solar"]["line"]))
check("no forecast: the old 'No solar forecast now' line, no 'kept'",
cb.block["state"] == "no_forecast"
and "tonight" not in cb.block["line"])
check("control and Batteries screen agree on the 22:00 run: not 'no_spread'",
why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread"
and la["runs_on"] is True)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")
+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)
# ============================================================================ #
+74 -19
View File
@@ -20,10 +20,15 @@ DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
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.
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
@@ -43,7 +48,7 @@ logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import app.main as m # noqa: E402
from app import arbitrage, homeview, modetrade, solarfc # noqa: E402
from app 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
@@ -54,13 +59,6 @@ from app.store import unit_key # noqa: E402
fails = []
class _NoHold(solarfc.Hold):
"""OPT-27: a hold that never stands in for a lost sensor."""
def stand_in(self, *a, **k):
return None
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
@@ -134,9 +132,10 @@ def take(c, now):
def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
house_w=300.0, soc0=None, snap_at=()):
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."""
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),
@@ -147,7 +146,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
class H(rp.Hass):
async def state(self, e):
now = NIGHT + timedelta(seconds=self.t)
now = t0 + timedelta(seconds=self.t)
if fc is None:
return None
at = forecast_mode(fc, self.t, now)
@@ -164,13 +163,8 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
# OPT-27: a sensor that is missing is covered for up to 3 h by its last
# fresh reading. This file is about what the plan does when the
# forecast IS lost, so it runs with a hold that never covers
# (test_opt27.py tests the cover).
c.fc_hold = _NoHold()
real_dt, real_hv = m.datetime, homeview.datetime
clock = [NIGHT]
clock = [t0]
class _DT(real_dt):
@classmethod
@@ -185,7 +179,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
try:
t = 0.0
while t < minutes * 60:
now = NIGHT + timedelta(seconds=t)
now = t0 + timedelta(seconds=t)
hass.t = t
clock[0] = now
if pts is not None:
@@ -222,6 +216,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
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,
@@ -442,6 +437,66 @@ for label, cx, rx, on in CASES:
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:")
+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")