Files
goodwe-addon/goodwe_controller/test_opt34.py
T
glenn schrooyenandClaude 3a18475a5a
checks / unit (pull_request) Successful in 20m36s
infra-pin / fresh (pull_request) Successful in 2s
OPT-36 round 2: test_opt34 keeps its tolerances; fresh sensors read the day they are on
The silent and fresh nights diverged only because the test's fresh sensors
kept reading the evening's 0.4 kWh 'left today' after midnight, while the
kept figure is the 4th's 6.0 kWh (solarfc.Hold.stand_in, as_today): two
different shortfalls (16.5 and 12.7 kWh) that A's ceiling hid until B was
counted. Fresh sensors now read 6.0 after midnight, so fresh and kept are
the same figure; the 0.3 tolerance is back, the other exact values are
recomputed. test_ux54 literals are recomputed too.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 05:34:32 +01:00

860 lines
42 KiB
Python

"""Runnable check for OPT-34. `python3 test_opt34.py`
OPT-27 keeps the last fresh Forecast.Solar figure over the night. With the
sensors silent from 17:05 it ended at local midnight + 3 h, in the middle of the
night grid charge: Leftover solar's planned level went with it and the charge
stopped (rig, 2026-10-31: battery A 49.8 % at 07:00 against 79.9 % with a fresh
figure). Forecast.Solar may not refresh after midnight on a live site, so the
add-on must not depend on it. OPT-34:
1. a night charge that has started keeps its planned level when the forecast
goes (`Controller._latch_charge()`), and the next slice of the run is planned
with it (the wide no-forecast gap does not refuse it);
2. the evening's "tomorrow" figure is today's until the bell starts (06:00
local, `BELL_FROM_H`) instead of until midnight + 3 h;
3. for that case only, a reading may be up to 15 h old (a 17:05 reading is
12 h 55 min old at 06:00), but only if it was at most 12 h old at midnight;
4. a fresh figure replaces the kept one at once.
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
Mutations (MUTATION.md, purged copies, graded over this file): see the
DECISIONS note and the dev report.
1. `solarfc` alone: the window, the age limits, the day, DST, restart.
2. The real Controller, the owner's shape (modes on, arbitrage off, A Leftover
solar + B Cheap hours, both 10 % at the start of the night, the night rate
22:00-07:00, tomorrow 6 kWh, a 2.5 kW peak limit so the charge takes hours),
sensors silent from 17:05, whole nights at 5 s per cycle (run in parallel
processes, about 2.5 minutes each):
a. silent from 17:05: both batteries end the night where a fresh figure
ends them (the base left A at 57 % against 87 %), and the screens agree;
b. restarts at 01:00 and 04:00 change nothing;
c. a charge that cannot start before 03:00 (the price dips at 04:00) still
starts at 04:00 on the kept figure;
d. a fresh figure at 02:00 replaces the level at once, up, during the charge;
and before a charge has started, up and down;
e. the last hour, after the kept figure has ended at 06:00: the charge under
way goes on to its level, also over a restart; the screens say so.
f. F-1 (rig): one fresh reading at 02:00, then silent again: when it is
stale (05:00) the 17:05 figure it replaced does not come back.
3. The text, EN and NL.
4. `_latch_charge()` alone: a battery that leaves the plan leaves the latch.
"""
import asyncio
import json
import logging
import os
import sys
import tempfile
import time
from concurrent.futures import ProcessPoolExecutor
from datetime import datetime, timedelta, timezone
from multiprocessing import get_context
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
import app.main as m # noqa: E402
from app import homeview, solarfc # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
def at(day, hh, mm=0):
"""Local time on 2026-11-`day` (CET) as UTC."""
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
def st(kwh, when, *, unit="kWh"):
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
"last_updated": when.isoformat(),
"last_reported": when.isoformat()}
GONE = {"state": "unavailable", "attributes": {}}
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
BRU = solarfc.BILLING_TZ
def bru(y, mo, d, hh, mm=0):
return datetime(y, mo, d, hh, mm, tzinfo=BRU).astimezone(timezone.utc)
def rs(today, tomorrow, now, hold):
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
def near(a, b, tol):
"""`|a - b| < tol`, and False (not a crash) when `a` is missing."""
return a is not None and abs(a - b) < tol
def used(hold, now):
f = rs(GONE, GONE, now, hold)[0]
return f is not None
def hold_of(read_at, today=0.4, tomorrow=12.0):
h = solarfc.Hold()
rs(st(today, read_at), st(tomorrow, read_at),
read_at + timedelta(minutes=5), h)
return h
# --------------------------------------------------------------------------- #
print("1. solarfc: the evening figure serves until the bell starts")
h17 = hold_of(at(3, 17, 5))
check("a 17:05 reading is kept at 20:00, 22:00, 00:00, 03:00 and 05:59",
all(used(h17, t) for t in (at(3, 20, 0), at(3, 22, 0), at(4, 0, 0),
at(4, 3, 0), at(4, 5, 59))))
f_late = rs(GONE, GONE, at(4, 5, 59), h17)[0]
check("... at 05:59 it is tomorrow's 12.0 kWh as TODAY's, the whole day",
f_late is not None and f_late.kept and f_late.today.kwh == 12.0
and f_late.tomorrow is None and f_late.at == at(3, 17, 5)
and f_late.between(at(4, 6, 0), at(4, 20, 0)) is not None
and abs(f_late.between(at(4, 6, 0), at(4, 20, 0)) - 12.0) < 1e-6)
check("... and at 06:00 (12 h 55 min old, the bell starts) it is not",
not used(h17, at(4, 6, 0)) and not used(h17, at(4, 6, 30))
and not used(h17, at(4, 12, 0)))
check("... not the next evening either, and not on the 5th",
not used(h17, at(4, 22, 0)) and not used(h17, at(5, 3, 0)))
# The age limit of this case: 15 h, for a figure that was usable at midnight.
h1230 = hold_of(at(3, 12, 30))
check("a 12:30 reading (11 h 30 min old at midnight): used until 03:29 "
"(14 h 59 min old) ...",
used(h1230, at(4, 0, 0)) and used(h1230, at(4, 3, 29)))
check("... and not from 03:31 (15 h 01 min), though it is before 06:00",
not used(h1230, at(4, 3, 31)) and not used(h1230, at(4, 5, 30)))
h1600 = hold_of(at(3, 16, 0))
check("a 16:00 reading: the 06:00 window ends it, not the 15 h limit "
"(14 h old at 06:00)", used(h1600, at(4, 5, 59))
and not used(h1600, at(4, 6, 0)))
# Not used: a morning figure at night (12 h), and no come-back after midnight.
h0905 = hold_of(at(3, 9, 5))
check("a 09:05 reading: kept at 20:00 (10 h 55 min), not at 22:00 (12 h 55 min)",
used(h0905, at(3, 20, 0)) and not used(h0905, at(3, 22, 0)))
check("... and it does not come back after midnight (14 h 55 min at 00:00, "
"under the 15 h of the evening-to-morning case): it was not usable "
"at midnight",
not used(h0905, at(4, 0, 0)) and not used(h0905, at(4, 0, 2))
and not used(h0905, at(4, 2, 0)))
h1105 = hold_of(at(3, 11, 59))
check("the same for a reading that is 12 h 01 min old at midnight",
not used(h1105, at(4, 0, 0)) and not used(h1105, at(4, 0, 30)))
h1201 = hold_of(at(3, 12, 1))
check("... and one that is 11 h 59 min old at midnight is used",
used(h1201, at(4, 0, 0)) and used(h1201, at(4, 3, 0)))
check("a same-day figure past 12 h is not used: a 07:05 reading at 20:00 "
"(12 h 55 min)", not used(hold_of(at(3, 7, 5)), at(3, 20, 0)))
check("a figure over 15 h old is not used: 17:05 read, 08:06 next day "
"(15 h 01 min, bell started)", not used(h17, at(4, 8, 6)))
check("(a figure from the day before is not used either)",
not used(hold_of(at(2, 17, 5)), at(3, 22, 0))
and not used(hold_of(at(2, 17, 5)), at(4, 3, 0)))
# Today's figure is not tomorrow's, and the figure is for its own day only.
hd = solarfc.Hold()
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), hd)
fx = rs(GONE, GONE, at(4, 4, 0), hd)[0]
check("04:00: today's sensor is gone: the 4th gets the 3rd's TOMORROW "
"figure (12.0), never the 3rd's today figure (3.0)",
fx is not None and fx.today.kwh == 12.0)
fy = rs(st(5.0, at(4, 3, 50)), st(9.0, at(4, 3, 50)), at(4, 4, 0), hd)[0]
check("a fresh figure replaces the kept one at once (04:00: 5.0 today, "
"9.0 tomorrow; not kept)", fy is not None and not fy.kept
and fy.today.kwh == 5.0 and fy.tomorrow.kwh == 9.0)
fy2 = rs(st(5.0, at(4, 7, 55)), GONE, at(4, 8, 0), hd)[0]
check("... and at 08:00, today fresh and tomorrow's sensor down for over 3 h: "
"no tomorrow (the 3rd's figure is not the 5th's)",
fy2 is not None and fy2.tomorrow is None and fy2.today.kwh == 5.0)
# Restart: the saved hold keeps the figure.
for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
back = solarfc.Hold(json.loads(json.dumps(hold_of(at(3, 17, 5)).dump())))
fr = rs(GONE, GONE, now, back)[0]
check(f"restart at {label}: the saved hold (FC_HOLD_KEY) gives the "
"17:05 figure again", fr is not None and fr.kept
and fr.today.kwh == 12.0 and fr.at == at(3, 17, 5))
# F-1 (rig): a fresh today reading at 02:00 replaces the 17:05 figure; once
# that reading is stale (05:00) the 17:05 figure must not come back.
for label, kwh in (("brighter", 40.0), ("darker", 1.0)):
hn = hold_of(at(3, 17, 5))
f2 = rs(st(kwh, at(4, 2, 0)), st(kwh, at(4, 2, 0)), at(4, 2, 1), hn)[0]
f4 = rs(GONE, GONE, at(4, 4, 59), hn)[0]
snap = json.loads(json.dumps(hn.dump()))
f5 = [rs(GONE, GONE, t, hn)[0] for t in (at(4, 5, 1), at(4, 5, 59))]
f5s = [rs(st(kwh, at(4, 2, 0)), st(12.0, at(3, 17, 5)), t, hn)[0]
for t in (at(4, 5, 1), at(4, 5, 30))]
check(f"F-1 {label} 02:00 reading: used fresh at 02:01 and kept for "
"a missing sensor to 04:59 (3 h)",
f2 is not None and not f2.kept and f2.today.kwh == kwh
and f4 is not None and f4.today.kwh == kwh and not f4.kept)
check("... and from 05:01 (stale) to 05:59: no forecast, never the "
"17:05 figure (sensors gone, or showing their old readings)",
all(x is None for x in f5 + f5s))
back = solarfc.Hold(snap)
check("... also after a restart at 03:00 (the saved hold keeps the "
"02:00 reading)", rs(GONE, GONE, at(4, 3, 0), back)[0] is not None
and rs(GONE, GONE, at(4, 5, 1), back)[0] is None)
# Two today sensors, only one read fresh: the sum never had a fresh figure,
# so the evening one still stands in (as before).
h2 = solarfc.Hold()
td2 = [("sensor.t1", st(0.2, at(3, 17, 5))),
("sensor.t2", st(0.2, at(3, 17, 5)))]
tm2 = [("sensor.m1", st(6.0, at(3, 17, 5))),
("sensor.m2", st(6.0, at(3, 17, 5)))]
gone2 = [(e, GONE) for e, _s in td2], [(e, GONE) for e, _s in tm2]
solarfc.read_sum(td2, tm2, at(3, 17, 10), h2)
solarfc.read_sum([("sensor.t1", st(9.0, at(4, 2, 0))), td2[1]], tm2,
at(4, 2, 1), h2)
g5 = solarfc.read_sum(*gone2, at(4, 5, 1), h2)[0]
check("two today sensors, only one read fresh at 02:00: the evening sum "
"(12.0) still stands in at 05:01", g5 is not None and g5.kept
and g5.today.kwh == 12.0)
solarfc.read_sum([td2[0], ("sensor.t2", st(9.0, at(4, 2, 10)))], tm2,
at(4, 2, 11), h2)
check("... and once both have read fresh, it does not",
solarfc.read_sum(*gone2, at(4, 5, 20), h2)[0] is None)
# DST: the 25 h autumn day and the 23 h spring day. The window ends at 06:00
# LOCAL, not 6 h after midnight.
for (y, mo, d), label in (((2026, 10, 25), "25 h day 2026-10-25"),
((2027, 3, 28), "23 h day 2027-03-28")):
t17 = bru(y, mo, d - 1, 17, 5)
hs = hold_of(t17)
ok_in = used(hs, bru(y, mo, d - 1, 23, 59)) and all(
used(hs, bru(y, mo, d, hh, mm)) for hh, mm in
((0, 0), (1, 30), (4, 0), (5, 59)))
check(f"{label}: the 17:05 figure serves 23:59 to 05:59 local", ok_in)
check(f"... and not at 06:00 local", not used(hs, bru(y, mo, d, 6, 0))
and not used(hs, bru(y, mo, d, 6, 30)))
# --------------------------------------------------------------------------- #
print("\n2. The real Controller, whole nights (parallel processes)")
A, B = "inverter-a", "inverter-b"
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.26},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
# The price dips at 04:00 (a dynamic site): the cheap run starts after 03:00.
PROFILE_LATE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00",'
' "to": "04:00", "eur_per_kwh": 0.28},'
'{"name": "dawn", "days": "all", "from": "04:00",'
' "to": "07:00", "eur_per_kwh": 0.20},'
'{"name": "day", "days": "all", "from": "07:00",'
' "to": "22:00", "eur_per_kwh": 0.34}]}')
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
LAST = at(3, 17, 5)
MIDNIGHT = at(4, 0, 0)
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
+ [at(3, 21, 55)]
def opts(late=False, cap=2500):
o = dict(rp.options(True, False, 2))
o.update(cycle_cost_threshold_eur=0.07,
static_profile=PROFILE_LATE if late else PROFILE,
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT,
peak_cap_w=cap)
return o
def sensors(*, silent=True, fresh_from=None, fresh=(1.0, 5.0),
fresh_until=None):
"""Sensors that update until 17:05 (or all night) and then show that
reading for good; from `fresh_from` a new reading every call."""
def f(now, ent):
if fresh_from is not None and now >= fresh_from:
when = now if fresh_until is None else min(now, fresh_until)
return st(fresh[0] if ent == T_ENT else fresh[1], when)
when = min(now, LAST) if silent else now
# OPT-36: a fresh sensor reads the day it is on. After midnight "today"
# is the 4th (6.0 kWh, the figure the 17:05 reading called tomorrow's),
# not the evening's 0.4 kWh left of the 3rd; with that, fresh and
# kept are the same figure all night (before, the plans only agreed
# because A was capped at the ceiling in both).
return st(0.4 if ent == T_ENT and now < MIDNIGHT else 6.0, when)
return f
def run_night(spec):
"""One scenario in this process -> a plain dict. `spec`: start (UTC),
hours, silent, fresh_from, fresh, restarts, soc, load_until_s, marks."""
start = spec["start"]
soc0 = spec.get("soc", {"a": 10.0, "b": 10.0})
# OPT-36: Cheap hours B now covers part of the dear hours, so the house the
# plan expects is 1.6 x larger than it was written for (700 W -> 1.1 kW):
# A still has a shortfall to buy and the night charge is still long.
house_w = 1.6 * spec.get("house_w", 700.0)
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
if spec.get("latch"):
store.data[m.CHARGE_LATCH_KEY] = spec["latch"]
sens = sensors(silent=spec.get("silent", True),
fresh_from=spec.get("fresh_from"),
fresh=spec.get("fresh", (1.0, 5.0)),
fresh_until=spec.get("fresh_until"))
clock = [EVENING[0]]
class H(rp.Hass):
async def state(self, e):
return GONE if spec.get("gone") else sens(clock[0], e)
hass = H(("a", "b"), lambda t: 300.0, soc0)
out = dict(marks={}, soc_at={})
def build(sto):
tmp = tempfile.mkdtemp(prefix="o34-")
c = Controller(dict(opts(spec.get("late", False),
spec.get("cap", 2500))), hass, sto,
rp._Pub(),
stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
c.house_profile.load({d: {str(hh): house_w for hh in range(24)}
for d in ("2026-10-31", "2026-11-01",
"2026-11-02")})
return c
async def go():
cur = store
c = build(cur)
real_dt, real_hv = m.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
try:
for t_pre in EVENING:
clock[0] = t_pre
await c._read_forecast()
pending = sorted(spec.get("restarts", ()))
marks = sorted(spec.get("marks", ()))
first, t = True, 0.0
hz = spec["hours"] * 3600.0
while t < hz:
now = start + timedelta(seconds=t)
clock[0] = now
if pending and now >= pending[0]:
pending.pop(0)
data = json.loads(json.dumps(cur.data))
cur = rp._Store()
cur.data.update(data)
c = build(cur)
first = True
if first or int(t) % 600 == 0:
# The tariff, resolved here at its own pace: the real
# refresh task sleeps on the wall clock.
hor = float(m.price_option(c.o, "price_horizon_h"))
c.tariff_plan = await m.resolve_tariff(
c.o, now, now + timedelta(hours=hor), now=now,
history=c.price_history, watch=c.price_watch)
c.tariff_at = now
first = False
hass.t = t
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(5.0, 10.0)
if int(t) % 300 == 0:
out["soc_at"][now.astimezone(L).strftime("%H:%M")] = (
hass.plants["a"].soc, hass.plants["b"].soc,
hass.plants["a"].p, c.solar_fc is None,
bool(c.solar_fc and c.solar_fc.kept),
None if c.plan_targets() is None
else c.plan_targets().get(A))
while marks and now >= marks[0]:
marks.pop(0)
cards = homeview.battery_rows(c, False, True)
blk = homeview.solar_block(c, now)
a_card = next(b for b in cards if b["name"] == A)
out["marks"][now.astimezone(L).strftime("%H:%M")] = dict(
line=blk["line"], state=blk["state"],
why_a=a_card["why_key"],
fc_none=c.solar_fc is None,
kept=bool(c.solar_fc and c.solar_fc.kept),
target_a=(c.plan_targets() or {}).get(A),
soc_a=hass.plants["a"].soc,
p_a=hass.plants["a"].p,
charging=hass.plants["b"].p < -200.0)
t += 5.0
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
out["latch"] = getattr(c, "_charge_latch", None) is not None
finally:
m.datetime, homeview.datetime = real_dt, real_hv
return out
return asyncio.run(go())
NIGHT = at(3, 22, 0)
SCEN = {
"fresh": dict(start=NIGHT, hours=9, silent=False,
marks=[at(4, 4, 30)]),
"silent": dict(start=NIGHT, hours=9,
marks=[at(4, 1, 0), at(4, 4, 30), at(4, 6, 30)]),
"restart": dict(start=NIGHT, hours=9,
restarts=[at(4, 1, 0), at(4, 4, 0)],
marks=[at(4, 1, 30), at(4, 4, 30)]),
# c. the charge cannot start before 03:00 (the price dips at 04:00)
"late": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0,
soc={"a": 40.0, "b": 70.0}, marks=[at(4, 3, 30)]),
"late_fresh": dict(start=at(4, 2, 30), hours=4.5, late=True,
house_w=500.0, soc={"a": 40.0, "b": 70.0},
silent=False),
# d. a fresh, darker figure at 02:00 during the charge (a lighter house
# use, so the level has room to rise)
"dark": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
house_w=500.0, fresh_from=at(4, 2, 0), fresh=(1.0, 5.0),
marks=[at(4, 1, 59), at(4, 2, 5)]),
"kept": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
house_w=500.0, marks=[at(4, 1, 59), at(4, 2, 5)]),
# before the charge starts: a fresh figure replaces the kept one, up and
# down, at once
"pre_kept": dict(start=at(4, 2, 30), hours=0.25, late=True,
house_w=500.0, marks=[at(4, 2, 44)]),
"pre_dark": dict(start=at(4, 2, 30), hours=0.25, late=True,
house_w=500.0, fresh_from=at(4, 2, 40),
fresh=(1.0, 5.0), marks=[at(4, 2, 44)]),
"pre_bright": dict(start=at(4, 2, 30), hours=0.25, late=True,
house_w=500.0, fresh_from=at(4, 2, 40),
fresh=(30.0, 30.0), marks=[at(4, 2, 44)]),
# d. the last hour, after the kept figure ended at 06:00
"tail": dict(start=at(4, 5, 40), hours=80 / 60.0,
soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)],
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]),
# a fresh, brighter figure at 03:10 lowers A's level below its SoC (A
# stops, B goes on), then the sensors go quiet: the forecast ends at 06:10
"lowered": dict(start=at(4, 2, 30), hours=4.5, house_w=500.0,
soc={"a": 30.0, "b": 30.0}, fresh_from=at(4, 3, 10),
fresh_until=at(4, 3, 10), fresh=(30.0, 30.0),
marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]),
# F1: the add-on was down when the latch of the night before ended; it is
# back inside a cheap run, with the sensors unavailable (no forecast)
"stale": dict(start=at(3, 22, 0), hours=0.5, gone=True,
latch={"until": at(3, 7, 15).isoformat(),
"born": at(2, 23, 0).isoformat(),
"levels": {A: 80.0, B: 80.0}},
marks=[at(3, 22, 20)]),
"stale_live": dict(start=at(3, 22, 0), hours=0.5, gone=True,
latch={"until": at(4, 7, 15).isoformat(),
"born": at(3, 22, 0).isoformat(),
"levels": {A: 80.0, B: 80.0}},
marks=[at(3, 22, 20)]),
# the same, B already full and a 1.5 kW limit: the run is sized at the
# battery's 2.5 kW rating and ends at 06:33, before A is there, so the
# next slice is planned after 06:00 and is only A's
"tail_bfull": dict(start=at(4, 5, 40), hours=80 / 60.0, cap=1500,
soc={"a": 65.0, "b": 95.0},
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 45)]),
}
# F-1 (rig 4v): silent from 17:05, one fresh reading at 02:00, silent again.
# It is stale at 05:00, before the 06:00 bell: the 17:05 figure it replaced
# must not come back. Brighter (A's level drops), with a restart at 03:00,
# and darker (the level rises).
F1_MARKS = [at(4, 1, 55)] + [at(4, 2, 15) + timedelta(minutes=15 * i)
for i in range(19)]
for k, kwh, rst, hw in (("f1_bright", 30.0, (), 700.0),
("f1_bright_rs", 30.0, (at(4, 3, 0),), 700.0),
("f1_dark", 1.0, (), 500.0)):
SCEN[k] = dict(start=at(4, 1, 30), hours=5.5, house_w=hw,
soc={"a": 50.0, "b": 50.0}, fresh_from=at(4, 2, 0),
fresh_until=at(4, 2, 0), fresh=(kwh, kwh),
restarts=list(rst), marks=F1_MARKS)
t0 = time.time()
with ProcessPoolExecutor(max_workers=min(6, os.cpu_count() or 2),
mp_context=get_context("fork")) as pool:
futs = {k: pool.submit(run_night, v) for k, v in SCEN.items()}
R = {k: f.result() for k, f in futs.items()}
print(f" {len(R)} scenarios in {time.time() - t0:.0f} s")
def show(k):
r = R[k]
print(f" {k:10} A {r['soc'][0]:.2f} % B {r['soc'][1]:.2f} % "
f"marks {', '.join(sorted(r['marks']))}")
for k in R:
show(k)
fa, si, rr = R["fresh"], R["silent"], R["restart"]
# a. silent from 17:05
# OPT-36: A's level is 95 % to ~05:00, then 78.4 % when B's cheap run is longer
# (B's give 5.9 -> 7.1 kWh); A, already at 81.5 %, stops there.
check("a fresh figure all night: A ends at 81.5 % (the plan's 78.4 % came "
"after it had passed), B full",
abs(fa["soc"][0] - 81.5) < 0.5 and fa["soc"][1] > 94.5)
check("sensors silent from 17:05: A ends where the fresh run ends it "
f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %)",
abs(si["soc"][0] - fa["soc"][0]) < 0.3)
check("... and so does B (Cheap hours; the wide no-forecast gap does not "
f"stop its slices): {si['soc'][1]:.1f} against {fa['soc'][1]:.1f} %",
abs(si["soc"][1] - fa["soc"][1]) < 0.3)
check("A charges through 03:00 without a break (no step at 03:00)",
si["soc_at"]["03:30"][0] > si["soc_at"]["03:00"][0] + 4.0
and si["soc_at"]["03:00"][0] > si["soc_at"]["02:55"][0] + 0.3)
m1, m4 = si["marks"]["01:00"], si["marks"]["04:30"]
check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
"line says so (EN)",
not m1["fc_none"] and m1["kept"] and not m4["fc_none"] and m4["kept"]
and m4["state"] == "plan"
and m4["line"] == "Solar forecast read 17:05, kept for tonight."
and m4["target_a"] is not None
and near(m4["target_a"], fa["marks"]["04:30"]["target_a"], 1e-9))
check("... and the level is the fresh run's (the kept figure counts as a "
"fresh one)", near(m4["target_a"], 95.0, 0.2))
m6 = si["marks"]["06:30"]
check("06:30: the kept figure has ended (the bell started) and the screen "
"does not say 'kept' (the charge was done by then in this shape)",
m6["fc_none"] and "kept" not in m6["line"])
# b. restarts
check("restarts at 01:00 and 04:00 (a new Controller on the saved store): "
f"A {rr['soc'][0]:.1f}, B {rr['soc'][1]:.1f} % as without them",
abs(rr["soc"][0] - si["soc"][0]) < 0.3
and abs(rr["soc"][1] - si["soc"][1]) < 0.3)
check("... with the figure kept right after each restart",
all(rr["marks"][k]["kept"] and not rr["marks"][k]["fc_none"]
and rr["marks"][k]["target_a"] is not None
for k in ("01:30", "04:30")))
# c. a charge that cannot start before 03:00
la, lf = R["late"], R["late_fresh"]
ml = la["marks"]["03:30"]
check("the cheap hours start at 04:00: nothing charges at 03:30",
ml["soc_a"] < 40.1 and not ml["charging"])
check("... the kept figure still stands at 03:30 (no charge had started, so "
"no level to latch): the plan is kept, not 'no forecast'",
not ml["fc_none"] and ml["kept"] and ml["target_a"] is not None)
check("... and the charge starts at 04:00 on it: A ends where a fresh "
f"figure ends it ({la['soc'][0]:.1f} against {lf['soc'][0]:.1f} %)",
lf["soc"][0] > 50.0 and abs(la["soc"][0] - lf["soc"][0]) < 0.3
and abs(la["soc"][1] - lf["soc"][1]) < 0.3)
# d. a fresh figure replaces the kept one at once
dk, kp = R["dark"], R["kept"]
check("a fresh, darker figure at 02:00 during the charge: the level goes "
"UP at once (02:05)",
kp["marks"]["02:05"]["target_a"] is not None
and dk["marks"]["02:05"]["target_a"] is not None
and dk["marks"]["02:05"]["target_a"] > kp["marks"]["02:05"]["target_a"]
+ 3.0 and not dk["marks"]["02:05"]["kept"]
and dk["marks"]["01:59"]["kept"])
check("... and A charges on to it (more than on the kept figure: "
f"{dk['soc'][0]:.1f} against {kp['soc'][0]:.1f} %)",
dk["soc"][0] > kp["soc"][0] + 3.0)
pk, pd, pb = R["pre_kept"], R["pre_dark"], R["pre_bright"]
t_k, t_d, t_b = (x["marks"]["02:44"]["target_a"] for x in (pk, pd, pb))
print(f" level before any charge, 02:44: kept {t_k}, dark {t_d}, "
f"bright {t_b}")
check("before a charge has started (nothing charges at 02:44): a darker "
"fresh figure raises the level and a brighter one lowers it, at once",
all(not x["marks"]["02:44"]["charging"] for x in (pk, pd, pb))
and t_k is not None and t_d is not None and t_b is not None
and t_d > t_k + 3.0 and t_b < t_k - 3.0
and not pd["marks"]["02:44"]["kept"]
and not pb["marks"]["02:44"]["kept"])
# e. the last hour, after the kept figure ended
tl = R["tail"]
t5, t6, t7 = (tl["marks"][k] for k in ("05:50", "06:10", "06:40"))
check("05:50: charging on the kept figure; 06:10: the figure has ended",
t5["kept"] and not t5["fc_none"] and t6["fc_none"])
check("06:10 and 06:40 (the second after a restart at 06:30): A still "
"charges to its level, 'charges from the grid' is the Why",
t6["p_a"] < -500.0 and t7["p_a"] < -500.0
and t6["why_a"] == "grid_charge" and t7["why_a"] == "grid_charge")
check("... and ends the hour higher than it was (+8 % or more from 06:00)",
tl["soc_at"]["06:55"][0] > tl["soc_at"]["06:00"][0] + 8.0)
lo = R["lowered"]
l0, l1, l2 = (lo["marks"][k] for k in ("03:05", "03:20", "06:30"))
check("a fresh, brighter figure lowers A's level below its SoC during the "
f"charge ({l0['target_a']} -> {l1['target_a']}, SoC "
f"{l1['soc_a']:.1f} %), and A stops",
l0["target_a"] is not None and l1["target_a"] is not None
and l1["target_a"] < l1["soc_a"] - 2.0
and l1["target_a"] < l0["target_a"] - 10.0
and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5)
check("the sensors go quiet, the forecast ends (06:30): the latch holds the "
"LOWERED level, so A does not charge back up to the old one "
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), "
"while B goes on",
l2["fc_none"] and l2["target_a"] is not None
and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0
and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0)
st_, sl_ = R["stale"], R["stale_live"]
check("F1: a latch from the night before (ended 07:15) loaded at a restart "
"inside a cheap run, with no forecast: nothing grid-charges "
f"(A {st_['soc'][0]:.1f}, B {st_['soc'][1]:.1f} %) and the line does "
"not say the charge goes on",
st_["soc"][0] < 10.2 and st_["soc"][1] < 10.2
and st_["marks"]["22:20"]["state"] == "no_forecast"
and not st_["latch"])
check("(control: the same latch, still valid until tomorrow 07:15, does "
f"charge both: A {sl_['soc'][0]:.1f}, B {sl_['soc'][1]:.1f} %)",
sl_["soc"][0] > 11.0 and sl_["soc"][1] > 11.0
and sl_["marks"]["22:20"]["state"] == "latched")
for k in ("f1_bright", "f1_bright_rs"):
fb = R[k]
mk = fb["marks"]
lo_t = mk["02:15"]["target_a"]
after = [mk[h] for h in sorted(mk) if h >= "02:15"]
print(f" {k}: A level 01:55 {mk['01:55']['target_a']} -> 02:15 "
f"{lo_t}; A {fb['soc_at']['02:15'][0]:.1f} % at 02:15, "
f"{fb['soc_at']['06:55'][0]:.1f} % at 06:55; lines "
+ " | ".join(sorted({x['line'] for x in after})))
check(f"F-1 {k}: a brighter fresh figure at 02:00 lowers A's level "
"below its SoC and A stops",
lo_t is not None and mk["01:55"]["target_a"] is not None
and lo_t < mk["01:55"]["target_a"] - 10.0
and lo_t < mk["02:15"]["soc_a"])
check(f"... after 02:00 the screen never says 'read 17:05' and from "
"05:15 there is no forecast, with the LOWERED level",
all("17:05" not in x["line"] and not (x["kept"] and
not x["fc_none"])
for x in after)
and all(mk[h]["fc_none"] and near(mk[h]["target_a"], lo_t, 0.5)
for h in mk if "05:15" <= h < "07:00"))
check(f"... so A stays at its lowered level to 07:00 "
f"({fb['soc_at']['02:30'][0]:.1f} -> "
f"{fb['soc_at']['06:55'][0]:.1f} %)",
fb["soc_at"]["06:55"][0] < fb["soc_at"]["02:30"][0] + 1.0)
fd = R["f1_dark"]
md = fd["marks"]
print(f" f1_dark: A level 01:55 {md['01:55']['target_a']} -> 02:15 "
f"{md['02:15']['target_a']}; A {fd['soc_at']['06:55'][0]:.1f} % "
"at 06:55")
# OPT-36: the level (82.3 % at 02:15) falls to 64.2 % by 04:00 as B's cheap run
# is made longer (B's expected give grows); A stops at its SoC of that moment
# (72.8 %) and the latch holds 64.2 % after the figure ends.
check("F-1 darker fresh figure at 02:00: the level rises, is held after "
"05:00 with no forecast (never 'read 17:05'), and A charges while it is "
"above its level",
md["02:15"]["target_a"] is not None
and md["01:55"]["target_a"] is not None
and md["02:15"]["target_a"] > md["01:55"]["target_a"] + 3.0
and all("17:05" not in md[h]["line"] for h in md if h >= "02:15")
and all(md[h]["fc_none"] for h in md if "05:15" <= h < "07:00")
and near(md["05:15"]["target_a"], 64.24, 0.2)
and near(fd["soc_at"]["06:55"][0], 72.8, 0.5))
tb = R["tail_bfull"]
check("B full: the run ends at 06:33 before A is at its level, so the next "
"slice is only A's, planned after the figure has ended with A's "
"latched level: A goes on charging (06:10, 06:45; "
f"{tb['soc_at']['06:00'][0]:.1f} to {tb['soc_at']['06:55'][0]:.1f} %)",
tb["marks"]["06:10"]["fc_none"] and tb["marks"]["06:10"]["p_a"] < -500.0
and tb["marks"]["06:45"]["p_a"] < -500.0
and tb["soc_at"]["06:55"][0] > tb["soc_at"]["06:00"][0] + 8.0)
check("the screens say what control does (EN): 'No solar forecast now. The "
"charge under way goes on to its planned level.'",
t6["state"] == "latched" and t7["state"] == "latched"
and t6["line"] == "No solar forecast now. The charge under way goes "
"on to its planned level.")
# --------------------------------------------------------------------------- #
print("\n3. The text (EN and NL)")
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
encoding="utf-8"))
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
encoding="utf-8"))
l_en, l_nl = (x["home_words"]["solar"]["latched"] for x in (en, nl))
print(f" EN {l_en!r}\n NL {l_nl!r}")
check("the new line is short and plain in both languages and the NL is not "
"the English", l_en != l_nl and len(l_en.split()) <= 24
and len(l_nl.split()) <= 24 and "{" not in l_en + l_nl)
check("'kept for tonight' is unchanged (EN and NL)",
en["home_words"]["solar"]["kept"]
== "Solar forecast read {at}, kept for tonight."
and nl["home_words"]["solar"]["kept"]
== "Zonneprognose van {at}, bewaard voor vannacht.")
# --------------------------------------------------------------------------- #
print("\n4. The latch alone: a battery that leaves the plan leaves the latch")
from types import SimpleNamespace # noqa: E402
from app import modetrade, priceruns # noqa: E402
CLOCK = [at(4, 1, 0)]
_dt0 = m.datetime
class _FixedDT(_dt0):
@classmethod
def now(cls, tz=None):
return CLOCK[0] if tz is None else CLOCK[0].astimezone(tz)
m.datetime = homeview.datetime = _FixedDT
_tmp = tempfile.mkdtemp(prefix="o34-")
lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0,
{"a": 40.0, "b": 40.0}),
rp._Store(), rp._Pub(), stop=asyncio.Event(),
peak_db_path=os.path.join(_tmp, "tariff.db"),
price_history_path=os.path.join(_tmp, "ph.json"))
n0 = at(4, 1, 0)
lruns = SimpleNamespace(cheap=priceruns.Run(at(3, 22, 0), at(4, 7, 0), 0.26))
charging = SimpleNamespace(action=modetrade.CHARGE,
roles={A: modetrade.CHARGE, B: modetrade.CHARGE})
resting = SimpleNamespace(action=None, roles={A: modetrade.HOLD,
B: modetrade.HOLD})
def latch_step(fc, tick, targets, now=n0, runs=None, packs=None):
CLOCK[0] = now
lc.solar_fc, lc.mode_tick = fc, tick
lc._latch_charge(now, runs or lruns, targets, packs)
return lc._charge_latch, lc.store.data.get(m.CHARGE_LATCH_KEY)
FC = object() # a forecast is read (fresh or kept)
lt, saved = latch_step(FC, charging, {A: 80.0, B: 70.0})
check("both batteries charge with a forecast: both are latched at their "
"levels, to the run's end + one slot, and saved",
lt is not None and lt[1] == {A: 80.0, B: 70.0}
and lt[0] == at(4, 7, 15) and saved is not None
and saved["levels"] == {A: 80.0, B: 70.0})
lt, saved = latch_step(FC, charging, {B: 70.0})
check("A leaves the plan (its mode or use changed): A is dropped from the "
"latch at once, B stays", lt is not None and lt[1] == {B: 70.0}
and saved["levels"] == {B: 70.0})
lc.solar_fc = None
check("... and with the forecast then gone, A has no latched level (it is "
"not charged back to 80 %)", lc.plan_targets() == {B: 70.0})
lt, saved = latch_step(FC, resting, {A: 80.0, B: 70.0})
check("A back in the plan while nothing charges: it does not rejoin",
lt is not None and lt[1] == {B: 70.0})
lt, saved = latch_step(FC, resting, {A: 80.0})
check("B leaves the plan too: the latch is empty, so it is gone and "
"cleared from the store", lt is None and saved is None
and lc.plan_targets() is None)
latch_step(FC, charging, {A: 80.0})
lc.solar_fc = None
ia = next((i for i in lc.inverters if i.name == A), None)
st_below = st_at = st_after = None
lc.mode_runs = lruns
if ia is not None:
ia.soc = 50.0
st_below = homeview.plan_state(lc)
CLOCK[0] = at(4, 7, 5) # the run has ended, the latch has not
st_after = homeview.plan_state(lc)
CLOCK[0] = n0
ia.soc = 80.0
st_at = homeview.plan_state(lc)
check("the screen says the charge goes on only while the latched battery is "
f"below its level ({st_below!r} at 50 %, {st_at!r} at 80 %)",
st_below == "latched" and st_at == "no_forecast")
check("F2: and only while a cheap run is on: at 07:05, the run over and the "
f"latch in its grace slot, it does not ({st_after!r})",
st_after == "no_forecast")
print("\n5. The latch ends: expiry, a changed mode, the night's bell")
long_run = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2))
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
lt, saved = latch_step(None, resting, None, now=at(4, 7, 20), runs=long_run)
check("F1: past its end (07:15) the latch is dropped even with a cheap run "
"on and no forecast (it was only dropped when no run held)",
lt is None and saved is None)
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
CLOCK[0] = at(4, 7, 20)
lc.solar_fc = None
check("... plan_targets() ignores a latch past its end", lc.plan_targets()
is None)
CLOCK[0] = at(4, 1, 0)
check("... and _load_latch() does not bring one back from the store",
m._load_latch({"until": at(4, 7, 15).isoformat(), "born":
at(4, 1, 0).isoformat(), "levels": {A: 80.0}},
at(4, 7, 20)) is None
and m._load_latch({"until": at(4, 7, 15).isoformat(), "born":
at(4, 1, 0).isoformat(), "levels": {A: 80.0}},
at(4, 6, 0)) is not None)
# F3: the mode or use of a latched battery changes
def packs_of(a_mode, a_use=True):
return [modetrade.Pack(A, a_use, a_mode, False, 2500.0, 50.0, 10.0),
modetrade.Pack(B, True, "cheap_hours", False, 2500.0, 50.0, 10.0)]
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
lt, _s = latch_step(None, resting, None, packs=packs_of("leftover_solar"))
check("F3: A is still Leftover solar and on: it stays latched",
lt is not None and lt[1] == {A: 80.0})
lt, saved = latch_step(None, resting, None, packs=packs_of("solar_first"))
check("F3: the user changes A to Solar first (no forecast): it leaves the "
"latch, which is then gone and cleared, so the narrow gap goes too",
lt is None and saved is None)
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
lt, _s = latch_step(None, resting, None,
packs=packs_of("leftover_solar", a_use=False))
check("F3: or switches A off", lt is None)
# F4: the night's bell
lc._charge_latch = None
lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0))
check("F4: the latch began at 01:00, so its night's bell is 06:00 the same "
"day", m._night_limit(lt[2]) == at(4, 6, 0)
and m._night_limit(at(3, 23, 0)) == at(4, 6, 0)
and m._night_limit(at(4, 6, 0)) == at(5, 6, 0))
r1 = SimpleNamespace(cheap=priceruns.Run(at(4, 0, 0), at(4, 7, 0), .2))
r2 = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2))
r3 = SimpleNamespace(cheap=priceruns.Run(at(4, 6, 0), at(4, 9, 0), .2))
lc._charge_latch = None
lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0), runs=r1)
check("F4: first run 00:00-07:00 sets the end at 07:15", lt[0] == at(4, 7, 15))
lt, _s = latch_step(None, resting, None, now=at(4, 6, 50), runs=r1)
lt2, _s = latch_step(None, resting, None, now=at(4, 7, 5), runs=r2)
check("F4: a back-to-back run that starts after the bell (07:00-23:00) does "
"NOT carry it on: still 07:15", lt2 is not None
and lt2[0] == at(4, 7, 15))
lt3, _s = latch_step(None, resting, None, now=at(4, 7, 20), runs=r2)
check("F4: and at 07:20 it has ended, though the run goes on", lt3 is None)
lc._charge_latch = (at(4, 6, 10), {A: 80.0}, at(4, 1, 0))
lt, _s = latch_step(None, resting, None, now=at(4, 6, 5), runs=r3)
check("F4: a run that starts AT the bell (06:00-09:00) does carry it on, "
"to its end + one slot", lt is not None and lt[0] == at(4, 9, 15))
m.datetime = homeview.datetime = _dt0
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")