Compare commits
6
Commits
OPT-27
...
release/1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6be31286a | ||
|
|
6be7e41de1 | ||
|
|
ae0cd542c0 | ||
|
|
a645d899f4 | ||
|
|
995028bc5d | ||
|
|
ffb0d61d5b |
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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -530,8 +530,8 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen (and Se
|
||||
- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
|
||||
- **Mode.**
|
||||
- *Solar first* charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells.
|
||||
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). It saves its power for the expensive hours and covers the house then.
|
||||
- *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then.
|
||||
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but it buys only what the sun will not give in the expensive hours (see below). It saves its power for those hours and covers the house then.
|
||||
- *Cheap hours only* fills up from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then. It does not look at the forecast.
|
||||
- **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A).
|
||||
|
||||
With battery modes on, these choices replace the *Charging and selling on price* switch (`arbitrage_enabled`); that switch is only read with modes off.
|
||||
@@ -548,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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -20,6 +20,7 @@ import contextlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import arbitrage, messages, peakguard, uitext
|
||||
from .dayplan import EFF
|
||||
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
|
||||
from .maintenance import IDLE as M_IDLE, next_slot_start
|
||||
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
@@ -471,9 +472,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 +513,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 +565,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 +891,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
|
||||
@@ -1160,6 +1189,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 +1325,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 +1358,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 +1445,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)
|
||||
|
||||
@@ -1516,6 +1572,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)),
|
||||
@@ -1530,6 +1587,7 @@ def _samples() -> list:
|
||||
("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 +1618,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"))]
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -719,8 +719,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 +756,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)",
|
||||
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user