Files
goodwe-addon/goodwe_controller/test_opt23_28.py
T
glenn schrooyenandClaude Opus 5.5 ae0cd542c0
checks / unit (pull_request) Successful in 50m6s
infra-pin / fresh (pull_request) Successful in 1s
OPT-23-28 review: the unheld-shortfall line says another battery or the grid
With A on Leftover solar and B on Cheap hours (the dynamic scenario, and the
owner's planned setup), the line said "The batteries cannot hold it all. The
grid covers N kWh", while B holds a full charge for the same hours. It now
says "The expensive hours need more than Leftover solar can hold. The rest,
N kWh, comes from another battery or the grid." (NL likewise). DOCS,
DECISIONS and the dayplan/homeview docstrings say the same. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-29 23:21:31 +01:00

557 lines
26 KiB
Python

"""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")