Compare commits
53
Commits
OPT-23-28
...
release/1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8afadbdd9a | ||
|
|
bb57a85b35 | ||
|
|
57b0eb2f20 | ||
|
|
8f0af9f318 | ||
|
|
eee7616022 | ||
|
|
412e47f111 | ||
|
|
deee98d352 | ||
|
|
a66a7f1a0f | ||
|
|
8e2463bc32 | ||
|
|
40595e42ae | ||
|
|
cc9bbeb73e | ||
|
|
b9dea6d90d | ||
|
|
13b61d586a | ||
|
|
307cb47caf | ||
|
|
60aa7f310c | ||
|
|
bf842997c1 | ||
|
|
4fde0b177c | ||
|
|
50b567d6d1 | ||
|
|
f1b72443ff | ||
|
|
60a3fa615e | ||
|
|
1c9fefd9dd | ||
|
|
8cca671a05 | ||
|
|
9dc22fb987 | ||
|
|
5ac1017eed | ||
|
|
8bf54ece3d | ||
|
|
f1c9733c32 | ||
|
|
b6be31286a | ||
|
|
6be7e41de1 | ||
|
|
a645d899f4 | ||
|
|
ffb0d61d5b | ||
|
|
8966867968 | ||
|
|
2929ece7da | ||
|
|
6eebd4882b | ||
|
|
88ab8a50e3 | ||
|
|
2932f3af39 | ||
|
|
7e77c723b9 | ||
|
|
97686319f3 | ||
|
|
d424b862d8 | ||
|
|
382b0ea411 | ||
|
|
230ba0b57a | ||
|
|
d667f3f3a8 | ||
|
|
793a67623c | ||
|
|
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, safety126 634 (SAFETY-126, local, one core), safety125 508 on hotfix-0.19.2 only, modes_p3 364, safety119 328,
|
||||
# price_run_fixes 184, safety90 160, safety103 134, safety94 114, peakguard 113,
|
||||
# opt20 102, safety97 101, safety81 100). With JOBS workers pulling from one
|
||||
# queue, starting the longest files first keeps the tail short: the batch takes
|
||||
# about as long as the longest file. A sort order, not a selection.
|
||||
HEAVY="test_modes_p4.py test_safety126.py test_safety125.py test_modes_p3.py test_safety119.py test_price_run_fixes.py test_safety90.py test_safety103.py test_safety94.py test_peakguard.py test_opt20.py test_safety97.py test_safety81.py"
|
||||
#
|
||||
JOBS=${CI_JOBS:-6}
|
||||
cd "${TESTS_DIR:-/w/goodwe-addon/goodwe_controller}"
|
||||
|
||||
# one FILE: run it, keep its output in its own file, print its block whole.
|
||||
if [ "${1:-}" = "--one" ]; then
|
||||
f=$2
|
||||
o="$RUN_DIR/$f.out"
|
||||
start=$(date +%s)
|
||||
if python3 "$f" > "$o" 2>&1; then rc=0; else rc=1; fi
|
||||
secs=$(( $(date +%s) - start ))
|
||||
{
|
||||
if [ $rc = 0 ]; then
|
||||
echo "PASS $f (${secs}s)"
|
||||
grep "^CI: " "$o" || true
|
||||
else
|
||||
echo "FAIL $f (${secs}s)"
|
||||
tail -40 "$o"
|
||||
fi
|
||||
} > "$RUN_DIR/$f.blk"
|
||||
echo $rc > "$RUN_DIR/$f.rc"
|
||||
flock "$RUN_DIR/print.lock" cat "$RUN_DIR/$f.blk"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -- test_*.py
|
||||
if [ "$1" = "test_*.py" ]; then
|
||||
echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked."
|
||||
exit 1
|
||||
fi
|
||||
echo "$# test file(s) to run: $JOBS at a time, then the serial group"
|
||||
|
||||
RUN_DIR=$(mktemp -d /tmp/ci-tests.XXXXXX)
|
||||
export RUN_DIR
|
||||
: > "$RUN_DIR/par"
|
||||
: > "$RUN_DIR/ser"
|
||||
for f in $HEAVY; do
|
||||
case " $SERIAL " in *" $f "*) continue ;; esac
|
||||
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/par"; fi
|
||||
done
|
||||
for f in "$@"; do
|
||||
case " $SERIAL $HEAVY " in *" $f "*) continue ;; esac
|
||||
echo "$f" >> "$RUN_DIR/par"
|
||||
done
|
||||
for f in $SERIAL; do
|
||||
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/ser"; fi
|
||||
done
|
||||
echo "parallel: $(wc -l < "$RUN_DIR/par") file(s); serial: $(wc -l < "$RUN_DIR/ser") file(s)"
|
||||
|
||||
t0=$(date +%s)
|
||||
rc=0
|
||||
# `exit 0` in the worker: a failing FILE is recorded in its .rc, so a non-zero
|
||||
# from xargs itself means the machinery broke, which is a failure too.
|
||||
xargs -n 1 -P "$JOBS" sh -eu "$0" --one < "$RUN_DIR/par" || rc=1
|
||||
echo "parallel batch done in $(( $(date +%s) - t0 ))s; serial group next"
|
||||
while read -r f; do
|
||||
sh -eu "$0" --one "$f" || rc=1
|
||||
done < "$RUN_DIR/ser"
|
||||
|
||||
# Every file that was meant to run has a verdict, and every verdict is PASS. A
|
||||
# worker that died before writing its .rc leaves no file, which counts as a fail.
|
||||
ran=0
|
||||
bad=0
|
||||
for f in "$@"; do
|
||||
ran=$((ran + 1))
|
||||
if [ ! -f "$RUN_DIR/$f.rc" ]; then
|
||||
echo "FAIL $f (no result: the run never reported)"
|
||||
bad=$((bad + 1))
|
||||
elif [ "$(cat "$RUN_DIR/$f.rc")" != 0 ]; then
|
||||
bad=$((bad + 1))
|
||||
fi
|
||||
done
|
||||
echo "$ran file(s), $bad failed, $(( $(date +%s) - t0 ))s wall"
|
||||
if [ "$bad" = 0 ] && [ "$rc" = 0 ]; then exit 0; fi
|
||||
exit 1
|
||||
+15
-23
@@ -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,65 @@
|
||||
# Changelog
|
||||
|
||||
## 0.19.2
|
||||
|
||||
### Not in a release yet
|
||||
|
||||
- The night grid charge no longer raises "Battery stopped limiting the peak" when the 15-minute period stays on the aim. A steadily rising load now alarms a little later, and with a peak margin of 0 a period can end up to 10 W over the limit without this alarm ([[SAFETY-126]]).
|
||||
- A night grid charge for *Leftover solar* no longer stops at 03:00 when the solar forecast sensors went quiet in the evening. The evening's figure is used until 06:00, and a charge that has started goes on to its level ([[OPT-34]]).
|
||||
|
||||
**A patch bump: two fixes, one for charging from the grid under the peak limit and one for the night charge of Leftover solar, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.19.1 also
|
||||
start 0.19.2. The change below applies with one inverter or two.
|
||||
|
||||
**Charging from the grid now stays under this month's peak.** A late meter reading near a period's end could push a grid charge to full power. That could take the 15-minute period over the peak. Now the charge is cut back, with one inverter too. The peak guard line no longer says "under" when the period would go over the limit.
|
||||
|
||||
Contents of this release, in merge order since 0.19.1 (`2932f3a`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `SAFETY-125` | A grid charge (a battery mode's cheap run, price trading or the maintenance charge, with one inverter or two) counts the seconds since a late meter reading at that reading, so it is cut back near the end of a 15-minute period instead of going back to full power. With one inverter the maintenance charge is now held to the room left in the period, as with two, so the peak guard no longer steps in and gives power at the period's end. Over the limit at the current rate, the peak guard row and the month peak line say so, and that the grid charge is cut back ([[SAFETY-125]]). |
|
||||
|
||||
### Known issues
|
||||
|
||||
The list under `## 0.19.0` below still applies. The SAFETY-99 line there now
|
||||
covers the guard's discharge only, not a grid charge.
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.19.1
|
||||
|
||||
**A patch bump: two fixes for battery modes, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.19.0 also
|
||||
start 0.19.1. The changes below apply with one inverter or two.
|
||||
|
||||
**Battery modes now see a day/night or fixed tariff.** With battery modes on, a day/night or fixed tariff and no price service, the batteries never got the prices: "Cheap hours" never charged from the grid at the cheap rate, and the Batteries screen said "No prices". They get them now, with no price service and no internet call added.
|
||||
|
||||
**A solar forecast now counts for the price gap.** With battery modes on and a solar forecast read, a price mode needs a gap of at least *Battery cycle cost*, not 1.5 times that. Without a forecast it is still 1.5 times. A day/night gap of 0.08 now works at a cycle cost of 0.07 or less.
|
||||
|
||||
Contents of this release, in merge order since 0.19.0 (`793a676`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `TARIFF-24` | With battery modes on, the add-on now also resolves the tariff when no price service and no price trading are set, so a day/night or fixed tariff gives the batteries their price runs ([[TARIFF-24]]). With battery modes on and a solar forecast read, the price gap a mode needs is *Battery cycle cost* (widened only for a real-time feed), not 1.5 times that; Home and the Batteries screen use the same rule ([[AUDIT-02]] F1). |
|
||||
|
||||
### Known issues
|
||||
|
||||
The list under `## 0.19.0` below still applies.
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.19.0
|
||||
|
||||
**A minor bump: price trading behaves differently, plus a fix for a false warning, the Home price chart in 15-minute bars, more Dutch text and EMS-port wording, and no option changes.**
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# OPT-27 - the night plan keeps the last fresh forecast
|
||||
|
||||
**Status: done on `release/1.0`, not released.** Tests: `test_opt27.py`.
|
||||
|
||||
**The defect.** A today sensor is stale after 3 h (`FRESH_S`) or when it is from
|
||||
before local midnight. Forecast.Solar stops updating in the evening, so at night
|
||||
every Forecast.Solar site had no forecast: a price mode's night cheap run needed
|
||||
1.5x the cycle cost (TARIFF-24 round 2) and Leftover solar planned nothing.
|
||||
Separately, one missed poll (`unavailable`) dropped the forecast at once.
|
||||
|
||||
**Where: `solarfc`, not the plan.** The last fresh reading of each sensor is
|
||||
kept in `solarfc.Hold` and `read_sum()` uses it when a sensor has none. A hold
|
||||
at the plan (`_wx_track`/`weather_held`) can only keep the 1.5x gap off. The
|
||||
day plan, Leftover solar's levels, Home and the Forecast tab all read
|
||||
`Controller.solar_fc`, so one kept forecast makes control and every screen agree
|
||||
(UX-53) with no second rule to keep in step. `weather_held()` is unchanged: it
|
||||
still keeps a run under way when a read is lost.
|
||||
|
||||
**The rule** (`Hold.stand_in()`). A kept reading is for one day. It covers a
|
||||
sensor that is missing or stale, never one with a bad value or unit, and:
|
||||
- for up to `FRESH_S` when the sensor is missing: a short outage, any time of day;
|
||||
- from the end of the bell (20:00 local, `BELL_TO_H`) until local midnight, if the
|
||||
reading is at most `KEPT_MAX_AGE_S` (12 h) old when used; after midnight the
|
||||
tomorrow figure is today's until the bell starts (06:00), up to 15 h old
|
||||
(OPT-34 changed this from midnight + 3 h);
|
||||
- the day it is for: a today figure only on that day, a tomorrow figure as
|
||||
tomorrow until midnight and, if the today sensors are still stale, as today's
|
||||
until 06:00 (all sensors of the sum are needed, as for any sum);
|
||||
- until the next fresh reading, which replaces it at once.
|
||||
|
||||
So a figure is never used for another day and never older than 12 hours (`KEPT_MAX_AGE_S`), except the morning case above: a 17:05 reading serves until 06:00 (12 h 55 min), a 09:05 reading only until 21:05. Until OPT-34 the figure ended at 03:00, and a charge under way lost its level there; OPT-34 closes that limit.
|
||||
Daytime is as before: a sensor that stopped is stale after 3 h, and no forecast
|
||||
all day is still no forecast (the night run still needs 1.5x).
|
||||
|
||||
**Where it is stored.** The controller's store, key `solar_fc_hold`, so an
|
||||
evening restart keeps it. It is written when a reading's figure changes or
|
||||
every 15 min (`SAVE_EVERY_S`), not on every poll. A damaged entry is dropped.
|
||||
|
||||
**The screens.** `Forecast.kept` is true when a reading older than `FRESH_S` is
|
||||
in use; the forecast line then reads "Solar forecast read 17:05, kept for
|
||||
tonight." (`home_words.solar.kept`, EN and NL). The event log says
|
||||
"read, kept from 17:05".
|
||||
|
||||
**Not changed.** Control with modes off: it does not use the forecast. The
|
||||
screens still show the line, so it says "kept", not "used for the plan". A
|
||||
forecast that stays fresh. The hold does not look at the weather, only at the clock and the day.
|
||||
@@ -0,0 +1,62 @@
|
||||
# OPT-34 - the night charge keeps its level when the kept forecast ends
|
||||
|
||||
**Status: done on `release/1.0`, not released.** Tests: `test_opt34.py`.
|
||||
|
||||
**The defect** (OPT-27 rig test, F-1). The kept evening figure ended at midnight + 3 h.
|
||||
With the sensors silent from 17:05 the Leftover solar battery's level went with
|
||||
it in the middle of the charge: A ended at 49.8 % against 79.9 % with a fresh
|
||||
figure. Whether Forecast.Solar refreshes after midnight on a live site is not
|
||||
known, so the add-on does not depend on it.
|
||||
|
||||
**The rule.**
|
||||
1. *A charge under way keeps its level* (`Controller._latch_charge()`). While
|
||||
there is a forecast (fresh or kept), each battery that charges from the grid in the
|
||||
cheap run takes its planned level into a latch (store key `night_charge_latch`).
|
||||
With no forecast, `plan_targets()` gives the latched level, and the wide
|
||||
no-forecast gap does not refuse the next slice of the run (`weather_held()`,
|
||||
as TARIFF-24 does for the run under way). While a
|
||||
forecast is there the latch follows the plan, up or down as a fresh figure moves
|
||||
it. It lasts through every cheap run on and one price slot after the last, and it
|
||||
ends at once when its end time passes, whatever the run state (it is also
|
||||
ignored at a restart if it has ended). A battery that is switched off or
|
||||
changed to another mode leaves it.
|
||||
*Night bound (manager's call, 2026-10-31):* a run carries the latch on only if
|
||||
it starts by the bell start (06:00 local) after the latch began; that run
|
||||
finishes, plus one slot. Chosen over a fixed 12 h because the night tariff
|
||||
(22:00-07:00) needs the 06:00-07:00 slice and 12 h from a late start would
|
||||
reach into the day. With dynamic prices a chain of cheap slots cannot carry a
|
||||
night's level into the next day.
|
||||
2. *The evening's tomorrow figure is today's until the bell starts*, 06:00 local
|
||||
(`BELL_FROM_H`, `solarfc._bell_start()`), not until midnight + 3 h.
|
||||
Nothing has been produced yet, so it is still the whole day.
|
||||
3. *Up to 15 h old for that case only* (`KEPT_MORNING_MAX_AGE_S`): a 17:05
|
||||
reading is 12 h 55 min old at 06:00. It must have been at most 12 h old at
|
||||
midnight, so a 09:05 reading, unusable since 21:05, does not come back for five
|
||||
minutes after midnight. Same-day figures and the daytime rule keep 12 h and 3 h.
|
||||
4. A fresh figure replaces the kept one at once, as before.
|
||||
|
||||
**Step 1 alone is enough for the owner's shape, steps 2-3 are still needed.**
|
||||
With the window put back to midnight + 3 h the owner's night still ends as a
|
||||
fresh figure ends it (A 86.9 %, B 95.0 %), because the charge had started at
|
||||
22:00. But a charge that has not started by 03:00 (the price dips at 04:00, or
|
||||
the headroom is taken) has no level to latch: A ends at 38 % against 78 %. The
|
||||
plan, the screens and a restart also need the figure itself.
|
||||
|
||||
**Screens.** "Solar forecast read {at}, kept for tonight." is still true until
|
||||
06:00 and unchanged. After the figure ends, with a charge still going on: "No
|
||||
solar forecast now. The charge under way goes on to its planned level." /
|
||||
"Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau."
|
||||
(`home_words.solar.latched`, only while a cheap run is on and a latched battery is below its level).
|
||||
|
||||
**Not used.** A figure over 15 h old; a morning figure at night (12 h); a figure
|
||||
for another day; a bad value or unit; and no latch for a charge that has not
|
||||
started, for a battery that is not charging, or after the cheap runs have ended
|
||||
(plus one slot).
|
||||
|
||||
**Not changed.** Charge sizing, the peak guard, Cheap hours, modes off, arbitrage.
|
||||
A fresh figure that LOWERS the level during a charge still lowers it, as before.
|
||||
|
||||
**Mutants** (all red in `test_opt34.py`): no latch; window back to midnight + 3 h;
|
||||
15 h without a limit; no "usable at midnight" rule; the latch without the narrow
|
||||
gap, without its save, or not kept through the next run; the window 6 h after
|
||||
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level.
|
||||
@@ -0,0 +1,10 @@
|
||||
# SAFETY-125 - a grid charge under the month peak
|
||||
|
||||
**Status: FIXED in 0.19.2 (hotfix from 0.19.1).** Tests: `test_safety125.py`.
|
||||
|
||||
A grid charge is sized by `peakguard.charge_room_w()`, the guard's headroom
|
||||
with the seconds since a late meter reading counted at that reading (the
|
||||
projection leaves them out, SAFETY-99, the bill does not), and the guard's own
|
||||
floor is unchanged. At N=1 the maintenance charge is now sized too (owner
|
||||
2026-10-29; SAFETY-84 had left it out). Evidence (repro on 0.19.1, sweep,
|
||||
digests): the SAFETY-125 pull request.
|
||||
@@ -0,0 +1,47 @@
|
||||
# SAFETY-126 - a landing error on the aim is not an overrun
|
||||
|
||||
**Status: FIXED on release/1.0 (after 0.19.2).** Tests: `test_safety126.py`.
|
||||
|
||||
**Cause.** A grid charge under the peak limit is sized to land the 15-minute
|
||||
period on the aim (SAFETY-125), so the period ends 0-7 W over it. In the last
|
||||
second `peakguard.excess_w()` multiplies that by 900 / remaining (3 000 at
|
||||
0.3 s left, 90 000 at 0.01 s), so `decide()` wants tens of kW
|
||||
(`wanted > 0`, peakguard.py `decide()`). With every battery under the peak
|
||||
reserve that is floor-limited at 0 W, and `check_peak_floor_limited()`
|
||||
alarmed on it at once: SAFETY-87 graded only the case where another battery
|
||||
could give. With one battery able, the guard asked it for its rating for that
|
||||
cycle and the Batteries "Why" said "The peak limit needs power". The
|
||||
add-on's own charge is not the cause: `wanted` already counts it as
|
||||
stoppable.
|
||||
|
||||
**Rule.** Each cycle the guard wants power, `Controller._peak_landing()`
|
||||
asks `peakguard.misses_aim()`: with the batteries at 0 W from now on, does
|
||||
the period end more than `ALARM_MISS_W` (10 W) over the aim? This is
|
||||
`load_too_high()` with nothing to give, without `decide()`'s gain. If not, and
|
||||
no real miss was seen since the guard took hold, the decision is
|
||||
`within_aim`. Such a decision raises no reserve alarm (ERROR,
|
||||
notification, red row), no red "Load too high" row, no "Why" peak and
|
||||
no "Battery stopped limiting the peak" event hint. The claim, the floor and the
|
||||
commands are unchanged.
|
||||
|
||||
**Still alarms.** House load the batteries cannot cover under the reserve:
|
||||
the period would end over the aim with the batteries at 0 W. A step load
|
||||
alarms on the same cycle as before. SAFETY-87's case and SAFETY-81's 'Load too
|
||||
high' notification are unchanged.
|
||||
|
||||
**What the 10 W tolerance costs** (review, owner accepted 2026-10-31):
|
||||
- A steadily rising load alarms a little later, once the projected miss passes
|
||||
10 W (3 000 -> 4 000 W over 45 min: 56 s later). With the default 50 W
|
||||
margin the alarm still comes before the limit.
|
||||
- With `peak_margin_w` 0, a period can end up to 10 W over the limit without
|
||||
this alarm, the tolerance 'Load too high' already accepts (SAFETY-81).
|
||||
|
||||
`misses_aim()` takes the house as meter plus batteries, so an export nets
|
||||
off (`decide()`'s projection counts offtake only). A re-alarm while the loop's
|
||||
draw on held batteries covers the house is gone too (test_safety126.py part 2,
|
||||
modes off: 2 notifications, was 4).
|
||||
|
||||
**Not changed.** The one-cycle floor itself: at a period's last instant the
|
||||
guard still asks an able battery for its rating, or stops a charge, for a
|
||||
landing error. That is control, outside the alarm path. Raise a card if it
|
||||
should go.
|
||||
@@ -39,7 +39,8 @@ was fine.
|
||||
**Not changed.**
|
||||
- At N=1 the charge is not sized to the headroom: the guard still suspends it
|
||||
and the battery gives until the quarter is safe. The same sizing would fit
|
||||
there; that is an owner question, not this fix.
|
||||
there; that is an owner question, not this fix. (SAFETY-125, 0.19.2: the
|
||||
owner said yes, and N=1 is sized the same way.)
|
||||
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
|
||||
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
|
||||
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# TARIFF-24 - battery modes need the tariff task
|
||||
|
||||
**Status: FIXED in 0.19.1 (hotfix from 0.19.0, owner go 2026-10-28).**
|
||||
Follows TARIFF-10 (fetching is not trading). Tests: `test_tariff24.py`.
|
||||
|
||||
**The defect.** The control cycle gets prices only from
|
||||
`Controller.tariff_plan`, which only `run_tariff_refresh()` stores. `amain()`
|
||||
starts that task when `tariff_refresh_wanted(opts)`, which was
|
||||
`provider_configured(opts) or arbitrage.enabled(opts)`. Battery modes read the
|
||||
plan but were not a term. With battery modes on, a day/night or fixed
|
||||
`static_profile`, `price_provider: off` and `arbitrage_enabled: false` (the
|
||||
site trade switch is not read with modes on) no task ran. Then
|
||||
`fresh_tariff_plan()` was None, `_price_runs()` was None and `mode_runs` was
|
||||
None, so Leftover solar and Cheap hours never charged from the grid. The
|
||||
Batteries screen said "No prices, so it follows the house" while Home priced
|
||||
the day from `static_plan()` itself (`homeview.build_plan()`).
|
||||
|
||||
**The fix** (`main.py`, `tariff_refresh_wanted()`). `packmode.enabled(opts)`
|
||||
is a third term. The gate stays a widening: nothing that started the task
|
||||
before stops. No HTTPS call is added: with `price_provider: off`,
|
||||
`resolve_tariff()` is the pure `static_plan()`.
|
||||
|
||||
**Not changed.** Modes off, arbitrage off, no feed still starts no task. The
|
||||
control law, the price runs and every message are untouched.
|
||||
|
||||
**One rule, not two.** Gating on "modes on and a static profile" was
|
||||
considered. It saves one arithmetic pass every 30 min on a site with modes on
|
||||
and no prices at all, and it adds a second condition that has to track what
|
||||
`resolve_tariff()` accepts. With no profile and no feed the plan is `no_data`,
|
||||
exactly what the cycle sees without the task.
|
||||
|
||||
**UX-55.** Home said "no prices have arrived" (`mode_runs` None) with every
|
||||
battery on Solar first. The same cause, and this fix clears it for a site with
|
||||
a static profile (`test_tariff24.py` part 5). A site with no profile and no
|
||||
feed still has no prices, and Home still says so.
|
||||
|
||||
## Round 2 (AUDIT-02 F1): the forecast replaces the "no weather" doubt
|
||||
|
||||
**Decided 2026-10-28 (owner), in the same hotfix.** The price gap a mode needs
|
||||
was `cycle_cost_threshold_eur` x 1.5 (`Uncertainty.NO_WEATHER`) whether or not
|
||||
a solar forecast was read. At the shipped cost 0.10 that is 0.15, so a
|
||||
day/night gap (0.08) and many dynamic days never traded, and the screens said
|
||||
only "Prices differ too little".
|
||||
|
||||
**Why the doubt goes with a forecast.** The widening stands in for not knowing
|
||||
the weather. The forecast is that knowledge, and the day plan already trusts it
|
||||
for how much to charge. Trusting it for the plan but doubting it for the gap
|
||||
was one input judged twice.
|
||||
|
||||
**The rule** (`arbitrage.weather_uncertainty()`). With battery modes on and a
|
||||
usable forecast (`solar_fc` not None, the day plan's own test) the doubt is
|
||||
not added: the gap needed is the cycle cost, widened by the price product only.
|
||||
No forecast, or a stale or unusable one: 1.5 x, as before. It is read in four
|
||||
places that must agree: the run plan (`_price_runs()`), the charge gate
|
||||
(`_mode_roles()`), Home's mirror (`homeview.trade_verdict()`) and the Forecast
|
||||
tab's longer cheap run (`dayview.py`, from release/1.0). The forecast
|
||||
is read at most every 60 s and the plan key already holds "forecast or not", so
|
||||
the gap follows in the cycle the read changes, both ways, with one exception:
|
||||
a run planned with a forecast that is under way keeps that gap to its end
|
||||
(`Controller.weather_held()`), so a sensor that goes unavailable does not stop
|
||||
or restart a Cheap hours charge (a Leftover solar battery still stops: without
|
||||
a forecast it has no planned level). The wider gap applies from the next run.
|
||||
A run that has started runs to its end: a give run that starts during the hold
|
||||
is kept after it (`keep_started()`), with no grid charge or sale.
|
||||
|
||||
**Not changed.** Modes off (the site trade) keeps the doubt always. The
|
||||
shipped cost 0.10 still needs a gap of 0.10 even with a forecast: a 0.08
|
||||
day/night site must set *Battery cycle cost* to 0.08 or less. Showing the two
|
||||
numbers on the screens (AUDIT-02 F1 option b) is a separate card.
|
||||
|
||||
@@ -538,7 +538,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
|
||||
**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.
|
||||
@@ -557,6 +557,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
|
||||
- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
|
||||
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
|
||||
- **At night.** Some forecasts stop updating in the evening (Forecast.Solar does). The add-on keeps the last reading of each sensor and uses it for that night's plan: from 20:00 until 06:00, or until a newer reading comes. After midnight the evening's figure for tomorrow is today's, whole, because nothing has been produced yet. A sensor that is missing for up to 3 hours is covered the same way. A figure is never used for another day, and never when it is more than 12 hours old (15 hours after midnight, for a figure that was still in use at midnight). A charge from the grid that has started keeps its level when the forecast ends, and goes on to it; the Batteries screen says so. The Batteries screen says "Solar forecast read 17:05, kept for tonight." With no reading all day, the night run still needs the wider gap.
|
||||
|
||||
**What always comes first.**
|
||||
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
|
||||
@@ -1710,7 +1711,9 @@ DECISIONS/MAINT-13-full-pack-is-complete.md.
|
||||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||||
under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
|
||||
is dropped so the battery can shave the peak instead. Money outranks the
|
||||
maintenance schedule.
|
||||
maintenance schedule. With one battery or several, the charge also stays
|
||||
within the room left in the 15-minute period, so a late meter reading does
|
||||
not let it run at full power.
|
||||
|
||||
With two or more batteries, the one being emptied gives its drain rate and the
|
||||
others only add power when the house needs more; none charges while it drains
|
||||
@@ -3455,7 +3458,7 @@ Charging from the grid stays blocked meanwhile. With several inverters the
|
||||
notification names the battery at its limit (SAFETY-32), each graded against
|
||||
its own cut-out level. That battery is held (weight 0 while the site gives)
|
||||
and the guard's need goes to the others, up to their rating; the alarm fires
|
||||
only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). ⚠️ **The reserve binds the peak guard only
|
||||
only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). With no battery able to give it fires at once, but only if the period would end more than 10 W over the aim with the batteries at 0 W. A grid charge the add-on runs counts as one it can stop, and the last second's landing error on the aim is not an alarm. A steadily rising load therefore alarms a little later. With `peak_margin_w` 0 a period can end up to 10 W over the limit without this alarm, as for “Load too high” (SAFETY-126). ⚠️ **The reserve binds the peak guard only
|
||||
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
|
||||
does not protect*.
|
||||
|
||||
@@ -3614,7 +3617,7 @@ for a new install*.
|
||||
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
|
||||
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
|
||||
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *At this rate … over …* means it would go over at the current rate. With *grid charge limited*, a charge from the grid is cut back so it does not. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
|
||||
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
|
||||
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
|
||||
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
|
||||
|
||||
@@ -147,7 +147,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .arbiter import ACTUATOR_SITE, P_ARBITRAGE, Claim
|
||||
from .price import Absence, price_option
|
||||
from .price import Absence, Uncertainty, price_option
|
||||
|
||||
_LOG = logging.getLogger("goodwe.arbitrage")
|
||||
|
||||
@@ -267,6 +267,21 @@ def threshold_eur_kwh(opts: dict) -> float:
|
||||
return v
|
||||
|
||||
|
||||
def weather_uncertainty(modes_on: bool, solar_fc, held: bool = False) -> frozenset:
|
||||
"""The "no weather" doubt (x1.5) a trade must clear. TARIFF-24.
|
||||
|
||||
Battery modes read a solar forecast; with one read (`solar_fc` not None) the
|
||||
doubt is gone, and `held` (a run planned with a forecast is under way, see
|
||||
`Controller.weather_held()`) keeps it gone until that run ends. Everything
|
||||
else, and the modes without a forecast, keep it. One rule for the run plan,
|
||||
the charge gate and Home's mirror. See
|
||||
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
"""
|
||||
if modes_on and (solar_fc is not None or held):
|
||||
return frozenset()
|
||||
return frozenset({Uncertainty.NO_WEATHER})
|
||||
|
||||
|
||||
def soc_bounds(opts: dict) -> tuple:
|
||||
"""(floor, ceiling) in percent. The reserve arbitrage may not spend."""
|
||||
return (float(price_option(opts, "arbitrage_soc_floor")),
|
||||
|
||||
@@ -23,7 +23,7 @@ from . import arbitrage, dayplan, homeview as hv, modetrade, priceruns
|
||||
from . import solarfc
|
||||
from .daycurve import Batt, curve
|
||||
from .packmode import SOLAR_FIRST
|
||||
from .price import Uncertainty, brussels, required_spread, uncertainty_of
|
||||
from .price import brussels, required_spread, uncertainty_of
|
||||
|
||||
BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry
|
||||
LEVEL_DP = 1 # the tab shows a level to 0.1 %
|
||||
@@ -204,9 +204,12 @@ def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow,
|
||||
end_t = None
|
||||
if plan_on and cheap is not None and rp.charge_useful() \
|
||||
and n0 <= cheap.start < n1 and plan is not None:
|
||||
# The gap control's run plan needs (TARIFF-24): no x1.5 with a
|
||||
# forecast read.
|
||||
required = required_spread(
|
||||
arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty_of(plan.read.product) | {Uncertainty.NO_WEATHER})
|
||||
uncertainty_of(plan.read.product)
|
||||
| arbitrage.weather_uncertainty(True, c.solar_fc))
|
||||
end_t = cheap.end
|
||||
for _ in range(24):
|
||||
ext = dataclasses.replace(rp, cheap=priceruns.Run(
|
||||
@@ -368,7 +371,8 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
|
||||
if fc is not None:
|
||||
if k == 0:
|
||||
line = hv.line_solar(fc.today.kwh, fc.today.kwh * trust,
|
||||
hv.hhmm(fc.at))
|
||||
hv.hhmm(fc.at),
|
||||
kept=getattr(fc, "kept", False))
|
||||
elif fc.tomorrow is not None:
|
||||
line = hv.day_note("tomorrow_total",
|
||||
kwh=hv._kwh(fc.tomorrow.kwh),
|
||||
|
||||
@@ -24,7 +24,7 @@ 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
|
||||
@@ -280,7 +280,11 @@ def trade_verdict(c, plan, now):
|
||||
return None, "no_prices"
|
||||
verdict = plan.arbitrage(peak_guard_active=guard,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
uncertainty=arbitrage.weather_uncertainty(
|
||||
bool(getattr(c, "modes_on", False)),
|
||||
getattr(c, "solar_fc", None),
|
||||
bool(getattr(c, "weather_held",
|
||||
lambda _n: False)(now))),
|
||||
now=now)
|
||||
return verdict.match(
|
||||
allowed=lambda v: (v, "allowed"),
|
||||
@@ -531,15 +535,29 @@ def plan_cover(c, name):
|
||||
return targets[name], max(0.0, top - floor) / 100.0 * cap * EFF
|
||||
|
||||
|
||||
def _latched_below(c) -> bool:
|
||||
"""OPT-34: no forecast, but a cheap run is on and a battery is still below
|
||||
the level its night charge keeps (`Controller.plan_targets()`), so control
|
||||
goes on charging."""
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
if rp is None or rp.cheap is None or not rp.cheap.holds(
|
||||
datetime.now(timezone.utc)):
|
||||
return False
|
||||
targets = getattr(c, "plan_targets", lambda: None)() or {}
|
||||
return any(inv.soc is not None and inv.name in targets
|
||||
and inv.soc < targets[inv.name] for inv in c.inverters)
|
||||
|
||||
|
||||
def plan_state(c):
|
||||
"""P4: `"plan"` (a day plan is made), `"learning"` (no house profile
|
||||
yet), `"no_forecast"` (set but missing or stale), or None (not set, or
|
||||
battery modes off)."""
|
||||
yet), `"no_forecast"` (set but missing or stale), `"latched"` (OPT-34:
|
||||
the same, but a night charge under way is still going on to its level),
|
||||
or None (not set, or battery modes off)."""
|
||||
if not getattr(c, "modes_on", False) or \
|
||||
getattr(c, "fc_entity", None) is None:
|
||||
return None
|
||||
if getattr(c, "solar_fc", None) is None:
|
||||
return "no_forecast"
|
||||
return "latched" if _latched_below(c) else "no_forecast"
|
||||
hp = getattr(c, "house_profile", None)
|
||||
if hp is not None and not hp.ready():
|
||||
return "learning"
|
||||
@@ -560,11 +578,12 @@ def solar_block(c, now):
|
||||
out.update(fc_kwh=round(fc.today.kwh, 1),
|
||||
count_kwh=round(fc.today.kwh * trust, 1),
|
||||
updated=hhmm(fc.at))
|
||||
if st in ("no_forecast", "learning"):
|
||||
if st in ("no_forecast", "learning", "latched"):
|
||||
out["line"] = _t("solar." + st)
|
||||
elif fc is not None:
|
||||
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
|
||||
out["updated"])
|
||||
out["updated"],
|
||||
kept=getattr(fc, "kept", False))
|
||||
dp = getattr(c, "day_plan", None)
|
||||
if st == "plan" and dp is not None and dp.uncovered_kwh >= MIN_KWH:
|
||||
out["line"] += " " + line_uncovered(dp.uncovered_kwh)
|
||||
@@ -813,9 +832,11 @@ def refusing(c, inv) -> int:
|
||||
def peak_engaged(c) -> bool:
|
||||
"""The peak guard asks the batteries for power this cycle. Not when it is
|
||||
blind or floor-limited: those are engaged with a 0 W floor (no charging
|
||||
only), so no battery gives power for it."""
|
||||
only), so no battery gives power for it. Nor when the quarter ends on
|
||||
the aim anyway (SAFETY-126, `within_aim`)."""
|
||||
pg = getattr(c, "peak_guard", None)
|
||||
return bool(pg is not None and pg.engaged and (pg.floor_w or 0.0) > 0.0)
|
||||
return bool(pg is not None and pg.engaged and (pg.floor_w or 0.0) > 0.0
|
||||
and not pg.within_aim)
|
||||
|
||||
|
||||
def battery_rows(c, shadow, running, held=False):
|
||||
@@ -1058,7 +1079,8 @@ def month_peak(c, now):
|
||||
ceiling = pg.ceiling_w or limit_w
|
||||
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
|
||||
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
|
||||
> 0.0)
|
||||
> 0.0,
|
||||
cut=getattr(c, "room_charge", False))
|
||||
return out
|
||||
|
||||
|
||||
@@ -1441,7 +1463,11 @@ def day_note(key, **values) -> str:
|
||||
return _t("day." + key, **values)
|
||||
|
||||
|
||||
def line_solar(fc_kwh, count_kwh, at) -> str:
|
||||
def line_solar(fc_kwh, count_kwh, at, kept=False) -> str:
|
||||
"""The forecast line. OPT-27: a reading kept past three hours (the
|
||||
evening's, used at night) says so instead of "Updated"."""
|
||||
if kept:
|
||||
return _t("solar.kept", at=at)
|
||||
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
|
||||
|
||||
|
||||
@@ -1504,13 +1530,19 @@ def line_peak_not_covered() -> str:
|
||||
return _t("peak_not_covered")
|
||||
|
||||
|
||||
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
|
||||
def line_period(period_w, limit_w, shadow, unmeasured=False,
|
||||
cut=False) -> str:
|
||||
step = _w(shadow, "step_in", kw=_kw(limit_w))
|
||||
if unmeasured:
|
||||
# SAFETY-90: the add-on started during this period.
|
||||
return _t("period_unmeasured") + " " + step
|
||||
if period_w is None:
|
||||
return step
|
||||
if period_w > limit_w:
|
||||
# SAFETY-125: the figure is the current rate, and a grid charge is
|
||||
# what gets cut back.
|
||||
return _t("period_over", kw=_kw(period_w)) + " " + (
|
||||
_w(shadow, "charge_cut", kw=_kw(limit_w)) if cut else step)
|
||||
return _t("period", kw=_kw(period_w)) + " " + step
|
||||
|
||||
|
||||
@@ -1584,6 +1616,8 @@ 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)),
|
||||
@@ -1622,7 +1656,8 @@ def _samples() -> list:
|
||||
kwh="9.4 kWh")) for k in words["why"]]
|
||||
out += [("plan", today_words(k)) for k in words["today"]]
|
||||
out += [("plan", line_solar(9.2, 6.4, "10:15")),
|
||||
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning"))]
|
||||
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning")),
|
||||
("plan", _t("solar.latched"))]
|
||||
out += [("plan", day_note(k, pct=70, name="Battery A", at="08:15",
|
||||
kwh="9.2 kWh", count="6.4 kWh"))
|
||||
for k in words["day"]]
|
||||
|
||||
+230
-78
@@ -175,57 +175,17 @@ TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||
|
||||
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
"""TARIFF-10. Does this install need the background tariff refresh at all?
|
||||
"""Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24)
|
||||
|
||||
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
|
||||
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
|
||||
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||||
only caller of `price_history.carry()` - so on a shipped install with a
|
||||
provider configured NOTHING was ever fetched, no plan was ever resolved,
|
||||
`/data/price_history.json` was never created, the *Price history* row read
|
||||
`empty` forever, and TARIFF-09's short-horizon warning could not fire
|
||||
because there was no fetch to warn about. Measured over a 40 h soak of two
|
||||
instances side by side: zero feed requests and `present: false` across all
|
||||
802 samples on the shipped default, against a resolved plan from the same
|
||||
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
|
||||
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
|
||||
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
|
||||
it, and the sentence saying a configured provider still fetches, all in one
|
||||
change. config.yaml has said it since TARIFF-02 (852fc7c).
|
||||
|
||||
Two terms, and they answer two different questions:
|
||||
|
||||
provider_configured(opts) -> "the operator asked for a price feed", so
|
||||
fetch it, validate it and record the day
|
||||
(§8.3). This is the term TARIFF-10 ADDS.
|
||||
arbitrage.enabled(opts) -> "the operator armed trading". This term is
|
||||
what the gate USED to be, kept verbatim.
|
||||
|
||||
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
|
||||
that started this task before still starts it; none that did not start it
|
||||
stops anything. That matters because the second term is not redundant:
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
|
||||
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
|
||||
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
|
||||
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
|
||||
that site forever and `decide()` would answer "no tariff plan has been
|
||||
resolved yet" on every cycle - silently disarming a strategy the UI says is
|
||||
running, which is the exact failure validate.py's OPT-04 rules exist to
|
||||
refuse.
|
||||
|
||||
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
|
||||
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
|
||||
arithmetic, no socket - so the second term starts a task that fetches
|
||||
nothing. The true shipped default (no provider, no arbitrage) starts no
|
||||
task at all. What is fetched is decided by the FIRST term only.
|
||||
|
||||
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
|
||||
Whether they move energy is `arbitrage.enabled()` at the one call site in
|
||||
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
|
||||
means no trade, ever, on any configuration.
|
||||
Yes with a price feed, with trading on price armed, or with battery modes
|
||||
on: the modes read `self.tariff_plan` for their price runs. With
|
||||
`price_provider` off the refresh is pure arithmetic on `static_profile`, so
|
||||
no term here adds an HTTPS call. It decides whether prices are looked up,
|
||||
never whether energy moves. See DECISIONS/TARIFF-10-fetching-is-not-trading.md
|
||||
and DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
"""
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
return (provider_configured(opts) or arbitrage.enabled(opts)
|
||||
or packmode.enabled(opts))
|
||||
|
||||
|
||||
def load_options() -> dict:
|
||||
@@ -248,9 +208,44 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
|
||||
# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and
|
||||
# how often the forecast sensors are read (they change hourly at most).
|
||||
HOUSE_PROFILE_KEY = "house_profile"
|
||||
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
|
||||
# evening restart keeps the figure the night plan uses.
|
||||
FC_HOLD_KEY = "solar_fc_hold"
|
||||
# OPT-34: the level of a night grid charge that has started, so it does not
|
||||
# drop when the forecast goes (`Controller._latch_charge()`).
|
||||
CHARGE_LATCH_KEY = "night_charge_latch"
|
||||
# OPT-34: a latch outlives the cheap run it was last used in by one price
|
||||
# slot, because the next run is planned at the slot edge, in the cycle after
|
||||
# the old one ended.
|
||||
CHARGE_LATCH_GRACE_S = 900.0
|
||||
FORECAST_READ_S = 60.0
|
||||
|
||||
|
||||
def _load_latch(data, now=None):
|
||||
"""`(until, {name: level %}, born)` from the store, or None if it is not a
|
||||
well-formed latch or has ended (OPT-34)."""
|
||||
try:
|
||||
until = datetime.fromisoformat(data["until"])
|
||||
born = datetime.fromisoformat(data.get("born") or data["until"])
|
||||
levels = {str(n): float(v) for n, v in data["levels"].items()}
|
||||
except (TypeError, KeyError, ValueError, AttributeError):
|
||||
return None
|
||||
if until.tzinfo is None or born.tzinfo is None or not levels or not all(
|
||||
math.isfinite(v) and 0.0 <= v <= 100.0 for v in levels.values()):
|
||||
return None
|
||||
if now is not None and now >= until:
|
||||
return None
|
||||
return until, levels, born
|
||||
|
||||
|
||||
def _night_limit(born):
|
||||
"""OPT-34: a run that starts after this cannot carry the latch on: the
|
||||
first bell start (`solarfc.BELL_FROM_H`, local) after `born`."""
|
||||
d0, d1 = solarfc._bounds(born)
|
||||
bell = solarfc._bell_start(d0)
|
||||
return bell if bell > born else solarfc._bell_start(d1)
|
||||
|
||||
|
||||
def why_raised(resolution, claims) -> bool:
|
||||
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
|
||||
ABOVE what the winner asked (the peak floor asking for more power)? Then
|
||||
@@ -987,6 +982,9 @@ class Controller:
|
||||
# SAFETY-87: the reserve alarm's hold, as SAFETY-81's above.
|
||||
self._reserve_batt_prev = None
|
||||
self._reserve_high_since = None
|
||||
# SAFETY-126: `_peak_landing()`'s last battery reading and latch.
|
||||
self._land_batt_prev = None
|
||||
self._peak_needed = False
|
||||
self._maint_shape_suspended = False
|
||||
# OPT-11. Battery modes, off by default so an install that has not
|
||||
# turned them on behaves exactly as before (SAFETY-10 section 1). Each
|
||||
@@ -1001,6 +999,9 @@ class Controller:
|
||||
# and whether last tick filed a mode trade (a run edge reseeds).
|
||||
self.mode_runs = None
|
||||
self._runs_key = None
|
||||
# TARIFF-24: the end of the run that was under way while a forecast
|
||||
# was read; until then losing the forecast does not widen the gap.
|
||||
self._wx_free_to = None
|
||||
self.mode_tick = None
|
||||
# UX-53: why the trade gate refused a grid charge this tick (`peak`,
|
||||
# `not_worth`, `not_covered`), or None when it allowed one. Home reads
|
||||
@@ -1032,8 +1033,14 @@ class Controller:
|
||||
self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None
|
||||
else solarfc.MISSING)
|
||||
self._fc_read_mono = None
|
||||
self.fc_hold = solarfc.Hold(
|
||||
(getattr(store, "data", None) or {}).get(FC_HOLD_KEY))
|
||||
self.day_plan = None
|
||||
self._plan_targets = None
|
||||
# OPT-34: `(until, {name: level %})` of a night charge under way.
|
||||
self._charge_latch = _load_latch(
|
||||
(getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY),
|
||||
datetime.now(timezone.utc))
|
||||
self.house_profile = None
|
||||
if self.fc_entity is not None:
|
||||
self.house_profile = dayplan.HouseProfile(
|
||||
@@ -1259,6 +1266,8 @@ class Controller:
|
||||
# because `self.peak_guard` is a different cycle's decision by the time
|
||||
# the status page renders.
|
||||
self.arb_headroom_w = None
|
||||
# SAFETY-125: a grid charge sized to the quarter's room this cycle.
|
||||
self.room_charge = False
|
||||
# UX-13 P1: today's prices as the Home chart has seen them. Display
|
||||
# only; nothing reads it back into a decision.
|
||||
self.home_prices = homeview.DayPrices()
|
||||
@@ -2274,6 +2283,32 @@ class Controller:
|
||||
probing=probing))
|
||||
return out
|
||||
|
||||
def _wx_track(self, now) -> None:
|
||||
"""TARIFF-24, once per cycle, before the plan: with a forecast read,
|
||||
remember when the run now under way (cheap, give or sell) ends."""
|
||||
if self.solar_fc is None:
|
||||
return
|
||||
rp = self.mode_runs
|
||||
ends = []
|
||||
if rp is not None and rp.on:
|
||||
ends = [r.end for r in (rp.cheap, rp.sell)
|
||||
if r is not None and r.holds(now)]
|
||||
g = rp.give_at(now)
|
||||
if g is not None:
|
||||
ends.append(g.end)
|
||||
self._wx_free_to = max(ends) if ends else None
|
||||
|
||||
def weather_held(self, now) -> bool:
|
||||
"""TARIFF-24: no forecast now, but a run planned with one is under
|
||||
way: it keeps its gap to its end. The wider gap applies from the next
|
||||
run. OPT-34: a night charge that keeps its level (`_latch_charge()`)
|
||||
keeps the gap too, run after run, or the next slice of it would be
|
||||
refused. Pure; Home asks the same (`homeview.trade_verdict()`)."""
|
||||
return self.solar_fc is None and (
|
||||
(self._wx_free_to is not None and now < self._wx_free_to)
|
||||
or (self._charge_latch is not None
|
||||
and now < self._charge_latch[0]))
|
||||
|
||||
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
|
||||
"""The kept run plan, rebuilt at most once per price slot (15 min) or
|
||||
when the prices or a battery's choice change; a run under way is
|
||||
@@ -2287,9 +2322,11 @@ class Controller:
|
||||
slot = int(now.timestamp() // 900)
|
||||
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
|
||||
for p in packs))
|
||||
held = self.weather_held(now)
|
||||
if self.fc_entity is not None:
|
||||
# P4: a forecast arriving or going rebuilds the plan at once.
|
||||
key += (self.solar_fc is None,)
|
||||
# P4: a forecast arriving or going rebuilds the plan at once (and
|
||||
# so does the end of a run that held the gap, TARIFF-24).
|
||||
key += (self.solar_fc is None, held)
|
||||
if key != self._runs_key:
|
||||
done_c = self._run_done.get("charge", (None, set()))[1]
|
||||
done_s = self._run_done.get("sell", (None, set()))[1]
|
||||
@@ -2298,7 +2335,7 @@ class Controller:
|
||||
required = price_required_spread(
|
||||
arbitrage.threshold_eur_kwh(self.o),
|
||||
price_uncertainty_of(plan.read.product)
|
||||
| {Uncertainty.NO_WEATHER})
|
||||
| arbitrage.weather_uncertainty(True, self.solar_fc, held))
|
||||
ref = self._ref_cheap
|
||||
if ref is not None and now - ref.end > REF_CHEAP_KEEP:
|
||||
ref = self._ref_cheap = None
|
||||
@@ -2355,11 +2392,73 @@ class Controller:
|
||||
|
||||
def plan_targets(self):
|
||||
"""P4: each battery's planned level, or None (no day plan). Dropped
|
||||
at once when the forecast goes (stale or missing)."""
|
||||
at once when the forecast goes (stale or missing), except the level
|
||||
of a night charge that has started (OPT-34): it keeps that level."""
|
||||
if self.solar_fc is None:
|
||||
return None
|
||||
lt = self._charge_latch
|
||||
if lt is None or datetime.now(timezone.utc) >= lt[0]:
|
||||
return None
|
||||
return dict(lt[1])
|
||||
return self._plan_targets
|
||||
|
||||
def _latch_charge(self, now, runs, targets, packs=None) -> None:
|
||||
"""OPT-34, once per cycle after `modetrade.decide()`. A night grid
|
||||
charge that has started keeps its planned level when the forecast goes
|
||||
mid-charge (the kept figure ends and the sensors are still silent).
|
||||
|
||||
While there is a forecast, fresh or kept, the latch is rebuilt each
|
||||
cycle from the plan: a battery that charges from the grid in the cheap
|
||||
run comes in with its planned level, and stays in it at whatever level
|
||||
the plan gives it, up or down as a fresh figure moves it (also when it
|
||||
has stopped, at a lowered level), until it leaves the plan. While there
|
||||
is none, `plan_targets()` gives the latched levels, so the charge goes
|
||||
on to them, and `weather_held()` keeps the narrow price gap. The latch
|
||||
lasts through every cheap run on and one price slot after the last
|
||||
(`CHARGE_LATCH_GRACE_S`), but a run is carried on only if it starts
|
||||
by the bell start of the night the latch began in (`_night_limit()`;
|
||||
the run under way then finishes). It is dropped when `now` passes its
|
||||
end whatever the run state, and a battery that is off or no longer in
|
||||
a mode with a planned level (`packs`) leaves it. A charge that never
|
||||
started has none."""
|
||||
old = lt = self._charge_latch
|
||||
tick = self.mode_tick
|
||||
run = None if runs is None else runs.cheap
|
||||
in_run = run is not None and run.holds(now)
|
||||
grace = timedelta(seconds=CHARGE_LATCH_GRACE_S)
|
||||
if lt is not None and now >= lt[0]:
|
||||
lt = None
|
||||
if lt is not None and packs is not None:
|
||||
ok = {p.name for p in packs
|
||||
if p.use and p.mode in packmode.PLAN_CHARGE_MODES}
|
||||
lv = {n: v for n, v in lt[1].items() if n in ok}
|
||||
lt = (lt[0], lv, lt[2]) if lv else None
|
||||
# A run may carry the latch on only if it starts by the night's bell.
|
||||
carry = in_run and (lt is None or run.start <= _night_limit(lt[2]))
|
||||
if in_run and self.solar_fc is not None:
|
||||
# Rebuilt from the plan: a battery already latched follows its
|
||||
# level up or down whatever its role this tick (a fresh figure may
|
||||
# lower it below the SoC), one that left the plan is dropped, and
|
||||
# a new one comes in only while it charges from the grid.
|
||||
tg = targets or {}
|
||||
lv = {n: float(tg[n]) for n in (lt[1] if lt else {}) if n in tg}
|
||||
if tick is not None and tick.action == modetrade.CHARGE:
|
||||
lv.update({n: float(tg[n]) for n, r in tick.roles.items()
|
||||
if r == modetrade.CHARGE and n in tg})
|
||||
if not lv:
|
||||
lt = None
|
||||
elif lt is None:
|
||||
lt = (run.end + grace, lv, now)
|
||||
else:
|
||||
lt = (max(run.end + grace, lt[0]) if carry else lt[0], lv,
|
||||
lt[2])
|
||||
elif lt is not None and carry:
|
||||
lt = (max(lt[0], run.end + grace), lt[1], lt[2])
|
||||
if lt != old:
|
||||
self._charge_latch = lt
|
||||
self.store.set(CHARGE_LATCH_KEY, None if lt is None else {
|
||||
"until": lt[0].isoformat(), "levels": lt[1],
|
||||
"born": lt[2].isoformat()})
|
||||
|
||||
def _mode_roles(self, now, maint_results):
|
||||
"""OPT-11 P3: each battery's role and range this tick. Grid charging
|
||||
and selling need a fresh plan that `TariffPlan.arbitrage()` allows
|
||||
@@ -2367,6 +2466,7 @@ class Controller:
|
||||
quarter), exactly the gate `arbitrage.decide()` has."""
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
packs = self._mode_packs(maint_results)
|
||||
self._wx_track(now)
|
||||
runs = self._price_runs(now, packs, floor_pct, ceiling_pct)
|
||||
guard = arbitrage.guard_state(self.peak_guard)
|
||||
plan = self.fresh_tariff_plan(now)
|
||||
@@ -2377,7 +2477,8 @@ class Controller:
|
||||
return plan.arbitrage(
|
||||
peak_guard_active=active,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
uncertainty=arbitrage.weather_uncertainty(
|
||||
True, self.solar_fc, self.weather_held(now)),
|
||||
now=now).match(
|
||||
allowed=lambda _v: True, refused=lambda _v: False)
|
||||
if guard is not None and plan is not None and runs is not None:
|
||||
@@ -2430,6 +2531,7 @@ class Controller:
|
||||
blocked=self._charge_blocked_q == quarter,
|
||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()))
|
||||
self._latch_charge(now, runs, targets, packs)
|
||||
# A charge the guard or the headroom stopped stays off this period.
|
||||
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
||||
# (None) in a quarter stops this tick only, so a gap cannot cost the
|
||||
@@ -2993,6 +3095,27 @@ class Controller:
|
||||
soc=min(float(p.soc) for p in able),
|
||||
soc_floor=peakguard.binding_soc_floor(able, floor))
|
||||
|
||||
def _peak_landing(self, decision):
|
||||
"""SAFETY-126: `decision` marked `within_aim` while the guard wants
|
||||
power but the batteries at 0 W would end the quarter within
|
||||
`ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). Once a real miss
|
||||
is seen, the need stands until the guard lets go. The claim and the
|
||||
commands are unchanged; only the alarms and the "Why" read it.
|
||||
DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md."""
|
||||
batt, prev = self.site_batt_w, self._land_batt_prev
|
||||
self._land_batt_prev = batt
|
||||
if not (decision.engaged and decision.blind is None
|
||||
and (decision.wanted_w or 0.0) > 0.0):
|
||||
self._peak_needed = False
|
||||
return decision
|
||||
if batt is not None and prev is not None:
|
||||
batt = min(float(batt), float(prev))
|
||||
self._peak_needed = self._peak_needed or (
|
||||
self._peak_proj is not None and peakguard.misses_aim(
|
||||
self._peak_proj, batt_w=batt, target_w=decision.target_w))
|
||||
return (decision if self._peak_needed
|
||||
else dataclasses.replace(decision, within_aim=True))
|
||||
|
||||
def _peak_reserve(self) -> frozenset:
|
||||
"""SAFETY-87: the batteries that give nothing for the peak this
|
||||
cycle: under their reserve while the others carry a peak floor."""
|
||||
@@ -3250,17 +3373,19 @@ class Controller:
|
||||
return floor
|
||||
|
||||
def _charge_headroom(self, floor: float, claims) -> float:
|
||||
"""SAFETY-84: at N>=2 a cheap-window charge pulls no more than the
|
||||
quarter's headroom under the peak aim (`charge_headroom_w()`), so it
|
||||
yields just enough to the peak guard, and nothing under a site stop
|
||||
(its 0 W would need the other pack to give). N=1 is unchanged."""
|
||||
if len(self.inverters) < 2:
|
||||
return floor
|
||||
if any(c.kind == SET and c.actuator == ACTUATOR_SITE
|
||||
and c.priority > P_MAINT_SHAPE for c in claims):
|
||||
"""SAFETY-84: a cheap-window charge pulls no more than the quarter's
|
||||
room under the peak aim (`charge_room_w()`), so it yields just enough
|
||||
to the peak guard; at N>=2 nothing under a site stop (its 0 W would
|
||||
need the other pack to give). SAFETY-125: at N=1 too, owner
|
||||
2026-10-29; the stop rule stays N>=2 only."""
|
||||
if len(self.inverters) > 1 and any(
|
||||
c.kind == SET and c.actuator == ACTUATOR_SITE
|
||||
and c.priority > P_MAINT_SHAPE for c in claims):
|
||||
return 0.0
|
||||
room = peakguard.charge_headroom_w(self.peak_guard)
|
||||
return floor if room is None else min(floor, room)
|
||||
room = peakguard.charge_room_w(self.peak_guard, self._peak_proj)
|
||||
floor = floor if room is None else min(floor, room)
|
||||
self.room_charge = self.room_charge or floor > 0.0
|
||||
return floor
|
||||
|
||||
def _charge_ceiling(self, claims):
|
||||
"""SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None.
|
||||
@@ -3417,17 +3542,25 @@ class Controller:
|
||||
now = datetime.now(timezone.utc)
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
list(zip(self.fc_entities, states[:n])),
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now)
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now,
|
||||
hold=self.fc_hold)
|
||||
if self.fc_hold.changed:
|
||||
self.fc_hold.changed = False
|
||||
self.store.set(FC_HOLD_KEY, self.fc_hold.dump())
|
||||
# A member is named only in a sum of several; one entity reads as
|
||||
# before. Logged once per episode: on a change, never every read.
|
||||
gaps = tuple(g for g in gaps if len(
|
||||
self.fc_entities if g[0] == "today"
|
||||
else self.fc_tomorrow_entities) > 1)
|
||||
was_kept = bool(self.solar_fc is not None and self.solar_fc.kept)
|
||||
if (fc is None) != (self.solar_fc is None) \
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps:
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps \
|
||||
or bool(fc is not None and fc.kept) != was_kept:
|
||||
self.log_event(
|
||||
"solar forecast: " + ("read" if fc is not None else
|
||||
f"none ({why})")
|
||||
"solar forecast: " + (
|
||||
"none (" + str(why) + ")" if fc is None else
|
||||
f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept
|
||||
else "read")
|
||||
+ "".join(f", {d} {e} not usable ({w})"
|
||||
for d, _i, e, w in gaps),
|
||||
hint=messages.hint(
|
||||
@@ -4839,6 +4972,7 @@ class Controller:
|
||||
if self.peak_guard is not None:
|
||||
self.peak_guard = self._peak_on_able(self.peak_guard,
|
||||
maint_results)
|
||||
self.peak_guard = self._peak_landing(self.peak_guard)
|
||||
# SITE, and peakguard.claim() says so on the object it builds. The
|
||||
# quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION,
|
||||
# which is what the capacity tariff bills; there is one quarter-hour
|
||||
@@ -4863,6 +4997,7 @@ class Controller:
|
||||
self.log_event(_pg.explain(), hint=messages.hint(
|
||||
"peak_off" if not _pg.engaged else
|
||||
"peak_blind" if _pg.blind else
|
||||
"peak_on" if _pg.within_aim else
|
||||
"peak_reserve" if _pg.floor_limited else
|
||||
"peak_saturated" if _pg.saturated else "peak_on"))
|
||||
|
||||
@@ -4910,6 +5045,7 @@ class Controller:
|
||||
# decision presented as current.
|
||||
self.arb, self.arb_at = None, now
|
||||
self.arb_headroom_w = None
|
||||
self.room_charge = False
|
||||
# OPT-11 P3: with battery modes on, each battery's mode decides who
|
||||
# trades (`_mode_roles()`, `_mode_trade()`, below, which read the
|
||||
# same headroom), and this site-wide trade is not run.
|
||||
@@ -4920,7 +5056,9 @@ class Controller:
|
||||
# `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the
|
||||
# caveat on the recorded watts can never be answered from a
|
||||
# different cycle's guard than the watts were.
|
||||
self.arb_headroom_w = peakguard.charge_headroom_w(self.peak_guard)
|
||||
# SAFETY-125: a late meter reading's seconds count, as the bill.
|
||||
self.arb_headroom_w = peakguard.charge_room_w(self.peak_guard,
|
||||
self._peak_proj)
|
||||
if site_trade:
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
|
||||
@@ -4975,6 +5113,7 @@ class Controller:
|
||||
arb_claim = arbitrage.claim(self.arb)
|
||||
if arb_claim is not None:
|
||||
claims.append(arb_claim)
|
||||
self.room_charge = self.arb.action == arbitrage.CHARGE
|
||||
# Edge-logged, for the peak guard's reason: the action log holds 40
|
||||
# lines and a trade lasts a whole hour, so a line per cycle would
|
||||
# flush the context that explains it. The transition is what an
|
||||
@@ -5194,6 +5333,8 @@ class Controller:
|
||||
mode_trade, trade_was = False, self._mode_trade_was
|
||||
if self.modes_on:
|
||||
mode_trade = self._mode_trade(claims, now)
|
||||
self.room_charge = self.room_charge or (
|
||||
mode_trade and self.arb.action == arbitrage.CHARGE)
|
||||
self._mode_trade_was = mode_trade
|
||||
self._charge_track(claims, maint_results)
|
||||
_live96 = set(self.live_units())
|
||||
@@ -6154,6 +6295,9 @@ class Controller:
|
||||
limited = self._reserve_short(g)
|
||||
else:
|
||||
self._reserve_batt_prev = self._reserve_high_since = None
|
||||
# SAFETY-126: nothing can give, so it alarms at once, but only
|
||||
# when the quarter really misses the aim (`_peak_landing()`).
|
||||
limited = limited and not g.within_aim
|
||||
was, self._peak_floor_limited = self._peak_floor_limited, limited
|
||||
if not limited or was:
|
||||
return
|
||||
@@ -7031,12 +7175,13 @@ class Controller:
|
||||
if _pg is None:
|
||||
out.append({"ok": True, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_waiting()})
|
||||
elif _pg.saturated:
|
||||
elif _pg.saturated and not _pg.within_aim:
|
||||
out.append({"ok": False, "warn": False, "id": "peak_guard",
|
||||
"runs": True,
|
||||
"text": messages.row_peak_saturated(_pg.ceiling_w)})
|
||||
elif _pg.floor_limited and (not (_pg.floor_w or 0.0) > 0.0
|
||||
or self._peak_floor_limited):
|
||||
elif _pg.floor_limited and (
|
||||
not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim)
|
||||
or self._peak_floor_limited):
|
||||
# SAFETY-24. NOT ok, like saturation: in both states the money
|
||||
# is going to be spent and a human is the only actuator left. A
|
||||
# separate row from saturation because the two ask for opposite
|
||||
@@ -7087,6 +7232,15 @@ class Controller:
|
||||
"text": messages.row_peak_guard_on(
|
||||
_pg.floor_w or 0.0, _pg.ceiling_w,
|
||||
shadow=self.shadow)})
|
||||
elif _pg.projected_w is not None:
|
||||
# SAFETY-125: "under" only when it is; over the limit at this
|
||||
# rate, a grid charge is cut back to the quarter's room.
|
||||
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_idle(
|
||||
_pg.projected_w, _pg.ceiling_w,
|
||||
month_peak=(_pg.ceiling_from
|
||||
== peakguard.CEILING_MONTH_PEAK),
|
||||
cut=self.room_charge and not self.shadow)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||
"text": _pg.explain()})
|
||||
@@ -8080,11 +8234,9 @@ async def amain() -> None:
|
||||
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
|
||||
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
|
||||
#
|
||||
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
|
||||
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
|
||||
# enabled` ships `false`, so a site that had selected a price feed
|
||||
# fetched nothing at all; the whole argument, the measurement and what
|
||||
# the `or` is protecting are in `tariff_refresh_wanted()` above.
|
||||
# ⚠️ STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES:
|
||||
# see `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and
|
||||
# TARIFF-24 for why each term is there.
|
||||
#
|
||||
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
|
||||
# two decisions; this is the first, and `cycle()`'s
|
||||
|
||||
@@ -1170,8 +1170,18 @@ def row_periods_measured(n) -> str:
|
||||
f"Gemeten kwartieren deze maand: {getoond}")
|
||||
|
||||
|
||||
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool) -> str:
|
||||
"""The projection first and the limit second (SAFETY-38's ordered pair)."""
|
||||
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool,
|
||||
cut: bool = False) -> str:
|
||||
"""The projection first and the limit second (SAFETY-38's ordered pair).
|
||||
SAFETY-125: over the limit at this rate it says so, and `cut` that a
|
||||
grid charge is limited to the room left."""
|
||||
if float(projected_w) > float(limit_w):
|
||||
doing, doet = (("grid charge limited", "laden beperkt") if cut
|
||||
else ("idle", "in rust"))
|
||||
return Bi(f"Peak guard: {doing}, at this rate {watts(projected_w)}, "
|
||||
f"over {watts(limit_w)}",
|
||||
f"Piekbewaking: {doet}, aan dit tempo "
|
||||
f"{watts(projected_w)}, boven {watts(limit_w)}")
|
||||
limit = (f"this month's {watts(limit_w)} peak" if month_peak
|
||||
else f"the {watts(limit_w)} peak limit")
|
||||
grens = (f"de maandpiek van {watts(limit_w)}" if month_peak
|
||||
@@ -1563,6 +1573,11 @@ def status_samples() -> list:
|
||||
("row_periods_measured", GREEN, row_periods_measured("unreadable")),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(5814, 5413, True)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(5814, 5413, True,
|
||||
cut=True)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(3814, 3500, False,
|
||||
cut=True)),
|
||||
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
|
||||
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
|
||||
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
|
||||
|
||||
@@ -504,6 +504,11 @@ class PeakDecision:
|
||||
# when they are not asked). See `place_on_able()`.
|
||||
held: tuple = ()
|
||||
able_w: float | None = None
|
||||
# SAFETY-126: the guard wants power, but with the batteries at 0 W the
|
||||
# quarter still ends within ALARM_MISS_W of the aim (`misses_aim()`), as
|
||||
# for a landing error in its last second. Set by main.py. No alarm and no
|
||||
# "Why" peak while it holds; the floor and the claim are unchanged.
|
||||
within_aim: bool = False
|
||||
|
||||
def explain(self) -> str:
|
||||
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
|
||||
@@ -997,6 +1002,18 @@ def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool:
|
||||
return ends_w - float(ceiling_w) > ALARM_MISS_W
|
||||
|
||||
|
||||
def misses_aim(projection, *, batt_w, target_w) -> bool:
|
||||
"""SAFETY-126. With the batteries at 0 W from now on, does the quarter
|
||||
end more than `ALARM_MISS_W` over the aim (`target_w`)? `decide()`'s
|
||||
`wanted > 0` asks the same question with no margin and with its gain, so a
|
||||
1 W landing error in a quarter's last second reads as tens of kW. A grid
|
||||
charge the add-on runs counts as stoppable: it is in `batt_w`. The caller
|
||||
passes the lower of two cycles' battery readings, as for
|
||||
`load_too_high()`. DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md."""
|
||||
return load_too_high(projection, batt_w=batt_w, ceiling_w=target_w,
|
||||
achievable_w=0.0)
|
||||
|
||||
|
||||
def charge_headroom_w(decision) -> float | None:
|
||||
"""SAFETY-45. The hardest this SITE may charge without putting the billed
|
||||
quarter over the ceiling, in W, positive - or None when nothing measured it.
|
||||
@@ -1136,6 +1153,21 @@ def charge_headroom_w(decision) -> float | None:
|
||||
return max(0.0, -float(decision.wanted_w))
|
||||
|
||||
|
||||
def charge_room_w(decision, projection) -> float | None:
|
||||
"""SAFETY-125: `charge_headroom_w()` with the seconds since the last
|
||||
meter reading counted at that reading, so a late reading near a
|
||||
quarter's end is not read as room for a grid charge (SAFETY-99).
|
||||
DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md."""
|
||||
room = charge_headroom_w(decision)
|
||||
if not room or projection is None or projection.block_start is None:
|
||||
return room
|
||||
left = QUARTER_S - float(projection.elapsed_s)
|
||||
if left <= 0.0:
|
||||
return room
|
||||
late_ws = max(0.0, float(projection.held_w)) * uncovered_s(projection)
|
||||
return max(0.0, room - late_ws / left)
|
||||
|
||||
|
||||
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
"""Must the maintenance charge-only shaping stand down this cycle?
|
||||
|
||||
|
||||
@@ -28,6 +28,20 @@ A reading older than `FRESH_S`, or from before local midnight, is stale,
|
||||
and a stale or missing forecast is no forecast: the day plan is off and
|
||||
every mode acts as in P3 option A.
|
||||
|
||||
OPT-27: but the last fresh reading of each sensor is kept (`Hold`), so the
|
||||
night plan is not made blind. Forecast.Solar's sensors stop changing in the
|
||||
evening and api.forecast.solar is sometimes out for a poll. A kept reading
|
||||
stands in for its sensor (a) for up to `FRESH_S` when the sensor is missing
|
||||
or unavailable, as a sensor that merely stopped updating is already
|
||||
tolerated, and (b) from the end of the bell (`BELL_TO_H`) until local
|
||||
midnight, if it is at most `KEPT_MAX_AGE_S` old when used.
|
||||
Never beyond that, and never for another day: a today figure is only today's,
|
||||
a tomorrow figure is tomorrow's until midnight and then, until the bell starts
|
||||
(`BELL_FROM_H`, 06:00 local), today's (OPT-34: at most `KEPT_MORNING_MAX_AGE_S`
|
||||
old, and it must have been usable at midnight).
|
||||
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md,
|
||||
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
|
||||
|
||||
OPT-17: each day may instead name a list (`pv_forecast_entities`,
|
||||
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
|
||||
not usable makes that day's sum unusable, never a partial sum.
|
||||
@@ -50,6 +64,19 @@ FRESH_S = 3 * 3600.0
|
||||
# The bell a state-only forecast is spread over, in local hours.
|
||||
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
||||
|
||||
# OPT-27: a kept reading serves the night plan from the end of the bell until
|
||||
# local midnight, and a reading older than this when used is not kept (a
|
||||
# morning figure must not drive the night).
|
||||
KEPT_MAX_AGE_S = 12 * 3600.0
|
||||
# OPT-34: the evening's "tomorrow" figure, once midnight has passed, is today's
|
||||
# until the bell starts (`BELL_FROM_H`): nothing has been produced yet, so it
|
||||
# is still the whole day. A 17:05 reading is 12 h 55 min old at 06:00, so this
|
||||
# case may be older than `KEPT_MAX_AGE_S`, up to this, but only a figure that
|
||||
# was usable at midnight (at most `KEPT_MAX_AGE_S` old then), so one that has
|
||||
# not been used all evening does not come back at 00:00.
|
||||
KEPT_MORNING_MAX_AGE_S = 15 * 3600.0
|
||||
SAVE_EVERY_S = 900.0
|
||||
|
||||
# Why there is no forecast (the page words are in ui_text `home_words`).
|
||||
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
||||
"not_set", "missing", "unit", "stale", "bad")
|
||||
@@ -75,6 +102,10 @@ class Forecast:
|
||||
today: Day
|
||||
tomorrow: Day | None
|
||||
at: datetime # when the sensor last reported
|
||||
# OPT-27: a reading kept past `FRESH_S` is in it (the evening's figure at
|
||||
# night). The screens say so. Last, so `Forecast(today, tomorrow, at)` is
|
||||
# as before.
|
||||
kept: bool = False
|
||||
|
||||
def between(self, t0: datetime, t1: datetime,
|
||||
since: datetime | None = None) -> float | None:
|
||||
@@ -246,6 +277,112 @@ def _reported(state):
|
||||
return max(ts) if ts else None
|
||||
|
||||
|
||||
class Hold:
|
||||
"""OPT-27: the last fresh reading of each forecast sensor, per day it is
|
||||
for. `read_sum()` remembers every fresh reading here and, when a sensor
|
||||
has none now, asks it (`stand_in()`).
|
||||
|
||||
Keyed `(kind, entity, day0)`: `kind` is "today" (energy still to come on
|
||||
`day0`) or "tomorrow" (all of `day0`), and `day0` is the day the figure is
|
||||
FOR. A today figure therefore never answers for another day, and a
|
||||
tomorrow figure answers for its own day only: tomorrow until midnight,
|
||||
then today until the bell starts (OPT-34). Only the newest reading of a
|
||||
key is kept, so the next fresh reading ends the hold at once.
|
||||
|
||||
`dump()` / `load()` are what the controller keeps in its store, so an
|
||||
evening restart does not lose it (`load()` drops what is bad).
|
||||
`changed` is set when `dump()` would differ."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self._r = {}
|
||||
self._saved = {} # key -> the `at` last flagged for saving
|
||||
self.changed = False
|
||||
if isinstance(data, dict):
|
||||
self.load(data)
|
||||
|
||||
def remember(self, kind, entity, day: "Day", at: datetime) -> None:
|
||||
key = (kind, entity, day.day0)
|
||||
old = self._r.get(key)
|
||||
if old is not None and at <= old[1]:
|
||||
return
|
||||
self._r[key] = (day, at)
|
||||
# A sensor that reports every minute with the same figure must not
|
||||
# rewrite the store every minute: the saved age may lag by
|
||||
# `SAVE_EVERY_S` (the bounds are hours).
|
||||
mark = self._saved.get(key)
|
||||
if old is None or old[0].kwh != day.kwh or mark is None \
|
||||
or (at - mark).total_seconds() >= SAVE_EVERY_S:
|
||||
self._saved[key] = at
|
||||
self.changed = True
|
||||
|
||||
def forget_before(self, day0: datetime) -> None:
|
||||
"""Drop what is for a day before `day0` (the local midnight now)."""
|
||||
gone = [k for k in self._r if k[2] < day0]
|
||||
for k in gone:
|
||||
del self._r[k]
|
||||
self._saved.pop(k, None)
|
||||
if gone:
|
||||
self.changed = True
|
||||
|
||||
def stand_in(self, kind, entity, day0, now, *, as_today=False):
|
||||
"""`(Day, at, kept)` for a sensor that has no fresh reading, or None.
|
||||
|
||||
The kept reading must be for `day0`, and either (a) at most `FRESH_S`
|
||||
old, or (b) used at night: from the end of the bell to local midnight,
|
||||
at most `KEPT_MAX_AGE_S` old. `kept` is true when (b) was needed,
|
||||
or `as_today` (OPT-34: a tomorrow figure standing in for today, after
|
||||
midnight and until the bell starts, `KEPT_MORNING_MAX_AGE_S`)."""
|
||||
rec = self._r.get((kind, entity, day0))
|
||||
if rec is None:
|
||||
return None
|
||||
day, at = rec
|
||||
age = (now - at).total_seconds()
|
||||
if age <= FRESH_S and not as_today:
|
||||
return day, at, False
|
||||
if as_today:
|
||||
ok = (day0 <= now < _bell_start(day0)
|
||||
and age <= KEPT_MORNING_MAX_AGE_S
|
||||
and (day0 - at).total_seconds() <= KEPT_MAX_AGE_S)
|
||||
return (day, at, True) if ok else None
|
||||
if age > KEPT_MAX_AGE_S:
|
||||
return None
|
||||
# A tomorrow sensor is asked with day0 = the next midnight, which is
|
||||
# in the future all day, so the evening ends at that day's end.
|
||||
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
|
||||
return (day, at, True) if evening else None
|
||||
|
||||
def dump(self) -> dict:
|
||||
return {"v": 1, "r": [
|
||||
[kind, ent, d.day0.isoformat(), d.kwh, at.isoformat(),
|
||||
[[s.isoformat(), e.isoformat(), k] for s, e, k in d.periods]]
|
||||
for (kind, ent, _d0), (d, at) in sorted(
|
||||
self._r.items(), key=lambda kv: (kv[0][0], kv[0][1],
|
||||
kv[0][2]))]}
|
||||
|
||||
def load(self, data: dict) -> None:
|
||||
rows = data.get("r") if isinstance(data, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return
|
||||
for row in rows:
|
||||
try:
|
||||
kind, ent, d0, kwh, at, per = row
|
||||
day0, at = _parse_time(d0), _parse_time(at)
|
||||
kwh = float(kwh)
|
||||
periods = tuple((_parse_time(s), _parse_time(e), float(k))
|
||||
for s, e, k in per)
|
||||
ok = (kind in ("today", "tomorrow") and isinstance(ent, str)
|
||||
and day0 is not None and at is not None
|
||||
and math.isfinite(kwh) and kwh >= 0
|
||||
and all(s is not None and e is not None and e > s
|
||||
and math.isfinite(k) and k >= 0
|
||||
for s, e, k in periods))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if ok:
|
||||
self._r[(kind, ent, day0)] = (Day(day0, kwh, periods), at)
|
||||
self._saved[(kind, ent, day0)] = at
|
||||
|
||||
|
||||
def entities(opts: dict, *, tomorrow=False) -> tuple:
|
||||
"""The forecast entities one day names, in configured order: the list
|
||||
if it has any entry, else the single key, else (). A repeated id is
|
||||
@@ -267,6 +404,15 @@ def _bounds(now):
|
||||
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _bell_start(day0):
|
||||
"""The moment the bell starts on the local day that begins at `day0`
|
||||
(`BELL_FROM_H` local, so 06:00 on a 23 h or 25 h day too)."""
|
||||
here = day0.astimezone(BILLING_TZ)
|
||||
t = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ) \
|
||||
+ timedelta(hours=BELL_FROM_H)
|
||||
return t.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _today(state, day0, now):
|
||||
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
|
||||
today, why = _day(state, day0, now, today=True)
|
||||
@@ -282,14 +428,14 @@ def _today(state, day0, now):
|
||||
|
||||
|
||||
def _tomorrow(state, day0, day1, now):
|
||||
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
|
||||
"""`(Day, at, None)` or `(None, None, why)` for one tomorrow sensor."""
|
||||
t, why = _day(state, day1, now, today=False)
|
||||
if t is None:
|
||||
return None, why
|
||||
return None, None, why
|
||||
t_at = _reported(state)
|
||||
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
|
||||
return None, STALE
|
||||
return t, None
|
||||
return None, None, STALE
|
||||
return t, t_at, None
|
||||
|
||||
|
||||
def read(today_state, tomorrow_state, now: datetime, *,
|
||||
@@ -305,7 +451,7 @@ def read(today_state, tomorrow_state, now: datetime, *,
|
||||
return None, why
|
||||
tomorrow = None
|
||||
if tomorrow_state is not None:
|
||||
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
tomorrow, _at, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
return Forecast(today, tomorrow, at), None
|
||||
|
||||
|
||||
@@ -321,7 +467,12 @@ def _sum(days):
|
||||
return Day(days[0].day0, sum(d.kwh for d in days), periods)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime):
|
||||
# Why a sensor may be covered by a kept reading: it has none now, or it is
|
||||
# too old. A bad value or a wrong unit is a set-up fault and is never covered.
|
||||
_COVERABLE = (MISSING, STALE)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
|
||||
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
|
||||
`(entity, state)`, one per configured member, in configured order.
|
||||
|
||||
@@ -334,29 +485,67 @@ def read_sum(today, tomorrow, now: datetime):
|
||||
`gaps` is `((day, position, entity, why), ...)` for every unusable
|
||||
member (`day` "today" or "tomorrow", `position` 1-based), so the page
|
||||
and the log can name it. With one member per day the forecast is what
|
||||
`read()` gives (test_opt17.py proves it)."""
|
||||
`read()` gives (test_opt17.py proves it).
|
||||
|
||||
OPT-27: with a `hold`, every fresh reading is remembered in it, and a
|
||||
member that is missing or stale is covered by its last fresh reading
|
||||
where `Hold.stand_in()` allows it (the forecast then has `kept` set when
|
||||
that took a reading older than `FRESH_S`). After midnight, if the today
|
||||
side is still short, the tomorrow figures read before midnight are
|
||||
today's, until the bell starts (`stand_in()`). Without a `hold` this is
|
||||
exactly as before."""
|
||||
if not today:
|
||||
return None, NOT_SET, ()
|
||||
day0, day1 = _bounds(now)
|
||||
gaps, days, ats, why0 = [], [], [], None
|
||||
if hold is not None:
|
||||
hold.forget_before(day0)
|
||||
gaps, days, ats, why0, kept = [], [], [], None, False
|
||||
for i, (ent, st) in enumerate(today, 1):
|
||||
d, at, why = _today(st, day0, now)
|
||||
if d is not None and hold is not None:
|
||||
hold.remember("today", ent, d, at)
|
||||
elif hold is not None and why in _COVERABLE:
|
||||
r = hold.stand_in("today", ent, day0, now)
|
||||
if r is not None:
|
||||
d, at, k = r
|
||||
kept = kept or k
|
||||
if d is None:
|
||||
gaps.append(("today", i, ent, why))
|
||||
why0 = why0 or why
|
||||
else:
|
||||
days.append(d)
|
||||
ats.append(at)
|
||||
tdays = []
|
||||
tdays, tats, tkept = [], [], False
|
||||
for i, (ent, st) in enumerate(tomorrow, 1):
|
||||
t, why = _tomorrow(st, day0, day1, now)
|
||||
t, t_at, why = _tomorrow(st, day0, day1, now)
|
||||
if t is not None and hold is not None:
|
||||
hold.remember("tomorrow", ent, t, t_at)
|
||||
elif hold is not None and why in _COVERABLE:
|
||||
r = hold.stand_in("tomorrow", ent, day1, now)
|
||||
if r is not None:
|
||||
t, t_at, k = r
|
||||
tkept = tkept or k
|
||||
if t is None:
|
||||
gaps.append(("tomorrow", i, ent, why))
|
||||
else:
|
||||
tdays.append(t)
|
||||
tats.append(t_at)
|
||||
if why0 is not None and hold is not None and tomorrow and all(
|
||||
g[3] in _COVERABLE for g in gaps if g[0] == "today"):
|
||||
# Past midnight the sensors may still show yesterday's "left today".
|
||||
# The figure each tomorrow sensor gave before midnight is for this
|
||||
# day; all of them are needed, as for any sum.
|
||||
subs = [hold.stand_in("tomorrow", ent, day0, now, as_today=True)
|
||||
for ent, _st in tomorrow]
|
||||
if all(r is not None for r in subs):
|
||||
days = [r[0] for r in subs]
|
||||
ats = [r[1] for r in subs]
|
||||
gaps = [g for g in gaps if g[0] != "today"]
|
||||
why0, kept = None, True
|
||||
gaps = tuple(gaps)
|
||||
if why0 is not None:
|
||||
return None, why0, gaps
|
||||
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
|
||||
kept = kept or (tday is not None and tkept)
|
||||
# The oldest member's report is when the sum was last fully known.
|
||||
return Forecast(_sum(days), tday, min(ats)), None, gaps
|
||||
return Forecast(_sum(days), tday, min(ats), kept), None, gaps
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
"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}.", "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."},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"today": {
|
||||
"not_planned": "Not planned yet.",
|
||||
"no_runs": "No cheap or expensive hours left today.",
|
||||
@@ -437,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}.",
|
||||
|
||||
@@ -300,7 +300,7 @@
|
||||
"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}.", "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."},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"today": {
|
||||
"not_planned": "Nog niet gepland.",
|
||||
"no_runs": "Vandaag geen goedkope of dure uren meer.",
|
||||
@@ -372,6 +372,9 @@
|
||||
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
|
||||
"period": "Dit kwartier gaat richting {kw}.",
|
||||
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
|
||||
"period_over": "Aan dit tempo komt dit kwartier op {kw}.",
|
||||
"charge_cut": "Laadt minder van het net om onder {kw} te blijven.",
|
||||
"charge_cut_would": "Zou minder van het net laden om onder {kw} te blijven.",
|
||||
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
|
||||
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
|
||||
"day": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.19.0"
|
||||
version: "0.19.2"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
|
||||
@@ -936,6 +936,8 @@ def _s45_assigned(name, clear=("None",)):
|
||||
|
||||
|
||||
_s45_head_src = _s45_assigned("arb_headroom_w")
|
||||
# SAFETY-125: the guard's headroom with a late reading's seconds counted.
|
||||
S125_ROOM = "peakguard.charge_room_w(self.peak_guard, self._peak_proj)"
|
||||
print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); "
|
||||
f"charge_headroom_w= is {_s45_arg}, and self.arb_headroom_w is assigned "
|
||||
f"from {_s45_head_src}")
|
||||
@@ -944,7 +946,7 @@ check("main.py passes the guard's OWN measured headroom at its one "
|
||||
"transcription of it, so a mutation back to a literal or to max_w is red "
|
||||
"rather than silent",
|
||||
_s45_arg == ["self.arb_headroom_w"]
|
||||
and _s45_head_src == ["peakguard.charge_headroom_w(self.peak_guard)"])
|
||||
and _s45_head_src == [S125_ROOM])
|
||||
# ⚠️ AND THE HOIST DID NOT MOVE IT OUT OF THE CYCLE. An `arb_headroom_w` set
|
||||
# once in `__init__` and never refreshed would satisfy the pin above and would
|
||||
# size every trade from the guard decision of whatever cycle happened to run
|
||||
@@ -960,8 +962,7 @@ check("...and the assignment is INSIDE the same `cycle()` that makes the call, "
|
||||
"and not one carried from an arbitrary earlier one",
|
||||
any(isinstance(n, ast.Assign)
|
||||
and [ast.unparse(t) for t in n.targets] == ["self.arb_headroom_w"]
|
||||
and ast.unparse(n.value)
|
||||
== "peakguard.charge_headroom_w(self.peak_guard)"
|
||||
and ast.unparse(n.value) == S125_ROOM
|
||||
for n in ast.walk(_s45_cycle)))
|
||||
|
||||
|
||||
|
||||
@@ -540,6 +540,12 @@ rp.Hass.state = _sensor
|
||||
rp_start = [UTC0]
|
||||
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
|
||||
"cycle_cost_threshold_eur": 0.20}
|
||||
# TARIFF-24 round 2: with a forecast read, a price mode needs the cycle cost
|
||||
# itself (0.20 here), no longer 1.5 times it (0.30). These scenarios were
|
||||
# written for a 0.30 gap, and a midday 0.21 gap now plans a grid top-up they
|
||||
# are not about, so they ask for 0.30 with the forecast; the no-forecast leg
|
||||
# (`rows_nf`) keeps 0.20, which is 0.30 widened: the same gate on both sides.
|
||||
GATE_030 = {"cycle_cost_threshold_eur": 0.30}
|
||||
|
||||
|
||||
def seed_profile(c):
|
||||
@@ -597,7 +603,8 @@ def invariants(tag, rows, c):
|
||||
|
||||
|
||||
# Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing.
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
charge = invariants("sunny", rows, c)
|
||||
_last = nth(rows, -1, "a cycle")
|
||||
print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, "
|
||||
@@ -607,6 +614,35 @@ check("sunny: a day plan is made, and says sunny",
|
||||
for r in rows))
|
||||
check("sunny: no grid charge at all", not charge)
|
||||
|
||||
# TARIFF-24 round 2: the same day at the cycle cost 0.20 itself. With the
|
||||
# forecast read the gap needed is 0.20 (not 1.5 x = 0.30), so the midday
|
||||
# quarter 0.339 against the evening 0.551 (gap 0.21, between 1x and 1.5x) is
|
||||
# a planned top-up for B (Leftover solar) up to its planned level.
|
||||
rows_g, st_, c_g = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge_g = invariants("gap between 1x and 1.5x", rows_g, c_g)
|
||||
_first_g = nth(charge_g, 0, "a charge cycle")
|
||||
_gaps = [min(g.mean_eur_kwh for g in r["runs"].give)
|
||||
- r["runs"].cheap.mean_eur_kwh for r in charge_g
|
||||
if r["runs"] is not None and r["runs"].give]
|
||||
print(f" gap 0.20 <= gap < 0.30: {len(charge_g)} charge cycles, run gap "
|
||||
f"{min(_gaps, default=0):.3f}, B to "
|
||||
f"{max((r['soc']['b'] for r in rows_g), default=0):.1f} %")
|
||||
check("a forecast day whose price gap lies between 1x and 1.5x the cycle "
|
||||
"cost (0.20 < 0.21 < 0.30) plans a cheap run: B charges from the grid "
|
||||
"inside it, only B, and only up to its planned level",
|
||||
present(_first_g) and len(_gaps) == len(charge_g)
|
||||
and all(0.20 <= g < 0.30 for g in _gaps)
|
||||
and all(r["runs"].cheap.holds(r["now"]) for r in charge_g)
|
||||
and all(r["roles"][B] == modetrade.CHARGE for r in charge_g)
|
||||
and all(r["roles"][A] != modetrade.CHARGE for r in charge_g)
|
||||
and max(r["soc"]["b"] for r in rows_g) <= 45.64 + 2.0)
|
||||
check("...and the same day with no forecast read does not (it needs 0.30): "
|
||||
"the old gate, kept for a day without a forecast",
|
||||
not [r for r in run_day(SF_LS, fc=None,
|
||||
soc0={"a": 60.0, "b": 30.0})[0]
|
||||
if r["action"] == arbitrage.CHARGE
|
||||
and r["now"].astimezone(L).hour == 12])
|
||||
|
||||
# Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge = invariants("dark", rows, c)
|
||||
@@ -623,7 +659,8 @@ check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
|
||||
for r in charge))
|
||||
|
||||
# Cheap hours on a partly sunny day: ignores the plan, fills as in P3.
|
||||
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0})
|
||||
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
charge = invariants("cheap hours, partly", rows, c)
|
||||
top = max(r["soc"]["b"] for r in rows)
|
||||
print(f" cheap hours partly: B reached {top:.1f} %")
|
||||
@@ -634,21 +671,49 @@ check("Cheap hours with a sun forecast charges as with none (P3)",
|
||||
charge and abs(max(r["soc"]["b"] for r in rows_nf) - top) < 1.0
|
||||
and top > 80.0)
|
||||
|
||||
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
|
||||
# The forecast goes away mid-run: a charge under way goes on to its level
|
||||
# (OPT-34; it stopped at once, P3 option A, before). With no charge under way
|
||||
# the levels go with the forecast (below, and test_opt27.py).
|
||||
# OPT-27: a sensor that is unavailable is covered by its last fresh reading
|
||||
# for up to 3 h (`solarfc.Hold`), so "lost" here is a hold that never covers;
|
||||
# the next scenario is the same loss with the hold (test_opt27.py).
|
||||
DROP = UTC0 + timedelta(hours=1, minutes=30)
|
||||
|
||||
|
||||
class _NoHold(solarfc.Hold):
|
||||
def stand_in(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
def seed_profile_no_hold(c):
|
||||
seed_profile(c)
|
||||
c.fc_hold = _NoHold()
|
||||
|
||||
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
|
||||
soc0={"a": 60.0, "b": 10.0})
|
||||
soc0={"a": 60.0, "b": 10.0}, patch=seed_profile_no_hold)
|
||||
invariants("forecast lost mid-run", rows, c)
|
||||
before = [r for r in rows if r["now"] < DROP and r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
late = [r for r in rows if r["now"] > DROP + timedelta(
|
||||
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
|
||||
check("forecast lost mid-run: Leftover solar was charging, and stops within "
|
||||
"a forecast read (60 s) and a cycle", before and not late)
|
||||
check("forecast lost mid-run (OPT-34): Leftover solar was charging, and goes "
|
||||
"on charging past a forecast read (60 s) and a cycle, to its level",
|
||||
before and late)
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
|
||||
soc0={"a": 60.0, "b": 10.0})
|
||||
invariants("forecast unavailable mid-run, covered", rows, c)
|
||||
check("forecast unavailable mid-run (OPT-27): the last reading covers it, so "
|
||||
"Leftover solar goes on charging past the point where it was lost",
|
||||
[r for r in rows if r["now"] < DROP and r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
and [r for r in rows if r["now"] > DROP + timedelta(
|
||||
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE])
|
||||
|
||||
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
|
||||
# charge, so B covers less of the evening - a money risk, never a safety one.
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0})
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
invariants("forecast too high", rows, c)
|
||||
check("forecast too high: no grid charge, and B still keeps its power for "
|
||||
"the dear run", not [r for r in rows if r["action"]
|
||||
@@ -658,7 +723,7 @@ check("forecast too high: no grid charge, and B still keeps its power for "
|
||||
|
||||
# A refusal wins over the plan.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
refuse=("b",))
|
||||
refuse=("b",), extra=GATE_030)
|
||||
invariants("refusal", rows, c)
|
||||
check("refusal: B refuses to charge, so the plan's charge ends and B is "
|
||||
"never a charger after it", rows and not [
|
||||
@@ -736,12 +801,25 @@ _m1 = c._run_mark("charge", _run, _packs, lambda p: True, ((B, 40.0),))
|
||||
_m2 = c._run_mark("charge", _run, _packs, lambda p: False, ((B, 50.0),))
|
||||
check("a level the hysteresis moved opens the run to that battery again",
|
||||
_m1 and not _m2)
|
||||
latch, c._charge_latch = c._charge_latch, None
|
||||
c.solar_fc = None
|
||||
check("the levels are dropped as soon as the forecast goes, even before "
|
||||
"the plan is rebuilt", c._plan_targets and c.plan_targets() is None)
|
||||
check("with no charge under way, the levels are dropped as soon as the "
|
||||
"forecast goes, even before the plan is rebuilt",
|
||||
c._plan_targets and c.plan_targets() is None)
|
||||
check("a lost forecast says so: no forecast, Leftover solar solar only",
|
||||
homeview.solar_block(c, datetime.now(timezone.utc))["state"]
|
||||
== "no_forecast" and homeview.day_key(c, B) == "no_forecast")
|
||||
c._charge_latch = latch
|
||||
# the latch ends at its end time; this replay ran in September, so ask at a
|
||||
# moment inside it
|
||||
_real_dt = rp.m.datetime
|
||||
rp.m.datetime = type("_DT", (datetime,), {"now": classmethod(
|
||||
lambda cls, tz=None: latch[0] - timedelta(hours=1))})
|
||||
check("OPT-34: with a charge under way (B charged in this run), the level "
|
||||
"stays: plan_targets() gives it (the line: test_opt34.py)",
|
||||
latch is not None and c.plan_targets() is not None
|
||||
and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B])
|
||||
rp.m.datetime = _real_dt
|
||||
check("the Why line wins over 'saves its power' only while holding",
|
||||
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
|
||||
reading=True, phase="idle", shadow=False,
|
||||
|
||||
@@ -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,655 @@
|
||||
"""Runnable check for OPT-27. `python3 test_opt27.py`
|
||||
|
||||
Forecast.Solar's sensors stop changing in the evening, so at night every
|
||||
Forecast.Solar site was "no forecast": a price mode's night cheap run needed
|
||||
1.5x the cycle cost and Leftover solar bought nothing. And one missed poll
|
||||
(`unavailable`) dropped the forecast at once. `solarfc.Hold` now keeps the last
|
||||
fresh reading of each sensor, for the day it is FOR:
|
||||
|
||||
* up to `FRESH_S` when the sensor is missing or unavailable (a short outage);
|
||||
* from the end of the bell (20:00) until local midnight, if the reading is at
|
||||
most `KEPT_MAX_AGE_S` (12 h) old when used (the night plan); after midnight
|
||||
the evening's tomorrow figure is today's until the bell starts (06:00, OPT-34,
|
||||
test_opt34.py); never beyond, never for another day.
|
||||
|
||||
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
|
||||
|
||||
1. `solarfc` alone: the evening, the outage, the bounds, the day a figure is
|
||||
for, restart (dump/load), and no hold = as before.
|
||||
2. The real Controller on a day/night tariff (0.34/0.26, gap 0.08, cycle cost
|
||||
0.07), forecast sensors that stop updating at 17:05 local:
|
||||
a. the 22:00 night run is planned with the cycle-cost gap and charges;
|
||||
b. no forecast all day: the night run still needs 1.5x and does not charge;
|
||||
c. an evening restart keeps it (a new Controller on the saved store);
|
||||
d. a one-hour outage at 18:47 does not drop it;
|
||||
e. from 06:00 (the bell) the figure is not used, before it it is (OPT-34).
|
||||
3. The screens: the forecast line says when it was read and that it is kept
|
||||
for tonight (EN and NL), and Home agrees with control.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): see the
|
||||
DECISIONS note and the dev report.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, modetrade, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
|
||||
|
||||
|
||||
def at(day, hh, mm=0):
|
||||
"""Local time on 2026-11-`day` (CET) as UTC."""
|
||||
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def st(kwh, when, *, unit="kWh"):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
|
||||
"last_updated": when.isoformat(),
|
||||
"last_reported": when.isoformat()}
|
||||
|
||||
|
||||
GONE = {"state": "unavailable", "attributes": {}}
|
||||
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. solarfc: the hold")
|
||||
D1_READ = at(3, 17, 5) # the last fresh reading, 17:05
|
||||
EVE = at(3, 22, 0)
|
||||
|
||||
|
||||
def rs(today, tomorrow, now, hold):
|
||||
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
|
||||
|
||||
|
||||
h = solarfc.Hold()
|
||||
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 17, 10), h)
|
||||
check("a fresh reading is a forecast, not kept", fc is not None
|
||||
and not fc.kept and fc.tomorrow.kwh == 12.0 and fc.today.kwh == 0.4)
|
||||
check("it is remembered (the hold is flagged for saving)", h.changed)
|
||||
fc_no, why_no, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, None)
|
||||
check("with no hold, 22:00 is no forecast (as before)",
|
||||
fc_no is None and why_no == solarfc.STALE)
|
||||
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, h)
|
||||
print(f" 22:00, sensors still show 17:05: {fc and fc.kept=}, {gaps=}")
|
||||
check("22:00 with the sensors stale since 17:05: the 17:05 figures are used",
|
||||
fc is not None and fc.kept and fc.tomorrow.kwh == 12.0
|
||||
and fc.at == D1_READ and gaps == ())
|
||||
check("... today has no sun left in it, tomorrow's is counted from 06:00",
|
||||
fc is not None and fc.between(EVE, at(4, 7, 0)) is not None
|
||||
and 0.0 < fc.between(EVE, at(4, 7, 0)) < 0.5
|
||||
and fc.today.kwh == 0.4)
|
||||
fc2, _w, _g = rs(GONE, GONE, EVE, h)
|
||||
check("the same when the sensors are unavailable at 22:00",
|
||||
fc2 is not None and fc2.kept and fc2.tomorrow.kwh == 12.0)
|
||||
# A new fresh reading replaces the old one at once.
|
||||
fc3, _w, _g = rs(st(0.0, at(3, 22, 20)), st(14.5, at(3, 22, 20)),
|
||||
at(3, 22, 30), h)
|
||||
check("a newer fresh reading is used and is not 'kept'",
|
||||
fc3 is not None and not fc3.kept and fc3.tomorrow.kwh == 14.5)
|
||||
fc3b, _w, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 23, 0), h)
|
||||
check("... and later the newest reading is the one kept, not the old one",
|
||||
fc3b is not None and fc3b.tomorrow.kwh == 14.5)
|
||||
|
||||
# A short outage: unavailable for one or two polls, any time of day.
|
||||
h2 = solarfc.Hold()
|
||||
rs(st(1.1, at(3, 18, 5)), st(12.0, at(3, 18, 5)), at(3, 18, 6), h2)
|
||||
for label, now in (("18:47", at(3, 18, 47)), ("19:20", at(3, 19, 20)),
|
||||
("19:47", at(3, 19, 47)), ("20:55", at(3, 20, 55))):
|
||||
fo, _w, _g = rs(GONE, GONE, now, h2)
|
||||
check(f"unavailable at {label}: the 18:05 forecast is still used",
|
||||
fo is not None and fo.tomorrow.kwh == 12.0)
|
||||
fo, _w, _g = rs(GONE, GONE, at(3, 20, 55), solarfc.Hold())
|
||||
check("(nothing was ever read: still no forecast)", fo is None)
|
||||
# Daytime is as before: an outage longer than FRESH_S drops it.
|
||||
h3 = solarfc.Hold()
|
||||
rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h3)
|
||||
d_in = rs(GONE, GONE, at(4, 11, 50), h3)[0]
|
||||
d_out = rs(GONE, GONE, at(4, 12, 10), h3)[0]
|
||||
d_out2 = rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 14, 0), h3)[0]
|
||||
check("daytime: an outage under 3 h is covered, one over 3 h is not",
|
||||
d_in is not None and not d_in.kept and d_out is None)
|
||||
check("daytime: a sensor that stopped is stale after 3 h, as before "
|
||||
"(the hold does not start before the bell ends)", d_out2 is None)
|
||||
|
||||
# Round 2: today fresh, tomorrow's sensor stopped or down in the daytime.
|
||||
h9 = solarfc.Hold()
|
||||
rs(st(5.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h9)
|
||||
for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)):
|
||||
fd = rs(st(3.0, at(4, 13, 55)), tm, at(4, 14, 0), h9)[0]
|
||||
check(f"14:00, today fresh, tomorrow {label} since 09:05 (over 3 h): "
|
||||
"no tomorrow, nothing kept",
|
||||
fd is not None and fd.tomorrow is None and not fd.kept)
|
||||
fn = rs(st(0.1, at(4, 20, 55)), tm, at(4, 21, 0), h9)[0]
|
||||
check(f"21:00, tomorrow {label}: the 09:05 figure covers it, kept",
|
||||
fn is not None and fn.tomorrow is not None
|
||||
and fn.tomorrow.kwh == 12.0 and fn.kept)
|
||||
fs = rs(st(3.0, at(4, 10, 55)), tm, at(4, 11, 0), h9)[0]
|
||||
check(f"11:00, tomorrow {label} for under 3 h: still covered, not kept",
|
||||
fs is not None and fs.tomorrow is not None and not fs.kept)
|
||||
|
||||
# The bound: the bell start (06:00), and the day a figure is for.
|
||||
fm = rs(st(0.0, at(3, 23, 0)), st(0.0, at(3, 23, 0)), at(4, 1, 0), h2)[0]
|
||||
check("00:30 is covered by the 18:05 tomorrow figure, as TODAY's",
|
||||
rs(GONE, GONE, at(4, 0, 30), h2)[0] is not None)
|
||||
fmid = rs(GONE, GONE, at(4, 2, 59), h2)[0]
|
||||
print(f" 02:59: {fmid and (fmid.today.kwh, fmid.tomorrow, fmid.kept)}")
|
||||
check("02:59: tomorrow's 12.0 kWh is now today's, there is no tomorrow",
|
||||
fmid is not None and fmid.today.kwh == 12.0 and fmid.tomorrow is None
|
||||
and fmid.kept and fmid.today.day0 == at(4, 0, 0))
|
||||
check("... its sun is counted on the day it is for (06:00-20:00 on the 4th)",
|
||||
fmid is not None and fmid.between(at(4, 3, 0), at(4, 7, 0)) is not None
|
||||
and fmid.between(at(4, 3, 0), at(4, 7, 0)) > 0.0)
|
||||
check("03:01: still used (OPT-34: until the bell starts at 06:00)",
|
||||
rs(GONE, GONE, at(4, 3, 1), h2)[0] is not None
|
||||
and rs(GONE, GONE, at(4, 5, 59), h2)[0] is not None)
|
||||
check("06:00 (the bell starts): not used any more",
|
||||
rs(GONE, GONE, at(4, 6, 0), h2)[0] is None)
|
||||
check("... and not the next evening either (past 06:00)",
|
||||
rs(GONE, GONE, at(4, 22, 0), h2)[0] is None)
|
||||
fe = rs(st(0.1, at(4, 21, 50)), GONE, at(4, 22, 0), h2)[0]
|
||||
check("... the next evening, with today fresh and tomorrow's sensor down, "
|
||||
"there is no tomorrow: the 4th's figure is not the 5th's",
|
||||
fe is not None and fe.today.kwh == 0.1 and fe.tomorrow is None)
|
||||
# Never today's figure for tomorrow.
|
||||
h4 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h4)
|
||||
# Next day 00:30: today's sensor is fresh again (4.0), tomorrow's is gone.
|
||||
fx = rs(st(4.0, at(4, 0, 20)), GONE, at(4, 0, 30), h4)[0]
|
||||
check("00:30: today is the fresh 4.0, and there is NO tomorrow "
|
||||
"(the 3rd's 'today' 3.0 and 'tomorrow' 12.0 are not the 5th's)",
|
||||
fx is not None and fx.today.kwh == 4.0 and fx.tomorrow is None
|
||||
and not fx.kept)
|
||||
fy = rs(st(4.0, at(4, 0, 20)), st(9.0, at(4, 0, 20)), at(4, 0, 30), h4)[0]
|
||||
check("00:30 with both fresh: tomorrow is the fresh 9.0, not the old 12.0",
|
||||
fy is not None and fy.tomorrow.kwh == 9.0 and not fy.kept)
|
||||
# A tomorrow sensor that is down while today's is fresh, in the evening: the
|
||||
# hold gives the tomorrow figure for tomorrow, never today's figure.
|
||||
h5 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h5)
|
||||
fz = rs(st(0.2, at(3, 21, 30)), GONE, at(3, 21, 40), h5)[0]
|
||||
check("21:40: today fresh, tomorrow down: tomorrow is the kept 12.0 (not "
|
||||
"today's 0.2 or 3.0)", fz is not None and fz.tomorrow.kwh == 12.0
|
||||
and fz.today.kwh == 0.2 and fz.kept)
|
||||
# A set-up fault is never covered.
|
||||
h6 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h6)
|
||||
fb = rs(st("lots", at(3, 21, 30)), st(12.0, at(3, 21, 30)),
|
||||
at(3, 21, 40), h6)
|
||||
fu = rs(st(0.2, at(3, 21, 30), unit="W"), st(12.0, at(3, 21, 30)),
|
||||
at(3, 21, 40), h6)
|
||||
check("a bad value or a wrong unit is not covered by the hold",
|
||||
fb[0] is None and fb[1] == solarfc.BAD and fu[0] is None
|
||||
and fu[1] == solarfc.UNIT)
|
||||
# Several sensors: all or nothing.
|
||||
h7 = solarfc.Hold()
|
||||
two_t = [("sensor.t1", st(1.0, at(3, 17, 0))),
|
||||
("sensor.t2", st(2.0, at(3, 17, 0)))]
|
||||
two_m = [("sensor.m1", st(5.0, at(3, 17, 0))),
|
||||
("sensor.m2", st(6.0, at(3, 17, 0)))]
|
||||
solarfc.read_sum(two_t, two_m, at(3, 17, 5), h7)
|
||||
gone2 = [("sensor.t1", GONE), ("sensor.t2", GONE)]
|
||||
f2 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
|
||||
at(3, 22, 0), h7)
|
||||
check("three or two sensors: the kept figures are summed",
|
||||
f2[0] is not None and f2[0].tomorrow.kwh == 11.0
|
||||
and f2[0].today.kwh == 3.0 and f2[2] == ())
|
||||
h8 = solarfc.Hold()
|
||||
solarfc.read_sum(two_t[:1] + [("sensor.t2", GONE)], two_m, at(3, 17, 5), h8)
|
||||
f3 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
|
||||
at(3, 22, 0), h8)
|
||||
check("one sensor never read fresh: no partial sum, the gap is named",
|
||||
f3[0] is None and any(g[:3] == ("today", 2, "sensor.t2") for g in f3[2]))
|
||||
# Restart: what the store holds comes back.
|
||||
saved = json.loads(json.dumps(h.dump()))
|
||||
hr = solarfc.Hold(saved)
|
||||
fr = rs(GONE, GONE, at(3, 23, 0), hr)[0]
|
||||
check("dump/load through JSON: the kept figures come back (40 min old: "
|
||||
"inside FRESH_S, so not 'kept' in the screens' sense)",
|
||||
fr is not None and not fr.kept and fr.tomorrow.kwh == 14.5
|
||||
and fr.at == at(3, 22, 20))
|
||||
junk = solarfc.Hold({"r": [["today", 7, "x", "y", "z", []],
|
||||
["tomorrow", T_ENT, "2026-11-04T00:00:00+00:00",
|
||||
-1, "2026-11-03T16:00:00+00:00", []],
|
||||
"bad", None]})
|
||||
check("a damaged store entry is dropped, not raised",
|
||||
rs(GONE, GONE, at(3, 22, 0), junk)[0] is None
|
||||
and solarfc.Hold(None).dump()["r"] == [] and solarfc.Hold(5).changed
|
||||
is False)
|
||||
hp = solarfc.Hold()
|
||||
rs(st(8.0, at(3, 9, 0), ), st(12.0, at(3, 9, 0)), at(3, 9, 5), hp)
|
||||
periods = [{"period_start": f"2026-11-04T{hh:02d}:00:00+01:00",
|
||||
"pv_estimate": 1.0} for hh in range(8, 16)]
|
||||
s_per = st(10.0, at(3, 17, 0))
|
||||
s_per["attributes"]["detailedForecast"] = periods
|
||||
rs(st(0.0, at(3, 17, 0)), s_per, at(3, 17, 5), hp)
|
||||
hq = solarfc.Hold(json.loads(json.dumps(hp.dump())))
|
||||
fq = rs(GONE, GONE, at(3, 22, 0), hq)[0]
|
||||
check("an hourly split (Solcast shape) survives the store",
|
||||
fq is not None and fq.tomorrow.periods
|
||||
and abs(fq.between(at(4, 8, 0), at(4, 16, 0)) - 7.5) < 1e-6)
|
||||
|
||||
# Round 3. F1: a kept reading is at most KEPT_MAX_AGE_S old when used.
|
||||
hz = solarfc.Hold()
|
||||
rs(st(0.4, at(3, 0, 5)), st(12.0, at(3, 0, 5)), at(3, 0, 10), hz)
|
||||
print(f" 00:05 reading, sensors dead: 19:59 "
|
||||
f"{rs(GONE, GONE, at(3, 19, 59), hz)[0]}, 20:00 "
|
||||
f"{rs(GONE, GONE, at(3, 20, 0), hz)[0]}")
|
||||
check("F1: a 00:05 reading with the sensors dead all day is not kept at "
|
||||
"20:00 (20 h old): no forecast",
|
||||
rs(GONE, GONE, at(3, 20, 0), hz)[0] is None
|
||||
and rs(GONE, GONE, at(3, 23, 0), hz)[0] is None)
|
||||
check("... nor after midnight until 03:00 (as today's, 26 h old)",
|
||||
rs(GONE, GONE, at(4, 0, 30), hz)[0] is None
|
||||
and rs(GONE, GONE, at(4, 2, 30), hz)[0] is None)
|
||||
hy = solarfc.Hold()
|
||||
rs(st(0.4, at(3, 17, 5)), st(12.0, at(3, 17, 5)), at(3, 17, 10), hy)
|
||||
check("F1: a 17:05 reading is kept at 22:00 (5 h) ...",
|
||||
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(3, 22, 0), hy)[0]))
|
||||
check("... and at 02:59 next day (9 h 54 min) ...",
|
||||
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 2, 59),
|
||||
hy)[0]))
|
||||
check("... and at 03:00 and 05:59 next day (OPT-34: the bell starts at 06:00)",
|
||||
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 3, 0),
|
||||
hy)[0])
|
||||
and (lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 5, 59),
|
||||
hy)[0]))
|
||||
check("... and at 06:00 it is not (12 h 55 min old, the bell has started)",
|
||||
rs(GONE, GONE, at(4, 6, 0), hy)[0] is None)
|
||||
hm = solarfc.Hold()
|
||||
rs(st(0.9, at(3, 9, 5)), st(12.0, at(3, 9, 5)), at(3, 9, 10), hm)
|
||||
check("F1: a 09:05 reading is kept at 20:00 (10 h 55 min) but not at 22:00",
|
||||
rs(GONE, GONE, at(3, 20, 0), hm)[0] is not None
|
||||
and rs(GONE, GONE, at(3, 22, 0), hm)[0] is None)
|
||||
|
||||
# F2: the 25 h autumn day (2026-10-25) and the 23 h spring day (2027-03-28).
|
||||
BRU = solarfc.BILLING_TZ # Europe/Brussels, or the encoded EU rule
|
||||
|
||||
|
||||
def bru(y, mo, d, hh, mm=0):
|
||||
return datetime(y, mo, d, hh, mm, tzinfo=BRU).astimezone(timezone.utc)
|
||||
|
||||
|
||||
for (y, mo, d), label in (((2026, 10, 25), "25 h autumn day 2026-10-25"),
|
||||
((2027, 3, 28), "23 h spring day 2027-03-28")):
|
||||
hd = solarfc.Hold()
|
||||
t17 = bru(y, mo, d, 17, 5)
|
||||
rs(st(0.4, t17), st(12.0, t17), t17 + timedelta(minutes=5), hd)
|
||||
# real hours from 20:00 local to the next local midnight, each hour
|
||||
start = bru(y, mo, d, 20, 0)
|
||||
end = bru(y, mo, d + 1, 0)
|
||||
t, miss = start, []
|
||||
while t < end:
|
||||
if rs(GONE, GONE, t, hd)[0] is None:
|
||||
miss.append(t.astimezone(BRU).strftime("%H:%M"))
|
||||
t += timedelta(minutes=30)
|
||||
print(f" {label}: not kept at {miss}")
|
||||
check(f"F2: {label}: the 17:05 figure is kept every half hour "
|
||||
"from 20:00 to local midnight", not miss)
|
||||
check(f"... and just before the next local midnight",
|
||||
rs(GONE, GONE, bru(y, mo, d + 1, 0) - timedelta(minutes=1),
|
||||
hd)[0] is not None)
|
||||
|
||||
# F5: old days are dropped from the stored hold, so it does not grow.
|
||||
hp5 = solarfc.Hold()
|
||||
sizes = []
|
||||
for dd in range(3, 13):
|
||||
t0_ = at(dd, 17, 5)
|
||||
rs(st(0.4, t0_), st(12.0, t0_), t0_ + timedelta(minutes=5), hp5)
|
||||
sizes.append(len(hp5.dump()["r"]))
|
||||
day0s = {row[2] for row in hp5.dump()["r"]}
|
||||
print(f" F5: stored entries per day over 10 days: {sizes}")
|
||||
check("F5: the stored hold does not grow day after day (at most today's and "
|
||||
"tomorrow's entry, with yesterday's tomorrow)", max(sizes) <= 3
|
||||
and len(day0s) <= 2)
|
||||
check("... and a day before today is gone",
|
||||
all(row[2] >= at(12, 0, 0).isoformat() for row in hp5.dump()["r"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. The real Controller: day/night 0.34 / 0.26, cycle cost 0.07")
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.26},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.34}]}')
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
DAY3 = 3
|
||||
|
||||
|
||||
def opts(cost=0.07):
|
||||
o = dict(rp.options(True, False, 2))
|
||||
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE,
|
||||
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT)
|
||||
return o
|
||||
|
||||
|
||||
def stops_at(last, *, down=()):
|
||||
"""Sensors that update until `last` and then show that reading for
|
||||
good. `down`: (from, to) windows in which they are unavailable."""
|
||||
def f(now, ent):
|
||||
for a, z in down:
|
||||
if a <= now < z:
|
||||
return GONE
|
||||
when = min(now, last)
|
||||
return st(0.4 if ent == T_ENT else 12.0, when)
|
||||
return f
|
||||
|
||||
|
||||
def never(now, ent):
|
||||
return st(0.0 if ent == T_ENT else 12.0, now - timedelta(hours=9))
|
||||
|
||||
|
||||
def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
|
||||
keep_store=True, snap=False):
|
||||
"""-> (controller, rows, pre_rows). `pre`: the times the forecast is read
|
||||
before the first cycle (the evening). `restart`: a time in `pre` at which
|
||||
the add-on restarts: a new Controller on the saved store."""
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in CHOICE.items():
|
||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||
("pack_sell", sell)):
|
||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
||||
clock = [pre[0] if pre else start]
|
||||
rows, pre_rows = [], []
|
||||
cur = [store]
|
||||
|
||||
class H(rp.Hass):
|
||||
async def state(self, e):
|
||||
return sensors(clock[0], e)
|
||||
|
||||
def build(store):
|
||||
hass = H(("a", "b"), lambda t: 300.0, {"a": 40.0, "b": 40.0})
|
||||
tmp = tempfile.mkdtemp(prefix="o27-")
|
||||
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
# A house profile a few days old (300 W round the clock), so the day
|
||||
# plan is made as it is on a site that has run for a week.
|
||||
c.house_profile.load({d: {str(hh): 300.0 for hh in range(24)}
|
||||
for d in ("2026-10-31", "2026-11-01",
|
||||
"2026-11-02")})
|
||||
return c, hass
|
||||
|
||||
async def go():
|
||||
c, hass = build(store)
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
task = None
|
||||
try:
|
||||
for t_pre in pre:
|
||||
clock[0] = t_pre
|
||||
if restart is not None and t_pre == restart:
|
||||
# The saved file, through JSON; `keep_store=False` loses
|
||||
# the hold only (the control for the test).
|
||||
data = json.loads(json.dumps(cur[0].data))
|
||||
if not keep_store:
|
||||
data.pop(m.FC_HOLD_KEY, None)
|
||||
new = rp._Store()
|
||||
new.data.update(data)
|
||||
cur[0] = new
|
||||
c, hass = build(new)
|
||||
await c._read_forecast()
|
||||
pre_rows.append((t_pre, c.solar_fc is not None,
|
||||
bool(c.solar_fc and c.solar_fc.kept)))
|
||||
clock[0] = start
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = start + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if t == 0.0:
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None and (
|
||||
c.tariff_at is not None and c.tariff_at >= now):
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(5.0, 10.0)
|
||||
rp_ = c.mode_runs
|
||||
rows.append(dict(
|
||||
t=t, fc_none=c.solar_fc is None,
|
||||
kept=bool(c.solar_fc and c.solar_fc.kept),
|
||||
runs_on=None if rp_ is None else rp_.on,
|
||||
reason=None if rp_ is None else rp_.reason,
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
charging=hass.plants["b"].p < -200.0,
|
||||
soc_b=hass.plants["b"].soc))
|
||||
t += 5.0
|
||||
keep = c.o
|
||||
c.o = dict(keep, arbitrage_enabled=True)
|
||||
try:
|
||||
c.mirror, c.mirror_why = homeview.trade_verdict(
|
||||
c, c.fresh_tariff_plan(clock[0]), clock[0])
|
||||
finally:
|
||||
c.o = keep
|
||||
c.cards = homeview.battery_rows(c, False, True)
|
||||
c.block = homeview.solar_block(c, clock[0])
|
||||
c.hold_saved = json.loads(json.dumps(cur[0].data.get(
|
||||
m.FC_HOLD_KEY)))
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
return c
|
||||
|
||||
return asyncio.run(go()), rows, pre_rows
|
||||
|
||||
|
||||
def why_of(c, name):
|
||||
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
|
||||
return card["why_key"]
|
||||
|
||||
|
||||
EVENING = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
|
||||
LAST = at(DAY3, 17, 5)
|
||||
NIGHT_START = at(DAY3, 22, 0)
|
||||
|
||||
# a. a forecast that stops updating at sunset
|
||||
ca, ra, pa = drive27(opts(), stops_at(LAST), pre=EVENING + [at(DAY3, 21, 55)],
|
||||
start=NIGHT_START)
|
||||
la = nth(ra, -1, "last cycle")
|
||||
print(f" a. 22:00 with the 17:05 forecast: kept={la['kept']} on="
|
||||
f"{la['runs_on']} reason={la['reason']}; B charging "
|
||||
f"{sum(r['charging'] for r in ra)} of {len(ra)}; Why A {why_of(ca, A)} "
|
||||
f"B {why_of(ca, B)}; Home mirror {ca.mirror}")
|
||||
check("the forecast was read fresh before 17:05 and kept after it",
|
||||
any(not k for _t, ok, k in pa if _t <= LAST + timedelta(minutes=30))
|
||||
and all(ok for _t, ok, _k in pa)
|
||||
and all(k for _t, _ok, k in pa if _t >= at(DAY3, 20, 15)))
|
||||
check("22:00 night run: planned with the cycle-cost gap (needs 0.07 < 0.08)",
|
||||
present(la) and not la["fc_none"] and la["kept"]
|
||||
and la["runs_on"] is True and la["reason"] != "not_worth")
|
||||
check("... and B (Cheap hours) charges from the grid in the night period",
|
||||
sum(r["charging"] for r in ra) > len(ra) // 2
|
||||
and la["roles"].get(B) == modetrade.CHARGE
|
||||
and why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread")
|
||||
check("the day plan is made from the kept forecast (levels for Leftover "
|
||||
"solar), as it is from a fresh one",
|
||||
ca.day_plan is not None and ca.plan_targets() is not None
|
||||
and ca.block["state"] == "plan")
|
||||
check("Home's mirror of the gate allows it, as control does",
|
||||
ca.mirror is not None and ca.mirror_why == "allowed")
|
||||
|
||||
# b. no forecast all day
|
||||
cb, rb, pb = drive27(opts(), never, pre=EVENING + [at(DAY3, 21, 55)],
|
||||
start=NIGHT_START)
|
||||
lb = nth(rb, -1, "last cycle")
|
||||
print(f" b. no forecast all day: on={lb['runs_on']} reason={lb['reason']}; "
|
||||
f"B charging {sum(r['charging'] for r in rb)}; Why A {why_of(cb, A)} "
|
||||
f"B {why_of(cb, B)}")
|
||||
check("with no forecast all day the night run still needs 1.5x (0.105 > 0.08)"
|
||||
": not worth, no charge, 'Prices differ too little'",
|
||||
present(lb) and lb["fc_none"] and lb["runs_on"] is False
|
||||
and lb["reason"] == "not_worth"
|
||||
and not any(r["charging"] for r in rb)
|
||||
and why_of(cb, A) == "no_spread" and why_of(cb, B) == "no_spread"
|
||||
and not any(ok for _t, ok, _k in pb))
|
||||
check("... and there is no day plan (Leftover solar takes solar only)",
|
||||
cb.day_plan is None and cb.plan_targets() is None
|
||||
and cb.block["state"] == "no_forecast")
|
||||
check("... Home's mirror refuses it too, for the gap ('not_worth')",
|
||||
cb.mirror is None and cb.mirror_why == "not_worth")
|
||||
check("... and nothing is saved for the night (no fresh reading was made)",
|
||||
cb.hold_saved is None or cb.hold_saved.get("r") == [])
|
||||
|
||||
# c. an evening restart
|
||||
cc, rc, pc = drive27(opts(), stops_at(LAST),
|
||||
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
|
||||
restart=at(DAY3, 21, 55))
|
||||
lc = nth(rc, -1, "last cycle")
|
||||
check("an evening restart (new Controller on the saved store) keeps the "
|
||||
"forecast: the night run is planned and charges",
|
||||
present(lc) and lc["kept"] and lc["runs_on"] is True
|
||||
and sum(r["charging"] for r in rc) > len(rc) // 2
|
||||
and present(nth(pc, -1, "last read")) and pc[-1][1] and pc[-1][2])
|
||||
cc0, rc0, pc0 = drive27(opts(), stops_at(LAST),
|
||||
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
|
||||
restart=at(DAY3, 21, 55), keep_store=False)
|
||||
lc0 = nth(rc0, -1, "last cycle")
|
||||
check("(control: the same restart with the store lost is blind, 1.5x)",
|
||||
present(lc0) and lc0["fc_none"] and lc0["runs_on"] is False
|
||||
and present(nth(pc0, -1, "last read")) and not pc0[-1][1])
|
||||
|
||||
# d. a one-hour outage at 18:47
|
||||
OUT = (at(DAY3, 18, 47), at(DAY3, 19, 47))
|
||||
quarter = [at(DAY3, 16, 0) + timedelta(minutes=15 * i) for i in range(24)]
|
||||
cd, rd, pd = drive27(opts(), stops_at(at(DAY3, 18, 5), down=[OUT]),
|
||||
pre=quarter + [at(DAY3, 21, 55)], start=NIGHT_START)
|
||||
ld = nth(rd, -1, "last cycle")
|
||||
inside = [(t_, ok) for t_, ok, _k in pd if OUT[0] <= t_ < OUT[1]]
|
||||
print(f" d. reads inside the outage: {len(inside)}, with a forecast: "
|
||||
f"{sum(ok for _t, ok in inside)}")
|
||||
check("a one-hour outage at 18:47 does not drop the forecast "
|
||||
"(every read from 16:00 to 22:00 has one)",
|
||||
present(pd) and len(inside) >= 4 and all(ok for _t, ok, _k in pd))
|
||||
check("... and the 22:00 night run is planned and charges",
|
||||
present(ld) and ld["runs_on"] is True
|
||||
and sum(r["charging"] for r in rd) > len(rd) // 2)
|
||||
|
||||
# e. the bound: until the bell starts (06:00, OPT-34; it was midnight + 3 h)
|
||||
mid = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
|
||||
ce, re_, pe = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 5, 30)],
|
||||
start=at(4, 5, 30))
|
||||
le = nth(re_, -1, "last cycle")
|
||||
check("05:30 (before the bell starts): the figure from 17:05 is still used, "
|
||||
"as today's; the night run is planned",
|
||||
present(le) and le["kept"] and le["runs_on"] is True)
|
||||
cf, rf, pf = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 6, 30)],
|
||||
start=at(4, 6, 30))
|
||||
lf = nth(rf, -1, "last cycle")
|
||||
check("06:30 (the bell has started): a figure from before the night is not "
|
||||
"used: no forecast, 1.5x, not worth, no charge",
|
||||
present(lf) and lf["fc_none"] and lf["runs_on"] is False
|
||||
and lf["reason"] == "not_worth" and not any(r["charging"] for r in rf))
|
||||
|
||||
# g. OPT-28's shortfall sizing on a kept figure: Leftover solar (A) is sized
|
||||
# from the kept forecast exactly as from a fresh one, not "no forecast"
|
||||
def fresh_all(now, ent):
|
||||
return st(0.4 if ent == T_ENT else 12.0, now)
|
||||
|
||||
|
||||
cg, rg, pg = drive27(opts(), fresh_all, pre=EVENING + [at(DAY3, 21, 55)],
|
||||
start=NIGHT_START)
|
||||
kp, fp_ = ca.day_plan, cg.day_plan
|
||||
print(f" g. kept : short={kp and kp.short_kwh} uncovered="
|
||||
f"{kp and kp.uncovered_kwh} targets={kp and kp.targets} "
|
||||
f"need={kp and kp.need_kwh} sun={kp and kp.sun_kwh}")
|
||||
print(f" fresh : short={fp_ and fp_.short_kwh} uncovered="
|
||||
f"{fp_ and fp_.uncovered_kwh} targets={fp_ and fp_.targets} "
|
||||
f"need={fp_ and fp_.need_kwh} sun={fp_ and fp_.sun_kwh}")
|
||||
check("a kept figure sizes the night by OPT-28's shortfall, with a level for "
|
||||
"Leftover solar (A): not 'no forecast', which has no plan at all",
|
||||
kp is not None and A in kp.targets and kp.short_kwh > 0.0
|
||||
and cb.day_plan is None and not cg.solar_fc.kept)
|
||||
check("... and the level, the grid need, the shortfall and the sun counted "
|
||||
"are those a fresh figure gives (counted once, from the right time)",
|
||||
fp_ is not None and kp.targets == fp_.targets
|
||||
and kp.need_kwh == fp_.need_kwh and kp.share_kwh == fp_.share_kwh
|
||||
and abs(kp.short_kwh - fp_.short_kwh) < 1e-9
|
||||
and abs(kp.uncovered_kwh - fp_.uncovered_kwh) < 1e-9
|
||||
and abs(kp.sun_kwh - fp_.sun_kwh) < 1e-9)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. The screens")
|
||||
blk = ca.block
|
||||
print(f" forecast line: {blk['line']!r} (state {blk['state']})")
|
||||
check("the forecast line says when it was read and that it is kept for "
|
||||
"tonight", blk["line"] == "Solar forecast read 17:05, kept for tonight.")
|
||||
check("the same line comes from the Forecast tab (dayview) on today",
|
||||
homeview.line_solar(0.4, 0.3, "17:05", kept=True) == blk["line"]
|
||||
and homeview.line_solar(0.4, 0.3, "17:05").startswith(
|
||||
"Solar still to come today"))
|
||||
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
||||
encoding="utf-8"))
|
||||
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
|
||||
encoding="utf-8"))
|
||||
k_en = en["home_words"]["solar"]["kept"]
|
||||
k_nl = nl["home_words"]["solar"]["kept"]
|
||||
print(f" EN {k_en!r}\n NL {k_nl!r}")
|
||||
check("EN and NL both say it, with the time, and the NL is not the English",
|
||||
"{at}" in k_en and "{at}" in k_nl and k_en != k_nl
|
||||
and "tonight" in k_en and "vannacht" in k_nl)
|
||||
check("a daytime reading keeps the old line (daytime unchanged)",
|
||||
"Updated 09:05" in homeview.line_solar(1.0, 0.7, "09:05")
|
||||
and "Bijgewerkt" in json.dumps(nl["home_words"]["solar"]["line"]))
|
||||
check("no forecast: the old 'No solar forecast now' line, no 'kept'",
|
||||
cb.block["state"] == "no_forecast"
|
||||
and "tonight" not in cb.block["line"])
|
||||
check("control and Batteries screen agree on the 22:00 run: not 'no_spread'",
|
||||
why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread"
|
||||
and la["runs_on"] is True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,747 @@
|
||||
"""Runnable check for OPT-34. `python3 test_opt34.py`
|
||||
|
||||
OPT-27 keeps the last fresh Forecast.Solar figure over the night. With the
|
||||
sensors silent from 17:05 it ended at local midnight + 3 h, in the middle of the
|
||||
night grid charge: Leftover solar's planned level went with it and the charge
|
||||
stopped (rig, 2026-10-31: battery A 49.8 % at 07:00 against 79.9 % with a fresh
|
||||
figure). Forecast.Solar may not refresh after midnight on a live site, so the
|
||||
add-on must not depend on it. OPT-34:
|
||||
|
||||
1. a night charge that has started keeps its planned level when the forecast
|
||||
goes (`Controller._latch_charge()`), and the next slice of the run is planned
|
||||
with it (the wide no-forecast gap does not refuse it);
|
||||
2. the evening's "tomorrow" figure is today's until the bell starts (06:00
|
||||
local, `BELL_FROM_H`) instead of until midnight + 3 h;
|
||||
3. for that case only, a reading may be up to 15 h old (a 17:05 reading is
|
||||
12 h 55 min old at 06:00), but only if it was at most 12 h old at midnight;
|
||||
4. a fresh figure replaces the kept one at once.
|
||||
|
||||
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): see the
|
||||
DECISIONS note and the dev report.
|
||||
|
||||
1. `solarfc` alone: the window, the age limits, the day, DST, restart.
|
||||
2. The real Controller, the owner's shape (modes on, arbitrage off, A Leftover
|
||||
solar + B Cheap hours, both 10 % at the start of the night, the night rate
|
||||
22:00-07:00, tomorrow 6 kWh, a 2.5 kW peak limit so the charge takes hours),
|
||||
sensors silent from 17:05, whole nights at 5 s per cycle (run in parallel
|
||||
processes, about 2.5 minutes each):
|
||||
a. silent from 17:05: both batteries end the night where a fresh figure
|
||||
ends them (the base left A at 57 % against 87 %), and the screens agree;
|
||||
b. restarts at 01:00 and 04:00 change nothing;
|
||||
c. a charge that cannot start before 03:00 (the price dips at 04:00) still
|
||||
starts at 04:00 on the kept figure;
|
||||
d. a fresh figure at 02:00 replaces the level at once, up, during the charge;
|
||||
and before a charge has started, up and down;
|
||||
e. the last hour, after the kept figure has ended at 06:00: the charge under
|
||||
way goes on to its level, also over a restart; the screens say so.
|
||||
3. The text, EN and NL.
|
||||
4. `_latch_charge()` alone: a battery that leaves the plan leaves the latch.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from multiprocessing import get_context
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
|
||||
|
||||
|
||||
def at(day, hh, mm=0):
|
||||
"""Local time on 2026-11-`day` (CET) as UTC."""
|
||||
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def st(kwh, when, *, unit="kWh"):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
|
||||
"last_updated": when.isoformat(),
|
||||
"last_reported": when.isoformat()}
|
||||
|
||||
|
||||
GONE = {"state": "unavailable", "attributes": {}}
|
||||
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
|
||||
BRU = solarfc.BILLING_TZ
|
||||
|
||||
|
||||
def bru(y, mo, d, hh, mm=0):
|
||||
return datetime(y, mo, d, hh, mm, tzinfo=BRU).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def rs(today, tomorrow, now, hold):
|
||||
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
|
||||
|
||||
|
||||
def near(a, b, tol):
|
||||
"""`|a - b| < tol`, and False (not a crash) when `a` is missing."""
|
||||
return a is not None and abs(a - b) < tol
|
||||
|
||||
|
||||
def used(hold, now):
|
||||
f = rs(GONE, GONE, now, hold)[0]
|
||||
return f is not None
|
||||
|
||||
|
||||
def hold_of(read_at, today=0.4, tomorrow=12.0):
|
||||
h = solarfc.Hold()
|
||||
rs(st(today, read_at), st(tomorrow, read_at),
|
||||
read_at + timedelta(minutes=5), h)
|
||||
return h
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. solarfc: the evening figure serves until the bell starts")
|
||||
h17 = hold_of(at(3, 17, 5))
|
||||
check("a 17:05 reading is kept at 20:00, 22:00, 00:00, 03:00 and 05:59",
|
||||
all(used(h17, t) for t in (at(3, 20, 0), at(3, 22, 0), at(4, 0, 0),
|
||||
at(4, 3, 0), at(4, 5, 59))))
|
||||
f_late = rs(GONE, GONE, at(4, 5, 59), h17)[0]
|
||||
check("... at 05:59 it is tomorrow's 12.0 kWh as TODAY's, the whole day",
|
||||
f_late is not None and f_late.kept and f_late.today.kwh == 12.0
|
||||
and f_late.tomorrow is None and f_late.at == at(3, 17, 5)
|
||||
and f_late.between(at(4, 6, 0), at(4, 20, 0)) is not None
|
||||
and abs(f_late.between(at(4, 6, 0), at(4, 20, 0)) - 12.0) < 1e-6)
|
||||
check("... and at 06:00 (12 h 55 min old, the bell starts) it is not",
|
||||
not used(h17, at(4, 6, 0)) and not used(h17, at(4, 6, 30))
|
||||
and not used(h17, at(4, 12, 0)))
|
||||
check("... not the next evening either, and not on the 5th",
|
||||
not used(h17, at(4, 22, 0)) and not used(h17, at(5, 3, 0)))
|
||||
|
||||
# The age limit of this case: 15 h, for a figure that was usable at midnight.
|
||||
h1230 = hold_of(at(3, 12, 30))
|
||||
check("a 12:30 reading (11 h 30 min old at midnight): used until 03:29 "
|
||||
"(14 h 59 min old) ...",
|
||||
used(h1230, at(4, 0, 0)) and used(h1230, at(4, 3, 29)))
|
||||
check("... and not from 03:31 (15 h 01 min), though it is before 06:00",
|
||||
not used(h1230, at(4, 3, 31)) and not used(h1230, at(4, 5, 30)))
|
||||
h1600 = hold_of(at(3, 16, 0))
|
||||
check("a 16:00 reading: the 06:00 window ends it, not the 15 h limit "
|
||||
"(14 h old at 06:00)", used(h1600, at(4, 5, 59))
|
||||
and not used(h1600, at(4, 6, 0)))
|
||||
|
||||
# Not used: a morning figure at night (12 h), and no come-back after midnight.
|
||||
h0905 = hold_of(at(3, 9, 5))
|
||||
check("a 09:05 reading: kept at 20:00 (10 h 55 min), not at 22:00 (12 h 55 min)",
|
||||
used(h0905, at(3, 20, 0)) and not used(h0905, at(3, 22, 0)))
|
||||
check("... and it does not come back after midnight (14 h 55 min at 00:00, "
|
||||
"under the 15 h of the evening-to-morning case): it was not usable "
|
||||
"at midnight",
|
||||
not used(h0905, at(4, 0, 0)) and not used(h0905, at(4, 0, 2))
|
||||
and not used(h0905, at(4, 2, 0)))
|
||||
h1105 = hold_of(at(3, 11, 59))
|
||||
check("the same for a reading that is 12 h 01 min old at midnight",
|
||||
not used(h1105, at(4, 0, 0)) and not used(h1105, at(4, 0, 30)))
|
||||
h1201 = hold_of(at(3, 12, 1))
|
||||
check("... and one that is 11 h 59 min old at midnight is used",
|
||||
used(h1201, at(4, 0, 0)) and used(h1201, at(4, 3, 0)))
|
||||
check("a same-day figure past 12 h is not used: a 07:05 reading at 20:00 "
|
||||
"(12 h 55 min)", not used(hold_of(at(3, 7, 5)), at(3, 20, 0)))
|
||||
check("a figure over 15 h old is not used: 17:05 read, 08:06 next day "
|
||||
"(15 h 01 min, bell started)", not used(h17, at(4, 8, 6)))
|
||||
check("(a figure from the day before is not used either)",
|
||||
not used(hold_of(at(2, 17, 5)), at(3, 22, 0))
|
||||
and not used(hold_of(at(2, 17, 5)), at(4, 3, 0)))
|
||||
|
||||
# Today's figure is not tomorrow's, and the figure is for its own day only.
|
||||
hd = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), hd)
|
||||
fx = rs(GONE, GONE, at(4, 4, 0), hd)[0]
|
||||
check("04:00: today's sensor is gone: the 4th gets the 3rd's TOMORROW "
|
||||
"figure (12.0), never the 3rd's today figure (3.0)",
|
||||
fx is not None and fx.today.kwh == 12.0)
|
||||
fy = rs(st(5.0, at(4, 3, 50)), st(9.0, at(4, 3, 50)), at(4, 4, 0), hd)[0]
|
||||
check("a fresh figure replaces the kept one at once (04:00: 5.0 today, "
|
||||
"9.0 tomorrow; not kept)", fy is not None and not fy.kept
|
||||
and fy.today.kwh == 5.0 and fy.tomorrow.kwh == 9.0)
|
||||
fy2 = rs(st(5.0, at(4, 7, 55)), GONE, at(4, 8, 0), hd)[0]
|
||||
check("... and at 08:00, today fresh and tomorrow's sensor down for over 3 h: "
|
||||
"no tomorrow (the 3rd's figure is not the 5th's)",
|
||||
fy2 is not None and fy2.tomorrow is None and fy2.today.kwh == 5.0)
|
||||
|
||||
# Restart: the saved hold keeps the figure.
|
||||
for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
|
||||
back = solarfc.Hold(json.loads(json.dumps(hold_of(at(3, 17, 5)).dump())))
|
||||
fr = rs(GONE, GONE, now, back)[0]
|
||||
check(f"restart at {label}: the saved hold (FC_HOLD_KEY) gives the "
|
||||
"17:05 figure again", fr is not None and fr.kept
|
||||
and fr.today.kwh == 12.0 and fr.at == at(3, 17, 5))
|
||||
|
||||
# DST: the 25 h autumn day and the 23 h spring day. The window ends at 06:00
|
||||
# LOCAL, not 6 h after midnight.
|
||||
for (y, mo, d), label in (((2026, 10, 25), "25 h day 2026-10-25"),
|
||||
((2027, 3, 28), "23 h day 2027-03-28")):
|
||||
t17 = bru(y, mo, d - 1, 17, 5)
|
||||
hs = hold_of(t17)
|
||||
ok_in = used(hs, bru(y, mo, d - 1, 23, 59)) and all(
|
||||
used(hs, bru(y, mo, d, hh, mm)) for hh, mm in
|
||||
((0, 0), (1, 30), (4, 0), (5, 59)))
|
||||
check(f"{label}: the 17:05 figure serves 23:59 to 05:59 local", ok_in)
|
||||
check(f"... and not at 06:00 local", not used(hs, bru(y, mo, d, 6, 0))
|
||||
and not used(hs, bru(y, mo, d, 6, 30)))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. The real Controller, whole nights (parallel processes)")
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.26},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.34}]}')
|
||||
# The price dips at 04:00 (a dynamic site): the cheap run starts after 03:00.
|
||||
PROFILE_LATE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00",'
|
||||
' "to": "04:00", "eur_per_kwh": 0.28},'
|
||||
'{"name": "dawn", "days": "all", "from": "04:00",'
|
||||
' "to": "07:00", "eur_per_kwh": 0.20},'
|
||||
'{"name": "day", "days": "all", "from": "07:00",'
|
||||
' "to": "22:00", "eur_per_kwh": 0.34}]}')
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
LAST = at(3, 17, 5)
|
||||
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
|
||||
+ [at(3, 21, 55)]
|
||||
|
||||
|
||||
def opts(late=False, cap=2500):
|
||||
o = dict(rp.options(True, False, 2))
|
||||
o.update(cycle_cost_threshold_eur=0.07,
|
||||
static_profile=PROFILE_LATE if late else PROFILE,
|
||||
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT,
|
||||
peak_cap_w=cap)
|
||||
return o
|
||||
|
||||
|
||||
def sensors(*, silent=True, fresh_from=None, fresh=(1.0, 5.0),
|
||||
fresh_until=None):
|
||||
"""Sensors that update until 17:05 (or all night) and then show that
|
||||
reading for good; from `fresh_from` a new reading every call."""
|
||||
def f(now, ent):
|
||||
if fresh_from is not None and now >= fresh_from:
|
||||
when = now if fresh_until is None else min(now, fresh_until)
|
||||
return st(fresh[0] if ent == T_ENT else fresh[1], when)
|
||||
when = min(now, LAST) if silent else now
|
||||
return st(0.4 if ent == T_ENT else 6.0, when)
|
||||
return f
|
||||
|
||||
|
||||
def run_night(spec):
|
||||
"""One scenario in this process -> a plain dict. `spec`: start (UTC),
|
||||
hours, silent, fresh_from, fresh, restarts, soc, load_until_s, marks."""
|
||||
start = spec["start"]
|
||||
soc0 = spec.get("soc", {"a": 10.0, "b": 10.0})
|
||||
house_w = spec.get("house_w", 700.0)
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in CHOICE.items():
|
||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||
("pack_sell", sell)):
|
||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
||||
if spec.get("latch"):
|
||||
store.data[m.CHARGE_LATCH_KEY] = spec["latch"]
|
||||
sens = sensors(silent=spec.get("silent", True),
|
||||
fresh_from=spec.get("fresh_from"),
|
||||
fresh=spec.get("fresh", (1.0, 5.0)),
|
||||
fresh_until=spec.get("fresh_until"))
|
||||
clock = [EVENING[0]]
|
||||
|
||||
class H(rp.Hass):
|
||||
async def state(self, e):
|
||||
return GONE if spec.get("gone") else sens(clock[0], e)
|
||||
|
||||
hass = H(("a", "b"), lambda t: 300.0, soc0)
|
||||
out = dict(marks={}, soc_at={})
|
||||
|
||||
def build(sto):
|
||||
tmp = tempfile.mkdtemp(prefix="o34-")
|
||||
c = Controller(dict(opts(spec.get("late", False),
|
||||
spec.get("cap", 2500))), hass, sto,
|
||||
rp._Pub(),
|
||||
stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
c.house_profile.load({d: {str(hh): house_w for hh in range(24)}
|
||||
for d in ("2026-10-31", "2026-11-01",
|
||||
"2026-11-02")})
|
||||
return c
|
||||
|
||||
async def go():
|
||||
cur = store
|
||||
c = build(cur)
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
try:
|
||||
for t_pre in EVENING:
|
||||
clock[0] = t_pre
|
||||
await c._read_forecast()
|
||||
pending = sorted(spec.get("restarts", ()))
|
||||
marks = sorted(spec.get("marks", ()))
|
||||
first, t = True, 0.0
|
||||
hz = spec["hours"] * 3600.0
|
||||
while t < hz:
|
||||
now = start + timedelta(seconds=t)
|
||||
clock[0] = now
|
||||
if pending and now >= pending[0]:
|
||||
pending.pop(0)
|
||||
data = json.loads(json.dumps(cur.data))
|
||||
cur = rp._Store()
|
||||
cur.data.update(data)
|
||||
c = build(cur)
|
||||
first = True
|
||||
if first or int(t) % 600 == 0:
|
||||
# The tariff, resolved here at its own pace: the real
|
||||
# refresh task sleeps on the wall clock.
|
||||
hor = float(m.price_option(c.o, "price_horizon_h"))
|
||||
c.tariff_plan = await m.resolve_tariff(
|
||||
c.o, now, now + timedelta(hours=hor), now=now,
|
||||
history=c.price_history, watch=c.price_watch)
|
||||
c.tariff_at = now
|
||||
first = False
|
||||
hass.t = t
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(5.0, 10.0)
|
||||
if int(t) % 300 == 0:
|
||||
out["soc_at"][now.astimezone(L).strftime("%H:%M")] = (
|
||||
hass.plants["a"].soc, hass.plants["b"].soc,
|
||||
hass.plants["a"].p, c.solar_fc is None,
|
||||
bool(c.solar_fc and c.solar_fc.kept),
|
||||
None if c.plan_targets() is None
|
||||
else c.plan_targets().get(A))
|
||||
while marks and now >= marks[0]:
|
||||
marks.pop(0)
|
||||
cards = homeview.battery_rows(c, False, True)
|
||||
blk = homeview.solar_block(c, now)
|
||||
a_card = next(b for b in cards if b["name"] == A)
|
||||
out["marks"][now.astimezone(L).strftime("%H:%M")] = dict(
|
||||
line=blk["line"], state=blk["state"],
|
||||
why_a=a_card["why_key"],
|
||||
fc_none=c.solar_fc is None,
|
||||
kept=bool(c.solar_fc and c.solar_fc.kept),
|
||||
target_a=(c.plan_targets() or {}).get(A),
|
||||
soc_a=hass.plants["a"].soc,
|
||||
p_a=hass.plants["a"].p,
|
||||
charging=hass.plants["b"].p < -200.0)
|
||||
t += 5.0
|
||||
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
|
||||
out["latch"] = getattr(c, "_charge_latch", None) is not None
|
||||
finally:
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
return out
|
||||
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
NIGHT = at(3, 22, 0)
|
||||
SCEN = {
|
||||
"fresh": dict(start=NIGHT, hours=9, silent=False,
|
||||
marks=[at(4, 4, 30)]),
|
||||
"silent": dict(start=NIGHT, hours=9,
|
||||
marks=[at(4, 1, 0), at(4, 4, 30), at(4, 6, 30)]),
|
||||
"restart": dict(start=NIGHT, hours=9,
|
||||
restarts=[at(4, 1, 0), at(4, 4, 0)],
|
||||
marks=[at(4, 1, 30), at(4, 4, 30)]),
|
||||
# c. the charge cannot start before 03:00 (the price dips at 04:00)
|
||||
"late": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0,
|
||||
soc={"a": 40.0, "b": 70.0}, marks=[at(4, 3, 30)]),
|
||||
"late_fresh": dict(start=at(4, 2, 30), hours=4.5, late=True,
|
||||
house_w=500.0, soc={"a": 40.0, "b": 70.0},
|
||||
silent=False),
|
||||
# d. a fresh, darker figure at 02:00 during the charge (a lighter house
|
||||
# use, so the level has room to rise)
|
||||
"dark": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
|
||||
house_w=500.0, fresh_from=at(4, 2, 0), fresh=(1.0, 5.0),
|
||||
marks=[at(4, 1, 59), at(4, 2, 5)]),
|
||||
"kept": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
|
||||
house_w=500.0, marks=[at(4, 1, 59), at(4, 2, 5)]),
|
||||
# before the charge starts: a fresh figure replaces the kept one, up and
|
||||
# down, at once
|
||||
"pre_kept": dict(start=at(4, 2, 30), hours=0.25, late=True,
|
||||
house_w=500.0, marks=[at(4, 2, 44)]),
|
||||
"pre_dark": dict(start=at(4, 2, 30), hours=0.25, late=True,
|
||||
house_w=500.0, fresh_from=at(4, 2, 40),
|
||||
fresh=(1.0, 5.0), marks=[at(4, 2, 44)]),
|
||||
"pre_bright": dict(start=at(4, 2, 30), hours=0.25, late=True,
|
||||
house_w=500.0, fresh_from=at(4, 2, 40),
|
||||
fresh=(30.0, 30.0), marks=[at(4, 2, 44)]),
|
||||
# d. the last hour, after the kept figure ended at 06:00
|
||||
"tail": dict(start=at(4, 5, 40), hours=80 / 60.0,
|
||||
soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)],
|
||||
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]),
|
||||
# a fresh, brighter figure at 03:10 lowers A's level below its SoC (A
|
||||
# stops, B goes on), then the sensors go quiet: the forecast ends at 06:10
|
||||
"lowered": dict(start=at(4, 2, 30), hours=4.5, house_w=500.0,
|
||||
soc={"a": 30.0, "b": 30.0}, fresh_from=at(4, 3, 10),
|
||||
fresh_until=at(4, 3, 10), fresh=(30.0, 30.0),
|
||||
marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]),
|
||||
# F1: the add-on was down when the latch of the night before ended; it is
|
||||
# back inside a cheap run, with the sensors unavailable (no forecast)
|
||||
"stale": dict(start=at(3, 22, 0), hours=0.5, gone=True,
|
||||
latch={"until": at(3, 7, 15).isoformat(),
|
||||
"born": at(2, 23, 0).isoformat(),
|
||||
"levels": {A: 80.0, B: 80.0}},
|
||||
marks=[at(3, 22, 20)]),
|
||||
"stale_live": dict(start=at(3, 22, 0), hours=0.5, gone=True,
|
||||
latch={"until": at(4, 7, 15).isoformat(),
|
||||
"born": at(3, 22, 0).isoformat(),
|
||||
"levels": {A: 80.0, B: 80.0}},
|
||||
marks=[at(3, 22, 20)]),
|
||||
# the same, B already full and a 1.5 kW limit: the run is sized at the
|
||||
# battery's 2.5 kW rating and ends at 06:33, before A is there, so the
|
||||
# next slice is planned after 06:00 and is only A's
|
||||
"tail_bfull": dict(start=at(4, 5, 40), hours=80 / 60.0, cap=1500,
|
||||
soc={"a": 65.0, "b": 95.0},
|
||||
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 45)]),
|
||||
}
|
||||
t0 = time.time()
|
||||
with ProcessPoolExecutor(max_workers=min(6, os.cpu_count() or 2),
|
||||
mp_context=get_context("fork")) as pool:
|
||||
futs = {k: pool.submit(run_night, v) for k, v in SCEN.items()}
|
||||
R = {k: f.result() for k, f in futs.items()}
|
||||
print(f" {len(R)} scenarios in {time.time() - t0:.0f} s")
|
||||
|
||||
|
||||
def show(k):
|
||||
r = R[k]
|
||||
print(f" {k:10} A {r['soc'][0]:.2f} % B {r['soc'][1]:.2f} % "
|
||||
f"marks {', '.join(sorted(r['marks']))}")
|
||||
|
||||
|
||||
for k in R:
|
||||
show(k)
|
||||
fa, si, rr = R["fresh"], R["silent"], R["restart"]
|
||||
|
||||
# a. silent from 17:05
|
||||
check("a fresh figure all night: A ends at its planned level (87 %), B "
|
||||
"full", abs(fa["soc"][0] - 86.9) < 0.5 and fa["soc"][1] > 94.5)
|
||||
check("sensors silent from 17:05: A ends where the fresh run ends it "
|
||||
f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %)",
|
||||
abs(si["soc"][0] - fa["soc"][0]) < 0.3)
|
||||
check("... and so does B (Cheap hours; the wide no-forecast gap does not "
|
||||
f"stop its slices): {si['soc'][1]:.1f} against {fa['soc'][1]:.1f} %",
|
||||
abs(si["soc"][1] - fa["soc"][1]) < 0.3)
|
||||
check("A charges through 03:00 without a break (no step at 03:00)",
|
||||
si["soc_at"]["03:30"][0] > si["soc_at"]["03:00"][0] + 4.0
|
||||
and si["soc_at"]["03:00"][0] > si["soc_at"]["02:55"][0] + 0.3)
|
||||
m1, m4 = si["marks"]["01:00"], si["marks"]["04:30"]
|
||||
check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
|
||||
"line says so (EN)",
|
||||
not m1["fc_none"] and m1["kept"] and not m4["fc_none"] and m4["kept"]
|
||||
and m4["state"] == "plan"
|
||||
and m4["line"] == "Solar forecast read 17:05, kept for tonight."
|
||||
and m4["target_a"] is not None
|
||||
and near(m4["target_a"], fa["marks"]["04:30"]["target_a"], 1e-9))
|
||||
check("... and the level is the fresh run's (the kept figure counts as a "
|
||||
"fresh one)", near(m4["target_a"], 86.87, 0.2))
|
||||
m6 = si["marks"]["06:30"]
|
||||
check("06:30: the kept figure has ended (the bell started) and the screen "
|
||||
"does not say 'kept' (the charge was done by then in this shape)",
|
||||
m6["fc_none"] and "kept" not in m6["line"])
|
||||
|
||||
# b. restarts
|
||||
check("restarts at 01:00 and 04:00 (a new Controller on the saved store): "
|
||||
f"A {rr['soc'][0]:.1f}, B {rr['soc'][1]:.1f} % as without them",
|
||||
abs(rr["soc"][0] - si["soc"][0]) < 0.3
|
||||
and abs(rr["soc"][1] - si["soc"][1]) < 0.3)
|
||||
check("... with the figure kept right after each restart",
|
||||
all(rr["marks"][k]["kept"] and not rr["marks"][k]["fc_none"]
|
||||
and rr["marks"][k]["target_a"] is not None
|
||||
for k in ("01:30", "04:30")))
|
||||
|
||||
# c. a charge that cannot start before 03:00
|
||||
la, lf = R["late"], R["late_fresh"]
|
||||
ml = la["marks"]["03:30"]
|
||||
check("the cheap hours start at 04:00: nothing charges at 03:30",
|
||||
ml["soc_a"] < 40.1 and not ml["charging"])
|
||||
check("... the kept figure still stands at 03:30 (no charge had started, so "
|
||||
"no level to latch): the plan is kept, not 'no forecast'",
|
||||
not ml["fc_none"] and ml["kept"] and ml["target_a"] is not None)
|
||||
check("... and the charge starts at 04:00 on it: A ends where a fresh "
|
||||
f"figure ends it ({la['soc'][0]:.1f} against {lf['soc'][0]:.1f} %)",
|
||||
lf["soc"][0] > 50.0 and abs(la["soc"][0] - lf["soc"][0]) < 0.3
|
||||
and abs(la["soc"][1] - lf["soc"][1]) < 0.3)
|
||||
|
||||
# d. a fresh figure replaces the kept one at once
|
||||
dk, kp = R["dark"], R["kept"]
|
||||
check("a fresh, darker figure at 02:00 during the charge: the level goes "
|
||||
"UP at once (02:05)",
|
||||
kp["marks"]["02:05"]["target_a"] is not None
|
||||
and dk["marks"]["02:05"]["target_a"] is not None
|
||||
and dk["marks"]["02:05"]["target_a"] > kp["marks"]["02:05"]["target_a"]
|
||||
+ 3.0 and not dk["marks"]["02:05"]["kept"]
|
||||
and dk["marks"]["01:59"]["kept"])
|
||||
check("... and A charges on to it (more than on the kept figure: "
|
||||
f"{dk['soc'][0]:.1f} against {kp['soc'][0]:.1f} %)",
|
||||
dk["soc"][0] > kp["soc"][0] + 3.0)
|
||||
pk, pd, pb = R["pre_kept"], R["pre_dark"], R["pre_bright"]
|
||||
t_k, t_d, t_b = (x["marks"]["02:44"]["target_a"] for x in (pk, pd, pb))
|
||||
print(f" level before any charge, 02:44: kept {t_k}, dark {t_d}, "
|
||||
f"bright {t_b}")
|
||||
check("before a charge has started (nothing charges at 02:44): a darker "
|
||||
"fresh figure raises the level and a brighter one lowers it, at once",
|
||||
all(not x["marks"]["02:44"]["charging"] for x in (pk, pd, pb))
|
||||
and t_k is not None and t_d is not None and t_b is not None
|
||||
and t_d > t_k + 3.0 and t_b < t_k - 3.0
|
||||
and not pd["marks"]["02:44"]["kept"]
|
||||
and not pb["marks"]["02:44"]["kept"])
|
||||
|
||||
# e. the last hour, after the kept figure ended
|
||||
tl = R["tail"]
|
||||
t5, t6, t7 = (tl["marks"][k] for k in ("05:50", "06:10", "06:40"))
|
||||
check("05:50: charging on the kept figure; 06:10: the figure has ended",
|
||||
t5["kept"] and not t5["fc_none"] and t6["fc_none"])
|
||||
check("06:10 and 06:40 (the second after a restart at 06:30): A still "
|
||||
"charges to its level, 'charges from the grid' is the Why",
|
||||
t6["p_a"] < -500.0 and t7["p_a"] < -500.0
|
||||
and t6["why_a"] == "grid_charge" and t7["why_a"] == "grid_charge")
|
||||
check("... and ends the hour higher than it was (+8 % or more from 06:00)",
|
||||
tl["soc_at"]["06:55"][0] > tl["soc_at"]["06:00"][0] + 8.0)
|
||||
lo = R["lowered"]
|
||||
l0, l1, l2 = (lo["marks"][k] for k in ("03:05", "03:20", "06:30"))
|
||||
check("a fresh, brighter figure lowers A's level below its SoC during the "
|
||||
f"charge ({l0['target_a']} -> {l1['target_a']}, SoC "
|
||||
f"{l1['soc_a']:.1f} %), and A stops",
|
||||
l0["target_a"] is not None and l1["target_a"] is not None
|
||||
and l1["target_a"] < l1["soc_a"] - 2.0
|
||||
and l1["target_a"] < l0["target_a"] - 10.0
|
||||
and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5)
|
||||
check("the sensors go quiet, the forecast ends (06:30): the latch holds the "
|
||||
"LOWERED level, so A does not charge back up to the old one "
|
||||
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), "
|
||||
"while B goes on",
|
||||
l2["fc_none"] and l2["target_a"] is not None
|
||||
and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0
|
||||
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0
|
||||
and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0)
|
||||
st_, sl_ = R["stale"], R["stale_live"]
|
||||
check("F1: a latch from the night before (ended 07:15) loaded at a restart "
|
||||
"inside a cheap run, with no forecast: nothing grid-charges "
|
||||
f"(A {st_['soc'][0]:.1f}, B {st_['soc'][1]:.1f} %) and the line does "
|
||||
"not say the charge goes on",
|
||||
st_["soc"][0] < 10.2 and st_["soc"][1] < 10.2
|
||||
and st_["marks"]["22:20"]["state"] == "no_forecast"
|
||||
and not st_["latch"])
|
||||
check("(control: the same latch, still valid until tomorrow 07:15, does "
|
||||
f"charge both: A {sl_['soc'][0]:.1f}, B {sl_['soc'][1]:.1f} %)",
|
||||
sl_["soc"][0] > 11.0 and sl_["soc"][1] > 11.0
|
||||
and sl_["marks"]["22:20"]["state"] == "latched")
|
||||
tb = R["tail_bfull"]
|
||||
check("B full: the run ends at 06:33 before A is at its level, so the next "
|
||||
"slice is only A's, planned after the figure has ended with A's "
|
||||
"latched level: A goes on charging (06:10, 06:45; "
|
||||
f"{tb['soc_at']['06:00'][0]:.1f} to {tb['soc_at']['06:55'][0]:.1f} %)",
|
||||
tb["marks"]["06:10"]["fc_none"] and tb["marks"]["06:10"]["p_a"] < -500.0
|
||||
and tb["marks"]["06:45"]["p_a"] < -500.0
|
||||
and tb["soc_at"]["06:55"][0] > tb["soc_at"]["06:00"][0] + 8.0)
|
||||
check("the screens say what control does (EN): 'No solar forecast now. The "
|
||||
"charge under way goes on to its planned level.'",
|
||||
t6["state"] == "latched" and t7["state"] == "latched"
|
||||
and t6["line"] == "No solar forecast now. The charge under way goes "
|
||||
"on to its planned level.")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. The text (EN and NL)")
|
||||
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
||||
encoding="utf-8"))
|
||||
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
|
||||
encoding="utf-8"))
|
||||
l_en, l_nl = (x["home_words"]["solar"]["latched"] for x in (en, nl))
|
||||
print(f" EN {l_en!r}\n NL {l_nl!r}")
|
||||
check("the new line is short and plain in both languages and the NL is not "
|
||||
"the English", l_en != l_nl and len(l_en.split()) <= 24
|
||||
and len(l_nl.split()) <= 24 and "{" not in l_en + l_nl)
|
||||
check("'kept for tonight' is unchanged (EN and NL)",
|
||||
en["home_words"]["solar"]["kept"]
|
||||
== "Solar forecast read {at}, kept for tonight."
|
||||
and nl["home_words"]["solar"]["kept"]
|
||||
== "Zonneprognose van {at}, bewaard voor vannacht.")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. The latch alone: a battery that leaves the plan leaves the latch")
|
||||
from types import SimpleNamespace # noqa: E402
|
||||
from app import modetrade, priceruns # noqa: E402
|
||||
|
||||
CLOCK = [at(4, 1, 0)]
|
||||
_dt0 = m.datetime
|
||||
|
||||
|
||||
class _FixedDT(_dt0):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return CLOCK[0] if tz is None else CLOCK[0].astimezone(tz)
|
||||
|
||||
|
||||
m.datetime = homeview.datetime = _FixedDT
|
||||
_tmp = tempfile.mkdtemp(prefix="o34-")
|
||||
lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0,
|
||||
{"a": 40.0, "b": 40.0}),
|
||||
rp._Store(), rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(_tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(_tmp, "ph.json"))
|
||||
n0 = at(4, 1, 0)
|
||||
lruns = SimpleNamespace(cheap=priceruns.Run(at(3, 22, 0), at(4, 7, 0), 0.26))
|
||||
charging = SimpleNamespace(action=modetrade.CHARGE,
|
||||
roles={A: modetrade.CHARGE, B: modetrade.CHARGE})
|
||||
resting = SimpleNamespace(action=None, roles={A: modetrade.HOLD,
|
||||
B: modetrade.HOLD})
|
||||
|
||||
|
||||
def latch_step(fc, tick, targets, now=n0, runs=None, packs=None):
|
||||
CLOCK[0] = now
|
||||
lc.solar_fc, lc.mode_tick = fc, tick
|
||||
lc._latch_charge(now, runs or lruns, targets, packs)
|
||||
return lc._charge_latch, lc.store.data.get(m.CHARGE_LATCH_KEY)
|
||||
|
||||
|
||||
FC = object() # a forecast is read (fresh or kept)
|
||||
lt, saved = latch_step(FC, charging, {A: 80.0, B: 70.0})
|
||||
check("both batteries charge with a forecast: both are latched at their "
|
||||
"levels, to the run's end + one slot, and saved",
|
||||
lt is not None and lt[1] == {A: 80.0, B: 70.0}
|
||||
and lt[0] == at(4, 7, 15) and saved is not None
|
||||
and saved["levels"] == {A: 80.0, B: 70.0})
|
||||
lt, saved = latch_step(FC, charging, {B: 70.0})
|
||||
check("A leaves the plan (its mode or use changed): A is dropped from the "
|
||||
"latch at once, B stays", lt is not None and lt[1] == {B: 70.0}
|
||||
and saved["levels"] == {B: 70.0})
|
||||
lc.solar_fc = None
|
||||
check("... and with the forecast then gone, A has no latched level (it is "
|
||||
"not charged back to 80 %)", lc.plan_targets() == {B: 70.0})
|
||||
lt, saved = latch_step(FC, resting, {A: 80.0, B: 70.0})
|
||||
check("A back in the plan while nothing charges: it does not rejoin",
|
||||
lt is not None and lt[1] == {B: 70.0})
|
||||
lt, saved = latch_step(FC, resting, {A: 80.0})
|
||||
check("B leaves the plan too: the latch is empty, so it is gone and "
|
||||
"cleared from the store", lt is None and saved is None
|
||||
and lc.plan_targets() is None)
|
||||
latch_step(FC, charging, {A: 80.0})
|
||||
lc.solar_fc = None
|
||||
ia = next((i for i in lc.inverters if i.name == A), None)
|
||||
st_below = st_at = st_after = None
|
||||
lc.mode_runs = lruns
|
||||
if ia is not None:
|
||||
ia.soc = 50.0
|
||||
st_below = homeview.plan_state(lc)
|
||||
CLOCK[0] = at(4, 7, 5) # the run has ended, the latch has not
|
||||
st_after = homeview.plan_state(lc)
|
||||
CLOCK[0] = n0
|
||||
ia.soc = 80.0
|
||||
st_at = homeview.plan_state(lc)
|
||||
check("the screen says the charge goes on only while the latched battery is "
|
||||
f"below its level ({st_below!r} at 50 %, {st_at!r} at 80 %)",
|
||||
st_below == "latched" and st_at == "no_forecast")
|
||||
check("F2: and only while a cheap run is on: at 07:05, the run over and the "
|
||||
f"latch in its grace slot, it does not ({st_after!r})",
|
||||
st_after == "no_forecast")
|
||||
|
||||
print("\n5. The latch ends: expiry, a changed mode, the night's bell")
|
||||
long_run = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2))
|
||||
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
||||
lt, saved = latch_step(None, resting, None, now=at(4, 7, 20), runs=long_run)
|
||||
check("F1: past its end (07:15) the latch is dropped even with a cheap run "
|
||||
"on and no forecast (it was only dropped when no run held)",
|
||||
lt is None and saved is None)
|
||||
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
||||
CLOCK[0] = at(4, 7, 20)
|
||||
lc.solar_fc = None
|
||||
check("... plan_targets() ignores a latch past its end", lc.plan_targets()
|
||||
is None)
|
||||
CLOCK[0] = at(4, 1, 0)
|
||||
check("... and _load_latch() does not bring one back from the store",
|
||||
m._load_latch({"until": at(4, 7, 15).isoformat(), "born":
|
||||
at(4, 1, 0).isoformat(), "levels": {A: 80.0}},
|
||||
at(4, 7, 20)) is None
|
||||
and m._load_latch({"until": at(4, 7, 15).isoformat(), "born":
|
||||
at(4, 1, 0).isoformat(), "levels": {A: 80.0}},
|
||||
at(4, 6, 0)) is not None)
|
||||
|
||||
# F3: the mode or use of a latched battery changes
|
||||
def packs_of(a_mode, a_use=True):
|
||||
return [modetrade.Pack(A, a_use, a_mode, False, 2500.0, 50.0, 10.0),
|
||||
modetrade.Pack(B, True, "cheap_hours", False, 2500.0, 50.0, 10.0)]
|
||||
|
||||
|
||||
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
||||
lt, _s = latch_step(None, resting, None, packs=packs_of("leftover_solar"))
|
||||
check("F3: A is still Leftover solar and on: it stays latched",
|
||||
lt is not None and lt[1] == {A: 80.0})
|
||||
lt, saved = latch_step(None, resting, None, packs=packs_of("solar_first"))
|
||||
check("F3: the user changes A to Solar first (no forecast): it leaves the "
|
||||
"latch, which is then gone and cleared, so the narrow gap goes too",
|
||||
lt is None and saved is None)
|
||||
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
||||
lt, _s = latch_step(None, resting, None,
|
||||
packs=packs_of("leftover_solar", a_use=False))
|
||||
check("F3: or switches A off", lt is None)
|
||||
|
||||
# F4: the night's bell
|
||||
lc._charge_latch = None
|
||||
lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0))
|
||||
check("F4: the latch began at 01:00, so its night's bell is 06:00 the same "
|
||||
"day", m._night_limit(lt[2]) == at(4, 6, 0)
|
||||
and m._night_limit(at(3, 23, 0)) == at(4, 6, 0)
|
||||
and m._night_limit(at(4, 6, 0)) == at(5, 6, 0))
|
||||
r1 = SimpleNamespace(cheap=priceruns.Run(at(4, 0, 0), at(4, 7, 0), .2))
|
||||
r2 = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2))
|
||||
r3 = SimpleNamespace(cheap=priceruns.Run(at(4, 6, 0), at(4, 9, 0), .2))
|
||||
lc._charge_latch = None
|
||||
lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0), runs=r1)
|
||||
check("F4: first run 00:00-07:00 sets the end at 07:15", lt[0] == at(4, 7, 15))
|
||||
lt, _s = latch_step(None, resting, None, now=at(4, 6, 50), runs=r1)
|
||||
lt2, _s = latch_step(None, resting, None, now=at(4, 7, 5), runs=r2)
|
||||
check("F4: a back-to-back run that starts after the bell (07:00-23:00) does "
|
||||
"NOT carry it on: still 07:15", lt2 is not None
|
||||
and lt2[0] == at(4, 7, 15))
|
||||
lt3, _s = latch_step(None, resting, None, now=at(4, 7, 20), runs=r2)
|
||||
check("F4: and at 07:20 it has ended, though the run goes on", lt3 is None)
|
||||
lc._charge_latch = (at(4, 6, 10), {A: 80.0}, at(4, 1, 0))
|
||||
lt, _s = latch_step(None, resting, None, now=at(4, 6, 5), runs=r3)
|
||||
check("F4: a run that starts AT the bell (06:00-09:00) does carry it on, "
|
||||
"to its end + one slot", lt is not None and lt[0] == at(4, 9, 15))
|
||||
m.datetime = homeview.datetime = _dt0
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -609,6 +609,10 @@ print("\n5. modes off: byte-identical to release/1.0 e3e4ba6")
|
||||
# AT_BASE the same rows through `ident.trade_digest()`, equal to what
|
||||
# e3e4ba6 gives through it;
|
||||
# AT_NOW this tree's own digests with nothing hidden.
|
||||
# SAFETY-125 (0.19.2) moved the two lag pins: their P1 sample is 0.35 s old on
|
||||
# every cycle, and a grid charge now counts those seconds at the held reading,
|
||||
# so 16 of 1080 cycles charge about 1.7 W less (before: n1_lag f6c67b35...
|
||||
# masked, 78aac892... full; n2_lag as n2). n1 and n2 still equal e3e4ba6.
|
||||
AT_BASE_OLD = {
|
||||
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
@@ -618,14 +622,14 @@ AT_BASE_OLD = {
|
||||
AT_BASE = {
|
||||
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
||||
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||
"n1_lag": "f6c67b351bd732fb69e62cafbfdb2bcff6032f5d8845836659d096b8c677d3c0",
|
||||
"n2_lag": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||
"n1_lag": "28e3182ae73c52dae0e65b2495bc04b2b71003889bf9de1a394b047d2c0e0edf",
|
||||
"n2_lag": "45bfb095b5707a91316f56afc408dba616afc729ce4c83a10b337f36948fa3f2",
|
||||
}
|
||||
AT_NOW = {
|
||||
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
|
||||
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||
"n1_lag": "78aac892975be81a7ceac0c000ed441a731bd4f42ff9f729996cbee53d6b7f02",
|
||||
"n2_lag": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||
"n1_lag": "1827918d570bfa0cc371c35dbbb64378b4937557061e7e1a396aba7663e68117",
|
||||
"n2_lag": "6acc1d1b5a11f3481c7a30d384612f37b59f8e5e9cf89204edd07d09a5b90d8d",
|
||||
}
|
||||
_cmd_digest = ident.trade_digest
|
||||
_rows5 = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_BASE}
|
||||
@@ -636,7 +640,8 @@ for _k in AT_BASE:
|
||||
f"{_got[_k][:16]}")
|
||||
check("with battery modes off every command, integrator, allocation and "
|
||||
"trade action equals e3e4ba6's (the sentence masked as above), also when "
|
||||
"every quarter boundary's cycle still sees the old P1 block",
|
||||
"every quarter boundary's cycle still sees the old P1 block (there, as "
|
||||
"SAFETY-125 moved it)",
|
||||
_got == AT_BASE)
|
||||
check("...the mask is needed and nothing more: unmasked, the digests differ "
|
||||
"from e3e4ba6's printed ones, and equal this tree's own pins, so a "
|
||||
|
||||
@@ -19,7 +19,9 @@ DECISIONS/SAFETY-121-bound-every-quarter.md.
|
||||
1. The rig's shape (1 s meter that lags the battery, 1 s battery lag, house
|
||||
800 W, rating 2500 W, limit 2500 W, margin 50, charge 2000 W, no
|
||||
forecast), 2 h, three seeds: no notification, the cap held, every command
|
||||
the same as without the fix; without the fix it warns.
|
||||
the same as without the fix; without the fix it warns. SAFETY-125 sized
|
||||
the N=1 charge to the room, which removed this hand-back; these runs keep
|
||||
the old sizing, and one run checks that with it nothing hands back.
|
||||
2. A genuine integrator bound still warns: the loop in charge with the house
|
||||
over the rating (at once, as before), and the loop at its rail after a
|
||||
hand-back (after the settle).
|
||||
@@ -140,10 +142,29 @@ def house(seed, base=800.0, step=None):
|
||||
return load
|
||||
|
||||
|
||||
_SIZED_CH = Controller._charge_headroom
|
||||
|
||||
|
||||
def _n1_unsized(self, floor, claims):
|
||||
"""The N=1 cheap-window charge as before SAFETY-125: not sized to the
|
||||
quarter's room, so the guard steps in at each quarter's end and hands
|
||||
back - the shape this file grades the settle on."""
|
||||
return floor if len(self.inverters) < 2 \
|
||||
else _SIZED_CH(self, floor, claims)
|
||||
|
||||
|
||||
def run(load, *, n=1, dur=7200.0, delay=1, extra=None, cheap=True, cut=None,
|
||||
charging=True, blind=0.0):
|
||||
charging=True, blind=0.0, sized=False):
|
||||
"""One run of `n` inverters of 2500 W; `charging=False`: no maintenance,
|
||||
the loop in charge; `blind`: no meter reading until that second."""
|
||||
the loop in charge; `blind`: no meter reading until that second.
|
||||
SAFETY-125 sizes the N=1 charge to the room, so it no longer hands back
|
||||
at a quarter's end; an N=1 charge here keeps the old sizing unless
|
||||
`sized`, so the hand-back this file is about still happens."""
|
||||
if n == 1 and charging and not sized:
|
||||
return patched(Controller, "_charge_headroom", _n1_unsized)(
|
||||
lambda: run(load, n=n, dur=dur, delay=delay, extra=extra,
|
||||
cheap=cheap, cut=cut, charging=charging,
|
||||
blind=blind, sized=True))
|
||||
extra = dict(extra or {})
|
||||
s84.OPT = dict(OPT0, peak_cap_w=int(CAP * n), peak_margin_w=50,
|
||||
maintenance_charge_w=2000)
|
||||
@@ -231,6 +252,15 @@ check("non-vacuous: without the fix every run warns, at a quarter's edge",
|
||||
all(len(notes(c)) >= 1 for _r, c in OLD.values())
|
||||
and all(min(t % QUARTER, QUARTER - t % QUARTER) <= 40
|
||||
for _r, c in OLD.values() for t in notes(c)))
|
||||
_sz = run(house(1), sized=True)
|
||||
print(f" SAFETY-125's sizing, seed 1: guard step-ins "
|
||||
f"{guard_steps(_sz[1])}, notifications {notes(_sz[1])}, highest "
|
||||
f"quarter {max(quarters(_sz[1]), default=0.0):.0f} W")
|
||||
check("SAFETY-125: with the N=1 charge sized to the room the guard does not "
|
||||
"step in at the quarter's end, nothing warns and the cap holds",
|
||||
not any(r[-1][1] and r[-1][1][0] for r in _sz[0])
|
||||
and notes(_sz[1]) == [] and any(r[1] < -1000.0 for r in _sz[0])
|
||||
and len(quarters(_sz[1])) == 8 and max(quarters(_sz[1])) < CAP)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a genuine integrator bound still warns")
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,473 @@
|
||||
"""Runnable check for SAFETY-126. `python3 test_safety126.py`
|
||||
|
||||
During the night grid charge, with both batteries under the peak reserve,
|
||||
the add-on logged ERROR PEAK_GUARD_FLOOR_LIMITED and raised "Battery stopped
|
||||
limiting the peak" at quarter ends, and the Batteries "Why" blinked "The peak
|
||||
limit needs power from the batteries.", while every quarter landed on the aim.
|
||||
The cause: the charge lands the quarter on the aim, a 1 W landing error in
|
||||
the quarter's last second becomes tens of kW through `excess_w()`'s
|
||||
900 / remaining, and the alarm fired on any `wanted > 0` when no battery can
|
||||
give. DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md.
|
||||
|
||||
Drives the REAL Controller (read_inputs + cycle, the tariff task on a
|
||||
day/night profile, a P1 meter on a simulated clock, OPT-15's lag), as
|
||||
test_safety125.py does, with the cycle a fraction of a second off the
|
||||
quarter's edge as on the rig.
|
||||
|
||||
1. The night grid charge under the reserve: no ERROR, no notification, no
|
||||
red peak row, no "Why" peak, every quarter on the aim; the guard did want
|
||||
power at the quarter's end (not vacuous); with the fix reverted the same
|
||||
run alarms. The commands are the same with and without the fix.
|
||||
2. A real overrun under the reserve still alarms, a step load at the same
|
||||
cycle as before, and names both batteries. After it, the latch is reset:
|
||||
the night's later landings are quiet again.
|
||||
3. `misses_aim()` on its own.
|
||||
4. The page and the "Why" on a decision within the aim.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
import app.p1 as p1mod # noqa: E402
|
||||
from app import homeview, messages, peakguard, tariff # noqa: E402
|
||||
from app import price as _price, static_tariff as _st # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
from app.tariff import QUARTER_S, Projection # noqa: E402
|
||||
|
||||
logging.disable(logging.NOTSET)
|
||||
logging.getLogger().setLevel(logging.CRITICAL)
|
||||
_ERRORS = io.StringIO()
|
||||
_h = logging.StreamHandler(_ERRORS)
|
||||
_h.setLevel(logging.ERROR)
|
||||
logging.getLogger("goodwe").addHandler(_h)
|
||||
logging.getLogger("goodwe").setLevel(logging.ERROR)
|
||||
logging.getLogger("goodwe").propagate = False
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
PEAK = 5413.17 # October's banked peak on the live site
|
||||
AIM_CAP = 3500.0 - 50.0
|
||||
AIM_PEAK = PEAK - 50.0
|
||||
TOL = 10.0 # W over the aim a quarter may land (SAFETY-81)
|
||||
L = timezone(timedelta(hours=1))
|
||||
NIGHT = datetime(2026, 10, 30, 22, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.2333},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.2801}]}')
|
||||
OWNER = {"a": "leftover_solar", "b": "cheap_hours"}
|
||||
|
||||
|
||||
def opts(*, modes=True, arb=False):
|
||||
o = dict(rp.options(modes, arb, 2))
|
||||
o.update(cycle_cost_threshold_eur=0.03, static_profile=PROFILE,
|
||||
peak_cap_w=3500, peak_margin_w=50)
|
||||
o["inverters"][0].update(max_w=5000, capacity_kwh=15.36)
|
||||
o["inverters"][1].update(max_w=4600, capacity_kwh=20.48)
|
||||
return o
|
||||
|
||||
|
||||
class Harder(rp.Plant):
|
||||
"""Charges harder than asked, as inverter-a did live (test_safety125.py:
|
||||
asked -4271 W, measured about -4750 W; asked -5000 W, about -5210 W), up
|
||||
to `cap` W. So a charge sized to the aim lands a few W over it, as on the
|
||||
rig (worst +7 W)."""
|
||||
cap = -5216.0
|
||||
|
||||
def step(self, dt, floor):
|
||||
c = self.cmd
|
||||
if c < 0:
|
||||
self.cmd = max(c * 1.11, self.cap)
|
||||
try:
|
||||
super().step(dt, floor)
|
||||
finally:
|
||||
self.cmd = c
|
||||
|
||||
|
||||
async def _no_fetch(*a, **k):
|
||||
raise AssertionError("the tariff task reached for a price feed")
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
"""release/1.0 cc9bbeb: no decision is ever within the aim."""
|
||||
real = getattr(Controller, "_peak_landing", None)
|
||||
if real is None:
|
||||
return fn()
|
||||
Controller._peak_landing = lambda self, decision: decision
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._peak_landing = real
|
||||
|
||||
|
||||
def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
|
||||
start=NIGHT, off=0.7, peak=None):
|
||||
"""The real Controller from `start` + `off` s for `minutes` at a 1 s
|
||||
cycle with OPT-15's lag, so a cycle lands `1 - off` s before each
|
||||
quarter's end. `house` is W or a function of t. Returns a dict: the
|
||||
quarters' true grid averages, the commands, the reserve notifications,
|
||||
the ERROR lines, and per cycle where the guard engaged: (t, floor
|
||||
limited, within the aim, battery "Why" peak, red reserve row, red
|
||||
saturated row, alarm latched)."""
|
||||
store = rp._Store()
|
||||
for u, mode in (modes or {}).items():
|
||||
store.data[unit_key(f"inverter-{u}", "pack_use")] = True
|
||||
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
|
||||
store.data[unit_key(f"inverter-{u}", "pack_sell")] = False
|
||||
load = house if callable(house) else (lambda t: float(house))
|
||||
t0 = start + timedelta(seconds=off)
|
||||
out = {"cmds": [], "notes": [], "seen": [], "sums": {}}
|
||||
_ERRORS.seek(0)
|
||||
_ERRORS.truncate()
|
||||
|
||||
async def go():
|
||||
lag = rp.Lag()
|
||||
hass = rp.Hass(("a", "b"), load, {"a": socs[0], "b": socs[1]},
|
||||
limits={"a": (-5000.0, 5000.0),
|
||||
"b": (-4600.0, 4600.0)}, lag=lag)
|
||||
for u, cap in (("a", -5216.0), ("b", -4800.0)):
|
||||
hass.plants[u].__class__ = Harder
|
||||
hass.plants[u].cap = cap
|
||||
real_set = hass.set_number
|
||||
|
||||
async def set_number(e, v):
|
||||
out["cmds"].append((round(hass.t, 3), e, float(v)))
|
||||
return await real_set(e, v)
|
||||
|
||||
async def call(*a):
|
||||
if a[:2] == ("persistent_notification", "create") \
|
||||
and a[2]["notification_id"] == messages.ID_PEAK_RESERVE:
|
||||
out["notes"].append((hass.t, a[2]["message"]))
|
||||
return True
|
||||
hass.set_number, hass.call = set_number, call
|
||||
tmp = tempfile.mkdtemp(prefix="s126-")
|
||||
db = os.path.join(tmp, "tariff.db")
|
||||
if peak is not None:
|
||||
tariff.PeakStore(db)._db.execute(
|
||||
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
|
||||
(tariff.month_key(t0), peak,
|
||||
"2026-10-21T20:15:00+00:00")).connection.commit()
|
||||
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=db,
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
|
||||
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
|
||||
c.auto = True
|
||||
real = m.datetime, homeview.datetime, p1mod.time
|
||||
clock, simt = [t0], [0.0]
|
||||
|
||||
class _DT(real[0]):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
m.datetime = homeview.datetime = _DT
|
||||
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
|
||||
real_load = _price.load_prices, _st.load_prices
|
||||
_price.load_prices = _st.load_prices = _no_fetch
|
||||
task = (asyncio.create_task(c.run_tariff_refresh())
|
||||
if o.get("battery_modes_enabled") or o.get("arbitrage_enabled")
|
||||
else None)
|
||||
try:
|
||||
hass.advance(0.0, 10.0)
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = t0 + timedelta(seconds=t)
|
||||
hass.t, simt[0], clock[0] = t, t, now
|
||||
if t == 0.0 and task is not None:
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
q = int(now.timestamp() // QUARTER_S)
|
||||
acc = out["sums"].setdefault(q, [0.0, 0])
|
||||
if t >= lag.p1_s:
|
||||
imp, exp = split_signed(hass._past(lag.meter_s
|
||||
+ lag.p1_s)[0])
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now - timedelta(seconds=lag.p1_s),
|
||||
ingest_mono=1e6 + t))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(lag.tick, 10.0)
|
||||
acc[0] += max(0.0, hass.grid())
|
||||
acc[1] += 1
|
||||
g = c.peak_guard
|
||||
if g is not None and g.engaged and g.blind is None:
|
||||
rows = {r["id"]: r for r in c.checks(now)}
|
||||
row = rows.get("peak_guard", {})
|
||||
red = row.get("ok") is False and not row.get("warn")
|
||||
out["seen"].append((
|
||||
t, g.floor_limited, getattr(g, "within_aim", False),
|
||||
homeview.peak_engaged(c),
|
||||
red and "banner" in row,
|
||||
red and "banner" not in row,
|
||||
c._peak_floor_limited))
|
||||
t += lag.tick
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime, p1mod.time = real
|
||||
_price.load_prices, _st.load_prices = real_load
|
||||
return c
|
||||
out["c"] = asyncio.run(go())
|
||||
out["quarters"] = {
|
||||
datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"): s / k
|
||||
for q, (s, k) in sorted(out["sums"].items()) if k >= QUARTER_S - 1}
|
||||
out["errors"] = [x for x in _ERRORS.getvalue().splitlines()
|
||||
if "PEAK_GUARD_FLOOR_LIMITED" in x]
|
||||
return out
|
||||
|
||||
|
||||
def show(tag, r):
|
||||
print(f" {tag}: quarters "
|
||||
f"{ {k: round(v, 1) for k, v in r['quarters'].items()} }, "
|
||||
f"guard cycles {len(r['seen'])}, floor-limited "
|
||||
f"{sum(1 for s in r['seen'] if s[1])}, within-aim "
|
||||
f"{sum(1 for s in r['seen'] if s[2])}, Why peak "
|
||||
f"{sum(1 for s in r['seen'] if s[3])}, notes {len(r['notes'])}, "
|
||||
f"errors {len(r['errors'])}")
|
||||
|
||||
|
||||
def quiet(r):
|
||||
"""No reserve alarm in any form: ERROR, notification, red row, latch,
|
||||
"Why" peak, and no red saturated row."""
|
||||
return (not r["errors"] and not r["notes"]
|
||||
and not any(s[3] or s[4] or s[5] or s[6] for s in r["seen"]))
|
||||
|
||||
|
||||
def on_aim(r, aim):
|
||||
q = r["quarters"]
|
||||
return len(q) >= 3 and all(v <= aim + TOL for v in q.values())
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. the night grid charge under the peak reserve")
|
||||
NIGHTS = {}
|
||||
# The owner's shape at the cap's aim. At the banked peak's aim it is
|
||||
# test_safety125.py's live shape (A Cheap hours, B Solar first), because
|
||||
# B's 4 600 W alone stays under that aim.
|
||||
LIVE = {"a": "cheap_hours", "b": "solar_first"}
|
||||
for tag, kw, aim in (
|
||||
("aim 3450, cycle 0.3 s before the edge", dict(off=0.7), AIM_CAP),
|
||||
("aim 3450, cycle 0.01 s before the edge", dict(off=0.99), AIM_CAP),
|
||||
("month peak 5413, cycle 0.01 s before the edge",
|
||||
dict(off=0.99, peak=PEAK, modes=LIVE, socs=(11.0, 10.0)),
|
||||
AIM_PEAK)):
|
||||
head = drive(opts(), **kw)
|
||||
base = without_fix(lambda: drive(opts(), **kw))
|
||||
NIGHTS[tag] = (head, base)
|
||||
show("head " + tag, head)
|
||||
show("fix reverted", base)
|
||||
check(f"⭐ {tag}: no ERROR, no notification, no red peak row and no "
|
||||
f"'Why' peak", quiet(head))
|
||||
check(f"{tag}: every quarter at or under the aim ({aim:.0f} W + "
|
||||
f"{TOL:.0f} W)", on_aim(head, aim))
|
||||
check(f"{tag}: not vacuous, the guard wanted power under the reserve "
|
||||
f"at a quarter's end (floor-limited, within the aim)",
|
||||
any(s[1] and s[2] for s in head["seen"]))
|
||||
check(f"{tag}: with the fix reverted the same run alarms",
|
||||
not quiet(base))
|
||||
check(f"{tag}: the commands are the same with and without the fix",
|
||||
head["cmds"] == base["cmds"] and len(head["cmds"]) > 100)
|
||||
_h, _b = NIGHTS["aim 3450, cycle 0.01 s before the edge"]
|
||||
check("fix reverted, at 0.01 s: the ERROR, the notification and the "
|
||||
"'Why' peak all come back (the rig's three symptoms)",
|
||||
_b["errors"] and _b["notes"] and any(s[3] for s in _b["seen"]))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a real overrun under the reserve still alarms")
|
||||
|
||||
|
||||
def evening(t):
|
||||
"""600 W, then an oven and a car charger from 20 minutes on."""
|
||||
return 600.0 if t < 1200 else 6500.0
|
||||
|
||||
|
||||
for tag, onset, kw in (
|
||||
("night grid charge, then 6500 W", 1200.0, dict(house=evening)),
|
||||
("modes off, 5500 W all along", 0.0,
|
||||
dict(house=5500.0, modes=None, socs=(15.0, 12.0)))):
|
||||
o = opts(modes=kw.get("modes", OWNER) is not None)
|
||||
head = drive(o, minutes=35, **kw)
|
||||
base = without_fix(lambda: drive(o, minutes=35, **kw))
|
||||
show("head " + tag, head)
|
||||
show("fix reverted", base)
|
||||
# From the load's onset: before it, the fix reverted shows the night's
|
||||
# false alarm (part 1).
|
||||
first_h = next((s[0] for s in head["seen"] if s[6] and s[0] >= onset),
|
||||
None)
|
||||
first_b = next((s[0] for s in base["seen"] if s[6] and s[0] >= onset),
|
||||
None)
|
||||
check(f"⭐ {tag}: the ERROR and the notification, naming both "
|
||||
f"batteries",
|
||||
head["errors"] and head["notes"]
|
||||
and all("inverter-a and inverter-b" in n for _t, n in head["notes"]))
|
||||
_th = [t for t, _n in head["notes"]]
|
||||
_tb = [t for t, _n in base["notes"] if t >= onset]
|
||||
print(f" notifications at {_th} s; with the fix reverted {_tb} s")
|
||||
check(f"{tag}: the first at the same cycle as before the fix "
|
||||
f"({first_h} s, {first_b} s), and none that was not raised before",
|
||||
first_h is not None and first_h == first_b
|
||||
and present(nth(_th, 0)) and nth(_th, 0) == nth(_tb, 0)
|
||||
and set(_th) <= set(_tb))
|
||||
check(f"{tag}: the red peak row and its banner while it stands",
|
||||
any(s[4] for s in head["seen"]))
|
||||
|
||||
|
||||
def burst(t):
|
||||
"""600 W, a 6 500 W load from 20 to 25 minutes, then 600 W again while
|
||||
the night charge goes on."""
|
||||
return 6500.0 if 1200 <= t < 1500 else 600.0
|
||||
|
||||
|
||||
# F3 (review): `_peak_landing()` resets its latch when the guard lets go, so
|
||||
# a real overrun does not keep the later landing errors alarming.
|
||||
_bh = drive(opts(), house=burst, off=0.99, minutes=61)
|
||||
_bb = without_fix(lambda: drive(opts(), house=burst, off=0.99,
|
||||
minutes=61))
|
||||
show("head real overrun, then the night again", _bh)
|
||||
show("fix reverted", _bb)
|
||||
# The burst banked a new month peak, which the later quarters stay under.
|
||||
_new_peak = _bh["c"].peak_guard.ceiling_w
|
||||
|
||||
|
||||
def _late(r):
|
||||
"""From the next quarter on: the overrun's own quarter ends over."""
|
||||
return [x for x in r["seen"] if x[0] >= 1800]
|
||||
|
||||
|
||||
print(f" notifications at {[t for t, _n in _bh['notes']]} s; with the fix "
|
||||
f"reverted {[t for t, _n in _bb['notes']]} s; limit after the burst "
|
||||
f"{_new_peak:.0f} W")
|
||||
check("⭐ after a real overrun ends, the latch is reset: the alarm came "
|
||||
"during the overrun, and the later quarters' landings are quiet "
|
||||
"(no notification, no 'Why' peak, no red row) and under the new peak",
|
||||
any(1200 <= t < 1500 for t, _n in _bh["notes"])
|
||||
and not [t for t, _n in _bh["notes"] if t >= 1800]
|
||||
and not any(x[3] or x[4] or x[5] for x in _late(_bh))
|
||||
and present(nth([v for k, v in _bh["quarters"].items()
|
||||
if k >= "22:30"], 0))
|
||||
and all(v <= _new_peak for k, v in _bh["quarters"].items()
|
||||
if k >= "22:30"))
|
||||
check("...not vacuous: the guard wanted power at those landings, and with "
|
||||
"the fix reverted they show the 'Why' peak or an alarm",
|
||||
any(x[2] for x in _late(_bh))
|
||||
and (any(x[3] or x[4] or x[5] for x in _late(_bb))
|
||||
or bool([t for t, _n in _bb["notes"] if t >= 1800])))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. misses_aim() on its own")
|
||||
START = datetime(2026, 10, 30, 21, 15, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def proj(projected, elapsed, held):
|
||||
return Projection(projected, projected, elapsed, 1.0, True, START, held)
|
||||
|
||||
|
||||
check("the rig's case: 3452 W against 3450 W, 0.3 s left, charging "
|
||||
"1250 W from the grid: not a miss",
|
||||
not peakguard.misses_aim(proj(3452.0, 899.7, 3452.0), batt_w=-1250.0,
|
||||
target_w=AIM_CAP))
|
||||
check("...although decide() wants power there (its gain, 900 / 0.3 s)",
|
||||
(peakguard.decide(proj(3452.0, 899.7, 3452.0), batt_w=-1250.0,
|
||||
cap_w=3500, margin_w=50, reading=None,
|
||||
achievable_w=0.0, soc=10.0, soc_floor=20.0)
|
||||
.wanted_w or 0.0) > 1000.0)
|
||||
check("a quarter heading 11 W over the aim with the batteries at 0 W is a "
|
||||
"miss", peakguard.misses_aim(proj(3461.0, 899.7, 3461.0), batt_w=0.0,
|
||||
target_w=AIM_CAP))
|
||||
check("...and 10 W is not (ALARM_MISS_W, as for 'Load too high')",
|
||||
not peakguard.misses_aim(proj(3460.0, 899.7, 3460.0), batt_w=0.0,
|
||||
target_w=AIM_CAP))
|
||||
check("the add-on's own grid charge counts as stoppable: 4000 W on the "
|
||||
"meter mid-quarter, 3000 W of it charging, is not a miss",
|
||||
not peakguard.misses_aim(proj(2500.0, 450.0, 4000.0), batt_w=-3000.0,
|
||||
target_w=AIM_CAP))
|
||||
check("...and a house of 6000 W the batteries are covering is one",
|
||||
peakguard.misses_aim(proj(1000.0, 450.0, 0.0), batt_w=6000.0,
|
||||
target_w=AIM_CAP))
|
||||
check("no battery reading, or no seconds left: not a miss (decide() files "
|
||||
"nothing there)",
|
||||
not peakguard.misses_aim(proj(9000.0, 450.0, 9000.0), batt_w=None,
|
||||
target_w=AIM_CAP)
|
||||
and not peakguard.misses_aim(proj(9000.0, 900.0, 9000.0), batt_w=0.0,
|
||||
target_w=AIM_CAP))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the page and the 'Why' on a decision within the aim")
|
||||
_c = NIGHTS["aim 3450, cycle 0.01 s before the edge"][0]["c"]
|
||||
_dec = peakguard.PeakDecision(
|
||||
engaged=True, floor_w=5000.0, ceiling_w=3500.0,
|
||||
ceiling_from=peakguard.CEILING_CAP, projected_w=3451.0, excess_w=9e4,
|
||||
wanted_w=9e4, target_w=AIM_CAP, saturated=True, soc=30.0, soc_floor=20.0)
|
||||
|
||||
|
||||
def page(d):
|
||||
"""(the peak row is red, the "Why" says peak) for decision `d`."""
|
||||
_c.peak_guard, _c._peak_floor_limited = d, False
|
||||
row = first((r for r in _c.checks(datetime.now(timezone.utc))
|
||||
if r["id"] == "peak_guard"), "peak row")
|
||||
if not present(row):
|
||||
return {"red": None}, None
|
||||
return dict(row, red=row["ok"] is False and not row.get("warn")), \
|
||||
homeview.peak_engaged(_c)
|
||||
|
||||
|
||||
_r1, _w1 = page(_dec)
|
||||
_r2, _w2 = page(dataclasses.replace(_dec, within_aim=True))
|
||||
check("a saturated decision: red 'Load too high' row and the 'Why' peak, "
|
||||
"as before", _r1["red"] and _w1)
|
||||
check("...within the aim: neither", _r2["red"] is False and _w2 is False)
|
||||
_lim = dataclasses.replace(_dec, floor_w=0.0, saturated=False,
|
||||
floor_limited=True, soc=10.0,
|
||||
soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR)
|
||||
_r3, _w3 = page(_lim)
|
||||
_r4, _w4 = page(dataclasses.replace(_lim, within_aim=True))
|
||||
check("floor-limited at 0 W: the red reserve row with its banner, as "
|
||||
"before", _r3["red"] and "banner" in _r3 and not _w3)
|
||||
check("...within the aim: no red row",
|
||||
_r4["red"] is False and _w4 is False)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} SAFETY-126 check(s) FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all SAFETY-126 checks passed")
|
||||
@@ -476,13 +476,18 @@ print("\n8. the N=1 and N=2 digests")
|
||||
# loop's bound just after the guard hands back are gone; every command and
|
||||
# claim is the same (test_safety119.py part 6). SAFETY-121 re-pinned all six
|
||||
# again: only OUTPUT_CLAMP rows (test_safety119.py part 6).
|
||||
# SAFETY-125 (0.19.2, owner 2026-10-29) re-pinned n1_charge and
|
||||
# n1_charge_banked: at N=1 the cheap-window charge is now sized to the
|
||||
# quarter's room, as at N=2, so the guard no longer steps in and discharges.
|
||||
# The banked quarters go 3461.9 -> 3450.0 W (aim 3450) and 3938.9 -> 3950.0
|
||||
# W (aim 3950). n1_charge_dear (no cheap window) is unchanged.
|
||||
PINNED = {
|
||||
"n1_charge":
|
||||
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5",
|
||||
"00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9",
|
||||
"n1_charge_dear":
|
||||
"c25340b56060d4581f7204e200b07e7c8618711be2baca3b1667b7ebeb600dc3",
|
||||
"n1_charge_banked":
|
||||
"2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609",
|
||||
"09a08298d35195380ff427cdd2448d9212119be4692db26b2b1a45957fdf3300",
|
||||
"n2_charge":
|
||||
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
|
||||
"n2_charge_dear":
|
||||
@@ -496,8 +501,9 @@ for k in PINNED:
|
||||
f"{_now[k][:16]}")
|
||||
check("⭐ at N=1 a charge in and out of a cheap window and under a banked "
|
||||
"month peak is byte-identical to 9d5b7d1 (commands, reasons and every "
|
||||
"claim; the log as re-pinned by SAFETY-121), and N=2 outside a cheap "
|
||||
"window matches its SAFETY-85 pin",
|
||||
"claim; the log as re-pinned by SAFETY-121; in a cheap window as "
|
||||
"SAFETY-125 re-pinned it), and N=2 outside a cheap window matches its "
|
||||
"SAFETY-85 pin",
|
||||
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
|
||||
"n1_charge_banked",
|
||||
"n2_charge_dear")))
|
||||
|
||||
@@ -522,7 +522,9 @@ print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
|
||||
# log. The loop's bound just after a hand-back is not counted, so OUTPUT_CLAMP
|
||||
# rows are gone or count fewer; every command and claim is the same
|
||||
# (test_safety119.py part 6). SAFETY-121 re-pinned the same three again:
|
||||
# only OUTPUT_CLAMP rows (test_safety119.py part 6).
|
||||
# only OUTPUT_CLAMP rows (test_safety119.py part 6). SAFETY-125 (0.19.2)
|
||||
# re-pinned n1_charge_low: the N=1 cheap-window charge is sized to the
|
||||
# quarter's room, so the banked quarter goes 3458.9 -> 3450.0 W (aim 3450).
|
||||
PINNED = { # tools/safety87_identity.py on b8ddfd7
|
||||
"n1_under":
|
||||
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
|
||||
@@ -533,7 +535,7 @@ PINNED = { # tools/safety87_identity.py on b8ddfd7
|
||||
"n1_under_banked":
|
||||
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
|
||||
"n1_charge_low":
|
||||
"91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32",
|
||||
"c3af6218cc14c072e08b09550b153502dafec806009a92ba9032738b9ac9fa61",
|
||||
"n1_hold":
|
||||
"0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d",
|
||||
}
|
||||
@@ -542,8 +544,8 @@ for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
|
||||
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
|
||||
"log) equals b8ddfd7's (the log as re-pinned by SAFETY-121), under, "
|
||||
"crossing and above the reserve, in a charge and a HOLD, and under a "
|
||||
"banked peak",
|
||||
"crossing and above the reserve, in a charge (as SAFETY-125 re-pinned "
|
||||
"it) and a HOLD, and under a banked peak",
|
||||
_now == PINNED)
|
||||
|
||||
# ============================================================================ #
|
||||
|
||||
@@ -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,518 @@
|
||||
"""Runnable check for TARIFF-24 round 2 (AUDIT-02 F1). `python3 test_tariff24_weather.py`
|
||||
|
||||
With battery modes on, the price gap a mode needs was always widened x1.5
|
||||
(`Uncertainty.NO_WEATHER`) even with a solar forecast read. Now the doubt is
|
||||
added only when no usable forecast is read (`arbitrage.weather_uncertainty()`),
|
||||
in the run plan (`_price_runs()`), the charge gate (`_mode_roles()`) and Home's
|
||||
mirror (`homeview.trade_verdict()`). Modes off is untouched.
|
||||
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
|
||||
1. The rule itself, and that modes off keeps the doubt.
|
||||
2. Day/night 0.34/0.26 (gap 0.08), modes on, real Controller, night period:
|
||||
with a forecast at cycle cost 0.07 the cheap run is planned and the Cheap
|
||||
hours battery charges; without one, or with a stale one, the same cost needs
|
||||
0.105 and the reason is `not_worth` / "Prices differ too little".
|
||||
3. A dynamic day with a 0.12 gap at cost 0.10: on with a forecast, not worth
|
||||
without.
|
||||
4. The forecast coming (the gate follows in the same cycle) and going mid-run:
|
||||
a run under way keeps its gap to its end, the next run needs the wider one;
|
||||
a forecast unavailable for one or two reads mid-run does not stop or
|
||||
restart the charge.
|
||||
5. Home agrees with control in each case (Batteries Why, Home line, and
|
||||
`trade_verdict()`, Home's mirror of the gate).
|
||||
6. The hold's edges (TARIFF-24 review R4, R5): a forecast lost while only the
|
||||
day's give run is under way holds to that run's end and no further, so the
|
||||
night's cheap run needs the wider gap; and a give run that starts during a
|
||||
hold runs to its end (`keep_started()`), with no grid charge after the hold.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): the doubt always
|
||||
added (the old code) fails parts 2 to 5; never added fails the without-forecast
|
||||
rows; Home's mirror left on the old rule fails part 5; `_wx_track()` also
|
||||
recording without a forecast (its `solar_fc is None` return dropped) fails part 6.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import arbitrage, homeview, modetrade, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
Uncertainty, brussels)
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
class _NoHold(solarfc.Hold):
|
||||
"""OPT-27: a hold that never stands in for a lost sensor."""
|
||||
|
||||
def stand_in(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.26},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.34}]}')
|
||||
L = timezone(timedelta(hours=1))
|
||||
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
FC_TODAY, FC_TOMORROW = "sensor.fc_today", "sensor.fc_tomorrow"
|
||||
TARIFF_REFRESH_S = m.TARIFF_REFRESH_S
|
||||
|
||||
|
||||
def opts(cost, *, arb=False, sensors=True):
|
||||
o = dict(rp.options(True, arb, 2))
|
||||
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE)
|
||||
if sensors:
|
||||
o.update(pv_forecast_entity=FC_TODAY,
|
||||
pv_forecast_tomorrow_entity=FC_TOMORROW)
|
||||
return o
|
||||
|
||||
|
||||
def dynamic_points(start):
|
||||
"""A dynamic day-ahead shape, 0.16 .. 0.28 (gap 0.12), 15-minute points."""
|
||||
out, t = [], start.replace(minute=0, second=0, microsecond=0)
|
||||
for _ in range(48 * 4):
|
||||
h = brussels(t).hour + brussels(t).minute / 60
|
||||
p = 0.22 + 0.06 * math.cos((h - 19) / 24 * 2 * math.pi)
|
||||
out.append(PricePoint(start=brussels(t), eur_per_kwh=round(p, 4),
|
||||
provisional=False))
|
||||
t += timedelta(minutes=15)
|
||||
return out
|
||||
|
||||
|
||||
def sensor(kwh, at):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": "kWh"},
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
def forecast_mode(mode, t, now):
|
||||
"""What the two forecast sensors read: 'fresh', 'stale', 'gone', or a
|
||||
function of the simulated seconds that returns one of those."""
|
||||
if callable(mode):
|
||||
mode = mode(t)
|
||||
if mode == "gone":
|
||||
return {"state": "unavailable", "attributes": {}}
|
||||
at = now if mode == "fresh" else now - timedelta(hours=5)
|
||||
return at
|
||||
|
||||
|
||||
def take(c, now):
|
||||
"""What Home and the Batteries screen say now, and Home's mirror of the
|
||||
gate (it reads the trade switch, so ask it as a site that has it on next
|
||||
to the modes)."""
|
||||
keep = c.o
|
||||
c.o = dict(keep, arbitrage_enabled=True)
|
||||
try:
|
||||
mirror, why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now)
|
||||
finally:
|
||||
c.o = keep
|
||||
return dict(home=homeview.build_plan(c, now, False, True),
|
||||
cards=homeview.battery_rows(c, False, True),
|
||||
mirror=mirror, mirror_why=why)
|
||||
|
||||
|
||||
def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
house_w=300.0, soc0=None, snap_at=(), t0=NIGHT):
|
||||
"""-> (controller, rows). `points`: a feed-shaped plan injected every
|
||||
cycle (no task); else the static profile via the real tariff task. `t0`:
|
||||
the first cycle's instant."""
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in CHOICE.items():
|
||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||
("pack_sell", sell)):
|
||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
||||
rows = []
|
||||
snaps = {}
|
||||
|
||||
class H(rp.Hass):
|
||||
async def state(self, e):
|
||||
now = t0 + timedelta(seconds=self.t)
|
||||
if fc is None:
|
||||
return None
|
||||
at = forecast_mode(fc, self.t, now)
|
||||
if isinstance(at, dict):
|
||||
return at
|
||||
return sensor(0.0 if e == FC_TODAY else 12.0, at)
|
||||
|
||||
async def go():
|
||||
hass = H(("a", "b"), lambda t: house_w, soc0 or {"a": 40.0, "b": 40.0})
|
||||
tmp = tempfile.mkdtemp(prefix="t24w-")
|
||||
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
# OPT-27: a sensor that is missing is covered for up to 3 h by its last
|
||||
# fresh reading. This file is about what the plan does when the
|
||||
# forecast IS lost, so it runs with a hold that never covers
|
||||
# (test_opt27.py tests the cover).
|
||||
c.fc_hold = _NoHold()
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
clock = [t0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
pts = None if points is None else points
|
||||
task = None
|
||||
if pts is None:
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
try:
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = t0 + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if pts is not None:
|
||||
c.tariff_plan = TariffPlan(
|
||||
PriceRead.series(pts, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", now), None,
|
||||
"provider")
|
||||
c.tariff_at = now
|
||||
elif t == 0.0 or t % TARIFF_REFRESH_S == 0.0:
|
||||
# The task's own 30 min wait is wall clock, so the
|
||||
# simulated clock restarts it: one refresh per period.
|
||||
if task is not None and t > 0.0:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None and (
|
||||
c.tariff_at is not None and c.tariff_at >= now):
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(tick, 10.0)
|
||||
rp_ = c.mode_runs
|
||||
rows.append(dict(
|
||||
t=t, now=now, fc_none=c.solar_fc is None,
|
||||
runs_on=None if rp_ is None else rp_.on,
|
||||
reason=None if rp_ is None else rp_.reason,
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
free_to=c._wx_free_to, held=c.weather_held(now),
|
||||
why=None if c.mode_tick is None else c.mode_tick.why,
|
||||
charging=hass.plants["b"].p < -200.0,
|
||||
soc_b=hass.plants["b"].soc,
|
||||
cheap_end=None if rp_ is None or rp_.cheap is None
|
||||
else rp_.cheap.end))
|
||||
if t in snap_at:
|
||||
snaps[t] = take(c, now)
|
||||
t += tick
|
||||
last = take(c, clock[0])
|
||||
c.home_plan, c.cards = last["home"], last["cards"]
|
||||
c.mirror, c.mirror_why = last["mirror"], last["mirror_why"]
|
||||
c.snaps = snaps
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
return c
|
||||
|
||||
return asyncio.run(go()), rows
|
||||
|
||||
|
||||
def why_of(c, name):
|
||||
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
|
||||
return card["why_key"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the rule")
|
||||
NW = frozenset({Uncertainty.NO_WEATHER})
|
||||
check("modes on with a forecast read: no doubt", arbitrage.weather_uncertainty(
|
||||
True, object()) == frozenset())
|
||||
check("modes on with no forecast (None): the doubt", arbitrage.weather_uncertainty(
|
||||
True, None) == NW)
|
||||
check("modes off, forecast or not: the doubt, as before",
|
||||
arbitrage.weather_uncertainty(False, object()) == NW
|
||||
and arbitrage.weather_uncertainty(False, None) == NW)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. day/night 0.34 / 0.26 (gap 0.08), night period")
|
||||
c1, r1 = drive(opts(0.07), fc="fresh")
|
||||
l1 = nth(r1, -1, "last cycle")
|
||||
print(f" forecast read, cost 0.07: mode_runs on={l1['runs_on']} "
|
||||
f"reason={l1['reason']}; B charging {sum(r['charging'] for r in r1)} of "
|
||||
f"{len(r1)}; Why A {why_of(c1, A)} B {why_of(c1, B)}")
|
||||
check("with a forecast at cost 0.07 the cheap run is planned (needed 0.07 < "
|
||||
"0.08) and the Cheap hours battery charges in the night period",
|
||||
present(l1) and not l1["fc_none"] and l1["runs_on"] is True
|
||||
and sum(r["charging"] for r in r1) > len(r1) // 2
|
||||
and l1["roles"].get(B) == modetrade.CHARGE)
|
||||
for label, mode in (("no forecast sensor reads", "gone"),
|
||||
("a stale forecast", "stale")):
|
||||
c, rr = drive(opts(0.07), fc=mode)
|
||||
ll = nth(rr, -1, "last cycle")
|
||||
print(f" {label}, cost 0.07: on={ll['runs_on']} reason={ll['reason']}; "
|
||||
f"B charging {sum(r['charging'] for r in rr)}; Why A "
|
||||
f"{why_of(c, A)} B {why_of(c, B)}")
|
||||
check(f"{label}: the same cost needs 0.105 > 0.08, so not_worth and "
|
||||
f"'Prices differ too little', no grid charge",
|
||||
present(ll) and ll["fc_none"] and ll["runs_on"] is False
|
||||
and ll["reason"] == "not_worth"
|
||||
and why_of(c, A) == "no_spread" and why_of(c, B) == "no_spread"
|
||||
and not any(r["charging"] for r in rr))
|
||||
c0, r0 = drive(opts(0.07, sensors=False), fc=None)
|
||||
check("no forecast sensor configured at all: not_worth too (as 0.19.0)",
|
||||
present(nth(r0, -1)) and r0[-1]["runs_on"] is False)
|
||||
c2, r2 = drive(opts(0.10), fc="fresh")
|
||||
check("at the shipped cost 0.10 even a forecast does not make 0.08 enough "
|
||||
"(needs 0.10): still not_worth, honestly",
|
||||
present(nth(r2, -1)) and r2[-1]["runs_on"] is False
|
||||
and r2[-1]["reason"] == "not_worth")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. a dynamic day, gap 0.12, cost 0.10")
|
||||
pts = dynamic_points(NIGHT - timedelta(hours=1))
|
||||
cd, rd = drive(opts(0.10), fc="fresh", points=pts)
|
||||
cn, rn = drive(opts(0.10), fc="gone", points=pts)
|
||||
_ld, _ln = nth(rd, -1, "last cycle"), nth(rn, -1, "last cycle")
|
||||
print(f" with forecast: on={_ld['runs_on']} reason={_ld['reason']}; "
|
||||
f"without: on={_ln['runs_on']} reason={_ln['reason']}")
|
||||
check("with a forecast the run plan is on (needs 0.10 < 0.12)",
|
||||
present(nth(rd, -1)) and rd[-1]["runs_on"] is True
|
||||
and why_of(cd, A) != "no_spread")
|
||||
check("without one it is not_worth (needs 0.15 > 0.12) and says so",
|
||||
present(nth(rn, -1)) and rn[-1]["runs_on"] is False
|
||||
and rn[-1]["reason"] == "not_worth"
|
||||
and why_of(cn, B) == "no_spread")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. the forecast going and coming mid-run (a run under way keeps its gap)")
|
||||
CUT = 600.0 # 10 min in
|
||||
SOC = {"a": 40.0, "b": 60.0} # B's cheap run is then about 2 h
|
||||
|
||||
|
||||
def going(t):
|
||||
return "fresh" if t < CUT else "gone"
|
||||
|
||||
|
||||
def coming(t):
|
||||
return "gone" if t < CUT else "fresh"
|
||||
|
||||
|
||||
def flap(n_reads):
|
||||
"""Unavailable for `n_reads` forecast reads (60 s apart) from CUT."""
|
||||
return lambda t: "gone" if CUT <= t < CUT + 60.0 * n_reads else "fresh"
|
||||
|
||||
|
||||
MID, AFTER = 3000.0, 7800.0
|
||||
cg, rg = drive(opts(0.07), fc=going, minutes=135, soc0=SOC,
|
||||
snap_at=(MID, AFTER))
|
||||
cc, rc = drive(opts(0.07), fc=coming, minutes=25)
|
||||
_end = nth([r["cheap_end"] for r in rg if r["cheap_end"] is not None], 0,
|
||||
"the cheap run's end")
|
||||
_end_t = (_end - NIGHT).total_seconds() if present(_end) else 1e9
|
||||
lost = [r for r in rg if r["fc_none"]]
|
||||
print(f" going: cheap run ends at t={_end_t:.0f} s; forecast lost from "
|
||||
f"t={lost[0]['t'] if lost else None}")
|
||||
check("going: the run planned with the forecast ends before the snapshot "
|
||||
"taken after it (the scenario is long enough)",
|
||||
present(_end) and CUT + 300.0 < _end_t < AFTER - 300.0 and bool(lost)
|
||||
and lost[0]["t"] - CUT <= 60.0)
|
||||
check("going: the forecast read is lost mid-run and the run under way KEEPS "
|
||||
"its gap: the plan stays on and B keeps charging, cycle after cycle, "
|
||||
"until B is full or the run ends",
|
||||
all(r["runs_on"] is True and (r["roles"].get(B) == modetrade.CHARGE
|
||||
or r["soc_b"] >= 94.0)
|
||||
for r in rg if r["fc_none"] and r["t"] < _end_t - 60.0)
|
||||
and all(r["charging"] or r["soc_b"] >= 94.0 for r in rg
|
||||
if 60.0 <= r["t"] < _end_t - 60.0)
|
||||
and max(r["soc_b"] for r in rg if r["fc_none"]) >= 90.0)
|
||||
check("going: from the run's end the wider gap is back: the next run is "
|
||||
"not_worth (needs 0.105 at 0.07), no new run, and no grid charge",
|
||||
all(r["runs_on"] is False and r["reason"] == "not_worth"
|
||||
for r in rg if r["t"] >= _end_t + 30.0)
|
||||
and not any(r["charging"] for r in rg if r["t"] >= _end_t + 60.0))
|
||||
_sm, _sa = cg.snaps.get(MID), cg.snaps.get(AFTER)
|
||||
check("going, Home agrees mid-run: its mirror still allows, the Batteries "
|
||||
"screen says it charges from the grid, and Home does not say the gap "
|
||||
"is too small",
|
||||
_sm is not None and _sm["mirror"] is not None
|
||||
and first((b for b in _sm["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "grid_charge"
|
||||
and not any("price gap is too small" in ln
|
||||
for ln in _sm["home"]["lines"]))
|
||||
check("going, Home agrees after the run: its mirror refuses, the Batteries "
|
||||
"screen says 'differ too little' and Home says the gap is too small",
|
||||
_sa is not None and _sa["mirror"] is None
|
||||
and first((b for b in _sa["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "no_spread"
|
||||
and any("price gap is too small" in ln for ln in _sa["home"]["lines"]))
|
||||
for n in (1, 2):
|
||||
cf, rf = drive(opts(0.07), fc=flap(n), minutes=30, soc0=SOC,
|
||||
snap_at=(CUT + 30.0,))
|
||||
gone = [r for r in rf if r["fc_none"]]
|
||||
print(f" flap, {n} read(s) lost: {len(gone)} cycles without a "
|
||||
f"forecast; B charging in {sum(r['charging'] for r in rf)} of "
|
||||
f"{len(rf)}")
|
||||
check(f"flap: the forecast is unavailable for {n} read(s) mid-run: the "
|
||||
f"plan stays on and B charges without a break (no stop, no "
|
||||
f"restart)",
|
||||
bool(gone) and all(r["runs_on"] is True for r in rf)
|
||||
and all(r["charging"] and r["roles"].get(B) == modetrade.CHARGE
|
||||
for r in rf if r["t"] >= 60.0))
|
||||
sf = cf.snaps.get(CUT + 30.0)
|
||||
check(f"flap, {n} read(s): Home agrees while the read is lost (mirror "
|
||||
f"allows, Batteries 'charges from the grid')",
|
||||
sf is not None and sf["mirror"] is not None
|
||||
and first((b for b in sf["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "grid_charge")
|
||||
|
||||
|
||||
def flips(rows):
|
||||
"""Cycles where the forecast read changed, and whether the plan and the
|
||||
charge gate changed in that very cycle."""
|
||||
out = []
|
||||
for i in range(1, len(rows)):
|
||||
if rows[i]["fc_none"] != rows[i - 1]["fc_none"]:
|
||||
out.append((i, rows[i]["t"], rows[i]["runs_on"],
|
||||
rows[i]["charging"], rows[i - 1]["charging"]))
|
||||
return out
|
||||
|
||||
|
||||
fcm = flips(rc)
|
||||
print(f" coming: forecast read arrives at cycle/t {fcm}")
|
||||
check("coming (no run under way before): not_worth until the read is back, "
|
||||
"then on in the same cycle, and the battery charges",
|
||||
len(fcm) == 1 and fcm[0][1] - CUT <= 60.0 and fcm[0][2] is True
|
||||
and all(r["runs_on"] is False for r in rc if r["fc_none"])
|
||||
and all(r["runs_on"] is True for r in rc if not r["fc_none"])
|
||||
and any(r["charging"] for r in rc if r["t"] >= fcm[0][1] + 40.0))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. Home agrees with control")
|
||||
CASES = (("forecast, cost 0.07", c1, r1, True),
|
||||
("no forecast, cost 0.07", *drive(opts(0.07), fc="gone"), False),
|
||||
("dynamic, forecast", cd, rd, True),
|
||||
("dynamic, no forecast", cn, rn, False))
|
||||
for label, cx, rx, on in CASES:
|
||||
last = nth(rx, -1, "last cycle")
|
||||
hp = cx.home_plan
|
||||
mirror_ok = cx.mirror is not None
|
||||
print(f" {label}: control on={last['runs_on']}; Home mirror "
|
||||
f"{'allows' if mirror_ok else 'refuses'} ({cx.mirror_why}); "
|
||||
f"lines {hp['lines'][:1]}; Why B {why_of(cx, B)}")
|
||||
check(f"{label}: Home's mirror of the gate allows exactly when control "
|
||||
f"plans", present(last) and mirror_ok is on
|
||||
and last["runs_on"] is on)
|
||||
check(f"{label}: the Batteries screen and the Home line say the gap is "
|
||||
f"too small exactly when control does",
|
||||
(why_of(cx, B) == "no_spread") is (not on)
|
||||
and any("price gap is too small" in ln for ln in hp["lines"])
|
||||
is (not on))
|
||||
if label.startswith("dynamic"):
|
||||
continue # its cheap run is tomorrow morning: not on today's chart
|
||||
check(f"{label}: Home draws a grid charge block exactly when control "
|
||||
f"charges", any(r["kind"] == "grid" for r in hp["runs"]) is on)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n6. the hold's edges (review probes R4, R5)")
|
||||
T_R4 = datetime(2026, 10, 30, 21, 40, tzinfo=L).astimezone(timezone.utc)
|
||||
TEN = datetime(2026, 10, 30, 22, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
c4, r4 = drive(opts(0.07), fc=lambda t: "fresh" if t < 300.0 else "gone",
|
||||
minutes=60, t0=T_R4)
|
||||
_held4 = [r for r in r4 if r["held"]]
|
||||
_l4 = nth(r4, -1, "last cycle")
|
||||
print(f" R4: forecast lost 21:45; held {len(_held4)} cycles, to "
|
||||
f"{_l4['free_to']}; after 22:00 on={ {r['runs_on'] for r in r4 if r['now'] >= TEN} } "
|
||||
f"charging {sum(r['charging'] for r in r4)}")
|
||||
check("R4, forecast lost before the next run: while only the day's give run "
|
||||
"was under way the hold is that run's end, 22:00, and stays there",
|
||||
present(_l4) and all(r["free_to"] == TEN for r in r4
|
||||
if r["t"] >= 5.0)
|
||||
and bool(_held4) and all(r["now"] < TEN for r in _held4))
|
||||
check("R4: from 22:00 the night's cheap run needs the wider gap: "
|
||||
"not_worth, and no grid charge at any time",
|
||||
all(r["runs_on"] is False and r["reason"] == "not_worth"
|
||||
for r in r4 if r["now"] >= TEN)
|
||||
and not any(r["charging"] for r in r4))
|
||||
|
||||
|
||||
def r5_points(t0):
|
||||
"""04:00-07:00 at 0.20, 07:00-10:00 at 0.30, else 0.25 (local)."""
|
||||
out, t = [], t0.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||
for _ in range(4 * 30):
|
||||
h = brussels(t).hour
|
||||
p = 0.20 if 4 <= h < 7 else 0.30 if 7 <= h < 10 else 0.25
|
||||
out.append(PricePoint(start=brussels(t), eur_per_kwh=p,
|
||||
provisional=False))
|
||||
t += timedelta(minutes=15)
|
||||
return out
|
||||
|
||||
|
||||
T_R5 = datetime(2026, 10, 30, 5, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
GIVE_END = datetime(2026, 10, 30, 10, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
c5, r5 = drive(opts(0.07), fc=lambda t: "fresh" if t < 3600.0 else "gone",
|
||||
points=r5_points(T_R5), minutes=320, t0=T_R5,
|
||||
soc0={"a": 40.0, "b": 30.0})
|
||||
_ft5 = nth([r["free_to"] for r in r5 if r["t"] == 3600.0], 0, "the hold")
|
||||
_in = [r for r in r5 if present(_ft5) and _ft5 <= r["now"] < GIVE_END]
|
||||
_out = [r for r in r5 if r["now"] >= GIVE_END + timedelta(seconds=30)]
|
||||
print(f" R5: forecast lost 06:00, hold to {_ft5}; after it, until 10:00, "
|
||||
f"on in {sum(r['runs_on'] is True for r in _in)} of {len(_in)}, B "
|
||||
f"{sorted({str(r['roles'].get(B)) for r in _in})}; after 10:00 "
|
||||
f"{ {r['reason'] for r in _out} }")
|
||||
check("R5: the hold is the cheap run's end, before the give run's end",
|
||||
present(_ft5) and T_R5 < _ft5 < GIVE_END)
|
||||
check("R5, a run that has started runs to its end: the give run that began "
|
||||
"during the hold stays on after it, B gives, and nothing charges from "
|
||||
"the grid",
|
||||
len(_in) > 100 and all(r["runs_on"] is True
|
||||
and r["roles"].get(B) == modetrade.GIVE
|
||||
and not r["charging"] for r in _in))
|
||||
check("R5: when the give run ends the wider gap decides: not_worth, no grid "
|
||||
"charge",
|
||||
bool(_out) and all(r["runs_on"] is False and r["reason"] == "not_worth"
|
||||
and not r["charging"] for r in _out))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1456,6 +1456,35 @@ hi_ = tab(c_t, at(10, 7))["days"][0]["batteries"][0]["levels"]
|
||||
check("on a controller the curve is built with the plan's trust "
|
||||
"(`solar_trust_pct`)", hi_ and max(v for _h, v in hi_) > hi_[0][1])
|
||||
|
||||
# TARIFF-24 (sync 0.19): the tab extends tomorrow's cheap run as control
|
||||
# re-plans it, so it asks control's gap: the cycle cost with a forecast read,
|
||||
# not 1.5 x it.
|
||||
from app.price import required_spread, uncertainty_of # noqa: E402
|
||||
gaps, _plan_real = [], dayview.priceruns.plan
|
||||
|
||||
|
||||
def _spy(*a, **k):
|
||||
gaps.append(k.get("required_eur_kwh"))
|
||||
return _plan_real(*a, **k)
|
||||
|
||||
|
||||
c_g = S.make("dynamic", at(17, 0))
|
||||
dayview.priceruns.plan = _spy
|
||||
try:
|
||||
tab(c_g, at(17, 0))
|
||||
finally:
|
||||
dayview.priceruns.plan = _plan_real
|
||||
_p = c_g.fresh_tariff_plan(at(17, 0))
|
||||
want = required_spread(arbitrage.threshold_eur_kwh(c_g.o),
|
||||
uncertainty_of(_p.read.product)
|
||||
| arbitrage.weather_uncertainty(True, c_g.solar_fc))
|
||||
print(f" dynamic 17:00: the tab's extension asks {gaps}, control needs {want}")
|
||||
check("the tab extends tomorrow's cheap run with control's gap (with a "
|
||||
"forecast: the cycle cost, not 1.5 x it)",
|
||||
c_g.solar_fc is not None and bool(gaps)
|
||||
and all(abs(g - want) < 1e-9 for g in gaps)
|
||||
and abs(want - arbitrage.threshold_eur_kwh(c_g.o)) < 1e-9)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
|
||||
@@ -247,6 +247,12 @@ def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first",
|
||||
tomorrow = False
|
||||
if name == "sunny":
|
||||
sun_today, sun_tomorrow = 40.0, 40.0
|
||||
if name not in ("nofc", "off", "stale") and \
|
||||
opts.get("battery_modes_enabled"):
|
||||
# TARIFF-24: with a forecast the gap needed is the cycle cost, not
|
||||
# 1.5 x it. These scenarios were written for the old gate (0.15 at
|
||||
# the shipped 0.10), so they keep it: the cost is 0.15.
|
||||
opts["cycle_cost_threshold_eur"] = 0.15
|
||||
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for i, inv in enumerate(c.inverters):
|
||||
|
||||
Reference in New Issue
Block a user