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
557 lines
26 KiB
Python
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")
|