Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a3fa615e | ||
|
|
1c9fefd9dd | ||
|
|
8cca671a05 | ||
|
|
9dc22fb987 | ||
|
|
5ac1017eed | ||
|
|
8bf54ece3d | ||
|
|
f1c9733c32 | ||
|
|
b6be31286a | ||
|
|
6be7e41de1 | ||
|
|
ae0cd542c0 | ||
|
|
a645d899f4 | ||
|
|
995028bc5d | ||
|
|
ffb0d61d5b | ||
|
|
8966867968 | ||
|
|
2929ece7da | ||
|
|
6eebd4882b | ||
|
|
88ab8a50e3 | ||
|
|
2932f3af39 | ||
|
|
7e77c723b9 | ||
|
|
97686319f3 | ||
|
|
d424b862d8 | ||
|
|
382b0ea411 | ||
|
|
230ba0b57a | ||
|
|
d667f3f3a8 | ||
|
|
4a1f6cc2d9 | ||
|
|
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, safety125 508 on hotfix-0.19.2 only, modes_p3 364, safety119 328,
|
||||
# price_run_fixes 184, safety90 160, safety103 134, safety94 114, peakguard 113,
|
||||
# opt20 102, safety97 101, safety81 100). With JOBS workers pulling from one
|
||||
# queue, starting the longest files first keeps the tail short: the batch takes
|
||||
# about as long as the longest file. A sort order, not a selection.
|
||||
HEAVY="test_modes_p4.py test_safety125.py test_modes_p3.py test_safety119.py test_price_run_fixes.py test_safety90.py test_safety103.py test_safety94.py test_peakguard.py test_opt20.py test_safety97.py test_safety81.py"
|
||||
#
|
||||
JOBS=${CI_JOBS:-6}
|
||||
cd "${TESTS_DIR:-/w/goodwe-addon/goodwe_controller}"
|
||||
|
||||
# one FILE: run it, keep its output in its own file, print its block whole.
|
||||
if [ "${1:-}" = "--one" ]; then
|
||||
f=$2
|
||||
o="$RUN_DIR/$f.out"
|
||||
start=$(date +%s)
|
||||
if python3 "$f" > "$o" 2>&1; then rc=0; else rc=1; fi
|
||||
secs=$(( $(date +%s) - start ))
|
||||
{
|
||||
if [ $rc = 0 ]; then
|
||||
echo "PASS $f (${secs}s)"
|
||||
grep "^CI: " "$o" || true
|
||||
else
|
||||
echo "FAIL $f (${secs}s)"
|
||||
tail -40 "$o"
|
||||
fi
|
||||
} > "$RUN_DIR/$f.blk"
|
||||
echo $rc > "$RUN_DIR/$f.rc"
|
||||
flock "$RUN_DIR/print.lock" cat "$RUN_DIR/$f.blk"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -- test_*.py
|
||||
if [ "$1" = "test_*.py" ]; then
|
||||
echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked."
|
||||
exit 1
|
||||
fi
|
||||
echo "$# test file(s) to run: $JOBS at a time, then the serial group"
|
||||
|
||||
RUN_DIR=$(mktemp -d /tmp/ci-tests.XXXXXX)
|
||||
export RUN_DIR
|
||||
: > "$RUN_DIR/par"
|
||||
: > "$RUN_DIR/ser"
|
||||
for f in $HEAVY; do
|
||||
case " $SERIAL " in *" $f "*) continue ;; esac
|
||||
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/par"; fi
|
||||
done
|
||||
for f in "$@"; do
|
||||
case " $SERIAL $HEAVY " in *" $f "*) continue ;; esac
|
||||
echo "$f" >> "$RUN_DIR/par"
|
||||
done
|
||||
for f in $SERIAL; do
|
||||
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/ser"; fi
|
||||
done
|
||||
echo "parallel: $(wc -l < "$RUN_DIR/par") file(s); serial: $(wc -l < "$RUN_DIR/ser") file(s)"
|
||||
|
||||
t0=$(date +%s)
|
||||
rc=0
|
||||
# `exit 0` in the worker: a failing FILE is recorded in its .rc, so a non-zero
|
||||
# from xargs itself means the machinery broke, which is a failure too.
|
||||
xargs -n 1 -P "$JOBS" sh -eu "$0" --one < "$RUN_DIR/par" || rc=1
|
||||
echo "parallel batch done in $(( $(date +%s) - t0 ))s; serial group next"
|
||||
while read -r f; do
|
||||
sh -eu "$0" --one "$f" || rc=1
|
||||
done < "$RUN_DIR/ser"
|
||||
|
||||
# Every file that was meant to run has a verdict, and every verdict is PASS. A
|
||||
# worker that died before writing its .rc leaves no file, which counts as a fail.
|
||||
ran=0
|
||||
bad=0
|
||||
for f in "$@"; do
|
||||
ran=$((ran + 1))
|
||||
if [ ! -f "$RUN_DIR/$f.rc" ]; then
|
||||
echo "FAIL $f (no result: the run never reported)"
|
||||
bad=$((bad + 1))
|
||||
elif [ "$(cat "$RUN_DIR/$f.rc")" != 0 ]; then
|
||||
bad=$((bad + 1))
|
||||
fi
|
||||
done
|
||||
echo "$ran file(s), $bad failed, $(( $(date +%s) - t0 ))s wall"
|
||||
if [ "$bad" = 0 ] && [ "$rc" = 0 ]; then exit 0; fi
|
||||
exit 1
|
||||
+15
-23
@@ -209,7 +209,7 @@ on:
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: [self-hosted, goodwe-rig]
|
||||
timeout-minutes: 70 # ~4 to run; the rest is queueing for the single slot
|
||||
timeout-minutes: 40 # ~15 to run (CI-15: 6 files at a time; the serial suite was ~60 and hit the runner cap); the rest is margin for a busy host
|
||||
env:
|
||||
# ⚠️ UNDER /home/homelab, NOT $GITHUB_WORKSPACE (ENV-01, and re-measured
|
||||
# during CI-01). act's workspace is `/root/.cache/act/<id>/hostexecutor`, a
|
||||
@@ -534,6 +534,19 @@ jobs:
|
||||
# shorter timeout - a 60 s limit was tried during CI-01 and reported
|
||||
# exactly the false hang the docstring predicts.
|
||||
#
|
||||
# ⚠️ CI-15: THE FILES RUN SIX AT A TIME, NOT ONE AFTER ANOTHER. The serial
|
||||
# loop summed 3 557 s on SAFETY-125's run (job 2580) and ran past the
|
||||
# runner's 60-minute cap. The loop now lives in .gitea/ci/run-tests.sh (it
|
||||
# comes from the merged tree, like the Dockerfile), so the same script runs
|
||||
# locally and here. It runs the longest files first, then the files whose
|
||||
# assertions depend on the wall clock - test_loop.py and test_freeze.py
|
||||
# (TEST-30 / CI-06) and test_safety87.py (CI-15) - ALONE, after the
|
||||
# parallel batch, so no sibling can steal their CPU. The reasons are in
|
||||
# the script. Its output stays one
|
||||
# PASS/FAIL block per file, in a file per file (never one shared /tmp/out).
|
||||
# A file that is not in the script's two typed lists still runs, in the
|
||||
# parallel batch: the list is a sort order, not a selection.
|
||||
#
|
||||
# test_freeze.py part 3 runs against the real `sim/hwsim.py` from the
|
||||
# checkout above; GOODWE_REQUIRE_HWSIM=1 makes it fail rather than use its
|
||||
# stub (CI-12). Lines a test prints with a `CI: ` prefix are echoed on PASS.
|
||||
@@ -552,28 +565,7 @@ jobs:
|
||||
- name: the add-on's own tests
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
|
||||
cd /w/goodwe-addon/goodwe_controller
|
||||
set -- test_*.py
|
||||
if [ "$1" = "test_*.py" ]; then
|
||||
echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked."
|
||||
exit 1
|
||||
fi
|
||||
echo "$# test file(s) to run"
|
||||
rc=0
|
||||
for f in "$@"; do
|
||||
start=$(date +%s)
|
||||
if python3 "$f" > /tmp/out 2>&1; then
|
||||
echo "PASS $f ($(( $(date +%s) - start ))s)"
|
||||
grep "^CI: " /tmp/out || true
|
||||
else
|
||||
echo "FAIL $f ($(( $(date +%s) - start ))s)"
|
||||
tail -40 /tmp/out
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
exit $rc
|
||||
'
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu /w/goodwe-addon/.gitea/ci/run-tests.sh
|
||||
|
||||
- name: clean up
|
||||
if: always()
|
||||
|
||||
@@ -107,7 +107,7 @@ on real hardware, and the comments in `control.py` say which.
|
||||
|
||||
### CI and the infra pin
|
||||
|
||||
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py`, with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
|
||||
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py` (six at a time, the wall-clock ones alone afterwards: `.gitea/ci/run-tests.sh`), with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
|
||||
The pin tracks `admin/goodwe` **release/1.0**, not master. Two non-required checks go red when release/1.0 has moved past the pin: `infra-pin / fresh` here, on any add-on PR or push to release/1.0, and `addon-pin / fresh` in `admin/goodwe`, on every push to its release/1.0. Nothing re-runs `addon-pin` after a bump, so it stays red on that admin/goodwe commit; `infra-pin / fresh` green on the bump is the all-clear.
|
||||
**Bump the pin** whenever either is red. Set `.gitea/infra-pin` to the new release/1.0 SHA (`git ls-remote <gitea>/admin/goodwe.git refs/heads/release/1.0`) in a PR, and let `checks / unit` grade the tests against it. CI reads `admin/goodwe` with the Actions secret `INFRA_READ_TOKEN`.
|
||||
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
# Changelog
|
||||
|
||||
## 0.19.2
|
||||
|
||||
**A patch bump: one fix for charging from the grid under the peak limit, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.19.1 also
|
||||
start 0.19.2. The change below applies with one inverter or two.
|
||||
|
||||
**Charging from the grid now stays under this month's peak.** A late meter reading near a period's end could push a grid charge to full power. That could take the 15-minute period over the peak. Now the charge is cut back, with one inverter too. The peak guard line no longer says "under" when the period would go over the limit.
|
||||
|
||||
Contents of this release, in merge order since 0.19.1 (`2932f3a`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `SAFETY-125` | A grid charge (a battery mode's cheap run, price trading or the maintenance charge, with one inverter or two) counts the seconds since a late meter reading at that reading, so it is cut back near the end of a 15-minute period instead of going back to full power. With one inverter the maintenance charge is now held to the room left in the period, as with two, so the peak guard no longer steps in and gives power at the period's end. Over the limit at the current rate, the peak guard row and the month peak line say so, and that the grid charge is cut back ([[SAFETY-125]]). |
|
||||
|
||||
### Known issues
|
||||
|
||||
The list under `## 0.19.0` below still applies. The SAFETY-99 line there now
|
||||
covers the guard's discharge only, not a grid charge.
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.19.1
|
||||
|
||||
**A patch bump: two fixes for battery modes, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.19.0 also
|
||||
start 0.19.1. The changes below apply with one inverter or two.
|
||||
|
||||
**Battery modes now see a day/night or fixed tariff.** With battery modes on, a day/night or fixed tariff and no price service, the batteries never got the prices: "Cheap hours" never charged from the grid at the cheap rate, and the Batteries screen said "No prices". They get them now, with no price service and no internet call added.
|
||||
|
||||
**A solar forecast now counts for the price gap.** With battery modes on and a solar forecast read, a price mode needs a gap of at least *Battery cycle cost*, not 1.5 times that. Without a forecast it is still 1.5 times. A day/night gap of 0.08 now works at a cycle cost of 0.07 or less.
|
||||
|
||||
Contents of this release, in merge order since 0.19.0 (`793a676`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `TARIFF-24` | With battery modes on, the add-on now also resolves the tariff when no price service and no price trading are set, so a day/night or fixed tariff gives the batteries their price runs ([[TARIFF-24]]). With battery modes on and a solar forecast read, the price gap a mode needs is *Battery cycle cost* (widened only for a real-time feed), not 1.5 times that; Home and the Batteries screen use the same rule ([[AUDIT-02]] F1). |
|
||||
|
||||
### Known issues
|
||||
|
||||
The list under `## 0.19.0` below still applies.
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.19.0
|
||||
|
||||
**A minor bump: price trading behaves differently, plus a fix for a false warning, the Home price chart in 15-minute bars, more Dutch text and EMS-port wording, and no option changes.**
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# OPT-23 and OPT-28 - the day plan counts the sun right, and the night charge covers the expensive-hour shortfall
|
||||
|
||||
**Status: for review.** Tests: `test_opt23_28.py`.
|
||||
|
||||
`dayplan.plan()` now buys, for Leftover solar batteries, what the house uses in the expensive hours less the sun counted for each hour, shared by capacity, and reads a state-only forecast from now for every span (OPT-23).
|
||||
|
||||
- **Shortfall.** `sum_h max(0, house_h - trust x sun_h)` over every expensive hour, `/ EFF`, from the floor. It replaces the use over the run with no sun counted, which on a day and night tariff bought for the whole day (G1, `reports/UX-69-design-report.md`). The sun before the run is unchanged. A run with its dear hours after sunset gives the old number.
|
||||
- **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun.
|
||||
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by another battery (Cheap hours, Solar first) or the grid, as the screen says.
|
||||
- **Cheap hours** still fills up and is not counted in the share; **Solar first** still never charges from the grid. The mode help says so.
|
||||
- **Not done:** the run length (OPT-25); netting what a Cheap hours or Solar first battery will hold against the shortfall.
|
||||
@@ -0,0 +1,10 @@
|
||||
# SAFETY-125 - a grid charge under the month peak
|
||||
|
||||
**Status: FIXED in 0.19.2 (hotfix from 0.19.1).** Tests: `test_safety125.py`.
|
||||
|
||||
A grid charge is sized by `peakguard.charge_room_w()`, the guard's headroom
|
||||
with the seconds since a late meter reading counted at that reading (the
|
||||
projection leaves them out, SAFETY-99, the bill does not), and the guard's own
|
||||
floor is unchanged. At N=1 the maintenance charge is now sized too (owner
|
||||
2026-10-29; SAFETY-84 had left it out). Evidence (repro on 0.19.1, sweep,
|
||||
digests): the SAFETY-125 pull request.
|
||||
@@ -39,7 +39,8 @@ was fine.
|
||||
**Not changed.**
|
||||
- At N=1 the charge is not sized to the headroom: the guard still suspends it
|
||||
and the battery gives until the quarter is safe. The same sizing would fit
|
||||
there; that is an owner question, not this fix.
|
||||
there; that is an owner question, not this fix. (SAFETY-125, 0.19.2: the
|
||||
owner said yes, and N=1 is sized the same way.)
|
||||
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
|
||||
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
|
||||
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# TARIFF-24 - battery modes need the tariff task
|
||||
|
||||
**Status: FIXED in 0.19.1 (hotfix from 0.19.0, owner go 2026-10-28).**
|
||||
Follows TARIFF-10 (fetching is not trading). Tests: `test_tariff24.py`.
|
||||
|
||||
**The defect.** The control cycle gets prices only from
|
||||
`Controller.tariff_plan`, which only `run_tariff_refresh()` stores. `amain()`
|
||||
starts that task when `tariff_refresh_wanted(opts)`, which was
|
||||
`provider_configured(opts) or arbitrage.enabled(opts)`. Battery modes read the
|
||||
plan but were not a term. With battery modes on, a day/night or fixed
|
||||
`static_profile`, `price_provider: off` and `arbitrage_enabled: false` (the
|
||||
site trade switch is not read with modes on) no task ran. Then
|
||||
`fresh_tariff_plan()` was None, `_price_runs()` was None and `mode_runs` was
|
||||
None, so Leftover solar and Cheap hours never charged from the grid. The
|
||||
Batteries screen said "No prices, so it follows the house" while Home priced
|
||||
the day from `static_plan()` itself (`homeview.build_plan()`).
|
||||
|
||||
**The fix** (`main.py`, `tariff_refresh_wanted()`). `packmode.enabled(opts)`
|
||||
is a third term. The gate stays a widening: nothing that started the task
|
||||
before stops. No HTTPS call is added: with `price_provider: off`,
|
||||
`resolve_tariff()` is the pure `static_plan()`.
|
||||
|
||||
**Not changed.** Modes off, arbitrage off, no feed still starts no task. The
|
||||
control law, the price runs and every message are untouched.
|
||||
|
||||
**One rule, not two.** Gating on "modes on and a static profile" was
|
||||
considered. It saves one arithmetic pass every 30 min on a site with modes on
|
||||
and no prices at all, and it adds a second condition that has to track what
|
||||
`resolve_tariff()` accepts. With no profile and no feed the plan is `no_data`,
|
||||
exactly what the cycle sees without the task.
|
||||
|
||||
**UX-55.** Home said "no prices have arrived" (`mode_runs` None) with every
|
||||
battery on Solar first. The same cause, and this fix clears it for a site with
|
||||
a static profile (`test_tariff24.py` part 5). A site with no profile and no
|
||||
feed still has no prices, and Home still says so.
|
||||
|
||||
## Round 2 (AUDIT-02 F1): the forecast replaces the "no weather" doubt
|
||||
|
||||
**Decided 2026-10-28 (owner), in the same hotfix.** The price gap a mode needs
|
||||
was `cycle_cost_threshold_eur` x 1.5 (`Uncertainty.NO_WEATHER`) whether or not
|
||||
a solar forecast was read. At the shipped cost 0.10 that is 0.15, so a
|
||||
day/night gap (0.08) and many dynamic days never traded, and the screens said
|
||||
only "Prices differ too little".
|
||||
|
||||
**Why the doubt goes with a forecast.** The widening stands in for not knowing
|
||||
the weather. The forecast is that knowledge, and the day plan already trusts it
|
||||
for how much to charge. Trusting it for the plan but doubting it for the gap
|
||||
was one input judged twice.
|
||||
|
||||
**The rule** (`arbitrage.weather_uncertainty()`). With battery modes on and a
|
||||
usable forecast (`solar_fc` not None, the day plan's own test) the doubt is
|
||||
not added: the gap needed is the cycle cost, widened by the price product only.
|
||||
No forecast, or a stale or unusable one: 1.5 x, as before. It is read in four
|
||||
places that must agree: the run plan (`_price_runs()`), the charge gate
|
||||
(`_mode_roles()`), Home's mirror (`homeview.trade_verdict()`) and the Forecast
|
||||
tab's longer cheap run (`dayview.py`, from release/1.0). The forecast
|
||||
is read at most every 60 s and the plan key already holds "forecast or not", so
|
||||
the gap follows in the cycle the read changes, both ways, with one exception:
|
||||
a run planned with a forecast that is under way keeps that gap to its end
|
||||
(`Controller.weather_held()`), so a sensor that goes unavailable does not stop
|
||||
or restart a Cheap hours charge (a Leftover solar battery still stops: without
|
||||
a forecast it has no planned level). The wider gap applies from the next run.
|
||||
A run that has started runs to its end: a give run that starts during the hold
|
||||
is kept after it (`keep_started()`), with no grid charge or sale.
|
||||
|
||||
**Not changed.** Modes off (the site trade) keeps the doubt always. The
|
||||
shipped cost 0.10 still needs a gap of 0.10 even with a forecast: a 0.08
|
||||
day/night site must set *Battery cycle cost* to 0.08 or less. Showing the two
|
||||
numbers on the screens (AUDIT-02 F1 option b) is a separate card.
|
||||
|
||||
@@ -530,15 +530,15 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen (and Se
|
||||
- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
|
||||
- **Mode.**
|
||||
- *Solar first* charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells.
|
||||
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). It saves its power for the expensive hours and covers the house then.
|
||||
- *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then.
|
||||
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but it buys only what the sun will not give in the expensive hours (see below). It saves its power for those hours and covers the house then.
|
||||
- *Cheap hours only* fills up from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then. It does not look at the forecast.
|
||||
- **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A).
|
||||
|
||||
With battery modes on, these choices replace the *Charging and selling on price* switch (`arbitrage_enabled`); that switch is only read with modes off.
|
||||
|
||||
**Cheap and expensive hours.** They come from the published day-ahead prices, as runs of consecutive 15-minute periods, not a single period.
|
||||
- The cheap run is the cheapest stretch long enough to fill the batteries that may charge, at their power.
|
||||
- An expensive hour costs at least *Battery cycle cost* (`cycle_cost_threshold_eur`, widened as for trading) more than the cheap run. The batteries hold their power until then.
|
||||
- An expensive hour costs more than the cheap run by at least *Battery cycle cost* (`cycle_cost_threshold_eur`) when a solar forecast is read, and by 1.5 times that when none is (a real-time price feed widens it further). The batteries hold their power until then.
|
||||
- The sale uses the most expensive stretch inside those hours, and never a period below 0.
|
||||
- A carried (not yet published) price is never used. A run that has started runs to its end.
|
||||
- With no prices, or too small a gap, every battery follows the house.
|
||||
@@ -548,7 +548,9 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
|
||||
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
|
||||
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
|
||||
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
|
||||
- *Leftover solar* then charges from the grid only the shortfall: for each expensive hour, what the house uses less the sun counted for that hour (never below 0), added up over all the expensive hours, early morning and evening. With a day and night tariff that is 07:00 to 22:00. Not up to the top level.
|
||||
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. In the expensive hours the rest comes from another battery (*Cheap hours only* or *Solar first*) or from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
|
||||
- A forecast that gives only one figure for the day (Forecast.Solar) is spread over the hours from now, so the hours add up to that figure.
|
||||
- *Cheap hours only* ignores the forecast and fills to the top level.
|
||||
- *Solar first* never charges from the grid.
|
||||
- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan.
|
||||
@@ -1708,7 +1710,9 @@ DECISIONS/MAINT-13-full-pack-is-complete.md.
|
||||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||||
under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
|
||||
is dropped so the battery can shave the peak instead. Money outranks the
|
||||
maintenance schedule.
|
||||
maintenance schedule. With one battery or several, the charge also stays
|
||||
within the room left in the 15-minute period, so a late meter reading does
|
||||
not let it run at full power.
|
||||
|
||||
With two or more batteries, the one being emptied gives its drain rate and the
|
||||
others only add power when the house needs more; none charges while it drains
|
||||
@@ -3612,7 +3616,7 @@ for a new install*.
|
||||
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
|
||||
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
|
||||
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *At this rate … over …* means it would go over at the current rate. With *grid charge limited*, a charge from the grid is cut back so it does not. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
|
||||
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
|
||||
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
|
||||
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -7,20 +7,31 @@ turns the level into permission to charge from the grid; main.py rebuilds the
|
||||
plan with the price runs (at most every 15 minutes) and keeps a level unless
|
||||
it moves by `HYST_KWH` or `HYST_FRAC` (`hold()`).
|
||||
|
||||
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers):
|
||||
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers;
|
||||
OPT-28 for the second):
|
||||
* `sun`: trust x the solar the house will not use, between now and the
|
||||
expensive run the cheap run is for, x the charge efficiency:
|
||||
`trust x sum_h max(0, forecast_h - house_h) x EFF`.
|
||||
* Solar first batteries take it first, up to the ceiling. What is left is
|
||||
shared by the Leftover solar batteries by their room.
|
||||
* `short` (OPT-28): what the house draws in the expensive run that the sun
|
||||
will not give, `sum_h max(0, house_h - trust x forecast_h)` over every
|
||||
hour of the run, early morning and evening. The Leftover solar batteries
|
||||
hold it together: `short / EFF` above the floor, shared by capacity, and
|
||||
a battery that reaches the ceiling passes the rest to the others. What
|
||||
all of them cannot hold (`Plan.uncovered_kwh`) is not bought: another
|
||||
battery (Cheap hours, Solar first) or the grid carries it.
|
||||
* Cheap hours only ignores the plan and fills to the ceiling, as in P3 and
|
||||
the design (section 1). Skipping the sun's share cost money in the replay
|
||||
(PR #156 review: 29.72 against 29.41 EUR on real 2026 prices).
|
||||
* A Leftover solar battery charges only up to what the house will draw from
|
||||
it in the expensive runs before the next cheap run, less its share:
|
||||
`min(ceiling, floor + house use / EFF) - share`. So it buys only the
|
||||
* A Leftover solar battery charges from the grid up to the level it needs
|
||||
for its part of `short`, less its share of the sun before the run:
|
||||
`min(ceiling, floor + its part / EFF) - share`. So it buys only the
|
||||
shortfall, not up to the ceiling.
|
||||
* Solar first never charges from the grid (owner, 2026-09-29).
|
||||
* A state-only forecast is read from now for every span (`Forecast.between`
|
||||
`since=`, OPT-23), so the hours add up to the day's figure.
|
||||
DECISIONS/OPT-23-28-the-night-charge-covers-the-dear-hours.md has the record.
|
||||
|
||||
The house profile is the add-on's own measurement, grid + battery power, per
|
||||
local hour, over the last `PROFILE_DAYS` days; the estimate for an hour is the
|
||||
@@ -31,7 +42,7 @@ control decision.
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
@@ -154,6 +165,11 @@ class Plan:
|
||||
targets: dict # name -> level % (Leftover solar only)
|
||||
need_kwh: dict # name -> kWh from the grid (0: none)
|
||||
share_kwh: dict # name -> kWh of solar counted for it
|
||||
# OPT-28: the expensive hours' shortfall, the part of it each battery
|
||||
# covers, and what no battery can hold. All kWh on the house's side.
|
||||
short_kwh: float = 0.0
|
||||
cover_kwh: dict = field(default_factory=dict)
|
||||
uncovered_kwh: float = 0.0
|
||||
|
||||
|
||||
def _hour_slices(t0, t1):
|
||||
@@ -171,6 +187,31 @@ def _hour_slices(t0, t1):
|
||||
return out
|
||||
|
||||
|
||||
def _spread(amount, room, weight):
|
||||
"""Share `amount` over the keys of `room` by `weight`, none past its
|
||||
room, what one cannot take passed to the others. Returns `(taken,
|
||||
left)`: what each took and what none could."""
|
||||
got = {k: 0.0 for k in room}
|
||||
left = float(amount)
|
||||
live = {k for k, r in room.items() if r > 1e-9 and weight.get(k, 0) > 0}
|
||||
while left > 1e-9 and live:
|
||||
total = sum(weight[k] for k in live)
|
||||
used, full = 0.0, set()
|
||||
for k in sorted(live):
|
||||
part = left * weight[k] / total
|
||||
free = room[k] - got[k]
|
||||
if part >= free - 1e-9:
|
||||
part = free
|
||||
full.add(k)
|
||||
got[k] += part
|
||||
used += part
|
||||
left -= used
|
||||
if not full:
|
||||
break
|
||||
live -= full
|
||||
return got, max(0.0, left)
|
||||
|
||||
|
||||
def _room(p, to_pct):
|
||||
if p.capacity_kwh is None or p.soc is None:
|
||||
return 0.0
|
||||
@@ -194,7 +235,7 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
trust = max(0.0, min(1.0, float(trust_pct) / 100.0))
|
||||
sun = fc_sum = 0.0
|
||||
for s, e, h in _hour_slices(now, until.start):
|
||||
pv = forecast.between(s, e)
|
||||
pv = forecast.between(s, e, since=now)
|
||||
if pv is None:
|
||||
return None # the span reaches a day with no forecast
|
||||
hrs = (e - s).total_seconds() / 3600.0
|
||||
@@ -212,30 +253,40 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
rooms = {p.name: _room(p, ceiling_pct) for p in price}
|
||||
total_room = sum(rooms.values())
|
||||
|
||||
# What the house draws in the expensive runs from the one this plan is
|
||||
# for until the next cheap run (or a day), from the profile.
|
||||
# OPT-28: what the house draws in the expensive runs, from the one this
|
||||
# plan is for until the next cheap run (or a day), that the sun will not
|
||||
# give. An hour with sun is read at no less than the night base, as the
|
||||
# sun sum above reads it (the profile is net of the sun the house used);
|
||||
# an hour without is read as measured. A run hour with no forecast counts
|
||||
# no sun.
|
||||
horizon = until.start + timedelta(hours=24)
|
||||
use_kwh = 0.0
|
||||
short = 0.0
|
||||
for g in runs.give:
|
||||
if g.start < until.start or g.start >= horizon:
|
||||
continue
|
||||
for s, e, h in _hour_slices(g.start, g.end):
|
||||
use_kwh += max(0.0, house_w(h)) * (
|
||||
e - s).total_seconds() / 3600.0 / 1000.0
|
||||
pv = forecast.between(s, e, since=now) or 0.0
|
||||
w = max(0.0, house_w(h))
|
||||
if pv > 0.0:
|
||||
w = max(w, base_w)
|
||||
short += max(0.0, w * (e - s).total_seconds() / 3.6e6
|
||||
- trust * pv)
|
||||
caps = {p.name: float(p.capacity_kwh) for p in price}
|
||||
cap_sum = sum(caps.values()) or 1.0
|
||||
span = max(0.0, float(ceiling_pct) - float(floor_pct))
|
||||
held, left = _spread(short / EFF,
|
||||
{n: span / 100.0 * c for n, c in caps.items()},
|
||||
caps)
|
||||
|
||||
targets, need, share = {}, {}, {}
|
||||
targets, need, share, cover = {}, {}, {}, {}
|
||||
for p in price:
|
||||
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
|
||||
sh_pct = sh / caps[p.name] * 100.0
|
||||
want = use_kwh * caps[p.name] / cap_sum / EFF
|
||||
top = min(float(ceiling_pct),
|
||||
float(floor_pct) + want / caps[p.name] * 100.0)
|
||||
top = float(floor_pct) + held[p.name] / caps[p.name] * 100.0
|
||||
level = max(0.0, top - sh_pct)
|
||||
targets[p.name] = round(level, 2)
|
||||
need[p.name] = round(_room(p, level), 3)
|
||||
share[p.name] = round(sh, 3)
|
||||
cover[p.name] = round(held[p.name] * EFF, 3)
|
||||
if sun < sf_room:
|
||||
verdict = DARK
|
||||
elif any(v > 0.05 for v in need.values()):
|
||||
@@ -243,7 +294,8 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
else:
|
||||
verdict = SUNNY
|
||||
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
|
||||
targets, need, share)
|
||||
targets, need, share, round(short, 3), cover,
|
||||
round(left * EFF if price else 0.0, 3))
|
||||
|
||||
|
||||
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
|
||||
|
||||
@@ -23,7 +23,7 @@ from . import arbitrage, dayplan, homeview as hv, modetrade, priceruns
|
||||
from . import solarfc
|
||||
from .daycurve import Batt, curve
|
||||
from .packmode import SOLAR_FIRST
|
||||
from .price import Uncertainty, brussels, required_spread, uncertainty_of
|
||||
from .price import brussels, required_spread, uncertainty_of
|
||||
|
||||
BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry
|
||||
LEVEL_DP = 1 # the tab shows a level to 0.1 %
|
||||
@@ -42,12 +42,12 @@ def build(c, status: dict, now: datetime, lang: str = "en") -> dict:
|
||||
|
||||
def sun_kwh(fc, now, a, b):
|
||||
"""kWh the forecast gives in `[a, b)`, `a` at or after `now`, or None.
|
||||
Both ends are measured from `now`: `Forecast.between()` reads its start
|
||||
as the moment the "left today" figure was taken, so a span that starts
|
||||
later would be spread over what is left of the bell, not over its own
|
||||
hours (state-only forecasts, DECISIONS/UX-54-forecast-tab.md)."""
|
||||
hi, lo = fc.between(now, b), fc.between(now, a)
|
||||
return None if hi is None or lo is None else max(0.0, hi - lo)
|
||||
Every span is measured from `now` (`Forecast.between(since=)`, OPT-23),
|
||||
as `dayplan.plan()` measures them: a state-only "left today" figure is
|
||||
the sun from the moment it was taken, so a span that starts later must
|
||||
not be spread over what is left of the bell from its own start
|
||||
(DECISIONS/UX-54-forecast-tab.md)."""
|
||||
return fc.between(a, b, since=now)
|
||||
|
||||
|
||||
def _times(run):
|
||||
@@ -204,9 +204,12 @@ def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow,
|
||||
end_t = None
|
||||
if plan_on and cheap is not None and rp.charge_useful() \
|
||||
and n0 <= cheap.start < n1 and plan is not None:
|
||||
# The gap control's run plan needs (TARIFF-24): no x1.5 with a
|
||||
# forecast read.
|
||||
required = required_spread(
|
||||
arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty_of(plan.read.product) | {Uncertainty.NO_WEATHER})
|
||||
uncertainty_of(plan.read.product)
|
||||
| arbitrage.weather_uncertainty(True, c.solar_fc))
|
||||
end_t = cheap.end
|
||||
for _ in range(24):
|
||||
ext = dataclasses.replace(rp, cheap=priceruns.Run(
|
||||
|
||||
@@ -20,10 +20,11 @@ import contextlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import arbitrage, messages, peakguard, uitext
|
||||
from .dayplan import EFF
|
||||
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
|
||||
from .maintenance import IDLE as M_IDLE, next_slot_start
|
||||
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .price import Absence, Uncertainty, brussels, _cover_table
|
||||
from .price import Absence, brussels, _cover_table
|
||||
from .static_tariff import static_plan
|
||||
from .tariff import BILLING_TZ
|
||||
from .uimodel import REGION_PRESETS, options_to_ui
|
||||
@@ -279,7 +280,11 @@ def trade_verdict(c, plan, now):
|
||||
return None, "no_prices"
|
||||
verdict = plan.arbitrage(peak_guard_active=guard,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
uncertainty=arbitrage.weather_uncertainty(
|
||||
bool(getattr(c, "modes_on", False)),
|
||||
getattr(c, "solar_fc", None),
|
||||
bool(getattr(c, "weather_held",
|
||||
lambda _n: False)(now))),
|
||||
now=now)
|
||||
return verdict.match(
|
||||
allowed=lambda v: (v, "allowed"),
|
||||
@@ -471,9 +476,15 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
continue
|
||||
price = run.mean_eur_kwh
|
||||
if kind == "grid":
|
||||
cov = plan_cover(c, inv.name)
|
||||
what, why, amount = (run_grid_what(kwh, shadow),
|
||||
run_cheap_why(price),
|
||||
run_grid_amount(kwh * price))
|
||||
if cov is not None:
|
||||
# OPT-28: with a day plan the block says how far it
|
||||
# charges and what that covers.
|
||||
what = run_grid_what_to(kwh, cov[0], shadow)
|
||||
why += " " + run_covers(cov[1])
|
||||
elif kind == "sell":
|
||||
# No money line: the price is what the house pays, and a sale
|
||||
# earns the lower injection price (OPT-11 P3 review).
|
||||
@@ -506,6 +517,24 @@ def plan_need(c, name, soc=None):
|
||||
inv.capacity_kwh)
|
||||
|
||||
|
||||
def plan_cover(c, name):
|
||||
"""OPT-28: `(level %, kWh)` for a battery the day plan charges from the
|
||||
grid: the level it charges to, and the expensive-hour use of the house
|
||||
that charge covers and the sun will not. Read from the level control
|
||||
holds (`plan_targets()`), so Home says what control does. None when the
|
||||
plan buys nothing for it."""
|
||||
dp = getattr(c, "day_plan", None)
|
||||
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
|
||||
inv = next((i for i in c.inverters if i.name == name), None)
|
||||
if dp is None or not targets or name not in targets or inv is None \
|
||||
or not inv.capacity_kwh or targets[name] <= 0.0:
|
||||
return None
|
||||
floor, _ceil = arbitrage.soc_bounds(c.o)
|
||||
cap = float(inv.capacity_kwh)
|
||||
top = targets[name] + dp.share_kwh.get(name, 0.0) / cap * 100.0
|
||||
return targets[name], max(0.0, top - floor) / 100.0 * cap * EFF
|
||||
|
||||
|
||||
def plan_state(c):
|
||||
"""P4: `"plan"` (a day plan is made), `"learning"` (no house profile
|
||||
yet), `"no_forecast"` (set but missing or stale), or None (not set, or
|
||||
@@ -540,6 +569,9 @@ def solar_block(c, now):
|
||||
elif fc is not None:
|
||||
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
|
||||
out["updated"])
|
||||
dp = getattr(c, "day_plan", None)
|
||||
if st == "plan" and dp is not None and dp.uncovered_kwh >= MIN_KWH:
|
||||
out["line"] += " " + line_uncovered(dp.uncovered_kwh)
|
||||
return out
|
||||
|
||||
|
||||
@@ -863,7 +895,8 @@ def battery_rows(c, shadow, running, held=False):
|
||||
# knows this cycle. Price and forecast reasons are OPT-11 P3/P4.
|
||||
"why_key": why,
|
||||
"why": why_words(why, name=cycling if why == "other_cycle"
|
||||
else other, at=at),
|
||||
else other, at=at,
|
||||
**plan_words(c, inv.name, why)),
|
||||
})
|
||||
if modes_on:
|
||||
# OPT-11 P1: the battery's switch and mode, for the Batteries
|
||||
@@ -1029,7 +1062,8 @@ def month_peak(c, now):
|
||||
ceiling = pg.ceiling_w or limit_w
|
||||
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
|
||||
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
|
||||
> 0.0)
|
||||
> 0.0,
|
||||
cut=getattr(c, "room_charge", False))
|
||||
return out
|
||||
|
||||
|
||||
@@ -1160,6 +1194,10 @@ def _kwh(v) -> str:
|
||||
return _dec(f"{float(v):.1f}") + " kWh"
|
||||
|
||||
|
||||
def _pct(v) -> str:
|
||||
return _dec(f"{float(v):.0f}") + " %"
|
||||
|
||||
|
||||
# Row id -> the title on Home. The row's own sentence is the body, in the
|
||||
# page's language (UX-46, `messages.pick`). Five titles are the notification's
|
||||
# own title, which test_ui_text.py checks.
|
||||
@@ -1292,11 +1330,18 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
|
||||
return _t("now.resting")
|
||||
|
||||
|
||||
def why_words(key, name=None, at="") -> str:
|
||||
def why_words(key, name=None, at="", pct="", kwh="") -> str:
|
||||
"""One battery's "why" line (UX-13 P3), from `battery_why()`'s key.
|
||||
`name`: the battery that refuses, for `other_refuses`; `at`: the
|
||||
give run's time, for `saves`."""
|
||||
return _t("why." + key, name=name or "", at=at)
|
||||
give run's time, for `saves`; `pct` and `kwh`: the planned level and
|
||||
what it covers, for `tops_up` (OPT-28)."""
|
||||
return _t("why." + key, name=name or "", at=at, pct=pct, kwh=kwh)
|
||||
|
||||
|
||||
def plan_words(c, name, why) -> dict:
|
||||
"""OPT-28: the figures of a `tops_up` line, or nothing."""
|
||||
cov = plan_cover(c, name) if why == "tops_up" else None
|
||||
return {} if cov is None else {"pct": _pct(cov[0]), "kwh": _kwh(cov[1])}
|
||||
|
||||
|
||||
def today_words(key) -> str:
|
||||
@@ -1318,6 +1363,16 @@ def run_grid_what(kwh, shadow) -> str:
|
||||
return _w(shadow, "run.grid_what", kwh=_kwh(kwh))
|
||||
|
||||
|
||||
def run_grid_what_to(kwh, pct, shadow) -> str:
|
||||
"""OPT-28: a grid block of the day plan, with the level it charges to."""
|
||||
return _w(shadow, "run.grid_what_to", kwh=_kwh(kwh), pct=_pct(pct))
|
||||
|
||||
|
||||
def run_covers(kwh) -> str:
|
||||
"""OPT-28: what a planned grid charge covers."""
|
||||
return _t("run.covers", kwh=_kwh(kwh))
|
||||
|
||||
|
||||
# The trade legs and the price line use the feed's own points (a 15-minute
|
||||
# period on a quarter-hourly feed), so both name the same price and time.
|
||||
def run_grid_why(price) -> str:
|
||||
@@ -1395,6 +1450,12 @@ def line_solar(fc_kwh, count_kwh, at) -> str:
|
||||
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
|
||||
|
||||
|
||||
def line_uncovered(kwh) -> str:
|
||||
"""OPT-28: the Leftover solar batteries cannot hold the expensive hours'
|
||||
shortfall. The rest comes from another battery or the grid."""
|
||||
return _t("solar.uncovered", kwh=_kwh(kwh))
|
||||
|
||||
|
||||
def line_no_trade(key) -> str:
|
||||
return "" if key == "off" else _t("no_trade." + key)
|
||||
|
||||
@@ -1448,13 +1509,19 @@ def line_peak_not_covered() -> str:
|
||||
return _t("peak_not_covered")
|
||||
|
||||
|
||||
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
|
||||
def line_period(period_w, limit_w, shadow, unmeasured=False,
|
||||
cut=False) -> str:
|
||||
step = _w(shadow, "step_in", kw=_kw(limit_w))
|
||||
if unmeasured:
|
||||
# SAFETY-90: the add-on started during this period.
|
||||
return _t("period_unmeasured") + " " + step
|
||||
if period_w is None:
|
||||
return step
|
||||
if period_w > limit_w:
|
||||
# SAFETY-125: the figure is the current rate, and a grid charge is
|
||||
# what gets cut back.
|
||||
return _t("period_over", kw=_kw(period_w)) + " " + (
|
||||
_w(shadow, "charge_cut", kw=_kw(limit_w)) if cut else step)
|
||||
return _t("period", kw=_kw(period_w)) + " " + step
|
||||
|
||||
|
||||
@@ -1516,6 +1583,7 @@ def _samples() -> list:
|
||||
for k in VERDICT:
|
||||
out.append(("plan", plan_head(k, sh)))
|
||||
out += [("plan", run_grid_what(12.34, sh)),
|
||||
("plan", run_grid_what_to(12.34, 82, sh)),
|
||||
("plan", run_give_what(9.6, sh)),
|
||||
("plan", run_cycle_what(M_IDLE, sh)),
|
||||
("plan", run_cycle_what(M_DRAIN, sh)),
|
||||
@@ -1527,9 +1595,12 @@ def _samples() -> list:
|
||||
("plan", line_period(1240, 3500, sh)),
|
||||
("plan", line_period(None, 3500, sh)),
|
||||
("plan", line_period(1240, 3500, sh, unmeasured=True)),
|
||||
("plan", line_period(5814, 5413, sh)),
|
||||
("plan", line_period(5814, 5413, sh, cut=True)),
|
||||
("plan", control_text(True, sh)),
|
||||
("plan", control_text(False, sh))]
|
||||
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
|
||||
("plan", run_covers(9.4)), ("plan", line_uncovered(2.5)),
|
||||
("plan", run_give_why(0.3612)), ("plan", run_give_amount(3.47)),
|
||||
("plan", run_cycle_why()), ("plan", run_cycle_why(True)),
|
||||
("plan", run_cycle_amount(None)),
|
||||
@@ -1560,7 +1631,8 @@ def _samples() -> list:
|
||||
out.append(("plan", battery_now(62, w, phase=M_IDLE,
|
||||
shadow=False, running=True,
|
||||
off=off, peak=peak)))
|
||||
out += [("plan", why_words(k, name="Battery A")) for k in words["why"]]
|
||||
out += [("plan", why_words(k, name="Battery A", pct="82 %",
|
||||
kwh="9.4 kWh")) for k in words["why"]]
|
||||
out += [("plan", today_words(k)) for k in words["today"]]
|
||||
out += [("plan", line_solar(9.2, 6.4, "10:15")),
|
||||
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning"))]
|
||||
|
||||
@@ -175,57 +175,17 @@ TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||
|
||||
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
"""TARIFF-10. Does this install need the background tariff refresh at all?
|
||||
"""Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24)
|
||||
|
||||
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
|
||||
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
|
||||
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||||
only caller of `price_history.carry()` - so on a shipped install with a
|
||||
provider configured NOTHING was ever fetched, no plan was ever resolved,
|
||||
`/data/price_history.json` was never created, the *Price history* row read
|
||||
`empty` forever, and TARIFF-09's short-horizon warning could not fire
|
||||
because there was no fetch to warn about. Measured over a 40 h soak of two
|
||||
instances side by side: zero feed requests and `present: false` across all
|
||||
802 samples on the shipped default, against a resolved plan from the same
|
||||
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
|
||||
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
|
||||
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
|
||||
it, and the sentence saying a configured provider still fetches, all in one
|
||||
change. config.yaml has said it since TARIFF-02 (852fc7c).
|
||||
|
||||
Two terms, and they answer two different questions:
|
||||
|
||||
provider_configured(opts) -> "the operator asked for a price feed", so
|
||||
fetch it, validate it and record the day
|
||||
(§8.3). This is the term TARIFF-10 ADDS.
|
||||
arbitrage.enabled(opts) -> "the operator armed trading". This term is
|
||||
what the gate USED to be, kept verbatim.
|
||||
|
||||
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
|
||||
that started this task before still starts it; none that did not start it
|
||||
stops anything. That matters because the second term is not redundant:
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
|
||||
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
|
||||
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
|
||||
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
|
||||
that site forever and `decide()` would answer "no tariff plan has been
|
||||
resolved yet" on every cycle - silently disarming a strategy the UI says is
|
||||
running, which is the exact failure validate.py's OPT-04 rules exist to
|
||||
refuse.
|
||||
|
||||
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
|
||||
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
|
||||
arithmetic, no socket - so the second term starts a task that fetches
|
||||
nothing. The true shipped default (no provider, no arbitrage) starts no
|
||||
task at all. What is fetched is decided by the FIRST term only.
|
||||
|
||||
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
|
||||
Whether they move energy is `arbitrage.enabled()` at the one call site in
|
||||
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
|
||||
means no trade, ever, on any configuration.
|
||||
Yes with a price feed, with trading on price armed, or with battery modes
|
||||
on: the modes read `self.tariff_plan` for their price runs. With
|
||||
`price_provider` off the refresh is pure arithmetic on `static_profile`, so
|
||||
no term here adds an HTTPS call. It decides whether prices are looked up,
|
||||
never whether energy moves. See DECISIONS/TARIFF-10-fetching-is-not-trading.md
|
||||
and DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
"""
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
return (provider_configured(opts) or arbitrage.enabled(opts)
|
||||
or packmode.enabled(opts))
|
||||
|
||||
|
||||
def load_options() -> dict:
|
||||
@@ -1001,6 +961,9 @@ class Controller:
|
||||
# and whether last tick filed a mode trade (a run edge reseeds).
|
||||
self.mode_runs = None
|
||||
self._runs_key = None
|
||||
# TARIFF-24: the end of the run that was under way while a forecast
|
||||
# was read; until then losing the forecast does not widen the gap.
|
||||
self._wx_free_to = None
|
||||
self.mode_tick = None
|
||||
# UX-53: why the trade gate refused a grid charge this tick (`peak`,
|
||||
# `not_worth`, `not_covered`), or None when it allowed one. Home reads
|
||||
@@ -1259,6 +1222,8 @@ class Controller:
|
||||
# because `self.peak_guard` is a different cycle's decision by the time
|
||||
# the status page renders.
|
||||
self.arb_headroom_w = None
|
||||
# SAFETY-125: a grid charge sized to the quarter's room this cycle.
|
||||
self.room_charge = False
|
||||
# UX-13 P1: today's prices as the Home chart has seen them. Display
|
||||
# only; nothing reads it back into a decision.
|
||||
self.home_prices = homeview.DayPrices()
|
||||
@@ -2274,6 +2239,28 @@ class Controller:
|
||||
probing=probing))
|
||||
return out
|
||||
|
||||
def _wx_track(self, now) -> None:
|
||||
"""TARIFF-24, once per cycle, before the plan: with a forecast read,
|
||||
remember when the run now under way (cheap, give or sell) ends."""
|
||||
if self.solar_fc is None:
|
||||
return
|
||||
rp = self.mode_runs
|
||||
ends = []
|
||||
if rp is not None and rp.on:
|
||||
ends = [r.end for r in (rp.cheap, rp.sell)
|
||||
if r is not None and r.holds(now)]
|
||||
g = rp.give_at(now)
|
||||
if g is not None:
|
||||
ends.append(g.end)
|
||||
self._wx_free_to = max(ends) if ends else None
|
||||
|
||||
def weather_held(self, now) -> bool:
|
||||
"""TARIFF-24: no forecast now, but a run planned with one is under
|
||||
way: it keeps its gap to its end. The wider gap applies from the next
|
||||
run. Pure; Home asks the same (`homeview.trade_verdict()`)."""
|
||||
return (self.solar_fc is None and self._wx_free_to is not None
|
||||
and now < self._wx_free_to)
|
||||
|
||||
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
|
||||
"""The kept run plan, rebuilt at most once per price slot (15 min) or
|
||||
when the prices or a battery's choice change; a run under way is
|
||||
@@ -2287,9 +2274,11 @@ class Controller:
|
||||
slot = int(now.timestamp() // 900)
|
||||
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
|
||||
for p in packs))
|
||||
held = self.weather_held(now)
|
||||
if self.fc_entity is not None:
|
||||
# P4: a forecast arriving or going rebuilds the plan at once.
|
||||
key += (self.solar_fc is None,)
|
||||
# P4: a forecast arriving or going rebuilds the plan at once (and
|
||||
# so does the end of a run that held the gap, TARIFF-24).
|
||||
key += (self.solar_fc is None, held)
|
||||
if key != self._runs_key:
|
||||
done_c = self._run_done.get("charge", (None, set()))[1]
|
||||
done_s = self._run_done.get("sell", (None, set()))[1]
|
||||
@@ -2298,7 +2287,7 @@ class Controller:
|
||||
required = price_required_spread(
|
||||
arbitrage.threshold_eur_kwh(self.o),
|
||||
price_uncertainty_of(plan.read.product)
|
||||
| {Uncertainty.NO_WEATHER})
|
||||
| arbitrage.weather_uncertainty(True, self.solar_fc, held))
|
||||
ref = self._ref_cheap
|
||||
if ref is not None and now - ref.end > REF_CHEAP_KEEP:
|
||||
ref = self._ref_cheap = None
|
||||
@@ -2367,6 +2356,7 @@ class Controller:
|
||||
quarter), exactly the gate `arbitrage.decide()` has."""
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
packs = self._mode_packs(maint_results)
|
||||
self._wx_track(now)
|
||||
runs = self._price_runs(now, packs, floor_pct, ceiling_pct)
|
||||
guard = arbitrage.guard_state(self.peak_guard)
|
||||
plan = self.fresh_tariff_plan(now)
|
||||
@@ -2377,7 +2367,8 @@ class Controller:
|
||||
return plan.arbitrage(
|
||||
peak_guard_active=active,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
uncertainty=arbitrage.weather_uncertainty(
|
||||
True, self.solar_fc, self.weather_held(now)),
|
||||
now=now).match(
|
||||
allowed=lambda _v: True, refused=lambda _v: False)
|
||||
if guard is not None and plan is not None and runs is not None:
|
||||
@@ -3250,17 +3241,19 @@ class Controller:
|
||||
return floor
|
||||
|
||||
def _charge_headroom(self, floor: float, claims) -> float:
|
||||
"""SAFETY-84: at N>=2 a cheap-window charge pulls no more than the
|
||||
quarter's headroom under the peak aim (`charge_headroom_w()`), so it
|
||||
yields just enough to the peak guard, and nothing under a site stop
|
||||
(its 0 W would need the other pack to give). N=1 is unchanged."""
|
||||
if len(self.inverters) < 2:
|
||||
return floor
|
||||
if any(c.kind == SET and c.actuator == ACTUATOR_SITE
|
||||
and c.priority > P_MAINT_SHAPE for c in claims):
|
||||
"""SAFETY-84: a cheap-window charge pulls no more than the quarter's
|
||||
room under the peak aim (`charge_room_w()`), so it yields just enough
|
||||
to the peak guard; at N>=2 nothing under a site stop (its 0 W would
|
||||
need the other pack to give). SAFETY-125: at N=1 too, owner
|
||||
2026-10-29; the stop rule stays N>=2 only."""
|
||||
if len(self.inverters) > 1 and any(
|
||||
c.kind == SET and c.actuator == ACTUATOR_SITE
|
||||
and c.priority > P_MAINT_SHAPE for c in claims):
|
||||
return 0.0
|
||||
room = peakguard.charge_headroom_w(self.peak_guard)
|
||||
return floor if room is None else min(floor, room)
|
||||
room = peakguard.charge_room_w(self.peak_guard, self._peak_proj)
|
||||
floor = floor if room is None else min(floor, room)
|
||||
self.room_charge = self.room_charge or floor > 0.0
|
||||
return floor
|
||||
|
||||
def _charge_ceiling(self, claims):
|
||||
"""SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None.
|
||||
@@ -4910,6 +4903,7 @@ class Controller:
|
||||
# decision presented as current.
|
||||
self.arb, self.arb_at = None, now
|
||||
self.arb_headroom_w = None
|
||||
self.room_charge = False
|
||||
# OPT-11 P3: with battery modes on, each battery's mode decides who
|
||||
# trades (`_mode_roles()`, `_mode_trade()`, below, which read the
|
||||
# same headroom), and this site-wide trade is not run.
|
||||
@@ -4920,7 +4914,9 @@ class Controller:
|
||||
# `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the
|
||||
# caveat on the recorded watts can never be answered from a
|
||||
# different cycle's guard than the watts were.
|
||||
self.arb_headroom_w = peakguard.charge_headroom_w(self.peak_guard)
|
||||
# SAFETY-125: a late meter reading's seconds count, as the bill.
|
||||
self.arb_headroom_w = peakguard.charge_room_w(self.peak_guard,
|
||||
self._peak_proj)
|
||||
if site_trade:
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
|
||||
@@ -4975,6 +4971,7 @@ class Controller:
|
||||
arb_claim = arbitrage.claim(self.arb)
|
||||
if arb_claim is not None:
|
||||
claims.append(arb_claim)
|
||||
self.room_charge = self.arb.action == arbitrage.CHARGE
|
||||
# Edge-logged, for the peak guard's reason: the action log holds 40
|
||||
# lines and a trade lasts a whole hour, so a line per cycle would
|
||||
# flush the context that explains it. The transition is what an
|
||||
@@ -5194,6 +5191,8 @@ class Controller:
|
||||
mode_trade, trade_was = False, self._mode_trade_was
|
||||
if self.modes_on:
|
||||
mode_trade = self._mode_trade(claims, now)
|
||||
self.room_charge = self.room_charge or (
|
||||
mode_trade and self.arb.action == arbitrage.CHARGE)
|
||||
self._mode_trade_was = mode_trade
|
||||
self._charge_track(claims, maint_results)
|
||||
_live96 = set(self.live_units())
|
||||
@@ -7087,6 +7086,15 @@ class Controller:
|
||||
"text": messages.row_peak_guard_on(
|
||||
_pg.floor_w or 0.0, _pg.ceiling_w,
|
||||
shadow=self.shadow)})
|
||||
elif _pg.projected_w is not None:
|
||||
# SAFETY-125: "under" only when it is; over the limit at this
|
||||
# rate, a grid charge is cut back to the quarter's room.
|
||||
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_idle(
|
||||
_pg.projected_w, _pg.ceiling_w,
|
||||
month_peak=(_pg.ceiling_from
|
||||
== peakguard.CEILING_MONTH_PEAK),
|
||||
cut=self.room_charge and not self.shadow)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||
"text": _pg.explain()})
|
||||
@@ -8080,11 +8088,9 @@ async def amain() -> None:
|
||||
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
|
||||
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
|
||||
#
|
||||
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
|
||||
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
|
||||
# enabled` ships `false`, so a site that had selected a price feed
|
||||
# fetched nothing at all; the whole argument, the measurement and what
|
||||
# the `or` is protecting are in `tariff_refresh_wanted()` above.
|
||||
# ⚠️ STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES:
|
||||
# see `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and
|
||||
# TARIFF-24 for why each term is there.
|
||||
#
|
||||
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
|
||||
# two decisions; this is the first, and `cycle()`'s
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1136,6 +1136,21 @@ def charge_headroom_w(decision) -> float | None:
|
||||
return max(0.0, -float(decision.wanted_w))
|
||||
|
||||
|
||||
def charge_room_w(decision, projection) -> float | None:
|
||||
"""SAFETY-125: `charge_headroom_w()` with the seconds since the last
|
||||
meter reading counted at that reading, so a late reading near a
|
||||
quarter's end is not read as room for a grid charge (SAFETY-99).
|
||||
DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md."""
|
||||
room = charge_headroom_w(decision)
|
||||
if not room or projection is None or projection.block_start is None:
|
||||
return room
|
||||
left = QUARTER_S - float(projection.elapsed_s)
|
||||
if left <= 0.0:
|
||||
return room
|
||||
late_ws = max(0.0, float(projection.held_w)) * uncovered_s(projection)
|
||||
return max(0.0, room - late_ws / left)
|
||||
|
||||
|
||||
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
"""Must the maintenance charge-only shaping stand down this cycle?
|
||||
|
||||
|
||||
@@ -76,12 +76,20 @@ class Forecast:
|
||||
tomorrow: Day | None
|
||||
at: datetime # when the sensor last reported
|
||||
|
||||
def between(self, t0: datetime, t1: datetime) -> float | None:
|
||||
def between(self, t0: datetime, t1: datetime,
|
||||
since: datetime | None = None) -> float | None:
|
||||
"""kWh the sun should give between `t0` (now or later) and `t1`.
|
||||
None when the span reaches the sunny hours of a day with no
|
||||
forecast (tomorrow, when its sensor is not set)."""
|
||||
forecast (tomorrow, when its sensor is not set).
|
||||
|
||||
`since` is the moment a state-only "left today" figure counts from
|
||||
(OPT-23): `t0` itself when not given, so a span that starts later
|
||||
than now would be spread over what is left of the bell from its own
|
||||
start. Pass now for every span of a plan, and the spans add up to
|
||||
the figure. An hourly split ignores it."""
|
||||
if t1 <= t0:
|
||||
return 0.0
|
||||
since = t0 if since is None else since
|
||||
last = self.tomorrow or self.today
|
||||
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
|
||||
return None
|
||||
@@ -92,7 +100,7 @@ class Forecast:
|
||||
a = max(t0, day.day0)
|
||||
b = min(t1, day.day0 + timedelta(days=1))
|
||||
if b > a:
|
||||
total += _day_between(day, a, b, t0)
|
||||
total += _day_between(day, a, b, since)
|
||||
return total
|
||||
|
||||
|
||||
@@ -124,8 +132,12 @@ def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float:
|
||||
out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds()
|
||||
return out
|
||||
# State only: `kwh` is what is left from `max(now, day0)` to the day's
|
||||
# end, spread over the bell.
|
||||
h_from = _hours_since(day.day0, max(now, day.day0))
|
||||
# end, spread over the bell. Nothing of it is before that moment.
|
||||
start = max(now, day.day0)
|
||||
a = max(a, start)
|
||||
if b <= a:
|
||||
return 0.0
|
||||
h_from = _hours_since(day.day0, start)
|
||||
left = 1.0 - _bell(h_from)
|
||||
if left <= 1e-9:
|
||||
return 0.0
|
||||
|
||||
@@ -103,18 +103,18 @@
|
||||
"batteries": {"name": "Batteries", "description": "To choose a mode for each battery, turn on battery modes in the settings.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings",
|
||||
"on": "On", "off": "Off",
|
||||
"choice": {
|
||||
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."},
|
||||
"leftover_solar": {"name": "Leftover solar", "description": "Gets solar the others cannot take, and gives power in expensive hours."},
|
||||
"cheap_hours": {"name": "Cheap hours only", "description": "Charges in the cheapest hours and gives power in the expensive ones."},
|
||||
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries, covers the house and never charges from the grid."},
|
||||
"leftover_solar": {"name": "Leftover solar", "description": "Gets the solar the others cannot take, buys from the grid only what the sun will not give, and gives power in expensive hours."},
|
||||
"cheap_hours": {"name": "Cheap hours only", "description": "Fills up from the grid in the cheapest hours and gives power in the expensive ones."},
|
||||
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
|
||||
},
|
||||
"today": "Today", "why": "Why",
|
||||
"cycle": {"start": "Start cycle", "abort": "Stop cycle", "start_dialog": {"name": "Start a cycle on {name}?", "description": "{name} empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"}, "abort_dialog": {"name": "Stop the cycle on {name}?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}, "toast": {"started": "Cycle started on {name}.", "stopped": "Cycle stopped on {name}."}, "unit": {"description": "There is no battery with that name."}, "busy": {"description": "Only one battery can cycle at a time, and {name} is cycling now."}, "off": {"description": "{name} is switched off, so it cannot start a cycle."}, "idle": {"description": "{name} is not running a cycle."}},
|
||||
"modes_off": {"name": "Battery modes are off"},
|
||||
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
|
||||
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
|
||||
"leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, and saves its power for expensive hours."},
|
||||
"cheap_hours": {"description": "Cheap hours only charges in the cheapest hours and gives power in the expensive ones."},
|
||||
"solar_first": {"description": "Solar first gets spare solar before the other batteries, covers the house at any hour and never charges from the grid."},
|
||||
"leftover_solar": {"description": "Leftover solar gets the solar Solar first cannot take, buys only what the sun will not give, and saves its power for expensive hours."},
|
||||
"cheap_hours": {"description": "Cheap hours only fills up from the grid in the cheapest hours and gives power in the expensive ones."},
|
||||
"off": {"description": "Off rests the battery and starts no monthly cycle, but the peak limit may still use it."},
|
||||
"rules": {"description": "The peak limit and the safety stops come first, and while one battery charges from the grid the others rest."}},
|
||||
"toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}."}},
|
||||
@@ -357,10 +357,10 @@
|
||||
"no_prices": "No prices, so it follows the house.",
|
||||
"no_spread": "Prices differ too little, so it follows the house.",
|
||||
"sun_fills": "Enough sun is forecast, so it does not charge from the grid.",
|
||||
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
|
||||
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
|
||||
"no_forecast": "No solar forecast, so it charges from solar only."
|
||||
},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"today": {
|
||||
"not_planned": "Not planned yet.",
|
||||
"no_runs": "No cheap or expensive hours left today.",
|
||||
@@ -385,6 +385,9 @@
|
||||
"run": {
|
||||
"grid_what": "Charges up to {kwh} from the grid.",
|
||||
"grid_what_would": "Would charge up to {kwh} from the grid.",
|
||||
"grid_what_to": "Charges up to {kwh} from the grid, to {pct}.",
|
||||
"grid_what_to_would": "Would charge up to {kwh} from the grid, to {pct}.",
|
||||
"covers": "It covers {kwh} of expensive hours the sun will not.",
|
||||
"grid_why": "Lowest price left, {eur} per kWh.",
|
||||
"grid_amount": "Costs up to {eur}.",
|
||||
"give_what": "Gives up to {kwh}, to the house first.",
|
||||
@@ -434,6 +437,9 @@
|
||||
"step_in_would": "The batteries would step in above {kw}.",
|
||||
"period": "This 15-minute period is heading for {kw}.",
|
||||
"period_unmeasured": "Part of this 15-minute period was not measured.",
|
||||
"period_over": "At this rate this 15-minute period reaches {kw}.",
|
||||
"charge_cut": "The grid charge is cut back to stay under {kw}.",
|
||||
"charge_cut_would": "The grid charge would be cut back to stay under {kw}.",
|
||||
"prices": {
|
||||
"now": "Price now {eur} per kWh, all in.",
|
||||
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
|
||||
|
||||
@@ -100,18 +100,18 @@
|
||||
"batteries": {"name": "Batterijen", "description": "Zet batterijmodi aan in de instellingen om per batterij een modus te kiezen.", "level": "Laadniveau", "now": "Nu", "stored": "Opgeslagen", "mode": "Modus", "settings": "Instellingen van omvormers wijzigen",
|
||||
"on": "Aan", "off": "Uit",
|
||||
"choice": {
|
||||
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."},
|
||||
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Krijgt zonne-energie die de andere niet opnemen, en levert in dure uren."},
|
||||
"cheap_hours": {"name": "Alleen goedkope uren", "description": "Laadt in de goedkoopste uren en levert in de dure uren."},
|
||||
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen, dekt het huis en laadt nooit van het net."},
|
||||
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Krijgt zonne-energie die de andere niet opnemen, koopt van het net alleen wat de zon niet geeft, en levert in dure uren."},
|
||||
"cheap_hours": {"name": "Alleen goedkope uren", "description": "Vult zich vol van het net in de goedkoopste uren en levert in de dure uren."},
|
||||
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
|
||||
},
|
||||
"today": "Vandaag", "why": "Waarom",
|
||||
"cycle": {"start": "Cyclus starten", "abort": "Cyclus stoppen", "start_dialog": {"name": "Cyclus starten op {name}?", "description": "{name} loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"}, "abort_dialog": {"name": "Cyclus stoppen op {name}?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"}, "toast": {"started": "Cyclus gestart op {name}.", "stopped": "Cyclus gestopt op {name}."}, "unit": {"description": "Er is geen batterij met die naam."}, "busy": {"description": "Er kan maar één batterij tegelijk een cyclus doen, en {name} is nu bezig."}, "off": {"description": "{name} staat uit en kan dus geen cyclus starten."}, "idle": {"description": "{name} doet nu geen cyclus."}},
|
||||
"modes_off": {"name": "Batterijmodi staan uit"},
|
||||
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
|
||||
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
|
||||
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, en spaart haar stroom voor dure uren."},
|
||||
"cheap_hours": {"description": "Alleen goedkope uren laadt in de goedkoopste uren en levert in de dure uren."},
|
||||
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, dekt het huis op elk uur en laadt nooit van het net."},
|
||||
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat Eerst zonne-energie niet opneemt, koopt alleen wat de zon niet geeft, en spaart haar stroom voor dure uren."},
|
||||
"cheap_hours": {"description": "Alleen goedkope uren vult zich vol van het net in de goedkoopste uren en levert in de dure uren."},
|
||||
"off": {"description": "Uit laat de batterij rusten en start geen maandelijkse cyclus, maar de piekgrens mag haar nog gebruiken."},
|
||||
"rules": {"description": "De piekgrens en de veiligheidsstops gaan voor, en terwijl een batterij van het net laadt rusten de andere."}},
|
||||
"toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}."}},
|
||||
@@ -297,10 +297,10 @@
|
||||
"no_prices": "Geen prijzen, dus ze volgt het huis.",
|
||||
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis.",
|
||||
"sun_fills": "Er is genoeg zon voorspeld, dus ze laadt niet van het net.",
|
||||
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
|
||||
"tops_up": "Ze laadt van het net tot {pct}. Dat dekt {kwh} dat de zon in dure uren niet levert.",
|
||||
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
|
||||
},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"today": {
|
||||
"not_planned": "Nog niet gepland.",
|
||||
"no_runs": "Vandaag geen goedkope of dure uren meer.",
|
||||
@@ -325,6 +325,9 @@
|
||||
"run": {
|
||||
"grid_what": "Laadt tot {kwh} van het net.",
|
||||
"grid_what_would": "Zou tot {kwh} van het net laden.",
|
||||
"grid_what_to": "Laadt maximaal {kwh} van het net, tot {pct}.",
|
||||
"grid_what_to_would": "Zou maximaal {kwh} van het net laden, tot {pct}.",
|
||||
"covers": "Dat dekt {kwh} dat de zon in dure uren niet levert.",
|
||||
"grid_why": "Laagste prijs die nog over is, {eur} per kWh.",
|
||||
"grid_amount": "Kost tot {eur}.",
|
||||
"give_what": "Levert tot {kwh}, eerst aan het huis.",
|
||||
@@ -369,6 +372,9 @@
|
||||
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
|
||||
"period": "Dit kwartier gaat richting {kw}.",
|
||||
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
|
||||
"period_over": "Aan dit tempo komt dit kwartier op {kw}.",
|
||||
"charge_cut": "Laadt minder van het net om onder {kw} te blijven.",
|
||||
"charge_cut_would": "Zou minder van het net laden om onder {kw} te blijven.",
|
||||
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
|
||||
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
|
||||
"day": {
|
||||
|
||||
@@ -1,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} %")
|
||||
@@ -648,7 +685,8 @@ check("forecast lost mid-run: Leftover solar was charging, and stops within "
|
||||
|
||||
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
|
||||
# charge, so B covers less of the evening - a money risk, never a safety one.
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0})
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
invariants("forecast too high", rows, c)
|
||||
check("forecast too high: no grid charge, and B still keeps its power for "
|
||||
"the dear run", not [r for r in rows if r["action"]
|
||||
@@ -658,7 +696,7 @@ check("forecast too high: no grid charge, and B still keeps its power for "
|
||||
|
||||
# A refusal wins over the plan.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
refuse=("b",))
|
||||
refuse=("b",), extra=GATE_030)
|
||||
invariants("refusal", rows, c)
|
||||
check("refusal: B refuses to charge, so the plan's charge ends and B is "
|
||||
"never a charger after it", rows and not [
|
||||
@@ -719,8 +757,10 @@ check("the forecast line names the forecast, what is counted and when",
|
||||
and "we count on" in sb["line"] and sb["state"] == "plan")
|
||||
why = homeview.day_key(c, B)
|
||||
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
|
||||
check("...and says so in plain words", homeview.why_words(why)
|
||||
== "Too little sun is forecast, so it charges some from the grid.")
|
||||
check("...and says so in plain words, with the level and what it covers "
|
||||
"(OPT-28)", homeview.why_words(why, pct="82 %", kwh="9.4 kWh")
|
||||
== "It charges from the grid to 82 %. That covers 9.4 kWh of "
|
||||
"expensive hours the sun will not.")
|
||||
_d0, _d1 = homeview.local_day(c.solar_fc.at)
|
||||
_blocks, _ = homeview.mode_runs(c, _d0, _d1, c.solar_fc.at, False)
|
||||
_grid = [b for b in _blocks if b["kind"] == "grid" and b["battery"] == 1]
|
||||
@@ -754,8 +794,8 @@ check("the Why line wins over 'saves its power' only while holding",
|
||||
== "grid_charge")
|
||||
for lang in ("en", "nl"):
|
||||
homeview._LANG[0] = lang
|
||||
t = [homeview.why_words(k) for k in ("sun_fills", "tops_up",
|
||||
"no_forecast")]
|
||||
t = [homeview.why_words(k, pct="82 %", kwh="9.4 kWh")
|
||||
for k in ("sun_fills", "tops_up", "no_forecast")]
|
||||
t += [homeview._t("solar.no_forecast"), homeview._t("solar.learning"),
|
||||
homeview.line_solar(9.2, 6.4, "10:15")]
|
||||
check(f"{lang}: every P4 sentence is there and short (<= 2 x 12 words)",
|
||||
|
||||
@@ -243,12 +243,16 @@ check("no opposite-direction split on any of these cycles",
|
||||
# ============================================================================ #
|
||||
print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
|
||||
# Printed by tools/opt20_scen.py on release/1.0 bf9019e (2026-10-15),
|
||||
# goodwe-addon-checks:1.
|
||||
# goodwe-addon-checks:1. SAFETY-125 (0.19.2) moved the two runs that charge
|
||||
# against the peak aim: the rig's P1 sample is 0.35 s old, and a grid charge
|
||||
# now counts those seconds, so ch_ch's quarters land on 3450.0 W where they
|
||||
# landed 1.3-1.4 W over it (bf9019e: 15fc25dd...), and ch_ch_maint's first,
|
||||
# partial quarter charges a little less at its end (bf9019e: e46e3c66...).
|
||||
AT_BASE = {
|
||||
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
|
||||
"ch_ch": "15fc25dd34186e56d34b752d37c9e8613295ba737938c92454acca08c83080fb",
|
||||
"ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa",
|
||||
"ch_ch_maint":
|
||||
"e46e3c666dd63ab33b56e83c0a560c07cec7774a9a70560f55c62a6ae6f55920",
|
||||
"e56eddabdb785f81793e7afa525f8e6da237b0b2165860aa8e0f8a6499b275a9",
|
||||
}
|
||||
_got = sc.no_probe_digests()
|
||||
for _k in AT_BASE:
|
||||
@@ -256,7 +260,8 @@ for _k in AT_BASE:
|
||||
f"{_got[_k][:16]}")
|
||||
check("with no refusal every share, grid, trade sentence, role and plant "
|
||||
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
|
||||
"charging, A in maintenance charge)", _got == AT_BASE)
|
||||
"charging, A in maintenance charge; the last two as SAFETY-125 moved "
|
||||
"them)", _got == AT_BASE)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
"""Runnable check for OPT-23 and OPT-28. `python3 test_opt23_28.py`
|
||||
|
||||
Both change how `dayplan.plan()` counts the sun.
|
||||
|
||||
1. OPT-23: a state-only forecast ("left today" or "tomorrow", no hourly
|
||||
split) is read from now for every span, so the plan's per-hour sun adds up
|
||||
to the day's figure. Hourly splits are unchanged. The Forecast tab and the
|
||||
plan count the same sun.
|
||||
2. OPT-28, the owner's shape at 20:00 with tomorrow 2, 6 and 12 kWh: the
|
||||
night level follows what the sun will not give in the expensive hours.
|
||||
3. Several Leftover solar batteries: one total, shared by capacity, the rest
|
||||
passed on; more than all can hold is said, not bought. Solar first never
|
||||
charges from the grid, Cheap hours fills up.
|
||||
4. A dynamic shape with the expensive run after sunset is unchanged.
|
||||
5. The screens: the Batteries line, the Home block and the mode help, in
|
||||
English and Dutch, say the same as control.
|
||||
|
||||
Mutations (MUTATION.md, purged copies): listed in the pull request.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import logging # noqa: E402
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from named_absence import nth, present # noqa: E402
|
||||
import ux54_scenarios as S # noqa: E402
|
||||
from app import dayplan, dayview, homeview, modetrade, packmode # noqa: E402
|
||||
from app import priceruns, solarfc, uitext # noqa: E402
|
||||
from app.tariff import BILLING_TZ # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
EFF = dayplan.EFF
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
|
||||
|
||||
def loc(day, hour, minute=0):
|
||||
"""Local time on 2026-10-`day` (CET from the 25th), as UTC."""
|
||||
return datetime(2026, 10, day, hour, minute,
|
||||
tzinfo=BILLING_TZ).astimezone(UTC)
|
||||
|
||||
|
||||
def span_sum(fc, now, end):
|
||||
"""What the plan sums: every hour slice from `now`, each read from now."""
|
||||
return sum(fc.between(s, e, since=now)
|
||||
for s, e, _h in dayplan._hour_slices(now, end))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. OPT-23: a state-only forecast adds up to the day's figure")
|
||||
# ============================================================================ #
|
||||
for hh, mm in ((7, 0), (10, 7), (13, 0), (16, 0), (19, 30)):
|
||||
now = loc(29, hh, mm)
|
||||
fc = S.forecast(now, 18.0, 16.0)
|
||||
d1 = solarfc._bounds(now)[1]
|
||||
per = span_sum(fc, now, d1)
|
||||
old = sum(fc.between(s, e) for s, e, _h in dayplan._hour_slices(now, d1))
|
||||
print(f" {hh:02d}:{mm:02d} left {fc.today.kwh:6.2f}, per-hour sum "
|
||||
f"{per:6.2f} (without `since`: {old:6.2f})")
|
||||
check(f"{hh:02d}:{mm:02d}: today's hours add up to what is left "
|
||||
f"({fc.today.kwh:.2f} kWh)", abs(per - fc.today.kwh) < 1e-6)
|
||||
check(f"{hh:02d}:{mm:02d}: today and tomorrow add up to both figures",
|
||||
abs(span_sum(fc, now, d1 + timedelta(days=1))
|
||||
- (fc.today.kwh + fc.tomorrow.kwh)) < 1e-6)
|
||||
now = loc(29, 7, 0)
|
||||
fc = S.forecast(now, 18.0, 16.0)
|
||||
check("...and the old per-hour call, with no `since`, still reads each span "
|
||||
"from its own start (74 kWh for 18 at 07:00): the plan must pass it",
|
||||
sum(fc.between(s, e) for s, e, _h in dayplan._hour_slices(
|
||||
now, solarfc._bounds(now)[1])) > 3 * fc.today.kwh)
|
||||
now = loc(29, 23, 30)
|
||||
fc = S.forecast(now, 18.0, 16.0)
|
||||
d1 = solarfc._bounds(now)[1]
|
||||
check("tomorrow, planned at 23:30: its 24 hours add up to tomorrow's figure",
|
||||
abs(span_sum(fc, now, d1 + timedelta(days=1)) - fc.tomorrow.kwh) < 1e-6
|
||||
and fc.today.kwh < 0.5)
|
||||
now = loc(29, 20, 0)
|
||||
fc = S.forecast(now, 18.0, 16.0)
|
||||
check("tomorrow, planned at 20:00: 07:00 to 22:00 is tomorrow's figure less "
|
||||
"its first hour (the bell until 07:00)",
|
||||
abs(fc.between(loc(30, 7), loc(30, 22), since=now)
|
||||
- 16.0 * (1.0 - solarfc._bell(7.0))) < 1e-6)
|
||||
check("a span before the figure's moment is not counted (nothing of a "
|
||||
"'left today' figure is before now)",
|
||||
fc.between(loc(29, 8), loc(29, 12), since=loc(29, 13)) == 0.0)
|
||||
|
||||
now = loc(29, 10, 7)
|
||||
fp = S.forecast(now, 18.0, 16.0, split=True)
|
||||
d1 = solarfc._bounds(now)[1]
|
||||
same = all(abs(fp.between(s, e, since=now) - fp.between(s, e)) < 1e-12
|
||||
for s, e, _h in dayplan._hour_slices(now, d1 + timedelta(days=1)))
|
||||
check("hourly periods are unchanged: `since` changes no span", same
|
||||
and fp.today.periods != ())
|
||||
check("...and they add up to the figure as before",
|
||||
abs(span_sum(fp, now, d1) - fp.today.kwh) < 1e-6)
|
||||
|
||||
now = loc(29, 10, 7)
|
||||
fc = S.forecast(now, 18.0, 16.0)
|
||||
ok = True
|
||||
for a, z in ((loc(29, 11), loc(29, 14)), (loc(29, 17), loc(30, 9)),
|
||||
(loc(30, 8), loc(30, 12))):
|
||||
hi, lo = fc.between(now, z), fc.between(now, a)
|
||||
ok = ok and abs(dayview.sun_kwh(fc, now, a, z) - (hi - lo)) < 1e-9 \
|
||||
and dayview.sun_kwh(fc, now, a, z) == fc.between(a, z, since=now)
|
||||
check("the Forecast tab and the plan read a span the same way "
|
||||
"(`dayview.sun_kwh()` = `between(since=now)`)", ok)
|
||||
check("a day with no forecast is still None, with or without `since`",
|
||||
fc.between(loc(29, 12), loc(31, 12), since=now) is None
|
||||
and fc.between(loc(29, 12), loc(31, 12)) is None)
|
||||
|
||||
# ---- the UX-54 review's three Leftover solar cases (check 3) ---------------
|
||||
print(" the UX-54 review's cases: 'dynamic' at 00:18, A Leftover solar, "
|
||||
"20 % to 95 %, sun counted until the dear run at 17:00")
|
||||
DAY0 = datetime(2026, 10, 26, 23, 0, tzinfo=UTC)
|
||||
now = DAY0 + timedelta(minutes=18, seconds=5)
|
||||
cases = {}
|
||||
for day_kwh in (6.0, 12.0, 20.0):
|
||||
c = S.make("dynamic", now)
|
||||
c.solar_fc = S.forecast(now, day_kwh, day_kwh)
|
||||
rp = c.mode_runs
|
||||
pl = dayplan.plan(c.solar_fc, c.house_profile.estimator(), rp, now,
|
||||
c._mode_packs(), trust_pct=70, floor_pct=20.0,
|
||||
ceiling_pct=95.0)
|
||||
cases[day_kwh] = (c, rp, pl)
|
||||
SHIPPED = {6.0: 11.39, 12.0: 22.77, 20.0: 37.95} # the review, as shipped
|
||||
for day_kwh, want_fc, want_sun, want_lvl, want_gc in (
|
||||
(6.0, 5.34, 0.14, 68.5, 7.44), (12.0, 10.69, 3.05, 49.5, 4.53),
|
||||
(20.0, 17.82, 7.40, 21.2, 0.18)):
|
||||
c, rp, pl = cases[day_kwh]
|
||||
cap = 15.36
|
||||
use = 4 * 1.8 # the dear run 17:00-21:00, 1.8 kW
|
||||
share = pl.share_kwh[A] if pl is not None else 0.0
|
||||
lvl = 20.0 + use / EFF / cap * 100.0 - share / cap * 100.0
|
||||
gc = max(0.0, lvl - 20.0) / 100.0 * cap
|
||||
check(f"{day_kwh:.0f} kWh day: the forecast summed is {want_fc} kWh "
|
||||
f"(shipped: {SHIPPED[day_kwh]}) and the sun counted {want_sun}",
|
||||
pl is not None and abs(pl.fc_kwh - want_fc) < 0.01
|
||||
and abs(pl.sun_kwh - want_sun) < 0.01)
|
||||
check(f"{day_kwh:.0f} kWh day: the corrected sun gives the review's "
|
||||
f"level {want_lvl} % and grid charge {want_gc} kWh (the "
|
||||
"sun-before-the-run part, with the old use sum)",
|
||||
abs(lvl - want_lvl) < 0.05 and abs(gc - want_gc) < 0.01)
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. OPT-28: the owner's shape at 20:00 (tomorrow 2, 6 and 12 kWh)")
|
||||
# ============================================================================ #
|
||||
# Example house (the brief: about 1 kW, evenings more): 0.6 kW at night,
|
||||
# 1.2 kW 06-10, 0.9 kW by day, 1.8 kW 17-22. 24.9 kWh a day.
|
||||
SHAPE = {h: (600.0 if h < 6 or h >= 22 else 1200.0 if h < 10
|
||||
else 900.0 if h < 17 else 1800.0) for h in range(24)}
|
||||
FLOOR, CEIL = 10.0, 95.0
|
||||
CAP_A, CAP_B = 15.36, 20.48
|
||||
NOW = loc(29, 20, 0)
|
||||
|
||||
|
||||
def profile_of(shape):
|
||||
day = NOW.astimezone(BILLING_TZ).date()
|
||||
return dayplan.HouseProfile(
|
||||
{(day - timedelta(days=k)).isoformat():
|
||||
{str(h): w for h, w in shape.items()} for k in (1, 2, 3)})
|
||||
|
||||
|
||||
def owner_runs():
|
||||
"""Day 07:00-22:00 at 0.2801, night at 0.2333 (the brief's tariff)."""
|
||||
return priceruns.RunPlan(
|
||||
priceruns.Run(loc(29, 22), loc(30, 7), 0.2333),
|
||||
(priceruns.Run(loc(29, 7), loc(29, 22), 0.2801),
|
||||
priceruns.Run(loc(30, 7), loc(30, 22), 0.2801)),
|
||||
None, 0.2801, None, True)
|
||||
|
||||
|
||||
def pk(name, mode, soc=FLOOR, cap=CAP_A, rating=5000.0):
|
||||
return modetrade.Pack(name, True, mode, False, rating, soc, cap)
|
||||
|
||||
|
||||
def forecast_tomorrow(kwh, now=NOW, left=0.0):
|
||||
"""A state-only forecast: `left` still to come today, `kwh` tomorrow."""
|
||||
d0, _d1 = solarfc._bounds(now)
|
||||
day1 = d0 + timedelta(days=1)
|
||||
return solarfc.Forecast(solarfc.Day(d0, left), solarfc.Day(day1, kwh),
|
||||
now)
|
||||
|
||||
|
||||
def owner_plan(kwh, packs, shape=SHAPE, runs=None, now=NOW, fc=None):
|
||||
return dayplan.plan(fc or forecast_tomorrow(kwh, now),
|
||||
profile_of(shape).estimator(), runs or owner_runs(),
|
||||
now, packs, trust_pct=70, floor_pct=FLOOR,
|
||||
ceiling_pct=CEIL)
|
||||
|
||||
|
||||
def expect_short(kwh, shape=SHAPE, hours=range(7, 22), trust=0.7, lift=True):
|
||||
"""The brief's sum, written out: per expensive hour max(0, house -
|
||||
trust x sun). The sun of an hour is the bell's share of tomorrow's
|
||||
figure; an hour with sun is read at no less than the night base."""
|
||||
base = sorted(shape[h] for h in (0, 1, 2, 3, 4, 5, 22, 23))
|
||||
base = (base[3] + base[4]) / 2.0
|
||||
tot = 0.0
|
||||
for h in hours:
|
||||
pv = kwh * (solarfc._bell(h + 1.0) - solarfc._bell(float(h)))
|
||||
use = (max(shape[h], base) if pv > 0 and lift else shape[h]) / 1000.0
|
||||
tot += max(0.0, use - trust * pv)
|
||||
return tot
|
||||
|
||||
|
||||
def expect_level(short, cap, share_pct=0.0):
|
||||
return max(0.0, min(CEIL, FLOOR + short / EFF / cap * 100.0) - share_pct)
|
||||
|
||||
|
||||
res = {}
|
||||
for kwh in (2.0, 6.0, 12.0):
|
||||
pl = owner_plan(kwh, [pk(A, packmode.LEFTOVER_SOLAR),
|
||||
pk(B, packmode.SOLAR_FIRST, cap=CAP_B,
|
||||
rating=4600.0)])
|
||||
res[kwh] = pl
|
||||
sh = expect_short(kwh)
|
||||
lv = pl.targets.get(A) if pl is not None else None
|
||||
print(f" tomorrow {kwh:4.1f} kWh: dear-hour shortfall "
|
||||
f"{pl.short_kwh:5.2f} kWh (check {sh:5.2f}), A to {lv} %, grid "
|
||||
f"{pl.need_kwh[A]:5.2f} kWh, unheld {pl.uncovered_kwh:4.2f} kWh")
|
||||
check(f"{kwh:g} kWh: the shortfall is the sum of max(0, house - 0.7 x "
|
||||
f"sun) over 07:00-22:00 ({sh:.2f} kWh)",
|
||||
pl is not None and abs(pl.short_kwh - sh) < 0.005)
|
||||
check(f"{kwh:g} kWh: A's level is floor + shortfall / 0.95, from the "
|
||||
"floor, capped at the ceiling",
|
||||
present(lv) and abs(lv - expect_level(sh, CAP_A)) < 0.05)
|
||||
check("the target follows the shortfall: more sun, a lower level",
|
||||
res[2.0].targets[A] >= res[6.0].targets[A] > res[12.0].targets[A])
|
||||
check("12 kWh: below 95 % because the sun covers part (was 95 % for any "
|
||||
"forecast)", res[12.0].targets[A] < 90.0
|
||||
and res[12.0].need_kwh[A] < res[2.0].need_kwh[A])
|
||||
check("2 kWh: more than A holds is not bought: A to 95 %, the rest said "
|
||||
"(`uncovered_kwh`)", res[2.0].targets[A] == CEIL
|
||||
and res[2.0].uncovered_kwh > 1.0)
|
||||
check("12 kWh: nothing is left over", res[12.0].uncovered_kwh == 0.0)
|
||||
check("B on Solar first never gets a level (never charges from the grid)",
|
||||
B not in res[6.0].targets and modetrade.charge_to(
|
||||
pk(B, packmode.SOLAR_FIRST, cap=CAP_B), CEIL, res[6.0].targets)
|
||||
is None)
|
||||
more = owner_plan(6.0, [pk(A, packmode.LEFTOVER_SOLAR),
|
||||
pk(B, packmode.CHEAP_HOURS, cap=CAP_B,
|
||||
rating=4600.0)])
|
||||
check("B on Cheap hours fills up and is no part of A's share: A's level is "
|
||||
"the same, B is not in the plan",
|
||||
more.targets == res[6.0].targets and B not in more.targets
|
||||
and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, cap=CAP_B), CEIL,
|
||||
more.targets) == CEIL)
|
||||
# The profile is net of the sun the house used, so an hour with sun is read
|
||||
# at no less than the night base (0.6 kW), as the sun sum reads it.
|
||||
NET = dict(SHAPE)
|
||||
for h in range(7, 18):
|
||||
NET[h] = 100.0
|
||||
pnet = owner_plan(6.0, [pk(A, packmode.LEFTOVER_SOLAR)], shape=NET)
|
||||
print(f" a profile net of the sun (0.1 kW by day): shortfall "
|
||||
f"{pnet.short_kwh:.2f} kWh, read at the night base; read as measured "
|
||||
f"it would be {expect_short(6.0, NET, lift=False):.2f}")
|
||||
check("an hour with sun is read at no less than the night base, an hour "
|
||||
"without as measured",
|
||||
abs(pnet.short_kwh - expect_short(6.0, NET)) < 0.005
|
||||
and expect_short(6.0, NET) > expect_short(6.0, NET, lift=False) + 1.0)
|
||||
# The sun before the run still fills first, as before: at 12:00 the rest of
|
||||
# today's sun beyond the house (0.6 kW) is counted, x trust x 0.95.
|
||||
N12 = loc(29, 12, 0)
|
||||
flat = {h: 600.0 for h in range(24)}
|
||||
fc12 = forecast_tomorrow(6.0, N12, left=9.0)
|
||||
runs12 = owner_runs()
|
||||
pl12 = dayplan.plan(fc12, profile_of(flat).estimator(), runs12, N12,
|
||||
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70,
|
||||
floor_pct=FLOOR, ceiling_pct=CEIL)
|
||||
left_share = 1.0 - solarfc._bell(12.0)
|
||||
excess = sum(max(0.0, 9.0 * (solarfc._bell(h + 1.0) - solarfc._bell(float(h)))
|
||||
/ left_share - 0.6) for h in range(12, 20))
|
||||
sun_share = 0.7 * EFF * excess
|
||||
print(f" 12:00, 9 kWh left today, house 0.6 kW: the sun before the run "
|
||||
f"is {sun_share:.2f} kWh, the plan counts {pl12.sun_kwh:.2f}")
|
||||
check("the sun before the run keeps filling: trust x 0.95 x the sun the "
|
||||
"house will not use, read from now for every hour (OPT-23)",
|
||||
sun_share > 0.5 and abs(pl12.sun_kwh - sun_share) < 0.01
|
||||
and abs(pl12.share_kwh[A] - sun_share) < 0.01)
|
||||
check("...and it is taken off the level the grid charges to",
|
||||
abs(pl12.targets[A] - expect_level(
|
||||
expect_short(6.0, flat), CAP_A, sun_share / CAP_A * 100.0)) < 0.05
|
||||
and pl12.targets[A] < expect_level(expect_short(6.0, flat), CAP_A))
|
||||
|
||||
# ============================================================================ #
|
||||
print("3. several batteries: one total, shared by capacity")
|
||||
# ============================================================================ #
|
||||
two = [pk(A, packmode.LEFTOVER_SOLAR),
|
||||
pk(B, packmode.LEFTOVER_SOLAR, cap=CAP_B, rating=4600.0)]
|
||||
p2 = owner_plan(6.0, two)
|
||||
held = sum((p2.targets[n] - FLOOR) / 100.0 * c
|
||||
for n, c in ((A, CAP_A), (B, CAP_B)))
|
||||
sh = expect_short(6.0)
|
||||
print(f" two Leftover solar, 6 kWh: {p2.targets}, together "
|
||||
f"{held:.2f} kWh above the floor (shortfall / 0.95 = "
|
||||
f"{sh / EFF:.2f})")
|
||||
check("the total is what counts: A and B hold the shortfall / 0.95 "
|
||||
"together", abs(held - sh / EFF) < 0.01)
|
||||
check("...shared by capacity: the same level, so B holds more kWh than A",
|
||||
abs(p2.targets[A] - p2.targets[B]) < 0.01
|
||||
and p2.cover_kwh[B] > p2.cover_kwh[A]
|
||||
and abs(p2.cover_kwh[B] / p2.cover_kwh[A] - CAP_B / CAP_A) < 0.01)
|
||||
check("...and covers it between them (cover_kwh)",
|
||||
abs(sum(p2.cover_kwh.values()) - sh) < 0.01 and p2.uncovered_kwh == 0)
|
||||
# A shortfall more than ONE holds: a house of 1.8 kW all day.
|
||||
big = {h: 1800.0 for h in range(24)}
|
||||
p3 = owner_plan(2.0, two, shape=big)
|
||||
sh3 = expect_short(2.0, big)
|
||||
print(f" 1.8 kW house, 2 kWh: shortfall {sh3:.1f} kWh = {sh3 / EFF:.1f} "
|
||||
f"kWh stored; A holds {(CEIL - FLOOR) / 100 * CAP_A:.1f}, B "
|
||||
f"{(CEIL - FLOOR) / 100 * CAP_B:.1f}; levels {p3.targets}")
|
||||
check("a shortfall of more than one battery holds, less than both: both "
|
||||
"take their share by capacity, the total is met",
|
||||
sh3 / EFF > (CEIL - FLOOR) / 100 * CAP_B
|
||||
and sh3 / EFF < (CEIL - FLOOR) / 100 * (CAP_A + CAP_B)
|
||||
and p3.targets[A] < CEIL and p3.targets[B] < CEIL
|
||||
and abs(sum((p3.targets[n] - FLOOR) / 100.0 * c
|
||||
for n, c in ((A, CAP_A), (B, CAP_B))) - sh3 / EFF) < 0.01
|
||||
and p3.uncovered_kwh == 0.0)
|
||||
# More than both hold.
|
||||
huge = {h: 4000.0 for h in range(24)}
|
||||
p4 = owner_plan(2.0, two, shape=huge)
|
||||
sh4 = expect_short(2.0, huge)
|
||||
rooms = (CEIL - FLOOR) / 100 * (CAP_A + CAP_B)
|
||||
print(f" 4 kW house, 2 kWh: shortfall {sh4:.1f} kWh; both hold "
|
||||
f"{rooms * EFF:.1f}; unheld {p4.uncovered_kwh:.2f} kWh; "
|
||||
f"levels {p4.targets}")
|
||||
check("more than all of them hold: both go to the ceiling, never above",
|
||||
p4.targets[A] == CEIL and p4.targets[B] == CEIL)
|
||||
check("...and the rest is said, not bought: another battery or the grid "
|
||||
"carries it (`uncovered_kwh` = shortfall less what they cover)",
|
||||
abs(p4.uncovered_kwh - (sh4 - rooms * EFF)) < 0.01
|
||||
and abs(sum(p4.cover_kwh.values()) - rooms * EFF) < 0.01)
|
||||
# The pass-through itself: a battery that reaches its ceiling passes the rest.
|
||||
got, left = dayplan._spread(10.0, {"x": 2.0, "y": 20.0}, {"x": 1.0, "y": 1.0})
|
||||
check("a battery that reaches its ceiling passes the rest on: one fills, "
|
||||
"the other takes the rest",
|
||||
abs(got["x"] - 2.0) < 1e-9 and abs(got["y"] - 8.0) < 1e-9
|
||||
and left == 0.0)
|
||||
got, left = dayplan._spread(30.0, {"x": 2.0, "y": 20.0}, {"x": 1.0, "y": 3.0})
|
||||
check("...by weight, and what none can take is returned",
|
||||
abs(got["x"] - 2.0) < 1e-9 and abs(got["y"] - 20.0) < 1e-9
|
||||
and abs(left - 8.0) < 1e-9)
|
||||
got, left = dayplan._spread(6.0, {"x": 10.0, "y": 10.0}, {"x": 1.0, "y": 3.0})
|
||||
check("...and shares by weight when none is full (1:3)",
|
||||
abs(got["x"] - 1.5) < 1e-9 and abs(got["y"] - 4.5) < 1e-9)
|
||||
check("a weight of 0 or a room of 0 takes nothing",
|
||||
dayplan._spread(5.0, {"x": 0.0, "y": 9.0}, {"x": 1.0, "y": 0.0})
|
||||
== ({"x": 0.0, "y": 0.0}, 5.0))
|
||||
# Off, in maintenance, or with no level: no part in it.
|
||||
off = [pk(A, packmode.LEFTOVER_SOLAR),
|
||||
modetrade.Pack(B, False, packmode.LEFTOVER_SOLAR, False, 4600.0,
|
||||
FLOOR, CAP_B)]
|
||||
check("a battery that is off takes no part: A carries it alone",
|
||||
owner_plan(6.0, off).targets == res[6.0].targets)
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. a dear run after sunset is unchanged")
|
||||
# ============================================================================ #
|
||||
# Cheap 01:00-05:00, dear 20:00-23:00 (no sun after 20:00). The house is
|
||||
# below the night base in the dear hours (0.4 kW against 0.6): measured
|
||||
# as the profile says, as before. An hourly split, so OPT-23 changes nothing.
|
||||
T0 = loc(29, 0, 30)
|
||||
DYN = priceruns.RunPlan(
|
||||
priceruns.Run(loc(29, 1), loc(29, 5), 0.08),
|
||||
(priceruns.Run(loc(29, 20), loc(29, 23), 0.34),), None, 0.34, None, True)
|
||||
LOW = dict(SHAPE)
|
||||
for h in range(20, 23):
|
||||
LOW[h] = 400.0
|
||||
|
||||
|
||||
def old_plan_level(pl, runs, shape, caps, now):
|
||||
"""The old sum, written out: the house's use over the dear runs of a
|
||||
day from the one the plan is for, shared by capacity, from the floor."""
|
||||
until = nth([g for g in runs.give if g.start >= runs.cheap.end], 0)
|
||||
use = 0.0
|
||||
for g in runs.give:
|
||||
if until.start <= g.start < until.start + timedelta(hours=24):
|
||||
for s, e, h in dayplan._hour_slices(g.start, g.end):
|
||||
use += max(0.0, shape[h]) * (e - s).total_seconds() / 3.6e6
|
||||
out = {}
|
||||
for n, cap in caps.items():
|
||||
top = min(CEIL, FLOOR + use * cap / sum(caps.values()) / EFF
|
||||
/ cap * 100.0)
|
||||
out[n] = round(max(0.0, top - pl.share_kwh[n] / cap * 100.0), 2)
|
||||
return out
|
||||
|
||||
|
||||
for kwh in (2.0, 12.0, 40.0):
|
||||
fcs = S.forecast(T0, kwh, kwh, split=True, members=1)
|
||||
for label, packs, caps in (
|
||||
("A alone", [pk(A, packmode.LEFTOVER_SOLAR)], {A: CAP_A}),
|
||||
("A + B", two, {A: CAP_A, B: CAP_B})):
|
||||
pl = dayplan.plan(fcs, profile_of(LOW).estimator(), DYN, T0, packs,
|
||||
trust_pct=70, floor_pct=FLOOR, ceiling_pct=CEIL)
|
||||
want = old_plan_level(pl, DYN, LOW, caps, T0)
|
||||
check(f"{kwh:g} kWh, {label}: the levels are the old sum's "
|
||||
f"{want}", pl is not None and pl.targets == want
|
||||
and pl.short_kwh == round(0.4 + 0.4 + 0.4, 3))
|
||||
fcs = S.forecast(T0, 12.0, 12.0, split=True, members=1)
|
||||
pl = dayplan.plan(fcs, profile_of(LOW).estimator(), DYN, T0,
|
||||
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70,
|
||||
floor_pct=FLOOR, ceiling_pct=CEIL)
|
||||
pr = dayplan.plan(
|
||||
fcs, profile_of(LOW).estimator(), DYN, T0,
|
||||
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70, floor_pct=FLOOR,
|
||||
ceiling_pct=CEIL)
|
||||
check("the same call twice gives the same plan (pure)", pl == pr)
|
||||
# The same shape with the dear run at 17:00 (sun until 20:00): lower.
|
||||
DYN17 = priceruns.RunPlan(
|
||||
priceruns.Run(loc(29, 1), loc(29, 5), 0.08),
|
||||
(priceruns.Run(loc(29, 17), loc(29, 21), 0.34),), None, 0.34, None, True)
|
||||
fcs = S.forecast(T0, 12.0, 12.0, split=True, members=1)
|
||||
p17 = dayplan.plan(fcs, profile_of(SHAPE).estimator(), DYN17, T0,
|
||||
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70,
|
||||
floor_pct=FLOOR, ceiling_pct=CEIL)
|
||||
o17 = old_plan_level(p17, DYN17, SHAPE, {A: CAP_A}, T0)
|
||||
check("a dear run with sun in it (17:00-21:00, 12 kWh): the level is lower "
|
||||
"than the old sum's, by the sun the house will not use",
|
||||
p17.targets[A] < o17[A] - 3.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("5. the screens say what control does (EN and NL)")
|
||||
# ============================================================================ #
|
||||
now = DAY0 + timedelta(minutes=18, seconds=5)
|
||||
c = S.make("dynamic", now, soc=(20.0, 20.0))
|
||||
c.solar_fc = S.forecast(now, 3.0, 3.0)
|
||||
c._runs_key = None
|
||||
c._mode_roles(now, None)
|
||||
tg = c.plan_targets()
|
||||
dp = c.day_plan
|
||||
cover = homeview.plan_cover(c, A)
|
||||
print(f" plan: {tg}, cover {cover}, shortfall {dp.short_kwh}")
|
||||
check("the controller holds a level for A (Leftover solar) and a cover "
|
||||
"figure that matches the plan", present(tg) and A in tg
|
||||
and cover is not None and abs(cover[0] - tg[A]) < 1e-9
|
||||
and abs(cover[1] - dp.cover_kwh[A]) < 0.01)
|
||||
out = {}
|
||||
for lang in ("en", "nl"):
|
||||
st = homeview.build(c, level="ok", checks=[], now=now, lang=lang)
|
||||
blk = nth([r for r in st["plan"]["runs"]
|
||||
if r["battery"] == 0 and r["kind"] == "grid"], 0)
|
||||
blk_b = nth([r for r in st["plan"]["runs"]
|
||||
if r["battery"] == 1 and r["kind"] == "grid"], 0)
|
||||
row = nth(st["batteries"], 0)
|
||||
out[lang] = (st, blk, blk_b, row)
|
||||
print(f" {lang}: {blk['what']} | {blk['why']}")
|
||||
print(f" {lang}: {row['why']}")
|
||||
with homeview.in_lang(lang):
|
||||
pct = homeview._pct(tg[A])
|
||||
kwh = homeview._kwh(cover[1])
|
||||
need = homeview._kwh(homeview.plan_need(c, A))
|
||||
check(f"{lang}: Home's grid block names the level and what it covers",
|
||||
present(blk) and pct in blk["what"] and kwh in blk["why"])
|
||||
check(f"{lang}: the Batteries line names the same level and figure",
|
||||
present(row) and pct in row["why"] and kwh in row["why"])
|
||||
check(f"{lang}: Home's block is the control's grid charge "
|
||||
"(plan_need)", present(blk) and need in blk["what"])
|
||||
check(f"{lang}: B on Cheap hours has no plan, so its block keeps the "
|
||||
"old words", present(blk_b) and "%" not in blk_b["what"]
|
||||
and kwh not in blk_b["why"])
|
||||
en, nl = out["en"], out["nl"]
|
||||
check("EN: the Batteries line says it plainly",
|
||||
"charges from the grid to" in en[3]["why"]
|
||||
and "expensive hours the sun will not" in en[3]["why"])
|
||||
check("NL: and the same in Dutch (a natural sentence)",
|
||||
"laadt van het net tot" in nl[3]["why"]
|
||||
and "dure uren" in nl[3]["why"] and "{" not in nl[3]["why"])
|
||||
check("Dutch uses a decimal comma", "," in nl[1]["what"]
|
||||
and "," in nl[1]["why"] and "." not in nl[1]["what"].rstrip("."))
|
||||
# The words of the plan lines are STYLE.md plan lines: two short sentences.
|
||||
for lang in ("en", "nl"):
|
||||
w = uitext.words(lang)["home_words"]
|
||||
for key in ("run.covers", "run.grid_what_to", "why.tops_up",
|
||||
"solar.uncovered"):
|
||||
node = w
|
||||
for k in key.split("."):
|
||||
node = node[k]
|
||||
sentences = [s for s in node.replace("%", "").split(".") if s.strip()]
|
||||
check(f"{lang}: '{key}' is at most 2 sentences of at most 12 words",
|
||||
len(sentences) <= 2 and all(
|
||||
len(s.split()) <= 12 for s in sentences))
|
||||
# More than all hold: the Batteries screen says another battery or the grid
|
||||
# carries the rest (here B is on Cheap hours: "the grid" alone is not true).
|
||||
c2 = S.make("dynamic", now, soc=(20.0, 20.0))
|
||||
c2.solar_fc = S.forecast(now, 1.0, 1.0)
|
||||
c2.house_profile = profile_of({h: 3500.0 for h in range(24)})
|
||||
c2._runs_key = None
|
||||
c2._mode_roles(now, None)
|
||||
dp2 = c2.day_plan
|
||||
sol = homeview.solar_block(c2, now)
|
||||
print(f" a 3.5 kW house: shortfall {dp2.short_kwh:.1f} kWh, unheld "
|
||||
f"{dp2.uncovered_kwh:.1f} kWh")
|
||||
check("the plan finds more than the battery holds", dp2.uncovered_kwh > 1.0
|
||||
and c2.plan_targets()[A] == 95.0)
|
||||
check("EN: the line under the Batteries cards says Leftover solar cannot "
|
||||
"hold it all and the rest comes from another battery or the grid",
|
||||
"more than Leftover solar can hold" in sol["line"]
|
||||
and "another battery or the grid" in sol["line"]
|
||||
and homeview._kwh(dp2.uncovered_kwh) in sol["line"])
|
||||
with homeview.in_lang("nl"):
|
||||
sol_nl = homeview.solar_block(c2, now)
|
||||
check("NL: and in Dutch", "meer dan Overschot zonne-energie kan opslaan"
|
||||
in sol_nl["line"] and "andere batterij of het net" in sol_nl["line"]
|
||||
and "{" not in sol_nl["line"])
|
||||
check("a covered plan has no such sentence",
|
||||
"can hold" not in homeview.solar_block(c, now)["line"])
|
||||
|
||||
# The mode help.
|
||||
en_w, nl_w = uitext.words("en"), uitext.words("nl")
|
||||
|
||||
|
||||
def help_of(w, mode):
|
||||
b = w["home"]["batteries"]
|
||||
return (b["choice"][mode]["description"],
|
||||
b["how"][mode]["description"])
|
||||
|
||||
|
||||
for lang, w, want in (
|
||||
("EN", en_w, {"leftover_solar": "only what the sun will not give",
|
||||
"cheap_hours": "Fills up",
|
||||
"solar_first": "never charges from the grid"}),
|
||||
("NL", nl_w, {"leftover_solar": "alleen wat de zon niet geeft",
|
||||
"cheap_hours": "vult zich vol",
|
||||
"solar_first": "nooit van het net"})):
|
||||
for mode, phrase in want.items():
|
||||
d1_, d2_ = help_of(w, mode)
|
||||
check(f"{lang}: the {mode} help says '{phrase}' (list and "
|
||||
"'How the modes work')",
|
||||
phrase.lower() in d1_.lower() and phrase.lower() in d2_.lower())
|
||||
for lang, w in (("EN", en_w), ("NL", nl_w)):
|
||||
for mode in ("solar_first", "leftover_solar", "cheap_hours"):
|
||||
for d in help_of(w, mode):
|
||||
check(f"{lang}: {mode} help is under 30 words", len(d.split()) <= 30)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -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")
|
||||
@@ -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,506 @@
|
||||
"""Runnable check for TARIFF-24 round 2 (AUDIT-02 F1). `python3 test_tariff24_weather.py`
|
||||
|
||||
With battery modes on, the price gap a mode needs was always widened x1.5
|
||||
(`Uncertainty.NO_WEATHER`) even with a solar forecast read. Now the doubt is
|
||||
added only when no usable forecast is read (`arbitrage.weather_uncertainty()`),
|
||||
in the run plan (`_price_runs()`), the charge gate (`_mode_roles()`) and Home's
|
||||
mirror (`homeview.trade_verdict()`). Modes off is untouched.
|
||||
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
|
||||
1. The rule itself, and that modes off keeps the doubt.
|
||||
2. Day/night 0.34/0.26 (gap 0.08), modes on, real Controller, night period:
|
||||
with a forecast at cycle cost 0.07 the cheap run is planned and the Cheap
|
||||
hours battery charges; without one, or with a stale one, the same cost needs
|
||||
0.105 and the reason is `not_worth` / "Prices differ too little".
|
||||
3. A dynamic day with a 0.12 gap at cost 0.10: on with a forecast, not worth
|
||||
without.
|
||||
4. The forecast coming (the gate follows in the same cycle) and going mid-run:
|
||||
a run under way keeps its gap to its end, the next run needs the wider one;
|
||||
a forecast unavailable for one or two reads mid-run does not stop or
|
||||
restart the charge.
|
||||
5. Home agrees with control in each case (Batteries Why, Home line, and
|
||||
`trade_verdict()`, Home's mirror of the gate).
|
||||
6. The hold's edges (TARIFF-24 review R4, R5): a forecast lost while only the
|
||||
day's give run is under way holds to that run's end and no further, so the
|
||||
night's cheap run needs the wider gap; and a give run that starts during a
|
||||
hold runs to its end (`keep_started()`), with no grid charge after the hold.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): the doubt always
|
||||
added (the old code) fails parts 2 to 5; never added fails the without-forecast
|
||||
rows; Home's mirror left on the old rule fails part 5; `_wx_track()` also
|
||||
recording without a forecast (its `solar_fc is None` return dropped) fails part 6.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import arbitrage, homeview, modetrade # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
Uncertainty, brussels)
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.26},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.34}]}')
|
||||
L = timezone(timedelta(hours=1))
|
||||
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
FC_TODAY, FC_TOMORROW = "sensor.fc_today", "sensor.fc_tomorrow"
|
||||
TARIFF_REFRESH_S = m.TARIFF_REFRESH_S
|
||||
|
||||
|
||||
def opts(cost, *, arb=False, sensors=True):
|
||||
o = dict(rp.options(True, arb, 2))
|
||||
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE)
|
||||
if sensors:
|
||||
o.update(pv_forecast_entity=FC_TODAY,
|
||||
pv_forecast_tomorrow_entity=FC_TOMORROW)
|
||||
return o
|
||||
|
||||
|
||||
def dynamic_points(start):
|
||||
"""A dynamic day-ahead shape, 0.16 .. 0.28 (gap 0.12), 15-minute points."""
|
||||
out, t = [], start.replace(minute=0, second=0, microsecond=0)
|
||||
for _ in range(48 * 4):
|
||||
h = brussels(t).hour + brussels(t).minute / 60
|
||||
p = 0.22 + 0.06 * math.cos((h - 19) / 24 * 2 * math.pi)
|
||||
out.append(PricePoint(start=brussels(t), eur_per_kwh=round(p, 4),
|
||||
provisional=False))
|
||||
t += timedelta(minutes=15)
|
||||
return out
|
||||
|
||||
|
||||
def sensor(kwh, at):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": "kWh"},
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
def forecast_mode(mode, t, now):
|
||||
"""What the two forecast sensors read: 'fresh', 'stale', 'gone', or a
|
||||
function of the simulated seconds that returns one of those."""
|
||||
if callable(mode):
|
||||
mode = mode(t)
|
||||
if mode == "gone":
|
||||
return {"state": "unavailable", "attributes": {}}
|
||||
at = now if mode == "fresh" else now - timedelta(hours=5)
|
||||
return at
|
||||
|
||||
|
||||
def take(c, now):
|
||||
"""What Home and the Batteries screen say now, and Home's mirror of the
|
||||
gate (it reads the trade switch, so ask it as a site that has it on next
|
||||
to the modes)."""
|
||||
keep = c.o
|
||||
c.o = dict(keep, arbitrage_enabled=True)
|
||||
try:
|
||||
mirror, why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now)
|
||||
finally:
|
||||
c.o = keep
|
||||
return dict(home=homeview.build_plan(c, now, False, True),
|
||||
cards=homeview.battery_rows(c, False, True),
|
||||
mirror=mirror, mirror_why=why)
|
||||
|
||||
|
||||
def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
house_w=300.0, soc0=None, snap_at=(), t0=NIGHT):
|
||||
"""-> (controller, rows). `points`: a feed-shaped plan injected every
|
||||
cycle (no task); else the static profile via the real tariff task. `t0`:
|
||||
the first cycle's instant."""
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in CHOICE.items():
|
||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||
("pack_sell", sell)):
|
||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
||||
rows = []
|
||||
snaps = {}
|
||||
|
||||
class H(rp.Hass):
|
||||
async def state(self, e):
|
||||
now = t0 + timedelta(seconds=self.t)
|
||||
if fc is None:
|
||||
return None
|
||||
at = forecast_mode(fc, self.t, now)
|
||||
if isinstance(at, dict):
|
||||
return at
|
||||
return sensor(0.0 if e == FC_TODAY else 12.0, at)
|
||||
|
||||
async def go():
|
||||
hass = H(("a", "b"), lambda t: house_w, soc0 or {"a": 40.0, "b": 40.0})
|
||||
tmp = tempfile.mkdtemp(prefix="t24w-")
|
||||
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
clock = [t0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
pts = None if points is None else points
|
||||
task = None
|
||||
if pts is None:
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
try:
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = t0 + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if pts is not None:
|
||||
c.tariff_plan = TariffPlan(
|
||||
PriceRead.series(pts, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", now), None,
|
||||
"provider")
|
||||
c.tariff_at = now
|
||||
elif t == 0.0 or t % TARIFF_REFRESH_S == 0.0:
|
||||
# The task's own 30 min wait is wall clock, so the
|
||||
# simulated clock restarts it: one refresh per period.
|
||||
if task is not None and t > 0.0:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None and (
|
||||
c.tariff_at is not None and c.tariff_at >= now):
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(tick, 10.0)
|
||||
rp_ = c.mode_runs
|
||||
rows.append(dict(
|
||||
t=t, now=now, fc_none=c.solar_fc is None,
|
||||
runs_on=None if rp_ is None else rp_.on,
|
||||
reason=None if rp_ is None else rp_.reason,
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
free_to=c._wx_free_to, held=c.weather_held(now),
|
||||
why=None if c.mode_tick is None else c.mode_tick.why,
|
||||
charging=hass.plants["b"].p < -200.0,
|
||||
soc_b=hass.plants["b"].soc,
|
||||
cheap_end=None if rp_ is None or rp_.cheap is None
|
||||
else rp_.cheap.end))
|
||||
if t in snap_at:
|
||||
snaps[t] = take(c, now)
|
||||
t += tick
|
||||
last = take(c, clock[0])
|
||||
c.home_plan, c.cards = last["home"], last["cards"]
|
||||
c.mirror, c.mirror_why = last["mirror"], last["mirror_why"]
|
||||
c.snaps = snaps
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
return c
|
||||
|
||||
return asyncio.run(go()), rows
|
||||
|
||||
|
||||
def why_of(c, name):
|
||||
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
|
||||
return card["why_key"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the rule")
|
||||
NW = frozenset({Uncertainty.NO_WEATHER})
|
||||
check("modes on with a forecast read: no doubt", arbitrage.weather_uncertainty(
|
||||
True, object()) == frozenset())
|
||||
check("modes on with no forecast (None): the doubt", arbitrage.weather_uncertainty(
|
||||
True, None) == NW)
|
||||
check("modes off, forecast or not: the doubt, as before",
|
||||
arbitrage.weather_uncertainty(False, object()) == NW
|
||||
and arbitrage.weather_uncertainty(False, None) == NW)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. day/night 0.34 / 0.26 (gap 0.08), night period")
|
||||
c1, r1 = drive(opts(0.07), fc="fresh")
|
||||
l1 = nth(r1, -1, "last cycle")
|
||||
print(f" forecast read, cost 0.07: mode_runs on={l1['runs_on']} "
|
||||
f"reason={l1['reason']}; B charging {sum(r['charging'] for r in r1)} of "
|
||||
f"{len(r1)}; Why A {why_of(c1, A)} B {why_of(c1, B)}")
|
||||
check("with a forecast at cost 0.07 the cheap run is planned (needed 0.07 < "
|
||||
"0.08) and the Cheap hours battery charges in the night period",
|
||||
present(l1) and not l1["fc_none"] and l1["runs_on"] is True
|
||||
and sum(r["charging"] for r in r1) > len(r1) // 2
|
||||
and l1["roles"].get(B) == modetrade.CHARGE)
|
||||
for label, mode in (("no forecast sensor reads", "gone"),
|
||||
("a stale forecast", "stale")):
|
||||
c, rr = drive(opts(0.07), fc=mode)
|
||||
ll = nth(rr, -1, "last cycle")
|
||||
print(f" {label}, cost 0.07: on={ll['runs_on']} reason={ll['reason']}; "
|
||||
f"B charging {sum(r['charging'] for r in rr)}; Why A "
|
||||
f"{why_of(c, A)} B {why_of(c, B)}")
|
||||
check(f"{label}: the same cost needs 0.105 > 0.08, so not_worth and "
|
||||
f"'Prices differ too little', no grid charge",
|
||||
present(ll) and ll["fc_none"] and ll["runs_on"] is False
|
||||
and ll["reason"] == "not_worth"
|
||||
and why_of(c, A) == "no_spread" and why_of(c, B) == "no_spread"
|
||||
and not any(r["charging"] for r in rr))
|
||||
c0, r0 = drive(opts(0.07, sensors=False), fc=None)
|
||||
check("no forecast sensor configured at all: not_worth too (as 0.19.0)",
|
||||
present(nth(r0, -1)) and r0[-1]["runs_on"] is False)
|
||||
c2, r2 = drive(opts(0.10), fc="fresh")
|
||||
check("at the shipped cost 0.10 even a forecast does not make 0.08 enough "
|
||||
"(needs 0.10): still not_worth, honestly",
|
||||
present(nth(r2, -1)) and r2[-1]["runs_on"] is False
|
||||
and r2[-1]["reason"] == "not_worth")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. a dynamic day, gap 0.12, cost 0.10")
|
||||
pts = dynamic_points(NIGHT - timedelta(hours=1))
|
||||
cd, rd = drive(opts(0.10), fc="fresh", points=pts)
|
||||
cn, rn = drive(opts(0.10), fc="gone", points=pts)
|
||||
_ld, _ln = nth(rd, -1, "last cycle"), nth(rn, -1, "last cycle")
|
||||
print(f" with forecast: on={_ld['runs_on']} reason={_ld['reason']}; "
|
||||
f"without: on={_ln['runs_on']} reason={_ln['reason']}")
|
||||
check("with a forecast the run plan is on (needs 0.10 < 0.12)",
|
||||
present(nth(rd, -1)) and rd[-1]["runs_on"] is True
|
||||
and why_of(cd, A) != "no_spread")
|
||||
check("without one it is not_worth (needs 0.15 > 0.12) and says so",
|
||||
present(nth(rn, -1)) and rn[-1]["runs_on"] is False
|
||||
and rn[-1]["reason"] == "not_worth"
|
||||
and why_of(cn, B) == "no_spread")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. the forecast going and coming mid-run (a run under way keeps its gap)")
|
||||
CUT = 600.0 # 10 min in
|
||||
SOC = {"a": 40.0, "b": 60.0} # B's cheap run is then about 2 h
|
||||
|
||||
|
||||
def going(t):
|
||||
return "fresh" if t < CUT else "gone"
|
||||
|
||||
|
||||
def coming(t):
|
||||
return "gone" if t < CUT else "fresh"
|
||||
|
||||
|
||||
def flap(n_reads):
|
||||
"""Unavailable for `n_reads` forecast reads (60 s apart) from CUT."""
|
||||
return lambda t: "gone" if CUT <= t < CUT + 60.0 * n_reads else "fresh"
|
||||
|
||||
|
||||
MID, AFTER = 3000.0, 7800.0
|
||||
cg, rg = drive(opts(0.07), fc=going, minutes=135, soc0=SOC,
|
||||
snap_at=(MID, AFTER))
|
||||
cc, rc = drive(opts(0.07), fc=coming, minutes=25)
|
||||
_end = nth([r["cheap_end"] for r in rg if r["cheap_end"] is not None], 0,
|
||||
"the cheap run's end")
|
||||
_end_t = (_end - NIGHT).total_seconds() if present(_end) else 1e9
|
||||
lost = [r for r in rg if r["fc_none"]]
|
||||
print(f" going: cheap run ends at t={_end_t:.0f} s; forecast lost from "
|
||||
f"t={lost[0]['t'] if lost else None}")
|
||||
check("going: the run planned with the forecast ends before the snapshot "
|
||||
"taken after it (the scenario is long enough)",
|
||||
present(_end) and CUT + 300.0 < _end_t < AFTER - 300.0 and bool(lost)
|
||||
and lost[0]["t"] - CUT <= 60.0)
|
||||
check("going: the forecast read is lost mid-run and the run under way KEEPS "
|
||||
"its gap: the plan stays on and B keeps charging, cycle after cycle, "
|
||||
"until B is full or the run ends",
|
||||
all(r["runs_on"] is True and (r["roles"].get(B) == modetrade.CHARGE
|
||||
or r["soc_b"] >= 94.0)
|
||||
for r in rg if r["fc_none"] and r["t"] < _end_t - 60.0)
|
||||
and all(r["charging"] or r["soc_b"] >= 94.0 for r in rg
|
||||
if 60.0 <= r["t"] < _end_t - 60.0)
|
||||
and max(r["soc_b"] for r in rg if r["fc_none"]) >= 90.0)
|
||||
check("going: from the run's end the wider gap is back: the next run is "
|
||||
"not_worth (needs 0.105 at 0.07), no new run, and no grid charge",
|
||||
all(r["runs_on"] is False and r["reason"] == "not_worth"
|
||||
for r in rg if r["t"] >= _end_t + 30.0)
|
||||
and not any(r["charging"] for r in rg if r["t"] >= _end_t + 60.0))
|
||||
_sm, _sa = cg.snaps.get(MID), cg.snaps.get(AFTER)
|
||||
check("going, Home agrees mid-run: its mirror still allows, the Batteries "
|
||||
"screen says it charges from the grid, and Home does not say the gap "
|
||||
"is too small",
|
||||
_sm is not None and _sm["mirror"] is not None
|
||||
and first((b for b in _sm["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "grid_charge"
|
||||
and not any("price gap is too small" in ln
|
||||
for ln in _sm["home"]["lines"]))
|
||||
check("going, Home agrees after the run: its mirror refuses, the Batteries "
|
||||
"screen says 'differ too little' and Home says the gap is too small",
|
||||
_sa is not None and _sa["mirror"] is None
|
||||
and first((b for b in _sa["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "no_spread"
|
||||
and any("price gap is too small" in ln for ln in _sa["home"]["lines"]))
|
||||
for n in (1, 2):
|
||||
cf, rf = drive(opts(0.07), fc=flap(n), minutes=30, soc0=SOC,
|
||||
snap_at=(CUT + 30.0,))
|
||||
gone = [r for r in rf if r["fc_none"]]
|
||||
print(f" flap, {n} read(s) lost: {len(gone)} cycles without a "
|
||||
f"forecast; B charging in {sum(r['charging'] for r in rf)} of "
|
||||
f"{len(rf)}")
|
||||
check(f"flap: the forecast is unavailable for {n} read(s) mid-run: the "
|
||||
f"plan stays on and B charges without a break (no stop, no "
|
||||
f"restart)",
|
||||
bool(gone) and all(r["runs_on"] is True for r in rf)
|
||||
and all(r["charging"] and r["roles"].get(B) == modetrade.CHARGE
|
||||
for r in rf if r["t"] >= 60.0))
|
||||
sf = cf.snaps.get(CUT + 30.0)
|
||||
check(f"flap, {n} read(s): Home agrees while the read is lost (mirror "
|
||||
f"allows, Batteries 'charges from the grid')",
|
||||
sf is not None and sf["mirror"] is not None
|
||||
and first((b for b in sf["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "grid_charge")
|
||||
|
||||
|
||||
def flips(rows):
|
||||
"""Cycles where the forecast read changed, and whether the plan and the
|
||||
charge gate changed in that very cycle."""
|
||||
out = []
|
||||
for i in range(1, len(rows)):
|
||||
if rows[i]["fc_none"] != rows[i - 1]["fc_none"]:
|
||||
out.append((i, rows[i]["t"], rows[i]["runs_on"],
|
||||
rows[i]["charging"], rows[i - 1]["charging"]))
|
||||
return out
|
||||
|
||||
|
||||
fcm = flips(rc)
|
||||
print(f" coming: forecast read arrives at cycle/t {fcm}")
|
||||
check("coming (no run under way before): not_worth until the read is back, "
|
||||
"then on in the same cycle, and the battery charges",
|
||||
len(fcm) == 1 and fcm[0][1] - CUT <= 60.0 and fcm[0][2] is True
|
||||
and all(r["runs_on"] is False for r in rc if r["fc_none"])
|
||||
and all(r["runs_on"] is True for r in rc if not r["fc_none"])
|
||||
and any(r["charging"] for r in rc if r["t"] >= fcm[0][1] + 40.0))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. Home agrees with control")
|
||||
CASES = (("forecast, cost 0.07", c1, r1, True),
|
||||
("no forecast, cost 0.07", *drive(opts(0.07), fc="gone"), False),
|
||||
("dynamic, forecast", cd, rd, True),
|
||||
("dynamic, no forecast", cn, rn, False))
|
||||
for label, cx, rx, on in CASES:
|
||||
last = nth(rx, -1, "last cycle")
|
||||
hp = cx.home_plan
|
||||
mirror_ok = cx.mirror is not None
|
||||
print(f" {label}: control on={last['runs_on']}; Home mirror "
|
||||
f"{'allows' if mirror_ok else 'refuses'} ({cx.mirror_why}); "
|
||||
f"lines {hp['lines'][:1]}; Why B {why_of(cx, B)}")
|
||||
check(f"{label}: Home's mirror of the gate allows exactly when control "
|
||||
f"plans", present(last) and mirror_ok is on
|
||||
and last["runs_on"] is on)
|
||||
check(f"{label}: the Batteries screen and the Home line say the gap is "
|
||||
f"too small exactly when control does",
|
||||
(why_of(cx, B) == "no_spread") is (not on)
|
||||
and any("price gap is too small" in ln for ln in hp["lines"])
|
||||
is (not on))
|
||||
if label.startswith("dynamic"):
|
||||
continue # its cheap run is tomorrow morning: not on today's chart
|
||||
check(f"{label}: Home draws a grid charge block exactly when control "
|
||||
f"charges", any(r["kind"] == "grid" for r in hp["runs"]) is on)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n6. the hold's edges (review probes R4, R5)")
|
||||
T_R4 = datetime(2026, 10, 30, 21, 40, tzinfo=L).astimezone(timezone.utc)
|
||||
TEN = datetime(2026, 10, 30, 22, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
c4, r4 = drive(opts(0.07), fc=lambda t: "fresh" if t < 300.0 else "gone",
|
||||
minutes=60, t0=T_R4)
|
||||
_held4 = [r for r in r4 if r["held"]]
|
||||
_l4 = nth(r4, -1, "last cycle")
|
||||
print(f" R4: forecast lost 21:45; held {len(_held4)} cycles, to "
|
||||
f"{_l4['free_to']}; after 22:00 on={ {r['runs_on'] for r in r4 if r['now'] >= TEN} } "
|
||||
f"charging {sum(r['charging'] for r in r4)}")
|
||||
check("R4, forecast lost before the next run: while only the day's give run "
|
||||
"was under way the hold is that run's end, 22:00, and stays there",
|
||||
present(_l4) and all(r["free_to"] == TEN for r in r4
|
||||
if r["t"] >= 5.0)
|
||||
and bool(_held4) and all(r["now"] < TEN for r in _held4))
|
||||
check("R4: from 22:00 the night's cheap run needs the wider gap: "
|
||||
"not_worth, and no grid charge at any time",
|
||||
all(r["runs_on"] is False and r["reason"] == "not_worth"
|
||||
for r in r4 if r["now"] >= TEN)
|
||||
and not any(r["charging"] for r in r4))
|
||||
|
||||
|
||||
def r5_points(t0):
|
||||
"""04:00-07:00 at 0.20, 07:00-10:00 at 0.30, else 0.25 (local)."""
|
||||
out, t = [], t0.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||
for _ in range(4 * 30):
|
||||
h = brussels(t).hour
|
||||
p = 0.20 if 4 <= h < 7 else 0.30 if 7 <= h < 10 else 0.25
|
||||
out.append(PricePoint(start=brussels(t), eur_per_kwh=p,
|
||||
provisional=False))
|
||||
t += timedelta(minutes=15)
|
||||
return out
|
||||
|
||||
|
||||
T_R5 = datetime(2026, 10, 30, 5, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
GIVE_END = datetime(2026, 10, 30, 10, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
c5, r5 = drive(opts(0.07), fc=lambda t: "fresh" if t < 3600.0 else "gone",
|
||||
points=r5_points(T_R5), minutes=320, t0=T_R5,
|
||||
soc0={"a": 40.0, "b": 30.0})
|
||||
_ft5 = nth([r["free_to"] for r in r5 if r["t"] == 3600.0], 0, "the hold")
|
||||
_in = [r for r in r5 if present(_ft5) and _ft5 <= r["now"] < GIVE_END]
|
||||
_out = [r for r in r5 if r["now"] >= GIVE_END + timedelta(seconds=30)]
|
||||
print(f" R5: forecast lost 06:00, hold to {_ft5}; after it, until 10:00, "
|
||||
f"on in {sum(r['runs_on'] is True for r in _in)} of {len(_in)}, B "
|
||||
f"{sorted({str(r['roles'].get(B)) for r in _in})}; after 10:00 "
|
||||
f"{ {r['reason'] for r in _out} }")
|
||||
check("R5: the hold is the cheap run's end, before the give run's end",
|
||||
present(_ft5) and T_R5 < _ft5 < GIVE_END)
|
||||
check("R5, a run that has started runs to its end: the give run that began "
|
||||
"during the hold stays on after it, B gives, and nothing charges from "
|
||||
"the grid",
|
||||
len(_in) > 100 and all(r["runs_on"] is True
|
||||
and r["roles"].get(B) == modetrade.GIVE
|
||||
and not r["charging"] for r in _in))
|
||||
check("R5: when the give run ends the wider gap decides: not_worth, no grid "
|
||||
"charge",
|
||||
bool(_out) and all(r["runs_on"] is False and r["reason"] == "not_worth"
|
||||
and not r["charging"] for r in _out))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -684,8 +684,9 @@ check("Dutch: the notes, the totals and the blocks are in Dutch, with a "
|
||||
c = S.make("dynamic", at(0, 18))
|
||||
nl_b = tab(c, at(0, 18), "nl")["days"][0]["batteries"][0]["blocks"]
|
||||
check("...and so are the plan's words on a block",
|
||||
nl_b[0]["what"].startswith("Laadt tot") and nl_b[0]["why"].startswith(
|
||||
"Goedkoopste uren") and nl_b[1]["what"].startswith("Levert tot"))
|
||||
nl_b[0]["what"].startswith("Laadt maximaal") and " %" in nl_b[0]["what"]
|
||||
and nl_b[0]["why"].startswith("Goedkoopste uren")
|
||||
and nl_b[1]["what"].startswith("Levert tot"))
|
||||
check("the language goes back to English after a Dutch build",
|
||||
homeview.pill("running") == en["home_words"]["pill"]["running"])
|
||||
c = S.make("dynamic", at(10, 7))
|
||||
@@ -1455,6 +1456,35 @@ hi_ = tab(c_t, at(10, 7))["days"][0]["batteries"][0]["levels"]
|
||||
check("on a controller the curve is built with the plan's trust "
|
||||
"(`solar_trust_pct`)", hi_ and max(v for _h, v in hi_) > hi_[0][1])
|
||||
|
||||
# TARIFF-24 (sync 0.19): the tab extends tomorrow's cheap run as control
|
||||
# re-plans it, so it asks control's gap: the cycle cost with a forecast read,
|
||||
# not 1.5 x it.
|
||||
from app.price import required_spread, uncertainty_of # noqa: E402
|
||||
gaps, _plan_real = [], dayview.priceruns.plan
|
||||
|
||||
|
||||
def _spy(*a, **k):
|
||||
gaps.append(k.get("required_eur_kwh"))
|
||||
return _plan_real(*a, **k)
|
||||
|
||||
|
||||
c_g = S.make("dynamic", at(17, 0))
|
||||
dayview.priceruns.plan = _spy
|
||||
try:
|
||||
tab(c_g, at(17, 0))
|
||||
finally:
|
||||
dayview.priceruns.plan = _plan_real
|
||||
_p = c_g.fresh_tariff_plan(at(17, 0))
|
||||
want = required_spread(arbitrage.threshold_eur_kwh(c_g.o),
|
||||
uncertainty_of(_p.read.product)
|
||||
| arbitrage.weather_uncertainty(True, c_g.solar_fc))
|
||||
print(f" dynamic 17:00: the tab's extension asks {gaps}, control needs {want}")
|
||||
check("the tab extends tomorrow's cheap run with control's gap (with a "
|
||||
"forecast: the cycle cost, not 1.5 x it)",
|
||||
c_g.solar_fc is not None and bool(gaps)
|
||||
and all(abs(g - want) < 1e-9 for g in gaps)
|
||||
and abs(want - arbitrage.threshold_eur_kwh(c_g.o)) < 1e-9)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
|
||||
@@ -247,6 +247,12 @@ def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first",
|
||||
tomorrow = False
|
||||
if name == "sunny":
|
||||
sun_today, sun_tomorrow = 40.0, 40.0
|
||||
if name not in ("nofc", "off", "stale") and \
|
||||
opts.get("battery_modes_enabled"):
|
||||
# TARIFF-24: with a forecast the gap needed is the cycle cost, not
|
||||
# 1.5 x it. These scenarios were written for the old gate (0.15 at
|
||||
# the shipped 0.10), so they keep it: the cost is 0.15.
|
||||
opts["cycle_cost_threshold_eur"] = 0.15
|
||||
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for i, inv in enumerate(c.inverters):
|
||||
|
||||
Reference in New Issue
Block a user