Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a3fa615e | ||
|
|
1c9fefd9dd | ||
|
|
8cca671a05 | ||
|
|
9dc22fb987 | ||
|
|
5ac1017eed | ||
|
|
8bf54ece3d | ||
|
|
f1c9733c32 | ||
|
|
b6be31286a | ||
|
|
6be7e41de1 | ||
|
|
ae0cd542c0 | ||
|
|
a645d899f4 | ||
|
|
995028bc5d | ||
|
|
ffb0d61d5b | ||
|
|
8966867968 | ||
|
|
2929ece7da | ||
|
|
6eebd4882b | ||
|
|
88ab8a50e3 | ||
|
|
2932f3af39 | ||
|
|
7e77c723b9 | ||
|
|
97686319f3 | ||
|
|
d424b862d8 | ||
|
|
382b0ea411 | ||
|
|
230ba0b57a | ||
|
|
d667f3f3a8 | ||
|
|
4a1f6cc2d9 | ||
|
|
a0ace389a4 | ||
|
|
a4b769f257 | ||
|
|
8dbf831854 | ||
|
|
00c8ae354c | ||
|
|
f5afb420cf | ||
|
|
d2820e8b9b | ||
|
|
4256bd3eda | ||
|
|
5ab8622265 | ||
|
|
75195f7073 | ||
|
|
b6b2d1b184 | ||
|
|
a1d07030f8 | ||
|
|
5dfe9720c5 | ||
|
|
242cf434f4 | ||
|
|
793a67623c | ||
|
|
514256923b | ||
|
|
b7108e552d | ||
|
|
31d09ebae2 | ||
|
|
ae9268835f | ||
|
|
f8b3e23a46 | ||
|
|
aa228062c5 | ||
|
|
6b2d0091d6 | ||
|
|
3dd4dd89e0 | ||
|
|
891ed7bdd7 | ||
|
|
2ff9be710c | ||
|
|
ecd9822861 | ||
|
|
4297f7f3f1 | ||
|
|
90bc9017b2 | ||
|
|
ae0ef4296b | ||
|
|
c1809b4ca9 | ||
|
|
7a392a5367 | ||
|
|
e077ae7f97 | ||
|
|
24f31a9c3a | ||
|
|
8bf25696cb | ||
|
|
bee7431793 | ||
|
|
ad374ae208 | ||
|
|
ec2b0c3de4 | ||
|
|
3b7d6b7f05 | ||
|
|
1b6f16dba2 | ||
|
|
df154a26d1 | ||
|
|
21599938ea | ||
|
|
3a54b253cb | ||
|
|
8b9680fc35 | ||
|
|
3ce18708b6 | ||
|
|
78acb558ff | ||
|
|
0f14c2a5de |
Executable
+125
@@ -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
@@ -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()
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -1,5 +1,131 @@
|
||||
# Changelog
|
||||
|
||||
## 0.19.2
|
||||
|
||||
**A patch bump: one fix for charging from the grid under the peak limit, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.19.1 also
|
||||
start 0.19.2. The change below applies with one inverter or two.
|
||||
|
||||
**Charging from the grid now stays under this month's peak.** A late meter reading near a period's end could push a grid charge to full power. That could take the 15-minute period over the peak. Now the charge is cut back, with one inverter too. The peak guard line no longer says "under" when the period would go over the limit.
|
||||
|
||||
Contents of this release, in merge order since 0.19.1 (`2932f3a`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `SAFETY-125` | A grid charge (a battery mode's cheap run, price trading or the maintenance charge, with one inverter or two) counts the seconds since a late meter reading at that reading, so it is cut back near the end of a 15-minute period instead of going back to full power. With one inverter the maintenance charge is now held to the room left in the period, as with two, so the peak guard no longer steps in and gives power at the period's end. Over the limit at the current rate, the peak guard row and the month peak line say so, and that the grid charge is cut back ([[SAFETY-125]]). |
|
||||
|
||||
### Known issues
|
||||
|
||||
The list under `## 0.19.0` below still applies. The SAFETY-99 line there now
|
||||
covers the guard's discharge only, not a grid charge.
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.19.1
|
||||
|
||||
**A patch bump: two fixes for battery modes, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.19.0 also
|
||||
start 0.19.1. The changes below apply with one inverter or two.
|
||||
|
||||
**Battery modes now see a day/night or fixed tariff.** With battery modes on, a day/night or fixed tariff and no price service, the batteries never got the prices: "Cheap hours" never charged from the grid at the cheap rate, and the Batteries screen said "No prices". They get them now, with no price service and no internet call added.
|
||||
|
||||
**A solar forecast now counts for the price gap.** With battery modes on and a solar forecast read, a price mode needs a gap of at least *Battery cycle cost*, not 1.5 times that. Without a forecast it is still 1.5 times. A day/night gap of 0.08 now works at a cycle cost of 0.07 or less.
|
||||
|
||||
Contents of this release, in merge order since 0.19.0 (`793a676`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `TARIFF-24` | With battery modes on, the add-on now also resolves the tariff when no price service and no price trading are set, so a day/night or fixed tariff gives the batteries their price runs ([[TARIFF-24]]). With battery modes on and a solar forecast read, the price gap a mode needs is *Battery cycle cost* (widened only for a real-time feed), not 1.5 times that; Home and the Batteries screen use the same rule ([[AUDIT-02]] F1). |
|
||||
|
||||
### Known issues
|
||||
|
||||
The list under `## 0.19.0` below still applies.
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.19.0
|
||||
|
||||
**A minor bump: price trading behaves differently, plus a fix for a false warning, the Home price chart in 15-minute bars, more Dutch text and EMS-port wording, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because price trading changes when the battery charges and gives power; every saved setting still starts.
|
||||
**Existing installs need no option change:** options that start 0.18.2 also
|
||||
start 0.19.0. The changes below apply with one inverter or two.
|
||||
|
||||
**Price trading skips periods that are over.** When the add-on trades on price, it now picks only from price periods that are not over yet. With battery modes off and 15-minute prices, the battery therefore trades for longer (about 1.5 to 2.5 times the minutes in a simulation of 16 real days), so it cycles more; with battery modes on, it no longer charges from the grid because of a cheap period that is over. Hourly and day/night sites change too: they no longer pause at the start of each hour.
|
||||
|
||||
Contents of this release, in merge order since 0.18.2 (`c67d0cd`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `243a756` | The docs, the add-on description and the field guide now say the add-on is wired to the inverter's EMS port, not a "meter bus". In Home Assistant the device's manufacturer text becomes "GoodWe (via RS485 EMS port)", and the device and its entities stay the same ([[DOC-8]], #201) |
|
||||
| `75f0804` | The price chart on the Home page now shows one bar for each 15-minute price, the price the add-on decides on, where it showed hour averages. Its cheap and dear colours now show where the add-on will charge or give power, instead of thirds of the price range ([[UX-45]], #202) |
|
||||
| `bee7431` | First half of the "not keeping up" fix, shipped with the next row and described there ([[SAFETY-119]], #203) |
|
||||
| `e077ae7` | The false "Battery is not keeping up" warning, and its Home Assistant notification, no longer appear when the peak guard steps in and then gives control back near the start of a 15-minute period; battery commands do not change. A real overload still warns at the start of each 15-minute period, up to about 25 s later with the default 5 s meter, but with an uneven meter of about 15 to 20 s and a small overload, it can stay quiet for up to an hour ([[SAFETY-121]], #204, with [[SAFETY-119]]) |
|
||||
| `90bc901` | On Dutch pages, the status rows, banners and settings notes now show Dutch text where they showed English, and so do the setup wizard's checks once [[UX-51]] is fixed. The settings label of the 15-minute demand forecast sensor no longer ends in "(W)", and English pages change only by that label ([[UX-46]], #205) |
|
||||
| `aa22806` | Price trading, and the check for it with battery modes on, now ignore price periods that have already ended, so the add-on no longer aims at a 15-minute period that is over and then does nothing. Hourly and day/night sites also change: there is no pause at the start of each hour, as the owner accepted ([[PRICE-02]], #206) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
|
||||
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
|
||||
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
|
||||
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
|
||||
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
|
||||
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
|
||||
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
|
||||
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
|
||||
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
|
||||
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
|
||||
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
|
||||
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
|
||||
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
|
||||
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
|
||||
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
|
||||
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
|
||||
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
|
||||
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
|
||||
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
|
||||
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
|
||||
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
|
||||
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
|
||||
- Home can show a grid charge that the add-on will not take: the rest of a cheap run that the trade check refuses (battery modes on), or a charge after the last dear price period (battery modes off). The battery stays idle then ([[UX-53]]).
|
||||
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
|
||||
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
|
||||
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
|
||||
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
|
||||
|
||||
### 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.18.2
|
||||
|
||||
**A patch bump: one fix to how the 15-minute demand forecast sensor is read, and no option changes.**
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# OPT-23 and OPT-28 - the day plan counts the sun right, and the night charge covers the expensive-hour shortfall
|
||||
|
||||
**Status: for review.** Tests: `test_opt23_28.py`.
|
||||
|
||||
`dayplan.plan()` now buys, for Leftover solar batteries, what the house uses in the expensive hours less the sun counted for each hour, shared by capacity, and reads a state-only forecast from now for every span (OPT-23).
|
||||
|
||||
- **Shortfall.** `sum_h max(0, house_h - trust x sun_h)` over every expensive hour, `/ EFF`, from the floor. It replaces the use over the run with no sun counted, which on a day and night tariff bought for the whole day (G1, `reports/UX-69-design-report.md`). The sun before the run is unchanged. A run with its dear hours after sunset gives the old number.
|
||||
- **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun.
|
||||
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by another battery (Cheap hours, Solar first) or the grid, as the screen says.
|
||||
- **Cheap hours** still fills up and is not counted in the share; **Solar first** still never charges from the grid. The mode help says so.
|
||||
- **Not done:** the run length (OPT-25); netting what a Cheap hours or Solar first battery will hold against the shortfall.
|
||||
@@ -0,0 +1,89 @@
|
||||
# PRICE-02 - the trading verdict skips price periods that have ended
|
||||
|
||||
**Status: for review.** Tests: `test_price02.py`. Found by AUDIT-01 (F1).
|
||||
|
||||
The refresh floors the plan to the top of the hour, so on the 15-minute feed
|
||||
it holds the periods of this hour that are already over. The verdict took the
|
||||
cheapest and dearest over every point. With modes off it could aim at a period
|
||||
that had passed and then do nothing, for up to 45 min plus the refresh lag.
|
||||
With modes on the `allowed()` gate could be open only because of such a period.
|
||||
|
||||
Decision: `TariffPlan.arbitrage(now=...)` drops the points whose period has
|
||||
ended (`end <= now`, the span `price._cover_table()` gives, as
|
||||
`priceruns.firm_slots()` does). Its three callers pass their own `now`:
|
||||
`arbitrage.decide()`, `Controller._mode_roles()` and `homeview.trade_verdict()`.
|
||||
|
||||
- Taken in the verdict, not when the plan is built: a plan built at 03:00 is
|
||||
still used at 03:20, and the Home chart keeps the day's earlier bars.
|
||||
- An hourly point that is still current stays in. But an hourly or static plan
|
||||
is rebuilt every 30 min counted from add-on start, so it is usually off the
|
||||
hour and, until the next refresh, holds the hour that has just ended. That
|
||||
hour is dropped too (round 3, owner-accepted 2026-10-22). On a day/night
|
||||
tariff all day hours tie and the old verdict took the first, the ended one,
|
||||
so it paused the trade every hour until the refresh: 91 on/off changes over
|
||||
2 days with the refresh at :10, 4 now (4 before as well, with the refresh on
|
||||
the hour). With the refresh on the hour nothing changes.
|
||||
- `now` is required: keyword-only, no default, like the other three arguments. A
|
||||
caller that could leave it out would get the elapsed periods back and the
|
||||
defect with them. A test that wants the whole series passes an instant before it.
|
||||
- The sentences of this verdict in `arbitrage.py` (status row and action log,
|
||||
English only) say "price period" where they said "hour": the idle one now
|
||||
reads "now is not the cheapest or the dearest price period". The UI texts
|
||||
that say "cheapest hour" (AUDIT-01 T1-T5) are not touched here.
|
||||
- Not changed here: F2 (static profile sampled at HH:00) and F3 (carried value).
|
||||
|
||||
Measured: with price off, every command over the modes-off replay is
|
||||
byte-identical. On a static tariff rebuilt on the hour it is too; rebuilt 20
|
||||
min off the hour the commands differ (digest 79f06d6381ff3358 on release/1.0,
|
||||
18de19d9fe919fe5 here), for the reason above. On the dynamic replay every command is
|
||||
the same too; only a sale's sentence quotes a smaller "above the cheapest"
|
||||
figure once the first cheap period is over. The identity pins in
|
||||
`test_modes_p3.py` and `test_price_run_fixes.py` hide just that figure and the
|
||||
word change (`opt11p3_identity.trade_digest()`).
|
||||
|
||||
## Ties (round 2)
|
||||
|
||||
The verdict takes `min`/`max` over the points still ahead and the first one wins
|
||||
a tie, so with equal prices it is the first period still ahead, which is the
|
||||
current one. Before, it was the first of the whole plan, often one that had
|
||||
ended. Two effects follow, and both are the card's intent, not a side effect:
|
||||
|
||||
- Equal quarters (hour-constant data, or a feed that prints the same price in
|
||||
several quarters) now act in every quarter of the cheap (or dear) block,
|
||||
not in the first only. Test: `test_price02.py` part 6.
|
||||
- After the cheapest period has passed, the next cheapest still ahead is the
|
||||
target, if its spread still clears the cost. A hourly plan did this at every
|
||||
refresh already (it started at the current hour); a 15-minute plan did it
|
||||
only from the next hour on, because it kept the elapsed quarters of this one.
|
||||
|
||||
Measured with `scratch/PRICE-02/dev/tiesim.py` (refresh every 30 min from the
|
||||
floored hour, verdict at 7 min into each period, threshold 0.05, old = the
|
||||
verdict over the whole plan, new = this card):
|
||||
|
||||
| data | days | acting periods old -> new | action changes old -> new |
|
||||
|---|---|---|---|
|
||||
| real 15-minute day, energy-charts 2026-09-09 | 1 | 4 -> 5 | 6 -> 6 |
|
||||
| real 15-minute days 2026-09-09 and -10 | 2 | 14 -> 26 | 24 -> 24 |
|
||||
| EPEX 2025-26 hourly prices as four equal quarters | 12 | 18 -> 72 | 36 -> 20 |
|
||||
| the same with random +-0.002 noise per quarter (no ties) | 12 | 18 -> 33 | 36 -> 48 |
|
||||
|
||||
So a real 15-minute day does not switch more often (24 -> 24), and a day of
|
||||
equal quarters switches less. A synthetic day of noisy, tie-free quarters does
|
||||
(3.0 -> 4.0 changes a day): each period that passes hands the target to the
|
||||
next cheapest one ahead. Every change is still at a period edge, never within
|
||||
one. The replay in `test_arbitrage.py` (synthetic year, hourly) gives the same
|
||||
ledger on the old and the new code once its ties are broken (280 round trips,
|
||||
85 held, both): it printed one maximum twice per day, and the old fixture
|
||||
tested the first of them only because its plan began at midnight.
|
||||
|
||||
## Battery throughput (round 3)
|
||||
|
||||
On real 15-minute days the trade runs longer: about 1.5 to 2.5 times the
|
||||
minutes spent charging or discharging, with fewer on/off switches (for example
|
||||
78 -> 68, 92 -> 72), over 16 days (2026-09-02 to 09-17, a decision every
|
||||
minute, refresh at :00 or :10, tomorrow's prices known always or from 13:00;
|
||||
reviewer's sim). Through a modelled 10 kWh battery that is 27.5 -> 42.6 kWh
|
||||
(refresh at :00, tomorrow always known); 19.6-31.9 -> 43.4-48.7 kWh in the
|
||||
other three variants. Charge to
|
||||
discharge reversals are unchanged (10 -> 10, 13 -> 13). More cycling is the
|
||||
price of not pausing.
|
||||
@@ -1,6 +1,9 @@
|
||||
# SAFETY-119 - no "not keeping up" when the loop takes the wire back
|
||||
|
||||
**Status: for review.** Tests: `test_safety119.py`.
|
||||
**Status: merged (bee7431); the settle and the budget changed in SAFETY-121**
|
||||
(DECISIONS/SAFETY-121-bound-every-quarter.md: 4 cycles and 22 s, one budget
|
||||
of 11). The rolling-budget sections below describe bee7431. Tests:
|
||||
`test_safety119.py`.
|
||||
|
||||
**Cause.** When another owner (the peak guard, a maintenance charge) has set
|
||||
the wire and the loop wins again, `cycle()` re-seeds the integrator from that
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# SAFETY-121 - a recurring overload warns every quarter
|
||||
|
||||
**Status: for review.** Tests: `test_safety119.py` (parts 2, 4, 5, 6).
|
||||
Follows DECISIONS/SAFETY-119-bound-after-hand-back.md.
|
||||
|
||||
**The defect (TEST-23, SAFETY-119 rig test F-1).** 5 s meter, 2 x 2500 W,
|
||||
cap 5000 W, maintenance charge, no forecast, house 5100 W. bee7431 warned
|
||||
once in 70 min (04:31:01), then not at 04:45, 05:00 or 05:15. Base 75f0804
|
||||
warned at every quarter start. The loop held the wire once a quarter, at its
|
||||
start, and was at its bound on 4 or 5 cycles. SAFETY-119's settle (12
|
||||
cycles, 60 s at 5 s) covered all of them, and 5 cycles a quarter fit in its
|
||||
budget of 8 per 900 s.
|
||||
|
||||
**Why the simulation said "every quarter".** `test_safety119.py` fed the
|
||||
meter one cycle behind the battery reading (`delay` counts cycles: 5 s at a
|
||||
5 s meter). Just after the loop leaves its rail, the guard's charge
|
||||
headroom (house = meter + battery) is then off by up to 5000 W, the charge
|
||||
pulls 2000 W, the guard steps in again, and the two chase each other: 8 to
|
||||
10 hand-backs and about 20 bound cycles a quarter at 5 s (rig: 1 and 4-5).
|
||||
With the meter read in the same cycle (`delay` 0, the rig's shape) the sim
|
||||
gives the rig's numbers: 3 to 5 bound cycles a quarter at 5 s, all inside
|
||||
SAFETY-119's settle, and bee7431 warns in 0 to 2 of 7 quarters. The test
|
||||
now runs both shapes.
|
||||
|
||||
**Decision.** After a hand-back the loop's bound is hidden until 4 cycles
|
||||
(`HANDBACK_CYCLES`) and 22 s (`SHORT_SPAN_S`) have passed; across all
|
||||
hand-backs on at most 11 cycles (`ROLLING_BOUND_CYCLES`) in any 900 s. The
|
||||
budget no longer follows the cycle length, and the cycle length is not
|
||||
tracked. A stop ends the settle and keeps the budget. An over-rating always
|
||||
counts. Commands are unchanged.
|
||||
|
||||
**Why it holds for any number of bound cycles a quarter.** The settle is
|
||||
fixed: at most 3 cycles or 22 s after a hand-back, whichever is longer. A
|
||||
bound that lasts past it counts on its first cycle after it. So one
|
||||
hand-back a quarter, with the loop at its rail past the settle, warns in
|
||||
that quarter, whether it is 1 bound cycle or 100. A bound that never lasts
|
||||
past a settle counts once more than 11 are hidden within 900 s. Hidden
|
||||
are only shapes with at most 11 bound cycles in 900 s, each within 3 cycles
|
||||
or 22 s of a hand-back. That is the quarter-edge kick: in every steady-meter
|
||||
run below it is on the 1st to 3rd cycle after its hand-back (up to 30 s at
|
||||
a 30 s meter). In the rig's shape at 5 s the bound comes 30 to 55 s after
|
||||
the hand-back.
|
||||
|
||||
**Sweep** (`goodwe-addon-checks:1`, 2 h runs, scratch/SAFETY-121/dev/).
|
||||
Quarter edge = test_safety119 part 1 (house 800 W, cap 2500 W, no
|
||||
forecast). Overload = part 4 (2 x 2500 W, cap 5000 W, house 5050 or
|
||||
5100 W, seeds 0-3). "Delay 1" is the old sim, "delay 0" the rig's shape.
|
||||
|
||||
| meter | quarter edge, runs with a warning (new / bee7431 / no fix) | overload delay 1, quarters warned of 7 (new / bee7431 / no fix) | overload delay 0, 5100 W (new / bee7431 / no fix) |
|
||||
|---|---|---|---|
|
||||
| 1 s | 0/70 / 0/70 / 68/70 | 7 / 7 / 7 | 7 / 7 / 7 |
|
||||
| 2 s | 0/70 / 0/70 / 39/70 | 7 / 7 / 7 | 7 / 7 / 7 |
|
||||
| 3 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 7 / 7 (5050 W: 7 / 1-7 / 7) |
|
||||
| 5 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0-2 / 7 |
|
||||
| 10 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0 / 7 |
|
||||
| 15 s | 0/70 / 0/70 / 40/70 | 7 / 4-7 / 7 | no fix warns 0-1 (5300 W: 7 / 0 / 7) |
|
||||
| 20 s | 0/60 / 0/60 / 30/60 | 7 / 5-7 / 7 | no fix warns 0 (5300 W: 7 / 0 / 7) |
|
||||
| 30 s | 0/60 / 0/60 / 30/60 | 7 / 3-5 / 7 | no fix warns 0 (5300 W: 6-7 / 0 / 6-7) |
|
||||
|
||||
Quarter-edge seeds: 1-30 with the meter in the same cycle, and 1-40 (1-30
|
||||
at 20 and 30 s) with the test's 1-2 cycle delay. 5300 W: seeds 1-5.
|
||||
Commands identical to no fix and to bee7431 in all 1013 runs (these and
|
||||
the limits below). Choosing the numbers: the runs above were recorded
|
||||
without the fix and replayed through candidate rules; with 3, 4 or 5
|
||||
cycles, budgets 9 to 13 met both cases at 1-30 s. 8 warns at the quarter
|
||||
edge (2 s, seed 25) and 14 misses quarters in the rig's shape (1 s,
|
||||
5050 W), both checked in part 4. 11 is the middle, and 4 the middle of 3
|
||||
to 5; with a jittery meter (below) 3 gave more false alarms, 5 more quiet
|
||||
quarters (replay).
|
||||
|
||||
**First warning after one hand-back** (the loop at its rail; later than
|
||||
no fix by): 11 s at 1 s, 22 s at 2 s, 24 s at 3 s, 25 s at 5 s (default),
|
||||
30 s at 10 s, 45 s at 15 s, 60 s at 20 s, 90 s at 30 s, 180 s at 60 s.
|
||||
bee7431: 10, 20, 30, 40, 80, 90, 80, 120, 240 s. In the overload runs at
|
||||
1 to 30 s, the first warning was at most 90 s later than without the fix.
|
||||
|
||||
**Limits (measured, not fixed).**
|
||||
- A meter that jitters by 30 % with `cycle_floor_s` 15 (11 seeds): with
|
||||
the test's 1-2 cycle meter delay the quarter edge warns on 2 of 11 seeds
|
||||
at 15 s, 2 at 20 s and 5 at 30 s (bee7431: 4, 0, 10; no fix: 11 each);
|
||||
with the meter in the same cycle on none (no fix: 10, 9, 11). The overload at 5100 W leaves up
|
||||
to 4 quiet quarters in a row at 15 and 20 s (bee7431: no warning at all
|
||||
on some seeds); at 5200 W at most 1. Its first warning came up to 61 min
|
||||
later than without the fix. The numbers depend on the jitter draw: the
|
||||
review's draw gave 4, 4 and 5 of 11 at the quarter edge (bee7431: 4, 1,
|
||||
9) and up to 2 and 4 quiet quarters at 15 and 20 s.
|
||||
- With cycles over about 40 s, a bound that is never past a settle uses up
|
||||
11 in 900 s only if the wire changes hands every few cycles; otherwise
|
||||
it is never counted (part 5 prints it at 60 s).
|
||||
- Hidden as designed: a real bound with the kick's shape (at most 11 cycles
|
||||
in 900 s, each within 3 cycles or 22 s of a hand-back).
|
||||
|
||||
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
|
||||
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ from
|
||||
bee7431 and from no fix; commands, claims, guard and phases are the same.
|
||||
@@ -0,0 +1,35 @@
|
||||
# SAFETY-123 - the day plan's hour loop steps in UTC
|
||||
|
||||
**Status: for review.** Tests: `test_safety123.py`. No option, no text change.
|
||||
|
||||
`dayplan._hour_slices()` stepped to the next local hour by adding an hour to
|
||||
the local wall clock and converting back. Where the zone has no tzdata
|
||||
(`tariff._EUTimezone`: the CI image, a dev host, an image that loses tzdata)
|
||||
the missing 02:00 of the spring change reads as summer time, which is
|
||||
00:00 UTC again. At 28 March 2027 00:00 UTC `t` did not move and the list
|
||||
grew until the process died. Control and the web page share one event loop,
|
||||
so the whole add-on froze. `dayplan.plan()` calls it on every plan, before it
|
||||
looks at a battery's mode, so a site with all batteries on Solar first reaches
|
||||
it as well, if the prices give a plan.
|
||||
|
||||
The shipped image has tzdata (`amd64-base:3.20`), where this zone gives the
|
||||
right answer, so a running add-on is not known to be hit. The fix is for any
|
||||
host without it. (AUDIT-01 F4, card SAFETY-120, is the same defect.)
|
||||
|
||||
**Decision.** Each step adds the time left in the local hour to the UTC
|
||||
instant. It is at least a microsecond, so `t` always advances and the loop
|
||||
ends, in any zone, with or without tzdata. A day of 23 hours gives 23 slices
|
||||
and a day of 25 hours gives 25, each at most an hour, none across a local
|
||||
hour. On ordinary days the slices are the old ones.
|
||||
|
||||
**One change on the autumn day.** The old function (with tzdata) made one
|
||||
two-hour slice for the doubled hour 02:00-03:00; it now makes two of an hour,
|
||||
both labelled 2. `plan()` sums per slice, so only a forecast with sun in that
|
||||
hour could tell the difference. There is none.
|
||||
|
||||
**Other loops.** The other code that walks local hours or days steps in UTC
|
||||
or on the calendar date (`static_tariff`, `price_forecast`, `price_history`,
|
||||
`price`, `maintenance`, `homeview.local_day`, `solarfc._bounds`), or has a
|
||||
fixed bound. None can stall. One known inexactness stays, outside this fix:
|
||||
`solarfc` spreads a state-only forecast over hours since local midnight, so on
|
||||
the two change days its sun curve is an hour off the clock.
|
||||
@@ -0,0 +1,10 @@
|
||||
# SAFETY-125 - a grid charge under the month peak
|
||||
|
||||
**Status: FIXED in 0.19.2 (hotfix from 0.19.1).** Tests: `test_safety125.py`.
|
||||
|
||||
A grid charge is sized by `peakguard.charge_room_w()`, the guard's headroom
|
||||
with the seconds since a late meter reading counted at that reading (the
|
||||
projection leaves them out, SAFETY-99, the bill does not), and the guard's own
|
||||
floor is unchanged. At N=1 the maintenance charge is now sized too (owner
|
||||
2026-10-29; SAFETY-84 had left it out). Evidence (repro on 0.19.1, sweep,
|
||||
digests): the SAFETY-125 pull request.
|
||||
@@ -39,7 +39,8 @@ was fine.
|
||||
**Not changed.**
|
||||
- At N=1 the charge is not sized to the headroom: the guard still suspends it
|
||||
and the battery gives until the quarter is safe. The same sizing would fit
|
||||
there; that is an owner question, not this fix.
|
||||
there; that is an owner question, not this fix. (SAFETY-125, 0.19.2: the
|
||||
owner said yes, and N=1 is sized the same way.)
|
||||
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
|
||||
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
|
||||
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# TARIFF-24 - battery modes need the tariff task
|
||||
|
||||
**Status: FIXED in 0.19.1 (hotfix from 0.19.0, owner go 2026-10-28).**
|
||||
Follows TARIFF-10 (fetching is not trading). Tests: `test_tariff24.py`.
|
||||
|
||||
**The defect.** The control cycle gets prices only from
|
||||
`Controller.tariff_plan`, which only `run_tariff_refresh()` stores. `amain()`
|
||||
starts that task when `tariff_refresh_wanted(opts)`, which was
|
||||
`provider_configured(opts) or arbitrage.enabled(opts)`. Battery modes read the
|
||||
plan but were not a term. With battery modes on, a day/night or fixed
|
||||
`static_profile`, `price_provider: off` and `arbitrage_enabled: false` (the
|
||||
site trade switch is not read with modes on) no task ran. Then
|
||||
`fresh_tariff_plan()` was None, `_price_runs()` was None and `mode_runs` was
|
||||
None, so Leftover solar and Cheap hours never charged from the grid. The
|
||||
Batteries screen said "No prices, so it follows the house" while Home priced
|
||||
the day from `static_plan()` itself (`homeview.build_plan()`).
|
||||
|
||||
**The fix** (`main.py`, `tariff_refresh_wanted()`). `packmode.enabled(opts)`
|
||||
is a third term. The gate stays a widening: nothing that started the task
|
||||
before stops. No HTTPS call is added: with `price_provider: off`,
|
||||
`resolve_tariff()` is the pure `static_plan()`.
|
||||
|
||||
**Not changed.** Modes off, arbitrage off, no feed still starts no task. The
|
||||
control law, the price runs and every message are untouched.
|
||||
|
||||
**One rule, not two.** Gating on "modes on and a static profile" was
|
||||
considered. It saves one arithmetic pass every 30 min on a site with modes on
|
||||
and no prices at all, and it adds a second condition that has to track what
|
||||
`resolve_tariff()` accepts. With no profile and no feed the plan is `no_data`,
|
||||
exactly what the cycle sees without the task.
|
||||
|
||||
**UX-55.** Home said "no prices have arrived" (`mode_runs` None) with every
|
||||
battery on Solar first. The same cause, and this fix clears it for a site with
|
||||
a static profile (`test_tariff24.py` part 5). A site with no profile and no
|
||||
feed still has no prices, and Home still says so.
|
||||
|
||||
## Round 2 (AUDIT-02 F1): the forecast replaces the "no weather" doubt
|
||||
|
||||
**Decided 2026-10-28 (owner), in the same hotfix.** The price gap a mode needs
|
||||
was `cycle_cost_threshold_eur` x 1.5 (`Uncertainty.NO_WEATHER`) whether or not
|
||||
a solar forecast was read. At the shipped cost 0.10 that is 0.15, so a
|
||||
day/night gap (0.08) and many dynamic days never traded, and the screens said
|
||||
only "Prices differ too little".
|
||||
|
||||
**Why the doubt goes with a forecast.** The widening stands in for not knowing
|
||||
the weather. The forecast is that knowledge, and the day plan already trusts it
|
||||
for how much to charge. Trusting it for the plan but doubting it for the gap
|
||||
was one input judged twice.
|
||||
|
||||
**The rule** (`arbitrage.weather_uncertainty()`). With battery modes on and a
|
||||
usable forecast (`solar_fc` not None, the day plan's own test) the doubt is
|
||||
not added: the gap needed is the cycle cost, widened by the price product only.
|
||||
No forecast, or a stale or unusable one: 1.5 x, as before. It is read in four
|
||||
places that must agree: the run plan (`_price_runs()`), the charge gate
|
||||
(`_mode_roles()`), Home's mirror (`homeview.trade_verdict()`) and the Forecast
|
||||
tab's longer cheap run (`dayview.py`, from release/1.0). The forecast
|
||||
is read at most every 60 s and the plan key already holds "forecast or not", so
|
||||
the gap follows in the cycle the read changes, both ways, with one exception:
|
||||
a run planned with a forecast that is under way keeps that gap to its end
|
||||
(`Controller.weather_held()`), so a sensor that goes unavailable does not stop
|
||||
or restart a Cheap hours charge (a Leftover solar battery still stops: without
|
||||
a forecast it has no planned level). The wider gap applies from the next run.
|
||||
A run that has started runs to its end: a give run that starts during the hold
|
||||
is kept after it (`keep_started()`), with no grid charge or sale.
|
||||
|
||||
**Not changed.** Modes off (the site trade) keeps the doubt always. The
|
||||
shipped cost 0.10 still needs a gap of 0.10 even with a forecast: a 0.08
|
||||
day/night site must set *Battery cycle cost* to 0.08 or less. Showing the two
|
||||
numbers on the screens (AUDIT-02 F1 option b) is a separate card.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# UX-53 - Home draws a grid charge only where the add-on will take it
|
||||
|
||||
**Status: DONE 2026-10-26 (owner go 2026-10-25, from the PRICE-02 rig test F-1 and F-2).**
|
||||
|
||||
## Decision
|
||||
|
||||
Home asks the add-on's own gate, and does not decide for itself.
|
||||
|
||||
- **Modes on.** `Controller._mode_roles()` files the gate it just asked
|
||||
(`TariffPlan.arbitrage()` with the measured peak guard) as `mode_gate`: None
|
||||
when it allowed a charge, else its reason (`peak`, `not_worth`,
|
||||
`not_covered`). While it is set, `homeview.mode_runs()` draws no grid charge
|
||||
and `price_windows()` marks no cheap quarter. Control never reads it. A
|
||||
refusal that is not the gate (no measured headroom, a battery cycling) is
|
||||
not filed, so those pages are as before.
|
||||
- **Modes off.** A charge the verdict puts after its last dear period is
|
||||
refused when its time comes: the quarters left are all equal. `leg_taken()`
|
||||
asks `trade_verdict()` (the control cycle's own call) at the leg's start and
|
||||
keeps the leg only if the gate allows it there. A leg in progress is the
|
||||
verdict's own answer now. The sale, "Tomorrow:" lines and chart marks use
|
||||
the same answer.
|
||||
- **Words.** "Lowest/Highest published price" becomes "Lowest/Highest price
|
||||
left" (the verdict reads only the periods not yet over). The flat-tariff
|
||||
refusal calls a site "a fully fixed product" only when its product is fixed.
|
||||
|
||||
## What does not change
|
||||
|
||||
No control decision, command or option. Day/night and hourly sites with a
|
||||
second day of prices keep both legs at every half hour of a day
|
||||
(`test_ux53.py` part 4), except where every price left is equal: with an
|
||||
all-night weekend, Friday's 22:00 charge is no longer drawn, as the add-on
|
||||
does not take it. Price-off sites draw nothing, as before.
|
||||
|
||||
## Limits
|
||||
|
||||
The leg's gate is asked on today's plan, as the control cycle will find it at
|
||||
that time. A plan that gets new prices in between is drawn again at the next
|
||||
refresh. While the peak guard holds, a modes-on page shows no grid charge at
|
||||
all, also not the rest of a run the add-on charges in once the guard clears,
|
||||
as the modes-off page already did.
|
||||
@@ -0,0 +1,146 @@
|
||||
# UX-54 - a Forecast tab: the sun, the house and each battery's level, per hour
|
||||
|
||||
**Status: DONE 2026-10-26 (owner go 2026-10-26: "Day curve", "New tab").**
|
||||
|
||||
## Decision
|
||||
|
||||
A tab beside Home and Batteries shows today and tomorrow, per hour: the solar
|
||||
forecast, the solar measured so far, the house's expected use and each
|
||||
battery's expected level with its blocks. It follows the add-on, as Home does
|
||||
(UX-53), and decides nothing.
|
||||
|
||||
- **Blocks are Home's.** Today's are the `runs` of the same plan Home draws
|
||||
(grid, give, monthly cycle). Tomorrow's come from the same builders
|
||||
(`homeview.runs_for()`). The words are the plan lines'. A block is on the tab
|
||||
exactly where Home has one, so the UX-53 gate is followed too.
|
||||
- **Tomorrow is read ahead.** Home sizes a block from the battery's level now
|
||||
and draws none when there is no room or energy (`room()` and `plan_need()`
|
||||
in `homeview.mode_runs()`, the leg sizing in `trade_runs()`). For a day that
|
||||
has not started that level says nothing, so tomorrow's blocks are built with
|
||||
`ahead` (Home never passes it): `mode_runs()` reads each block at the level
|
||||
the curve has when it starts, with its own filter and `MIN_KWH`, so there is
|
||||
no give block where the battery is at the floor and no grid block where it
|
||||
is at its planned level. Where the curve has no level yet, a charge is read
|
||||
from the floor and a give from the ceiling. The blocks and the curve follow
|
||||
each other and are taken to a fixed point. The gate (`mode_gate`, UX-53) is
|
||||
not bypassed.
|
||||
- **Modes off has no curve** (no forecast is read, no house profile kept), so
|
||||
`trade_runs(ahead=f)` reads each leg at Home's own count
|
||||
(`dayview._counted()`): the level now, plus what the blocks before it put in,
|
||||
less what they take out. No sun is read and the house only takes, so no give
|
||||
block where the emptiest battery is at the floor by that count, and control
|
||||
does not give there. A charge is read from the floor: a full battery covers
|
||||
the house before the cheap hour, and control charges it back then. Walked
|
||||
every 15 minutes on a cloudy day, the packs moved by control and a 400 W
|
||||
house (`test_ux54.py` part 7, cases A-D, one price quarter per leg),
|
||||
control charges and gives in exactly the quarters the tab has a block.
|
||||
- **A block lasts as long as control acts.** A cheap run is sized for all the
|
||||
batteries charging together, so a slower one is still below
|
||||
`modetrade.charge_to()` when it ends, and control plans the next cheap slots
|
||||
(`Controller._price_runs()`). `dayview._levels()` asks the same functions,
|
||||
`modetrade.energy()` and `priceruns.plan()`, with the packs at the curve's
|
||||
level, and draws the grid block to where control stops. Each battery's block
|
||||
then ends where the curve has it at its own level, and a give block where
|
||||
it reaches the floor: the curve puts a point at both (`daycurve.curve()`
|
||||
cuts the step there), and the block ends at the first point where the level
|
||||
the tab shows (0.1 %) is there. What is proven: control walked every 15
|
||||
minutes through tomorrow with its own model of the packs, not the curve's
|
||||
levels (`_mode_roles()` each quarter; the house from the profile, the sun
|
||||
at 100 % trust, a GIVE role counted only where control's own discharge
|
||||
envelope `packshare.envelope()` is open, the cut-out at the floor), charges
|
||||
and gives in exactly the quarters the tab has a block in 13 cases
|
||||
(`test_ux54.py` part 7, `own_walk()`), e.g. 25/45 % with the peak guard:
|
||||
control gives 17:00-20:15, the block is 17:00-20:06; 30/80 % with a 7 kW
|
||||
evening house, B held to its rating: control 17:00-19:45, the block
|
||||
17:00-19:32. The floor point is placed in one cut, where the battery runs
|
||||
out at the rate it gives (`_floor_cut()`, checked to the second against
|
||||
times worked out by hand), so a build stays small: at most 14 floor cuts
|
||||
and 28 level points per battery and day in those cases. With a cut-out under
|
||||
the floor, or a house or sun that differs from the forecast and profile,
|
||||
control differs (Limits).
|
||||
- **The sun** is what `solarfc` reads. An hourly split (Solcast
|
||||
`detailedForecast`, Open-Meteo `wh_period`) is used as given. A daily total
|
||||
(Forecast.Solar, and any other state-only sensor, the owner's three "today
|
||||
remaining" and three "tomorrow") is spread over the 06:00-20:00 bell, and the
|
||||
tab says so.
|
||||
- **The house** is `HouseProfile.estimator()`, never below the night median, as
|
||||
the plan reads it.
|
||||
- **The level did not exist** (the plan holds targets and run blocks only), so
|
||||
`daycurve.curve()` derives it: each battery has the role `modetrade.decide()`
|
||||
gives (follow the house, hold, give, charge from the grid, rest), spare solar
|
||||
goes to Solar first batteries first, a shortfall is met by those that may
|
||||
give, shared as control shares it (by what each holds above the floor,
|
||||
`packshare.weights()`, each at most its rating, the rest passed on), both
|
||||
at `dayplan.EFF`, inside the plan's floor and ceiling, from the
|
||||
level now. Spare solar is `trust x max(0, sun - house)` and what is stored
|
||||
loses `EFF`, the plan's own `trust x EFF x max(0, sun - house)` (10 kWh of
|
||||
sun, 2 kWh of house, 70 %: 5.32 kWh stored). A shortfall is not scaled by
|
||||
trust. The tab says it counts on `solar_trust_pct`. Grid charging goes to
|
||||
`modetrade.charge_to()`. It is an estimate.
|
||||
- **Measured solar** is kept per local hour in memory (`SolarToday`), from the
|
||||
reading the control cycle already takes; nothing is read back.
|
||||
- **On request.** `GET forecast` builds the tab (`Controller.forecast_payload()`
|
||||
reads `/status`' plan first, so today's blocks are the very ones Home has). No
|
||||
Home Assistant call, nothing cached, nothing in the cycle but one in-memory
|
||||
add. The page asks every 30 s while the tab shows.
|
||||
- **Not known yet is a note, not an empty chart:** modes off, no forecast set,
|
||||
forecast missing or old, learning, no tomorrow forecast, a monthly cycle, no
|
||||
level reading, control stopped, shadow.
|
||||
|
||||
## What does not change
|
||||
|
||||
No decision, command, timing or option key. The one write in the control path
|
||||
is `SolarToday.add()` after the solar read, in a `try`, in memory.
|
||||
|
||||
## Limits
|
||||
|
||||
- Prices are planned about a day ahead (`priceruns.HORIZON`), so a block of
|
||||
tomorrow later than 24 h from now, or one that needs prices not yet
|
||||
published, appears as the day comes near. The tab says so.
|
||||
- Today's blocks are Home's, and are not extended or shortened: control may
|
||||
plan more once they end, and Home draws it then. The curve stops charging
|
||||
at its level (`charge_to()`) and giving at the floor.
|
||||
- Today, a give run in the plan on a battery that is empty now has no block on
|
||||
Home (no energy to give). The tab equals Home, and says so: "The add-on plans
|
||||
to give power. No block shows while a battery has no energy to give." The
|
||||
curve gives in that run if the sun fills the battery first.
|
||||
- With no forecast for tomorrow the curve stops at the first hour of the sun,
|
||||
`solarfc.BELL_FROM_H` (06:00): `Forecast.between()` has no answer past it. The
|
||||
note names that hour from the constant.
|
||||
- Tomorrow shows price blocks only. A monthly cycle is drawn today, where the
|
||||
curve then stops for that battery.
|
||||
- The curve uses the arbitrage floor and ceiling, as the plan does, not the
|
||||
battery's own cut-out level. Control stops a pack at its cut-out, so with a
|
||||
cut-out under the floor a battery in a give run covers the house below the
|
||||
floor; Home draws no block there either. The house profile is a median per
|
||||
hour, net of the solar the house used.
|
||||
- Modes off: the count knows no sun and no house. A battery at the floor that
|
||||
the sun fills before a give gets no block until then (the tab says "The
|
||||
add-on plans to give power. No block shows while a battery has no energy to
|
||||
give."), and a small charge may be used by the house before the give. The
|
||||
other way round, a charge is drawn even when the sun fills the batteries
|
||||
before it: at the ceiling control then takes none (a cheap quarter at 13:00
|
||||
on a sunny day: the tab draws the charge, control charges nothing at 95 %
|
||||
or 100 %).
|
||||
A leg is the verdict's as it stands now, one price period, as on Home; with
|
||||
several equal prices in a row control takes the next one as each passes.
|
||||
- Measured solar starts when the add-on starts; the tab says from when.
|
||||
- Clock changes: the tab walks local hours with `dayplan._hour_slices()`,
|
||||
which steps in UTC (SAFETY-123), and keys measured solar by the start of
|
||||
the local hour taken back from the UTC instant, so the spring day has 23
|
||||
hours, the autumn day 25 (two of them 02:00), and a request always returns
|
||||
(`test_ux54_dst.py`, every quarter of both change days, with and without
|
||||
tzdata). The solar bell is spread over hours from local midnight, so on
|
||||
those two days it is an hour off the clock (SAFETY-123, not changed here).
|
||||
|
||||
## Found, not changed
|
||||
|
||||
`dayplan.plan()` reads the forecast hour by hour with `Forecast.between(s, e)`.
|
||||
For a state-only forecast `between()` takes its start as the moment the "left
|
||||
today" figure was read, so each later hour is spread over what is left of the
|
||||
bell from that hour: on the owner's shape 14.4 kWh left gives 57 kWh over the
|
||||
hours to midnight (`test_ux54.py` part 1). The plan's sun is overcounted for
|
||||
those sites. It does not show on a site whose batteries are all Solar first
|
||||
(no level is planned for them). The tab measures both ends from now
|
||||
(`dayview.sun_kwh()`), which keeps the total and gives the same hours for an
|
||||
hourly split. A card should fix the plan.
|
||||
@@ -211,7 +211,7 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
|
||||
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
|
||||
| Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. |
|
||||
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. |
|
||||
| Limits | 15-minute demand forecast entity | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. |
|
||||
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
|
||||
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
|
||||
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
|
||||
@@ -530,15 +530,15 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen (and Se
|
||||
- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
|
||||
- **Mode.**
|
||||
- *Solar first* charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells.
|
||||
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). It saves its power for the expensive hours and covers the house then.
|
||||
- *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then.
|
||||
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but it buys only what the sun will not give in the expensive hours (see below). It saves its power for those hours and covers the house then.
|
||||
- *Cheap hours only* fills up from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then. It does not look at the forecast.
|
||||
- **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A).
|
||||
|
||||
With battery modes on, these choices replace the *Charging and selling on price* switch (`arbitrage_enabled`); that switch is only read with modes off.
|
||||
|
||||
**Cheap and expensive hours.** They come from the published day-ahead prices, as runs of consecutive 15-minute periods, not a single period.
|
||||
- The cheap run is the cheapest stretch long enough to fill the batteries that may charge, at their power.
|
||||
- An expensive hour costs at least *Battery cycle cost* (`cycle_cost_threshold_eur`, widened as for trading) more than the cheap run. The batteries hold their power until then.
|
||||
- An expensive hour costs more than the cheap run by at least *Battery cycle cost* (`cycle_cost_threshold_eur`) when a solar forecast is read, and by 1.5 times that when none is (a real-time price feed widens it further). The batteries hold their power until then.
|
||||
- The sale uses the most expensive stretch inside those hours, and never a period below 0.
|
||||
- A carried (not yet published) price is never used. A run that has started runs to its end.
|
||||
- With no prices, or too small a gap, every battery follows the house.
|
||||
@@ -548,7 +548,9 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
|
||||
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
|
||||
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
|
||||
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
|
||||
- *Leftover solar* then charges from the grid only the shortfall: for each expensive hour, what the house uses less the sun counted for that hour (never below 0), added up over all the expensive hours, early morning and evening. With a day and night tariff that is 07:00 to 22:00. Not up to the top level.
|
||||
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. In the expensive hours the rest comes from another battery (*Cheap hours only* or *Solar first*) or from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
|
||||
- A forecast that gives only one figure for the day (Forecast.Solar) is spread over the hours from now, so the hours add up to that figure.
|
||||
- *Cheap hours only* ignores the forecast and fills to the top level.
|
||||
- *Solar first* never charges from the grid.
|
||||
- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan.
|
||||
@@ -1708,7 +1710,9 @@ DECISIONS/MAINT-13-full-pack-is-complete.md.
|
||||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||||
under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
|
||||
is dropped so the battery can shave the peak instead. Money outranks the
|
||||
maintenance schedule.
|
||||
maintenance schedule. With one battery or several, the charge also stays
|
||||
within the room left in the 15-minute period, so a late meter reading does
|
||||
not let it run at full power.
|
||||
|
||||
With two or more batteries, the one being emptied gives its drain rate and the
|
||||
others only add power when the house needs more; none charges while it drains
|
||||
@@ -3530,6 +3534,21 @@ the peak limit, the batteries' switches and modes, and shadow mode, each
|
||||
marked "Applies right away" or "Applies after a restart". **Installer** shows
|
||||
every setting.
|
||||
|
||||
### The Forecast screen
|
||||
|
||||
The **Forecast** tab shows today and tomorrow, hour by hour: the solar forecast,
|
||||
the solar measured so far today, the house's expected use, and each battery's
|
||||
expected level. Under each battery's chart are its planned blocks, in the same
|
||||
words as Home. A block is there only where the add-on plans one.
|
||||
|
||||
The level is an estimate. It counts on `solar_trust_pct` of the forecast, as the
|
||||
plan does, and stays between the battery's low and high levels. A forecast that
|
||||
gives one total for the day (Forecast.Solar) is spread over the hours, and the
|
||||
screen says so. Where a part is not known yet (battery modes off, no forecast,
|
||||
still learning your house, no forecast for tomorrow) the screen says that
|
||||
instead of drawing an empty chart. It changes nothing in control, and it asks
|
||||
Home Assistant for nothing: it is built when you open it.
|
||||
|
||||
### Setup, for a new install
|
||||
|
||||
The **Setup** tab walks an installer through six steps: the meter, the
|
||||
@@ -3597,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. |
|
||||
|
||||
@@ -147,7 +147,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .arbiter import ACTUATOR_SITE, P_ARBITRAGE, Claim
|
||||
from .price import Absence, price_option
|
||||
from .price import Absence, Uncertainty, price_option
|
||||
|
||||
_LOG = logging.getLogger("goodwe.arbitrage")
|
||||
|
||||
@@ -172,7 +172,7 @@ IDLE_NOT_COVERED = (
|
||||
IDLE_NO_SOC = (
|
||||
"state of charge is not being read, and a battery whose fill nobody knows "
|
||||
"cannot be told to empty or to fill")
|
||||
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
|
||||
IDLE_OFF_PEAK_HOUR = "now is not the cheapest or the dearest price period"
|
||||
# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE
|
||||
# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating
|
||||
# and are now SIZED to the packs whose state of charge permits that direction, so
|
||||
@@ -196,7 +196,7 @@ IDLE_HEADROOM_UNMEASURED = (
|
||||
"creates the billed quantity (§9.1) and nothing is measuring the quarter "
|
||||
"this one would land in")
|
||||
NEGATIVE_PRICE = (
|
||||
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
|
||||
"§17: the sell period is priced BELOW ZERO, so discharging into it would give "
|
||||
"energy away and pay for the privilege. Arbitrage must not export at a "
|
||||
"negative price")
|
||||
|
||||
@@ -267,6 +267,21 @@ def threshold_eur_kwh(opts: dict) -> float:
|
||||
return v
|
||||
|
||||
|
||||
def weather_uncertainty(modes_on: bool, solar_fc, held: bool = False) -> frozenset:
|
||||
"""The "no weather" doubt (x1.5) a trade must clear. TARIFF-24.
|
||||
|
||||
Battery modes read a solar forecast; with one read (`solar_fc` not None) the
|
||||
doubt is gone, and `held` (a run planned with a forecast is under way, see
|
||||
`Controller.weather_held()`) keeps it gone until that run ends. Everything
|
||||
else, and the modes without a forecast, keep it. One rule for the run plan,
|
||||
the charge gate and Home's mirror. See
|
||||
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
"""
|
||||
if modes_on and (solar_fc is not None or held):
|
||||
return frozenset()
|
||||
return frozenset({Uncertainty.NO_WEATHER})
|
||||
|
||||
|
||||
def soc_bounds(opts: dict) -> tuple:
|
||||
"""(floor, ceiling) in percent. The reserve arbitrage may not spend."""
|
||||
return (float(price_option(opts, "arbitrage_soc_floor")),
|
||||
@@ -347,7 +362,7 @@ def decide(plan, now: datetime, *, peak_guard_active, charge_headroom_w, soc,
|
||||
|
||||
verdict = plan.arbitrage(peak_guard_active=peak_guard_active,
|
||||
min_spread_eur_kwh=min_spread_eur_kwh,
|
||||
uncertainty=uncertainty)
|
||||
uncertainty=uncertainty, now=now)
|
||||
return verdict.match(
|
||||
refused=lambda v: _idle(v.reason),
|
||||
allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w,
|
||||
@@ -362,7 +377,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
if here is None:
|
||||
return _idle(
|
||||
f"the series carries a tradeable spread but no price for this "
|
||||
f"instant ({now.isoformat()}), which is §11.4 for this hour rather "
|
||||
f"instant ({now.isoformat()}), which is §11.4 for this period rather "
|
||||
f"than a reason to reach for a neighbouring one",
|
||||
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh)
|
||||
|
||||
@@ -381,7 +396,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
# irreversible action may rest on it.
|
||||
if here.provisional:
|
||||
return _idle(
|
||||
f"§8.3: the price for this hour ({here.start.isoformat()}) is "
|
||||
f"§8.3: the price for this period ({here.start.isoformat()}) is "
|
||||
f"CARRIED FORWARD, not published, and no irreversible action may be "
|
||||
f"taken on a provisional price",
|
||||
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
|
||||
@@ -399,18 +414,18 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
# negative price reports the reason that actually forbids the trade
|
||||
# rather than one that happens to fire first on some other site.
|
||||
if price < 0:
|
||||
return _idle(f"{NEGATIVE_PRICE} - this hour is "
|
||||
return _idle(f"{NEGATIVE_PRICE} - this period is "
|
||||
f"{price:+.4f} EUR/kWh", **common)
|
||||
if soc <= floor_pct:
|
||||
return _idle(
|
||||
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
|
||||
f"the sell period is here at {price:.4f} EUR/kWh but the pack is "
|
||||
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
|
||||
f"arbitrage may not spend (§10.1 priority 4)", **common)
|
||||
if not abs(float(discharge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
return ArbitrageDecision(
|
||||
action=DISCHARGE, power_w=abs(float(discharge_w)),
|
||||
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
|
||||
reason=(f"{DISCHARGE}: this is the dearest firm price period at "
|
||||
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} above the "
|
||||
f"cheapest and clear of the {v.required_spread_eur_kwh:.4f} "
|
||||
f"this cycle had to cover; discharging "
|
||||
@@ -421,7 +436,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
|
||||
if soc >= ceiling_pct:
|
||||
return _idle(
|
||||
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
|
||||
f"the buy period is here at {price:.4f} EUR/kWh but the pack is at "
|
||||
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
|
||||
f"nowhere to put the cheap energy", **common)
|
||||
if not abs(float(charge_w)) > 0.0:
|
||||
@@ -456,7 +471,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
capped = size_w < asked_w
|
||||
return ArbitrageDecision(
|
||||
action=CHARGE, power_w=-size_w,
|
||||
reason=(f"{CHARGE}: this is the cheapest firm hour at "
|
||||
reason=(f"{CHARGE}: this is the cheapest firm price period at "
|
||||
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest "
|
||||
f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle "
|
||||
f"had to cover; charging {size_w:.0f} W from {soc:g} %"
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"""UX-54: the expected battery level through the day, for the Forecast tab.
|
||||
|
||||
Pure: batteries, the sun and the house's use in; a level per battery over
|
||||
time out. Display only: nothing here is read by control, and it never
|
||||
decides a block. The blocks are the plan's (`homeview.mode_runs()`); this
|
||||
only says where the level goes, hour by hour, if the add-on does what the
|
||||
plan says.
|
||||
|
||||
Each step gives every battery a role, as `modetrade.decide()` does (the
|
||||
names are modetrade's): in a grid block it charges (CHARGE) and the others
|
||||
rest (REST, no charge); else it follows the house (HOUSE) if it is Solar
|
||||
first or no price plan is on; inside a give run it covers the house (GIVE);
|
||||
else it holds (HOLD, may charge from spare solar, gives nothing). Spare
|
||||
solar (sun less the house's use) goes to Solar first batteries first, then
|
||||
the rest, by room. A shortfall comes from the batteries that may give,
|
||||
those in a give run first, by what they hold above the floor, each at most
|
||||
its rating, the rest passed on (as control shares it, `packshare.weights()`
|
||||
and the allocator's fill). A battery that empties inside a step gets a
|
||||
point there (`_floor_cut()`), as one that reaches its grid level does.
|
||||
Charging and giving lose `dayplan.EFF`, as the plan counts. A level stays
|
||||
inside the floor and ceiling the plan uses, and a battery already outside
|
||||
them does not move further out.
|
||||
|
||||
`SolarToday` keeps the site's measured solar per hour, in memory, from the
|
||||
reading the control cycle already has. It is shown and never read back
|
||||
(TEL-11 keeps site solar out of control).
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dayplan import EFF, _hour_slices, _local
|
||||
from .modetrade import CHARGE, GIVE, HOLD, HOUSE, REST
|
||||
|
||||
EPS = 1e-9
|
||||
MIN_CUT = timedelta(seconds=1) # no step is cut closer to its ends
|
||||
MAX_FLOOR_CUTS = 400 # a bound on the floor cuts of one curve
|
||||
GAP_S = 60.0 # a longer gap between readings is not counted
|
||||
MIN_COVER = 0.5 # share of the elapsed hour a reading must cover
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Batt:
|
||||
"""One battery that is on, with a level and a capacity."""
|
||||
name: str
|
||||
capacity_kwh: float
|
||||
rating_kw: float
|
||||
soc: float # % now
|
||||
solar_first: bool # Solar first: follows the house all day
|
||||
priced: bool # a price plan is on and its mode is a price mode
|
||||
grid: tuple = () # ((start, end, to_pct), ...) grid blocks
|
||||
give: tuple = () # ((start, end), ...) give runs
|
||||
stop_at: datetime | None = None # a monthly cycle starts: no level after
|
||||
|
||||
|
||||
def _role(b, t, charging, own):
|
||||
"""The battery's role at instant `t`; `own`: it charges from the grid;
|
||||
`charging`: some battery does. Returns `(role, may_charge, may_give)`."""
|
||||
in_give = any(s <= t < e for s, e in b.give)
|
||||
if own:
|
||||
return CHARGE, False, False
|
||||
if charging:
|
||||
return REST, False, b.solar_first or not b.priced or in_give
|
||||
if b.solar_first or not b.priced:
|
||||
return HOUSE, True, True
|
||||
if in_give:
|
||||
return GIVE, True, True
|
||||
return HOLD, True, False
|
||||
|
||||
|
||||
def _spread(amount, caps, weights):
|
||||
"""Split `amount` over the keys of `caps` by `weights`, none over its
|
||||
cap. Returns `(taken, left)`: what each took and what no one could."""
|
||||
got = {k: 0.0 for k in caps}
|
||||
left = float(amount)
|
||||
live = {k for k, c in caps.items() if c > EPS and weights.get(k, 0.0) > 0}
|
||||
while left > EPS and live:
|
||||
total = sum(weights[k] for k in live)
|
||||
used, full = 0.0, set()
|
||||
for k in live:
|
||||
part = left * weights[k] / total
|
||||
room = caps[k] - got[k]
|
||||
if part >= room - EPS:
|
||||
part = room
|
||||
full.add(k)
|
||||
got[k] += part
|
||||
used += part
|
||||
left -= used
|
||||
if not full:
|
||||
break
|
||||
live -= full
|
||||
return got, max(0.0, left)
|
||||
|
||||
|
||||
def _give_caps(roles, level, by, floor, hrs):
|
||||
"""`(first, rest, held)`: what each battery that may give can give in a
|
||||
step of `hrs` hours (its rating, or what it holds above the floor), those
|
||||
in a give run first, and what each holds above the floor (as given)."""
|
||||
held = {n: max(0.0, level[n] - floor) / 100.0 * by[n].capacity_kwh * EFF
|
||||
for n, r in roles.items() if r[2]}
|
||||
caps = {n: min(by[n].rating_kw * hrs, h) for n, h in held.items()}
|
||||
first = {n: c for n, c in caps.items() if roles[n][0] == GIVE}
|
||||
return first, {n: c for n, c in caps.items() if n not in first}, held
|
||||
|
||||
|
||||
def _floor_cut(a, z, net, roles, level, by, floor, hrs):
|
||||
"""Where in the step `[a, z)` the first battery that gives reaches the
|
||||
floor, or None. The shortfall comes at a steady rate, `-net / hrs` kW.
|
||||
It is shared as control shares it (`packshare.weights()`: by the energy
|
||||
each holds above its cut-out, here the floor), each at most its rating,
|
||||
what one cannot take passed to the others, those in a give run first.
|
||||
A battery giving `v` kW that holds `h` kWh is empty at `h / v` hours; a
|
||||
cut there makes the step come out exact. A battery already at the floor
|
||||
holds nothing, gives nothing and never sets a cut."""
|
||||
rate, cut = -net / hrs, None
|
||||
*groups, held = _give_caps(roles, level, by, floor, hrs)
|
||||
for grp in groups:
|
||||
rates, rate = _spread(rate, {n: by[n].rating_kw for n in grp},
|
||||
{n: held[n] for n in grp})
|
||||
for n, v in rates.items():
|
||||
if held[n] > EPS and v > EPS:
|
||||
t = a + timedelta(hours=held[n] / v)
|
||||
if a + MIN_CUT < t < z - MIN_CUT and (cut is None
|
||||
or t < cut):
|
||||
cut = t
|
||||
return cut
|
||||
|
||||
|
||||
def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct,
|
||||
trust=1.0, stats=None):
|
||||
"""`{name: [(t, pct), ...]}` from `start` (each battery's level now) to
|
||||
`end`, a point at every hour edge, every block edge and where a battery
|
||||
reaches its grid level or, giving, the floor. A battery's list
|
||||
stops where `sun_kwh(a, b)` is None (the sun is not known) or its
|
||||
`stop_at` comes. `sun_kwh` and `use_kwh` give kWh for the span `[a, b)`.
|
||||
Spare solar is `trust x max(0, sun - use)`, and what is stored loses
|
||||
`EFF`: `dayplan.plan()`'s own `trust x EFF x max(0, sun - house)`. A
|
||||
shortfall is the house's use less the whole sun. `stats`, a dict or
|
||||
None: gets `floor_cuts` (the floor points placed) and `capped` (True when
|
||||
`MAX_FLOOR_CUTS` stopped one)."""
|
||||
floor, ceil = float(floor_pct), float(ceiling_pct)
|
||||
cuts = {end}
|
||||
for a, _b, _h in _hour_slices(start, end):
|
||||
cuts.add(a)
|
||||
for b in batts:
|
||||
for s, e, *_ in b.grid + b.give:
|
||||
cuts.update(x for x in (s, e) if start < x < end)
|
||||
cuts.add(start)
|
||||
ts = sorted(cuts)
|
||||
level = {b.name: float(b.soc) for b in batts}
|
||||
out = {b.name: [(start, level[b.name])] for b in batts}
|
||||
alive = {b.name for b in batts}
|
||||
floor_cuts, capped = 0, False
|
||||
i = 0
|
||||
while i < len(ts) - 1:
|
||||
a, z = ts[i], ts[i + 1]
|
||||
if not alive:
|
||||
break
|
||||
sun = sun_kwh(a, z)
|
||||
if sun is None:
|
||||
break
|
||||
for b in batts:
|
||||
if b.name in alive and b.stop_at is not None and a >= b.stop_at:
|
||||
alive.discard(b.name)
|
||||
net = sun - use_kwh(a, z)
|
||||
if net > 0.0:
|
||||
net *= trust
|
||||
hrs = (z - a).total_seconds() / 3600.0
|
||||
want = {} # name -> grid target %
|
||||
for b in batts:
|
||||
if b.name in alive:
|
||||
for s, e, to in b.grid:
|
||||
if s <= a < e and level[b.name] < to - EPS:
|
||||
want[b.name] = float(to)
|
||||
by = {b.name: b for b in batts}
|
||||
# A battery that reaches its level inside the step gets a point
|
||||
# there: the step is cut and read again, so the line is exact.
|
||||
cut = None
|
||||
for n, to in want.items():
|
||||
b = by[n]
|
||||
room = (to - level[n]) / 100.0 * b.capacity_kwh
|
||||
rate = b.rating_kw * hrs * EFF
|
||||
if EPS < room < rate * (1.0 - 1e-9):
|
||||
t_r = a + (z - a) * (room / rate)
|
||||
if a < t_r < z and (cut is None or t_r < cut):
|
||||
cut = t_r
|
||||
if cut is not None:
|
||||
ts.insert(i + 1, cut)
|
||||
continue
|
||||
roles = {b.name: _role(b, a, bool(want), b.name in want)
|
||||
for b in batts if b.name in alive}
|
||||
# A battery that gives all it holds inside the step gets a point
|
||||
# where it reaches the floor, the same way.
|
||||
cut = _floor_cut(a, z, net, roles, level, by, floor, hrs) \
|
||||
if net < -EPS else None
|
||||
if cut is not None and floor_cuts >= MAX_FLOOR_CUTS:
|
||||
capped, cut = True, None
|
||||
if cut is not None:
|
||||
floor_cuts += 1
|
||||
ts.insert(i + 1, cut)
|
||||
continue
|
||||
for n, to in want.items(): # charging from the grid
|
||||
b = by[n]
|
||||
room = (to - level[n]) / 100.0 * b.capacity_kwh
|
||||
level[n] += min(b.rating_kw * hrs * EFF, room) \
|
||||
/ b.capacity_kwh * 100.0
|
||||
if net > EPS:
|
||||
caps = {n: min(by[n].rating_kw * hrs,
|
||||
max(0.0, ceil - level[n]) / 100.0
|
||||
* by[n].capacity_kwh / EFF)
|
||||
for n, r in roles.items() if r[1]}
|
||||
first = {n: c for n, c in caps.items() if by[n].solar_first}
|
||||
rest = {n: c for n, c in caps.items() if n not in first}
|
||||
left = net
|
||||
for grp in (first, rest):
|
||||
got, left = _spread(left, grp, dict(grp))
|
||||
for n, v in got.items():
|
||||
level[n] += v * EFF / by[n].capacity_kwh * 100.0
|
||||
elif net < -EPS:
|
||||
first, rest, held = _give_caps(roles, level, by, floor, hrs)
|
||||
left = -net
|
||||
for grp in (first, rest):
|
||||
got, left = _spread(left, grp, {n: held[n] for n in grp})
|
||||
for n, v in got.items():
|
||||
level[n] -= v / EFF / by[n].capacity_kwh * 100.0
|
||||
for b in batts:
|
||||
if b.name in alive:
|
||||
out[b.name].append((z, level[b.name]))
|
||||
i += 1
|
||||
if stats is not None:
|
||||
stats["floor_cuts"] = stats.get("floor_cuts", 0) + floor_cuts
|
||||
stats["capped"] = stats.get("capped", False) or capped
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Measured solar, per local hour
|
||||
# --------------------------------------------------------------------------- #
|
||||
class SolarToday:
|
||||
"""The site's solar power, averaged per local hour of one local day. In
|
||||
memory only: a restart starts it again, and `first` says from when it
|
||||
has counted. Display only."""
|
||||
|
||||
def __init__(self):
|
||||
self.day = None
|
||||
self.first = None
|
||||
self._hours = {} # hour start (UTC) -> [W x s, seconds]
|
||||
self._last = None # (time, W)
|
||||
|
||||
def add(self, now: datetime, w) -> None:
|
||||
"""One reading (`None`: no reading, which ends the interval)."""
|
||||
day = _local(now).date()
|
||||
if day != self.day:
|
||||
self.day, self.first, self._hours, self._last = day, None, {}, None
|
||||
try:
|
||||
ok = w is not None and math.isfinite(float(w))
|
||||
except (TypeError, ValueError):
|
||||
ok = False
|
||||
if not ok:
|
||||
self._last = None
|
||||
return
|
||||
w = max(0.0, float(w))
|
||||
if self.first is None:
|
||||
self.first = now
|
||||
if self._last is not None and now > self._last[0]:
|
||||
dt = min(GAP_S, (now - self._last[0]).total_seconds())
|
||||
key = _hour_start(self._last[0])
|
||||
row = self._hours.setdefault(key, [0.0, 0.0])
|
||||
row[0] += (self._last[1] + w) / 2.0 * dt
|
||||
row[1] += dt
|
||||
self._last = (now, w)
|
||||
|
||||
def kwh(self, start: datetime, end: datetime, now: datetime):
|
||||
"""kWh for the hour `[start, end)` up to `now`: the mean power it was
|
||||
read at over the part of the hour that has passed, or None when the
|
||||
readings cover under half of that part (or none has passed)."""
|
||||
if self.day != _local(now).date():
|
||||
return None
|
||||
over = (min(end, now) - start).total_seconds()
|
||||
row = self._hours.get(start)
|
||||
if over <= 0 or row is None or row[1] < MIN_COVER * over:
|
||||
return None
|
||||
return row[0] / row[1] * over / 3.6e6
|
||||
|
||||
|
||||
def _hour_start(t: datetime) -> datetime:
|
||||
"""The start of `t`'s local hour, in UTC. Taken back from the UTC instant,
|
||||
never from the wall clock, so each of the two 02:00 hours of the autumn
|
||||
change has its own (`dayplan._hour_slices()`, SAFETY-123)."""
|
||||
lt = _local(t)
|
||||
return t.astimezone(timezone.utc) - timedelta(
|
||||
minutes=lt.minute, seconds=lt.second, microseconds=lt.microsecond)
|
||||
@@ -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,21 +165,53 @@ 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):
|
||||
"""Local-hour slices of [t0, t1): (start, end, local hour)."""
|
||||
"""Local-hour slices of [t0, t1): (start, end, local hour).
|
||||
|
||||
Steps in UTC, so `t` always advances (SAFETY-123, DECISIONS)."""
|
||||
out, t = [], t0
|
||||
while t < t1:
|
||||
lt = _local(t)
|
||||
nxt = (lt.replace(minute=0, second=0, microsecond=0)
|
||||
+ timedelta(hours=1)).astimezone(timezone.utc)
|
||||
e = min(nxt, t1)
|
||||
left = timedelta(minutes=60 - lt.minute) - timedelta(
|
||||
seconds=lt.second, microseconds=lt.microsecond)
|
||||
e = min(t.astimezone(timezone.utc) + left, t1)
|
||||
out.append((t, e, lt.hour))
|
||||
t = e
|
||||
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
|
||||
@@ -192,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
|
||||
@@ -210,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()):
|
||||
@@ -241,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:
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""UX-54: the Forecast tab, built on request (`GET forecast`). Reads only.
|
||||
|
||||
Two days, today and tomorrow, per local hour: the solar forecast, the solar
|
||||
measured so far, the house's expected use, and each battery's expected level
|
||||
with its blocks.
|
||||
|
||||
It follows the add-on, as Home does (UX-53), and decides nothing:
|
||||
* the blocks are Home's own: today's are the `runs` of `/status`'s plan, and
|
||||
tomorrow's come from the same builders (`homeview.runs_for()`), so a block
|
||||
is on the tab exactly where the plan has one, with the plan's words;
|
||||
* the sun is `solarfc.Forecast.between()` and the house's use is
|
||||
`dayplan.HouseProfile.estimator()` read as `dayplan.plan()` reads it;
|
||||
* the level is `daycurve.curve()`, fed the blocks, the give runs, the packs
|
||||
and the plan's levels (`modetrade.charge_to()`), floor and ceiling.
|
||||
A part that is not known yet is a plain note (`home_words.day`), never an
|
||||
empty chart. DECISIONS/UX-54-forecast-tab.md has the record.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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 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 %
|
||||
|
||||
|
||||
def _hours_between(a, b) -> float:
|
||||
return (b - a).total_seconds() / 3600.0
|
||||
|
||||
|
||||
def build(c, status: dict, now: datetime, lang: str = "en") -> dict:
|
||||
"""The tab's JSON. `status` is the `/status` payload just built, whose
|
||||
plan holds today's blocks."""
|
||||
with hv.in_lang(lang):
|
||||
return _build(c, status, now)
|
||||
|
||||
|
||||
def sun_kwh(fc, now, a, b):
|
||||
"""kWh the forecast gives in `[a, b)`, `a` at or after `now`, or None.
|
||||
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):
|
||||
return (datetime.fromisoformat(run["start"]),
|
||||
datetime.fromisoformat(run["end"]))
|
||||
|
||||
|
||||
def _build(c, status, now):
|
||||
plan, rows = status.get("plan"), status.get("batteries")
|
||||
if not plan or rows is None:
|
||||
return {"ok": False}
|
||||
d0, d1 = hv.local_day(now)
|
||||
n0, n1 = hv.local_day(d1 + timedelta(hours=2))
|
||||
shadow = bool(c.shadow)
|
||||
key = plan.get("verdict_key")
|
||||
running = key not in ("held", "stopped")
|
||||
modes_on = bool(getattr(c, "modes_on", False))
|
||||
floor, ceil = arbitrage.soc_bounds(c.o)
|
||||
fc = getattr(c, "solar_fc", None)
|
||||
hp = getattr(c, "house_profile", None)
|
||||
est = hp.estimator() if hp is not None and fc is not None else None
|
||||
trust = max(0.0, min(1.0, float(getattr(c, "solar_trust_pct", 70))
|
||||
/ 100.0))
|
||||
|
||||
# Why a part is not known (None: nothing is missing).
|
||||
missing = ("modes_off" if not modes_on
|
||||
else "not_set" if c.fc_entity is None
|
||||
else "no_forecast" if fc is None
|
||||
else "learning" if est is None else None)
|
||||
|
||||
# The blocks of each day: today's from Home, tomorrow's by its builders.
|
||||
# Tomorrow has not started, so the level now says nothing about it: the
|
||||
# builders read each block at the level it starts at (`ahead`), with
|
||||
# their own room and energy filter. With modes on that level is the
|
||||
# curve's (`_levels()`); with modes off there is no curve, and it is
|
||||
# Home's own count (`_counted()`).
|
||||
runs = [plan["runs"], []]
|
||||
if running and not modes_on:
|
||||
runs[1] = _off_tomorrow(c, runs[0], now, shadow, n0, n1)
|
||||
elif running:
|
||||
runs[1] = hv.runs_for(c, n0, n1, now, shadow, ahead=True)
|
||||
|
||||
# The level curve of every battery that is on and known. It also makes
|
||||
# tomorrow's blocks again at the level each has when its run starts, and
|
||||
# draws a grid block as long as control keeps charging (`_levels()`).
|
||||
notes_b = {}
|
||||
levels = {}
|
||||
if running and missing is None:
|
||||
levels, runs = _levels(c, est, fc, trust, now, n1, runs, floor, ceil,
|
||||
notes_b, shadow, n0, n1)
|
||||
blocks = [[[r for r in rs if r["battery"] == i and r["kind"] in BLOCKS]
|
||||
for i in range(len(c.inverters))] for rs in runs]
|
||||
# Prices are planned about a day ahead (`priceruns.HORIZON`), so tomorrow's
|
||||
# blocks may be missing now and appear later.
|
||||
priced = bool(plan.get("has_prices")) and (
|
||||
any(c.pack_mode(i).use and c.pack_mode(i).mode in hv.PRICE_MODES
|
||||
for i in c.inverters) if modes_on else arbitrage.enabled(c.o))
|
||||
days = []
|
||||
for k, (a, z) in enumerate(((d0, d1), (n0, n1))):
|
||||
days.append(_day(c, k, a, z, now, fc, est, trust, levels, blocks[k],
|
||||
rows, shadow, running, key, missing, notes_b, status,
|
||||
priced))
|
||||
return {"ok": True, "running": running, "shadow": shadow,
|
||||
"now_h": round(_hours_between(d0, now), 3),
|
||||
"updated": None if fc is None else hv.hhmm(fc.at),
|
||||
"days": days}
|
||||
|
||||
|
||||
def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow,
|
||||
n0, n1):
|
||||
"""`({name: [(t, pct), ...]}, runs)`: `daycurve.curve()` for the batteries
|
||||
that are on, have a level and are not in their monthly cycle, and `runs`
|
||||
with tomorrow's blocks made as control will meet them.
|
||||
|
||||
Tomorrow's blocks are `homeview.mode_runs()`'s, read at the level the
|
||||
curve has when each block starts (its `ahead` function), so its own room
|
||||
and energy filter and `MIN_KWH` decide: no give block where the battery
|
||||
is at the floor when the run starts, no grid block where it is at its
|
||||
planned level. The curve in turn follows the blocks, so the two are taken
|
||||
to a fixed point.
|
||||
|
||||
A cheap run is sized for all the batteries charging together, so a slower
|
||||
one is still below `modetrade.charge_to()` when it ends, and control then
|
||||
plans the next cheap slots (`Controller._price_runs()`). That is asked of
|
||||
the same functions, `modetrade.energy()` and `priceruns.plan()`, with the
|
||||
packs at the curve's level, and the block is drawn to where control
|
||||
stops charging. Today's blocks are Home's, as they are."""
|
||||
packs = {p.name: p for p in c._mode_packs()}
|
||||
targets = c.plan_targets()
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
plan_on = rp is not None and rp.on
|
||||
house_w, base_w = est
|
||||
|
||||
def make(rs):
|
||||
batts = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
pm = c.pack_mode(inv)
|
||||
if not pm.use:
|
||||
continue
|
||||
if inv.soc is None or not inv.capacity_kwh:
|
||||
notes_b[inv.name] = hv.day_note("no_level", name=inv.name)
|
||||
continue
|
||||
mine = [r for day in rs for r in day
|
||||
if r["battery"] == i and r["kind"] in BLOCKS]
|
||||
cyc = [_times(r)[0] for r in mine if r["kind"] == "cycle"]
|
||||
if cyc and min(cyc) <= now:
|
||||
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
|
||||
continue
|
||||
if cyc:
|
||||
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
|
||||
to = modetrade.charge_to(packs[inv.name], ceil, targets)
|
||||
grid = tuple((*_times(r), to) for r in mine
|
||||
if r["kind"] == "grid" and to is not None)
|
||||
priced = plan_on and pm.mode != SOLAR_FIRST
|
||||
give = tuple((g.start, g.end) for g in rp.give) if priced else ()
|
||||
batts.append(Batt(inv.name, float(inv.capacity_kwh),
|
||||
inv.max_w / 1000.0, float(inv.soc),
|
||||
pm.mode == SOLAR_FIRST, priced, grid, give,
|
||||
min(cyc) if cyc else None))
|
||||
return batts
|
||||
|
||||
def sun(a, z):
|
||||
return sun_kwh(fc, now, a, z)
|
||||
|
||||
def use(a, z):
|
||||
return sum(max(house_w(h), base_w) * _hours_between(s, e) / 1000.0
|
||||
for s, e, h in dayplan._hour_slices(a, z))
|
||||
|
||||
def run(rs):
|
||||
batts = make(rs)
|
||||
return curve(batts, now, end, sun, use, floor_pct=floor,
|
||||
ceiling_pct=ceil, trust=trust) if batts else {}
|
||||
|
||||
def settle(ext, lv):
|
||||
"""Tomorrow's blocks read at the levels of `lv`, and the curve they
|
||||
make, again until the blocks stop changing."""
|
||||
seen = None
|
||||
for _ in range(4):
|
||||
def at(name, _kind, start, lv=lv):
|
||||
return _level_at(lv.get(name, ()), start)
|
||||
rt = hv.runs_for(c, n0, n1, now, shadow, ahead=at, rp=ext)
|
||||
lv = run([runs[0], rt])
|
||||
sig = [(r["battery"], r["kind"], r["start"], r["end"])
|
||||
for r in rt]
|
||||
if sig == seen:
|
||||
break
|
||||
seen = sig
|
||||
return rt, lv
|
||||
|
||||
lv0 = run([runs[0], []])
|
||||
ext = rp
|
||||
plan = c.fresh_tariff_plan(now)
|
||||
cheap = None if rp is None else rp.cheap
|
||||
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)
|
||||
| arbitrage.weather_uncertainty(True, c.solar_fc))
|
||||
end_t = cheap.end
|
||||
for _ in range(24):
|
||||
ext = dataclasses.replace(rp, cheap=priceruns.Run(
|
||||
cheap.start, end_t, cheap.mean_eur_kwh))
|
||||
rt, lv = settle(ext, lv0)
|
||||
at_end = [dataclasses.replace(packs[n], soc=v)
|
||||
for n, v in ((n, _level_at(lv.get(n, ()), end_t))
|
||||
for n in lv) if v is not None]
|
||||
e = modetrade.energy(at_end, floor, ceil, targets=targets)
|
||||
if e[0] <= 1e-6:
|
||||
break
|
||||
new = priceruns.plan(
|
||||
plan.read.points, end_t, required_eur_kwh=required,
|
||||
charge_kwh=e[0], charge_kw=e[1], sell_kwh=0.0, sell_kw=0.0)
|
||||
nxt = new.cheap
|
||||
if nxt is None or not new.charge_useful() or nxt.start > end_t \
|
||||
or nxt.end <= end_t:
|
||||
break
|
||||
end_t = nxt.end
|
||||
ext = dataclasses.replace(rp, cheap=priceruns.Run(
|
||||
cheap.start, end_t, cheap.mean_eur_kwh))
|
||||
rt, lv = settle(ext, lv0)
|
||||
# A run is for all the batteries charging together; each stops when it is
|
||||
# at its own level (control's `chargers()`), so its block ends there.
|
||||
names = [inv.name for inv in c.inverters]
|
||||
rt = [_stops(r, names, lv, packs, targets, floor, ceil, n0) for r in rt]
|
||||
return lv, [runs[0], rt]
|
||||
|
||||
|
||||
def _off_tomorrow(c, today, now, shadow, n0, n1):
|
||||
"""Modes off: tomorrow's blocks by `homeview.trade_runs()`, each read at
|
||||
the level `_counted()` gives when it starts. A charge may come before a
|
||||
give, so this repeats until the blocks stop changing."""
|
||||
sig, rt = None, []
|
||||
for _ in range(4):
|
||||
rt = hv.runs_for(c, n0, n1, now, shadow,
|
||||
ahead=_counted(c, [today, rt]))
|
||||
new = [(r["battery"], r["kind"], r["start"], r["end"]) for r in rt]
|
||||
if new == sig:
|
||||
break
|
||||
sig = new
|
||||
return rt
|
||||
|
||||
|
||||
def _counted(c, rs):
|
||||
"""Modes off reads no forecast and keeps no house profile, so there is no
|
||||
curve. A give is read at Home's own count (`trade_runs()`): the level now,
|
||||
plus what the blocks in `rs` that start before it put in, less what they
|
||||
take out. The house can only take from that and no sun is read, so a
|
||||
battery at the floor by the count is at the floor then. A charge is read
|
||||
from the floor (None): the house uses some of a full battery before the
|
||||
cheap hour, and control charges it back then."""
|
||||
_floor, ceil = arbitrage.soc_bounds(c.o)
|
||||
|
||||
def at(name, kind, start):
|
||||
i = next(k for k, inv in enumerate(c.inverters) if inv.name == name)
|
||||
inv = c.inverters[i]
|
||||
if kind == "grid" or inv.soc is None or not inv.capacity_kwh:
|
||||
return None
|
||||
v = float(inv.soc)
|
||||
mine = sorted((r for day in rs for r in day if r["battery"] == i
|
||||
and r["kind"] in ("grid", "give")
|
||||
and r.get("kwh") is not None
|
||||
and _times(r)[0] < start),
|
||||
key=lambda r: _times(r)[0])
|
||||
for r in mine:
|
||||
d = float(r["kwh"]) / float(inv.capacity_kwh) * 100.0
|
||||
v = min(max(v, ceil), v + d) if r["kind"] == "grid" else v - d
|
||||
return v
|
||||
return at
|
||||
|
||||
|
||||
def _stops(r, names, lv, packs, targets, floor, ceil, day0):
|
||||
"""`r` ended where its battery stops, if that is before the run ends: a
|
||||
grid block where the curve has it at the level `modetrade.charge_to()`
|
||||
allows (the curve has a point there), a give block at the first point
|
||||
where the level the tab shows (`LEVEL_DP`) is at the floor. A run is for
|
||||
all the batteries together; each leaves it on its own."""
|
||||
if r["kind"] not in ("grid", "give"):
|
||||
return r
|
||||
name = names[r["battery"]]
|
||||
start, end = _times(r)
|
||||
if r["kind"] == "grid":
|
||||
to = modetrade.charge_to(packs[name], ceil, targets)
|
||||
if to is None:
|
||||
return r
|
||||
hit = lambda v: v >= to - 1e-6 # noqa: E731
|
||||
else:
|
||||
hit = lambda v: round(v, LEVEL_DP) <= floor # noqa: E731
|
||||
done = next((t for t, v in lv.get(name, ()) if start < t <= end
|
||||
and hit(v)), None)
|
||||
if done is None or done >= end:
|
||||
return r
|
||||
return dict(r, end=done.isoformat(), to=hv.hhmm(done),
|
||||
end_h=round(_hours_between(day0, done), 3))
|
||||
|
||||
|
||||
def _level_at(pts, t):
|
||||
"""The curve's level at `t` (between two points, straight), or None when
|
||||
the curve does not reach `t`."""
|
||||
for (t0, v0), (t1, v1) in zip(pts, pts[1:]):
|
||||
if t0 <= t <= t1:
|
||||
span = (t1 - t0).total_seconds()
|
||||
return v0 if span <= 0 else v0 + (v1 - v0) * (
|
||||
(t - t0).total_seconds() / span)
|
||||
return None
|
||||
|
||||
|
||||
def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
|
||||
running, key, missing, notes_b, status, priced):
|
||||
day = (fc.today, fc.tomorrow)[k] if fc is not None else None
|
||||
solar = getattr(c, "solar_today", None)
|
||||
names = [inv.name for inv in c.inverters]
|
||||
hours = []
|
||||
for s, e, h in dayplan._hour_slices(d0, d1):
|
||||
f = None
|
||||
if fc is not None and e > now and (k == 0 or fc.tomorrow is not None):
|
||||
f = sun_kwh(fc, now, max(s, now), e)
|
||||
got = solar.kwh(s, e, now) if k == 0 and solar is not None \
|
||||
and s < now else None
|
||||
use = None if house is None else max(house[0](h), house[1]) \
|
||||
* _hours_between(s, e) / 1000.0
|
||||
row = {"start_h": round(_hours_between(d0, s), 3),
|
||||
"end_h": round(_hours_between(d0, e), 3), "label": hv.hhmm(s),
|
||||
"forecast_kwh": None if f is None else round(f, 3),
|
||||
"measured_kwh": None if got is None else round(got, 3),
|
||||
"house_kwh": None if use is None else round(use, 3),
|
||||
"levels": []}
|
||||
hours.append((s, row))
|
||||
batts = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
row = rows[i] if i < len(rows) else {}
|
||||
pts = [(t, v) for t, v in levels.get(inv.name, ()) if d0 <= t <= d1]
|
||||
mine = blocks[i]
|
||||
batts.append({
|
||||
"name": inv.name, "capacity_kwh": inv.capacity_kwh,
|
||||
"off": row.get("use") is False,
|
||||
"levels": [[round(_hours_between(d0, t), 3), round(v, LEVEL_DP)]
|
||||
for t, v in pts],
|
||||
"blocks": [{x: r[x] for x in ("kind", "from", "to", "start_h",
|
||||
"end_h", "what", "why", "amount")}
|
||||
for r in mine],
|
||||
"line": _line(c, inv, row, mine, running, key, shadow, d0, d1,
|
||||
now)})
|
||||
at = {t: v for t, v in pts}
|
||||
for s, hr in hours:
|
||||
hr["levels"].append(None if s not in at
|
||||
else round(at[s], LEVEL_DP))
|
||||
has_levels = any(b["levels"] for b in batts)
|
||||
fcs = [h["forecast_kwh"] for _s, h in hours]
|
||||
notes = []
|
||||
if missing is not None:
|
||||
notes.append(hv.day_note(missing))
|
||||
if not running:
|
||||
notes.append(hv.why_words(key))
|
||||
if shadow:
|
||||
notes.append(hv.why_words("shadow"))
|
||||
line = ""
|
||||
if fc is not None:
|
||||
if k == 0:
|
||||
line = hv.line_solar(fc.today.kwh, fc.today.kwh * trust,
|
||||
hv.hhmm(fc.at))
|
||||
elif fc.tomorrow is not None:
|
||||
line = hv.day_note("tomorrow_total",
|
||||
kwh=hv._kwh(fc.tomorrow.kwh),
|
||||
count=hv._kwh(fc.tomorrow.kwh * trust))
|
||||
if day is not None and not day.periods:
|
||||
notes.append(hv.day_note("estimate"))
|
||||
if k == 1 and fc.tomorrow is None:
|
||||
notes.append(hv.day_note("no_tomorrow", at=_sun_from()))
|
||||
if k == 0 and solar is not None and solar.first is not None \
|
||||
and solar.day == brussels(now).date() \
|
||||
and _hours_between(d0, solar.first) > 0.5:
|
||||
notes.append(hv.day_note("measured_from", at=hv.hhmm(solar.first)))
|
||||
if has_levels:
|
||||
notes.append(hv.day_note("trust", pct=round(trust * 100)))
|
||||
if k == 1 and _tomorrow_line(status):
|
||||
notes.append(_tomorrow_line(status))
|
||||
if k == 1 and running and priced:
|
||||
notes.append(hv.day_note("ahead"))
|
||||
notes += [notes_b[n] for n in names if n in notes_b]
|
||||
return {"key": ("today", "tomorrow")[k], "date": brussels(d0).date()
|
||||
.isoformat(),
|
||||
"length_h": int(round(_hours_between(d0, d1))),
|
||||
"line": line, "notes": list(dict.fromkeys(notes)),
|
||||
"sun": {"forecast": any(x is not None for x in fcs),
|
||||
"estimate": day is not None and not day.periods,
|
||||
"measured": any(h["measured_kwh"] is not None
|
||||
for _s, h in hours),
|
||||
"house": house is not None},
|
||||
"hours": [h for _s, h in hours], "batteries": batts}
|
||||
|
||||
|
||||
def _sun_from() -> str:
|
||||
"""When the sun of a day starts for `solarfc` (its bell's first hour):
|
||||
with no forecast for tomorrow the sun is unknown from then, and so the
|
||||
curve stops there (`Forecast.between()` answers None past it)."""
|
||||
return f"{int(solarfc.BELL_FROM_H):02d}:00"
|
||||
|
||||
|
||||
def _tomorrow_line(status) -> str:
|
||||
"""Home's own line about tomorrow's prices, for the tomorrow tab."""
|
||||
return (status.get("plan") or {}).get("tomorrow_line") or ""
|
||||
|
||||
|
||||
def _give_planned(c, d0, d1, now) -> bool:
|
||||
"""Does the add-on plan a give in `[d0, d1)` not yet over? Modes on: a
|
||||
give run of the run plan. Modes off: a give leg of the verdict that the
|
||||
gate takes (`homeview.trade_legs()`), at a price that is not negative."""
|
||||
if not getattr(c, "modes_on", False):
|
||||
plan = c.fresh_tariff_plan(now)
|
||||
verdict, _why = hv.trade_verdict(c, plan, now)
|
||||
return verdict is not None and any(
|
||||
k == "give" and pt.eur_per_kwh >= 0 and pt.start < d1
|
||||
and hv._point_end(plan, pt) > max(now, d0)
|
||||
for k, pt in hv.trade_legs(c, plan, verdict, now))
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
return rp is not None and rp.on and any(
|
||||
g.end > max(now, d0) and g.start < d1 for g in rp.give)
|
||||
|
||||
|
||||
def _line(c, inv, row, mine, running, key, shadow, d0, d1, now) -> str:
|
||||
"""What to say for a battery with no block: the plan's own words. Home
|
||||
draws no give block for a battery with no energy to give, so a give that
|
||||
the add-on plans with no block gets its own sentence."""
|
||||
if not running:
|
||||
return hv.why_words(key)
|
||||
if row.get("use") is False:
|
||||
return hv.why_words("off")
|
||||
modes_on = getattr(c, "modes_on", False)
|
||||
gives = not modes_on or row.get("mode_choice") in hv.PRICE_MODES
|
||||
if gives and not any(r["kind"] == "give" for r in mine) \
|
||||
and _give_planned(c, d0, d1, now):
|
||||
return hv.day_note("give_later")
|
||||
if mine:
|
||||
return ""
|
||||
if modes_on and gives:
|
||||
return hv.day_note("no_blocks")
|
||||
return hv.run_house_what(shadow) + " " + hv.run_house_why(shadow)
|
||||
@@ -16,13 +16,15 @@ them, so nothing here invents a plan. Every sentence is in the words
|
||||
section at the bottom and is held to STYLE.md by test_messages.py.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import arbitrage, messages, peakguard, uitext
|
||||
from .dayplan import EFF
|
||||
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
|
||||
from .maintenance import IDLE as M_IDLE, next_slot_start
|
||||
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .price import Absence, Uncertainty, brussels, _cover_table
|
||||
from .price import Absence, brussels, _cover_table
|
||||
from .static_tariff import static_plan
|
||||
from .tariff import BILLING_TZ
|
||||
from .uimodel import REGION_PRESETS, options_to_ui
|
||||
@@ -212,25 +214,26 @@ def _price_mode_in_use(c):
|
||||
return False
|
||||
|
||||
|
||||
def price_windows(c, plan, verdict):
|
||||
def price_windows(c, plan, verdict, now=None):
|
||||
"""`[(kind, start, end)]`: where the add-on itself buys ("cheap") and
|
||||
gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan`
|
||||
(`c.mode_runs`): the cheap run when charging in it pays, and every give
|
||||
run. With them off it is the arbitrage verdict's two points, the ones
|
||||
`trade_runs()` draws. Nothing is classified here."""
|
||||
(`c.mode_runs`): the cheap run when charging in it pays and the trade gate
|
||||
allows it (UX-53), and every give run. With them off it is the arbitrage
|
||||
verdict's legs the add-on will take, the ones `trade_runs()` draws
|
||||
(`trade_legs()`, given `now`). Nothing is classified here."""
|
||||
if getattr(c, "modes_on", False):
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
if rp is None or not rp.on or not _price_mode_in_use(c):
|
||||
return []
|
||||
out = [("cheap", rp.cheap.start, rp.cheap.end)] \
|
||||
if rp.charge_useful() else []
|
||||
if rp.charge_useful() and getattr(c, "mode_gate", None) is None \
|
||||
else []
|
||||
return out + [("dear", g.start, g.end) for g in rp.give]
|
||||
if verdict is None:
|
||||
return []
|
||||
return [("cheap", verdict.cheapest.start,
|
||||
_point_end(plan, verdict.cheapest)),
|
||||
("dear", verdict.dearest.start,
|
||||
_point_end(plan, verdict.dearest))]
|
||||
return [("cheap" if kind == "grid" else "dear", pt.start,
|
||||
_point_end(plan, pt))
|
||||
for kind, pt in trade_legs(c, plan, verdict, now)]
|
||||
|
||||
|
||||
def day_range(points):
|
||||
@@ -262,11 +265,12 @@ def _run(i, kind, start, end, day0, day1, what, why, amount, *, kwh=None,
|
||||
"eur_kwh": None if price is None else round(price, 4)}
|
||||
|
||||
|
||||
def trade_verdict(c, plan):
|
||||
def trade_verdict(c, plan, now):
|
||||
"""The arbitrage verdict the control cycle would reach on this plan, or a
|
||||
reason there is none. The SAME call and inputs as `Controller.cycle()`:
|
||||
`plan.arbitrage()` with the peak guard's state, the cycle cost and the
|
||||
no-weather uncertainty. Returns `(verdict_or_None, why_key)`."""
|
||||
no-weather uncertainty, and (PRICE-02) the same `now`. Returns
|
||||
`(verdict_or_None, why_key)`."""
|
||||
if not arbitrage.enabled(c.o):
|
||||
return None, "off"
|
||||
guard = arbitrage.guard_state(c.peak_guard)
|
||||
@@ -276,20 +280,28 @@ def trade_verdict(c, plan):
|
||||
return None, "no_prices"
|
||||
verdict = plan.arbitrage(peak_guard_active=guard,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}))
|
||||
uncertainty=arbitrage.weather_uncertainty(
|
||||
bool(getattr(c, "modes_on", False)),
|
||||
getattr(c, "solar_fc", None),
|
||||
bool(getattr(c, "weather_held",
|
||||
lambda _n: False)(now))),
|
||||
now=now)
|
||||
return verdict.match(
|
||||
allowed=lambda v: (v, "allowed"),
|
||||
refused=lambda _v: (None, refusal_key(plan, guard)))
|
||||
refused=lambda _v: (None, refusal_key(plan, guard, now)))
|
||||
|
||||
|
||||
def refusal_key(plan, guard):
|
||||
def refusal_key(plan, guard, now):
|
||||
"""Which of `TariffPlan.arbitrage()`'s refusals fired, in its own order:
|
||||
the peak guard, no prices, under two published points, then the gap."""
|
||||
if guard:
|
||||
return "peak"
|
||||
if not plan.has_prices:
|
||||
return "no_prices"
|
||||
if sum(1 for p in _points(plan) if not p.provisional) < 2:
|
||||
pts = _points(plan)
|
||||
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
|
||||
pts = [p for p in pts if id(p) in ahead]
|
||||
if sum(1 for p in pts if not p.provisional) < 2:
|
||||
return "not_firm"
|
||||
return "not_worth"
|
||||
|
||||
@@ -301,32 +313,65 @@ def _point_end(plan, point):
|
||||
return point.start + timedelta(hours=1)
|
||||
|
||||
|
||||
def leg_taken(c, plan, pt, now):
|
||||
"""UX-53: will the add-on act on this point of the verdict? One in
|
||||
progress is the verdict's own answer now. One still ahead is asked of the
|
||||
same gate (`trade_verdict()`, the call the control cycle makes) at the
|
||||
point's own start. A charge that comes after the last dear period meets a
|
||||
flat remainder there and is refused, so it is not announced. When the gate
|
||||
allows it, the point is still the cheapest (dearest) one: the points
|
||||
before it are gone and it was the first extreme of the points from now."""
|
||||
if now is None or pt.start <= now:
|
||||
return True
|
||||
return trade_verdict(c, plan, pt.start)[0] is not None
|
||||
|
||||
|
||||
def trade_legs(c, plan, verdict, now):
|
||||
"""`[(kind, point)]` by start: the verdict's charge ("grid") and give
|
||||
("give") legs that the add-on will take (`leg_taken()`)."""
|
||||
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
|
||||
key=lambda kp: kp[1].start)
|
||||
return [(k, pt) for k, pt in legs if leg_taken(c, plan, pt, now)]
|
||||
|
||||
|
||||
# Under this many kWh a block is not drawn: the cycle would not move it.
|
||||
MIN_KWH = 0.05
|
||||
|
||||
|
||||
def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
|
||||
"""A charge block and a give block on each battery, where the verdict put
|
||||
them. The site trades as one; each battery's share is capped by its own
|
||||
rating and by the room its level leaves (arbitrage's own floor/ceiling).
|
||||
Only what `arbitrage.decide()` would still do is drawn: no block on a
|
||||
battery with no room, no sale at a negative price, and no sale while the
|
||||
emptiest battery is at the floor (the cycle's `soc` is the emptiest).
|
||||
With a level unknown the cycle does not trade, so nothing is drawn."""
|
||||
With a level unknown the cycle does not trade, so nothing is drawn.
|
||||
UX-54 `ahead`: the window has not started, so the level now says nothing
|
||||
about it. The same rules then read each battery at the level a leg starts
|
||||
at: `ahead(name, kind, start)`, a level in % or None, as in `mode_runs()`;
|
||||
for None (and for `ahead=True`) a charge is read from the floor and a give
|
||||
from the ceiling (the most it could be). The leg itself is still the
|
||||
gate's (`trade_legs()`). Home never passes it."""
|
||||
floor, ceiling = arbitrage.soc_bounds(c.o)
|
||||
if any(inv.soc is None for inv in c.inverters):
|
||||
if not ahead and any(inv.soc is None for inv in c.inverters):
|
||||
return [], [], "no_level"
|
||||
level = [float(inv.soc) for inv in c.inverters]
|
||||
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
|
||||
key=lambda kp: kp[1].start)
|
||||
level = [] if ahead else [float(inv.soc) for inv in c.inverters]
|
||||
|
||||
def start_pct(kind, start):
|
||||
got = [ahead(inv.name, kind, start) if callable(ahead) else None
|
||||
for inv in c.inverters]
|
||||
return [(floor if kind == "grid" else ceiling) if v is None
|
||||
else float(v) for v in got]
|
||||
runs, later = [], []
|
||||
for kind, pt in legs:
|
||||
for kind, pt in trade_legs(c, plan, verdict, now):
|
||||
end = _point_end(plan, pt)
|
||||
if end <= now:
|
||||
continue
|
||||
if not (day0 <= pt.start < day1):
|
||||
later.append((kind, pt))
|
||||
continue
|
||||
if ahead:
|
||||
level = start_pct(kind, pt.start)
|
||||
if kind == "give" and (pt.eur_per_kwh < 0 or min(level) <= floor):
|
||||
continue
|
||||
hrs = (end - pt.start).total_seconds() / 3600.0
|
||||
@@ -341,7 +386,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
kwh = min(kwh, max(0.0, room) / 100.0 * cap)
|
||||
# A later give may use what this charge put in: an upper
|
||||
# bound, like every amount here ("up to").
|
||||
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
|
||||
if not ahead:
|
||||
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
|
||||
if kwh < MIN_KWH:
|
||||
continue
|
||||
eur = kwh * pt.eur_per_kwh
|
||||
@@ -361,14 +407,22 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
|
||||
|
||||
def mode_runs(c, day0, day1, now, shadow):
|
||||
def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
"""OPT-11 P3: each battery's price runs today, as the control cycle keeps
|
||||
them (`c.mode_runs`): the cheap run on the batteries that may charge
|
||||
from the grid, the give runs on Leftover solar and Cheap hours, and the
|
||||
sell run on those with selling on. Amounts are upper bounds ("up to"),
|
||||
from the battery's own room, rating and the run's length. Returns
|
||||
`(runs, no_trade_key)`."""
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
from the battery's own room, rating and the run's length. UX-53: no grid
|
||||
charge is drawn while the trade gate refuses one (`c.mode_gate`, what the
|
||||
control cycle found). Returns `(runs, no_trade_key)`.
|
||||
UX-54 `ahead`: the window has not started, so the level now says nothing
|
||||
about it. The room and energy filter (`room()` and `plan_need()` below)
|
||||
then reads the level a block starts at: `ahead(name, kind, start)`, a
|
||||
function giving the expected level in % or None, or for None (and for
|
||||
`ahead=True`) a charge from the floor and a give from the ceiling. The
|
||||
same filter, the same `MIN_KWH`. `rp`: a run plan to read instead of
|
||||
`c.mode_runs`. Home never passes either."""
|
||||
rp = rp if rp is not None else getattr(c, "mode_runs", None)
|
||||
if getattr(c, "mode_tick", None) is None:
|
||||
return [], None # no cycle yet: nothing is known
|
||||
if rp is None:
|
||||
@@ -376,6 +430,7 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
if not rp.on:
|
||||
return [], "not_worth"
|
||||
floor, ceiling = arbitrage.soc_bounds(c.o)
|
||||
gate = getattr(c, "mode_gate", None)
|
||||
out = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
pm = c.pack_mode(inv)
|
||||
@@ -383,17 +438,26 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
continue
|
||||
soc, cap = inv.soc, inv.capacity_kwh
|
||||
|
||||
def room(kind, hrs):
|
||||
def start_pct(kind, start):
|
||||
"""The level a block of `kind` is read at when `ahead`."""
|
||||
got = ahead(inv.name, kind, start) if callable(ahead) else None
|
||||
return got if got is not None else (
|
||||
floor if kind == "grid" else ceiling)
|
||||
|
||||
def room(kind, hrs, start):
|
||||
kwh = inv.max_w / 1000.0 * hrs
|
||||
if cap and soc is not None:
|
||||
kwh = min(kwh, max(0.0, (ceiling - soc) if kind == "grid"
|
||||
else (soc - floor)) / 100.0 * cap)
|
||||
lvl = start_pct(kind, start) if ahead else soc
|
||||
if cap and lvl is not None:
|
||||
kwh = min(kwh, max(0.0, (ceiling - lvl) if kind == "grid"
|
||||
else (lvl - floor)) / 100.0 * cap)
|
||||
return kwh
|
||||
blocks = []
|
||||
# P4: with a day plan, the grid block is the planned shortfall.
|
||||
need = plan_need(c, inv.name)
|
||||
if rp.charge_useful() and (need > 0.0 if need is not None
|
||||
else pm.mode in GRID_CHARGE_MODES):
|
||||
need = plan_need(c, inv.name, start_pct("grid", rp.cheap.start)
|
||||
if ahead and rp.cheap is not None else None)
|
||||
if gate is None and rp.charge_useful() and (
|
||||
need > 0.0 if need is not None
|
||||
else pm.mode in GRID_CHARGE_MODES):
|
||||
blocks.append(("grid", rp.cheap))
|
||||
sell = rp.sell if pm.sell and pm.can_sell else None
|
||||
for g in rp.give:
|
||||
@@ -405,16 +469,22 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
if run.end <= now or not (run.start < day1 and run.end > day0):
|
||||
continue
|
||||
hrs = (run.end - run.start).total_seconds() / 3600.0
|
||||
kwh = room(kind, hrs)
|
||||
kwh = room(kind, hrs, run.start)
|
||||
if kind == "grid" and need is not None:
|
||||
kwh = min(kwh, need)
|
||||
if kwh < MIN_KWH:
|
||||
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).
|
||||
@@ -432,19 +502,39 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
return out, None
|
||||
|
||||
|
||||
def plan_need(c, name):
|
||||
def plan_need(c, name, soc=None):
|
||||
"""P4: kWh this battery buys from the grid by the day plan, or None when
|
||||
there is no plan for it (then P3 option A applies)."""
|
||||
there is no plan for it (then P3 option A applies). `soc`: UX-54, a level
|
||||
to read it from instead of the one now (`mode_runs(ahead=True)`)."""
|
||||
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
|
||||
if not targets or name not in targets:
|
||||
return None
|
||||
inv = next((i for i in c.inverters if i.name == name), None)
|
||||
if inv is None or inv.soc is None or not inv.capacity_kwh:
|
||||
at = None if inv is None else (inv.soc if soc is None else soc)
|
||||
if inv is None or at is None or not inv.capacity_kwh:
|
||||
return None
|
||||
return max(0.0, targets[name] - float(inv.soc)) / 100.0 * float(
|
||||
return max(0.0, targets[name] - float(at)) / 100.0 * float(
|
||||
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
|
||||
@@ -479,6 +569,9 @@ def solar_block(c, now):
|
||||
elif fc is not None:
|
||||
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
|
||||
out["updated"])
|
||||
dp = getattr(c, "day_plan", None)
|
||||
if st == "plan" and dp is not None and dp.uncovered_kwh >= MIN_KWH:
|
||||
out["line"] += " " + line_uncovered(dp.uncovered_kwh)
|
||||
return out
|
||||
|
||||
|
||||
@@ -584,6 +677,20 @@ def region_of(opts):
|
||||
if rid in REGION_PRESETS else None}
|
||||
|
||||
|
||||
def runs_for(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
"""UX-54: the price blocks of the window `[day0, day1)`, by the same
|
||||
builders `build_plan()` uses for today: the batteries' price runs with
|
||||
modes on, the verdict's legs with them off. Nothing is decided here.
|
||||
`ahead`: see `mode_runs()`; for a day that has not started."""
|
||||
if getattr(c, "modes_on", False):
|
||||
return mode_runs(c, day0, day1, now, shadow, ahead, rp)[0]
|
||||
fresh = c.fresh_tariff_plan(now)
|
||||
verdict, _why = trade_verdict(c, fresh, now)
|
||||
if verdict is None:
|
||||
return []
|
||||
return trade_runs(c, fresh, verdict, day0, day1, now, shadow, ahead)[0]
|
||||
|
||||
|
||||
def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
day0, day1 = local_day(now)
|
||||
fresh = c.fresh_tariff_plan(now)
|
||||
@@ -602,8 +709,8 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
limit_w = c.peak.cap_w # the limit the controller parsed at start
|
||||
runs, lines = [], []
|
||||
# The control cycle trades on the fresh plan only, so this does too.
|
||||
verdict, trade_why = trade_verdict(c, fresh)
|
||||
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict),
|
||||
verdict, trade_why = trade_verdict(c, fresh, now)
|
||||
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict, now),
|
||||
now, days.kinds)
|
||||
modes_on = bool(getattr(c, "modes_on", False))
|
||||
if running:
|
||||
@@ -788,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
|
||||
@@ -954,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
|
||||
|
||||
|
||||
@@ -973,6 +1082,16 @@ def state_of(c, level, shadow, waiting=False):
|
||||
return "running"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def in_lang(lang):
|
||||
"""UX-54: build sentences in `lang` for the length of a `with` block."""
|
||||
_LANG[0] = uitext.lang_of(lang)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_LANG[0] = "en"
|
||||
|
||||
|
||||
def build(c, *, level: str, checks: list, now: datetime | None = None,
|
||||
lang: str = "en", held: bool | None = None,
|
||||
waiting: bool = False) -> dict:
|
||||
@@ -1075,9 +1194,13 @@ def _kwh(v) -> str:
|
||||
return _dec(f"{float(v):.1f}") + " kWh"
|
||||
|
||||
|
||||
# Row id -> the title on Home. The row's own sentence is the body, and it stays
|
||||
# in English (app/messages.py). Five titles are the notification's own title,
|
||||
# which test_ui_text.py checks.
|
||||
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.
|
||||
_EN = uitext.words("en")["home_words"]
|
||||
ATTN_TITLES = {(None if k == "none" else k): v for k, v in _EN["attn"].items()}
|
||||
ATTN_FROM_MESSAGES = {
|
||||
@@ -1207,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:
|
||||
@@ -1233,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:
|
||||
@@ -1301,10 +1441,21 @@ def run_house_amount() -> str:
|
||||
return _t("run.house_amount")
|
||||
|
||||
|
||||
def day_note(key, **values) -> str:
|
||||
"""UX-54: one sentence of the Forecast tab (`home_words.day`)."""
|
||||
return _t("day." + key, **values)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -1358,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
|
||||
|
||||
|
||||
@@ -1426,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)),
|
||||
@@ -1437,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)),
|
||||
@@ -1470,10 +1631,14 @@ 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"))]
|
||||
out += [("plan", day_note(k, pct=70, name="Battery A", at="08:15",
|
||||
kwh="9.2 kWh", count="6.4 kWh"))
|
||||
for k in words["day"]]
|
||||
out += [("plan", line_no_trade(k)) for k in
|
||||
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
|
||||
"no_level")]
|
||||
|
||||
+136
-102
@@ -64,7 +64,8 @@ from .clamp import (
|
||||
shortfall_note, write_clamp_note,
|
||||
)
|
||||
from . import freshness
|
||||
from . import homeview
|
||||
from . import dayview, homeview
|
||||
from .daycurve import SolarToday
|
||||
from . import messages
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
@@ -174,57 +175,17 @@ TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||
|
||||
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
"""TARIFF-10. Does this install need the background tariff refresh at all?
|
||||
"""Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24)
|
||||
|
||||
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
|
||||
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
|
||||
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||||
only caller of `price_history.carry()` - so on a shipped install with a
|
||||
provider configured NOTHING was ever fetched, no plan was ever resolved,
|
||||
`/data/price_history.json` was never created, the *Price history* row read
|
||||
`empty` forever, and TARIFF-09's short-horizon warning could not fire
|
||||
because there was no fetch to warn about. Measured over a 40 h soak of two
|
||||
instances side by side: zero feed requests and `present: false` across all
|
||||
802 samples on the shipped default, against a resolved plan from the same
|
||||
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
|
||||
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
|
||||
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
|
||||
it, and the sentence saying a configured provider still fetches, all in one
|
||||
change. config.yaml has said it since TARIFF-02 (852fc7c).
|
||||
|
||||
Two terms, and they answer two different questions:
|
||||
|
||||
provider_configured(opts) -> "the operator asked for a price feed", so
|
||||
fetch it, validate it and record the day
|
||||
(§8.3). This is the term TARIFF-10 ADDS.
|
||||
arbitrage.enabled(opts) -> "the operator armed trading". This term is
|
||||
what the gate USED to be, kept verbatim.
|
||||
|
||||
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
|
||||
that started this task before still starts it; none that did not start it
|
||||
stops anything. That matters because the second term is not redundant:
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
|
||||
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
|
||||
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
|
||||
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
|
||||
that site forever and `decide()` would answer "no tariff plan has been
|
||||
resolved yet" on every cycle - silently disarming a strategy the UI says is
|
||||
running, which is the exact failure validate.py's OPT-04 rules exist to
|
||||
refuse.
|
||||
|
||||
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
|
||||
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
|
||||
arithmetic, no socket - so the second term starts a task that fetches
|
||||
nothing. The true shipped default (no provider, no arbitrage) starts no
|
||||
task at all. What is fetched is decided by the FIRST term only.
|
||||
|
||||
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
|
||||
Whether they move energy is `arbitrage.enabled()` at the one call site in
|
||||
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
|
||||
means no trade, ever, on any configuration.
|
||||
Yes with a price feed, with trading on price armed, or with battery modes
|
||||
on: the modes read `self.tariff_plan` for their price runs. With
|
||||
`price_provider` off the refresh is pure arithmetic on `static_profile`, so
|
||||
no term here adds an HTTPS call. It decides whether prices are looked up,
|
||||
never whether energy moves. See DECISIONS/TARIFF-10-fetching-is-not-trading.md
|
||||
and DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
"""
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
return (provider_configured(opts) or arbitrage.enabled(opts)
|
||||
or packmode.enabled(opts))
|
||||
|
||||
|
||||
def load_options() -> dict:
|
||||
@@ -299,14 +260,14 @@ REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
|
||||
# was told over the same span as above (the plant's measured settle time), so
|
||||
# a healthy battery still settling on a new command is not short.
|
||||
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
|
||||
# SAFETY-119: across all hand-backs, the loop's bound is hidden on at most
|
||||
# ROLLING_BUDGET cycles in any ROLLING_S seconds, fewer on a slower cycle:
|
||||
# (cycle seconds up to, cycles), measured in
|
||||
# DECISIONS/SAFETY-119-bound-after-hand-back.md. Without a monotonic clock
|
||||
# the budget never refills.
|
||||
# SAFETY-119/121: after a hand-back the loop's bound is hidden until
|
||||
# HANDBACK_CYCLES cycles and SHORT_SPAN_S have passed; across all hand-backs
|
||||
# on at most ROLLING_BOUND_CYCLES cycles in any ROLLING_S seconds. Measured in
|
||||
# DECISIONS/SAFETY-121-bound-every-quarter.md. Without a monotonic clock the
|
||||
# budget never refills.
|
||||
HANDBACK_CYCLES = 4
|
||||
ROLLING_S = 900.0
|
||||
ROLLING_BUDGET = ((3.5, 10), (11.0, 8), (16.0, 6), (float("inf"), 4))
|
||||
ROLLING_BOUND_CYCLES = 8 # the budget while the cycle length is unknown
|
||||
ROLLING_BOUND_CYCLES = 11
|
||||
|
||||
|
||||
def _refused_interval(sign: int) -> tuple:
|
||||
@@ -1000,7 +961,14 @@ class Controller:
|
||||
# and whether last tick filed a mode trade (a run edge reseeds).
|
||||
self.mode_runs = None
|
||||
self._runs_key = None
|
||||
# TARIFF-24: the end of the run that was under way while a forecast
|
||||
# was read; until then losing the forecast does not widen the gap.
|
||||
self._wx_free_to = None
|
||||
self.mode_tick = None
|
||||
# UX-53: why the trade gate refused a grid charge this tick (`peak`,
|
||||
# `not_worth`, `not_covered`), or None when it allowed one. Home reads
|
||||
# it; nothing in control does.
|
||||
self.mode_gate = None
|
||||
self._run_done = {}
|
||||
self._mode_trade_was = False
|
||||
self._charge_blocked_q = None
|
||||
@@ -1254,9 +1222,14 @@ 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()
|
||||
# UX-54: the site's measured solar per hour, for the Forecast tab.
|
||||
# In memory, display only; nothing reads it back into a decision.
|
||||
self.solar_today = SolarToday()
|
||||
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
|
||||
# is the exact, derived statement of "the latest cycle did not get as
|
||||
# far as the arbitrage step", which is the fact the *Arbitrage* row
|
||||
@@ -1282,12 +1255,11 @@ class Controller:
|
||||
self._short_run = 0
|
||||
# SAFETY-119: did another owner set the wire last cycle; the window
|
||||
# after a hand-back (monotonic s, the loop's cycles in a row); the
|
||||
# monotonic s of the bound cycles hidden in the last ROLLING_S; the
|
||||
# last cycle's monotonic s and the cycle length.
|
||||
# monotonic s of the bound cycles hidden in the last ROLLING_S (a
|
||||
# stop does not clear them).
|
||||
self._wire_lost = False
|
||||
self._handback = None
|
||||
self._hidden = ()
|
||||
self._settle_t, self._settle_dt = None, None
|
||||
self.stopping = False
|
||||
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
|
||||
self.loop_pass_mono = None
|
||||
@@ -2267,6 +2239,28 @@ class Controller:
|
||||
probing=probing))
|
||||
return out
|
||||
|
||||
def _wx_track(self, now) -> None:
|
||||
"""TARIFF-24, once per cycle, before the plan: with a forecast read,
|
||||
remember when the run now under way (cheap, give or sell) ends."""
|
||||
if self.solar_fc is None:
|
||||
return
|
||||
rp = self.mode_runs
|
||||
ends = []
|
||||
if rp is not None and rp.on:
|
||||
ends = [r.end for r in (rp.cheap, rp.sell)
|
||||
if r is not None and r.holds(now)]
|
||||
g = rp.give_at(now)
|
||||
if g is not None:
|
||||
ends.append(g.end)
|
||||
self._wx_free_to = max(ends) if ends else None
|
||||
|
||||
def weather_held(self, now) -> bool:
|
||||
"""TARIFF-24: no forecast now, but a run planned with one is under
|
||||
way: it keeps its gap to its end. The wider gap applies from the next
|
||||
run. Pure; Home asks the same (`homeview.trade_verdict()`)."""
|
||||
return (self.solar_fc is None and self._wx_free_to is not None
|
||||
and now < self._wx_free_to)
|
||||
|
||||
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
|
||||
"""The kept run plan, rebuilt at most once per price slot (15 min) or
|
||||
when the prices or a battery's choice change; a run under way is
|
||||
@@ -2280,9 +2274,11 @@ class Controller:
|
||||
slot = int(now.timestamp() // 900)
|
||||
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
|
||||
for p in packs))
|
||||
held = self.weather_held(now)
|
||||
if self.fc_entity is not None:
|
||||
# P4: a forecast arriving or going rebuilds the plan at once.
|
||||
key += (self.solar_fc is None,)
|
||||
# P4: a forecast arriving or going rebuilds the plan at once (and
|
||||
# so does the end of a run that held the gap, TARIFF-24).
|
||||
key += (self.solar_fc is None, held)
|
||||
if key != self._runs_key:
|
||||
done_c = self._run_done.get("charge", (None, set()))[1]
|
||||
done_s = self._run_done.get("sell", (None, set()))[1]
|
||||
@@ -2291,7 +2287,7 @@ class Controller:
|
||||
required = price_required_spread(
|
||||
arbitrage.threshold_eur_kwh(self.o),
|
||||
price_uncertainty_of(plan.read.product)
|
||||
| {Uncertainty.NO_WEATHER})
|
||||
| arbitrage.weather_uncertainty(True, self.solar_fc, held))
|
||||
ref = self._ref_cheap
|
||||
if ref is not None and now - ref.end > REF_CHEAP_KEEP:
|
||||
ref = self._ref_cheap = None
|
||||
@@ -2360,6 +2356,7 @@ class Controller:
|
||||
quarter), exactly the gate `arbitrage.decide()` has."""
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
packs = self._mode_packs(maint_results)
|
||||
self._wx_track(now)
|
||||
runs = self._price_runs(now, packs, floor_pct, ceiling_pct)
|
||||
guard = arbitrage.guard_state(self.peak_guard)
|
||||
plan = self.fresh_tariff_plan(now)
|
||||
@@ -2370,7 +2367,9 @@ class Controller:
|
||||
return plan.arbitrage(
|
||||
peak_guard_active=active,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER})).match(
|
||||
uncertainty=arbitrage.weather_uncertainty(
|
||||
True, self.solar_fc, self.weather_held(now)),
|
||||
now=now).match(
|
||||
allowed=lambda _v: True, refused=lambda _v: False)
|
||||
if guard is not None and plan is not None and runs is not None:
|
||||
# A charge asks the guard (section 9.1 interlock); a sell only
|
||||
@@ -2380,6 +2379,7 @@ class Controller:
|
||||
charge_ok = sell_ok and allowed(guard)
|
||||
trade_why = (None if charge_ok else "peak" if sell_ok
|
||||
else "not_worth")
|
||||
self.mode_gate = None if charge_ok else trade_why
|
||||
price_now = None
|
||||
if plan is not None:
|
||||
try:
|
||||
@@ -3241,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.
|
||||
@@ -3376,6 +3378,11 @@ class Controller:
|
||||
*(self.hass.state(entity) for entity in roster))
|
||||
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
|
||||
list(zip(roster, states)), pv_read.inverted(self.o))
|
||||
# UX-54: kept for the Forecast tab only. It must never cost a cycle.
|
||||
try:
|
||||
self.solar_today.add(datetime.now(timezone.utc), self.pv_w)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOG.debug("measured solar not kept", exc_info=True)
|
||||
|
||||
def _peak_fc_unit_log(self, fault) -> None:
|
||||
"""SAFETY-118. One WARNING when the peak forecast's unit cannot be read
|
||||
@@ -4896,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.
|
||||
@@ -4906,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)
|
||||
@@ -4961,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
|
||||
@@ -5180,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())
|
||||
@@ -5830,7 +5843,6 @@ class Controller:
|
||||
self._short_run = 0
|
||||
self._asked_hist = {}
|
||||
self._wire_lost, self._handback = False, None
|
||||
self._settle_t = None # a stop is not a cycle length
|
||||
return
|
||||
unmet = self.allocation.unmet_w if self.allocation is not None else 0
|
||||
cut = (resolution.bound_by or "").split("(")[0]
|
||||
@@ -5871,20 +5883,15 @@ class Controller:
|
||||
"""SAFETY-119: does the loop's bound on this cycle count? Not within
|
||||
a settle after another owner handed the wire back: its integrator is
|
||||
re-seeded from that owner's command and kicked by a meter that does
|
||||
not show it yet. The settle is SHORT_CYCLES cycles and SHORT_SPAN_S
|
||||
after the hand-back; losing the wire restarts it. Across all
|
||||
hand-backs the bound is hidden on at most ROLLING_BUDGET[cycle
|
||||
length] cycles in any ROLLING_S, so a bound that stays is counted
|
||||
however often the wire changes hands.
|
||||
DECISIONS/SAFETY-119-bound-after-hand-back.md."""
|
||||
not show it yet. SAFETY-121: the settle is HANDBACK_CYCLES cycles and
|
||||
SHORT_SPAN_S after the hand-back, so a bound that outlasts it counts
|
||||
at any cycle length; losing the wire restarts it. Across all
|
||||
hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles
|
||||
in any ROLLING_S, so a bound that never outlasts a settle is counted
|
||||
too. DECISIONS/SAFETY-121-bound-every-quarter.md."""
|
||||
def aged(t0, span):
|
||||
return now is None or t0 is None or now - t0 >= span
|
||||
|
||||
# The cycle length (a gap over two minutes is not one).
|
||||
if now is not None and self._settle_t is not None \
|
||||
and 0.0 < now - self._settle_t <= 120.0:
|
||||
self._settle_dt = now - self._settle_t
|
||||
self._settle_t = now
|
||||
if not owns:
|
||||
self._wire_lost, self._handback = True, None
|
||||
return False
|
||||
@@ -5895,16 +5902,13 @@ class Controller:
|
||||
tc, n = self._handback
|
||||
n += 1
|
||||
self._handback = (tc, n)
|
||||
if aged(tc, SHORT_SPAN_S) and n >= SHORT_CYCLES:
|
||||
if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES:
|
||||
self._handback = None
|
||||
return False
|
||||
if self.loop_clamped[0] and not self.loop_clamped[2]:
|
||||
kept = tuple(t for t in self._hidden if now is None or (
|
||||
t is not None and now - t < ROLLING_S))
|
||||
dt = self._settle_dt
|
||||
budget = ROLLING_BOUND_CYCLES if dt is None else next(
|
||||
m for up, m in ROLLING_BUDGET if dt <= up)
|
||||
if len(kept) >= budget:
|
||||
if len(kept) >= ROLLING_BOUND_CYCLES:
|
||||
self._hidden = kept
|
||||
return False
|
||||
self._hidden = kept + (now,)
|
||||
@@ -7082,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()})
|
||||
@@ -7474,7 +7487,8 @@ class Controller:
|
||||
level, banner = "warn", messages.banner_stopped()
|
||||
elif runs:
|
||||
level, running_alarm = "bad", True
|
||||
banner = runs[0].get("banner") or messages.headline(runs[0]["text"])
|
||||
banner = runs[0].get("banner") or messages.headline_bi(
|
||||
runs[0]["text"])
|
||||
elif any(i.delivery_wd.tripped for i in self.inverters):
|
||||
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
||||
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
|
||||
@@ -7497,11 +7511,11 @@ class Controller:
|
||||
# character; at two, "Maintenance: drain" without a name is a
|
||||
# banner an operator cannot act on, because the remedy is at one
|
||||
# device and the site has two.
|
||||
level, banner = "warn", f"Maintenance: {self.maint.phase}"
|
||||
if len(self.inverters) > 1:
|
||||
banner += f" ({self.maint_unit.name})"
|
||||
level, banner = "warn", messages.banner_maintenance(
|
||||
self.maint.phase,
|
||||
self.maint_unit.name if len(self.inverters) > 1 else "")
|
||||
else:
|
||||
level, banner = "ok", "Running — holding grid at zero"
|
||||
level, banner = "ok", messages.banner_running()
|
||||
|
||||
def w(v):
|
||||
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
|
||||
@@ -7605,6 +7619,13 @@ class Controller:
|
||||
now=datetime.now(timezone.utc), why=self.why_words)),
|
||||
]
|
||||
held = level == "bad" and not running_alarm
|
||||
# UX-46. The rows were built in English with their Dutch beside them
|
||||
# (`messages.Bi`); the page's language is chosen once, here, for the
|
||||
# Home sentences, the checks list, the rows that carry one and the
|
||||
# banner. A plain str (no Dutch twin) stays as it is.
|
||||
checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks]
|
||||
rows = [(k, messages.pick(v, lang)) for k, v in rows]
|
||||
banner = messages.pick(banner, lang)
|
||||
out = {
|
||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||
"hint": self.hint,
|
||||
@@ -7631,6 +7652,21 @@ class Controller:
|
||||
out["home_error"] = True
|
||||
return out
|
||||
|
||||
def forecast_payload(self, lang: str = "en") -> dict:
|
||||
"""UX-54: the Forecast tab's data. Built on request, from what the
|
||||
control cycle already holds; no Home Assistant call. Today's blocks are
|
||||
Home's own, so `status_payload()` is built first and read."""
|
||||
try:
|
||||
return dayview.build(self, self.status_payload(lang=lang),
|
||||
datetime.now(timezone.utc), lang)
|
||||
except Exception: # noqa: BLE001
|
||||
if not getattr(self, "_forecast_failed", False):
|
||||
self._forecast_failed = True
|
||||
_LOG.exception("The Forecast tab could not be built")
|
||||
else:
|
||||
_LOG.debug("The Forecast tab failed again", exc_info=True)
|
||||
return {"ok": False}
|
||||
|
||||
async def handle_action(self, what: str, *, unit=None, value=None):
|
||||
now = datetime.now(timezone.utc)
|
||||
if what in ("pack_use", "pack_mode", "pack_sell"):
|
||||
@@ -8052,11 +8088,9 @@ async def amain() -> None:
|
||||
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
|
||||
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
|
||||
#
|
||||
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
|
||||
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
|
||||
# enabled` ships `false`, so a site that had selected a price feed
|
||||
# fetched nothing at all; the whole argument, the measurement and what
|
||||
# the `or` is protecting are in `tariff_refresh_wanted()` above.
|
||||
# ⚠️ STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES:
|
||||
# see `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and
|
||||
# TARIFF-24 for why each term is there.
|
||||
#
|
||||
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
|
||||
# two decisions; this is the first, and `cycle()`'s
|
||||
|
||||
+522
-149
File diff suppressed because it is too large
Load Diff
@@ -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?
|
||||
|
||||
|
||||
@@ -54,10 +54,12 @@ class SetupOnly:
|
||||
|
||||
def status_payload(self, lang: str = "en") -> dict:
|
||||
return {"setup_only": True, "mode": "setup_only",
|
||||
"banner": messages.banner_setup_only(), "level": "warn",
|
||||
"banner": messages.pick(messages.banner_setup_only(),
|
||||
lang), "level": "warn",
|
||||
"rows": [["Control", "not started, in setup"]],
|
||||
"checks": [{"ok": False, "warn": True,
|
||||
"text": messages.row_setup_only()}],
|
||||
"text": messages.pick(messages.row_setup_only(),
|
||||
lang)}],
|
||||
"hint": self.hint, "events": list(self.events)}
|
||||
|
||||
async def handle_action(self, what):
|
||||
|
||||
@@ -76,12 +76,20 @@ class Forecast:
|
||||
tomorrow: Day | None
|
||||
at: datetime # when the sensor last reported
|
||||
|
||||
def between(self, t0: datetime, t1: datetime) -> float | None:
|
||||
def between(self, t0: datetime, t1: datetime,
|
||||
since: datetime | None = None) -> float | None:
|
||||
"""kWh the sun should give between `t0` (now or later) and `t1`.
|
||||
None when the span reaches the sunny hours of a day with no
|
||||
forecast (tomorrow, when its sensor is not set)."""
|
||||
forecast (tomorrow, when its sensor is not set).
|
||||
|
||||
`since` is the moment a state-only "left today" figure counts from
|
||||
(OPT-23): `t0` itself when not given, so a span that starts later
|
||||
than now would be spread over what is left of the bell from its own
|
||||
start. Pass now for every span of a plan, and the spans add up to
|
||||
the figure. An hourly split ignores it."""
|
||||
if t1 <= t0:
|
||||
return 0.0
|
||||
since = t0 if since is None else since
|
||||
last = self.tomorrow or self.today
|
||||
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
|
||||
return None
|
||||
@@ -92,7 +100,7 @@ class Forecast:
|
||||
a = max(t0, day.day0)
|
||||
b = min(t1, day.day0 + timedelta(days=1))
|
||||
if b > a:
|
||||
total += _day_between(day, a, b, t0)
|
||||
total += _day_between(day, a, b, since)
|
||||
return total
|
||||
|
||||
|
||||
@@ -124,8 +132,12 @@ def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float:
|
||||
out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds()
|
||||
return out
|
||||
# State only: `kwh` is what is left from `max(now, day0)` to the day's
|
||||
# end, spread over the bell.
|
||||
h_from = _hours_since(day.day0, max(now, day.day0))
|
||||
# end, spread over the bell. Nothing of it is before that moment.
|
||||
start = max(now, day.day0)
|
||||
a = max(a, start)
|
||||
if b <= a:
|
||||
return 0.0
|
||||
h_from = _hours_since(day.day0, start)
|
||||
left = 1.0 - _bell(h_from)
|
||||
if left <= 1e-9:
|
||||
return 0.0
|
||||
|
||||
@@ -246,3 +246,29 @@ select.sel:disabled{opacity:.6}
|
||||
.simple .card .lbl{font-weight:600;font-size:15px}
|
||||
.simple .inv .hd{border-bottom:0}
|
||||
.simple .inv .f{border-top:0;padding-top:0}
|
||||
/* UX-54: the Forecast screen */
|
||||
.fcline{text-align:right}
|
||||
.fcnote{background:var(--info-bg);border-radius:10px;padding:8px 10px;margin:0 0 8px}
|
||||
.fcbars{display:flex;align-items:flex-end;gap:2px;height:110px}
|
||||
.fcol{flex:1;position:relative;height:100%;min-width:0}
|
||||
.fcol i{position:absolute;display:block}
|
||||
.fcol .sun{bottom:0;left:0;right:0;background:var(--solar);border-radius:3px 3px 0 0;opacity:.85}
|
||||
.fcol .sun.est{opacity:.5}
|
||||
.fcol .got{bottom:0;left:30%;right:30%;background:repeating-linear-gradient(135deg,var(--ink) 0 3px,var(--card) 3px 5px);border:1px solid var(--ink);border-bottom:0}
|
||||
.fcol .use{left:-1px;right:-1px;height:3px;background:var(--house)}
|
||||
.fcols{position:absolute;inset:0;display:flex;gap:0}
|
||||
.fcols>i{flex:1;min-width:0;display:block}
|
||||
.lvlchart{position:relative;height:110px;margin:18px 0 4px}
|
||||
.lvlchart svg{position:absolute;inset:0;width:100%;height:100%;display:block}
|
||||
.lvlchart .yl{position:absolute;right:2px;font-size:10px;color:var(--faint);pointer-events:none}
|
||||
.fcnow{position:absolute;top:-4px;bottom:0;width:2px;background:var(--ink);opacity:.7;pointer-events:none}
|
||||
.fcnow span{position:absolute;top:-15px;left:-10px;font-size:10px}
|
||||
.legend i.sun{background:var(--solar)}
|
||||
.legend i.got{background:repeating-linear-gradient(135deg,var(--ink) 0 2px,var(--card) 2px 4px);border:1px solid var(--ink)}
|
||||
.legend i.use{height:3px;background:var(--house)}
|
||||
.legend i.lv{height:3px;background:var(--batt)}
|
||||
table.fct{width:100%;border-collapse:collapse;font-size:12px;font-variant-numeric:tabular-nums}
|
||||
table.fct th,table.fct td{padding:4px 3px;text-align:right;border-bottom:1px solid var(--line)}
|
||||
table.fct th:first-child,table.fct td:first-child{text-align:left}
|
||||
table.fct th{color:var(--muted);font-weight:600}
|
||||
.ymax{position:absolute;top:-2px;left:2px;font-size:10px;color:var(--faint);pointer-events:none}
|
||||
|
||||
@@ -448,6 +448,175 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Forecast (UX-54) ---------------- */
|
||||
// Today and tomorrow, per hour: the solar forecast and what was measured,
|
||||
// the house's expected use, and each battery's expected level with its
|
||||
// blocks. The data is built by app/dayview.py when the tab asks (GET
|
||||
// forecast), the sentences in the page's language. A part that is not
|
||||
// known yet comes as a note, so no chart is ever drawn empty.
|
||||
// The SVG namespace, read off the page's own icons: the page names no URL.
|
||||
const SVGNS = document.querySelector("svg").namespaceURI;
|
||||
function sv(tag, attrs) {
|
||||
const e = document.createElementNS(SVGNS, tag);
|
||||
for (const k in attrs || {}) e.setAttribute(k, attrs[k]);
|
||||
return e;
|
||||
}
|
||||
let FC = null;
|
||||
let fcDay = 0;
|
||||
let fcBusy = false;
|
||||
let fcTimer = null;
|
||||
const fmtKwh = (x) => dec(Number(x).toFixed(1));
|
||||
const hoursOf = (d) => d.length_h || d.hours.length || 24;
|
||||
function endLabel(d, i) { return i + 1 < d.hours.length ? d.hours[i + 1].label : "24:00"; }
|
||||
function hourTip(d, i) {
|
||||
const x = d.hours[i];
|
||||
const lines = [];
|
||||
if (x.forecast_kwh != null) lines.push(t("forecast.forecast") + ": " + fmtKwh(x.forecast_kwh) + " kWh");
|
||||
if (x.measured_kwh != null) lines.push(t("forecast.measured") + ": " + fmtKwh(x.measured_kwh) + " kWh");
|
||||
if (x.house_kwh != null) lines.push(t("forecast.house") + ": " + fmtKwh(x.house_kwh) + " kWh");
|
||||
d.batteries.forEach((b, j) => { if (x.levels[j] != null) lines.push(b.name + ": " + dec(Math.round(x.levels[j]).toString()) + " %"); });
|
||||
return [x.label + " " + t("plan.time_to") + " " + endLabel(d, i)].concat(lines);
|
||||
}
|
||||
function hourItem(tag, cls, d, i, kids) {
|
||||
const tip = hourTip(d, i);
|
||||
const e = h(tag, { class: cls, tabindex: "-1", role: "img", "aria-label": tip.join(". "), "data-tip": JSON.stringify(tip) }, kids);
|
||||
return e;
|
||||
}
|
||||
function renderSun(d) {
|
||||
const card = $("#fc-sun");
|
||||
const any = d.sun.forecast || d.sun.measured || d.sun.house;
|
||||
card.hidden = !any;
|
||||
if (!any) return;
|
||||
const mx = Math.max(0.5, ...d.hours.map((x) => Math.max(x.forecast_kwh || 0, x.measured_kwh || 0, x.house_kwh || 0)));
|
||||
const pct = (v) => Math.max(0, Math.min(100, v / mx * 100)) + "%";
|
||||
const bars = $("#fc-bars");
|
||||
bars.setAttribute("aria-label", t("forecast.sun"));
|
||||
bars.replaceChildren(...d.hours.map((x, i) => hourItem("div", "fcol", d, i, [
|
||||
x.forecast_kwh != null ? h("i", { class: "sun" + (d.sun.estimate ? " est" : ""), style: "height:" + pct(x.forecast_kwh) }) : null,
|
||||
x.measured_kwh != null ? h("i", { class: "got", style: "height:" + pct(x.measured_kwh) }) : null,
|
||||
x.house_kwh != null ? h("i", { class: "use", style: "bottom:calc(" + pct(x.house_kwh) + " - 1px)" }) : null])));
|
||||
roving(bars);
|
||||
const chart = $("#fc-sunchart");
|
||||
chart.querySelectorAll(".fcnow, .ymax").forEach((e) => e.remove());
|
||||
chart.append(h("span", { class: "ymax", "aria-hidden": "true" }, fmtKwh(mx)));
|
||||
if (fcDay === 0) chart.append(nowMark(d));
|
||||
const used = [["sun", d.sun.forecast, "forecast"], ["got", d.sun.measured, "measured"], ["use", d.sun.house, "house"]];
|
||||
$("#fc-legend").replaceChildren(...used.filter((u) => u[1]).map((u) => h("span", {}, h("i", { class: u[0], "aria-hidden": "true" }), t("forecast." + u[2]))));
|
||||
}
|
||||
function nowMark(d) {
|
||||
return h("div", { class: "fcnow", "aria-hidden": "true", style: "left:" + Math.max(0, Math.min(100, FC.now_h / hoursOf(d) * 100)) + "%" }, h("span", {}, t("plan.now")));
|
||||
}
|
||||
function levelChart(d, b, bi) {
|
||||
const L = hoursOf(d), W = L * 10;
|
||||
const x = (hr) => hr * 10;
|
||||
const box = h("div", { class: "chart" });
|
||||
const area = h("div", { class: "lvlchart" });
|
||||
const svg = sv("svg", { viewBox: "0 0 " + W + " 100", preserveAspectRatio: "none", "aria-hidden": "true", focusable: "false" });
|
||||
[0, 50, 100].forEach((p) => svg.append(sv("line", { x1: 0, x2: W, y1: 100 - p, y2: 100 - p, stroke: "var(--line)", "stroke-width": 1, "vector-effect": "non-scaling-stroke" })));
|
||||
const fill = { grid: "var(--accent)", give: "var(--house)", cycle: "var(--warn)" };
|
||||
for (const r of b.blocks) {
|
||||
if (r.end_h <= r.start_h) continue;
|
||||
svg.append(sv("rect", { x: x(r.start_h), y: 0, width: x(r.end_h) - x(r.start_h), height: 100, fill: fill[r.kind] || "var(--mid)", opacity: 0.18 }));
|
||||
}
|
||||
const pts = b.levels.map((p) => x(p[0]) + "," + (100 - p[1]));
|
||||
svg.append(sv("polygon", { points: x(b.levels[0][0]) + ",100 " + pts.join(" ") + " " + x(b.levels[b.levels.length - 1][0]) + ",100", fill: "var(--batt)", opacity: 0.12 }));
|
||||
svg.append(sv("polyline", { points: pts.join(" "), fill: "none", stroke: "var(--batt)", "stroke-width": 2.5, "stroke-linejoin": "round", "vector-effect": "non-scaling-stroke" }));
|
||||
area.append(svg);
|
||||
[["100", 0], ["50", 50], ["0", 100]].forEach((y) => area.append(h("span", { class: "yl", "aria-hidden": "true", style: "top:" + y[1] + "%;transform:translateY(" + (y[1] === 100 ? "-100%" : y[1] === 0 ? "0" : "-50%") + ")" }, y[0] + " %")));
|
||||
area.append(h("div", { class: "fcols", role: "group", "aria-label": b.name }, ...d.hours.map((hx, i) => hourItem("i", "", d, i, null))));
|
||||
roving(area.querySelector(".fcols"));
|
||||
if (fcDay === 0) area.append(nowMark(d));
|
||||
box.append(area);
|
||||
// The blocks under the chart, drawn as Home draws them.
|
||||
if (b.blocks.length) {
|
||||
const lane = h("div", { class: "lane", role: "group", "aria-label": b.name });
|
||||
for (const r of b.blocks) {
|
||||
if (r.end_h <= r.start_h) continue;
|
||||
lane.append(chartItem("b", r.kind, "left:" + (r.start_h / L * 100) + "%;width:" + ((r.end_h - r.start_h) / L * 100) + "%",
|
||||
b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount]));
|
||||
}
|
||||
box.append(lane);
|
||||
roving(lane);
|
||||
}
|
||||
box.append(h("div", { class: "axis", "aria-hidden": "true" }, h("span", {}, "00:00"), h("span", {}, "06:00"), h("span", {}, "12:00"), h("span", {}, "18:00"), h("span", {}, "24:00")));
|
||||
return box;
|
||||
}
|
||||
function batteryCardFc(d, b, bi) {
|
||||
const drawn = b.levels.length > 1;
|
||||
const list = b.blocks.map((r) => h("li", {}, h("strong", {}, r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount])));
|
||||
return h("div", { class: "card" },
|
||||
h("div", { class: "row spread" }, h("h2", {}, b.name),
|
||||
h("span", { class: "tiny muted" }, (b.capacity_kwh ? fmtKwh(b.capacity_kwh) + " kWh · " : "") + t("forecast.levels"))),
|
||||
drawn ? levelChart(d, b, bi) : null,
|
||||
list.length ? h("ul", { class: "plain small" }, ...list) : null,
|
||||
b.line ? h("p", { class: "small muted", style: "margin:6px 0 0" }, b.line) : null);
|
||||
}
|
||||
function renderTable(d) {
|
||||
const more = $("#fc-more");
|
||||
const any = d.sun.forecast || d.sun.measured || d.sun.house;
|
||||
more.hidden = !any;
|
||||
if (!any) return;
|
||||
const lv = d.batteries.filter((b) => b.levels.length > 1);
|
||||
const head = h("tr", {}, h("th", { scope: "col" }, t("forecast.hour")),
|
||||
d.sun.forecast ? h("th", { scope: "col" }, t("forecast.solar")) : null,
|
||||
d.sun.measured ? h("th", { scope: "col" }, t("forecast.got")) : null,
|
||||
d.sun.house ? h("th", { scope: "col" }, t("forecast.use")) : null,
|
||||
...lv.map((b) => h("th", { scope: "col" }, b.name + " %")));
|
||||
const cell = (v, f) => h("td", {}, v == null ? "–" : f(v));
|
||||
const rows = d.hours.map((x) => h("tr", {},
|
||||
h("td", {}, x.label),
|
||||
d.sun.forecast ? cell(x.forecast_kwh, fmtKwh) : null,
|
||||
d.sun.measured ? cell(x.measured_kwh, fmtKwh) : null,
|
||||
d.sun.house ? cell(x.house_kwh, fmtKwh) : null,
|
||||
...lv.map((b) => cell(x.levels[d.batteries.indexOf(b)], (v) => dec(Math.round(v).toString())))));
|
||||
$("#fc-table").replaceChildren(h("thead", {}, head), h("tbody", {}, ...rows));
|
||||
}
|
||||
function renderForecast() {
|
||||
const loading = $("#fc-loading");
|
||||
if (!FC || FC.ok !== true) {
|
||||
loading.hidden = false; loading.textContent = FC ? t("offline.name") : t("forecast.loading");
|
||||
$("#fc-notes").replaceChildren(); $("#fc-sun").hidden = true; $("#fc-batts").replaceChildren(); $("#fc-more").hidden = true; $("#fc-line").textContent = "";
|
||||
return;
|
||||
}
|
||||
loading.hidden = true;
|
||||
const d = FC.days[fcDay];
|
||||
$("#fc-line").textContent = d.line || "";
|
||||
$("#fc-notes").replaceChildren(...d.notes.map((n) => h("p", { class: "fcnote small" }, n)));
|
||||
renderSun(d);
|
||||
$("#fc-batts").replaceChildren(...d.batteries.map((b, i) => batteryCardFc(d, b, i)));
|
||||
renderTable(d);
|
||||
document.querySelectorAll("#fc-day button").forEach((b) => {
|
||||
const on = Number(b.dataset.d) === fcDay;
|
||||
b.setAttribute("aria-checked", String(on)); b.tabIndex = on ? 0 : -1;
|
||||
});
|
||||
}
|
||||
async function loadForecast() {
|
||||
if (fcBusy) return;
|
||||
fcBusy = true;
|
||||
try {
|
||||
const r = await fetch("forecast?lang=" + LANG, { cache: "no-store" });
|
||||
if (!r.ok) throw new Error("forecast " + r.status);
|
||||
FC = await r.json();
|
||||
} catch (e) { FC = { ok: false }; }
|
||||
finally { fcBusy = false; }
|
||||
renderForecast();
|
||||
}
|
||||
document.querySelectorAll("#fc-day button").forEach((b, i, all) => {
|
||||
b.addEventListener("click", () => { fcDay = Number(b.dataset.d); renderForecast(); });
|
||||
b.addEventListener("keydown", (e) => {
|
||||
const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + all.length }[e.key];
|
||||
if (j === undefined) return;
|
||||
e.preventDefault(); fcDay = j % all.length; renderForecast(); all[fcDay].focus();
|
||||
});
|
||||
});
|
||||
// Every 30 s while the screen shows: the data moves slowly, and each load
|
||||
// is built on request.
|
||||
function scheduleForecast() {
|
||||
const on = !document.hidden && $("#t-fc").getAttribute("aria-selected") === "true";
|
||||
if (on && !fcTimer) { loadForecast(); fcTimer = setInterval(loadForecast, 30000); }
|
||||
if (!on && fcTimer) { clearInterval(fcTimer); fcTimer = null; }
|
||||
}
|
||||
|
||||
/* ---------------- control buttons ---------------- */
|
||||
// The action is a toggle, so the page reads the live state first and sends
|
||||
// nothing when control is already where the person asked it to be.
|
||||
@@ -511,11 +680,13 @@
|
||||
}
|
||||
addEventListener("hashchange", fromHash);
|
||||
|
||||
/* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */
|
||||
/* ---------------- polling: every 3 s while Home, Batteries or Forecast shows ---------------- */
|
||||
let timer = null;
|
||||
function schedule() {
|
||||
scheduleForecast();
|
||||
const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" ||
|
||||
$("#t-batt").getAttribute("aria-selected") === "true");
|
||||
$("#t-batt").getAttribute("aria-selected") === "true" ||
|
||||
$("#t-fc").getAttribute("aria-selected") === "true");
|
||||
if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); }
|
||||
if (!live && timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<nav class="tabs" role="tablist" id="tabs" data-t-label="tabs.label">
|
||||
<button class="tab" role="tab" id="t-home" aria-controls="s-home" aria-selected="true" data-hash="home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg><span data-t="tabs.home"></span><span class="badge" id="attn-badge" hidden></span></button>
|
||||
<button class="tab" role="tab" id="t-batt" aria-controls="s-batt" aria-selected="false" data-hash="batteries" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2"/><rect x="5.5" y="9.5" width="7" height="5" fill="currentColor" stroke="none"/></svg><span data-t="tabs.batteries"></span></button>
|
||||
<button class="tab" role="tab" id="t-fc" aria-controls="s-fc" aria-selected="false" data-hash="forecast" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 20h18M4 16l5-6 4 4 7-9"/></svg><span data-t="tabs.forecast"></span></button>
|
||||
<button class="tab" role="tab" id="t-set" aria-controls="s-set" aria-selected="false" data-hash="settings" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg><span data-t="tabs.settings"></span></button>
|
||||
<button class="tab" role="tab" id="t-wiz" aria-controls="s-wiz" aria-selected="false" data-hash="setup" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 7l3 3M5 19l9.5-9.5 3 3L8 22H5z"/><path d="M19 2v4M17 4h4"/></svg><span data-t="tabs.setup"></span></button>
|
||||
</nav>
|
||||
@@ -114,6 +115,32 @@
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-fc" role="tabpanel" aria-labelledby="t-fc" hidden>
|
||||
<div class="row spread" style="margin-bottom:8px">
|
||||
<div class="seg" role="radiogroup" id="fc-day" data-t-label="forecast.day">
|
||||
<button type="button" role="radio" aria-checked="true" data-d="0" data-t="forecast.today"></button>
|
||||
<button type="button" role="radio" aria-checked="false" data-d="1" tabindex="-1" data-t="forecast.tomorrow"></button>
|
||||
</div>
|
||||
<span class="tiny muted grow fcline" id="fc-line"></span>
|
||||
</div>
|
||||
<div id="fc-notes" aria-live="polite"></div>
|
||||
<p class="small muted" id="fc-loading" data-t="forecast.loading"></p>
|
||||
<div class="card" id="fc-sun" hidden>
|
||||
<div class="row spread"><h2 data-t="forecast.sun"></h2><span class="tiny muted" data-t="forecast.unit"></span></div>
|
||||
<div class="chart" id="fc-sunchart">
|
||||
<div class="fcbars" id="fc-bars" role="group"></div>
|
||||
<div class="axis" aria-hidden="true"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
|
||||
</div>
|
||||
<div class="legend" id="fc-legend"></div>
|
||||
<p class="tiny muted" style="margin:6px 0 0" data-t="plan.description"></p>
|
||||
</div>
|
||||
<div id="fc-batts"></div>
|
||||
<details class="grp" id="fc-more" hidden>
|
||||
<summary><span class="grow"><strong data-t="forecast.table"></strong></span><span class="chev" aria-hidden="true">›</span></summary>
|
||||
<div class="body"><table class="fct" id="fc-table"></table></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
|
||||
<div class="row spread" style="margin-bottom:6px">
|
||||
<h2 style="margin:0" data-s="name"></h2>
|
||||
|
||||
@@ -207,7 +207,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from . import messages, price_chain
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||
Uncertainty, _Outcome, _cover_table, brussels, load_prices, price_option,
|
||||
provider_configured, required_spread, uncertainty_of, widen_factor,
|
||||
)
|
||||
from .price_chain import uses_chain
|
||||
@@ -986,12 +986,25 @@ class TariffPlan:
|
||||
return here.eur_per_kwh < (sum(p.eur_per_kwh for p in firm) / len(firm))
|
||||
|
||||
def arbitrage(self, *, peak_guard_active: bool, min_spread_eur_kwh,
|
||||
uncertainty) -> Arbitrage:
|
||||
uncertainty, now: datetime) -> Arbitrage:
|
||||
"""May OPT-04 trade, and on what numbers. See the module docstring.
|
||||
|
||||
⚠️ ALL THREE ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
|
||||
PRICE-02. `now` (tz-aware) drops the points whose price period has
|
||||
ended (`end <= now`, the span `price._cover_table()` gives it), so the
|
||||
cheapest and dearest point are always ones that can still be traded.
|
||||
It is taken here, per call, and not when the plan is built: a plan
|
||||
built at 03:00 is still used at 03:20. An hourly point that is still
|
||||
current stays in.
|
||||
|
||||
⚠️ ALL FOUR ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
|
||||
each, and none of them has a default:
|
||||
|
||||
`now` PRICE-02. A caller that could leave it out
|
||||
would get the whole series back, elapsed
|
||||
periods included, and the verdict could aim
|
||||
at a period that is over. A test that wants
|
||||
the whole series passes an instant before it.
|
||||
|
||||
`peak_guard_active` §9.1/§10.1 put peak shaving above arbitrage,
|
||||
and this is how that ordering survives a
|
||||
strategy that has not been written yet: the
|
||||
@@ -1029,6 +1042,12 @@ class TariffPlan:
|
||||
f"{sorted(map(repr, caller_sources))} - a string widens by "
|
||||
f"nothing while reporting that it widened.")
|
||||
|
||||
if not isinstance(now, datetime) or now.tzinfo is None \
|
||||
or now.utcoffset() is None:
|
||||
raise TypeError(f"now must be a tz-aware datetime, not {now!r}; "
|
||||
f"a naive instant is ambiguous for one hour "
|
||||
f"every October")
|
||||
|
||||
if peak_guard_active:
|
||||
return Arbitrage.refuse(
|
||||
"the capacity-tariff peak guard is holding, and §10.1 puts peak "
|
||||
@@ -1056,6 +1075,8 @@ class TariffPlan:
|
||||
# day with no fetch at all) has no firm pair left and is refused, which
|
||||
# is the previous behaviour arrived at by a truer route.
|
||||
pts = self.read.points
|
||||
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
|
||||
pts = tuple(p for p in pts if id(p) in ahead)
|
||||
firm = tuple(p for p in pts if not p.provisional)
|
||||
if len(firm) < 2:
|
||||
# ⚠️ POINTS, AND THE HOUR FIGURE BESIDE IT RATHER THAN INSTEAD OF
|
||||
@@ -1082,8 +1103,12 @@ class TariffPlan:
|
||||
f"the tariff is flat - every one of the {len(firm)} firm "
|
||||
f"points, spanning {_whole_hours(firm)} hours, costs "
|
||||
f"{firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
|
||||
f"selling high is arithmetically impossible. §9.2: a fully "
|
||||
f"fixed product is self-consumption only")
|
||||
f"selling high is arithmetically impossible"
|
||||
# UX-53: only a real fixed product is "fully fixed". A
|
||||
# dynamic or day/night site whose remaining periods happen to
|
||||
# be equal trades again when they differ.
|
||||
+ (". §9.2: a fully fixed product is self-consumption only"
|
||||
if self.product is PriceProduct.FIXED else ""))
|
||||
|
||||
# REFUSAL 5, NEW IN FCST-03 (AC3). One widening path, driven by the set
|
||||
# of active uncertainty sources: the product's own (§9.2 row 2, derived
|
||||
@@ -1334,6 +1359,11 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
that, so the spread is over hours that are still ahead. Both directions of
|
||||
the difference are real: the window gains the unpublished hours it was
|
||||
missing and loses the elapsed ones it should never have had.
|
||||
|
||||
⚠️ PRICE-02: THAT HOLDS FOR AN HOURLY FEED ONLY. The window is floored to
|
||||
the hour, so a 15-minute feed keeps the elapsed periods of the current
|
||||
hour, and the plan ages between refreshes. `TariffPlan.arbitrage(now=...)`
|
||||
drops the periods that have ended, at every call.
|
||||
"""
|
||||
stamp = now or datetime.now(timezone.utc)
|
||||
if not provider_configured(opts):
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
},
|
||||
"home": {
|
||||
"app": {"name": "Battery add-on"},
|
||||
"tabs": {"home": "Home", "batteries": "Batteries", "settings": "Settings", "setup": "Setup", "label": "Screens"},
|
||||
"tabs": {"home": "Home", "batteries": "Batteries", "forecast": "Forecast", "settings": "Settings", "setup": "Setup", "label": "Screens"},
|
||||
"now": {"name": "Now", "description": "Updated every 3 seconds.", "solar": "Solar", "house": "House", "grid": "Grid", "batteries": "Batteries"},
|
||||
"plan": {
|
||||
"name": "Today's plan",
|
||||
@@ -68,6 +68,24 @@
|
||||
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
|
||||
"list": "The plan in words"
|
||||
},
|
||||
"forecast": {
|
||||
"name": "Forecast",
|
||||
"day": "Day",
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"sun": "Solar and house",
|
||||
"unit": "kWh per hour",
|
||||
"forecast": "Solar forecast",
|
||||
"measured": "Measured solar",
|
||||
"house": "House use",
|
||||
"levels": "Battery level",
|
||||
"table": "Hour by hour",
|
||||
"hour": "Hour",
|
||||
"solar": "Solar",
|
||||
"got": "Measured",
|
||||
"use": "House",
|
||||
"loading": "Loading the forecast."
|
||||
},
|
||||
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
|
||||
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
|
||||
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
|
||||
@@ -85,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}."}},
|
||||
@@ -339,10 +357,10 @@
|
||||
"no_prices": "No prices, so it follows the house.",
|
||||
"no_spread": "Prices differ too little, so it follows the house.",
|
||||
"sun_fills": "Enough sun is forecast, so it does not charge from the grid.",
|
||||
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
|
||||
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
|
||||
"no_forecast": "No solar forecast, so it charges from solar only."
|
||||
},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"today": {
|
||||
"not_planned": "Not planned yet.",
|
||||
"no_runs": "No cheap or expensive hours left today.",
|
||||
@@ -367,11 +385,14 @@
|
||||
"run": {
|
||||
"grid_what": "Charges up to {kwh} from the grid.",
|
||||
"grid_what_would": "Would charge up to {kwh} from the grid.",
|
||||
"grid_why": "Lowest published price, {eur} per kWh.",
|
||||
"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.",
|
||||
"give_what_would": "Would give up to {kwh}, to the house first.",
|
||||
"give_why": "Highest published price, {eur} per kWh.",
|
||||
"give_why": "Highest price left, {eur} per kWh.",
|
||||
"sell_what": "Sells up to {kwh}, to the house first.",
|
||||
"sell_what_would": "Would sell up to {kwh}, to the house first.",
|
||||
"cheap_why": "Cheapest hours, about {eur} per kWh.",
|
||||
@@ -416,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}.",
|
||||
@@ -425,6 +449,22 @@
|
||||
"known": "Tomorrow's prices are in.",
|
||||
"unknown": "Tomorrow's prices usually arrive after 13:00."
|
||||
},
|
||||
"day": {
|
||||
"modes_off": "Battery modes are off. No forecast is read and no level is planned.",
|
||||
"not_set": "No solar forecast is set up. Without one, no battery level is planned.",
|
||||
"no_forecast": "The solar forecast is missing or too old. The levels return when it is back.",
|
||||
"learning": "The add-on is learning your house's use. The levels start after one full day.",
|
||||
"no_tomorrow": "There is no solar forecast for tomorrow yet. The levels stop at {at}.",
|
||||
"estimate": "The forecast gives one total for the day. The add-on spreads it over the hours.",
|
||||
"trust": "The levels count on {pct} % of the forecast.",
|
||||
"cycle": "{name} does its monthly cycle. Its level is not shown once the cycle starts.",
|
||||
"no_level": "{name} has no level reading yet. Its level is not shown.",
|
||||
"measured_from": "Measured solar is counted from {at}, when the add-on started.",
|
||||
"tomorrow_total": "Solar expected tomorrow: {kwh}, we count on {count}.",
|
||||
"no_blocks": "No charge or give blocks planned.",
|
||||
"give_later": "The add-on plans to give power. No block shows while a battery has no energy to give.",
|
||||
"ahead": "The add-on plans about a day ahead. Blocks for later hours appear as they come near."
|
||||
},
|
||||
"region": {
|
||||
"preset": "Levies and VAT: {region}, {year} figures.",
|
||||
"own": "Levies and VAT: your own figure."
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"applies": {"restart": {"name": "Geldt na een herstart"}, "now": {"name": "Geldt meteen"}},
|
||||
"home": {
|
||||
"app": {"name": "Batterij-add-on"},
|
||||
"tabs": {"home": "Start", "batteries": "Batterijen", "settings": "Instellingen", "setup": "Installatie", "label": "Schermen"},
|
||||
"tabs": {"home": "Start", "batteries": "Batterijen", "forecast": "Prognose", "settings": "Instellingen", "setup": "Installatie", "label": "Schermen"},
|
||||
"now": {"name": "Nu", "description": "Elke 3 seconden bijgewerkt.", "solar": "Zonnepanelen", "house": "Huis", "grid": "Net", "batteries": "Batterijen"},
|
||||
"plan": {
|
||||
"name": "Plan voor vandaag",
|
||||
@@ -65,6 +65,24 @@
|
||||
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."},
|
||||
"list": "Het plan in woorden"
|
||||
},
|
||||
"forecast": {
|
||||
"name": "Prognose",
|
||||
"day": "Dag",
|
||||
"today": "Vandaag",
|
||||
"tomorrow": "Morgen",
|
||||
"sun": "Zon en huis",
|
||||
"unit": "kWh per uur",
|
||||
"forecast": "Zonneprognose",
|
||||
"measured": "Gemeten zon",
|
||||
"house": "Huisverbruik",
|
||||
"levels": "Laadniveau",
|
||||
"table": "Uur per uur",
|
||||
"hour": "Uur",
|
||||
"solar": "Zon",
|
||||
"got": "Gemeten",
|
||||
"use": "Huis",
|
||||
"loading": "De prognose wordt geladen."
|
||||
},
|
||||
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
|
||||
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
|
||||
"control": {"name": "Regeling", "stop": "Regeling stoppen", "start": "Regeling starten", "maint_start": "Nu een batterijcyclus starten", "maint_abort": "Batterijcyclus stoppen", "docs": "Documentatie van de add-on", "more": "Meer acties"},
|
||||
@@ -82,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}."}},
|
||||
@@ -279,10 +297,10 @@
|
||||
"no_prices": "Geen prijzen, dus ze volgt het huis.",
|
||||
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis.",
|
||||
"sun_fills": "Er is genoeg zon voorspeld, dus ze laadt niet van het net.",
|
||||
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
|
||||
"tops_up": "Ze laadt van het net tot {pct}. Dat dekt {kwh} dat de zon in dure uren niet levert.",
|
||||
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
|
||||
},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"today": {
|
||||
"not_planned": "Nog niet gepland.",
|
||||
"no_runs": "Vandaag geen goedkope of dure uren meer.",
|
||||
@@ -307,11 +325,14 @@
|
||||
"run": {
|
||||
"grid_what": "Laadt tot {kwh} van het net.",
|
||||
"grid_what_would": "Zou tot {kwh} van het net laden.",
|
||||
"grid_why": "Laagste gepubliceerde prijs, {eur} per kWh.",
|
||||
"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.",
|
||||
"give_what_would": "Zou tot {kwh} leveren, eerst aan het huis.",
|
||||
"give_why": "Hoogste gepubliceerde prijs, {eur} per kWh.",
|
||||
"give_why": "Hoogste prijs die nog over is, {eur} per kWh.",
|
||||
"sell_what": "Verkoopt tot {kwh}, eerst aan het huis.",
|
||||
"sell_what_would": "Zou tot {kwh} verkopen, eerst aan het huis.",
|
||||
"cheap_why": "Goedkoopste uren, ongeveer {eur} per kWh.",
|
||||
@@ -351,8 +372,27 @@
|
||||
"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": {
|
||||
"modes_off": "Batterijmodi staan uit. Er wordt geen prognose gelezen en geen niveau gepland.",
|
||||
"not_set": "Er is geen zonneprognose ingesteld. Zonder prognose wordt geen laadniveau gepland.",
|
||||
"no_forecast": "De zonneprognose ontbreekt of is te oud. De niveaus komen terug als ze er weer is.",
|
||||
"learning": "De add-on leert het verbruik van uw huis. De niveaus starten na één volle dag.",
|
||||
"no_tomorrow": "Er is nog geen zonneprognose voor morgen. De niveaus stoppen om {at}.",
|
||||
"estimate": "De prognose geeft één totaal voor de dag. De add-on verdeelt het over de uren.",
|
||||
"trust": "De niveaus rekenen op {pct} % van de prognose.",
|
||||
"cycle": "{name} doet de maandelijkse cyclus. Het niveau wordt niet getoond zodra de cyclus start.",
|
||||
"no_level": "{name} geeft nog geen laadniveau door. Het niveau wordt niet getoond.",
|
||||
"measured_from": "Gemeten zon telt vanaf {at}, toen de add-on startte.",
|
||||
"tomorrow_total": "Verwachte zon morgen: {kwh}, we rekenen op {count}.",
|
||||
"no_blocks": "Geen laad- of leverblokken gepland.",
|
||||
"give_later": "De add-on plant stroom te leveren. Er verschijnt geen blok zolang een batterij niets kan leveren.",
|
||||
"ahead": "De add-on plant ongeveer een dag vooruit. Blokken voor latere uren verschijnen als ze naderen."
|
||||
},
|
||||
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
|
||||
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
|
||||
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
|
||||
|
||||
@@ -17,7 +17,7 @@ from . import uilayout as L
|
||||
from . import uitext
|
||||
from . import uimodel as U
|
||||
from .configui import help_of, label_of, problems_by_field, split_group
|
||||
from .validate import power_request_entities
|
||||
from .validate import note_text, power_request_entities
|
||||
|
||||
# A choice the page cannot make, only keep. `price_service` needs a token, and
|
||||
# the token is a secret this page never draws (uilayout.HIDDEN), so it is set on
|
||||
@@ -314,7 +314,7 @@ def anchors(problems, notes, model, keys, lang="en") -> list:
|
||||
add(None, None, "bad", text)
|
||||
for note in notes:
|
||||
tier = note.tier if note.tier in _TIERS else "info"
|
||||
text = messages.NOTE_NL.get(note.text, note.text) if lang == "nl" else note.text
|
||||
text = note_text(note, lang)
|
||||
for key in note.keys or (None,):
|
||||
for field, card in (targets(key, note.text, model) if key
|
||||
else [(None, None)]):
|
||||
|
||||
@@ -386,12 +386,23 @@ class Note(NamedTuple):
|
||||
# also "ok" and "bad" (setup_checks)
|
||||
keys: tuple
|
||||
text: str
|
||||
nl: str = "" # UX-46: the same sentence in Dutch
|
||||
|
||||
|
||||
def _advise(notes, tier: str, keys, text: str) -> None:
|
||||
"""Add a Note when the caller collects them (the settings page)."""
|
||||
def _advise(notes, tier: str, keys, text: str, nl: str = "") -> None:
|
||||
"""Add a Note when the caller collects them (the settings page). `nl` is
|
||||
the Dutch twin of `text`; the page picks one (`note_text()`)."""
|
||||
if notes is not None:
|
||||
notes.append(Note(tier, tuple(keys), text))
|
||||
notes.append(Note(tier, tuple(keys), text, nl))
|
||||
|
||||
|
||||
def note_text(note, lang: str = "en") -> str:
|
||||
"""The note's sentence in the page's language, English when it has no
|
||||
Dutch twin."""
|
||||
if lang == "nl":
|
||||
return getattr(note, "nl", "") or messages.NOTE_NL.get(
|
||||
note.text, note.text)
|
||||
return note.text
|
||||
|
||||
|
||||
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
|
||||
@@ -628,6 +639,11 @@ def S(key: str, where: str = "") -> str:
|
||||
return messages.setting(key, where.strip().strip("()"))
|
||||
|
||||
|
||||
def SN(key: str, where: str = "") -> str:
|
||||
"""`S()` with the Dutch label, for the Dutch twin of a note."""
|
||||
return messages.setting(key, where.strip().strip("()"), "nl")
|
||||
|
||||
|
||||
def _labels(keys, joiner: str = "and") -> str:
|
||||
"""Several settings, each with its key: the config panel files a refusal
|
||||
under every key it names (`configui.problems_by_field()`)."""
|
||||
@@ -1216,7 +1232,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||
integ_w, max_w)
|
||||
_advise(notes, "warn", ("integrator_max_w",),
|
||||
f"{S('integrator_max_w')} is above {S('max_w')}, which only "
|
||||
"adds overshoot, so set it to 0.")
|
||||
"adds overshoot, so set it to 0.",
|
||||
nl=f"{SN('integrator_max_w')} is hoger dan {SN('max_w')}, wat "
|
||||
"alleen doorschieten geeft, dus zet het op 0.")
|
||||
|
||||
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
|
||||
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
|
||||
@@ -1244,7 +1262,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||
_advise(notes, "warn", ("maintenance_discharge_w",),
|
||||
f"{S('maintenance_discharge_w')} is above "
|
||||
f"{S('max_w', maint_where)}, so the battery empties at "
|
||||
f"{messages.watts(maint_cap)} and the cycle takes longer.")
|
||||
f"{messages.watts(maint_cap)} and the cycle takes longer.",
|
||||
nl=f"{SN('maintenance_discharge_w')} is hoger dan "
|
||||
f"{SN('max_w', maint_where)}, dus de batterij loopt leeg met "
|
||||
f"{messages.watts(maint_cap)} en de cyclus duurt langer.")
|
||||
_LOG.debug(
|
||||
"Not refused: the drain phase sets the setpoint, the arbiter "
|
||||
"clamps it to %.0f W and names the clamp, and the drain phase has "
|
||||
@@ -1473,7 +1494,9 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
TAXES_PROFILE_OPTION, negative)
|
||||
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
|
||||
f"{S(TAXES_PROFILE_OPTION)} has negative figures, which "
|
||||
"lower the price, and that is usually a sign error.")
|
||||
"lower the price, and that is usually a sign error.",
|
||||
nl=f"{SN(TAXES_PROFILE_OPTION)} heeft negatieve bedragen, "
|
||||
"die de prijs verlagen, en dat is meestal een tekenfout.")
|
||||
|
||||
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||
# STARTUP RATHER THAN AT 03:00.
|
||||
@@ -1532,7 +1555,10 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
_advise(notes, "warn", ("price_formula", "price_provider"),
|
||||
f"{S('price_formula')} is not used, because "
|
||||
f"{S('price_provider')} is not 'custom_formula', so clear "
|
||||
"it or choose that source.")
|
||||
"it or choose that source.",
|
||||
nl=f"{SN('price_formula')} wordt niet gebruikt, omdat "
|
||||
f"{SN('price_provider')} niet 'custom_formula' is, dus wis "
|
||||
"ze of kies die bron.")
|
||||
if spec is not None and pid == "custom_formula":
|
||||
taxes = _number(opts, "price_taxes_eur_kwh",
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
@@ -1547,7 +1573,8 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
# cannot start.
|
||||
_LOG.warning("%s", aged)
|
||||
_advise(notes, "warn", ("price_formula",),
|
||||
_card_note("price_formula", spec))
|
||||
_card_note("price_formula", spec),
|
||||
nl=_card_note("price_formula", spec, "nl"))
|
||||
|
||||
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
|
||||
#
|
||||
@@ -1630,7 +1657,10 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
xspec.offset_eur_kwh)
|
||||
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
|
||||
f"{S(EXPORT_FORMULA_OPTION)} has no card block, so "
|
||||
"only the shape of its numbers is checked.")
|
||||
"only the shape of its numbers is checked.",
|
||||
nl=f"{SN(EXPORT_FORMULA_OPTION)} heeft geen kaartblok, "
|
||||
"dus alleen de vorm van de getallen wordt "
|
||||
"gecontroleerd.")
|
||||
_LOG.debug(
|
||||
"Accepted because some suppliers (Eneco) print the "
|
||||
"injection formula with no price beside it. If your card "
|
||||
@@ -1643,7 +1673,8 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
if xaged:
|
||||
_LOG.warning("%s", xaged)
|
||||
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
|
||||
_card_note(EXPORT_FORMULA_OPTION, xspec))
|
||||
_card_note(EXPORT_FORMULA_OPTION, xspec),
|
||||
nl=_card_note(EXPORT_FORMULA_OPTION, xspec, "nl"))
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
@@ -1657,13 +1688,16 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
"fallback.", pid)
|
||||
_advise(notes, "warn", ("static_profile",),
|
||||
f"{S('static_profile')} is empty, so the site has no tariff "
|
||||
"at all while the price feed is down.")
|
||||
"at all while the price feed is down.",
|
||||
nl=f"{SN('static_profile')} is leeg, dus de installatie heeft "
|
||||
"helemaal geen tarief zolang de prijsfeed uitvalt.")
|
||||
|
||||
# PRICE-01: not a fault, so an info note and no log line here (the add-on
|
||||
# logs it once at start). With `price_service` the fixed tariff is only the
|
||||
# plan's last fallback; the cheap window and the gate do not read it.
|
||||
if pid == "price_service" and str(opts.get("static_profile", "") or "").strip():
|
||||
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE)
|
||||
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE,
|
||||
nl=messages.FIXED_TARIFF_NOTE_NL)
|
||||
|
||||
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
||||
# rather than logged, because a strategy that is armed in the UI and cannot
|
||||
@@ -1735,7 +1769,11 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
def _card_note(option: str, spec) -> str:
|
||||
def _card_note(option: str, spec, lang: str = "en") -> str:
|
||||
if lang == "nl":
|
||||
return (f"{SN(option)} gebruikt een tariefkaart van "
|
||||
f"{spec.card_month}, dus neem de nieuwe cijfers over als uw "
|
||||
"contract veranderde.")
|
||||
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
|
||||
"the new figures if your contract changed.")
|
||||
|
||||
@@ -1810,7 +1848,10 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
||||
_advise(notes, "warn", ("peak_cap_w",),
|
||||
f"This month's peak is already {messages.watts(month_peak_w)}, "
|
||||
f"so a lower {S('peak_cap_w')} saves nothing until next month.")
|
||||
f"so a lower {S('peak_cap_w')} saves nothing until next month.",
|
||||
nl=f"De piek van deze maand is al "
|
||||
f"{messages.watts(month_peak_w)}, dus een lagere "
|
||||
f"{SN('peak_cap_w')} bespaart niets tot volgende maand.")
|
||||
return notes
|
||||
|
||||
|
||||
@@ -1823,31 +1864,43 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
if row is None:
|
||||
_advise(notes, "bad" if key in bad else "warn", (key,),
|
||||
f"Home Assistant has no entity {entity}, which "
|
||||
f"{S(key, where)} names.")
|
||||
f"{S(key, where)} names.",
|
||||
nl=f"Home Assistant kent {entity} niet, maar "
|
||||
f"{SN(key, where)} verwijst ernaar.")
|
||||
continue
|
||||
unit = str(row.get("unit") or "")
|
||||
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
"taken as watts, so pick a sensor in W.",
|
||||
nl=f"{entity} meldt in {unit}, maar {SN(key, where)} wordt "
|
||||
"als watt gelezen, dus kies een sensor in W.")
|
||||
if key == "peak_forecast_entity" and unit not in pv.WATTS_PER:
|
||||
_advise(notes, "warn", (key,),
|
||||
messages.peak_forecast_unit(unit) + " Pick a sensor in "
|
||||
"W or kW.")
|
||||
"W or kW.",
|
||||
nl=messages.peak_forecast_unit(unit, "nl")
|
||||
+ " Kies een sensor in W of kW.")
|
||||
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} does not read in kWh, so pick a solar "
|
||||
"forecast sensor in kWh.")
|
||||
"forecast sensor in kWh.",
|
||||
nl=f"{entity} meet niet in kWh, dus kies een sensor voor "
|
||||
"de zonneprognose in kWh.")
|
||||
today = solarfc.entities(opts)
|
||||
tomorrow = solarfc.entities(opts, tomorrow=True)
|
||||
if (today or tomorrow) and \
|
||||
opts.get("battery_modes_enabled") is not True:
|
||||
_advise(notes, "info", ("pv_forecast_entity",),
|
||||
"The solar forecast is used only with battery modes on.")
|
||||
"The solar forecast is used only with battery modes on.",
|
||||
nl="De zonneprognose wordt alleen gebruikt met batterijmodi "
|
||||
"aan.")
|
||||
if tomorrow and not today:
|
||||
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
|
||||
"Tomorrow's forecast is used only with today's, so set "
|
||||
f"{S('pv_forecast_entity')} too.")
|
||||
f"{S('pv_forecast_entity')} too.",
|
||||
nl="De prognose voor morgen wordt alleen met die van vandaag "
|
||||
f"gebruikt, dus stel ook {SN('pv_forecast_entity')} in.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1926,7 +1979,9 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
if entities is None:
|
||||
# Red: only the list can tell a missing power request entity (SAFETY-77).
|
||||
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
|
||||
"answering, so press Check again once it is back.")
|
||||
"answering, so press Check again once it is back.",
|
||||
nl="Home Assistant antwoordt niet, dus druk op Opnieuw "
|
||||
"nakijken zodra het terug is.")
|
||||
else:
|
||||
# A missing meter entity is red too (UX-15): with the kettle test
|
||||
# skipped, nothing else on the way to the save would catch it.
|
||||
@@ -1948,13 +2003,16 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
solar.append(None)
|
||||
_advise(notes, "warn", ("pv_invert_entities",),
|
||||
f"{entity} reads below zero now, so turn on its "
|
||||
"“Reads negative” switch in the Solar step.")
|
||||
"“Reads negative” switch in the Solar step.",
|
||||
nl=f"{entity} meet nu onder nul, dus zet de schakelaar "
|
||||
"“Meet negatief” aan bij de stap Zonnepanelen.")
|
||||
else:
|
||||
w = -w if entity in flipped else w
|
||||
solar.append(w)
|
||||
_advise(notes, "ok", ("pv_entity",),
|
||||
f"{entity} reads {_signed(w)} of solar production "
|
||||
"now.")
|
||||
"now.",
|
||||
nl=f"{entity} meet nu {_signed(w)} zonneproductie.")
|
||||
# A battery sensor that reads the wrong way while giving power makes the
|
||||
# house use less than nothing. Only with every term known: a missing
|
||||
# solar reading could be the difference.
|
||||
@@ -1964,7 +2022,10 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
_advise(notes, "warn", ("batt_invert",),
|
||||
f"The readings add up to {_signed(sum(known))} of house "
|
||||
"use, so check each "
|
||||
"battery's “Battery power sign is reversed” switch in step 2.")
|
||||
"battery's “Battery power sign is reversed” switch in step 2.",
|
||||
nl=f"De metingen tellen op tot {_signed(sum(known))} "
|
||||
"huisverbruik, dus controleer bij elke batterij de schakelaar "
|
||||
"“Teken batterijvermogen omkeren” in stap 2.")
|
||||
_price_check(notes, opts, running, prices_known)
|
||||
return notes
|
||||
|
||||
@@ -1988,7 +2049,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
return float(meter_now_w)
|
||||
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
||||
"this meter itself, so Home shows its reading after "
|
||||
"the restart.")
|
||||
"the restart.",
|
||||
nl="De add-on leest deze meter zelf, dus Start toont de "
|
||||
"meting na de herstart.")
|
||||
return None
|
||||
if entities is None:
|
||||
return None
|
||||
@@ -2001,7 +2064,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("meter_source",),
|
||||
f"{entity} reads {text} now, so check it in Home "
|
||||
"Assistant before you start.")
|
||||
"Assistant before you start.",
|
||||
nl=f"{entity} meldt nu {text}, dus controleer ze in Home "
|
||||
"Assistant voor u start.")
|
||||
return None
|
||||
total += sign * w
|
||||
if source == "off" and opts.get("meter_invert"):
|
||||
@@ -2012,12 +2077,14 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
|
||||
def _meter_line(notes, w) -> None:
|
||||
if abs(w) < 1.0:
|
||||
text = "The meter reads 0 W now."
|
||||
text, nl = "The meter reads 0 W now.", "De meter meldt nu 0 W."
|
||||
elif w > 0:
|
||||
text = f"The meter reads {messages.watts(w)} taken from the grid now."
|
||||
nl = f"De meter meldt nu {messages.watts(w)} afname van het net."
|
||||
else:
|
||||
text = f"The meter reads {messages.watts(w)} fed into the grid now."
|
||||
_advise(notes, "ok", ("meter_source",), text)
|
||||
nl = f"De meter meldt nu {messages.watts(w)} injectie op het net."
|
||||
_advise(notes, "ok", ("meter_source",), text, nl=nl)
|
||||
|
||||
|
||||
def _card_checks(notes, who, entry, entities):
|
||||
@@ -2025,40 +2092,55 @@ def _card_checks(notes, who, entry, entities):
|
||||
positive while giving power, or None when it is not known."""
|
||||
if entities is None:
|
||||
return None
|
||||
wie = who.replace(" for ", " voor ", 1)
|
||||
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
||||
if setpoint and setpoint in entities:
|
||||
_advise(notes, "ok", ("setpoint_entity",),
|
||||
f"Home Assistant has {setpoint}, the power request "
|
||||
f"entity{who}.")
|
||||
f"entity{who}.",
|
||||
nl=f"Home Assistant heeft {setpoint}, de entiteit voor de "
|
||||
f"vermogensvraag{wie}.")
|
||||
soc = str(entry.get("soc_entity") or "").strip()
|
||||
if not soc:
|
||||
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
|
||||
f"level entity{who}, so pick one in step 2 or the add-on "
|
||||
"holds 0 W.")
|
||||
"holds 0 W.",
|
||||
nl=f"Er is geen entiteit voor het laadniveau{wie}, dus kies er "
|
||||
"een in stap 2 of de add-on houdt 0 W aan.")
|
||||
elif soc in entities:
|
||||
level, _unit = _reading(entities, soc)
|
||||
if level is None or not 0.0 <= level <= 100.0:
|
||||
_advise(notes, "warn", ("soc_entity",), f"The battery "
|
||||
f"level{who} reads nothing now, so check {soc}.")
|
||||
f"level{who} reads nothing now, so check {soc}.",
|
||||
nl=f"Het laadniveau{wie} meldt niets, dus controleer "
|
||||
f"{soc}.")
|
||||
else:
|
||||
_advise(notes, "ok", ("soc_entity",),
|
||||
f"The battery level{who} reads {level:.0f} %.")
|
||||
f"The battery level{who} reads {level:.0f} %.",
|
||||
nl=f"Het laadniveau{wie} meldt {level:.0f} %.")
|
||||
batt = str(entry.get("batt_entity") or "").strip()
|
||||
if batt and batt in entities:
|
||||
w, _unit = _reading(entities, batt)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("batt_entity",), f"The battery "
|
||||
f"power{who} reads nothing now, so check {batt}.")
|
||||
f"power{who} reads nothing now, so check {batt}.",
|
||||
nl=f"Het batterijvermogen{wie} meldt niets, dus controleer "
|
||||
f"{batt}.")
|
||||
return None
|
||||
w = -w if entry.get("batt_invert") else w
|
||||
if abs(w) < IDLE_W:
|
||||
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
|
||||
"idle now, so check its direction on Home later.")
|
||||
"idle now, so check its direction on Home later.",
|
||||
nl=f"De batterij{wie} staat nu stil, dus controleer later "
|
||||
"op Start de richting.")
|
||||
else:
|
||||
doing = "giving power" if w > 0 else "charging"
|
||||
doet = "levert" if w > 0 else "laadt"
|
||||
_advise(notes, "info", ("batt_invert",),
|
||||
f"The battery{who} reads {messages.watts(w)} {doing} "
|
||||
"now, so check the inverter shows the same.")
|
||||
"now, so check the inverter shows the same.",
|
||||
nl=f"De batterij{wie} {doet} nu {messages.watts(w)}, dus "
|
||||
"controleer of de omvormer hetzelfde toont.")
|
||||
return w
|
||||
return None
|
||||
|
||||
@@ -2070,20 +2152,28 @@ def _price_check(notes, opts, running, prices_known) -> None:
|
||||
if profile:
|
||||
_advise(notes, "ok", ("static_profile",),
|
||||
"Your prices come from your contract, so they are "
|
||||
"always known.")
|
||||
"always known.",
|
||||
nl="Uw prijzen komen uit uw contract, dus ze zijn altijd "
|
||||
"bekend.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"No prices are set, so the batteries do not charge "
|
||||
"on price.")
|
||||
"on price.",
|
||||
nl="Er zijn geen prijzen ingesteld, dus de batterijen "
|
||||
"laden niet op prijs.")
|
||||
return
|
||||
same = (isinstance(running, dict)
|
||||
and str(running.get("price_provider") or "") == provider)
|
||||
if same and prices_known:
|
||||
_advise(notes, "ok", ("price_provider",),
|
||||
f"Prices for today are in, for {prices_known} of 24 hours.")
|
||||
f"Prices for today are in, for {prices_known} of 24 hours.",
|
||||
nl=f"De prijzen van vandaag zijn binnen, voor {prices_known} "
|
||||
"van 24 uur.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"Prices are fetched after the restart, and Home shows them.")
|
||||
"Prices are fetched after the restart, and Home shows them.",
|
||||
nl="De prijzen worden na de herstart opgehaald, en Start toont "
|
||||
"ze.")
|
||||
|
||||
|
||||
def _margin_problems(opts: dict) -> list[str]:
|
||||
|
||||
@@ -51,7 +51,7 @@ from aiohttp import web
|
||||
from . import messages, price, selfconfig, uipage, uitext
|
||||
from .haentities import EntityCache
|
||||
from .homeview import priced_hours, region_of
|
||||
from .validate import config_notes, setup_checks
|
||||
from .validate import config_notes, note_text, setup_checks
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
|
||||
problems_by_field, secret_keys)
|
||||
@@ -105,6 +105,9 @@ def page() -> str:
|
||||
DEVICE_NOTE = (
|
||||
"%s did not report its power range, so two checks were skipped, here and "
|
||||
"at start. If they fail later, the add-on will not start then.")
|
||||
DEVICE_NOTE_NL = (
|
||||
"%s meldde geen vermogensbereik, dus twee controles zijn overgeslagen, "
|
||||
"hier en bij het starten. Falen ze later, dan start de add-on niet.")
|
||||
|
||||
|
||||
def build_app(controller, selfcfg=None) -> web.Application:
|
||||
@@ -147,6 +150,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
return web.json_response(controller.status_payload())
|
||||
return web.json_response(controller.status_payload(lang=lang))
|
||||
|
||||
async def forecast(request):
|
||||
"""UX-54: the Forecast tab's data, built when the tab asks. Reads only."""
|
||||
build = getattr(controller, "forecast_payload", None)
|
||||
lang = uitext.lang_of(request.query.get("lang"))
|
||||
return web.json_response(build(lang=lang) if build else {"ok": False})
|
||||
|
||||
async def action(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -301,22 +310,27 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
# UX-13: warnings and info beside the refusals. Never block a save.
|
||||
# The entity checks use the picker's cached list and never wait on HA.
|
||||
notes += config_notes(options, entities=entities.peek())
|
||||
# UX-46: one language choice for everything below that has words.
|
||||
lang = uitext.lang_of(_body.get("lang"))
|
||||
payload = {"problems": problems, "by_key": by_key,
|
||||
"unanchored": unanchored,
|
||||
"notes": [n._asdict() for n in notes]}
|
||||
if unread:
|
||||
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
|
||||
payload["device_note"] = (DEVICE_NOTE_NL if lang == "nl"
|
||||
else DEVICE_NOTE) % ", ".join(
|
||||
sorted(set(unread)))
|
||||
if model is not None:
|
||||
payload["messages"] = (
|
||||
uipage.derive_messages(errors, problems, notes, model,
|
||||
_keys_of(options),
|
||||
uitext.lang_of(_body.get("lang")))
|
||||
_keys_of(options), lang)
|
||||
if errors
|
||||
else uipage.anchors(problems, notes, model, _keys_of(options),
|
||||
uitext.lang_of(_body.get("lang"))))
|
||||
lang))
|
||||
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
||||
if _body.get("setup"):
|
||||
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
|
||||
payload["setup"] = [
|
||||
dict(n._asdict(), text=note_text(n, lang))
|
||||
for n in await _setup_checks(options)]
|
||||
return web.json_response(payload)
|
||||
|
||||
async def _setup_checks(options):
|
||||
@@ -463,6 +477,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
app.add_routes([
|
||||
web.get("/", index),
|
||||
web.get("/status", status),
|
||||
web.get("/forecast", forecast),
|
||||
web.post("/action", action),
|
||||
web.get("/config", config_index),
|
||||
web.get("/config/model", config_model),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.18.2"
|
||||
version: "0.19.2"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
|
||||
@@ -109,6 +109,11 @@ from app.validate import validate_startup_config
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
||||
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
||||
fails = []
|
||||
|
||||
|
||||
@@ -194,13 +199,22 @@ NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
||||
|
||||
|
||||
def plan_of(prices, *, product=PriceProduct.DAY_AHEAD, start=D0,
|
||||
provisional=False, provider="generic_dayahead"):
|
||||
provisional=False, provider="generic_dayahead", days=2):
|
||||
"""A TariffPlan over consecutive hourly prices starting at `start`.
|
||||
|
||||
`provisional` may be a single bool or a per-hour sequence.
|
||||
|
||||
PRICE-02: the verdict skips price periods that have ended, so a plan
|
||||
probed at 18:30 no longer sees the cheap hour at 03:00 of the same day. A
|
||||
running site holds 24-48 h, so `days=2` repeats the day (tomorrow's
|
||||
prices are today's): probes in day one keep their extremes, and the
|
||||
first cheapest/dearest hour still ahead is today's. Pass `days=1` for a
|
||||
plan that must be exactly the given hours.
|
||||
"""
|
||||
prices = list(prices) * days
|
||||
flags = ([bool(provisional)] * len(prices)
|
||||
if isinstance(provisional, bool) else list(provisional))
|
||||
if isinstance(provisional, bool)
|
||||
else list(provisional) * days)
|
||||
pts = [PricePoint(start=brussels(start + timedelta(hours=i)),
|
||||
eur_per_kwh=round(float(p), 6), provisional=f)
|
||||
for i, (p, f) in enumerate(zip(prices, flags))]
|
||||
@@ -344,7 +358,7 @@ check(f"a realistic Belgian day/night profile has a spread of "
|
||||
f"{_st_spread!r} EUR/kWh - the number the product decision is about",
|
||||
near(_st_spread, 0.08))
|
||||
_shipped = PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"]
|
||||
_st_verdict = derived(lambda: _st_plan.arbitrage(
|
||||
_st_verdict = derived(lambda: _st_plan.arbitrage(now=LONG_AGO,
|
||||
peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER))
|
||||
check(f"...and at the SHIPPED threshold of {_shipped} EUR/kWh that site does "
|
||||
@@ -361,7 +375,7 @@ check(f"...and it is the SPREAD rung that refuses it, not a flat-tariff or a "
|
||||
check("...while the SAME site trades once the threshold is set below its "
|
||||
"spread, so the refusal above is about the number and not about static "
|
||||
"tariffs being unsupported",
|
||||
derived(lambda: _st_plan.arbitrage(
|
||||
derived(lambda: _st_plan.arbitrage(now=LONG_AGO,
|
||||
peak_guard_active=False,
|
||||
min_spread_eur_kwh=_st_spread / widen_factor(NO_WEATHER) - 1e-4,
|
||||
uncertainty=NO_WEATHER).kind) == "allowed")
|
||||
@@ -922,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}")
|
||||
@@ -930,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
|
||||
@@ -946,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)))
|
||||
|
||||
|
||||
@@ -1644,7 +1659,7 @@ check("a FULLY carried series trades nothing at all, whatever its spread - "
|
||||
# standing if a future caller ever hands `_act` a plan the verdict did not come
|
||||
# from.
|
||||
_probe = _arb._point_at(PLAN, hour(CHEAP_H))
|
||||
_verdict = PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
_verdict = PLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset())
|
||||
check("the point matched for the buy hour IS the verdict's own cheapest point, "
|
||||
"by identity - which is why `_act`'s provisional guard is unreachable "
|
||||
@@ -1665,6 +1680,14 @@ print("4b. §10.2 hysteresis - a price on the boundary cannot flap at 30 s")
|
||||
# threshold repeatedly, hour to hour, which is the shape that makes a
|
||||
# per-tick price-vs-threshold comparison flap.
|
||||
FLAP = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)]
|
||||
# PRICE-02: the verdict skips periods that have ended, and this day had ties
|
||||
# (0.20 at every even hour, 0.301 at every odd one). With ties the first hour
|
||||
# still ahead is the extreme, so the verdict moves on every hour - once per
|
||||
# hour, never per tick, which test_price02.py part 6 grades. This check is
|
||||
# about a verdict that stays put while the prices keep crossing the threshold,
|
||||
# so the day gets one cheapest and one dearest hour at its end (22: 0.60,
|
||||
# 23: 0.05), unique and ahead of every probe until they come.
|
||||
FLAP[22], FLAP[23] = 0.60, 0.05
|
||||
_flap_plan = plan_of(FLAP)
|
||||
check("the flapping fixture really does straddle the threshold hour by hour - "
|
||||
"every neighbouring pair differs by more than it",
|
||||
@@ -1846,7 +1869,15 @@ def replay(threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
||||
neg_sells = 0
|
||||
for n in range(365):
|
||||
day0 = REPLAY_START + timedelta(days=n)
|
||||
prices = synth_day(n)
|
||||
# PRICE-02: no two hours share a price. The synthetic day is
|
||||
# symmetric, so it prints the same maximum twice; the verdict now takes
|
||||
# the first one still AHEAD, where it used to take the first of the
|
||||
# day (a tie, graded on its own in test_price02.py part 6). So the
|
||||
# ties are broken by a jitter of at most 5e-5 EUR/kWh that has no
|
||||
# direction: not later-is-dearer, which would send every day to a
|
||||
# buy-then-sell and hide the selectivity this replay checks.
|
||||
prices = [round(p + 1e-6 * ((37 * h + 101 * n) % 50), 6)
|
||||
for h, p in enumerate(synth_day(n))]
|
||||
plan = plan_of(prices, start=day0)
|
||||
open_buy = None
|
||||
for h in range(24):
|
||||
@@ -2056,18 +2087,26 @@ check(f"BUT NOT ONE OF THE 365 DAYS HAS A NEGATIVE DEAREST HOUR "
|
||||
def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
||||
"""The shipped `decide()` over the real year. Returns the ledger, nothing else.
|
||||
|
||||
One `TariffPlan` per Brussels day, so "the cheapest hour" means the cheapest
|
||||
hour of that day - the same shape as section 7's replay, and the same shape
|
||||
a site with a day-ahead feed actually sees.
|
||||
The plan is built the way production builds it: one per hour, from that
|
||||
hour (the refresh floors to the hour) to the end of the Brussels day,
|
||||
plus the next day from 13:00 Brussels, when the day-ahead prices are
|
||||
published. So a site sees 11 to 35 hours ahead, never a whole day behind
|
||||
it and never a day on its own. (An earlier version built one plan per
|
||||
calendar day with no tomorrow: after the cheap hour no cheap hour was left
|
||||
ahead, and the ledger shrank to a few trades that no real site would see.)
|
||||
An open buy is kept over midnight: with tomorrow in view most buys are
|
||||
sold the next day or later (46 of 51 at the shipped threshold).
|
||||
"""
|
||||
soc = soc0
|
||||
trades, holdings, bad, neg_sells = [], [], [], 0
|
||||
for day in days:
|
||||
plan = TariffPlan(
|
||||
PriceRead.series(day, PriceProduct.DAY_AHEAD, "generic_dayahead",
|
||||
day[0].start), None, "provider")
|
||||
open_buy = None
|
||||
for p in day:
|
||||
open_buy = None
|
||||
for di, day in enumerate(days):
|
||||
nxt = days[di + 1] if di + 1 < len(days) else []
|
||||
for i, p in enumerate(day):
|
||||
ahead = day[i:] + (nxt if brussels(p.start).hour >= 13 else [])
|
||||
plan = TariffPlan(
|
||||
PriceRead.series(ahead, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", p.start), None, "provider")
|
||||
d = decide(plan, p.start + timedelta(minutes=30),
|
||||
peak_guard_active=False, soc=soc,
|
||||
min_spread_eur_kwh=threshold, uncertainty=unc,
|
||||
@@ -2444,7 +2483,7 @@ check("...and every one of its points is provisional, so firm_points() is "
|
||||
"a fresh observation",
|
||||
isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True
|
||||
and _t12_fb.firm_points() == ()
|
||||
and _t12_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
and _t12_fb.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=NO_WEATHER).kind == "refused")
|
||||
_t12_f.tariff_plan, _t12_f.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=1800)
|
||||
@@ -2789,7 +2828,7 @@ check(f"the shipped rule and this one give DIFFERENT points at both probes - "
|
||||
|
||||
print()
|
||||
print("10b. the buy/sell identity test matches the actual extremum interval")
|
||||
_qv = QPLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
_qv = QPLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER)
|
||||
check("the verdict's cheapest and dearest are the two QUARTERS, not the hours "
|
||||
"containing them - `static_tariff` reduces over every firm point and "
|
||||
@@ -2921,7 +2960,7 @@ check(f"...and its firm half's extremes are quarters {_mix_cheap_i} and "
|
||||
f"{_mix_dear_i}, neither on a whole hour, so the identity test below is "
|
||||
f"about the quarter and not about the hour",
|
||||
(_mix_cheap_i - MIX_N_H) % 4 != 0 and (_mix_dear_i - MIX_N_H) % 4 != 0)
|
||||
_mv = MIX_PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
_mv = MIX_PLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER)
|
||||
_mix_buy_t = qprobe(_mix_cheap_i, gaps=MIX_GAPS)
|
||||
_mix_sell_t = qprobe(_mix_dear_i, gaps=MIX_GAPS)
|
||||
@@ -2933,14 +2972,19 @@ check("the verdict over a MIXED series still names firm quarters, and "
|
||||
is MIX_PTS[_mix_cheap_i]
|
||||
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_sell_t))
|
||||
is MIX_PTS[_mix_dear_i])
|
||||
# PRICE-02: the same day twice, as a running site holds 24-48 h (see plan_of).
|
||||
# The probes stay in day one and keep their extremes: once the cheap quarter is
|
||||
# over, the verdict at the dear quarter needs a cheap one still ahead.
|
||||
MIX2_PLAN = qplan_of(MIX_PRICES * 2, provisional=MIX_FLAGS * 2,
|
||||
gaps=MIX_GAPS + [900] + MIX_GAPS)
|
||||
check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
|
||||
"their own prices",
|
||||
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
|
||||
decide(MIX2_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == CHARGE
|
||||
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
|
||||
and decide(MIX2_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
|
||||
@@ -264,7 +264,7 @@ check("trading on price: a charge block at the cheapest hour (13:00) and a "
|
||||
check("...placed where the control cycle's own verdict puts them: the same "
|
||||
"plan.arbitrage() call on the same plan",
|
||||
c_arb.fresh_tariff_plan(NOW).arbitrage(
|
||||
peak_guard_active=False, min_spread_eur_kwh=0.05,
|
||||
now=NOW, peak_guard_active=False, min_spread_eur_kwh=0.05,
|
||||
uncertainty=frozenset({__import__("app.price", fromlist=["x"])
|
||||
.Uncertainty.NO_WEATHER}))
|
||||
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
|
||||
@@ -523,7 +523,7 @@ _lo = min(_q[:96], key=lambda p: p.eur_per_kwh)
|
||||
check("quarter-hour prices: the charge block, its price and the price line's "
|
||||
"lowest are the same 15-minute period",
|
||||
_grid and _grid[0]["from"] == homeview.hhmm(_lo.start)
|
||||
and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} "
|
||||
and _grid[0]["why"] == f"Lowest price left, €{_lo.eur_per_kwh:.2f} "
|
||||
"per kWh."
|
||||
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
|
||||
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
|
||||
@@ -717,7 +717,7 @@ def _dn(arb):
|
||||
|
||||
_c_off, _p_off = _dn(False)
|
||||
_c_on, _p_on = _dn(True)
|
||||
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW))
|
||||
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW), NOW)
|
||||
check("UX-45: day/night tariff with the stored plan and price trading off: "
|
||||
"24 bars, no cheap or dear mark",
|
||||
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
|
||||
@@ -831,10 +831,12 @@ check("the page is assembled: no placeholder is left and the words parse, "
|
||||
"/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE
|
||||
and "/*UI_TEXT*/" not in PAGE
|
||||
and _WORDS["en"]["home"] == UI and set(_WORDS) == {"en", "nl"})
|
||||
check("four tabs with the four hash routes, in a keyboard tablist",
|
||||
check("five tabs with the five hash routes, in a keyboard tablist (UX-54 "
|
||||
"adds Forecast beside Home and Batteries)",
|
||||
re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries",
|
||||
"settings", "setup"]
|
||||
and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 4
|
||||
"forecast", "settings",
|
||||
"setup"]
|
||||
and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 5
|
||||
and "ArrowRight" in JS and "ArrowLeft" in JS)
|
||||
_ext = re.findall(r"""(?:https?:)?//[A-Za-z0-9.-]+\.[a-z]{2,}""", PAGE)
|
||||
check(f"no external file and no web font: no URL leaves the add-on "
|
||||
|
||||
@@ -66,22 +66,54 @@ A, B = "inverter-a", "inverter-b"
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. modes off: byte-identical to 7a00f12 on the trade path")
|
||||
# Printed by tools/opt11p3_identity.py on release/1.0 7a00f12 (2026-09-29),
|
||||
# goodwe-addon-checks:1. 1080 cycles each; the trade CHARGEs and DISCHARGEs.
|
||||
AT_7A00F12 = {
|
||||
# Printed on release/1.0 7a00f12 (2026-09-29), goodwe-addon-checks:1, 1080
|
||||
# cycles each; the trade CHARGEs and DISCHARGEs.
|
||||
#
|
||||
# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing
|
||||
# else: the plan now drops price periods that have ended, so a sale's "N above
|
||||
# the cheapest" is another number once the first cheap period is over, and the
|
||||
# sentences say "price period" for "hour". So there are three pins:
|
||||
# AT_7A00F12_OLD the digests printed on 7a00f12, sentence included. They no
|
||||
# longer match, and the check below says so: that is what
|
||||
# makes the mask necessary.
|
||||
# AT_7A00F12 the same rows through `ident.trade_digest()`, which hides
|
||||
# exactly those two things (and only for this comparison with
|
||||
# an older tree). It equals what 7a00f12 gives through it.
|
||||
# AT_NOW this tree's own digests, sentence included, with nothing
|
||||
# hidden: any later change to a command, a sentence or a
|
||||
# figure moves it. The mask cannot hide a future drift.
|
||||
AT_7A00F12_OLD = {
|
||||
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
"n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
}
|
||||
AT_7A00F12 = {
|
||||
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
||||
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||
"n1_off": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
||||
"n2_off": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||
}
|
||||
AT_NOW = {
|
||||
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
|
||||
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||
"n1_off": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
|
||||
"n2_off": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||
}
|
||||
_cmd_digest = ident.trade_digest
|
||||
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
|
||||
_got = {k: ident.digest(v) for k, v in _rows.items()}
|
||||
_got = {k: _cmd_digest(v) for k, v in _rows.items()}
|
||||
for k in AT_7A00F12:
|
||||
print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} "
|
||||
f"{_got[k][:16]}")
|
||||
_full = {k: ident.digest(v) for k, v in _rows.items()}
|
||||
check("with battery modes off (option absent or false), every command, "
|
||||
"integrator, allocation and trade sentence equals 7a00f12's, at one "
|
||||
"inverter and at two", _got == AT_7A00F12)
|
||||
"integrator, allocation and trade action equals 7a00f12's, at one "
|
||||
"inverter and at two (the sentence masked as above)",
|
||||
_got == AT_7A00F12)
|
||||
check("...the mask is needed and nothing more: unmasked, n1 and n2 differ "
|
||||
"from 7a00f12's printed digests, and unmasked they equal this tree's "
|
||||
"own pins, so a later change to any sentence or figure fails here",
|
||||
all(_full[k] != AT_7A00F12_OLD[k] for k in AT_7A00F12_OLD)
|
||||
and _full == AT_NOW)
|
||||
_acts = {r[-1][0] for r in _rows["n2"] if r[-1]}
|
||||
check("...and the runs reach the trade P3 now skips with modes on: a charge "
|
||||
"and a sale", {arbitrage.CHARGE, arbitrage.DISCHARGE} <= _acts)
|
||||
@@ -96,7 +128,7 @@ def _moved(scn, patch):
|
||||
real(c, t)
|
||||
ident._feed = feed
|
||||
try:
|
||||
return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
return _cmd_digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
!= AT_7A00F12[scn]
|
||||
finally:
|
||||
ident._feed = real
|
||||
@@ -112,7 +144,7 @@ def _moved_headroom(scn):
|
||||
real = peakguard.charge_headroom_w
|
||||
peakguard.charge_headroom_w = lambda g: None
|
||||
try:
|
||||
return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
return _cmd_digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
!= AT_7A00F12[scn]
|
||||
finally:
|
||||
peakguard.charge_headroom_w = real
|
||||
|
||||
@@ -540,6 +540,12 @@ rp.Hass.state = _sensor
|
||||
rp_start = [UTC0]
|
||||
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
|
||||
"cycle_cost_threshold_eur": 0.20}
|
||||
# TARIFF-24 round 2: with a forecast read, a price mode needs the cycle cost
|
||||
# itself (0.20 here), no longer 1.5 times it (0.30). These scenarios were
|
||||
# written for a 0.30 gap, and a midday 0.21 gap now plans a grid top-up they
|
||||
# are not about, so they ask for 0.30 with the forecast; the no-forecast leg
|
||||
# (`rows_nf`) keeps 0.20, which is 0.30 widened: the same gate on both sides.
|
||||
GATE_030 = {"cycle_cost_threshold_eur": 0.30}
|
||||
|
||||
|
||||
def seed_profile(c):
|
||||
@@ -597,7 +603,8 @@ def invariants(tag, rows, c):
|
||||
|
||||
|
||||
# Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing.
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
charge = invariants("sunny", rows, c)
|
||||
_last = nth(rows, -1, "a cycle")
|
||||
print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, "
|
||||
@@ -607,6 +614,35 @@ check("sunny: a day plan is made, and says sunny",
|
||||
for r in rows))
|
||||
check("sunny: no grid charge at all", not charge)
|
||||
|
||||
# TARIFF-24 round 2: the same day at the cycle cost 0.20 itself. With the
|
||||
# forecast read the gap needed is 0.20 (not 1.5 x = 0.30), so the midday
|
||||
# quarter 0.339 against the evening 0.551 (gap 0.21, between 1x and 1.5x) is
|
||||
# a planned top-up for B (Leftover solar) up to its planned level.
|
||||
rows_g, st_, c_g = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge_g = invariants("gap between 1x and 1.5x", rows_g, c_g)
|
||||
_first_g = nth(charge_g, 0, "a charge cycle")
|
||||
_gaps = [min(g.mean_eur_kwh for g in r["runs"].give)
|
||||
- r["runs"].cheap.mean_eur_kwh for r in charge_g
|
||||
if r["runs"] is not None and r["runs"].give]
|
||||
print(f" gap 0.20 <= gap < 0.30: {len(charge_g)} charge cycles, run gap "
|
||||
f"{min(_gaps, default=0):.3f}, B to "
|
||||
f"{max((r['soc']['b'] for r in rows_g), default=0):.1f} %")
|
||||
check("a forecast day whose price gap lies between 1x and 1.5x the cycle "
|
||||
"cost (0.20 < 0.21 < 0.30) plans a cheap run: B charges from the grid "
|
||||
"inside it, only B, and only up to its planned level",
|
||||
present(_first_g) and len(_gaps) == len(charge_g)
|
||||
and all(0.20 <= g < 0.30 for g in _gaps)
|
||||
and all(r["runs"].cheap.holds(r["now"]) for r in charge_g)
|
||||
and all(r["roles"][B] == modetrade.CHARGE for r in charge_g)
|
||||
and all(r["roles"][A] != modetrade.CHARGE for r in charge_g)
|
||||
and max(r["soc"]["b"] for r in rows_g) <= 45.64 + 2.0)
|
||||
check("...and the same day with no forecast read does not (it needs 0.30): "
|
||||
"the old gate, kept for a day without a forecast",
|
||||
not [r for r in run_day(SF_LS, fc=None,
|
||||
soc0={"a": 60.0, "b": 30.0})[0]
|
||||
if r["action"] == arbitrage.CHARGE
|
||||
and r["now"].astimezone(L).hour == 12])
|
||||
|
||||
# Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge = invariants("dark", rows, c)
|
||||
@@ -623,7 +659,8 @@ check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
|
||||
for r in charge))
|
||||
|
||||
# Cheap hours on a partly sunny day: ignores the plan, fills as in P3.
|
||||
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0})
|
||||
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
charge = invariants("cheap hours, partly", rows, c)
|
||||
top = max(r["soc"]["b"] for r in rows)
|
||||
print(f" cheap hours partly: B reached {top:.1f} %")
|
||||
@@ -648,7 +685,8 @@ check("forecast lost mid-run: Leftover solar was charging, and stops within "
|
||||
|
||||
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
|
||||
# charge, so B covers less of the evening - a money risk, never a safety one.
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0})
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
invariants("forecast too high", rows, c)
|
||||
check("forecast too high: no grid charge, and B still keeps its power for "
|
||||
"the dear run", not [r for r in rows if r["action"]
|
||||
@@ -658,7 +696,7 @@ check("forecast too high: no grid charge, and B still keeps its power for "
|
||||
|
||||
# A refusal wins over the plan.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
refuse=("b",))
|
||||
refuse=("b",), extra=GATE_030)
|
||||
invariants("refusal", rows, c)
|
||||
check("refusal: B refuses to charge, so the plan's charge ends and B is "
|
||||
"never a charger after it", rows and not [
|
||||
@@ -719,8 +757,10 @@ check("the forecast line names the forecast, what is counted and when",
|
||||
and "we count on" in sb["line"] and sb["state"] == "plan")
|
||||
why = homeview.day_key(c, B)
|
||||
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
|
||||
check("...and says so in plain words", homeview.why_words(why)
|
||||
== "Too little sun is forecast, so it charges some from the grid.")
|
||||
check("...and says so in plain words, with the level and what it covers "
|
||||
"(OPT-28)", homeview.why_words(why, pct="82 %", kwh="9.4 kWh")
|
||||
== "It charges from the grid to 82 %. That covers 9.4 kWh of "
|
||||
"expensive hours the sun will not.")
|
||||
_d0, _d1 = homeview.local_day(c.solar_fc.at)
|
||||
_blocks, _ = homeview.mode_runs(c, _d0, _d1, c.solar_fc.at, False)
|
||||
_grid = [b for b in _blocks if b["kind"] == "grid" and b["battery"] == 1]
|
||||
@@ -754,8 +794,8 @@ check("the Why line wins over 'saves its power' only while holding",
|
||||
== "grid_charge")
|
||||
for lang in ("en", "nl"):
|
||||
homeview._LANG[0] = lang
|
||||
t = [homeview.why_words(k) for k in ("sun_fills", "tops_up",
|
||||
"no_forecast")]
|
||||
t = [homeview.why_words(k, pct="82 %", kwh="9.4 kWh")
|
||||
for k in ("sun_fills", "tops_up", "no_forecast")]
|
||||
t += [homeview._t("solar.no_forecast"), homeview._t("solar.learning"),
|
||||
homeview.line_solar(9.2, 6.4, "10:15")]
|
||||
check(f"{lang}: every P4 sentence is there and short (<= 2 x 12 words)",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -99,6 +99,11 @@ from app.static_tariff import (
|
||||
TariffPlan, parse_taxes_profile, static_status)
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
||||
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
||||
fails = []
|
||||
|
||||
|
||||
@@ -4518,8 +4523,8 @@ _T11_FLAT = TariffPlan(
|
||||
[PricePoint(_T11_NOW + timedelta(minutes=15 * i), 0.10, False)
|
||||
for i in range(96)],
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
|
||||
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(**_T11_A9).reason)
|
||||
_rflat = derived(lambda: _T11_FLAT.arbitrage(**_T11_A9).reason)
|
||||
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(now=LONG_AGO, **_T11_A9).reason)
|
||||
_rflat = derived(lambda: _T11_FLAT.arbitrage(now=LONG_AGO, **_T11_A9).reason)
|
||||
print(f" §8.3 refusal : {_r83[:118]}")
|
||||
print(f" flat refusal : {_rflat[:118]}")
|
||||
# 96 quarter-hourly points span 24 hours; ONE firm point spans one of them.
|
||||
@@ -4828,7 +4833,7 @@ def _t17_verdict(points):
|
||||
read = PriceRead.series(points, PriceProduct.DAY_AHEAD, "t17",
|
||||
datetime.now(UTC))
|
||||
plan = TariffPlan(read, None, "provider")
|
||||
return plan.arbitrage(peak_guard_active=False,
|
||||
return plan.arbitrage(now=LONG_AGO, peak_guard_active=False,
|
||||
min_spread_eur_kwh=PRICE_OPTION_DEFAULTS[
|
||||
"cycle_cost_threshold_eur"],
|
||||
uncertainty=frozenset())
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
"""Runnable check for PRICE-02. `python3 test_price02.py`
|
||||
|
||||
The trading verdict is taken over the price periods that have not ended.
|
||||
`TariffPlan.arbitrage(now=...)` drops the points with `end <= now` (the span
|
||||
`price._cover_table()` gives each one) and every caller passes its `now`:
|
||||
`arbitrage.decide()` (modes off), the `allowed()` gate in
|
||||
`Controller._mode_roles()` (modes on) and the Home page's `trade_verdict()`.
|
||||
|
||||
1. A 15-minute day, probed at :05, :20, :35 and :50 of the cheap hour: the
|
||||
buy leg and the sell leg move to the current period and never name one that
|
||||
has ended, and `decide()` trades in it. Cheapest in the past, the current
|
||||
and a future period are all covered.
|
||||
2. A plan built at 03:00 and used at 03:20 (the 30-minute refresh lag), and the
|
||||
shipped refresh (`fill_from_yesterday`, which keeps the whole current hour).
|
||||
3. An hourly feed and the static day/night tariff: an hourly point that is
|
||||
still current stays in. A plan built off the hour (the refresh runs every
|
||||
30 min from add-on start, so :20 and :50 are normal) no longer aims at the
|
||||
hour that has just ended, so a trade runs through (owner, 2026-10-22), and
|
||||
a period that ends exactly at `now` is over.
|
||||
4. Modes on: a spread that exists only because of an ended period no longer
|
||||
opens the gate (`Controller._mode_roles`), nor the Home page verdict.
|
||||
5. The reason text says "price period"; `now` is required (no default).
|
||||
6. Ties. The verdict takes the first extreme still ahead, where it used to
|
||||
take the first of the whole plan. On hour-constant 15-minute data (four
|
||||
equal quarters) that is four acting periods in the cheap hour, not one;
|
||||
the number of times the action changes does not grow. The same on a real
|
||||
15-minute day, and hourly alternating prices change at most once an hour.
|
||||
|
||||
Mutations: MUTATION.md says how to run them (purged copies); the ones of
|
||||
PRICE-02 are listed in the pull request.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
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"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from app import arbitrage, homeview # noqa: E402
|
||||
from app.arbitrage import CHARGE, DISCHARGE, decide # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
Uncertainty, brussels)
|
||||
from app.price_forecast import fill_from_yesterday # noqa: E402
|
||||
from app.static_tariff import TariffPlan, static_plan # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
LONG_AGO = datetime(2000, 1, 1, tzinfo=UTC) # before every point: nothing has ended, the old reach
|
||||
NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
||||
D0 = datetime(2026, 10, 20, 0, 0, tzinfo=UTC) # 02:00 Brussels
|
||||
H3 = D0 + timedelta(hours=1) # 03:00 Brussels
|
||||
ARGS = dict(peak_guard_active=False, min_spread_eur_kwh=0.10,
|
||||
uncertainty=NO_WEATHER)
|
||||
|
||||
|
||||
def qplan(prices, start=D0, minutes=15, product=PriceProduct.DAY_AHEAD):
|
||||
pts = [PricePoint(brussels(start + timedelta(minutes=minutes * i)), p,
|
||||
False) for i, p in enumerate(prices)]
|
||||
return TariffPlan(PriceRead.series(pts, product, "generic_dayahead",
|
||||
start), None, "provider")
|
||||
|
||||
|
||||
ARGS_NO_NOW = dict(ARGS)
|
||||
ARGS5 = dict(ARGS, min_spread_eur_kwh=0.05, uncertainty=frozenset())
|
||||
|
||||
|
||||
def dec(plan, now, **kw):
|
||||
base = dict(peak_guard_active=False, charge_headroom_w=5000.0, soc=50.0,
|
||||
min_spread_eur_kwh=0.10, uncertainty=frozenset(),
|
||||
charge_w=2500.0, discharge_w=2500.0, soc_floor_pct=10.0,
|
||||
soc_ceiling_pct=90.0)
|
||||
base.update(kw)
|
||||
return decide(plan, now, **base)
|
||||
|
||||
|
||||
def hhmm(t):
|
||||
return f"{brussels(t):%H:%M}"
|
||||
|
||||
|
||||
def ahead_extreme(prices, now, pick, start=D0, minutes=15):
|
||||
"""The expected leg, by plain arithmetic: the extreme price over the
|
||||
periods whose end is after `now`, as the start instant (first on a tie)."""
|
||||
best = None
|
||||
for i, p in enumerate(prices):
|
||||
s = start + timedelta(minutes=minutes * i)
|
||||
if s + timedelta(minutes=minutes) <= now:
|
||||
continue
|
||||
if best is None or pick(p, best[1]):
|
||||
best = (s, p)
|
||||
return best[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. a 15-minute day: the verdict is taken from the current period on")
|
||||
BUY = [0.30] * 192 # 48 h of quarters from 02:00
|
||||
for q, p in ((4, 0.05), (5, 0.06), (6, 0.07), (7, 0.08), (68, 0.50)):
|
||||
BUY[q] = p # 03:00 .05 03:15 .06 03:30 .07 03:45 .08 19:00 .50
|
||||
BUY_PLAN = qplan(BUY)
|
||||
check("the fixture: the day's cheapest period is 03:00, then three dearer "
|
||||
"ones, and the dearest is 19:00 - so every probe has a past, a current "
|
||||
"and a future period to choose between",
|
||||
BUY.index(min(BUY)) == 4 and BUY.index(max(BUY)) == 68
|
||||
and BUY[4] < BUY[5] < BUY[6] < BUY[7] < min(BUY[8:68]))
|
||||
want_q = {5: 4, 20: 5, 35: 6, 50: 7}
|
||||
for mins, q in want_q.items():
|
||||
now = H3 + timedelta(minutes=mins)
|
||||
v = BUY_PLAN.arbitrage(now=now, **ARGS)
|
||||
d = dec(BUY_PLAN, now)
|
||||
got = v.cheapest.start
|
||||
exp = ahead_extreme(BUY, now, lambda a, b: a < b)
|
||||
check(f"03:{mins:02d}: the buy leg is {hhmm(exp)} (the first period not "
|
||||
f"over), the verdict says {hhmm(got)}, and decide() is {d.action}",
|
||||
got == exp == D0 + timedelta(minutes=15 * q)
|
||||
and d.action == CHARGE
|
||||
and abs(d.price_now_eur_kwh - BUY[q]) < 1e-9)
|
||||
check("before the change the same plan at 03:20 aimed at 03:00 (the "
|
||||
"verdict without `now`) and decide() did nothing - the defect, shown",
|
||||
BUY_PLAN.arbitrage(now=LONG_AGO, **ARGS).cheapest.start == H3
|
||||
and arbitrage._act(BUY_PLAN, H3 + timedelta(minutes=20),
|
||||
BUY_PLAN.arbitrage(now=LONG_AGO, **ARGS), 50.0, 2500.0, 2500.0,
|
||||
10.0, 90.0, 5000.0).action is None)
|
||||
|
||||
# A cheapest period still AHEAD stays the target from every probe.
|
||||
FUT = [0.30] * 192
|
||||
FUT[8], FUT[68] = 0.04, 0.50 # cheapest 04:00, dearest 19:00
|
||||
FUT_PLAN = qplan(FUT)
|
||||
check("the cheapest in the FUTURE (04:00) is the buy leg at :05, :20, :35 and "
|
||||
":50 of the hour before it - nothing ahead is dropped",
|
||||
all(FUT_PLAN.arbitrage(now=H3 + timedelta(minutes=m), **ARGS)
|
||||
.cheapest.start == H3 + timedelta(hours=1) for m in (5, 20, 35, 50)))
|
||||
check("...and at 04:20 (that period is now the current one) it is still the "
|
||||
"current period at :00-:15 only: 04:20 aims elsewhere",
|
||||
FUT_PLAN.arbitrage(now=H3 + timedelta(hours=1, minutes=20), **ARGS)
|
||||
.cheapest.start != H3 + timedelta(hours=1))
|
||||
|
||||
print("\n1b. the sell leg")
|
||||
SELL = [0.30] * 192
|
||||
for q, p in ((4, 0.70), (5, 0.65), (6, 0.60), (7, 0.55), (68, 0.10)):
|
||||
SELL[q] = p # dearest 03:00 .70 .. 03:45 .55 ; cheapest 19:00
|
||||
SELL_PLAN = qplan(SELL)
|
||||
for mins, q in want_q.items():
|
||||
now = H3 + timedelta(minutes=mins)
|
||||
v = SELL_PLAN.arbitrage(now=now, **ARGS)
|
||||
d = dec(SELL_PLAN, now)
|
||||
exp = ahead_extreme(SELL, now, lambda a, b: a > b)
|
||||
check(f"03:{mins:02d}: the sell leg is {hhmm(exp)}, the verdict says "
|
||||
f"{hhmm(v.dearest.start)}, and decide() is {d.action}",
|
||||
v.dearest.start == exp == D0 + timedelta(minutes=15 * q)
|
||||
and d.action == DISCHARGE
|
||||
and abs(d.price_now_eur_kwh - SELL[q]) < 1e-9)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the plan is older than the verdict")
|
||||
# Built at 03:00 and used at 03:20, 03:35 (the refresh runs every 30 min).
|
||||
built = H3
|
||||
feed = qplan(BUY).read
|
||||
plan_0300 = TariffPlan(fill_from_yesterday(
|
||||
feed, yesterday=PriceRead.no_data("none"), since=built,
|
||||
until=built + timedelta(hours=48), stamp=built), None, "provider")
|
||||
check("the plan built at 03:00 starts at 03:00 (the refresh floors to the hour)",
|
||||
min(p.start for p in plan_0300.read.points) == H3)
|
||||
for mins, q in ((20, 5), (35, 6)):
|
||||
now = H3 + timedelta(minutes=mins)
|
||||
d = dec(plan_0300, now)
|
||||
check(f"used at 03:{mins:02d} the verdict is "
|
||||
f"{hhmm(D0 + timedelta(minutes=15 * q))} and decide() charges there",
|
||||
plan_0300.arbitrage(now=now, **ARGS).cheapest.start
|
||||
== D0 + timedelta(minutes=15 * q) and d.action == CHARGE)
|
||||
# The shipped refresh at 03:20 keeps the whole hour: the verdict still skips it.
|
||||
now = H3 + timedelta(minutes=20)
|
||||
plan_refresh = TariffPlan(fill_from_yesterday(
|
||||
feed, yesterday=PriceRead.no_data("none"), since=now,
|
||||
until=now + timedelta(hours=48), stamp=now), None, "provider")
|
||||
check("the shipped refresh at 03:20 still holds the 03:00 period, as before, "
|
||||
"and the verdict does not aim at it",
|
||||
min(p.start for p in plan_refresh.read.points) == H3
|
||||
and plan_refresh.arbitrage(now=now, **ARGS).cheapest.start
|
||||
== H3 + timedelta(minutes=15))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. hourly feeds and the static tariff: a current hour stays in")
|
||||
HR = [0.30] * 48 # 48 hourly points from 02:00
|
||||
HR[1], HR[17] = 0.05, 0.50 # cheapest 03:00, dearest 19:00
|
||||
HR_PLAN = qplan(HR, minutes=60)
|
||||
same = True
|
||||
for m in (0, 5, 20, 35, 59):
|
||||
now = H3 + timedelta(minutes=m)
|
||||
a = HR_PLAN.arbitrage(now=now, **ARGS)
|
||||
b = HR_PLAN.arbitrage(now=LONG_AGO, **ARGS)
|
||||
same = same and a.cheapest is b.cheapest and a.dearest is b.dearest \
|
||||
and a.spread_eur_kwh == b.spread_eur_kwh
|
||||
check("an hourly point that is still current stays in: at 03:00, :05, :20, "
|
||||
":35 and :59 the verdict is the one without `now`", same)
|
||||
check("...and decide() still charges in the current hour at 03:40",
|
||||
dec(HR_PLAN, H3 + timedelta(minutes=40)).action == CHARGE)
|
||||
check("...and once the hour has ended its point is gone: at 04:05 the buy leg "
|
||||
"is no longer 03:00",
|
||||
HR_PLAN.arbitrage(now=H3 + timedelta(hours=1, minutes=5), **ARGS)
|
||||
.cheapest.start != H3)
|
||||
|
||||
TWO = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-sun",
|
||||
"eur_per_kwh": 0.40},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-sun",
|
||||
"eur_per_kwh": 0.18}]}
|
||||
OPTS = {"price_provider": "off", "static_profile": json.dumps(TWO)}
|
||||
ok, n_checked = True, 0
|
||||
for h in range(0, 24):
|
||||
for m in (0, 20, 50):
|
||||
now = D0 + timedelta(hours=h - 2, minutes=m) # from 22:00 UTC the day before
|
||||
plan = static_plan(OPTS, now, now + timedelta(hours=48), now=now)
|
||||
a = plan.arbitrage(now=now, **ARGS)
|
||||
b = plan.arbitrage(now=LONG_AGO, **ARGS)
|
||||
n_checked += 1
|
||||
ok = ok and a.kind == b.kind == "allowed" and a.cheapest is b.cheapest \
|
||||
and a.dearest is b.dearest and a.spread_eur_kwh == b.spread_eur_kwh
|
||||
check(f"the static day/night plan, built at the instant it is used (so it "
|
||||
f"holds no ended hour), gives the same verdict with and without `now` "
|
||||
f"at {n_checked} instants", ok and n_checked == 72)
|
||||
|
||||
# 3b. The refresh is NOT on the hour. main.py rebuilds the plan every 30 min
|
||||
# counted from add-on start (TARIFF_REFRESH_S = 1800), so a plan built at :20
|
||||
# or :50 starts at the floored hour and, until the next refresh, still holds
|
||||
# the hour that has just ended. Owner decision 2026-10-22: the verdict skips
|
||||
# that hour too, so a trade runs through instead of pausing until the refresh.
|
||||
print("\n3b. hourly and static plans built off the hour: the ended hour is not the target")
|
||||
HR2 = [0.30] * 48 # hourly from 02:00 Brussels
|
||||
HR2[1], HR2[2], HR2[17] = 0.05, 0.05, 0.50 # cheap 03:00 and 04:00 (a tie), dear 19:00
|
||||
built = H3 + timedelta(minutes=50) # the refresh at :50 floors to 03:00
|
||||
plan_h = TariffPlan(fill_from_yesterday(
|
||||
qplan(HR2, minutes=60).read, yesterday=PriceRead.no_data("none"),
|
||||
since=built, until=built + timedelta(hours=48), stamp=built),
|
||||
None, "provider")
|
||||
now = H3 + timedelta(hours=1, minutes=5) # 04:05, before the next refresh
|
||||
check("the hourly plan built at 03:50 starts at 03:00 and is used at 04:05, so "
|
||||
"it still holds the 03:00 hour, which is over",
|
||||
min(p.start for p in plan_h.read.points) == H3)
|
||||
old_v = plan_h.arbitrage(now=LONG_AGO, **ARGS)
|
||||
new_v = plan_h.arbitrage(now=now, **ARGS)
|
||||
check("without `now` (the old verdict) the buy leg is the ended 03:00 hour "
|
||||
"(first of the tie) and decide() does nothing at 04:05 - the pause",
|
||||
old_v.cheapest.start == H3
|
||||
and arbitrage._act(plan_h, now, old_v, 50.0, 2500.0, 2500.0, 10.0,
|
||||
90.0, 5000.0).action is None)
|
||||
check("with `now` the buy leg is 04:00, the hour in progress, and decide() "
|
||||
"charges at 04:05: the trade runs through",
|
||||
new_v.cheapest.start == H3 + timedelta(hours=1)
|
||||
and dec(plan_h, now).action == CHARGE)
|
||||
|
||||
# The day/night tariff, refresh at :20 of the hour: all 15 day hours tie.
|
||||
b_local = D0 + timedelta(hours=10, minutes=20) # 12:20 Brussels
|
||||
plan_s = static_plan(OPTS, b_local, b_local + timedelta(hours=48), now=b_local)
|
||||
now = D0 + timedelta(hours=11, minutes=5) # 13:05 Brussels
|
||||
old_v = plan_s.arbitrage(now=LONG_AGO, **ARGS)
|
||||
new_v = plan_s.arbitrage(now=now, **ARGS)
|
||||
check("the day/night plan built at 12:20 starts at 12:00; used at 13:05 its "
|
||||
"sell leg without `now` is the ended 12:00 hour (first of the tied day "
|
||||
"hours) and decide() does nothing - the old hourly pause",
|
||||
min(p.start for p in plan_s.read.points) == D0 + timedelta(hours=10)
|
||||
and old_v.dearest.start == D0 + timedelta(hours=10)
|
||||
and arbitrage._act(plan_s, now, old_v, 50.0, 2500.0, 2500.0, 10.0,
|
||||
90.0, 5000.0).action is None)
|
||||
check("with `now` the sell leg is 13:00, the hour in progress, and decide() "
|
||||
"discharges at 13:05: the trade runs through",
|
||||
new_v.dearest.start == D0 + timedelta(hours=11)
|
||||
and dec(plan_s, now).action == DISCHARGE)
|
||||
ok = True
|
||||
for m in (0, 5, 20, 35, 50, 59): # every minute class of the hour
|
||||
t = D0 + timedelta(hours=12, minutes=m) # 14:mm Brussels
|
||||
v = plan_s.arbitrage(now=t, **ARGS)
|
||||
ok = ok and v.dearest.start == D0 + timedelta(hours=12) \
|
||||
and dec(plan_s, t).action == DISCHARGE
|
||||
check("...and through the whole of 14:00-14:59 the target is the 14:00 hour "
|
||||
"and the trade stays on (no pause at the top of the hour)", ok)
|
||||
|
||||
# The exact boundary: a period ends AT its `end`, so at that instant it is over.
|
||||
# (`end >= now` would keep it for one more instant.)
|
||||
print("\n3c. the exact end of a period")
|
||||
at = H3 + timedelta(minutes=15) # 03:15:00 exactly
|
||||
v = BUY_PLAN.arbitrage(now=at, **ARGS)
|
||||
check("at 03:15:00 exactly the 03:00 quarter is over: the buy leg is 03:15 "
|
||||
"and decide() charges", v.cheapest.start == at and dec(BUY_PLAN, at)
|
||||
.action == CHARGE)
|
||||
check("one microsecond earlier the 03:00 quarter is still in view",
|
||||
BUY_PLAN.arbitrage(now=at - timedelta(microseconds=1), **ARGS)
|
||||
.cheapest.start == H3)
|
||||
at_h = H3 + timedelta(hours=1) # 04:00:00 exactly
|
||||
check("at 04:00:00 exactly the 03:00 hour is over (hourly feed), one "
|
||||
"microsecond earlier it is not",
|
||||
plan_h.arbitrage(now=at_h, **ARGS).cheapest.start == at_h
|
||||
and plan_h.arbitrage(now=at_h - timedelta(microseconds=1), **ARGS)
|
||||
.cheapest.start == H3)
|
||||
two = qplan([0.10, 0.20]) # two firm quarters from 02:00
|
||||
check("the Home page's refusal key at the exact end of the first of two "
|
||||
"quarters: one is left, so 'not_firm' (with `end >= now` it would say "
|
||||
"'not_worth'); a microsecond earlier both count",
|
||||
homeview.refusal_key(two, False, D0 + timedelta(minutes=15)) == "not_firm"
|
||||
and homeview.refusal_key(two, False, D0 + timedelta(minutes=15)
|
||||
- timedelta(microseconds=1)) == "not_worth")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. modes on: an ended period no longer opens the gate")
|
||||
GATE = [0.30] * 192
|
||||
GATE[4] = 0.10 # the ONLY spread is the 03:00 period
|
||||
GATE_PLAN = qplan(GATE)
|
||||
now = H3 + timedelta(minutes=20)
|
||||
check("the fixture: with the ended 03:00 period in view the verdict is "
|
||||
"allowed; from 03:20 on the rest of the series is flat",
|
||||
GATE_PLAN.arbitrage(now=LONG_AGO, **ARGS).kind == "allowed"
|
||||
and GATE_PLAN.arbitrage(now=now, **ARGS).kind == "refused"
|
||||
and "flat" in GATE_PLAN.arbitrage(now=now, **ARGS).reason)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
|
||||
WPTS = [(D0 + timedelta(minutes=15 * i), 900, 0.10) for i in range(192)]
|
||||
|
||||
|
||||
def controller_at(at, plan):
|
||||
"""The real Controller after a few cycles of the modes-on replay at `at`
|
||||
(two batteries, one on Cheap hours), holding `plan` as its fresh plan."""
|
||||
_st, c = rp.run("p3_ch_sell", [(D0.timestamp() - 10, 800.0)], WPTS, at,
|
||||
20.0, soc0={"a": 60.0, "b": 30.0},
|
||||
extra={"cycle_cost_threshold_eur": 0.05})
|
||||
c.tariff_plan, c.tariff_at = plan, at
|
||||
return c
|
||||
|
||||
|
||||
from app import modetrade # noqa: E402
|
||||
|
||||
GATE_SEEN = {}
|
||||
_real_decide = modetrade.decide
|
||||
|
||||
|
||||
def _spy(runs, now_, packs, **kw):
|
||||
GATE_SEEN.update(charge_ok=kw["charge_ok"], sell_ok=kw["sell_ok"],
|
||||
trade_why=kw["trade_why"])
|
||||
return _real_decide(runs, now_, packs, **kw)
|
||||
|
||||
|
||||
modetrade.decide = _spy
|
||||
c = controller_at(now, GATE_PLAN)
|
||||
GATE_SEEN.clear()
|
||||
c._mode_roles(now, None)
|
||||
late = dict(GATE_SEEN)
|
||||
c2 = controller_at(H3 + timedelta(minutes=5), GATE_PLAN)
|
||||
GATE_SEEN.clear()
|
||||
c2._mode_roles(H3 + timedelta(minutes=5), None)
|
||||
early = dict(GATE_SEEN)
|
||||
modetrade.decide = _real_decide
|
||||
check(f"Controller._mode_roles() (modes on, the `allowed()` gate) at 03:05, "
|
||||
f"03:00 still current: charge and sell are allowed ({early})",
|
||||
early.get("charge_ok") is True and early.get("sell_ok") is True)
|
||||
check(f"...and at 03:20, with only flat periods ahead: neither is allowed, "
|
||||
f"and the reason is the spread ({late})",
|
||||
late.get("charge_ok") is False and late.get("sell_ok") is False
|
||||
and late.get("trade_why") == "not_worth")
|
||||
|
||||
c.o["arbitrage_enabled"] = True
|
||||
v, why = homeview.trade_verdict(c, GATE_PLAN, now)
|
||||
check(f"the Home page verdict takes the same `now`: no verdict at 03:20 "
|
||||
f"({why!r}), and a verdict at 03:05",
|
||||
v is None and why == "not_worth"
|
||||
and homeview.trade_verdict(c, GATE_PLAN, H3 + timedelta(minutes=5))[0]
|
||||
is not None)
|
||||
check("...and the refusal key counts only periods not over: two firm periods "
|
||||
"left, so 'not_worth', and with one left 'not_firm'",
|
||||
homeview.refusal_key(GATE_PLAN, False, now) == "not_worth"
|
||||
and homeview.refusal_key(qplan([0.1, 0.2]), False,
|
||||
D0 + timedelta(minutes=20)) == "not_firm")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. the text")
|
||||
check("the reason for an off-peak period says 'price period', not 'hour'",
|
||||
arbitrage.IDLE_OFF_PEAK_HOUR
|
||||
== "now is not the cheapest or the dearest price period")
|
||||
_b = dec(BUY_PLAN, H3 + timedelta(minutes=20))
|
||||
_s = dec(SELL_PLAN, H3 + timedelta(minutes=20))
|
||||
check("...and the charge and the discharge sentences say 'price period' too "
|
||||
"(the unit is 15 minutes), with no 'firm hour' left",
|
||||
"cheapest firm price period" in _b.reason
|
||||
and "dearest firm price period" in _s.reason
|
||||
and "firm hour" not in _b.reason + _s.reason)
|
||||
_hi = dec(BUY_PLAN, H3 + timedelta(minutes=20), soc=95.0)
|
||||
_lo = dec(SELL_PLAN, H3 + timedelta(minutes=20), soc=10.0)
|
||||
check("...and so do the full-pack and empty-pack sentences ('buy period', "
|
||||
"'sell period')",
|
||||
"the buy period is here" in _hi.reason
|
||||
and "the sell period is here" in _lo.reason)
|
||||
import inspect # noqa: E402
|
||||
|
||||
_par = inspect.signature(TariffPlan.arbitrage).parameters
|
||||
check("`now` is required: keyword-only with no default, like the three "
|
||||
"arguments beside it, so a caller that forgets it fails loudly instead "
|
||||
"of getting the elapsed periods back",
|
||||
_par["now"].default is inspect.Parameter.empty
|
||||
and _par["now"].kind is inspect.Parameter.KEYWORD_ONLY
|
||||
and all(p.default is inspect.Parameter.empty
|
||||
for n, p in _par.items() if n != "self"))
|
||||
try:
|
||||
BUY_PLAN.arbitrage(**ARGS_NO_NOW)
|
||||
_forgot = False
|
||||
except TypeError:
|
||||
_forgot = True
|
||||
check("...and calling without it raises TypeError", _forgot)
|
||||
try:
|
||||
BUY_PLAN.arbitrage(now=datetime(2026, 10, 20, 3, 20), **ARGS)
|
||||
_naive = False
|
||||
except TypeError as e:
|
||||
_naive = "tz-aware" in str(e)
|
||||
check("a naive `now` is refused: it is ambiguous for one hour every October",
|
||||
_naive)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n6. ties: the first extreme still ahead")
|
||||
def acts(plan, instants, ago=False, args=ARGS):
|
||||
out = []
|
||||
for t in instants:
|
||||
v = plan.arbitrage(now=LONG_AGO if ago else t, **args)
|
||||
d = arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0, 90.0,
|
||||
5000.0) if v.kind == "allowed" else None
|
||||
out.append(None if d is None else d.action)
|
||||
return out
|
||||
|
||||
|
||||
# Hour-constant 15-minute data: the cheap hour is four equal quarters.
|
||||
HC = [round(0.40 - 0.0001 * i, 4) for i in range(192)] # falling, no ties
|
||||
for q in range(4, 8):
|
||||
HC[q] = 0.05 # 03:00-04:00 four equal quarters
|
||||
HC[68:72] = [0.50] * 4 # 19:00-20:00 four equal quarters
|
||||
HC_PLAN = qplan(HC)
|
||||
probe = [H3 + timedelta(minutes=m) for m in (5, 20, 35, 50, 65)]
|
||||
old = acts(HC_PLAN, probe, ago=True)
|
||||
new = acts(HC_PLAN, probe)
|
||||
check(f"before: four equal cheap quarters, the verdict is the first of them, "
|
||||
f"so the trade acts in one quarter ({old})",
|
||||
old == [CHARGE, None, None, None, None])
|
||||
check(f"after: the first one still ahead, so it acts in all four and stops "
|
||||
f"with the hour ({new}) - four acting periods for the same hour, one "
|
||||
f"block either way",
|
||||
new == [CHARGE, CHARGE, CHARGE, CHARGE, None])
|
||||
changes = lambda a: sum(1 for x, y in zip(a, a[1:]) if x != y) # noqa: E731
|
||||
check("...the action changes the same number of times (one start, one stop)",
|
||||
changes(old) == changes(new) == 1)
|
||||
|
||||
# A real 15-minute day, as production runs it: refresh every 30 min from the
|
||||
# floored hour, the verdict at 7 min into every period.
|
||||
REAL = os.path.join(HERE, "scenarios",
|
||||
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json")
|
||||
_d = json.load(open(REAL))
|
||||
_pts = [PricePoint(brussels(datetime.fromtimestamp(t, UTC)), p / 1000.0, False)
|
||||
for t, p in zip(_d["unix_seconds"], _d["price"])]
|
||||
_feed = PriceRead.series(_pts, PriceProduct.DAY_AHEAD, "generic_dayahead",
|
||||
_pts[0].start)
|
||||
_day0 = _pts[0].start.astimezone(UTC)
|
||||
|
||||
def real_day(ago):
|
||||
out = []
|
||||
for q in range(96):
|
||||
t = _day0 + timedelta(minutes=15 * q + 7)
|
||||
ref = t.replace(minute=0 if t.minute < 30 else 30, second=0,
|
||||
microsecond=0)
|
||||
plan = TariffPlan(fill_from_yesterday(
|
||||
_feed, yesterday=PriceRead.no_data("none"), since=ref,
|
||||
until=ref + timedelta(hours=36), stamp=ref), None, "provider")
|
||||
v = plan.arbitrage(now=LONG_AGO if ago else t, **ARGS5)
|
||||
out.append(arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0,
|
||||
90.0, 5000.0).action
|
||||
if v.kind == "allowed" else None)
|
||||
return out
|
||||
|
||||
|
||||
r_old, r_new = real_day(True), real_day(False)
|
||||
n_old = sum(1 for a in r_old if a)
|
||||
n_new = sum(1 for a in r_new if a)
|
||||
print(f" real 15-minute day 2026-09-09: acting periods {n_old} -> {n_new}, "
|
||||
f"action changes {changes(r_old)} -> {changes(r_new)}")
|
||||
check("a real 15-minute day (energy-charts, 2026-09-09) does not switch more "
|
||||
"often: the action changes no more often than before",
|
||||
changes(r_new) <= changes(r_old))
|
||||
check("...and every period the old verdict acted in is still acted in, with "
|
||||
"the quarters it had lost to the elapsed-period tie added",
|
||||
all(n is not None for o, n in zip(r_old, r_new) if o) and n_new >= n_old)
|
||||
|
||||
# Alternating hourly prices (the flapping day): ties everywhere, so the verdict
|
||||
# moves on every hour. At most once an hour, never once a tick.
|
||||
FL = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)] * 2
|
||||
FL_PLAN = qplan(FL, minutes=60)
|
||||
ticks = [acts(FL_PLAN, [D0 + timedelta(seconds=30 * k)], args=ARGS5)[0]
|
||||
for k in range(24 * 120)]
|
||||
check(f"alternating hourly prices: {changes(ticks)} action changes over "
|
||||
f"2880 ticks - at most one an hour, not one a tick",
|
||||
changes(ticks) <= 24 and CHARGE in ticks and DISCHARGE in ticks)
|
||||
|
||||
# The Home page path: `homeview.build_plan()` hands its own `now` to
|
||||
# `trade_verdict`. With a `now` floored to the hour it would still aim at the
|
||||
# 03:00 period at 03:20 and draw a trade that the control cycle does not take.
|
||||
print("\n4b. the Home page plan (build_plan) uses the instant it is rendered at")
|
||||
c.o["arbitrage_enabled"] = True
|
||||
c.modes_on = False
|
||||
|
||||
|
||||
def home_at(t):
|
||||
c.tariff_plan, c.tariff_at = GATE_PLAN, t
|
||||
return homeview.build_plan(c, t, False, True)
|
||||
|
||||
|
||||
h_early = home_at(H3 + timedelta(minutes=5))
|
||||
h_late = home_at(H3 + timedelta(minutes=20))
|
||||
trade_kinds = ("grid", "give")
|
||||
check("build_plan at 03:05 (03:00 still current): the spread is real, so the "
|
||||
"page draws a trade (verdict_key 'trade')",
|
||||
h_early["verdict_key"] == "trade"
|
||||
and any(r["kind"] in trade_kinds for r in h_early["runs"]))
|
||||
check("build_plan at 03:20 (only flat periods ahead): no trade on the page, "
|
||||
"the same refusal as trade_verdict gives (verdict_key 'house')",
|
||||
h_late["verdict_key"] == "house"
|
||||
and not any(r["kind"] in trade_kinds for r in h_late["runs"])
|
||||
and h_late["lines"] != h_early["lines"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -87,6 +87,11 @@ from app.static_tariff import Arbitrage, TariffPlan
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
||||
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
||||
fails = []
|
||||
|
||||
|
||||
@@ -905,7 +910,7 @@ def min_accepted_spread(product, sources, base, provider="generic_dayahead"):
|
||||
lo, hi = 0.0, 4.0
|
||||
for _ in range(60):
|
||||
mid = (lo + hi) / 2
|
||||
v = plan_with_spread(mid, product, provider).arbitrage(
|
||||
v = plan_with_spread(mid, product, provider).arbitrage(now=LONG_AGO,
|
||||
peak_guard_active=False, min_spread_eur_kwh=base,
|
||||
uncertainty=sources)
|
||||
if v.kind == "allowed":
|
||||
@@ -1013,7 +1018,7 @@ check(f"the fixture is non-degenerate: the whole-series spread "
|
||||
f"{_all_spread:.6f} and the firm-only spread {_firm_spread:.6f} are "
|
||||
f"different numbers, so a verdict can be traced to one of them",
|
||||
not near(_all_spread, _firm_spread, 1e-6))
|
||||
_v = derived(lambda: _mixed.arbitrage(peak_guard_active=False,
|
||||
_v = derived(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()))
|
||||
check("a PART-published series still allows arbitrage - §8.3 forbids acting on "
|
||||
@@ -1032,7 +1037,7 @@ check("...and it reports the threshold it cleared and why, rather than leaving "
|
||||
_all_prov = TariffPlan(PriceRead.series(_cheap_stale, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", D0), None,
|
||||
"fallback")
|
||||
_vp = derived(lambda: _all_prov.arbitrage(peak_guard_active=False,
|
||||
_vp = derived(lambda: _all_prov.arbitrage(now=LONG_AGO, peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()))
|
||||
check("a WHOLLY provisional series - the §11.4 fallback, or a day before any "
|
||||
@@ -1046,23 +1051,23 @@ _one_firm = TariffPlan(
|
||||
"generic_dayahead", D0), None, "provider")
|
||||
check("one firm hour is not enough either: a buy hour and a sell hour must "
|
||||
"BOTH be published before a cycle can be committed",
|
||||
_one_firm.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
_one_firm.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()).kind == "refused")
|
||||
check("§10.1 still holds above all of it: the peak guard refuses the same "
|
||||
"series that is otherwise allowed",
|
||||
_mixed.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0,
|
||||
_mixed.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()).kind == "refused")
|
||||
check("the three questions cannot be skipped: all of peak_guard_active, "
|
||||
"min_spread_eur_kwh and uncertainty are required and keyword-only",
|
||||
raises(lambda: _mixed.arbitrage(peak_guard_active=False), TypeError)
|
||||
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
|
||||
raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False), TypeError)
|
||||
and raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.0), TypeError)
|
||||
and raises(lambda: _mixed.arbitrage(False, 0.0, frozenset()), TypeError))
|
||||
check("...nor answered with a truthy 'unknown' or a negative threshold",
|
||||
raises(lambda: _mixed.arbitrage(peak_guard_active=None,
|
||||
raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=None,
|
||||
min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()), TypeError)
|
||||
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
|
||||
and raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
|
||||
min_spread_eur_kwh=-0.1,
|
||||
uncertainty=frozenset()), TypeError))
|
||||
|
||||
|
||||
@@ -118,6 +118,11 @@ from app.static_tariff import resolve_tariff
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
||||
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
||||
fails = []
|
||||
_tmpdirs = []
|
||||
|
||||
@@ -699,8 +704,8 @@ check(f"the short answer stays a SERIES of what exists - {_pre.read.kind}, "
|
||||
# --- the trade set, which is where the whole thing lands ------------------- #
|
||||
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset())
|
||||
_v2 = _pub.arbitrage(**_A9)
|
||||
_v1 = _pre.arbitrage(**_A9)
|
||||
_v2 = _pub.arbitrage(now=LONG_AGO, **_A9)
|
||||
_v1 = _pre.arbitrage(now=LONG_AGO, **_A9)
|
||||
check("both plans still ALLOW a trade, so the two verdicts below are compared "
|
||||
"against each other and not against a refusal",
|
||||
_v2.kind == "allowed" and _v1.kind == "allowed")
|
||||
@@ -744,8 +749,8 @@ print("2. AC2 - the spread stopped being taken over hours that have gone")
|
||||
|
||||
_A = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset())
|
||||
_v_before = _before.arbitrage(**_A)
|
||||
_v_after = _after.arbitrage(**_A)
|
||||
_v_before = _before.arbitrage(now=LONG_AGO, **_A)
|
||||
_v_after = _after.arbitrage(now=LONG_AGO, **_A)
|
||||
check("both plans still ALLOW a trade, so this section compares two verdicts "
|
||||
"rather than a verdict and a refusal",
|
||||
_v_before.kind == "allowed" and _v_after.kind == "allowed")
|
||||
@@ -843,7 +848,7 @@ check("...and no two of them share a whole hour by accident: the fixture puts "
|
||||
|
||||
_q_spread_want = round(max(p.eur_per_kwh for p in _q_win)
|
||||
- min(p.eur_per_kwh for p in _q_win), 6)
|
||||
_qv = _q_after.arbitrage(**_A)
|
||||
_qv = _q_after.arbitrage(now=LONG_AGO, **_A)
|
||||
_q_spread_got = _qv.match(allowed=lambda v: v.spread_eur_kwh,
|
||||
refused=lambda _v: None)
|
||||
check(f"...and the spread is the fixture's own max - min over that window "
|
||||
|
||||
@@ -596,30 +596,64 @@ check("selling is off: no sale on any cycle",
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. modes off: byte-identical to release/1.0 e3e4ba6")
|
||||
# Printed by tools/opt11p3_identity.py on release/1.0 e3e4ba6 (2026-10-13),
|
||||
# goodwe-addon-checks:1, with this branch's harness (same API). n1 and n2 are
|
||||
# also 5a4cdc2's; the lag ones moved with SAFETY-90/91 (n2_lag now equals n2:
|
||||
# the next-quarter grading removed the edge tick's blindness at N=2).
|
||||
AT_BASE = {
|
||||
# Printed on release/1.0 e3e4ba6 (2026-10-13), goodwe-addon-checks:1, with this
|
||||
# branch's harness (same API). n1 and n2 are also 5a4cdc2's; the lag ones moved
|
||||
# with SAFETY-90/91 (n2_lag now equals n2: the next-quarter grading removed the
|
||||
# edge tick's blindness at N=2).
|
||||
#
|
||||
# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing
|
||||
# else (see test_modes_p3.py part 1): the "N above the cheapest" figure of a
|
||||
# sale and "hour" -> "price period". Three pins, as there:
|
||||
# AT_BASE_OLD the digests printed on e3e4ba6, sentence included (no longer
|
||||
# matched: the mask is needed);
|
||||
# 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",
|
||||
"n1_lag": "86ef0f5ad8da1498ff7dcf59262a177bef847a0b201ce804e947c8eab5f07190",
|
||||
"n2_lag": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
}
|
||||
_got = {k: ident.digest(ident.run(**ident.SCENARIOS[k])[0])
|
||||
for k in AT_BASE}
|
||||
AT_BASE = {
|
||||
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
||||
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||
"n1_lag": "28e3182ae73c52dae0e65b2495bc04b2b71003889bf9de1a394b047d2c0e0edf",
|
||||
"n2_lag": "45bfb095b5707a91316f56afc408dba616afc729ce4c83a10b337f36948fa3f2",
|
||||
}
|
||||
AT_NOW = {
|
||||
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
|
||||
"n2": "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}
|
||||
_got = {k: _cmd_digest(v) for k, v in _rows5.items()}
|
||||
_full5 = {k: ident.digest(v) for k, v in _rows5.items()}
|
||||
for _k in AT_BASE:
|
||||
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
|
||||
f"{_got[_k][:16]}")
|
||||
check("with battery modes off every command, integrator, allocation and "
|
||||
"trade sentence equals e3e4ba6's, also when every quarter boundary's "
|
||||
"cycle still sees the old P1 block", _got == AT_BASE)
|
||||
"trade action equals e3e4ba6's (the sentence masked as above), also when "
|
||||
"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 "
|
||||
"later change to any sentence or figure fails here",
|
||||
all(_full5[k] != AT_BASE_OLD[k] for k in AT_BASE_OLD)
|
||||
and _full5 == AT_NOW)
|
||||
|
||||
_real_project = tariff.PeakTracker.project
|
||||
tariff.PeakTracker.project = staticmethod(
|
||||
lambda a, now, roll=False: _real_project(a, now, roll=True))
|
||||
try:
|
||||
_moved = ident.digest(ident.run(**ident.SCENARIOS["n1_lag"])[0]) \
|
||||
_moved = _cmd_digest(ident.run(**ident.SCENARIOS["n1_lag"])[0]) \
|
||||
!= AT_BASE["n1_lag"]
|
||||
finally:
|
||||
tariff.PeakTracker.project = _real_project
|
||||
|
||||
@@ -1178,7 +1178,7 @@ async def _modes_plan():
|
||||
c = W.ctl
|
||||
plan = c.fresh_tariff_plan(t)
|
||||
verdict = plan.arbitrage(
|
||||
peak_guard_active=False, min_spread_eur_kwh=0.05,
|
||||
now=t, peak_guard_active=False, min_spread_eur_kwh=0.05,
|
||||
uncertainty=frozenset())
|
||||
return verdict.match(allowed=lambda v: ("allowed", v.spread_eur_kwh),
|
||||
refused=lambda v: ("refused", v.reason))
|
||||
|
||||
@@ -9,28 +9,34 @@ It crossed its rail for one or two cycles, the loop's ask beat the one-sided
|
||||
limit, so the cycle counted as the loop's own and raised
|
||||
`goodwe_integrator_bound`. The battery gave what it was told. Now the loop's
|
||||
bound does not count until the plant's settle (SHORT_CYCLES cycles and
|
||||
SHORT_SPAN_S) has passed since the hand-back. Round 3: across all
|
||||
hand-backs the bound is hidden on at most ROLLING_BUDGET[cycle length] cycles
|
||||
(10 up to a 3.5 s cycle, 8 up to 11 s, 6 up to 16 s, 4 beyond) in any
|
||||
ROLLING_S seconds, so a bound that stays warns when the guard and the charge
|
||||
keep taking turns. DECISIONS/SAFETY-119-bound-after-hand-back.md.
|
||||
SHORT_SPAN_S) has passed since the hand-back. SAFETY-121: the settle is
|
||||
HANDBACK_CYCLES (4) cycles and SHORT_SPAN_S (22 s), and across all
|
||||
hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES (11) cycles in
|
||||
any ROLLING_S (900 s), at every cycle length.
|
||||
DECISIONS/SAFETY-119-bound-after-hand-back.md,
|
||||
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).
|
||||
3. With a usable forecast: the same commands and the same alarms as without
|
||||
the fix.
|
||||
4. Repeated hand-backs (N=2, the guard and the charge take turns), 2, 5, 10
|
||||
and 15 s meters: it warns (every quarter at 2, 5 and 10 s, every other
|
||||
quarter at least at 15 s); the quarter-edge case stays silent at 2, 5, 10
|
||||
and 15 s, including the 2 s seed where a fixed budget of 8 warns; a start
|
||||
or a blind cycle is not a hand-back; a slow meter delays by cycles.
|
||||
5. The rule at its boundaries (`Controller._settling`): the settle, and the
|
||||
rolling budget.
|
||||
and 15 s meters: it warns in every quarter, both with the meter a cycle
|
||||
behind the battery reading and in the rig's shape (SAFETY-121, F-1: the
|
||||
meter read in the same cycle, 1 to 5 bound cycles a quarter), where
|
||||
SAFETY-119's settle of 12 cycles hid every quarter but one; the
|
||||
quarter-edge case stays silent at 2, 5, 10 and 15 s; the budget's margins
|
||||
(8 warns at the quarter edge, 14 misses quarters); a start or a blind
|
||||
cycle is not a hand-back; the delay after one hand-back.
|
||||
5. The rule at its boundaries (`Controller._settling`): the settle, the
|
||||
rolling budget, and a stop that does not give the budget back.
|
||||
6. test_safety84's and test_safety87's re-pinned digests: the same commands
|
||||
and claims; only OUTPUT_CLAMP log rows differ.
|
||||
|
||||
@@ -136,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)
|
||||
@@ -227,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")
|
||||
@@ -252,7 +286,7 @@ _first = notes(_hb[1])[:1]
|
||||
print(f" hand-back to a loop at its rail at 600 s: first notification "
|
||||
f"{_first}, without the fix {notes(_hb_old[1])[:1]}; command at the "
|
||||
f"end {_hb[0][-1][1]}")
|
||||
MB = 10 # the budget at a 1 s cycle
|
||||
MB = 11 # the budget (at 1 s it ends the settle)
|
||||
check(f"⭐ the loop at its rail after a hand-back still warns, once "
|
||||
f"{MB} of its bound cycles are hidden (1 s meter: {MB} s)",
|
||||
_first and 600.0 + MB <= _first[0] <= 600.0 + MB + 4.0
|
||||
@@ -317,10 +351,18 @@ def round1(self, owns, now):
|
||||
# over the 5000 W rating from 300 s: the loop never held the wire for more
|
||||
# than 10 cycles. Round 1: no notification in 2 h; round 2: none at 10 s or
|
||||
# 15 s meters.
|
||||
def never(tick, hw=5050.0, fix=True):
|
||||
def never(tick, hw=5050.0, fix=True, seed=0, delay=1):
|
||||
"""`seed` 0: a constant house; else 800 W +- 30 W, `hw` +- 30 W from
|
||||
300 s. `delay` 0 is the rig's shape: the meter read in the same cycle
|
||||
as the battery (SAFETY-121)."""
|
||||
rig.TICK = tick
|
||||
if seed:
|
||||
load = (lambda t, h=house(seed):
|
||||
h(t) + (hw - 800.0 if t >= 300.0 else 0.0))
|
||||
else:
|
||||
load = house(1, step=(300.0, hw))
|
||||
try:
|
||||
f = lambda: run(house(1, step=(300.0, hw)), n=2)
|
||||
f = lambda: run(load, n=2, delay=delay)
|
||||
return f() if fix else no_fix(f)
|
||||
finally:
|
||||
rig.TICK = 1.0
|
||||
@@ -331,6 +373,10 @@ def per_quarter(c):
|
||||
for q in range(8)]
|
||||
|
||||
|
||||
def every_quarter(c):
|
||||
return all(n >= 1 for n in per_quarter(c)[1:])
|
||||
|
||||
|
||||
_nv = {t: never(t) for t in (2.0, 5.0, 10.0, 15.0)}
|
||||
_nv_old = {t: never(t, fix=False) for t in (2.0, 5.0, 10.0, 15.0)}
|
||||
_nv_r1 = patched(Controller, "_settling", round1)(lambda: never(5.0))
|
||||
@@ -341,16 +387,9 @@ for _t in _nv:
|
||||
f"{len(notes(_nv_old[_t][1]))}, highest quarter "
|
||||
f"{max(quarters(_nv[_t][1], _t)):.0f} W")
|
||||
print(f" round 1's rule at 5 s: {len(notes(_nv_r1[1]))}")
|
||||
check("⭐ 2 s and 5 s meters: it warns in every quarter after the first",
|
||||
all(per_quarter(_nv[t][1])[q] >= 1 for q in range(1, 8)
|
||||
for t in (2.0, 5.0)))
|
||||
check("⭐ 10 s meter: it warns in every quarter after the first (round 2 "
|
||||
"gave none)",
|
||||
all(per_quarter(_nv[10.0][1])[q] >= 1 for q in range(1, 8)))
|
||||
check("⭐ 15 s meter: it warns at least every other quarter after the first "
|
||||
"(round 2 gave none)",
|
||||
not any(a == 0 and b == 0 for a, b in zip(
|
||||
per_quarter(_nv[15.0][1])[1:], per_quarter(_nv[15.0][1])[2:])))
|
||||
check("⭐ 2, 5, 10 and 15 s meters: it warns in every quarter after the "
|
||||
"first (round 2 gave none at 10 and 15 s)",
|
||||
all(every_quarter(_nv[t][1]) for t in _nv))
|
||||
check("non-vacuous: round 1's rule (a window per hand-back) never warned "
|
||||
"here, and the fix still hides some of what was raised without it",
|
||||
notes(_nv_r1[1]) == [] and all(
|
||||
@@ -363,15 +402,46 @@ check("...and at 5 s the cap held",
|
||||
len(quarters(_nv[5.0][1], 5.0)) == 8
|
||||
and max(quarters(_nv[5.0][1], 5.0)) < 5000.0)
|
||||
|
||||
# SAFETY-121, the rig's shape (TEST-23, F-1): the meter is read in the same
|
||||
# cycle as the battery, so the guard's charge headroom is right after it
|
||||
# hands back and the guard and the charge do not chase each other. The loop
|
||||
# then holds the wire once a quarter, at its start, and is at its bound on
|
||||
# 1 to 5 cycles (5 s), not 20. House 5100 W +- 30 W (5300 W at 15 s, where
|
||||
# 5100 W is over the rating in one quarter only, without the fix too).
|
||||
RIG = {1.0: 5100.0, 2.0: 5100.0, 5.0: 5100.0, 10.0: 5100.0, 15.0: 5300.0}
|
||||
_rg = {t: never(t, hw, seed=1, delay=0) for t, hw in RIG.items()}
|
||||
_rg_old = {t: never(t, hw, fix=False, seed=1, delay=0)
|
||||
for t, hw in RIG.items()}
|
||||
# SAFETY-119 as merged (bee7431) at 5 s: a settle of 12 cycles, budget 8.
|
||||
_rg_119 = patched(app_main, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)(
|
||||
lambda: patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
|
||||
lambda: never(5.0, 5100.0, seed=1, delay=0)))
|
||||
for _t in _rg:
|
||||
print(f" rig shape, {_t:.0f} s meter, house {RIG[_t]:.0f} W: per "
|
||||
f"quarter {per_quarter(_rg[_t][1])}, without the fix "
|
||||
f"{per_quarter(_rg_old[_t][1])}, highest quarter "
|
||||
f"{max(quarters(_rg[_t][1], _t)):.0f} W")
|
||||
print(f" rig shape, 5 s, SAFETY-119's settle and budget: per quarter "
|
||||
f"{per_quarter(_rg_119[1])}")
|
||||
check("⭐ the rig's shape at 1, 2, 5, 10 and 15 s meters: it warns in every "
|
||||
"quarter after the first, as without the fix",
|
||||
all(every_quarter(_rg[t][1]) and every_quarter(_rg_old[t][1])
|
||||
for t in _rg))
|
||||
check("non-vacuous (F-1): with SAFETY-119's settle of 12 cycles and budget "
|
||||
"of 8, the rig's shape at 5 s warns in at most one quarter",
|
||||
sum(1 for n in per_quarter(_rg_119[1])[1:] if n) <= 1)
|
||||
check("...every command the same as without the fix, and the cap held at "
|
||||
"1 to 10 s",
|
||||
all(commands(_rg[t][0]) == commands(_rg_old[t][0]) for t in _rg)
|
||||
and all(max(quarters(_rg[t][1], t)) < 5000.0
|
||||
for t in (1.0, 2.0, 5.0, 10.0)))
|
||||
|
||||
# The quarter-edge case (part 1) at the slower meters: still no warning.
|
||||
# The meter's own lag limits the cap there, the same with and without the fix.
|
||||
_qe = {}
|
||||
# Seeds 1 to 3 are the brief's fixed set; 2 s seed 25 is the one where the
|
||||
# sweep's fixed budget of 8 warned (DECISIONS), kept here so that stays seen.
|
||||
for _t in (2.0, 5.0, 10.0, 15.0): # with part 1: 1, 2, 5, 10, 15 s
|
||||
rig.TICK = _t
|
||||
for _seed, _dl in ((1, 1), (2, 1), (3, 2)) + (
|
||||
((25, 2),) if _t == 2.0 else ()):
|
||||
for _seed, _dl in ((1, 1), (2, 1), (3, 2)):
|
||||
_qe[_t, _seed] = (run(house(_seed), delay=_dl),
|
||||
no_fix(lambda: run(house(_seed), delay=_dl)))
|
||||
rig.TICK = 1.0
|
||||
@@ -379,8 +449,8 @@ for _t in (2.0, 5.0, 10.0, 15.0): # with part 1: 1, 2, 5, 10, 15 s
|
||||
f"{[len(notes(r[0][1])) for k, r in _qe.items() if k[0] == _t]} "
|
||||
f"(without the fix "
|
||||
f"{[len(notes(r[1][1])) for k, r in _qe.items() if k[0] == _t]})")
|
||||
check("⭐ the quarter-edge case at 2, 5, 10 and 15 s meters, seeds 1 to 3 "
|
||||
"and (2 s) 25: no notification",
|
||||
check("⭐ the quarter-edge case at 2, 5, 10 and 15 s meters, seeds 1 to 3: "
|
||||
"no notification",
|
||||
all(notes(r[0][1]) == [] and r[0][1].clamp_wd.bound == 0
|
||||
for r in _qe.values()))
|
||||
check("...every command the same as without the fix; without it the false "
|
||||
@@ -388,20 +458,30 @@ check("...every command the same as without the fix; without it the false "
|
||||
all(commands(r[0][0]) == commands(r[1][0]) for r in _qe.values())
|
||||
and all(any(len(notes(_qe[_t, k][1][1])) >= 1 for k in (1, 2, 3))
|
||||
for _t in (2.0, 5.0, 10.0, 15.0)))
|
||||
|
||||
# The budget's margins (DECISIONS/SAFETY-121): 9 to 13 met both cases in the
|
||||
# sweep. 8 warns at the quarter edge (2 s, seed 25); 14 misses quarters in
|
||||
# the rig's shape (1 s, house 5050 W, seed 1).
|
||||
rig.TICK = 2.0
|
||||
_q25_8 = patched(app_main, "ROLLING_BUDGET", ((math.inf, 8),))(
|
||||
lambda: run(house(25), delay=2))
|
||||
_q25 = (run(house(25), delay=2),
|
||||
patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
|
||||
lambda: run(house(25), delay=2)))
|
||||
rig.TICK = 1.0
|
||||
print(f" 2 s meter, seed 25, a fixed budget of 8: notifications "
|
||||
f"{notes(_q25_8[1])}")
|
||||
check("...2 s seed 25 is the sweep's known case: a fixed budget of 8 warns "
|
||||
"there (why the budget follows the cycle length)",
|
||||
len(notes(_q25_8[1])) >= 1)
|
||||
check("the budget is the one the sweep chose. Changing it means re-running "
|
||||
"the sweep in DECISIONS/SAFETY-119-bound-after-hand-back.md",
|
||||
app_main.ROLLING_BUDGET == ((3.5, 10), (11.0, 8), (16.0, 6),
|
||||
(math.inf, 4))
|
||||
and app_main.ROLLING_BOUND_CYCLES == 8 and app_main.ROLLING_S == 900.0)
|
||||
_m14 = patched(app_main, "ROLLING_BOUND_CYCLES", 14)(
|
||||
lambda: never(1.0, 5050.0, seed=1, delay=0))
|
||||
_m11 = never(1.0, 5050.0, seed=1, delay=0)
|
||||
print(f" 2 s, quarter edge, seed 25: {notes(_q25[0][1])}, budget 8 "
|
||||
f"{notes(_q25[1][1])}; rig shape 1 s 5050 W per quarter "
|
||||
f"{per_quarter(_m11[1])}, budget 14 {per_quarter(_m14[1])}")
|
||||
check("...the budget's margins: 11 is silent at 2 s seed 25 and warns every "
|
||||
"quarter in the rig's shape at 1 s and 5050 W; 8 warns there, 14 "
|
||||
"misses a quarter there",
|
||||
notes(_q25[0][1]) == [] and len(notes(_q25[1][1])) >= 1
|
||||
and every_quarter(_m11[1]) and not every_quarter(_m14[1]))
|
||||
check("the rule is the one the sweep chose. Changing it means re-running "
|
||||
"the sweep in DECISIONS/SAFETY-121-bound-every-quarter.md",
|
||||
app_main.HANDBACK_CYCLES == 4 and app_main.ROLLING_BOUND_CYCLES == 11
|
||||
and app_main.ROLLING_S == 900.0 and app_main.SHORT_SPAN_S == 22.0)
|
||||
|
||||
# A start, or a blind cycle, is not a hand-back: the loop at its rail after
|
||||
# 20 s of not running warns at once (R2: a restart counted as a hand-back).
|
||||
@@ -415,19 +495,22 @@ check("⭐ a start after a blind spell is not a hand-back: it warns at the "
|
||||
notes(_bl[1])[:1] and 20.0 <= notes(_bl[1])[0] <= 26.0
|
||||
and notes(_bl[1]) == notes(_bl_old[1]))
|
||||
|
||||
# The budget is cycles, not seconds (R4): at a 5 s meter the loop at its
|
||||
# rail after a hand-back warns 8 cycles (40 s) later, not 8 s.
|
||||
MB = 8 # the budget at a 5 s cycle
|
||||
rig.TICK = 5.0
|
||||
_sl = run(house(1, step=(600.0, -6000.0)), dur=900.0,
|
||||
extra={"peak_cap_w": 20000})
|
||||
rig.TICK = 1.0
|
||||
_sl1 = [t for t in notes(_sl[1]) if t >= 600.0][:1]
|
||||
print(f" 5 s meter, hand-back to a loop at its rail at 600 s: first "
|
||||
f"notification {_sl1}")
|
||||
check(f"⭐ at a 5 s meter the budget is cycles, not seconds: the first "
|
||||
f"warning comes {MB} cycles ({MB * 5} s) after the hand-back",
|
||||
_sl1 and 600.0 + MB * 5.0 <= _sl1[0] <= 600.0 + MB * 5.0 + 10.0)
|
||||
# One hand-back to a loop at its rail at 600 s: the first warning comes when
|
||||
# the settle ends, 22 s and 4 cycles after it (SAFETY-119: 12 cycles, 40 s at
|
||||
# 5 s and 80 s at 10 s).
|
||||
_sl = {}
|
||||
for _t in (5.0, 10.0):
|
||||
rig.TICK = _t
|
||||
_sl[_t] = run(house(1, step=(600.0, -6000.0)), dur=900.0,
|
||||
extra={"peak_cap_w": 20000})
|
||||
rig.TICK = 1.0
|
||||
_sl1 = {t: [x for x in notes(r[1]) if x >= 600.0][:1] for t, r in _sl.items()}
|
||||
print(f" hand-back to a loop at its rail at 600 s, first notification: "
|
||||
f"5 s {_sl1[5.0]}, 10 s {_sl1[10.0]}")
|
||||
check("⭐ one hand-back (the loop has the wire from the cycle after the "
|
||||
"step): the first warning comes 25 s after it at a 5 s meter (the "
|
||||
"22 s span), 30 s after it at 10 s (4 cycles)",
|
||||
_sl1[5.0] == [630.0] and _sl1[10.0] == [640.0])
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the rule at its boundaries")
|
||||
@@ -438,7 +521,6 @@ class _C:
|
||||
|
||||
def __init__(self):
|
||||
self._wire_lost, self._handback, self._hidden = False, None, ()
|
||||
self._settle_t, self._settle_dt = None, None
|
||||
self.loop_clamped = (False, "", False)
|
||||
|
||||
|
||||
@@ -451,21 +533,30 @@ def seq(c, steps):
|
||||
return out
|
||||
|
||||
|
||||
N, SPAN = app_main.SHORT_CYCLES, app_main.SHORT_SPAN_S
|
||||
M, ROLL = 8, 900.0 # the chosen budget, written out on purpose
|
||||
SPAN = app_main.SHORT_SPAN_S
|
||||
K, M, ROLL = 4, 11, 900.0 # the chosen rule, written out on purpose
|
||||
_c = _C()
|
||||
check("from the start, owned every cycle: never settling (no hand-back)",
|
||||
seq(_c, [(True, float(i)) for i in range(30)]) == [False] * 30)
|
||||
_c = _C()
|
||||
_out = seq(_c, [(False, 0.0)] + [(True, 2.0 * i) for i in range(1, N + 3)])
|
||||
check(f"after another owner: settling for {N - 1} owned cycles, then not "
|
||||
f"(12 cycles at 2 s span {SPAN:.0f} s)",
|
||||
_out == [False] + [True] * (N - 1) + [False] * 3)
|
||||
_out = seq(_c, [(False, 0.0)] + [(True, 2.0 * i) for i in range(1, 15)])
|
||||
check(f"after another owner at 2 s: settling for 11 owned cycles, then not "
|
||||
f"(the span, {SPAN:.0f} s, decides)",
|
||||
_out == [False] + [True] * 11 + [False] * 3)
|
||||
_c = _C()
|
||||
_out = seq(_c, [(False, 0.0)] + [(True, 0.5 * i) for i in range(1, 60)])
|
||||
_end = _out.index(False, 1) if False in _out[1:] else -1
|
||||
check(f"at a 0.5 s meter the span decides: settling until {SPAN:.0f} s",
|
||||
_end == 1 + int(SPAN / 0.5) and all(_out[1:_end]))
|
||||
_set = {}
|
||||
for _tick in (10.0, 30.0, 60.0):
|
||||
_c = _C()
|
||||
_set[_tick] = seq(_c, [(False, 0.0)]
|
||||
+ [(True, _tick * i) for i in range(1, K + 2)])
|
||||
check(f"at 10, 30 and 60 s the cycles decide: settling for {K - 1} owned "
|
||||
f"cycles, then not (SAFETY-119: 11)",
|
||||
all(o == [False] + [True] * (K - 1) + [False] * 2
|
||||
for o in _set.values()))
|
||||
_c = _C()
|
||||
_out = seq(_c, [(False, 0.0), (True, 2.0), (True, 4.0), (False, 6.0),
|
||||
(True, 8.0), (True, 10.0)])
|
||||
@@ -474,12 +565,19 @@ check("losing the wire again restarts the settle",
|
||||
and _c._handback == (8.0, 2))
|
||||
_c = _C()
|
||||
check("no monotonic clock: the cycle count alone decides",
|
||||
seq(_c, [(False, None)] + [(True, None)] * (N + 1))
|
||||
== [False] + [True] * (N - 1) + [False] * 2)
|
||||
seq(_c, [(False, None)] + [(True, None)] * (K + 1))
|
||||
== [False] + [True] * (K - 1) + [False] * 2)
|
||||
|
||||
# The rolling budget: the loop's bound is hidden on at most M cycles in any
|
||||
# ROLL seconds, across all hand-backs. T = the loop's bound that cycle.
|
||||
assert M < N - 1, "the budget is meant to run out inside one settle"
|
||||
# One hand-back, the loop's bound on every cycle: the settle (5 s) or the
|
||||
# budget (1 s) ends the hiding.
|
||||
_c = _C()
|
||||
_o5 = seq(_c, [(False, 0.0)] + [(True, 5.0 * i, True) for i in range(1, 9)])
|
||||
_c = _C()
|
||||
_o1 = seq(_c, [(False, 0.0)] + [(True, 1.0 * i, True) for i in range(1, 21)])
|
||||
check("one hand-back, the bound on every cycle: 5 hidden at 5 s (22 s), "
|
||||
f"then counted; {M} hidden at 1 s (the budget), then counted",
|
||||
_o5 == [False] + [True] * 5 + [False] * 3
|
||||
and _o1 == [False] + [True] * M + [False] * (20 - M))
|
||||
|
||||
|
||||
def turns(c, tick, lost, periods, first=0.0, bound=True):
|
||||
@@ -499,11 +597,8 @@ def turns(c, tick, lost, periods, first=0.0, bound=True):
|
||||
return out, t
|
||||
|
||||
|
||||
_c = _C()
|
||||
_o = seq(_c, [(False, 0.0)] + [(True, 5.0 * i, True) for i in range(1, N)])
|
||||
check(f"one hand-back at 5 s, the bound on every cycle: {M} are hidden, "
|
||||
f"then it counts for the rest of the settle ({N - 1} cycles)",
|
||||
_o == [False] + [True] * M + [False] * (N - 1 - M))
|
||||
# The rolling budget: the loop's bound is hidden on at most M cycles in any
|
||||
# ROLL seconds, across all hand-backs.
|
||||
_c = _C()
|
||||
_o, _ = turns(_c, 5.0, 3, 60)
|
||||
_hid = [t for t, h in _o if h]
|
||||
@@ -528,7 +623,7 @@ check(f"the budget refills {ROLL:.0f} s after the oldest hidden cycle: "
|
||||
_a is False and _b is True)
|
||||
_c = _C()
|
||||
_o1, _t = turns(_c, 2.0, 3, 20)
|
||||
for _ in range(N): # the wire away a whole settle
|
||||
for _ in range(12): # the wire away a whole settle
|
||||
_c._settling(False, _t)
|
||||
_t += 2.0
|
||||
_o2, _ = turns(_c, 2.0, 3, 5, first=_t)
|
||||
@@ -539,7 +634,7 @@ check("the budget is not per hand-back or per run: after a whole settle "
|
||||
and not any(h for _t2, h in _o2))
|
||||
_c = _C()
|
||||
_o1, _t = turns(_c, 2.0, 3, 20)
|
||||
_ = [_c._settling(True, _t + 2.0 * i) for i in range(N + 1)]
|
||||
_ = [_c._settling(True, _t + 2.0 * i) for i in range(13)]
|
||||
_c._settling(False, _t + 40.0)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
check("the loop holding the wire a whole settle ends the window: the next "
|
||||
@@ -558,24 +653,20 @@ _c.loop_clamped = (True, "", False)
|
||||
check("an over-rating does not use up the budget",
|
||||
_c._settling(True, _t + 5.0) is True and len(_c._hidden) == 1)
|
||||
|
||||
# The budget follows the cycle length: bound cycles hidden before the first
|
||||
# one counts, one hand-back per turn.
|
||||
# The budget is one number at every cycle length (SAFETY-119 scaled it).
|
||||
_bud = {}
|
||||
for _tick in (1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0, 60.0):
|
||||
for _tick in (1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0):
|
||||
_c = _C()
|
||||
_o, _ = turns(_c, _tick, 1, min(14, int(880.0 // (2 * _tick))))
|
||||
_o, _ = turns(_c, _tick, 1, 14)
|
||||
_bud[_tick] = sum(1 for _t2, h in _o if h)
|
||||
print(f" hidden before the first counts, by cycle length: {_bud}")
|
||||
check("the budget by cycle length: 10 at 1-3 s, 8 at 5-10 s, 6 at 15 s, 4 "
|
||||
"at 20-60 s",
|
||||
_bud == {1.0: 10, 2.0: 10, 3.0: 10, 5.0: 8, 10.0: 8, 15.0: 6,
|
||||
20.0: 4, 30.0: 4, 60.0: 4})
|
||||
_c = _C()
|
||||
_o, _t = turns(_c, 2.0, 3, 8)
|
||||
_o2, _ = turns(_c, 121.0, 1, 3, first=_t + 119.0)
|
||||
check("a gap over two minutes is not a cycle length: the 2 s budget (10) "
|
||||
"stays",
|
||||
[h for _t2, h in _o + _o2] == [True] * 10 + [False])
|
||||
_o60, _ = turns(_c, 60.0, 1, 14)
|
||||
print(f" hidden before the first counts, one bound cycle per turn: {_bud}; "
|
||||
f"at 60 s, turns of 2 cycles: {sum(1 for _t2, h in _o60 if h)} of 14 "
|
||||
"hidden (the budget never runs out: DECISIONS limit)")
|
||||
check(f"the budget does not follow the cycle length: {M} hidden at 1 to "
|
||||
"30 s, then counted",
|
||||
set(_bud.values()) == {M})
|
||||
|
||||
# No monotonic clock: the budget cannot refill, so a bound that stays is
|
||||
# counted after the budget however often the wire changes hands.
|
||||
@@ -586,8 +677,8 @@ for _ in range(40):
|
||||
_c._settling(False, None)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
_nc.append(_c._settling(True, None))
|
||||
check("no monotonic clock, repeated hand-backs: the budget holds (8 hidden, "
|
||||
"then every bound counts)", _nc == [True] * M + [False] * (40 - M))
|
||||
check(f"no monotonic clock, repeated hand-backs: the budget holds ({M} "
|
||||
"hidden, then every bound counts)", _nc == [True] * M + [False] * (40 - M))
|
||||
|
||||
# An over-rating (a command over `max_w`) is a bug, never settled away.
|
||||
_ctl = _hb[1]
|
||||
@@ -613,13 +704,22 @@ _ctl._loop_alarm_input(_Res())
|
||||
check("...while the integrator bound on the next cycle is not counted",
|
||||
_ctl.loop_clamped[0] is False)
|
||||
|
||||
# A stop ends the window but does not give the budget back (review F2, X3).
|
||||
_now = _ctl._now_mono
|
||||
_ctl._hidden = (_now,) * M
|
||||
_ctl._wire_lost, _ctl._handback = True, (1.0, 3)
|
||||
_ctl._loop_decision = None # stopped or blind
|
||||
_ctl._loop_alarm_input(_Res())
|
||||
check("a stop clears it: the next start is not a hand-back, and the stop "
|
||||
"is not a cycle length",
|
||||
(_ctl._wire_lost, _ctl._handback, _ctl._settle_t)
|
||||
== (False, None, None))
|
||||
_st, _kept = (_ctl._wire_lost, _ctl._handback), _ctl._hidden
|
||||
_ctl._settling(False, _now + 1.0)
|
||||
_ctl.loop_clamped = (True, "", False)
|
||||
_after = _ctl._settling(True, _now + 2.0)
|
||||
check("a stop clears the window (the next start is not a hand-back) and "
|
||||
f"keeps the budget: with {M} hidden, the bound after the next "
|
||||
"hand-back counts",
|
||||
_st == (False, None) and _kept == (_now,) * M and _after is False)
|
||||
print(f" after the stop: hidden kept {len(_kept)}, the next hand-back's "
|
||||
f"bound hidden {_after}")
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the pinned digests of test_safety84 and test_safety87: only the "
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
"""SAFETY-123: the day plan's local-hour loop ends on the clock-change days.
|
||||
`python3 test_safety123.py`
|
||||
|
||||
On a host with no tzdata (`tariff._EUTimezone`) `dayplan._hour_slices()` never
|
||||
returned on the spring clock change (28 March 2027, 00:00 UTC): the control
|
||||
tick froze. Graded here, each call under a hard time limit (SIGALRM):
|
||||
|
||||
1. `_hour_slices()` over 27-29 March and 30 Oct - 1 Nov 2027, and the US
|
||||
change days, at every quarter, in the no-tzdata zone, a US-rule zone built
|
||||
the same way, and (where the host has tzdata) Europe/Brussels,
|
||||
America/New_York and zones with half-hour changes: it ends, covers the
|
||||
span, never crosses a local hour, labels the local hour, and gives 23
|
||||
slices on the spring day and 25 on the autumn day.
|
||||
2. Normal days: the same slices as release/1.0 a1d0703 (a copy of its
|
||||
function is kept below as the reference).
|
||||
3. `dayplan.plan()` at every quarter of those days with each pack mode, and
|
||||
its answer on normal days equal to the old function's.
|
||||
4. `Controller._mode_roles()` at every quarter of those days with modes on,
|
||||
for Solar first, Leftover solar and Cheap hours, and one full control
|
||||
cycle through the forecast read at the hang's instants.
|
||||
See DECISIONS/SAFETY-123-hour-slices-step-in-utc.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import resource
|
||||
import signal
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone, tzinfo
|
||||
|
||||
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 app.main as M # noqa: E402
|
||||
from app import dayplan, modetrade, packmode, priceruns, solarfc # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
from app.tariff import BILLING_TZ, _EUTimezone # noqa: E402
|
||||
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
LIMIT_S = 3.0 # one call; the old loop never returned
|
||||
try: # a loop that appends without end must not take the host
|
||||
resource.setrlimit(resource.RLIMIT_AS, (3 << 29, resource.getrlimit(
|
||||
resource.RLIMIT_AS)[1]))
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class Slow(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _alarm(*_a):
|
||||
raise Slow()
|
||||
|
||||
|
||||
def limited(fn, *a, **k):
|
||||
"""`fn(*a, **k)` or `Slow` when it takes more than LIMIT_S seconds."""
|
||||
signal.signal(signal.SIGALRM, _alarm)
|
||||
signal.setitimer(signal.ITIMER_REAL, LIMIT_S)
|
||||
try:
|
||||
return fn(*a, **k)
|
||||
except MemoryError:
|
||||
raise Slow() from None
|
||||
finally:
|
||||
signal.setitimer(signal.ITIMER_REAL, 0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The reference: release/1.0 a1d0703's function, verbatim, for normal days
|
||||
# --------------------------------------------------------------------------- #
|
||||
def old_slices(t0, t1):
|
||||
out, t = [], t0
|
||||
while t < t1:
|
||||
lt = dayplan._local(t)
|
||||
nxt = (lt.replace(minute=0, second=0, microsecond=0)
|
||||
+ timedelta(hours=1)).astimezone(timezone.utc)
|
||||
e = min(nxt, t1)
|
||||
out.append((t, e, lt.hour))
|
||||
t = e
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Zones
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _nth_sunday(year, month, n):
|
||||
d = date(year, month, 1)
|
||||
d += timedelta(days=(6 - d.weekday()) % 7 + 7 * (n - 1))
|
||||
return datetime(d.year, d.month, d.day)
|
||||
|
||||
|
||||
class USZone(tzinfo):
|
||||
"""America/New_York by the US rule (DST from the second Sunday of March
|
||||
02:00 to the first Sunday of November 02:00, local), built like
|
||||
tariff._EUTimezone: the offset is read from the local wall clock, so the
|
||||
missing 02:00 reads as DST. Its change days are not the EU's."""
|
||||
|
||||
def _dst_local(self, naive):
|
||||
y = naive.year
|
||||
return (_nth_sunday(y, 3, 2).replace(hour=2) <= naive
|
||||
< _nth_sunday(y, 11, 1).replace(hour=2))
|
||||
|
||||
def _dst_utc(self, naive_utc):
|
||||
y = naive_utc.year
|
||||
return (_nth_sunday(y, 3, 2).replace(hour=7) <= naive_utc
|
||||
< _nth_sunday(y, 11, 1).replace(hour=6))
|
||||
|
||||
def fromutc(self, dt):
|
||||
return dt + timedelta(
|
||||
hours=-4 if self._dst_utc(dt.replace(tzinfo=None)) else -5)
|
||||
|
||||
def utcoffset(self, dt):
|
||||
if dt is None:
|
||||
return timedelta(hours=-5)
|
||||
return timedelta(hours=-4 if self._dst_local(
|
||||
dt.replace(tzinfo=None)) else -5)
|
||||
|
||||
def dst(self, dt):
|
||||
return timedelta(hours=1) if dt is not None and self._dst_local(
|
||||
dt.replace(tzinfo=None)) else timedelta(0)
|
||||
|
||||
|
||||
def _q(y, mo, d, h=0):
|
||||
return datetime(y, mo, d, h, tzinfo=UTC)
|
||||
|
||||
|
||||
# (name, tzinfo, whole-hour offsets?, windows [from, to) at every quarter)
|
||||
BRU_SPRING = (_q(2027, 3, 27), _q(2027, 3, 30))
|
||||
BRU_AUTUMN = (_q(2027, 10, 30), _q(2027, 11, 2))
|
||||
US_SPRING = (_q(2027, 3, 13), _q(2027, 3, 16))
|
||||
US_AUTUMN = (_q(2027, 11, 6), _q(2027, 11, 9))
|
||||
COMMON = [BRU_SPRING, BRU_AUTUMN, US_SPRING, US_AUTUMN]
|
||||
ZONES = [("no-tzdata EU rule", _EUTimezone(), True, COMMON),
|
||||
("US rule", USZone(), True, COMMON)]
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
for _n, _w, _extra in (
|
||||
("Europe/Brussels", True, []),
|
||||
("America/New_York", True, []),
|
||||
# DST of half an hour, and clocks that jump at midnight
|
||||
("Australia/Lord_Howe", False,
|
||||
[(_q(2027, 4, 3), _q(2027, 4, 6)),
|
||||
(_q(2027, 10, 2), _q(2027, 10, 5))]),
|
||||
("America/Santiago", True,
|
||||
[(_q(2027, 4, 2), _q(2027, 4, 5)),
|
||||
(_q(2027, 9, 4), _q(2027, 9, 7))]),
|
||||
("Asia/Kathmandu", False, [])):
|
||||
try:
|
||||
ZONES.append((_n, ZoneInfo(_n), _w, COMMON + _extra))
|
||||
except Exception: # noqa: BLE001
|
||||
print(f" no tzdata for {_n} here: not graded")
|
||||
except ImportError:
|
||||
print(" no zoneinfo here")
|
||||
print(" zones graded:", ", ".join(z[0] for z in ZONES))
|
||||
|
||||
|
||||
class In:
|
||||
"""`dayplan.BILLING_TZ` is `tz` inside the with-block."""
|
||||
|
||||
def __init__(self, tz):
|
||||
self.tz = tz
|
||||
|
||||
def __enter__(self):
|
||||
self.was = dayplan.BILLING_TZ
|
||||
dayplan.BILLING_TZ = self.tz
|
||||
|
||||
def __exit__(self, *_a):
|
||||
dayplan.BILLING_TZ = self.was
|
||||
|
||||
|
||||
def quarters(a, b):
|
||||
t = a
|
||||
while t < b:
|
||||
yield t
|
||||
t += timedelta(minutes=15)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. _hour_slices(): ends, covers, local hours, 23 and 25 hours")
|
||||
HOUR = timedelta(hours=1)
|
||||
for zname, tz, whole, windows in ZONES:
|
||||
bad, slow, n = [], None, 0
|
||||
with In(tz):
|
||||
for a, b in windows:
|
||||
for t0 in quarters(a, b):
|
||||
for span in (HOUR, 7 * HOUR, 30 * HOUR):
|
||||
t1 = t0 + span
|
||||
n += 1
|
||||
try:
|
||||
sl = limited(dayplan._hour_slices, t0, t1)
|
||||
except Slow:
|
||||
slow = (t0, t1)
|
||||
break
|
||||
ok = bool(sl) and sl[0][0] == t0 and sl[-1][1] == t1
|
||||
ok = ok and all(x[1] == y[0] for x, y in zip(sl, sl[1:]))
|
||||
for s, e, h in sl:
|
||||
ok = ok and timedelta(0) < e - s <= HOUR
|
||||
lt = s.astimezone(tz)
|
||||
ok = ok and h == lt.hour
|
||||
last = (e - timedelta(microseconds=1)).astimezone(tz)
|
||||
# no local hour boundary inside the slice
|
||||
ok = ok and (lt.year, lt.month, lt.day, lt.hour) == (
|
||||
last.year, last.month, last.day, last.hour)
|
||||
# it ends on a local hour, the span's end, or where
|
||||
# the clock itself jumps (half-hour changes)
|
||||
el = e.astimezone(tz)
|
||||
ok = ok and (e == t1 or el.minute == 0
|
||||
or el.utcoffset() != lt.utcoffset())
|
||||
if whole:
|
||||
# whole-hour zones: local hours are UTC hours
|
||||
ok = ok and (e.minute == 0 or e == t1) \
|
||||
and (s.minute == 0 or s == t0)
|
||||
if not ok:
|
||||
bad.append((t0, t1))
|
||||
if slow:
|
||||
break
|
||||
if slow:
|
||||
break
|
||||
check(f"{zname}: every quarter x 1, 7, 30 h ({n} spans) returns within "
|
||||
f"{LIMIT_S:.0f} s" + ("" if not slow else f" - HUNG at {slow[0]}"),
|
||||
slow is None)
|
||||
check(f"{zname}: ...contiguous from t0 to t1, each slice at most an "
|
||||
f"hour, labelled with its local hour, none across a local hour",
|
||||
not bad)
|
||||
|
||||
# The change days: 23 and 25 local hours, from local midnight to midnight.
|
||||
# (spring day, autumn day, the local hour that happens twice in autumn)
|
||||
BRU_DAYS = ((2027, 3, 28), (2027, 10, 31), 2)
|
||||
US_DAYS = ((2027, 3, 14), (2027, 11, 7), 1)
|
||||
DAYS = {"no-tzdata EU rule": BRU_DAYS, "Europe/Brussels": BRU_DAYS,
|
||||
"US rule": US_DAYS, "America/New_York": US_DAYS}
|
||||
|
||||
|
||||
def local_day(tz, y, m, d):
|
||||
d0 = datetime(y, m, d, tzinfo=tz).astimezone(UTC)
|
||||
n = date(y, m, d) + timedelta(days=1)
|
||||
return d0, datetime(n.year, n.month, n.day, tzinfo=tz).astimezone(UTC)
|
||||
|
||||
|
||||
for zname, tz, _w, _win in ZONES:
|
||||
if zname not in DAYS:
|
||||
continue
|
||||
spring, autumn, twice = DAYS[zname]
|
||||
with In(tz):
|
||||
for what, (y, m, d), want, want_hours in (
|
||||
("spring", spring, 23, [h for h in range(24) if h != 2]),
|
||||
("autumn", autumn, 25,
|
||||
[h for h in range(24) for _ in range(2 if h == twice else 1)])):
|
||||
d0, d1 = local_day(tz, y, m, d)
|
||||
try:
|
||||
sl = limited(dayplan._hour_slices, d0, d1)
|
||||
except Slow:
|
||||
sl = None
|
||||
check(f"{zname}, {what} day {y}-{m:02d}-{d:02d}: {want} one-hour "
|
||||
f"slices from local midnight to midnight",
|
||||
sl is not None and len(sl) == want
|
||||
and all(e - s == HOUR for s, e, _h in sl)
|
||||
and d1 - d0 == want * HOUR and sl[0][0] == d0
|
||||
and sl[-1][1] == d1)
|
||||
check(f"{zname}, {what} day: the local hours are "
|
||||
f"{'0-23 without 2' if what == 'spring' else f'0-23 with {twice} twice'}",
|
||||
sl is not None and [h for _s, _e, h in sl] == want_hours)
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. normal days: the same slices as release/1.0 a1d0703")
|
||||
NORMAL = [(_q(2027, 1, 9), _q(2027, 1, 12)), (_q(2027, 6, 18), _q(2027, 6, 21)),
|
||||
(_q(2027, 8, 30), _q(2027, 9, 2)), (_q(2027, 12, 18), _q(2027, 12, 21))]
|
||||
for zname, tz, _w, _win in ZONES:
|
||||
diff, n = [], 0
|
||||
with In(tz):
|
||||
for a, b in NORMAL:
|
||||
for t0 in quarters(a, b):
|
||||
for off in (timedelta(0), timedelta(seconds=7, microseconds=5),
|
||||
timedelta(minutes=14, seconds=59, microseconds=999999),
|
||||
timedelta(minutes=37, seconds=1)):
|
||||
for span in (timedelta(minutes=1), timedelta(minutes=50),
|
||||
3 * HOUR, 26 * HOUR):
|
||||
s0 = t0 + off
|
||||
n += 1
|
||||
if dayplan._hour_slices(s0, s0 + span) != old_slices(
|
||||
s0, s0 + span):
|
||||
diff.append(s0)
|
||||
check(f"{zname}: {n} spans on ordinary days equal the old slices "
|
||||
f"(including starts off the quarter)", n > 1000 and not diff)
|
||||
with In(ZONES[0][1]):
|
||||
s0 = _q(2027, 1, 10, 9)
|
||||
eq = dayplan._hour_slices(s0, s0) == [] == old_slices(s0, s0) \
|
||||
and dayplan._hour_slices(s0, s0 - HOUR) == []
|
||||
check("an empty or backwards span gives no slices, as before", eq)
|
||||
plus2 = timezone(timedelta(hours=2))
|
||||
t0 = datetime(2027, 6, 1, 10, 20, tzinfo=plus2)
|
||||
check("a start given in another zone still gives the old slices",
|
||||
dayplan._hour_slices(t0, t0 + 3 * HOUR) ==
|
||||
old_slices(t0, t0 + 3 * HOUR))
|
||||
|
||||
# ============================================================================ #
|
||||
print("3. dayplan.plan() at every quarter of the change days")
|
||||
|
||||
|
||||
def st(kwh, at):
|
||||
return {"state": str(kwh), "attributes": {"unit_of_measurement": "kWh"},
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
def cheap_dear(h):
|
||||
return 0.08 if 1.0 <= h < 5.0 else 0.34 if 17.0 <= h < 21.0 else 0.20
|
||||
|
||||
|
||||
def price_plan(now, f=cheap_dear, days=4):
|
||||
"""15-minute firm prices from a day before `now` for `days` days."""
|
||||
start = (now - timedelta(days=1)).replace(minute=0, second=0,
|
||||
microsecond=0)
|
||||
pts = []
|
||||
for i in range(days * 96 + 96):
|
||||
b = brussels(start + timedelta(minutes=15 * i))
|
||||
pts.append(PricePoint(start=b, eur_per_kwh=f(b.hour + b.minute / 60),
|
||||
provisional=False))
|
||||
return TariffPlan(PriceRead.series(pts, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", now), None,
|
||||
"provider")
|
||||
|
||||
|
||||
_PLANS = {}
|
||||
|
||||
|
||||
def plan_for(start):
|
||||
"""One price plan covering `start` - 1 day .. + 5 days, kept: building it
|
||||
for every quarter is most of the run time."""
|
||||
if start not in _PLANS:
|
||||
_PLANS[start] = price_plan(start, days=6)
|
||||
return _PLANS[start]
|
||||
|
||||
|
||||
HOUSE = dayplan.HouseProfile({"2027-03-20": {
|
||||
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}})
|
||||
MODES = {"Solar first": (packmode.SOLAR_FIRST, packmode.SOLAR_FIRST),
|
||||
"Leftover solar": (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR),
|
||||
"Cheap hours": (packmode.CHEAP_HOURS, packmode.CHEAP_HOURS)}
|
||||
|
||||
|
||||
def packs_of(modes, soc=(55.0, 40.0)):
|
||||
return [modetrade.Pack(f"inverter-{u}", True, mode, False, 2500.0, s, 10.0)
|
||||
for u, mode, s in zip("ab", modes, soc)]
|
||||
|
||||
|
||||
_INPUTS = {}
|
||||
|
||||
|
||||
def plan_at(now, modes, start=None):
|
||||
"""plan() on the price runs and forecast of `now` (made once per quarter:
|
||||
the runs do not depend on the pack modes)."""
|
||||
if now not in _INPUTS:
|
||||
pts = plan_for(start or now.replace(hour=0, minute=0)).read.points
|
||||
_INPUTS[now] = (
|
||||
priceruns.plan(pts, now, required_eur_kwh=0.1, charge_kwh=8.0,
|
||||
charge_kw=5.0, sell_kwh=0.0, sell_kw=0.0),
|
||||
solarfc.read(st(2.0, now), st(8.0, now), now)[0])
|
||||
runs, fc = _INPUTS[now]
|
||||
return dayplan.plan(fc, HOUSE.estimator(), runs, now, packs_of(modes),
|
||||
trust_pct=70, floor_pct=20, ceiling_pct=95)
|
||||
|
||||
|
||||
spy = []
|
||||
_slices = dayplan._hour_slices
|
||||
dayplan._hour_slices = lambda a, b: (spy.append((a, b)), _slices(a, b))[1]
|
||||
try:
|
||||
for mname, modes in MODES.items():
|
||||
for wname, (a, b) in (("27-29 March", BRU_SPRING),
|
||||
("30 Oct-1 Nov", BRU_AUTUMN)):
|
||||
made, slow, n = 0, None, 0
|
||||
spy.clear()
|
||||
for now in quarters(a, b):
|
||||
n += 1
|
||||
try:
|
||||
p = limited(plan_at, now, modes, a)
|
||||
except Slow:
|
||||
slow = now
|
||||
break
|
||||
made += p is not None
|
||||
check(f"plan(), {mname}, {wname}: {n} quarters return within "
|
||||
f"{LIMIT_S:.0f} s" + ("" if not slow else
|
||||
f" - HUNG at {slow}")
|
||||
+ f"; a plan is made at {made} of them", slow is None
|
||||
and made > 20)
|
||||
if wname == "27-29 March" and not slow:
|
||||
cross = [s for s in spy if s[0] <= _q(2027, 3, 28) < s[1]]
|
||||
check(f"plan(), {mname}: {len(cross)} spans ran across the "
|
||||
"change (28 March 00:00 UTC): the test reaches the "
|
||||
"loop", len(cross) > 20)
|
||||
finally:
|
||||
dayplan._hour_slices = _slices
|
||||
|
||||
# The answer on normal days is the old function's.
|
||||
for mname, modes in MODES.items():
|
||||
same, n = True, 0
|
||||
for now in quarters(_q(2027, 1, 11), _q(2027, 1, 13)):
|
||||
new = plan_at(now, modes)
|
||||
dayplan._hour_slices = old_slices
|
||||
try:
|
||||
was = plan_at(now, modes)
|
||||
finally:
|
||||
dayplan._hour_slices = _slices
|
||||
n += 1
|
||||
same = same and new == was
|
||||
check(f"plan(), {mname}: {n} quarters on ordinary days equal the old "
|
||||
"answer", same)
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. Controller._mode_roles() at every quarter, modes on")
|
||||
|
||||
|
||||
def controller(modes, now):
|
||||
extra = {"battery_modes_enabled": True, "arbitrage_enabled": True,
|
||||
"cycle_cost_threshold_eur": 0.05,
|
||||
"pv_forecast_entities": ["sensor.t1", "sensor.t2", "sensor.t3"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.m1", "sensor.m2",
|
||||
"sensor.m3"]}
|
||||
opts = rig._options(2, extra)
|
||||
for i in opts["inverters"]:
|
||||
i["capacity_kwh"] = 10.0
|
||||
store = rig._Store()
|
||||
for u, mode in zip("ab", modes):
|
||||
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
|
||||
store.data["house_profile"] = {"2027-03-20": {
|
||||
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}}
|
||||
|
||||
class Hass(rig._Hass):
|
||||
async def state(self, e):
|
||||
if e.startswith(("sensor.t", "sensor.m")):
|
||||
return st(2.0 if e.startswith("sensor.t") else 8.0, self.at)
|
||||
return await super().state(e)
|
||||
hass = Hass(("a", "b"), lambda u, t: 55.0 if u == "a" else 40.0,
|
||||
lambda u, t: False)
|
||||
hass.at = now
|
||||
c = M.Controller(opts, hass, store, rig._Pub(), stop=asyncio.Event())
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
i.soc = 55.0 if i.name.endswith("a") else 40.0
|
||||
c.auto = True
|
||||
return c, hass
|
||||
|
||||
|
||||
for mname, modes in MODES.items():
|
||||
for wname, (a, b) in (("27-29 March", BRU_SPRING),
|
||||
("30 Oct-1 Nov", BRU_AUTUMN)):
|
||||
c, hass = controller(modes, a)
|
||||
slow, n, made, roles = None, 0, 0, set()
|
||||
spy.clear()
|
||||
dayplan._hour_slices = lambda x, y: (spy.append((x, y)),
|
||||
_slices(x, y))[1]
|
||||
try:
|
||||
for now in quarters(a, b):
|
||||
n += 1
|
||||
c.tariff_plan, c.tariff_at = plan_for(a), now
|
||||
c.solar_fc, _w, _g = solarfc.read_sum(
|
||||
[("sensor.t1", st(2.0, now))],
|
||||
[("sensor.m1", st(8.0, now))], now)
|
||||
try:
|
||||
t = limited(c._mode_roles, now, None)
|
||||
except Slow:
|
||||
slow = now
|
||||
break
|
||||
made += c.day_plan is not None
|
||||
roles |= set(t.roles.values())
|
||||
finally:
|
||||
dayplan._hour_slices = _slices
|
||||
check(f"_mode_roles(), {mname}, {wname}: {n} quarters return within "
|
||||
f"{LIMIT_S:.0f} s" + ("" if not slow else f" - HUNG at {slow}"),
|
||||
slow is None)
|
||||
check(f"...a day plan is made at {made} of them (the loop is "
|
||||
f"reached), roles seen {sorted(roles)}", slow is None
|
||||
and made > 20 and len(spy) > 20)
|
||||
|
||||
# One whole control cycle through the real forecast read (three sensors a
|
||||
# day) at the instants the old loop hung: the page and control share the loop.
|
||||
for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
|
||||
"2027-03-28T00:00:05+00:00"):
|
||||
now = datetime.fromisoformat(iso)
|
||||
c, hass = controller(MODES["Leftover solar"], now)
|
||||
real_dt = M.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None, _n=now):
|
||||
return _n if tz is None else _n.astimezone(tz)
|
||||
M.datetime = _DT
|
||||
try:
|
||||
async def one():
|
||||
await c.read_inputs(now_mono=0.0)
|
||||
c.tariff_plan, c.tariff_at = price_plan(now), now
|
||||
await c.cycle()
|
||||
try:
|
||||
limited(lambda: asyncio.run(one()))
|
||||
ok = c.solar_fc is not None and c.day_plan is not None
|
||||
except Slow:
|
||||
ok = False
|
||||
finally:
|
||||
M.datetime = real_dt
|
||||
check(f"a whole control cycle at {iso[:16]}Z (three forecast sensors a "
|
||||
"day) returns with a day plan", ok)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all ok")
|
||||
@@ -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")
|
||||
@@ -474,20 +474,26 @@ print("\n8. the N=1 and N=2 digests")
|
||||
# pin lifts (t=1800) and B no longer jumps 1390 W, and after the surplus step.
|
||||
# SAFETY-119 re-pinned all six: only the log. OUTPUT_CLAMP rows from the
|
||||
# loop's bound just after the guard hands back are gone; every command and
|
||||
# claim is the same (test_safety119.py part 6).
|
||||
# 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":
|
||||
"19781f90b9751b10994dedeaafb38e42d48ad2551640792e601336b7f9b5bb4f",
|
||||
"00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9",
|
||||
"n1_charge_dear":
|
||||
"eeeb0fa19a6fdc04d44a0838bb2bb911f506a936b0f93d8a6f4d87159053ec74",
|
||||
"c25340b56060d4581f7204e200b07e7c8618711be2baca3b1667b7ebeb600dc3",
|
||||
"n1_charge_banked":
|
||||
"a51f125a8c1c98fdbb009fce72b6781d20d384bdf0f5ce7bbfe367d86e5477db",
|
||||
"09a08298d35195380ff427cdd2448d9212119be4692db26b2b1a45957fdf3300",
|
||||
"n2_charge":
|
||||
"e37e98390baa5dad9128991fabf8b170894395fe026d8a33c8a51de7ed0789cc",
|
||||
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
|
||||
"n2_charge_dear":
|
||||
"7727372de423f62d77b4426a7226eae31a30f3bb89a3cf70272cc7c1823d7263",
|
||||
"aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db",
|
||||
"n2_charge_banked":
|
||||
"c77e26006c026b953e9c3dcc9a02f3993589f885cb52c7cbceac6bd59b600551",
|
||||
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f",
|
||||
}
|
||||
_now = s84.digests()
|
||||
for k in PINNED:
|
||||
@@ -495,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-119), 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")))
|
||||
|
||||
@@ -521,18 +521,21 @@ print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
|
||||
# SAFETY-119 re-pinned n1_crossing, n1_above and n1_charge_low: only the
|
||||
# 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).
|
||||
# (test_safety119.py part 6). SAFETY-121 re-pinned the same three again:
|
||||
# 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",
|
||||
"n1_crossing":
|
||||
"20f8e542c1ee3eb3a58c8fd6ba3b3e5b6b1486800cd63b675abc68bc222a172b",
|
||||
"97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198",
|
||||
"n1_above":
|
||||
"3a79f1394b2b49fa88f3c054da1d9da450153ad5f0d2a2c2f960d02a7abe2208",
|
||||
"9229b0f0e8addd1bd114461cc36c7f02e9734012b3b7e154d7747bd4cc69b773",
|
||||
"n1_under_banked":
|
||||
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
|
||||
"n1_charge_low":
|
||||
"2e1ee6db0d4f9f18836c4cbbe406fddd496656b54b97f329da513a1591f0ce6c",
|
||||
"c3af6218cc14c072e08b09550b153502dafec806009a92ba9032738b9ac9fa61",
|
||||
"n1_hold":
|
||||
"0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d",
|
||||
}
|
||||
@@ -540,9 +543,9 @@ _now = s87.digests()
|
||||
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-119), under, "
|
||||
"crossing and above the reserve, in a charge and a HOLD, and under a "
|
||||
"banked peak",
|
||||
"log) equals b8ddfd7's (the log as re-pinned by SAFETY-121), under, "
|
||||
"crossing and above the reserve, in a charge (as SAFETY-125 re-pinned "
|
||||
"it) and a HOLD, and under a banked peak",
|
||||
_now == PINNED)
|
||||
|
||||
# ============================================================================ #
|
||||
|
||||
@@ -636,10 +636,12 @@ _app = web.build_app(FakeController(), FakeSupervisor())
|
||||
_routes = sorted({(r.method, r.get_info().get("path"))
|
||||
for r in _app.router.routes() if r.method != "HEAD"})
|
||||
print(" " + " ".join(f"{m} {p}" for m, p in _routes))
|
||||
check("the configuration surface adds five routes and UX-13 one GET, and "
|
||||
"none touches the three that were there",
|
||||
check("the configuration surface adds five routes, UX-13 one GET and "
|
||||
"UX-54 one more GET (the Forecast tab), and none touches the three "
|
||||
"that were there",
|
||||
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
|
||||
("GET", "/ha/entities"), ("GET", "/status"),
|
||||
("GET", "/forecast"), ("GET", "/ha/entities"),
|
||||
("GET", "/status"),
|
||||
("POST", "/action"), ("POST", "/config/restart"),
|
||||
("POST", "/config/save"), ("POST", "/config/validate")])
|
||||
_ents_src = io.open(os.path.join(HERE, "app", "haentities.py"),
|
||||
|
||||
@@ -82,6 +82,11 @@ from app.validate import validate_startup_config
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
UTC = timezone.utc
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
||||
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
||||
fails = []
|
||||
|
||||
|
||||
@@ -333,8 +338,8 @@ check(f"...while the two-period spread is the day/night difference "
|
||||
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
|
||||
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
|
||||
|
||||
_v_fixed = p_fixed.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
|
||||
_v_two = p_two.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
|
||||
_v_fixed = p_fixed.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
|
||||
_v_two = p_two.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
|
||||
check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused")
|
||||
check("...for the true reason - the series is flat, so there is nothing to buy "
|
||||
"low and sell high, not because a FIXED enum was matched on",
|
||||
@@ -354,7 +359,7 @@ _flat_dayahead = TariffPlan(
|
||||
None, "provider")
|
||||
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
|
||||
"is arithmetic and not a product label",
|
||||
_flat_dayahead.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
|
||||
_flat_dayahead.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
|
||||
|
||||
# The numbers an optimiser needs exist ONLY on the permitting variant.
|
||||
check("the allowed verdict carries the spread, the cheapest hour and the "
|
||||
@@ -379,16 +384,16 @@ check("...and there is no falsy variant, so `if verdict:` cannot look like a "
|
||||
# §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it.
|
||||
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
|
||||
"the peak guard is holding",
|
||||
p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
|
||||
p_two.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
|
||||
check("...and the reason names the ordering rather than the tariff",
|
||||
"priority 2" in p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason)
|
||||
"priority 2" in p_two.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason)
|
||||
check("...and the question cannot be skipped: peak_guard_active is required "
|
||||
"and keyword-only, so a spread is not obtainable without answering it",
|
||||
raises(lambda: p_two.arbitrage(), TypeError)
|
||||
and raises(lambda: p_two.arbitrage(False), TypeError))
|
||||
check("...nor answered with a truthy 'unknown'",
|
||||
raises(lambda: p_two.arbitrage(peak_guard_active=None), TypeError)
|
||||
and raises(lambda: p_two.arbitrage(peak_guard_active="no"), TypeError))
|
||||
raises(lambda: p_two.arbitrage(now=LONG_AGO, peak_guard_active=None), TypeError)
|
||||
and raises(lambda: p_two.arbitrage(now=LONG_AGO, peak_guard_active="no"), TypeError))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -524,7 +529,7 @@ check("...and its underlying read has no .points at all, so `min(read.points)` "
|
||||
"cannot run against it",
|
||||
raises(lambda: _none.read.points, Absence))
|
||||
check("...and arbitrage is refused, per §11.4",
|
||||
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
|
||||
_none.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
|
||||
and arbitrage_allowed(_none.read) is False)
|
||||
check("...and static_status() says so in words rather than reporting a price",
|
||||
static_status(opts(None), THU) == (False, "No fixed tariff set"))
|
||||
@@ -776,12 +781,12 @@ check("...marked PROVISIONAL, which is the one flag that differs",
|
||||
_fb.provisional and plan_is_provisional(_fb.read))
|
||||
check("...and arbitrage is refused on it, per §11.4, even though a full series "
|
||||
"exists and has a real spread",
|
||||
_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
|
||||
_fb.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
|
||||
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
|
||||
check("...while the SAME profile as the configured product allows it - so the "
|
||||
"refusal is about provisionality and not about the profile",
|
||||
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
|
||||
).arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed")
|
||||
).arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed")
|
||||
check("...and the provisional plan has no cheap window either: §8.3 forbids an "
|
||||
"irreversible action on a provisional price, and a grid top-up is one",
|
||||
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
|
||||
@@ -821,8 +826,8 @@ check("a live feed is used in preference to the static profile",
|
||||
and _live_plan.product is PriceProduct.DAY_AHEAD)
|
||||
check("...and arbitrage is allowed on it, with the spread derived from the "
|
||||
"feed rather than from the profile",
|
||||
_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
|
||||
and near(_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
|
||||
_live_plan.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
|
||||
and near(_live_plan.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
|
||||
0.023, 1e-9))
|
||||
|
||||
|
||||
@@ -1036,7 +1041,7 @@ check(f"each tiny-spread probe really carries the spread it is named for, and "
|
||||
all(near(p.spread_eur_kwh(), d, 1e-12)
|
||||
and len([q for q in p.read.points if not q.provisional]) == 2
|
||||
for d, p in _tiny9))
|
||||
_tinyv9 = [(d, rung9(p.arbitrage(**_A9))) for d, p in _tiny9]
|
||||
_tinyv9 = [(d, rung9(p.arbitrage(now=LONG_AGO, **_A9))) for d, p in _tiny9]
|
||||
check(f"TEST-16 AC1: a spread that is SMALL BUT REAL is ALLOWED at "
|
||||
f"min_spread_eur_kwh=0.0 - down to 0.000001 EUR/kWh, so the flat rung is "
|
||||
f"`spread == 0.0` and not `spread <= epsilon` for any epsilon "
|
||||
@@ -1045,7 +1050,7 @@ check(f"TEST-16 AC1: a spread that is SMALL BUT REAL is ALLOWED at "
|
||||
check("...and the exactly flat series on the OTHER side of that boundary is "
|
||||
"still refused, and refused BY THE FLAT RUNG - without this the sweep "
|
||||
"above would also pass for a rung that had been deleted outright",
|
||||
rung9(plan9([0.20000, 0.20000]).arbitrage(**_A9)) == "flat")
|
||||
rung9(plan9([0.20000, 0.20000]).arbitrage(now=LONG_AGO, **_A9)) == "flat")
|
||||
_q9 = plan9([0.20000, 0.2000005])
|
||||
check(f"...and a difference below the rung's own 6-decimal rounding is flat BY "
|
||||
f"CONSTRUCTION and says so: the raw difference is "
|
||||
@@ -1056,10 +1061,10 @@ check(f"...and a difference below the rung's own 6-decimal rounding is flat BY "
|
||||
f"to bisect further",
|
||||
not near(_q9.spread_eur_kwh(), 0.0, 1e-9)
|
||||
and near(round(_q9.spread_eur_kwh(), 6), 0.0, 1e-12)
|
||||
and rung9(_q9.arbitrage(**_A9)) == "flat")
|
||||
and rung9(_q9.arbitrage(now=LONG_AGO, **_A9)) == "flat")
|
||||
|
||||
# --- and what D's survival actually costs: the sentence, not the verdict -----
|
||||
_lie9 = plan9([0.20000, 0.20050]).arbitrage(**_A9)
|
||||
_lie9 = plan9([0.20000, 0.20050]).arbitrage(now=LONG_AGO, **_A9)
|
||||
check(f"TEST-16 AC4: the 0.0005 verdict carries the real spread rather than a "
|
||||
f"flatness claim about a tariff that is not flat - the false sentence is "
|
||||
f"what a loosened rung ships to the operator while the outcome at a "
|
||||
@@ -1085,19 +1090,19 @@ check(f"the one-firm probe is well formed: exactly ONE firm hour out of three, "
|
||||
check("TEST-16 AC2: one firm hour is refused BY §8.3's FIRM-PAIR RUNG, named "
|
||||
"in the reason - not by the flat rung that would also have refused it. "
|
||||
"This is what takes `len(firm) < 2` -> `< 1` red on its own",
|
||||
rung9(_one9.arbitrage(**_A9)) == "firm-pair")
|
||||
rung9(_one9.arbitrage(now=LONG_AGO, **_A9)) == "firm-pair")
|
||||
check("...and the sentence is §8.3's in full, so the operator is told the "
|
||||
"series is half-published rather than told it is fixed-price",
|
||||
"§8.3" in _one9.arbitrage(**_A9).reason
|
||||
and "only 1 of 3" in _one9.arbitrage(**_A9).reason
|
||||
and "flat" not in _one9.arbitrage(**_A9).reason)
|
||||
"§8.3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason
|
||||
and "only 1 of 3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason
|
||||
and "flat" not in _one9.arbitrage(now=LONG_AGO, **_A9).reason)
|
||||
# The rung pinned from ABOVE as well. Without this, `len(firm) < 3` - the same
|
||||
# +0 B edit in the other direction - would pass everything above.
|
||||
_two9 = plan9([0.20, 0.40, 0.10], [False, False, True])
|
||||
check("...and TWO firm hours are ALLOWED on the identical series shape, so the "
|
||||
"rung is pinned from above too and `< 3` is red as well as `< 1`",
|
||||
rung9(_two9.arbitrage(**_A9)) == "allowed"
|
||||
and near(_two9.arbitrage(**_A9).spread_eur_kwh, 0.20))
|
||||
rung9(_two9.arbitrage(now=LONG_AGO, **_A9)) == "allowed"
|
||||
and near(_two9.arbitrage(now=LONG_AGO, **_A9).spread_eur_kwh, 0.20))
|
||||
# ...and from below, where the failure is not a wrong sentence but a crash:
|
||||
# `max()` over an empty `firm` raises ValueError, so `< 0` does not degrade the
|
||||
# reason, it takes the verdict away entirely.
|
||||
@@ -1105,7 +1110,7 @@ _zero9 = plan9([0.20, 0.40, 0.10], [True, True, True])
|
||||
check("...and a FULLY provisional series is refused by the same rung and does "
|
||||
"not raise - `max()` over an empty firm set is a ValueError, so this row "
|
||||
"is what makes `len(firm) < 0` red",
|
||||
rung9(derived(lambda: _zero9.arbitrage(**_A9))) == "firm-pair")
|
||||
rung9(derived(lambda: _zero9.arbitrage(now=LONG_AGO, **_A9))) == "firm-pair")
|
||||
|
||||
# --- AC3: the two rungs do not mask each other ------------------------------
|
||||
# ⚠️ THE POINT OF THE TICKET, AND IT IS A PROPERTY OF THE TABLE, NOT OF A ROW.
|
||||
@@ -1130,7 +1135,7 @@ _TABLE9 = (
|
||||
("two firm hours of three", _two9, "allowed"),
|
||||
("no firm hours at all", _zero9, "firm-pair"),
|
||||
)
|
||||
_got9 = [(name, want, rung9(derived(lambda p=pl: p.arbitrage(**_A9))))
|
||||
_got9 = [(name, want, rung9(derived(lambda p=pl: p.arbitrage(now=LONG_AGO, **_A9))))
|
||||
for name, pl, want in _TABLE9]
|
||||
check(f"TEST-16 AC3: each stimulus is answered by the rung that OWNS it, and "
|
||||
f"by no other - the flat rung is asked a question the firm-pair rung "
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Runnable check for TARIFF-24. `python3 test_tariff24.py`
|
||||
|
||||
Battery modes on, a day/night `static_profile`, no price feed and the site trade
|
||||
switch off used to start no tariff task: `Controller.tariff_plan` stayed None,
|
||||
`mode_runs` stayed None, Leftover solar and Cheap hours never charged from the
|
||||
grid, and the Batteries screen said "No prices" while Home priced the day from
|
||||
the profile itself. DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
|
||||
1. The startup gate: `tariff_refresh_wanted()` by configuration, and the guard
|
||||
`amain()` really puts on the task (read from its source, not retyped).
|
||||
2. End to end, on the real Controller and the real `run_tariff_refresh()` task,
|
||||
started only if `amain()`'s own guard says so: night period, one battery
|
||||
Leftover solar and one Cheap hours.
|
||||
3. The same with a gap below the cycle cost.
|
||||
4. Home and the Batteries screen agree.
|
||||
5. UX-55: with every battery Solar first Home no longer says "no prices have
|
||||
arrived" on a static profile, and still does with no profile and no feed.
|
||||
6. Nothing else moved: modes off starts no task; the gate rows in part 1 keep
|
||||
the arbitrage and feed cases as before.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): the gate
|
||||
reverted to `provider or arbitrage` fails 18 checks in parts 1 to 5; `True`
|
||||
fails the modes-off rows; `bool(opts.get(...))` fails the exact-true row;
|
||||
dropping the arbitrage term fails the arbitrage rows.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, modetrade, packmode # noqa: E402
|
||||
from app import price as _price, static_tariff as _st # noqa: E402
|
||||
from app.main import Controller, tariff_refresh_wanted # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
|
||||
# A Belgian-style day/night tariff, example values: dear 07:00-22:00, cheap
|
||||
# 22:00-07:00, every day. The gap is 0.08 EUR/kWh.
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.22},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.30}]}')
|
||||
# A Friday in winter time (+01:00), 23:00 local: inside the night period.
|
||||
L = timezone(timedelta(hours=1))
|
||||
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def opts(*, modes=True, arb=False, profile=PROFILE, provider=None,
|
||||
cost=0.05):
|
||||
o = dict(rp.options(modes, arb, 2))
|
||||
o["cycle_cost_threshold_eur"] = cost
|
||||
if profile is not None:
|
||||
o["static_profile"] = profile
|
||||
if provider is not None:
|
||||
o["price_provider"] = provider
|
||||
return o
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The guard amain() puts on the tariff task, read from amain()'s source.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _guards_of(node, guards=()):
|
||||
"""[(guards, coroutine name)] for every create_task(...) under `node`."""
|
||||
out = []
|
||||
for child in ast.iter_child_nodes(node):
|
||||
if isinstance(child, ast.If):
|
||||
test = ast.unparse(child.test)
|
||||
for stmt in child.body:
|
||||
out += _guards_of(stmt, guards + (test,))
|
||||
for stmt in child.orelse:
|
||||
out += _guards_of(stmt, guards + (f"not ({test})",))
|
||||
continue
|
||||
if isinstance(child, ast.Call) and \
|
||||
ast.unparse(child.func).endswith("create_task") and child.args:
|
||||
arg = child.args[0]
|
||||
name = (arg.func.attr if isinstance(arg, ast.Call)
|
||||
and isinstance(arg.func, ast.Attribute)
|
||||
else ast.unparse(arg))
|
||||
out.append((guards, name))
|
||||
continue
|
||||
out += _guards_of(child, guards)
|
||||
return out
|
||||
|
||||
|
||||
_TREE = ast.parse(open(os.path.join(HERE, "app", "main.py"),
|
||||
encoding="utf-8").read())
|
||||
_AMAIN = first((n for n in ast.walk(_TREE)
|
||||
if isinstance(n, ast.AsyncFunctionDef) and n.name == "amain"),
|
||||
"amain() in main.py")
|
||||
_TASK_GUARDS = [g for g, n in _guards_of(_AMAIN) if n == "run_tariff_refresh"]
|
||||
|
||||
|
||||
def task_started(o) -> bool:
|
||||
"""Would amain() start the tariff refresh task for options `o`?"""
|
||||
if len(_TASK_GUARDS) != 1:
|
||||
return False
|
||||
return all(bool(eval(g, vars(m), {"opts": o})) for g in _TASK_GUARDS[0])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the startup gate")
|
||||
check("amain() starts the tariff refresh from exactly one guard, and that "
|
||||
"guard can be decided from the options alone",
|
||||
len(_TASK_GUARDS) == 1 and len(_TASK_GUARDS[0]) >= 1
|
||||
and all(isinstance(eval(g, vars(m), {"opts": opts()}), bool)
|
||||
for g in _TASK_GUARDS[0]))
|
||||
GATE = [
|
||||
# label, options, task wanted
|
||||
("modes on + static day/night only", opts(), True),
|
||||
("modes on + a fixed price only",
|
||||
opts(profile='{"periods": [{"name": "all", "days": "all", "from": '
|
||||
'"00:00", "to": "24:00", "eur_per_kwh": 0.27}]}'), True),
|
||||
("modes on, nothing else", opts(profile=None), True),
|
||||
("modes off + arbitrage off + no feed (static profile)",
|
||||
opts(modes=False), False),
|
||||
("modes off + arbitrage off + no feed, no profile",
|
||||
opts(modes=False, profile=None), False),
|
||||
("modes off + arbitrage on", opts(modes=False, arb=True), True),
|
||||
("modes off + a feed", opts(modes=False, provider="generic_dayahead"),
|
||||
True),
|
||||
("modes on + a feed", opts(provider="generic_dayahead"), True),
|
||||
("modes on + arbitrage on", opts(arb=True), True),
|
||||
]
|
||||
for label, o, want in GATE:
|
||||
got = tariff_refresh_wanted(o)
|
||||
print(f" wanted={got!s:5} started={task_started(o)!s:5} {label}")
|
||||
check(f"{label}: tariff_refresh_wanted() is {want}", got is want)
|
||||
check(f"{label}: amain() starts the task exactly as that says",
|
||||
task_started(o) is want)
|
||||
check("battery_modes_enabled has to be exactly true (packmode.enabled()): "
|
||||
"'true' as text or 1 does not start the task",
|
||||
not tariff_refresh_wanted(dict(opts(), battery_modes_enabled="true"))
|
||||
and not tariff_refresh_wanted(dict(opts(), battery_modes_enabled=1)))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The real Controller over a few minutes of a night: the task starts only if
|
||||
# amain()'s own guard says so; then read_inputs() + cycle() as the control
|
||||
# loop runs them.
|
||||
# --------------------------------------------------------------------------- #
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
|
||||
|
||||
FETCHES = []
|
||||
|
||||
|
||||
async def _no_fetch(*a, **k):
|
||||
FETCHES.append(a)
|
||||
raise AssertionError("the tariff task reached for a price feed")
|
||||
|
||||
|
||||
def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
|
||||
house_w=300.0, tick=5.0):
|
||||
"""-> (controller, rows, task_was_started)."""
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in choice.items():
|
||||
store.data[unit_key(f"inverter-{u}", "pack_use")] = use
|
||||
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
|
||||
store.data[unit_key(f"inverter-{u}", "pack_sell")] = sell
|
||||
rows = []
|
||||
|
||||
async def go():
|
||||
hass = rp.Hass(("a", "b"), lambda t: house_w,
|
||||
soc0 or {"a": 40.0, "b": 40.0})
|
||||
tmp = tempfile.mkdtemp(prefix="t24-")
|
||||
c = Controller(dict(o), hass, store, rp._Pub(),
|
||||
stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
clock = [start]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
_real_load = _price.load_prices, _st.load_prices
|
||||
_price.load_prices = _st.load_prices = _no_fetch
|
||||
started = task_started(o)
|
||||
task = (asyncio.create_task(c.run_tariff_refresh())
|
||||
if started else None)
|
||||
try:
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = start + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if t == 0.0 and started:
|
||||
for _ in range(500): # let the task do its first pass
|
||||
if c.tariff_plan is not None:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(tick, 10.0)
|
||||
rows.append(dict(
|
||||
now=now, runs=c.mode_runs, tick=c.mode_tick,
|
||||
plan=c.fresh_tariff_plan(now),
|
||||
action=getattr(c.arb, "action", None),
|
||||
p={u: p.p for u, p in hass.plants.items()},
|
||||
soc={u: p.soc for u, p in hass.plants.items()}))
|
||||
t += tick
|
||||
# What the two screens say at the end, while the clock is still
|
||||
# the simulated one.
|
||||
c.home_plan = homeview.build_plan(c, clock[0], False, True)
|
||||
c.battery_cards = homeview.battery_rows(c, False, True)
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
_price.load_prices, _st.load_prices = _real_load
|
||||
return c, started
|
||||
|
||||
c, started = asyncio.run(go())
|
||||
return c, rows, started
|
||||
|
||||
|
||||
def charging_b(rows):
|
||||
"""Cycles in which the Cheap hours battery was charging (grid: no sun and
|
||||
a 300 W house in this scenario)."""
|
||||
return [r for r in rows if r["p"]["b"] < -200.0]
|
||||
|
||||
|
||||
def why_of(c, name):
|
||||
card = first((b for b in c.battery_cards if b["name"] == name),
|
||||
f"a card for {name}")
|
||||
return card["why_key"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. end to end, night, modes on, static day/night, no feed, no arbitrage")
|
||||
c1, r1, started1 = drive(opts())
|
||||
_last = nth(r1, -1, "the last cycle")
|
||||
_first1 = nth(r1, 0, "the first cycle")
|
||||
print(f" tariff task started by amain's guard: {started1}")
|
||||
print(f" tariff_plan: {None if c1.tariff_plan is None else c1.tariff_plan.words}")
|
||||
print(f" mode_runs: {_last['runs']}")
|
||||
print(f" mode_tick reason / roles: "
|
||||
f"{None if _last['tick'] is None else (_last['tick'].why, dict(_last['tick'].roles))}")
|
||||
print(f" Batteries screen why_key: A {why_of(c1, A)}, B {why_of(c1, B)}")
|
||||
print(f" cycles with B charging: {len(charging_b(r1))} of {len(r1)}, "
|
||||
f"B soc {_first1['soc']['b']:.1f} -> {_last['soc']['b']:.1f}")
|
||||
check("the tariff refresh task is started", started1)
|
||||
check("...and it adds no network call: with price_provider off the feed "
|
||||
"loader is never reached (it is replaced by one that fails)",
|
||||
not FETCHES and not _price.provider_configured(opts()))
|
||||
check("a fresh tariff plan with prices is held", present(_last)
|
||||
and _last["plan"] is not None and _last["plan"].has_prices)
|
||||
check("the price runs exist (mode_runs is not None) and carry a cheap run "
|
||||
"that holds in the night period",
|
||||
present(_last) and _last["runs"] is not None and _last["runs"].on
|
||||
and _last["runs"].cheap is not None
|
||||
and _last["runs"].cheap.holds(_last["now"]))
|
||||
check("the Cheap hours battery charges from the grid in the night period",
|
||||
len(charging_b(r1)) > len(r1) // 2
|
||||
and present(_last, _first1)
|
||||
and _last["soc"]["b"] > _first1["soc"]["b"] + 0.3)
|
||||
check("...and the Leftover solar battery does not (no sun, no forecast)",
|
||||
not any(r["p"]["a"] < -200.0 for r in r1))
|
||||
check("...the reason is not 'no prices': neither battery reads no_prices",
|
||||
why_of(c1, A) != "no_prices" and why_of(c1, B) != "no_prices")
|
||||
check("...only the Cheap hours battery got the grid charge role",
|
||||
present(_last) and _last["tick"] is not None
|
||||
and _last["tick"].roles.get(B) == modetrade.CHARGE
|
||||
and _last["tick"].roles.get(A) != modetrade.CHARGE)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. the same with a gap below the cycle cost")
|
||||
c3, r3, started3 = drive(opts(cost=0.20))
|
||||
_l3 = nth(r3, -1, "the last cycle")
|
||||
print(f" started {started3}; mode_runs {_l3['runs']}; "
|
||||
f"why A {why_of(c3, A)}, B {why_of(c3, B)}")
|
||||
check("the task is started and the plan is there, so mode_runs is not None",
|
||||
started3 and present(_l3) and _l3["plan"] is not None
|
||||
and _l3["runs"] is not None)
|
||||
check("the reason is the gap: no_spread ('Prices differ too little'), "
|
||||
"never no_prices, on both batteries",
|
||||
why_of(c3, A) == "no_spread" and why_of(c3, B) == "no_spread"
|
||||
and present(_l3) and _l3["runs"] is not None
|
||||
and not _l3["runs"].on)
|
||||
check("...and no battery charges from the grid",
|
||||
not charging_b(r3) and not any(r["p"]["a"] < -200.0 for r in r3))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. Home and the Batteries screen agree")
|
||||
hp = c1.home_plan
|
||||
_bars = [b for b in hp["prices"] if b["eur_kwh"] is not None]
|
||||
_words = {b["why_key"]: b["why"] for b in c1.battery_cards}
|
||||
print(f" Home: has_prices {hp['has_prices']}, {len(_bars)} priced bars, "
|
||||
f"verdict {hp['verdict_key']}, lines {hp['lines']}")
|
||||
print(f" Batteries: {[(b['name'], b['why_key'], b['why']) for b in c1.battery_cards]}")
|
||||
check("Home prices the day, and the Batteries screen does not say it has no "
|
||||
"prices", hp["has_prices"] and _bars
|
||||
and all(b["why_key"] != "no_prices" for b in c1.battery_cards))
|
||||
check("Home's plan has the grid charge block for the Cheap hours battery "
|
||||
"(the 'trade' verdict) and no 'no prices have arrived' line",
|
||||
hp["verdict_key"] == "trade"
|
||||
and any(r["kind"] == "grid" for r in hp["runs"])
|
||||
and not any("no prices have arrived" in ln for ln in hp["lines"]))
|
||||
hp3 = c3.home_plan
|
||||
check("with the gap too small both say so: Home 'price gap is too small', "
|
||||
"the batteries 'differ too little'; neither says no prices",
|
||||
any("price gap is too small" in ln for ln in hp3["lines"])
|
||||
and all(b["why_key"] == "no_spread" for b in c3.battery_cards))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. UX-55: every battery Solar first, static day/night, no feed")
|
||||
SF = {"a": (True, "solar_first", False), "b": (True, "solar_first", False)}
|
||||
c5, r5, s5 = drive(opts(), choice=SF, minutes=5)
|
||||
print(f" Home lines: {c5.home_plan['lines']}")
|
||||
check("Home no longer says 'no prices have arrived' with every battery Solar "
|
||||
"first on a static profile",
|
||||
s5 and not any("no prices have arrived" in ln
|
||||
for ln in c5.home_plan["lines"]))
|
||||
c5b, r5b, s5b = drive(opts(profile=None), choice=SF, minutes=5)
|
||||
print(f" no profile, no feed: started {s5b}, Home lines "
|
||||
f"{c5b.home_plan['lines']}")
|
||||
check("...and with no profile and no feed there are truly no prices, and "
|
||||
"Home still says so",
|
||||
any("no prices have arrived" in ln for ln in c5b.home_plan["lines"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n6. nothing else moved")
|
||||
c6, r6, s6 = drive(opts(modes=False), choice={}, minutes=5)
|
||||
check("modes off, arbitrage off, no feed: no task, no plan, no price runs "
|
||||
"(as before)", not s6 and c6.tariff_plan is None
|
||||
and all(r["runs"] is None for r in r6))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Runnable check for TARIFF-24 round 2 (AUDIT-02 F1). `python3 test_tariff24_weather.py`
|
||||
|
||||
With battery modes on, the price gap a mode needs was always widened x1.5
|
||||
(`Uncertainty.NO_WEATHER`) even with a solar forecast read. Now the doubt is
|
||||
added only when no usable forecast is read (`arbitrage.weather_uncertainty()`),
|
||||
in the run plan (`_price_runs()`), the charge gate (`_mode_roles()`) and Home's
|
||||
mirror (`homeview.trade_verdict()`). Modes off is untouched.
|
||||
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
|
||||
1. The rule itself, and that modes off keeps the doubt.
|
||||
2. Day/night 0.34/0.26 (gap 0.08), modes on, real Controller, night period:
|
||||
with a forecast at cycle cost 0.07 the cheap run is planned and the Cheap
|
||||
hours battery charges; without one, or with a stale one, the same cost needs
|
||||
0.105 and the reason is `not_worth` / "Prices differ too little".
|
||||
3. A dynamic day with a 0.12 gap at cost 0.10: on with a forecast, not worth
|
||||
without.
|
||||
4. The forecast coming (the gate follows in the same cycle) and going mid-run:
|
||||
a run under way keeps its gap to its end, the next run needs the wider one;
|
||||
a forecast unavailable for one or two reads mid-run does not stop or
|
||||
restart the charge.
|
||||
5. Home agrees with control in each case (Batteries Why, Home line, and
|
||||
`trade_verdict()`, Home's mirror of the gate).
|
||||
6. The hold's edges (TARIFF-24 review R4, R5): a forecast lost while only the
|
||||
day's give run is under way holds to that run's end and no further, so the
|
||||
night's cheap run needs the wider gap; and a give run that starts during a
|
||||
hold runs to its end (`keep_started()`), with no grid charge after the hold.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): the doubt always
|
||||
added (the old code) fails parts 2 to 5; never added fails the without-forecast
|
||||
rows; Home's mirror left on the old rule fails part 5; `_wx_track()` also
|
||||
recording without a forecast (its `solar_fc is None` return dropped) fails part 6.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import arbitrage, homeview, modetrade # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
Uncertainty, brussels)
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.26},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.34}]}')
|
||||
L = timezone(timedelta(hours=1))
|
||||
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
FC_TODAY, FC_TOMORROW = "sensor.fc_today", "sensor.fc_tomorrow"
|
||||
TARIFF_REFRESH_S = m.TARIFF_REFRESH_S
|
||||
|
||||
|
||||
def opts(cost, *, arb=False, sensors=True):
|
||||
o = dict(rp.options(True, arb, 2))
|
||||
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE)
|
||||
if sensors:
|
||||
o.update(pv_forecast_entity=FC_TODAY,
|
||||
pv_forecast_tomorrow_entity=FC_TOMORROW)
|
||||
return o
|
||||
|
||||
|
||||
def dynamic_points(start):
|
||||
"""A dynamic day-ahead shape, 0.16 .. 0.28 (gap 0.12), 15-minute points."""
|
||||
out, t = [], start.replace(minute=0, second=0, microsecond=0)
|
||||
for _ in range(48 * 4):
|
||||
h = brussels(t).hour + brussels(t).minute / 60
|
||||
p = 0.22 + 0.06 * math.cos((h - 19) / 24 * 2 * math.pi)
|
||||
out.append(PricePoint(start=brussels(t), eur_per_kwh=round(p, 4),
|
||||
provisional=False))
|
||||
t += timedelta(minutes=15)
|
||||
return out
|
||||
|
||||
|
||||
def sensor(kwh, at):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": "kWh"},
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
def forecast_mode(mode, t, now):
|
||||
"""What the two forecast sensors read: 'fresh', 'stale', 'gone', or a
|
||||
function of the simulated seconds that returns one of those."""
|
||||
if callable(mode):
|
||||
mode = mode(t)
|
||||
if mode == "gone":
|
||||
return {"state": "unavailable", "attributes": {}}
|
||||
at = now if mode == "fresh" else now - timedelta(hours=5)
|
||||
return at
|
||||
|
||||
|
||||
def take(c, now):
|
||||
"""What Home and the Batteries screen say now, and Home's mirror of the
|
||||
gate (it reads the trade switch, so ask it as a site that has it on next
|
||||
to the modes)."""
|
||||
keep = c.o
|
||||
c.o = dict(keep, arbitrage_enabled=True)
|
||||
try:
|
||||
mirror, why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now)
|
||||
finally:
|
||||
c.o = keep
|
||||
return dict(home=homeview.build_plan(c, now, False, True),
|
||||
cards=homeview.battery_rows(c, False, True),
|
||||
mirror=mirror, mirror_why=why)
|
||||
|
||||
|
||||
def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
house_w=300.0, soc0=None, snap_at=(), t0=NIGHT):
|
||||
"""-> (controller, rows). `points`: a feed-shaped plan injected every
|
||||
cycle (no task); else the static profile via the real tariff task. `t0`:
|
||||
the first cycle's instant."""
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in CHOICE.items():
|
||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||
("pack_sell", sell)):
|
||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
||||
rows = []
|
||||
snaps = {}
|
||||
|
||||
class H(rp.Hass):
|
||||
async def state(self, e):
|
||||
now = t0 + timedelta(seconds=self.t)
|
||||
if fc is None:
|
||||
return None
|
||||
at = forecast_mode(fc, self.t, now)
|
||||
if isinstance(at, dict):
|
||||
return at
|
||||
return sensor(0.0 if e == FC_TODAY else 12.0, at)
|
||||
|
||||
async def go():
|
||||
hass = H(("a", "b"), lambda t: house_w, soc0 or {"a": 40.0, "b": 40.0})
|
||||
tmp = tempfile.mkdtemp(prefix="t24w-")
|
||||
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
clock = [t0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
pts = None if points is None else points
|
||||
task = None
|
||||
if pts is None:
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
try:
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = t0 + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if pts is not None:
|
||||
c.tariff_plan = TariffPlan(
|
||||
PriceRead.series(pts, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", now), None,
|
||||
"provider")
|
||||
c.tariff_at = now
|
||||
elif t == 0.0 or t % TARIFF_REFRESH_S == 0.0:
|
||||
# The task's own 30 min wait is wall clock, so the
|
||||
# simulated clock restarts it: one refresh per period.
|
||||
if task is not None and t > 0.0:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None and (
|
||||
c.tariff_at is not None and c.tariff_at >= now):
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(tick, 10.0)
|
||||
rp_ = c.mode_runs
|
||||
rows.append(dict(
|
||||
t=t, now=now, fc_none=c.solar_fc is None,
|
||||
runs_on=None if rp_ is None else rp_.on,
|
||||
reason=None if rp_ is None else rp_.reason,
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
free_to=c._wx_free_to, held=c.weather_held(now),
|
||||
why=None if c.mode_tick is None else c.mode_tick.why,
|
||||
charging=hass.plants["b"].p < -200.0,
|
||||
soc_b=hass.plants["b"].soc,
|
||||
cheap_end=None if rp_ is None or rp_.cheap is None
|
||||
else rp_.cheap.end))
|
||||
if t in snap_at:
|
||||
snaps[t] = take(c, now)
|
||||
t += tick
|
||||
last = take(c, clock[0])
|
||||
c.home_plan, c.cards = last["home"], last["cards"]
|
||||
c.mirror, c.mirror_why = last["mirror"], last["mirror_why"]
|
||||
c.snaps = snaps
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
return c
|
||||
|
||||
return asyncio.run(go()), rows
|
||||
|
||||
|
||||
def why_of(c, name):
|
||||
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
|
||||
return card["why_key"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the rule")
|
||||
NW = frozenset({Uncertainty.NO_WEATHER})
|
||||
check("modes on with a forecast read: no doubt", arbitrage.weather_uncertainty(
|
||||
True, object()) == frozenset())
|
||||
check("modes on with no forecast (None): the doubt", arbitrage.weather_uncertainty(
|
||||
True, None) == NW)
|
||||
check("modes off, forecast or not: the doubt, as before",
|
||||
arbitrage.weather_uncertainty(False, object()) == NW
|
||||
and arbitrage.weather_uncertainty(False, None) == NW)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. day/night 0.34 / 0.26 (gap 0.08), night period")
|
||||
c1, r1 = drive(opts(0.07), fc="fresh")
|
||||
l1 = nth(r1, -1, "last cycle")
|
||||
print(f" forecast read, cost 0.07: mode_runs on={l1['runs_on']} "
|
||||
f"reason={l1['reason']}; B charging {sum(r['charging'] for r in r1)} of "
|
||||
f"{len(r1)}; Why A {why_of(c1, A)} B {why_of(c1, B)}")
|
||||
check("with a forecast at cost 0.07 the cheap run is planned (needed 0.07 < "
|
||||
"0.08) and the Cheap hours battery charges in the night period",
|
||||
present(l1) and not l1["fc_none"] and l1["runs_on"] is True
|
||||
and sum(r["charging"] for r in r1) > len(r1) // 2
|
||||
and l1["roles"].get(B) == modetrade.CHARGE)
|
||||
for label, mode in (("no forecast sensor reads", "gone"),
|
||||
("a stale forecast", "stale")):
|
||||
c, rr = drive(opts(0.07), fc=mode)
|
||||
ll = nth(rr, -1, "last cycle")
|
||||
print(f" {label}, cost 0.07: on={ll['runs_on']} reason={ll['reason']}; "
|
||||
f"B charging {sum(r['charging'] for r in rr)}; Why A "
|
||||
f"{why_of(c, A)} B {why_of(c, B)}")
|
||||
check(f"{label}: the same cost needs 0.105 > 0.08, so not_worth and "
|
||||
f"'Prices differ too little', no grid charge",
|
||||
present(ll) and ll["fc_none"] and ll["runs_on"] is False
|
||||
and ll["reason"] == "not_worth"
|
||||
and why_of(c, A) == "no_spread" and why_of(c, B) == "no_spread"
|
||||
and not any(r["charging"] for r in rr))
|
||||
c0, r0 = drive(opts(0.07, sensors=False), fc=None)
|
||||
check("no forecast sensor configured at all: not_worth too (as 0.19.0)",
|
||||
present(nth(r0, -1)) and r0[-1]["runs_on"] is False)
|
||||
c2, r2 = drive(opts(0.10), fc="fresh")
|
||||
check("at the shipped cost 0.10 even a forecast does not make 0.08 enough "
|
||||
"(needs 0.10): still not_worth, honestly",
|
||||
present(nth(r2, -1)) and r2[-1]["runs_on"] is False
|
||||
and r2[-1]["reason"] == "not_worth")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. a dynamic day, gap 0.12, cost 0.10")
|
||||
pts = dynamic_points(NIGHT - timedelta(hours=1))
|
||||
cd, rd = drive(opts(0.10), fc="fresh", points=pts)
|
||||
cn, rn = drive(opts(0.10), fc="gone", points=pts)
|
||||
_ld, _ln = nth(rd, -1, "last cycle"), nth(rn, -1, "last cycle")
|
||||
print(f" with forecast: on={_ld['runs_on']} reason={_ld['reason']}; "
|
||||
f"without: on={_ln['runs_on']} reason={_ln['reason']}")
|
||||
check("with a forecast the run plan is on (needs 0.10 < 0.12)",
|
||||
present(nth(rd, -1)) and rd[-1]["runs_on"] is True
|
||||
and why_of(cd, A) != "no_spread")
|
||||
check("without one it is not_worth (needs 0.15 > 0.12) and says so",
|
||||
present(nth(rn, -1)) and rn[-1]["runs_on"] is False
|
||||
and rn[-1]["reason"] == "not_worth"
|
||||
and why_of(cn, B) == "no_spread")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. the forecast going and coming mid-run (a run under way keeps its gap)")
|
||||
CUT = 600.0 # 10 min in
|
||||
SOC = {"a": 40.0, "b": 60.0} # B's cheap run is then about 2 h
|
||||
|
||||
|
||||
def going(t):
|
||||
return "fresh" if t < CUT else "gone"
|
||||
|
||||
|
||||
def coming(t):
|
||||
return "gone" if t < CUT else "fresh"
|
||||
|
||||
|
||||
def flap(n_reads):
|
||||
"""Unavailable for `n_reads` forecast reads (60 s apart) from CUT."""
|
||||
return lambda t: "gone" if CUT <= t < CUT + 60.0 * n_reads else "fresh"
|
||||
|
||||
|
||||
MID, AFTER = 3000.0, 7800.0
|
||||
cg, rg = drive(opts(0.07), fc=going, minutes=135, soc0=SOC,
|
||||
snap_at=(MID, AFTER))
|
||||
cc, rc = drive(opts(0.07), fc=coming, minutes=25)
|
||||
_end = nth([r["cheap_end"] for r in rg if r["cheap_end"] is not None], 0,
|
||||
"the cheap run's end")
|
||||
_end_t = (_end - NIGHT).total_seconds() if present(_end) else 1e9
|
||||
lost = [r for r in rg if r["fc_none"]]
|
||||
print(f" going: cheap run ends at t={_end_t:.0f} s; forecast lost from "
|
||||
f"t={lost[0]['t'] if lost else None}")
|
||||
check("going: the run planned with the forecast ends before the snapshot "
|
||||
"taken after it (the scenario is long enough)",
|
||||
present(_end) and CUT + 300.0 < _end_t < AFTER - 300.0 and bool(lost)
|
||||
and lost[0]["t"] - CUT <= 60.0)
|
||||
check("going: the forecast read is lost mid-run and the run under way KEEPS "
|
||||
"its gap: the plan stays on and B keeps charging, cycle after cycle, "
|
||||
"until B is full or the run ends",
|
||||
all(r["runs_on"] is True and (r["roles"].get(B) == modetrade.CHARGE
|
||||
or r["soc_b"] >= 94.0)
|
||||
for r in rg if r["fc_none"] and r["t"] < _end_t - 60.0)
|
||||
and all(r["charging"] or r["soc_b"] >= 94.0 for r in rg
|
||||
if 60.0 <= r["t"] < _end_t - 60.0)
|
||||
and max(r["soc_b"] for r in rg if r["fc_none"]) >= 90.0)
|
||||
check("going: from the run's end the wider gap is back: the next run is "
|
||||
"not_worth (needs 0.105 at 0.07), no new run, and no grid charge",
|
||||
all(r["runs_on"] is False and r["reason"] == "not_worth"
|
||||
for r in rg if r["t"] >= _end_t + 30.0)
|
||||
and not any(r["charging"] for r in rg if r["t"] >= _end_t + 60.0))
|
||||
_sm, _sa = cg.snaps.get(MID), cg.snaps.get(AFTER)
|
||||
check("going, Home agrees mid-run: its mirror still allows, the Batteries "
|
||||
"screen says it charges from the grid, and Home does not say the gap "
|
||||
"is too small",
|
||||
_sm is not None and _sm["mirror"] is not None
|
||||
and first((b for b in _sm["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "grid_charge"
|
||||
and not any("price gap is too small" in ln
|
||||
for ln in _sm["home"]["lines"]))
|
||||
check("going, Home agrees after the run: its mirror refuses, the Batteries "
|
||||
"screen says 'differ too little' and Home says the gap is too small",
|
||||
_sa is not None and _sa["mirror"] is None
|
||||
and first((b for b in _sa["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "no_spread"
|
||||
and any("price gap is too small" in ln for ln in _sa["home"]["lines"]))
|
||||
for n in (1, 2):
|
||||
cf, rf = drive(opts(0.07), fc=flap(n), minutes=30, soc0=SOC,
|
||||
snap_at=(CUT + 30.0,))
|
||||
gone = [r for r in rf if r["fc_none"]]
|
||||
print(f" flap, {n} read(s) lost: {len(gone)} cycles without a "
|
||||
f"forecast; B charging in {sum(r['charging'] for r in rf)} of "
|
||||
f"{len(rf)}")
|
||||
check(f"flap: the forecast is unavailable for {n} read(s) mid-run: the "
|
||||
f"plan stays on and B charges without a break (no stop, no "
|
||||
f"restart)",
|
||||
bool(gone) and all(r["runs_on"] is True for r in rf)
|
||||
and all(r["charging"] and r["roles"].get(B) == modetrade.CHARGE
|
||||
for r in rf if r["t"] >= 60.0))
|
||||
sf = cf.snaps.get(CUT + 30.0)
|
||||
check(f"flap, {n} read(s): Home agrees while the read is lost (mirror "
|
||||
f"allows, Batteries 'charges from the grid')",
|
||||
sf is not None and sf["mirror"] is not None
|
||||
and first((b for b in sf["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "grid_charge")
|
||||
|
||||
|
||||
def flips(rows):
|
||||
"""Cycles where the forecast read changed, and whether the plan and the
|
||||
charge gate changed in that very cycle."""
|
||||
out = []
|
||||
for i in range(1, len(rows)):
|
||||
if rows[i]["fc_none"] != rows[i - 1]["fc_none"]:
|
||||
out.append((i, rows[i]["t"], rows[i]["runs_on"],
|
||||
rows[i]["charging"], rows[i - 1]["charging"]))
|
||||
return out
|
||||
|
||||
|
||||
fcm = flips(rc)
|
||||
print(f" coming: forecast read arrives at cycle/t {fcm}")
|
||||
check("coming (no run under way before): not_worth until the read is back, "
|
||||
"then on in the same cycle, and the battery charges",
|
||||
len(fcm) == 1 and fcm[0][1] - CUT <= 60.0 and fcm[0][2] is True
|
||||
and all(r["runs_on"] is False for r in rc if r["fc_none"])
|
||||
and all(r["runs_on"] is True for r in rc if not r["fc_none"])
|
||||
and any(r["charging"] for r in rc if r["t"] >= fcm[0][1] + 40.0))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. Home agrees with control")
|
||||
CASES = (("forecast, cost 0.07", c1, r1, True),
|
||||
("no forecast, cost 0.07", *drive(opts(0.07), fc="gone"), False),
|
||||
("dynamic, forecast", cd, rd, True),
|
||||
("dynamic, no forecast", cn, rn, False))
|
||||
for label, cx, rx, on in CASES:
|
||||
last = nth(rx, -1, "last cycle")
|
||||
hp = cx.home_plan
|
||||
mirror_ok = cx.mirror is not None
|
||||
print(f" {label}: control on={last['runs_on']}; Home mirror "
|
||||
f"{'allows' if mirror_ok else 'refuses'} ({cx.mirror_why}); "
|
||||
f"lines {hp['lines'][:1]}; Why B {why_of(cx, B)}")
|
||||
check(f"{label}: Home's mirror of the gate allows exactly when control "
|
||||
f"plans", present(last) and mirror_ok is on
|
||||
and last["runs_on"] is on)
|
||||
check(f"{label}: the Batteries screen and the Home line say the gap is "
|
||||
f"too small exactly when control does",
|
||||
(why_of(cx, B) == "no_spread") is (not on)
|
||||
and any("price gap is too small" in ln for ln in hp["lines"])
|
||||
is (not on))
|
||||
if label.startswith("dynamic"):
|
||||
continue # its cheap run is tomorrow morning: not on today's chart
|
||||
check(f"{label}: Home draws a grid charge block exactly when control "
|
||||
f"charges", any(r["kind"] == "grid" for r in hp["runs"]) is on)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n6. the hold's edges (review probes R4, R5)")
|
||||
T_R4 = datetime(2026, 10, 30, 21, 40, tzinfo=L).astimezone(timezone.utc)
|
||||
TEN = datetime(2026, 10, 30, 22, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
c4, r4 = drive(opts(0.07), fc=lambda t: "fresh" if t < 300.0 else "gone",
|
||||
minutes=60, t0=T_R4)
|
||||
_held4 = [r for r in r4 if r["held"]]
|
||||
_l4 = nth(r4, -1, "last cycle")
|
||||
print(f" R4: forecast lost 21:45; held {len(_held4)} cycles, to "
|
||||
f"{_l4['free_to']}; after 22:00 on={ {r['runs_on'] for r in r4 if r['now'] >= TEN} } "
|
||||
f"charging {sum(r['charging'] for r in r4)}")
|
||||
check("R4, forecast lost before the next run: while only the day's give run "
|
||||
"was under way the hold is that run's end, 22:00, and stays there",
|
||||
present(_l4) and all(r["free_to"] == TEN for r in r4
|
||||
if r["t"] >= 5.0)
|
||||
and bool(_held4) and all(r["now"] < TEN for r in _held4))
|
||||
check("R4: from 22:00 the night's cheap run needs the wider gap: "
|
||||
"not_worth, and no grid charge at any time",
|
||||
all(r["runs_on"] is False and r["reason"] == "not_worth"
|
||||
for r in r4 if r["now"] >= TEN)
|
||||
and not any(r["charging"] for r in r4))
|
||||
|
||||
|
||||
def r5_points(t0):
|
||||
"""04:00-07:00 at 0.20, 07:00-10:00 at 0.30, else 0.25 (local)."""
|
||||
out, t = [], t0.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||
for _ in range(4 * 30):
|
||||
h = brussels(t).hour
|
||||
p = 0.20 if 4 <= h < 7 else 0.30 if 7 <= h < 10 else 0.25
|
||||
out.append(PricePoint(start=brussels(t), eur_per_kwh=p,
|
||||
provisional=False))
|
||||
t += timedelta(minutes=15)
|
||||
return out
|
||||
|
||||
|
||||
T_R5 = datetime(2026, 10, 30, 5, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
GIVE_END = datetime(2026, 10, 30, 10, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
c5, r5 = drive(opts(0.07), fc=lambda t: "fresh" if t < 3600.0 else "gone",
|
||||
points=r5_points(T_R5), minutes=320, t0=T_R5,
|
||||
soc0={"a": 40.0, "b": 30.0})
|
||||
_ft5 = nth([r["free_to"] for r in r5 if r["t"] == 3600.0], 0, "the hold")
|
||||
_in = [r for r in r5 if present(_ft5) and _ft5 <= r["now"] < GIVE_END]
|
||||
_out = [r for r in r5 if r["now"] >= GIVE_END + timedelta(seconds=30)]
|
||||
print(f" R5: forecast lost 06:00, hold to {_ft5}; after it, until 10:00, "
|
||||
f"on in {sum(r['runs_on'] is True for r in _in)} of {len(_in)}, B "
|
||||
f"{sorted({str(r['roles'].get(B)) for r in _in})}; after 10:00 "
|
||||
f"{ {r['reason'] for r in _out} }")
|
||||
check("R5: the hold is the cheap run's end, before the give run's end",
|
||||
present(_ft5) and T_R5 < _ft5 < GIVE_END)
|
||||
check("R5, a run that has started runs to its end: the give run that began "
|
||||
"during the hold stays on after it, B gives, and nothing charges from "
|
||||
"the grid",
|
||||
len(_in) > 100 and all(r["runs_on"] is True
|
||||
and r["roles"].get(B) == modetrade.GIVE
|
||||
and not r["charging"] for r in _in))
|
||||
check("R5: when the give run ends the wider gap decides: not_worth, no grid "
|
||||
"charge",
|
||||
bool(_out) and all(r["runs_on"] is False and r["reason"] == "not_worth"
|
||||
and not r["charging"] for r in _out))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,583 @@
|
||||
"""UX-46: the Dutch pages show Dutch status rows, notes and wizard lines.
|
||||
`python3 -B test_ux46.py`
|
||||
|
||||
Before this, Home's status rows, the settings page's notes and the setup
|
||||
wizard's checks were built in English and sent as they were, so the NL pages
|
||||
showed English under Dutch titles (SAFETY-118 I-1, UX-44 I-2, UX-45 I-3).
|
||||
|
||||
1. Rows: every row in `messages.status_samples()` carries a Dutch twin
|
||||
(`messages.Bi`), the Dutch follows STYLE.md, and the Home page's status
|
||||
payload gives it for lang=nl and the English text for everything else.
|
||||
2. Notes: every `_advise()` call in app/validate.py gives a Dutch twin, the
|
||||
notes the validator really builds carry it, and /config/validate sends the
|
||||
page's language, for the settings notes and for the wizard's check list.
|
||||
3. The setting labels the Dutch notes quote are the labels in nl.yaml, and the
|
||||
peak forecast label has no "(W)", since any power unit is read.
|
||||
4. The rules above catch spoiled input.
|
||||
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import ClientSession, web as aioweb
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import translations_lint as TL # noqa: E402
|
||||
import ux13_demo as D # noqa: E402
|
||||
from style_rules import sentences, text_problems, words # noqa: E402
|
||||
from app import messages, uimodel as U, uitext, web # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from named_absence import field, nth, present # noqa: E402
|
||||
from app.validate import (Note, note_text, setup_checks, # noqa: E402
|
||||
config_notes)
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# STYLE.md for a Dutch row or note: the same length and shape rules, plus the
|
||||
# Dutch words that test_ui_text.py bans for the same things. A quoted setting
|
||||
# label may keep its own words ("Setpoint-entiteit" is the label in nl.yaml).
|
||||
JARGON_NL = re.compile(
|
||||
r"\b(?:setpoints?|setpunt\w*|SoC|laadtoestand|BMS|PV|failsafe|regelaar|"
|
||||
r"regellus|controller|latch|ontlad\w*|payload|endpoint|JSON|API|"
|
||||
r"time-?out|boolean|config|kwartuur\w*|commando\w*|geparkeerd|ESP32|"
|
||||
r"master)\b", re.I)
|
||||
# English function words that never belong in a Dutch sentence.
|
||||
ENGLISH = re.compile(r"\b(?:the|not|and|for|with|from|reads|check|so|no|set|"
|
||||
r"keeps|stays|has|have|ran|ago|seconds?|minutes?)\b", re.I)
|
||||
QUOTED = re.compile(r"“[^”]*”")
|
||||
|
||||
|
||||
def dutch_problems(text):
|
||||
bare = re.sub(r"\b[a-z_]+\.[a-z0-9_]+\b", "X", QUOTED.sub("X", text))
|
||||
out = []
|
||||
if JARGON_NL.search(bare):
|
||||
out.append("jargon: " + JARGON_NL.search(bare).group(0))
|
||||
names = re.sub(r"\binverter-[a-z0-9]+\b|\b[a-z_]+\.[a-z0-9_]+\b", "X",
|
||||
bare)
|
||||
eng = ENGLISH.search(names)
|
||||
if eng:
|
||||
out.append("English word: " + eng.group(0))
|
||||
return out
|
||||
|
||||
|
||||
def row_problems_nl(colour, text):
|
||||
n_s, n_w = sentences(text), len(words(text))
|
||||
out = []
|
||||
if colour == messages.RED:
|
||||
if n_s > 3 or n_w > 30:
|
||||
out.append(f"red row over 3 sentences / 30 words ({n_s}/{n_w})")
|
||||
elif n_s > 1 or n_w > 15:
|
||||
out.append(f"row over 1 sentence / 15 words ({n_s}/{n_w})")
|
||||
if ";" in text:
|
||||
out.append("semicolon")
|
||||
return out + text_problems(text, keys=False, entities=False) \
|
||||
+ dutch_problems(text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. every status row has a Dutch twin, and the page picks one")
|
||||
# --------------------------------------------------------------------------- #
|
||||
ROWS = [(fn, c, t) for fn, c, t in messages.status_samples() if c is not None]
|
||||
check(f"there are rows to grade ({len(ROWS)})", len(ROWS) > 80)
|
||||
_none = [(fn, str(t)) for fn, c, t in ROWS
|
||||
if not (isinstance(t, messages.Bi) and t.nl)]
|
||||
check(f"every row has a Dutch text ({_none})", not _none)
|
||||
_same = [(fn, str(t)) for fn, c, t in ROWS
|
||||
if isinstance(t, messages.Bi) and t.nl == str(t)]
|
||||
check(f"no Dutch row is the English one ({_same})", not _same)
|
||||
_bad = [(fn, p) for fn, c, t in ROWS if isinstance(t, messages.Bi) and t.nl
|
||||
for p in row_problems_nl(c, t.nl)]
|
||||
for x in _bad:
|
||||
print(f" STYLE {x}")
|
||||
check(f"every Dutch row follows STYLE.md ({len(_bad)} problems)", not _bad)
|
||||
BANNERS = [(fn, t) for fn, c, t in messages.status_samples() if c is None]
|
||||
_nob = [(fn, str(t)) for fn, t in BANNERS
|
||||
if not (isinstance(t, messages.Bi) and t.nl and t.nl != str(t))]
|
||||
check(f"every banner has a Dutch text ({_nob})", BANNERS and not _nob)
|
||||
_bb = [(fn, t.nl) for fn, t in BANNERS if isinstance(t, messages.Bi) and t.nl
|
||||
and (len(words(t.nl)) > 12 or ";" in t.nl or dutch_problems(t.nl)
|
||||
or text_problems(t.nl, keys=False, entities=False))]
|
||||
check(f"every Dutch banner is at most 12 words and follows STYLE.md ({_bb})",
|
||||
not _bb)
|
||||
_cat = {k for k, v in vars(messages).items()
|
||||
if callable(v) and k.startswith("row_")}
|
||||
check("the samples cover every row_ function, so none can skip the check",
|
||||
_cat <= {fn for fn, _c, _t in ROWS})
|
||||
_lat = {"row_direction_trip": "herstart heft dit niet op",
|
||||
"row_site_direction_trip": "regeling starten heft dit op",
|
||||
"row_circulating_trip": "regeling starten heft dit op",
|
||||
"row_not_delivering": "stoppen en starten heft dit op"}
|
||||
_unclear = [fn for fn, _c, t in ROWS if fn in _lat and _lat[fn] not in t.nl]
|
||||
check(f"a Dutch latch row says how it clears too ({_unclear})", not _unclear)
|
||||
check("pick() gives the English for en, the Dutch for nl, and leaves other "
|
||||
"text alone",
|
||||
messages.pick(messages.row_estop_fitted(), "en") == "Emergency stop: fitted"
|
||||
and type(messages.pick(messages.row_estop_fitted(), "en")) is str
|
||||
and messages.pick(messages.row_estop_fitted(), "nl") == "Noodstop: geplaatst"
|
||||
and messages.pick("Plain", "nl") == "Plain"
|
||||
and messages.pick(messages.Bi("A"), "nl") == "A")
|
||||
check("a Bi survives copy and pickle, and JSON",
|
||||
copy.deepcopy(messages.row_estop_fitted()).nl == "Noodstop: geplaatst"
|
||||
and json.dumps(messages.row_estop_fitted()) == '"Emergency stop: fitted"')
|
||||
|
||||
|
||||
# --- the page: a real Controller -------------------------------------------- #
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return None if v is None else datetime.fromisoformat(v)
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, _v):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": False, "peak_forecast_entity": "sensor.fc"}
|
||||
TWO = dict(ONE, inverters=[
|
||||
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500},
|
||||
{"name": "Battery B", "setpoint_entity": "", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500}])
|
||||
|
||||
|
||||
def make(opts, soc=(62.0, 35.0), batt=(-2500.0, -300.0)):
|
||||
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for i, inv in enumerate(c.inverters):
|
||||
inv.soc, inv.batt = soc[i], batt[i]
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.grid = 10.0
|
||||
return c
|
||||
|
||||
|
||||
def attention(payload, rid):
|
||||
return next((a for a in payload["attention"] if a["id"] == rid), None)
|
||||
|
||||
|
||||
c = make(ONE, soc=(62.0,), batt=(960.0,))
|
||||
c._peak_fc_fault = "kWh"
|
||||
s_en, s_nl = c.status_payload(), c.status_payload(lang="nl")
|
||||
def fc_row(payload):
|
||||
return nth([r for r in payload["checks"] if r.get("id") == "peak_forecast"])
|
||||
|
||||
|
||||
_en, _nl = fc_row(s_en), fc_row(s_nl)
|
||||
check("Home, English: the peak forecast row is the English sentence",
|
||||
present(_en) and field(_en, "text") == messages.peak_forecast_unit("kWh")
|
||||
and attention(s_en, "peak_forecast")["body"]
|
||||
== messages.peak_forecast_unit("kWh"))
|
||||
check("Home, Dutch: the row, the Needs-attention entry and the title are "
|
||||
"Dutch",
|
||||
present(_nl) and field(_nl, "text") == messages.peak_forecast_unit("kWh", "nl")
|
||||
and attention(s_nl, "peak_forecast")["body"]
|
||||
== messages.peak_forecast_unit("kWh", "nl")
|
||||
and attention(s_nl, "peak_forecast")["title"]
|
||||
== uitext.words("nl")["home_words"]["attn"]["peak_forecast"])
|
||||
c._peak_fc_fault = ""
|
||||
_nl0 = fc_row(c.status_payload(lang="nl"))
|
||||
check("...and with no unit at all, the Dutch is the 'no unit' sentence",
|
||||
present(_nl0) and field(_nl0, "text") == messages.peak_forecast_unit("", "nl")
|
||||
and messages.peak_forecast_unit("", "nl")
|
||||
!= messages.peak_forecast_unit("kWh", "nl"))
|
||||
check("a language that is not Dutch gets the English, and the default call "
|
||||
"is unchanged",
|
||||
c.status_payload(lang="fr")["checks"] == c.status_payload()["checks"]
|
||||
and all(type(r["text"]) is str for r in c.status_payload()["checks"]))
|
||||
|
||||
# A site with faults of many kinds: every row it shows is Dutch on the NL page.
|
||||
SCEN = {}
|
||||
SCEN["two, one without a setpoint, meter stale"] = make(TWO)
|
||||
SCEN["one, nothing read"] = make(ONE, soc=(None,), batt=(None,))
|
||||
SCEN["one, no forecast set"] = make(dict(ONE, peak_forecast_entity=""))
|
||||
SCEN["one, shadow, estop fitted"] = make(dict(ONE, shadow_mode="on",
|
||||
estop_fitted=True))
|
||||
SCEN["one, solar sources"] = make(dict(ONE, pv_entity="sensor.pv"))
|
||||
SCEN["two, solar list"] = make(dict(TWO, pv_entities=["sensor.a", "sensor.b"]))
|
||||
SCEN["one, peak forecast unit fault"] = c
|
||||
SCEN["one, control stopped"] = make(ONE)
|
||||
SCEN["one, control stopped"].auto = False
|
||||
_English, _rows_seen = [], 0
|
||||
for name, ctl in SCEN.items():
|
||||
raw = ctl.checks()
|
||||
en = ctl.status_payload()["checks"]
|
||||
nl = ctl.status_payload(lang="nl")["checks"]
|
||||
check(f"[{name}] {len(raw)} rows: same rows in both languages",
|
||||
len(raw) == len(en) == len(nl))
|
||||
for r, e, n in zip(raw, en, nl):
|
||||
_rows_seen += 1
|
||||
if not (isinstance(r["text"], messages.Bi) and r["text"].nl):
|
||||
_English.append((name, r.get("id"), str(r["text"])))
|
||||
elif e["text"] != str(r["text"]) or n["text"] != r["text"].nl:
|
||||
_English.append((name, r.get("id"), "page did not pick"))
|
||||
_banners = {n: (ctl.status_payload()["banner"],
|
||||
ctl.status_payload(lang="nl")["banner"])
|
||||
for n, ctl in SCEN.items()}
|
||||
print(f" banners: {_banners}")
|
||||
check("the banner at the top of Home is Dutch on the Dutch page, English on "
|
||||
"the English one", all(e != n and not dutch_problems(n)
|
||||
for e, n in _banners.values()))
|
||||
check(f"{_rows_seen} rows over {len(SCEN)} sites: none shows English on the "
|
||||
f"Dutch page when it has no Dutch text ({_English})", not _English)
|
||||
check("the row's details list in /status is picked the same way",
|
||||
all(isinstance(v, str) and not isinstance(v, messages.Bi)
|
||||
for ctl in SCEN.values()
|
||||
for _k, v in ctl.status_payload(lang="nl")["rows"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. every settings note and wizard line has a Dutch twin")
|
||||
# --------------------------------------------------------------------------- #
|
||||
tree = ast.parse((HERE / "app" / "validate.py").read_text(encoding="utf-8"))
|
||||
_calls = [n for n in ast.walk(tree) if isinstance(n, ast.Call)
|
||||
and ast.unparse(n.func) == "_advise"]
|
||||
_bare = [n.lineno for n in _calls if not any(k.arg == "nl" for k in n.keywords)]
|
||||
check(f"every _advise() call in validate.py gives `nl` ({len(_calls)} calls, "
|
||||
f"without: {_bare})", len(_calls) >= 30 and not _bare)
|
||||
|
||||
OPTS = dict(D.defaults(D.CONFIG), setpoint_entity="number.sp",
|
||||
meter_entity="sensor.grid", soc_entity="sensor.soc",
|
||||
batt_entity="sensor.batt", pv_entity="sensor.pv",
|
||||
peak_forecast_entity="sensor.fc")
|
||||
|
||||
|
||||
def ent(state, unit=None):
|
||||
return {"state": state, "unit": unit}
|
||||
|
||||
|
||||
GOOD = {"number.sp": ent("0"), "sensor.grid": ent("402", "W"),
|
||||
"sensor.soc": ent("62", "%"), "sensor.batt": ent("-800", "W"),
|
||||
"sensor.pv": ent("-1200", "W"), "sensor.fc": ent("820", "kWh")}
|
||||
NOTES = []
|
||||
NOTES += setup_checks(OPTS, entities=GOOD, running=None)
|
||||
NOTES += setup_checks(OPTS, entities=None)
|
||||
NOTES += setup_checks(dict(OPTS, meter_invert=True, pv_invert_entities=["sensor.pv"]),
|
||||
entities=dict(GOOD, **{"sensor.batt": ent("0", "W")}))
|
||||
NOTES += setup_checks(dict(OPTS, soc_entity="sensor.x", batt_entity="sensor.y",
|
||||
setpoint_entity="number.typo"),
|
||||
entities=GOOD)
|
||||
NOTES += setup_checks(dict(OPTS, meter_source="homewizard_local"),
|
||||
entities=GOOD, running=None)
|
||||
NOTES += setup_checks(dict(OPTS, meter_source="homewizard_local",
|
||||
price_provider="generic_dayahead"),
|
||||
entities=GOOD, running=dict(OPTS, meter_source="homewizard_local",
|
||||
price_provider="generic_dayahead"),
|
||||
meter_now_w=-350.0, prices_known=18)
|
||||
NOTES += setup_checks(dict(OPTS, price_provider="generic_dayahead"), entities=GOOD,
|
||||
running=None)
|
||||
NOTES += setup_checks(dict(OPTS, price_provider="off", static_profile=""),
|
||||
entities=GOOD)
|
||||
NOTES += setup_checks(dict(OPTS, price_provider="off",
|
||||
static_profile='{"periods":[]}'), entities=GOOD)
|
||||
NOTES += setup_checks(dict(OPTS, batt_entity="sensor.batt2"),
|
||||
entities=dict(GOOD, **{"sensor.batt2": ent("unavailable")}))
|
||||
NOTES += setup_checks(dict(OPTS, soc_entity="sensor.soc2"),
|
||||
entities=dict(GOOD, **{"sensor.soc2": ent("unavailable")}))
|
||||
NOTES += setup_checks(dict(OPTS, meter_entity="sensor.m2"),
|
||||
entities=dict(GOOD, **{"sensor.m2": ent("unavailable")}))
|
||||
_TWO = dict(OPTS, inverters=[
|
||||
{"name": "a", "setpoint_entity": "number.sp", "soc_entity": "sensor.soc",
|
||||
"batt_entity": "sensor.batt", "max_w": 2000, "bms_soc_floor": 10},
|
||||
{"name": "b", "setpoint_entity": "number.b", "max_w": 2000,
|
||||
"bms_soc_floor": 10}])
|
||||
NOTES += setup_checks(_TWO, entities=dict(GOOD, **{"sensor.batt": ent("-0.5", "kW")}))
|
||||
NOTES += setup_checks(dict(OPTS, meter_source="ha_signed",
|
||||
p1_net_entity="sensor.net"),
|
||||
entities=dict(GOOD, **{"sensor.net": ent("-3.1", "kW")}))
|
||||
NOTES += config_notes(dict(OPTS, integrator_max_w=9000, max_w=2000,
|
||||
peak_cap_w=2000), entities=GOOD, month_peak_w=3000)
|
||||
NOTES += config_notes(dict(OPTS, pv_forecast_entity="sensor.f1",
|
||||
pv_forecast_tomorrow_entity="sensor.f2",
|
||||
peak_forecast_entity="sensor.fc2"),
|
||||
entities=dict(GOOD, **{"sensor.f1": ent("5", "W"),
|
||||
"sensor.f2": ent("6", "kWh"),
|
||||
"sensor.fc2": ent("5")}))
|
||||
NOTES += config_notes(dict(OPTS, meter_entity="sensor.gone"), entities=GOOD)
|
||||
NOTES += config_notes(dict(OPTS, pv_forecast_tomorrow_entity="sensor.f2"),
|
||||
entities=dict(GOOD, **{"sensor.f2": ent("6", "kWh")}))
|
||||
NOTES += config_notes(dict(OPTS, batt_entity="sensor.kw"),
|
||||
entities=dict(GOOD, **{"sensor.kw": ent("1", "kW")}))
|
||||
print(f" {len(NOTES)} notes built")
|
||||
_nonl = [n.text for n in NOTES if not n.nl]
|
||||
check(f"every note the validator builds has a Dutch twin ({_nonl})",
|
||||
NOTES and not _nonl)
|
||||
_same = [n.text for n in NOTES if n.nl == n.text]
|
||||
check(f"...which is not the English ({_same})", not _same)
|
||||
_bad = []
|
||||
for n in NOTES:
|
||||
nw, ns = len(words(n.nl)), sentences(n.nl)
|
||||
p = text_problems(n.nl, keys=True, entities=True) + dutch_problems(n.nl)
|
||||
if nw > 25 and not n.text.startswith(("The 15-minute", "Home Assistant")):
|
||||
p.append(f"over 25 words ({nw})")
|
||||
if ns > 2:
|
||||
p.append(f"over 2 sentences ({ns})")
|
||||
if ";" in n.nl:
|
||||
p.append("semicolon")
|
||||
if len(words(n.nl)) > len(words(n.text)) + 6:
|
||||
p.append("much longer than the English")
|
||||
_bad += [(n.nl, x) for x in p]
|
||||
for x in _bad:
|
||||
print(f" STYLE {x}")
|
||||
check(f"every Dutch note follows STYLE.md ({len(_bad)} problems)", not _bad)
|
||||
_l = {t: [n for n in NOTES if n.text.startswith(t)] for t in (
|
||||
"Prices for today are in", "The 15-minute demand forecast")}
|
||||
check("the wizard's price line, and the peak forecast unit note, are among "
|
||||
"them", all(_l.values()))
|
||||
check("note_text() picks: Dutch for nl, English for en, the old twin table "
|
||||
"for a note with no `nl`",
|
||||
note_text(_l["Prices for today are in"][0], "nl")
|
||||
== "De prijzen van vandaag zijn binnen, voor 18 van 24 uur."
|
||||
and note_text(_l["Prices for today are in"][0], "en")
|
||||
== "Prices for today are in, for 18 of 24 hours."
|
||||
and note_text(Note("info", (), messages.FIXED_TARIFF_NOTE), "nl")
|
||||
== messages.FIXED_TARIFF_NOTE_NL
|
||||
and note_text(Note("info", (), "Other"), "nl") == "Other")
|
||||
|
||||
# --- over HTTP: the settings page and the wizard, in both languages --------- #
|
||||
SCHEMA = D.schema_ui(D.CONFIG)
|
||||
TRANSLATIONS = {l: TL.parse_translation_file(HERE / "translations" / f"{l}.yaml")
|
||||
for l in ("en", "nl")}
|
||||
STATES = [{"entity_id": e, "state": v["state"],
|
||||
"attributes": ({"unit_of_measurement": v["unit"]} if v["unit"]
|
||||
else {})} for e, v in GOOD.items()]
|
||||
|
||||
|
||||
class Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def states(self):
|
||||
return copy.deepcopy(STATES)
|
||||
|
||||
async def state(self, entity):
|
||||
return next((copy.deepcopy(s) for s in STATES
|
||||
if s["entity_id"] == entity), None)
|
||||
|
||||
|
||||
class Supervisor:
|
||||
def __init__(self, options):
|
||||
self.options = copy.deepcopy(options)
|
||||
|
||||
async def info(self):
|
||||
return {"options": copy.deepcopy(self.options), "schema": SCHEMA,
|
||||
"translations": TRANSLATIONS}
|
||||
|
||||
async def write_options(self, options):
|
||||
pass
|
||||
|
||||
async def restart(self, confirm):
|
||||
pass
|
||||
|
||||
|
||||
class Ctl:
|
||||
def __init__(self, opts):
|
||||
self.o, self.hass, self.inverters = dict(opts), Hass(), []
|
||||
self.store, self.auto, self.grid = None, True, 402.0
|
||||
|
||||
def log_event(self, text, hint=None):
|
||||
pass
|
||||
|
||||
def status_payload(self, lang="en"):
|
||||
return {"banner": "", "level": "ok", "rows": [], "checks": [],
|
||||
"plan": {"prices": [{"label": f"{h:02d}:00", "eur_kwh": 0.2}
|
||||
for h in range(20)]
|
||||
+ [{"label": f"{h:02d}:00", "eur_kwh": None}
|
||||
for h in range(20, 24)]}}
|
||||
|
||||
|
||||
async def serve(opts, steps):
|
||||
runner = aioweb.AppRunner(web.build_app(Ctl(opts), Supervisor(opts)))
|
||||
await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/"
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
return await steps(s, root)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
async def post(s, url, body):
|
||||
async with s.post(url, json=body) as r:
|
||||
return await r.json()
|
||||
|
||||
|
||||
RUN = dict(OPTS, meter_source="homewizard_local", p1_host="192.0.2.50",
|
||||
price_provider="generic_dayahead")
|
||||
|
||||
|
||||
async def pages(s, root):
|
||||
out = {}
|
||||
# The settings notes read the entity list the picker has cached, so the
|
||||
# first call only warms it.
|
||||
await post(s, root + "config/validate",
|
||||
{"model": U.options_to_ui(RUN), "setup": True})
|
||||
for lang in ("en", "nl"):
|
||||
async with s.get(root + f"config/model?lang={lang}") as r:
|
||||
m = await r.json()
|
||||
model = U.options_to_ui(RUN)
|
||||
out[lang] = await post(s, root + "config/validate",
|
||||
{"model": model, "setup": True, "lang": lang})
|
||||
out["none"] = await post(s, root + "config/validate", {
|
||||
"model": U.options_to_ui(RUN), "setup": True})
|
||||
return out
|
||||
|
||||
|
||||
R = asyncio.run(serve(RUN, pages))
|
||||
_texts = {l: [m["text"] for m in R[l].get("messages", [])] for l in ("en", "nl")}
|
||||
_setup = {l: [m["text"] for m in R[l].get("setup", [])] for l in ("en", "nl")}
|
||||
FC_EN = messages.peak_forecast_unit("kWh") + " Pick a sensor in W or kW."
|
||||
FC_NL = messages.peak_forecast_unit("kWh", "nl") + " Kies een sensor in W of kW."
|
||||
print(f" settings notes en: {_texts['en']}")
|
||||
print(f" settings notes nl: {_texts['nl']}")
|
||||
print(f" wizard lines nl: {_setup['nl']}")
|
||||
check("settings page, English: the peak forecast unit note is English",
|
||||
FC_EN in _texts["en"])
|
||||
check("settings page, Dutch: it is the Dutch sentence, and no English "
|
||||
"sentence of it is left", FC_NL in _texts["nl"]
|
||||
and FC_EN not in _texts["nl"])
|
||||
check("wizard, English: the price line and the checks are English",
|
||||
"Prices for today are in, for 20 of 24 hours." in _setup["en"])
|
||||
check("wizard, Dutch: the price line is Dutch",
|
||||
"De prijzen van vandaag zijn binnen, voor 20 van 24 uur."
|
||||
in _setup["nl"] and "Prices for today are in, for 20 of 24 hours."
|
||||
not in _setup["nl"])
|
||||
_left = [t for t in _setup["nl"] + _texts["nl"]
|
||||
if ENGLISH.search(QUOTED.sub("X", t))
|
||||
and not t.startswith("Home Assistant has no entity")]
|
||||
check(f"no wizard line or settings note on the Dutch page has English words "
|
||||
f"left ({_left})", not _left)
|
||||
check("a request with no language gets English (an old page keeps working)",
|
||||
[m["text"] for m in R["none"].get("setup", [])]
|
||||
== [m["text"] for m in R["en"].get("setup", [])])
|
||||
check("the raw `notes` list keeps the English `text` and adds `nl`",
|
||||
R["nl"]["notes"] and all(n["text"] != n["nl"] for n in R["nl"]["notes"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. labels")
|
||||
# --------------------------------------------------------------------------- #
|
||||
for lang in ("en", "nl"):
|
||||
name = TRANSLATIONS[lang]["configuration"]["peak_forecast_entity"]["name"]
|
||||
check(f"[{lang}] the peak forecast label has no unit: {name!r}",
|
||||
"(W)" not in name and "(kW)" not in name
|
||||
and name.split("·")[-1].strip() == (
|
||||
"15-minute demand forecast entity" if lang == "en"
|
||||
else "Entiteit kwartierprognose"))
|
||||
check("DOCS.md shows the same label, so the table cannot drift",
|
||||
"| Limits | 15-minute demand forecast entity | `peak_forecast_entity` |"
|
||||
in (HERE / "DOCS.md").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def stems(key):
|
||||
tr = TRANSLATIONS["nl"]["configuration"]
|
||||
out = []
|
||||
for c in (tr, tr["inverters"]["fields"]):
|
||||
if isinstance(c.get(key), dict):
|
||||
n = c[key]["name"].split("·")[-1].strip()
|
||||
while re.search(r"\s*\([^()]*\)$", n):
|
||||
n = re.sub(r"\s*\([^()]*\)$", "", n)
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
|
||||
_drift = sorted(k for k, v in messages.LABELS_NL.items() if v not in stems(k))
|
||||
check(f"every Dutch label a note quotes is the setting's name in nl.yaml "
|
||||
f"(drifted: {_drift})", not _drift
|
||||
and set(messages.LABELS_NL) == set(messages.LABELS))
|
||||
_unit = sorted(k for k, v in messages.UNIT_LABELS_NL.items() if v not in stems(k))
|
||||
check(f"...and so are the two the inverter list names differently ({_unit})",
|
||||
not _unit)
|
||||
check("setting() in Dutch keeps the key, so the config panel can find it",
|
||||
messages.setting("max_w", "", "nl") == "“Vermogen van de omvormer” (max_w)"
|
||||
and messages.setting("max_w", "inverter-b", "nl")
|
||||
== "“Vermogen van deze omvormer” voor inverter-b (inverters.max_w)"
|
||||
and messages.setting("max_w") == "“Inverter rating” (max_w)")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. the rules catch spoiled input")
|
||||
# --------------------------------------------------------------------------- #
|
||||
check("a Dutch row left in English is caught",
|
||||
row_problems_nl(messages.AMBER, "Control: last ran 2 seconds ago"))
|
||||
check("an over-long Dutch row is caught",
|
||||
row_problems_nl(messages.AMBER, " ".join(["woord"] * 16) + "."))
|
||||
check("Dutch jargon is caught", dutch_problems("Het commando is geweigerd"))
|
||||
check("a row with no Dutch is caught",
|
||||
not isinstance(messages.pick("x", "nl"), messages.Bi)
|
||||
and not getattr(messages.Bi("x"), "nl"))
|
||||
check("the untranslated-call scan sees a bare _advise",
|
||||
any(isinstance(n, ast.Call) and not any(k.arg == "nl" for k in n.keywords)
|
||||
for n in ast.walk(ast.parse('_advise(notes, "warn", (), "x")'))))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -0,0 +1,589 @@
|
||||
"""Runnable check for UX-53. `python3 test_ux53.py`
|
||||
|
||||
Home draws a grid charge only where the add-on will take it.
|
||||
|
||||
1. Modes on (F-1): inside a cheap run that is under way, the trade gate
|
||||
(`TariffPlan.arbitrage()`, as `Controller._mode_roles()` asks it) refuses
|
||||
with `not_worth`. The control cycle files that as `Controller.mode_gate`,
|
||||
and Home draws no charge, and marks no cheap quarter, from then on. With
|
||||
the gate open the charge is drawn as before. The real `_mode_roles()` runs
|
||||
at each instant, so the page is graded against the decision it makes.
|
||||
2. Modes off (F-2): the verdict's charge comes after its last dear period.
|
||||
Home asks the same gate at the charge's own start (`homeview.leg_taken()`):
|
||||
a flat remainder is refused there, so no charge is drawn and no quarter is
|
||||
marked cheap. A charge the gate allows at its start is drawn as before, and
|
||||
so is the sale.
|
||||
3. Wording, English and Dutch: "lowest price left", and "a fully fixed
|
||||
product" only for a real fixed product.
|
||||
4. Day/night and price-off pages are the same as before.
|
||||
|
||||
Mutations (MUTATION.md, purged copies): listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
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"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from app import arbitrage, homeview, modetrade, packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.peakguard import PeakDecision # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
Uncertainty, brussels)
|
||||
from app.static_tariff import TariffPlan, static_plan # 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)
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return None if v is None else datetime.fromisoformat(v)
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, _v):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": False, "arbitrage_enabled": True,
|
||||
"cycle_cost_threshold_eur": 0.10,
|
||||
"inverters": [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.a",
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
||||
"max_w": 2500, "capacity_kwh": 2.0},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.b",
|
||||
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
|
||||
"max_w": 2500, "capacity_kwh": 2.0}]}
|
||||
|
||||
# Tuesday 27 October 2026 in Brussels (UTC+1 since 25 October).
|
||||
DAY0 = datetime(2026, 10, 26, 23, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def at(h, m=0, s=0):
|
||||
return DAY0 + timedelta(hours=h, minutes=m, seconds=s)
|
||||
|
||||
|
||||
def quarters(price_of, days=2, product=PriceProduct.DAY_AHEAD):
|
||||
"""48 hours of 15-minute prices; `price_of(local_hour_float)` -> EUR/kWh."""
|
||||
pts = []
|
||||
for i in range(96 * days):
|
||||
t = DAY0 + timedelta(minutes=15 * i)
|
||||
pts.append(PricePoint(brussels(t), price_of((i % 96) / 4.0), False))
|
||||
return TariffPlan(PriceRead.series(tuple(pts), product,
|
||||
"generic_dayahead", DAY0), None,
|
||||
"provider")
|
||||
|
||||
|
||||
def make(opts, plan, now, *, modes=None, soc=(50.0, 50.0)):
|
||||
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for i, inv in enumerate(c.inverters):
|
||||
inv.soc, inv.batt, inv.last_write = soc[i], 0.0, 0.0
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.grid = 500.0
|
||||
c.tariff_plan, c.tariff_at = plan, now
|
||||
c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0,
|
||||
ceiling_from="cap", projected_w=800.0)
|
||||
c.arb_headroom_w = 3000.0
|
||||
if modes:
|
||||
for inv in c.inverters:
|
||||
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes
|
||||
return c
|
||||
|
||||
|
||||
def build(c, now):
|
||||
return homeview.build(c, level="ok", checks=[], now=now)["plan"]
|
||||
|
||||
|
||||
def kinds_at(plan_json, label):
|
||||
return [b["kind"] for b in plan_json["prices"] if b["label"] == label]
|
||||
|
||||
|
||||
def grid_blocks(plan_json):
|
||||
return [r for r in plan_json["runs"] if r["kind"] == "grid"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. modes on: the gate refuses inside a cheap run (F-1)")
|
||||
# All-in prices: 0.10 everywhere, 20:00 0.01, 20:15 0.09, 21:00-22:00 0.22. The
|
||||
# cheap run is 20:00-20:30. At 20:00 the spread is 0.21. From 20:15 the quarters
|
||||
# ahead are 0.09 to 0.22: 0.13 does not clear the 0.15 this cycle needs (0.10
|
||||
# widened by 1.5 for the missing weather forecast).
|
||||
def f1_price(h):
|
||||
if h == 20.0:
|
||||
return 0.01
|
||||
if h == 20.25:
|
||||
return 0.09
|
||||
if 21.0 <= h < 22.0:
|
||||
return 0.22
|
||||
return 0.10
|
||||
|
||||
|
||||
F1_PLAN = quarters(f1_price, days=1)
|
||||
c1 = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(19, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
|
||||
|
||||
def f1(now):
|
||||
"""One real control tick of `_mode_roles()` at `now`, then Home."""
|
||||
c1.tariff_at = now
|
||||
tick = c1._mode_roles(now, None)
|
||||
return tick, build(c1, now)
|
||||
|
||||
|
||||
t_pre, p_pre = f1(at(19, 0, 5)) # builds the run plan
|
||||
t_open, p_open = f1(at(20, 0, 5))
|
||||
print(f" 20:00:05 roles {t_open.roles}, action {t_open.action}, "
|
||||
f"gate {c1.mode_gate}, runs {c1.mode_runs.cheap}")
|
||||
check("gate open (20:00): the control cycle charges, mode_gate is None, and "
|
||||
"Home draws the charge on both batteries, 20:00 to 20:30",
|
||||
t_open.action == modetrade.CHARGE and c1.mode_gate is None
|
||||
and {r["battery"] for r in grid_blocks(p_open)} == {0, 1}
|
||||
and all(r["from"] == "20:00" and r["to"] == "20:30"
|
||||
for r in grid_blocks(p_open)))
|
||||
check("...and marks the run's two quarters cheap on the chart",
|
||||
kinds_at(p_open, "20:00") == ["cheap"]
|
||||
and kinds_at(p_open, "20:15") == ["cheap"])
|
||||
t_mid, p_mid = f1(at(20, 7, 30))
|
||||
check("gate still open at 20:07: the charge goes on and is still drawn",
|
||||
t_mid.action == modetrade.CHARGE and c1.mode_gate is None
|
||||
and len(grid_blocks(p_mid)) == 2)
|
||||
c1.inverters[0].soc = c1.inverters[1].soc = 52.0
|
||||
t_ref, p_ref = f1(at(20, 17, 52))
|
||||
print(f" 20:17:52 roles {t_ref.roles}, action {t_ref.action}, why "
|
||||
f"{t_ref.why}, gate {c1.mode_gate}, run {c1.mode_runs.cheap}")
|
||||
check("the run is still kept (20:00-20:30) and the gate refuses with "
|
||||
"not_worth at 20:17: no charge, why not_worth",
|
||||
c1.mode_runs.cheap.holds(at(20, 17, 52)) and c1.mode_gate == "not_worth"
|
||||
and t_ref.action is None and t_ref.why == "not_worth"
|
||||
and all(r != modetrade.CHARGE for r in t_ref.roles.values()))
|
||||
check("Home draws no charge block for the refused part, on any battery",
|
||||
grid_blocks(p_ref) == [] and not any(
|
||||
r["what"].startswith("Charges") for r in p_ref["runs"]))
|
||||
check("...marks no quarter from 20:15 cheap, keeps the 20:00 quarter it "
|
||||
"did charge in, and keeps the give run marked dear",
|
||||
kinds_at(p_ref, "20:00") == ["cheap"]
|
||||
and kinds_at(p_ref, "20:15") == ["mid"]
|
||||
and kinds_at(p_ref, "20:30") == ["mid"]
|
||||
and kinds_at(p_ref, "21:00") == ["dear"])
|
||||
saved = c1.mode_gate
|
||||
c1.mode_gate = None # what Home did before UX-53
|
||||
p_old = build(c1, at(20, 17, 52))
|
||||
c1.mode_gate = saved
|
||||
check("the same moment without the gate record draws the charge again (the "
|
||||
"defect, shown): so the fixture is the F-1 case",
|
||||
len(grid_blocks(p_old)) == 2 and kinds_at(p_old, "20:15") == ["cheap"])
|
||||
row = homeview.battery_rows(c1, False, True)[0]
|
||||
check("the battery card says what Home now says: it rests (role hold), and "
|
||||
"does not say it charges",
|
||||
t_ref.roles["inverter-a"] == modetrade.HOLD
|
||||
and row["why_key"] in ("saves", "saves_later"))
|
||||
t_n, p_n = f1(at(20, 31, 0))
|
||||
check("after the run: nothing is drawn, as before",
|
||||
grid_blocks(p_n) == [] and c1.mode_gate == "not_worth")
|
||||
|
||||
# Other refusals of the gate are followed the same way: no charge is drawn
|
||||
# while the control cycle's gate says no.
|
||||
c1b = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
c1b.tariff_at = at(20, 0, 5)
|
||||
c1b.peak_guard = PeakDecision(engaged=True, floor_w=1000.0, ceiling_w=3500.0,
|
||||
ceiling_from="cap", projected_w=3600.0)
|
||||
tb = c1b._mode_roles(at(20, 0, 5), None)
|
||||
check("the peak guard holding refuses the charge (gate 'peak'): the cycle "
|
||||
"does not charge and Home draws no charge",
|
||||
c1b.mode_gate == "peak" and tb.action is None
|
||||
and grid_blocks(build(c1b, at(20, 0, 5))) == [])
|
||||
c1c = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
c1c.peak_guard = None
|
||||
tc = c1c._mode_roles(at(20, 0, 5), None)
|
||||
check("a peak guard that measures nothing refuses it too (gate "
|
||||
"'not_covered'): no charge, none drawn",
|
||||
c1c.mode_gate == "not_covered" and tc.action is None
|
||||
and grid_blocks(build(c1c, at(20, 0, 5))) == [])
|
||||
c1d = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
c1d.arb_headroom_w = None
|
||||
td = c1d._mode_roles(at(20, 0, 5), None)
|
||||
check("a refusal that is not the gate (no measured headroom yet) is not the "
|
||||
"gate's: mode_gate stays None and the charge is still drawn",
|
||||
c1d.mode_gate is None and td.why == "headroom"
|
||||
and len(grid_blocks(build(c1d, at(20, 0, 5)))) == 2)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. modes off: a charge after the last dear period (F-2)")
|
||||
NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
||||
|
||||
|
||||
def decide_at(c, now, soc=50.0):
|
||||
"""What the control cycle does at `now` on the plan (modes off)."""
|
||||
return arbitrage.decide(
|
||||
c.tariff_plan, now, peak_guard_active=False, charge_headroom_w=5000.0,
|
||||
soc=soc, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty=NO_WEATHER, charge_w=2500.0, discharge_w=2500.0,
|
||||
soc_floor_pct=10.0, soc_ceiling_pct=90.0)
|
||||
|
||||
|
||||
OFF = dict(BASE)
|
||||
|
||||
|
||||
def f2_price(h):
|
||||
return 0.54 if 21.5 <= h < 21.75 else 0.24
|
||||
|
||||
|
||||
F2_PLAN = quarters(f2_price, days=1)
|
||||
c2 = make(OFF, F2_PLAN, at(21, 36, 0))
|
||||
p2 = build(c2, at(21, 36, 0))
|
||||
g2 = [r for r in p2["runs"] if r["kind"] == "give"]
|
||||
v2, _w = homeview.trade_verdict(c2, F2_PLAN, at(21, 36, 0))
|
||||
print(f" 21:36 verdict: cheapest {homeview.hhmm(v2.cheapest.start)}, "
|
||||
f"dearest {homeview.hhmm(v2.dearest.start)}")
|
||||
check("the fixture: at 21:36 the verdict's cheapest quarter (21:45) comes "
|
||||
"after its dearest (21:30, in progress), and the rest of the day is flat",
|
||||
homeview.hhmm(v2.cheapest.start) == "21:45"
|
||||
and homeview.hhmm(v2.dearest.start) == "21:30")
|
||||
check("the add-on sells in 21:30, and at 21:45 it does nothing: idle, the "
|
||||
"tariff is flat",
|
||||
decide_at(c2, at(21, 30, 5)).action == arbitrage.DISCHARGE
|
||||
and decide_at(c2, at(21, 45, 5)).action is None
|
||||
and "the tariff is flat" in decide_at(c2, at(21, 45, 5)).reason)
|
||||
check("Home draws the sale in progress and NO charge at 21:45",
|
||||
len(g2) == 2 and all(r["from"] == "21:30" for r in g2)
|
||||
and grid_blocks(p2) == [])
|
||||
check("...and marks 21:30 dear and 21:45 not cheap",
|
||||
kinds_at(p2, "21:30") == ["dear"] and kinds_at(p2, "21:45") == ["mid"])
|
||||
real = homeview.leg_taken
|
||||
homeview.leg_taken = lambda *a, **k: True # Home before UX-53
|
||||
p2_old = build(c2, at(21, 36, 0))
|
||||
homeview.leg_taken = real
|
||||
check("without the question to the gate the page draws the 21:45 charge "
|
||||
"(the defect, shown): so the fixture is the F-2 case",
|
||||
[r["from"] for r in grid_blocks(p2_old)] == ["21:45", "21:45"]
|
||||
and kinds_at(p2_old, "21:45") == ["cheap"])
|
||||
|
||||
# A charge the gate allows at its own start is still drawn, with the sale.
|
||||
def q_price(h):
|
||||
return {20.0: 0.10, 20.5: 0.12, 21.0: 0.59, 21.5: 0.54}.get(h, 0.24)
|
||||
|
||||
|
||||
Q_PLAN = quarters(q_price, days=1)
|
||||
c3 = make(OFF, Q_PLAN, at(20, 20, 0))
|
||||
p3 = build(c3, at(20, 20, 0))
|
||||
v3, _w = homeview.trade_verdict(c3, Q_PLAN, at(20, 20, 0))
|
||||
check("gate allows (20:20, charge 20:30, sale 21:00): both are drawn, and "
|
||||
"the add-on does charge at 20:30 and sell at 21:00",
|
||||
[r["from"] for r in grid_blocks(p3)] == ["20:30", "20:30"]
|
||||
and [r["from"] for r in p3["runs"] if r["kind"] == "give"] == ["21:00"] * 2
|
||||
and decide_at(c3, at(20, 30, 5)).action == arbitrage.CHARGE
|
||||
and decide_at(c3, at(21, 0, 5)).action == arbitrage.DISCHARGE
|
||||
and kinds_at(p3, "20:30") == ["cheap"] and kinds_at(p3, "21:00") == ["dear"])
|
||||
check("...and the charge in progress at 20:30 is drawn too",
|
||||
len(grid_blocks(build(c3, at(20, 35, 0)))) == 2)
|
||||
|
||||
# Every probe on a day-ahead day: the charge is drawn exactly where the add-on
|
||||
# will take it. "Takes it" is the control cycle's own `decide()` at the start
|
||||
# of the charge's period, on the same plan.
|
||||
def sweep(plan, opts, lo, hi, step=15):
|
||||
rows = []
|
||||
for m in range(lo, hi, step):
|
||||
now = at(0, 0, 0) + timedelta(minutes=m, seconds=7)
|
||||
c = make(opts, plan, now)
|
||||
v, _w = homeview.trade_verdict(c, plan, now)
|
||||
if v is None:
|
||||
rows.append((now, None, None, None))
|
||||
continue
|
||||
legs = homeview.trade_legs(c, plan, v, now)
|
||||
drawn = {k: homeview.hhmm(pt.start) for k, pt in legs}
|
||||
rows.append((now, v, drawn, [k for k, _pt in legs]))
|
||||
return rows
|
||||
|
||||
|
||||
def takes(c, plan, kind, pt, now):
|
||||
"""True when decide() acts in that leg's first moment (or now, when it
|
||||
is already under way)."""
|
||||
t = max(now, pt.start + timedelta(seconds=5))
|
||||
d = decide_at(c, t)
|
||||
return d.action == (arbitrage.CHARGE if kind == "grid"
|
||||
else arbitrage.DISCHARGE)
|
||||
|
||||
|
||||
bad, nlegs, nsuppressed = [], 0, 0
|
||||
for now, v, drawn, kinds in sweep(F2_PLAN, OFF, 0, 24 * 60):
|
||||
if v is None:
|
||||
continue
|
||||
c = make(OFF, F2_PLAN, now)
|
||||
for kind, pt in (("grid", v.cheapest), ("give", v.dearest)):
|
||||
nlegs += 1
|
||||
shown = kind in kinds
|
||||
if not shown:
|
||||
nsuppressed += 1
|
||||
if shown != takes(c, F2_PLAN, kind, pt, now):
|
||||
bad.append((homeview.hhmm(now), kind, shown))
|
||||
print(f" {nlegs} verdict legs over a day, {nsuppressed} not drawn, "
|
||||
f"{len(bad)} where Home and decide() disagree {bad[:3]}")
|
||||
check("over a whole day of 15-minute probes, a leg is drawn exactly when "
|
||||
"decide() acts on it at its start; and some are not drawn",
|
||||
not bad and nsuppressed > 0)
|
||||
|
||||
# The same, on 40 random days of 15-minute prices (rounded to a cent, so there
|
||||
# are ties), today only, every quarter probed: Home and decide() agree on every
|
||||
# leg, and the page does drop some.
|
||||
import random # noqa: E402
|
||||
rnd = random.Random(53)
|
||||
n_leg = n_drop = 0
|
||||
disagree = []
|
||||
for day in range(40):
|
||||
shape = [round(rnd.choice((0.10, 0.18, 0.24, 0.24, 0.30, 0.45, 0.60))
|
||||
+ rnd.randrange(0, 3) * 0.01, 2) for _ in range(96)]
|
||||
if day % 2:
|
||||
shape = [shape[i // 4 * 4] for i in range(96)] # hour-constant
|
||||
plan = quarters(lambda h, sh=shape: sh[int(h * 4)], days=1)
|
||||
c = make(OFF, plan, at(0, 0, 0))
|
||||
for q in range(96):
|
||||
now = at(0, 0, 0) + timedelta(minutes=15 * q, seconds=7)
|
||||
v, _w = homeview.trade_verdict(c, plan, now)
|
||||
if v is None:
|
||||
continue
|
||||
kinds = [k for k, _pt in homeview.trade_legs(c, plan, v, now)]
|
||||
for kind, pt in (("grid", v.cheapest), ("give", v.dearest)):
|
||||
n_leg += 1
|
||||
shown = kind in kinds
|
||||
n_drop += not shown
|
||||
if shown != takes(c, plan, kind, pt, now):
|
||||
disagree.append((day, homeview.hhmm(now), kind, shown))
|
||||
print(f" 40 random days: {n_leg} verdict legs, {n_drop} not drawn, "
|
||||
f"{len(disagree)} disagreements with decide() {disagree[:3]}")
|
||||
check("40 random 15-minute days, every quarter: a leg is drawn exactly "
|
||||
"when decide() acts on it at its start",
|
||||
not disagree and n_leg > 3000 and n_drop > 0)
|
||||
|
||||
# A charge tomorrow: announced only when the add-on will take it. Today's
|
||||
# dear period is 21:00 (0.50, then 0.30). Tomorrow opens with a dear hour
|
||||
# (0.45) and the cheapest quarter comes right after it, at 01:00 (0.24).
|
||||
def two_days(tomorrow):
|
||||
pts = []
|
||||
for i in range(96 * 2):
|
||||
t = DAY0 + timedelta(minutes=15 * i)
|
||||
h = (i % 96) / 4.0
|
||||
price = (0.50 if h == 21.0 else 0.30) if i < 96 else tomorrow(h)
|
||||
pts.append(PricePoint(brussels(t), price, False))
|
||||
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", DAY0), None,
|
||||
"provider")
|
||||
|
||||
|
||||
def tomorrow_lines(plan, now):
|
||||
c = make(OFF, plan, now)
|
||||
return [x for x in build(c, now)["lines"] if x.startswith("Tomorrow")]
|
||||
|
||||
|
||||
T_FLAT = two_days(lambda h: 0.45 if h < 1.0 else 0.24)
|
||||
T_WITH = two_days(lambda h: 0.45 if h < 1.0 else (0.50 if h == 18.0 else 0.24))
|
||||
l_with = tomorrow_lines(T_WITH, at(21, 5, 0))
|
||||
l_flat = tomorrow_lines(T_FLAT, at(21, 5, 0))
|
||||
print(f" tomorrow lines: with a later dear period {l_with}; without {l_flat}")
|
||||
check("tomorrow's charge (01:00) is announced when a dearer period follows "
|
||||
"it, and not when the rest of tomorrow is flat",
|
||||
len(l_with) == 1 and "01:00" in l_with[0] and l_flat == [])
|
||||
check("...and the add-on charges at 01:00 on the first and does not on the "
|
||||
"second",
|
||||
decide_at(make(OFF, T_WITH, at(21, 5, 0)),
|
||||
at(25, 0, 5)).action == arbitrage.CHARGE
|
||||
and decide_at(make(OFF, T_FLAT, at(21, 5, 0)),
|
||||
at(25, 0, 5)).action is None)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. wording, English and Dutch")
|
||||
c6 = make(OFF, Q_PLAN, at(20, 20, 0))
|
||||
en = homeview.build(c6, level="ok", checks=[], now=at(20, 20, 0),
|
||||
lang="en")["plan"]
|
||||
nl = homeview.build(c6, level="ok", checks=[], now=at(20, 20, 0),
|
||||
lang="nl")["plan"]
|
||||
gw_en = [r["why"] for r in en["runs"] if r["kind"] == "grid"]
|
||||
gw_nl = [r["why"] for r in nl["runs"] if r["kind"] == "grid"]
|
||||
dw_en = [r["why"] for r in en["runs"] if r["kind"] == "give"]
|
||||
dw_nl = [r["why"] for r in nl["runs"] if r["kind"] == "give"]
|
||||
print(f" {gw_en[:1]} {gw_nl[:1]} {dw_en[:1]} {dw_nl[:1]}")
|
||||
check("the charge says 'lowest price left', English and Dutch",
|
||||
gw_en and set(gw_en) == {"Lowest price left, \u20ac0.12 per kWh."}
|
||||
and set(gw_nl) == {"Laagste prijs die nog over is, \u20ac0,12 per kWh."})
|
||||
check("...and the sale 'highest price left'",
|
||||
set(dw_en) == {"Highest price left, \u20ac0.59 per kWh."}
|
||||
and set(dw_nl) == {"Hoogste prijs die nog over is, \u20ac0,59 per kWh."})
|
||||
check("...and no 'published' is left in the run words of either language",
|
||||
all("ublished" not in str(homeview.uitext.words(l)["home_words"]["run"])
|
||||
and "gepubliceerd" not in str(
|
||||
homeview.uitext.words(l)["home_words"]["run"])
|
||||
for l in ("en", "nl")))
|
||||
check("the short words fit the plan-line rule: one sentence of at most 12 "
|
||||
"words",
|
||||
all(len(x.split()) <= 12 for x in gw_en + gw_nl + dw_en + dw_nl))
|
||||
|
||||
NOW6 = at(21, 45, 5)
|
||||
FLAT_DYN = quarters(lambda h: 0.24, days=1)
|
||||
r_dyn = FLAT_DYN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.10,
|
||||
uncertainty=NO_WEATHER, now=NOW6).match(
|
||||
allowed=lambda v: None, refused=lambda v: v.reason)
|
||||
FIXED = {"periods": [{"name": "all", "from": "00:00", "to": "24:00",
|
||||
"days": "all", "eur_per_kwh": 0.30}]}
|
||||
FIXED_PLAN = static_plan({"static_profile": json.dumps(FIXED)}, at(0), at(36),
|
||||
now=at(0))
|
||||
r_fix = FIXED_PLAN.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.10, uncertainty=NO_WEATHER,
|
||||
now=NOW6).match(allowed=lambda v: None,
|
||||
refused=lambda v: v.reason)
|
||||
DN = {"periods": [{"name": "day", "from": "07:00", "to": "22:00",
|
||||
"days": "all", "eur_per_kwh": 0.34},
|
||||
{"name": "night", "from": "22:00", "to": "07:00",
|
||||
"days": "all", "eur_per_kwh": 0.26}]}
|
||||
DN_FLAT = static_plan({"static_profile": json.dumps(DN)}, at(23, 0), at(30),
|
||||
now=at(23, 0)) # 23:00 to 06:00: night only
|
||||
r_dn = DN_FLAT.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.10,
|
||||
uncertainty=NO_WEATHER, now=at(23, 10)).match(
|
||||
allowed=lambda v: None, refused=lambda v: v.reason)
|
||||
print(f" dynamic: {r_dyn}\n day/night: {r_dn}\n fixed: {r_fix}")
|
||||
check("a dynamic site whose remaining quarters are equal is refused as flat, "
|
||||
"and is not called a fully fixed product",
|
||||
r_dyn and r_dyn.startswith("the tariff is flat - every one of the ")
|
||||
and "fully fixed" not in r_dyn and "fixed product" not in r_dyn
|
||||
and r_dyn.endswith("arithmetically impossible"))
|
||||
check("...a day/night site whose remaining hours are all night: the same",
|
||||
r_dn and r_dn.startswith("the tariff is flat")
|
||||
and "fixed" not in r_dn)
|
||||
check("...a site with a real fixed product is still told so",
|
||||
r_fix and r_fix.endswith(
|
||||
"§9.2: a fully fixed product is self-consumption only")
|
||||
and FIXED_PLAN.product is PriceProduct.FIXED
|
||||
and DN_FLAT.product is PriceProduct.STATIC
|
||||
and FLAT_DYN.product is PriceProduct.DAY_AHEAD)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. day/night, hourly and price-off pages")
|
||||
def old_vs_new(plan, opts, hours=24):
|
||||
"""Verdict legs the page drew before (both, when there is a verdict) and
|
||||
the ones it draws now, probed every 30 minutes."""
|
||||
same = diff = none = 0
|
||||
for m in range(0, hours * 60, 30):
|
||||
now = at(0) + timedelta(minutes=m, seconds=7)
|
||||
c = make(opts, plan, now)
|
||||
v, _w = homeview.trade_verdict(c, plan, now)
|
||||
if v is None:
|
||||
none += 1
|
||||
continue
|
||||
if len(homeview.trade_legs(c, plan, v, now)) == 2:
|
||||
same += 1
|
||||
else:
|
||||
diff += 1
|
||||
return same, diff, none
|
||||
|
||||
|
||||
DN_OPTS = dict(OFF, cycle_cost_threshold_eur=0.02,
|
||||
static_profile=json.dumps(DN))
|
||||
DN_48 = static_plan(DN_OPTS, at(0), at(48), now=at(0))
|
||||
s_dn = old_vs_new(DN_48, DN_OPTS)
|
||||
print(f" day/night, 48 h plan, 48 probes: same {s_dn[0]}, changed "
|
||||
f"{s_dn[1]}, no verdict {s_dn[2]}")
|
||||
check("day/night (a 48-hour plan, as the refresh builds it): at every probe "
|
||||
"that has a verdict both legs are drawn, as before; and there are some",
|
||||
s_dn[1] == 0 and s_dn[0] >= 20)
|
||||
HOURLY = quarters(lambda h: [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22,
|
||||
.18, .16, .15, .14, .16, .19, .23, .28, .33, .36,
|
||||
.34, .29, .26, .24][int(h)], days=2)
|
||||
HR_OPTS = dict(OFF, cycle_cost_threshold_eur=0.05)
|
||||
s_hr = old_vs_new(HOURLY, HR_OPTS)
|
||||
print(f" hourly shape on a 48 h plan: same {s_hr[0]}, changed {s_hr[1]}, "
|
||||
f"no verdict {s_hr[2]}")
|
||||
check("an hourly-shaped day-ahead feed with tomorrow known: both legs are "
|
||||
"drawn at every probe with a verdict, as before",
|
||||
s_hr[1] == 0 and s_hr[0] >= 20)
|
||||
s_one = old_vs_new(quarters(lambda h: [.24, .22, .21, .20, .21, .22, .24, .27,
|
||||
.26, .22, .18, .16, .15, .14, .16, .19,
|
||||
.23, .28, .33, .36, .34, .29, .26,
|
||||
.24][int(h)], days=1), HR_OPTS)
|
||||
print(f" the same with today only: same {s_one[0]}, changed {s_one[1]}, "
|
||||
f"no verdict {s_one[2]}")
|
||||
check("...and with today only, the probes that change are the ones where "
|
||||
"the cheapest quarter left is after the last dear one (F-2)",
|
||||
s_one[1] > 0 and s_one[0] > 0)
|
||||
c7 = make(dict(OFF, arbitrage_enabled=False), DN_48, at(12, 0, 0))
|
||||
p7 = build(c7, at(12, 0, 0))
|
||||
check("price trading off (a day/night site, modes off): no charge or sale "
|
||||
"is drawn and no bar is marked, as before",
|
||||
[r for r in p7["runs"] if r["kind"] in ("grid", "give")] == []
|
||||
and {b["kind"] for b in p7["prices"]} == {"mid"})
|
||||
c8 = make(dict(OFF, arbitrage_enabled=False, battery_modes_enabled=True),
|
||||
DN_48, at(12, 0, 0), modes=packmode.SOLAR_FIRST)
|
||||
c8._mode_roles(at(12, 0, 0), None)
|
||||
p8 = build(c8, at(12, 0, 0))
|
||||
check("modes on, every battery Solar first (the owner's setting): no grid "
|
||||
"charge is drawn, and the gate record changes nothing",
|
||||
grid_blocks(p8) == [] and {b["kind"] for b in p8["prices"]} == {"mid"})
|
||||
c9 = make(dict(OFF, battery_modes_enabled=True), DN_48, at(12, 0, 0),
|
||||
modes=packmode.SOLAR_FIRST)
|
||||
check("a controller that never ran a cycle has no gate record, and the "
|
||||
"default is 'no refusal' (None)",
|
||||
getattr(c9, "mode_gate", "missing") is None)
|
||||
|
||||
print(f"fails: {len(fails)}")
|
||||
sys.exit(1 if fails else 0)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
"""UX-54: the Forecast tab on the two clock-change days.
|
||||
`python3 test_ux54_dst.py`
|
||||
|
||||
The tab walks local hours (`dayplan._hour_slices()` in `dayview` and
|
||||
`daycurve`, `SolarToday`'s hour keys). On the spring change a loop that steps
|
||||
on the wall clock never ends (SAFETY-123), and a tab request runs on the
|
||||
event loop control runs on. Graded here, every call under a hard time limit
|
||||
that the add-on's own `except Exception` cannot swallow (`Hang` is a
|
||||
`BaseException`), and a memory limit:
|
||||
|
||||
1. The limit itself: a tab build that never returns is stopped and reported,
|
||||
through `forecast_payload()`'s `except Exception`.
|
||||
2. `Controller.forecast_payload()` at every quarter of 27-29 March 2027 and
|
||||
30 October-1 November 2027, modes on (Leftover solar and Cheap hours,
|
||||
with a forecast and a house profile: the level curve) and modes off, in
|
||||
the no-tzdata zone (`tariff._EUTimezone`) and, where the host has tzdata,
|
||||
in ZoneInfo Europe/Brussels: it returns, `ok`, each day has 23 / 24 / 25
|
||||
hours with the local hour labels of that day, every level inside floor
|
||||
and ceiling, every block inside its day.
|
||||
3. `SolarToday` keeps the two 02:00 hours of the autumn change apart.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import resource
|
||||
import signal
|
||||
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"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import ux54_scenarios as S # noqa: E402
|
||||
import app.main as M # noqa: E402
|
||||
from app import arbitrage, dayplan, dayview, homeview, solarfc # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
from app.daycurve import SolarToday # noqa: E402
|
||||
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
LIMIT_S = 10.0 # one call; a build takes well under a second
|
||||
try: # a loop that appends without end must not take the host
|
||||
resource.setrlimit(resource.RLIMIT_AS, (3 << 29, resource.getrlimit(
|
||||
resource.RLIMIT_AS)[1]))
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class Hang(BaseException):
|
||||
"""A call ran over LIMIT_S. Not an `Exception`: `forecast_payload()`
|
||||
catches every `Exception` and would turn it into `{"ok": False}`."""
|
||||
|
||||
|
||||
def _alarm(*_a):
|
||||
raise Hang()
|
||||
|
||||
|
||||
def limited(fn, *a, **k):
|
||||
"""`fn(*a, **k)`, or `Hang` when it takes more than LIMIT_S seconds or
|
||||
runs out of memory."""
|
||||
signal.signal(signal.SIGALRM, _alarm)
|
||||
signal.setitimer(signal.ITIMER_REAL, LIMIT_S)
|
||||
try:
|
||||
return fn(*a, **k)
|
||||
except MemoryError:
|
||||
raise Hang() from None
|
||||
finally:
|
||||
signal.setitimer(signal.ITIMER_REAL, 0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The clock and the zone
|
||||
# --------------------------------------------------------------------------- #
|
||||
NOW = [datetime(2027, 1, 1, tzinfo=UTC)]
|
||||
|
||||
|
||||
class FakeDT(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return NOW[0].astimezone(tz) if tz else NOW[0].replace(tzinfo=None)
|
||||
|
||||
|
||||
for _mod in (M, homeview, dayview): # what reads the wall clock
|
||||
_mod.datetime = FakeDT
|
||||
|
||||
TZ_MODULES = (tariff, dayplan, homeview, solarfc)
|
||||
|
||||
|
||||
class zone:
|
||||
"""`BILLING_TZ` is `tz` in every module that holds it, in the block."""
|
||||
|
||||
def __init__(self, tz):
|
||||
self.tz = tz
|
||||
|
||||
def __enter__(self):
|
||||
self.was = [m.BILLING_TZ for m in TZ_MODULES]
|
||||
for m in TZ_MODULES:
|
||||
m.BILLING_TZ = self.tz
|
||||
|
||||
def __exit__(self, *_a):
|
||||
for m, tz in zip(TZ_MODULES, self.was):
|
||||
m.BILLING_TZ = tz
|
||||
|
||||
|
||||
ZONES = [("no tzdata (_EUTimezone)", tariff._EUTimezone())]
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
_bru = ZoneInfo("Europe/Brussels")
|
||||
if datetime(2026, 7, 1, tzinfo=UTC).astimezone(_bru).hour == 2:
|
||||
ZONES.append(("ZoneInfo Europe/Brussels", _bru))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if len(ZONES) == 1:
|
||||
print(" no tzdata on this host: ZoneInfo is not graded here")
|
||||
|
||||
# The local days (UTC midnight-to-midnight instants are not used: the days
|
||||
# are known by their dates) and their hour labels.
|
||||
SPRING, AUTUMN = (2027, 3, 28), (2027, 10, 31)
|
||||
WINDOWS = (((2027, 3, 27), SPRING, (2027, 3, 29)),
|
||||
((2027, 10, 30), AUTUMN, (2027, 11, 1)))
|
||||
|
||||
|
||||
def labels_of(ymd):
|
||||
if ymd == SPRING:
|
||||
return ["00:00", "01:00"] + [f"{h:02d}:00" for h in range(3, 24)]
|
||||
if ymd == AUTUMN:
|
||||
return ["00:00", "01:00", "02:00", "02:00"] + [
|
||||
f"{h:02d}:00" for h in range(3, 24)]
|
||||
return [f"{h:02d}:00" for h in range(24)]
|
||||
|
||||
|
||||
def next_ymd(ymd):
|
||||
d = datetime(*ymd) + timedelta(days=1)
|
||||
return (d.year, d.month, d.day)
|
||||
|
||||
|
||||
def day_start_utc(ymd):
|
||||
"""Local midnight of `ymd` in UTC (CET before the change, CEST after it;
|
||||
midnight is never inside a change)."""
|
||||
summer = (2027, 3, 28) < ymd < (2027, 10, 31) or ymd == (2027, 10, 31)
|
||||
if ymd == SPRING:
|
||||
summer = False
|
||||
return datetime(*ymd, tzinfo=UTC) - timedelta(hours=2 if summer else 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the time limit cannot be swallowed")
|
||||
# --------------------------------------------------------------------------- #
|
||||
check("`Hang` is a BaseException and not an Exception",
|
||||
issubclass(Hang, BaseException) and not issubclass(Hang, Exception))
|
||||
c0 = limited(S.make, "off",
|
||||
datetime(2027, 1, 12, 11, 0, 5, tzinfo=UTC))
|
||||
_real_build = dayview.build
|
||||
|
||||
|
||||
def _spin(*_a, **_k):
|
||||
while True:
|
||||
pass
|
||||
|
||||
|
||||
dayview.build = _spin
|
||||
try:
|
||||
NOW[0] = datetime(2027, 1, 12, 11, 0, 5, tzinfo=UTC)
|
||||
got = limited(c0.forecast_payload, "en")
|
||||
except Hang:
|
||||
got = "stopped"
|
||||
finally:
|
||||
dayview.build = _real_build
|
||||
check("a tab build that never returns is stopped by the limit and reported, "
|
||||
f"not turned into a page answer by forecast_payload() ({got!r})",
|
||||
got == "stopped")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the tab at every quarter of the change days")
|
||||
# --------------------------------------------------------------------------- #
|
||||
Q = timedelta(minutes=15)
|
||||
SCENARIOS = (("modes on", "dynamic", (55.0, 40.0)),
|
||||
("modes off", "off", (55.0, 40.0)))
|
||||
|
||||
|
||||
def wrong(out, ymd, floor_, ceil_):
|
||||
"""What is wrong with one build, or None."""
|
||||
if not out.get("ok") or len(out.get("days") or []) != 2:
|
||||
return "not ok"
|
||||
for k, day in enumerate(out["days"]):
|
||||
exp = labels_of((ymd, next_ymd(ymd))[k])
|
||||
hrs = day["hours"]
|
||||
if day["length_h"] != len(exp):
|
||||
return f"day {k}: length {day['length_h']}"
|
||||
if [h["label"] for h in hrs] != exp:
|
||||
return f"day {k}: labels"
|
||||
if any(abs(h["end_h"] - h["start_h"] - 1.0) > 1e-6
|
||||
or abs(h["start_h"] - j) > 1e-6 for j, h in enumerate(hrs)):
|
||||
return f"day {k}: hour spans"
|
||||
for b in day["batteries"]:
|
||||
if any(not floor_ - 1e-6 <= v <= ceil_ + 1e-6
|
||||
or not -1e-6 <= h <= len(exp) + 1e-6
|
||||
for h, v in b["levels"]):
|
||||
return f"day {k}: level {b['name']}"
|
||||
if any(not 0.0 <= r["start_h"] <= r["end_h"] <= len(exp) + 1e-6
|
||||
for r in b["blocks"]):
|
||||
return f"day {k}: block {b['name']}"
|
||||
return None
|
||||
|
||||
|
||||
def walk(scn, soc):
|
||||
"""`(builds, with a curve, stopped, wrong)` over every quarter of the
|
||||
six local days; the first call stopped by the limit ends the walk."""
|
||||
builds, curves, bad = 0, 0, []
|
||||
for window in WINDOWS:
|
||||
for ymd in window:
|
||||
a = day_start_utc(ymd)
|
||||
z = day_start_utc(next_ymd(ymd))
|
||||
t = a + timedelta(seconds=5)
|
||||
try:
|
||||
NOW[0] = t
|
||||
c = limited(S.make, scn, t, soc=soc)
|
||||
floor_, ceil_ = arbitrage.soc_bounds(c.o)
|
||||
while t < z:
|
||||
NOW[0] = t
|
||||
c.tariff_at = t
|
||||
if c.modes_on:
|
||||
limited(c._mode_roles, t, None)
|
||||
limited(c.solar_today.add, t, 1500.0)
|
||||
out = limited(c.forecast_payload, "en")
|
||||
builds += 1
|
||||
curves += any(len(b["levels"]) > 1
|
||||
for d in out.get("days") or []
|
||||
for b in d["batteries"])
|
||||
why = wrong(out, ymd, floor_, ceil_)
|
||||
if why:
|
||||
bad.append((t.isoformat(), why))
|
||||
t += Q
|
||||
except Hang:
|
||||
return builds, curves, t.isoformat(), bad
|
||||
return builds, curves, None, bad
|
||||
|
||||
|
||||
for zname, tz in ZONES:
|
||||
with zone(tz):
|
||||
for label, scn, soc in SCENARIOS:
|
||||
builds, curves, stopped, bad = walk(scn, soc)
|
||||
print(f" {zname}, {label}: {builds} builds ({curves} with a "
|
||||
f"level curve), stopped at {stopped}, {len(bad)} wrong "
|
||||
f"{bad[:3]}")
|
||||
check(f"{zname}, {label}: the tab returns at every quarter of "
|
||||
"27-29 March and 30 October-1 November 2027 (under "
|
||||
f"{LIMIT_S:g} s each), and each day has its 23, 24 or 25 "
|
||||
"hours, labelled, levels inside floor and ceiling, blocks "
|
||||
"inside the day",
|
||||
stopped is None and not bad
|
||||
and builds == (96 + 92 + 96) + (96 + 100 + 96)
|
||||
and (curves == builds if scn == "dynamic" else not curves))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. measured solar keeps the two autumn 02:00 hours apart")
|
||||
# --------------------------------------------------------------------------- #
|
||||
for zname, tz in ZONES:
|
||||
with zone(tz):
|
||||
s = SolarToday()
|
||||
a = day_start_utc(AUTUMN) # 00:00 CEST = 22:00 UTC
|
||||
t = a
|
||||
while t <= a + timedelta(hours=4):
|
||||
# 1 kW in the first 02:00 hour (00:00-01:00 UTC), 3 kW in the
|
||||
# second (01:00-02:00 UTC), nothing else
|
||||
w = 1000.0 if a + timedelta(hours=2) <= t < a + timedelta(
|
||||
hours=3) else 3000.0 if a + timedelta(hours=3) <= t < a + \
|
||||
timedelta(hours=4) else 0.0
|
||||
limited(s.add, t, w)
|
||||
t += timedelta(minutes=1)
|
||||
now = a + timedelta(hours=4, minutes=30)
|
||||
sl = [x for x in limited(dayplan._hour_slices, a, now)
|
||||
if x[2] == 2]
|
||||
got = [None if x is None else round(x, 2) for x in
|
||||
(limited(s.kwh, x[0], x[1], now) for x in sl)]
|
||||
print(f" {zname}: the two 02:00 hours {got}")
|
||||
check(f"{zname}: two 02:00 slices, about 1 kWh and 3 kWh, each in "
|
||||
"its own hour", len(sl) == 2 and got[0] is not None
|
||||
and got[1] is not None and abs(got[0] - 1.0) < 0.06
|
||||
and abs(got[1] - 3.0) < 0.06)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -17,6 +17,7 @@ test_modes_p3.py part 1 holds this tree to the digests printed on 7a00f12.
|
||||
import functools
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -117,6 +118,31 @@ def digest(rows) -> str:
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
def _trade_words(text):
|
||||
"""PRICE-02: the trade sentence as release/1.0 before it printed it. The
|
||||
plan now drops price periods that have ended, so a sale's "above the
|
||||
cheapest" figure is a different number once the first cheap period is
|
||||
over, and the sentences say "price period" where they said "hour". Both
|
||||
maps are applied to both sides, so only those two things are hidden.
|
||||
|
||||
ONLY FOR COMPARING WITH A TREE OLDER THAN PRICE-02. A pin of this tree
|
||||
itself uses `digest()`, with nothing hidden."""
|
||||
text = text.replace("this hour is neither the buy hour nor the sell hour",
|
||||
"now is not the cheapest or the dearest price period")
|
||||
text = re.sub(r"[0-9]+\.[0-9]+ above the cheapest",
|
||||
"N above the cheapest", text)
|
||||
return text.replace("firm hour at", "firm price period at")
|
||||
|
||||
|
||||
def trade_digest(rows) -> str:
|
||||
"""`digest()` with the trade sentence passed through `_trade_words()`:
|
||||
every command, integrator and allocation, and the action, watts and the
|
||||
rest of the sentence, byte for byte."""
|
||||
return digest([r[:-1] + (None if r[-1] is None else
|
||||
(r[-1][0], r[-1][1], _trade_words(r[-1][2])),)
|
||||
for r in rows])
|
||||
|
||||
|
||||
def digests() -> dict:
|
||||
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""UX-54: controllers in a known situation, for test_ux54.py and the browser
|
||||
proof (`scratch/UX-54/dev/demo54.py`). Real `Controller`, readings set by hand,
|
||||
no Home Assistant. `make(name, now)` returns one; the names are in `SCENARIOS`.
|
||||
|
||||
owner modes on, both batteries Solar first, a day/night tariff, a forecast
|
||||
from three state-only sensors (Forecast.Solar's shape), a house
|
||||
profile (the owner's site 2026-10-26)
|
||||
dynamic modes on, A Leftover solar and B Cheap hours, 15-minute prices with a
|
||||
cheap night and a dear evening, a dark day (a grid charge is due)
|
||||
sunny as dynamic, a sunny day (the sun fills A, no grid charge)
|
||||
periods as dynamic with a Solcast-shaped forecast (an hourly split)
|
||||
off modes off, arbitrage on, the same prices
|
||||
learning modes on, a forecast, and no house profile yet
|
||||
nofc modes on, no forecast sensor set
|
||||
stale modes on, a sensor set that gives no forecast now
|
||||
notomorrow as owner, today's forecast only
|
||||
cycle as dynamic, battery A waiting for its monthly cycle
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from app import dayplan, packmode, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.peakguard import PeakDecision # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
|
||||
from app.static_tariff import TariffPlan, static_plan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
UTC = timezone.utc
|
||||
SCENARIOS = ("owner", "dynamic", "sunny", "periods", "off", "learning",
|
||||
"nofc", "stale", "notomorrow", "cycle")
|
||||
|
||||
INV = [{"name": "inverter-a", "setpoint_entity": "number.a",
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
||||
"max_w": 5000, "capacity_kwh": 15.36},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.b",
|
||||
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
|
||||
"max_w": 4600, "capacity_kwh": 20.48}]
|
||||
|
||||
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"meter_source": "off", "max_w": 9600, "shadow_mode": "off",
|
||||
"maintenance_enabled": False, "arbitrage_enabled": True,
|
||||
"cycle_cost_threshold_eur": 0.10, "pv_entity": "sensor.pv",
|
||||
"inverters": INV}
|
||||
FC1 = "sensor.energy_production_today_remaining"
|
||||
FCT1 = "sensor.energy_production_tomorrow"
|
||||
|
||||
DN = {"periods": [{"name": "day", "from": "07:00", "to": "22:00",
|
||||
"days": "all", "eur_per_kwh": 0.2801},
|
||||
{"name": "night", "from": "22:00", "to": "07:00",
|
||||
"days": "all", "eur_per_kwh": 0.2333}]}
|
||||
|
||||
|
||||
class Store:
|
||||
fresh = False
|
||||
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return None if v is None else datetime.fromisoformat(v)
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, _v):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
def day0_of(now):
|
||||
return solarfc._bounds(now)[0]
|
||||
|
||||
|
||||
def quarters(now, price_of, days=2):
|
||||
"""`days` days of 15-minute prices from local midnight; `price_of(h)`."""
|
||||
d0 = day0_of(now)
|
||||
pts = []
|
||||
for i in range(96 * days):
|
||||
t = d0 + timedelta(minutes=15 * i)
|
||||
pts.append(PricePoint(brussels(t), price_of((i % 96) / 4.0), False))
|
||||
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", now), None,
|
||||
"provider")
|
||||
|
||||
|
||||
def dyn_price(h):
|
||||
if 1.0 <= h < 5.0:
|
||||
return 0.08
|
||||
if 11.0 <= h < 15.0:
|
||||
return 0.14
|
||||
if 17.0 <= h < 21.0:
|
||||
return 0.34
|
||||
return 0.20
|
||||
|
||||
|
||||
def profile(now, *, day=600.0, evening=1800.0, night=350.0):
|
||||
"""A house profile with three full days: the net use per local hour."""
|
||||
shape = {h: (night if h < 6 or h >= 22 else 900.0 if h < 9
|
||||
else day if h < 17 else evening) for h in range(24)}
|
||||
d = brussels(now).date()
|
||||
return dayplan.HouseProfile(
|
||||
{(d - timedelta(days=k)).isoformat():
|
||||
{str(h): w for h, w in shape.items()} for k in (1, 2, 3)})
|
||||
|
||||
|
||||
def sensor(entity, kwh, now, *, periods=None, unit="kWh"):
|
||||
"""A forecast sensor's state, as Home Assistant gives it."""
|
||||
attrs = {"unit_of_measurement": unit}
|
||||
if periods:
|
||||
attrs["wh_period"] = periods
|
||||
return {"entity_id": entity, "state": f"{kwh:.3f}", "attributes": attrs,
|
||||
"last_updated": now.isoformat(),
|
||||
"last_reported": now.isoformat()}
|
||||
|
||||
|
||||
def bell_periods(now, day_kwh, *, tomorrow=False):
|
||||
"""An hourly split (Open-Meteo's `wh_period`) of a bell, for `now`'s day."""
|
||||
d0 = day0_of(now) + (timedelta(days=1) if tomorrow else timedelta(0))
|
||||
out = {}
|
||||
for h in range(24):
|
||||
a = solarfc._bell(h + 1.0) - solarfc._bell(float(h))
|
||||
out[(d0 + timedelta(hours=h)).isoformat()] = round(
|
||||
a * day_kwh * 1000.0, 1)
|
||||
return out
|
||||
|
||||
|
||||
def left_today(now, day_kwh):
|
||||
"""What the integration says is left today at `now` (state only)."""
|
||||
d0 = day0_of(now)
|
||||
h = (now - d0).total_seconds() / 3600.0
|
||||
return day_kwh * (1.0 - solarfc._bell(h))
|
||||
|
||||
|
||||
def forecast(now, today_kwh, tomorrow_kwh, *, split=False, members=1,
|
||||
tomorrow=True):
|
||||
"""A `solarfc.Forecast` through `read_sum()`, as the controller reads it.
|
||||
`today_kwh` is the whole day's sun; the sensors say what is left."""
|
||||
left = left_today(now, today_kwh)
|
||||
ts = [(f"{FC1}_{i}", sensor(
|
||||
f"{FC1}_{i}", left / members, now,
|
||||
periods=(bell_periods(now, today_kwh / members) if split else None)))
|
||||
for i in range(members)]
|
||||
tm = [(f"{FCT1}_{i}", sensor(
|
||||
f"{FCT1}_{i}", tomorrow_kwh / members, now,
|
||||
periods=(bell_periods(now, tomorrow_kwh / members, tomorrow=True)
|
||||
if split else None)))
|
||||
for i in range(members)] if tomorrow else []
|
||||
fc, why, _gaps = solarfc.read_sum(ts, tm, now)
|
||||
return fc
|
||||
|
||||
|
||||
def measure(c, now, peak_w):
|
||||
"""The solar this site made today until `now`, one reading a minute."""
|
||||
d0 = day0_of(now)
|
||||
t = d0
|
||||
while t <= now:
|
||||
h = (t - d0).total_seconds() / 3600.0
|
||||
c.solar_today.add(t, peak_w * math.sin(math.pi * (h - 6.0) / 14.0)
|
||||
if 6.0 <= h < 20.0 else 0.0)
|
||||
t += timedelta(minutes=1)
|
||||
|
||||
|
||||
def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first",
|
||||
"solar_first")):
|
||||
"""The controller of scenario `name` at `now` (UTC), its cycle run once."""
|
||||
assert name in SCENARIOS, name
|
||||
opts = dict(BASE)
|
||||
plan = quarters(now, dyn_price)
|
||||
sun_today, sun_tomorrow = 18.0, 16.0
|
||||
members, split, tomorrow = 1, False, True
|
||||
if name == "owner":
|
||||
opts.update(battery_modes_enabled=True,
|
||||
pv_forecast_entities=[f"{FC1}_{i}" for i in range(3)],
|
||||
pv_forecast_tomorrow_entities=[f"{FCT1}_{i}"
|
||||
for i in range(3)],
|
||||
static_profile=json.dumps(DN), arbitrage_enabled=False)
|
||||
plan = static_plan(opts, day0_of(now), day0_of(now) + timedelta(
|
||||
hours=48), now=now)
|
||||
members = 3
|
||||
elif name in ("dynamic", "sunny", "periods", "learning", "stale",
|
||||
"cycle"):
|
||||
opts.update(battery_modes_enabled=True, pv_forecast_entity=FC1,
|
||||
pv_forecast_tomorrow_entity=FCT1)
|
||||
modes = ("leftover_solar", "cheap_hours")
|
||||
if name == "dynamic":
|
||||
sun_today, sun_tomorrow = 3.0, 3.0
|
||||
if name == "periods":
|
||||
split = True
|
||||
sun_today, sun_tomorrow = 6.0, 6.0
|
||||
if name == "cycle":
|
||||
opts.update(maintenance_enabled=True)
|
||||
elif name == "off":
|
||||
opts.update(battery_modes_enabled=False, arbitrage_enabled=True)
|
||||
elif name == "nofc":
|
||||
opts.update(battery_modes_enabled=True)
|
||||
modes = ("leftover_solar", "cheap_hours")
|
||||
elif name == "notomorrow":
|
||||
opts.update(battery_modes_enabled=True, pv_forecast_entity=FC1,
|
||||
static_profile=json.dumps(DN), arbitrage_enabled=False)
|
||||
plan = static_plan(opts, day0_of(now), day0_of(now) + timedelta(
|
||||
hours=48), now=now)
|
||||
tomorrow = False
|
||||
if name == "sunny":
|
||||
sun_today, sun_tomorrow = 40.0, 40.0
|
||||
if name not in ("nofc", "off", "stale") and \
|
||||
opts.get("battery_modes_enabled"):
|
||||
# TARIFF-24: with a forecast the gap needed is the cycle cost, not
|
||||
# 1.5 x it. These scenarios were written for the old gate (0.15 at
|
||||
# the shipped 0.10), so they keep it: the cost is 0.15.
|
||||
opts["cycle_cost_threshold_eur"] = 0.15
|
||||
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for i, inv in enumerate(c.inverters):
|
||||
inv.soc, inv.batt, inv.last_write = soc[i], 0.0, 0.0
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
if opts.get("battery_modes_enabled"):
|
||||
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes[i]
|
||||
c.grid, c.pv_w = 300.0, 0.0
|
||||
c.tariff_plan, c.tariff_at = plan, now
|
||||
c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=7000.0,
|
||||
ceiling_from="cap", projected_w=800.0)
|
||||
c.arb_headroom_w = 6000.0
|
||||
if name not in ("nofc", "off", "stale") and \
|
||||
opts.get("battery_modes_enabled"):
|
||||
c.solar_fc = forecast(now, sun_today, sun_tomorrow, split=split,
|
||||
members=members, tomorrow=tomorrow)
|
||||
c.solar_fc_why = None
|
||||
if name != "learning":
|
||||
c.house_profile = profile(now)
|
||||
if name in ("owner", "dynamic", "sunny", "periods", "learning",
|
||||
"notomorrow", "cycle"):
|
||||
measure(c, now, 7000.0 if name != "dynamic" else 2500.0)
|
||||
if opts.get("battery_modes_enabled"):
|
||||
c.tariff_at = now
|
||||
c._mode_roles(now, None)
|
||||
return c
|
||||
@@ -164,7 +164,7 @@ configuration:
|
||||
name: "Limits · Margin below the peak limit (W)"
|
||||
description: "The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it."
|
||||
peak_forecast_entity:
|
||||
name: "Limits · 15-minute demand forecast entity (W)"
|
||||
name: "Limits · 15-minute demand forecast entity"
|
||||
description: "Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit."
|
||||
peak_soc_floor:
|
||||
name: "Limits · Peak reserve (%)"
|
||||
|
||||
@@ -145,7 +145,7 @@ configuration:
|
||||
name: "Grenzen · Marge onder de piekgrens (W)"
|
||||
description: "De add-on mikt zoveel onder de piekgrens, zodat een kwartier dat iets hoger uitvalt er toch onder blijft."
|
||||
peak_forecast_entity:
|
||||
name: "Grenzen · Entiteit kwartierprognose (W)"
|
||||
name: "Grenzen · Entiteit kwartierprognose"
|
||||
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier, in W of kW. Zonder vermogenseenheid wordt ze niet gebruikt. Laat u dit leeg, dan is er geen piekgrens."
|
||||
peak_soc_floor:
|
||||
name: "Grenzen · Piekreserve (%)"
|
||||
|
||||
Reference in New Issue
Block a user