Files
goodwe-addon/goodwe_controller/test_opt34.py
T
glenn schrooyenandClaude eee7616022
checks / unit (pull_request) Successful in 18m37s
infra-pin / fresh (pull_request) Successful in 1s
OPT-34 round 2: the latch follows the plan, up or down, and drops what left it
While a forecast is present and the cheap run holds, the night-charge latch is
rebuilt from the plan: a latched battery takes its current level whatever its
role this tick (a fresh figure may lower it below the SoC), one that left the
plan is dropped, a new one comes in only while it charges from the grid.
Test: a fresh brighter figure lowers A below its SoC, the sensors go quiet, A
does not charge back up. CHANGELOG summary says two fixes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-31 19:17:20 +01:00

576 lines
27 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.
"""
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))
# 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)
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
return st(0.4 if ent == T_ENT 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})
house_w = 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
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 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)]),
# 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)]),
}
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
check("a fresh figure all night: A ends at its planned level (87 %), B "
"full", abs(fa["soc"][0] - 86.9) < 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"], 86.87, 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)
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()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")