647 lines
30 KiB
Python
647 lines
30 KiB
Python
"""Runnable check for OPT-27. `python3 test_opt27.py`
|
|
|
|
Forecast.Solar's sensors stop changing in the evening, so at night every
|
|
Forecast.Solar site was "no forecast": a price mode's night cheap run needed
|
|
1.5x the cycle cost and Leftover solar bought nothing. And one missed poll
|
|
(`unavailable`) dropped the forecast at once. `solarfc.Hold` now keeps the last
|
|
fresh reading of each sensor, for the day it is FOR:
|
|
|
|
* up to `FRESH_S` when the sensor is missing or unavailable (a short outage);
|
|
* from the end of the bell (20:00) until local midnight + 3 h, if the reading
|
|
is at most `KEPT_MAX_AGE_S` (12 h) old when used (the night plan); never
|
|
beyond, never for another day.
|
|
|
|
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
|
|
|
|
1. `solarfc` alone: the evening, the outage, the bounds, the day a figure is
|
|
for, restart (dump/load), and no hold = as before.
|
|
2. The real Controller on a day/night tariff (0.34/0.26, gap 0.08, cycle cost
|
|
0.07), forecast sensors that stop updating at 17:05 local:
|
|
a. the 22:00 night run is planned with the cycle-cost gap and charges;
|
|
b. no forecast all day: the night run still needs 1.5x and does not charge;
|
|
c. an evening restart keeps it (a new Controller on the saved store);
|
|
d. a one-hour outage at 18:47 does not drop it;
|
|
e. past midnight + 3 h the figure is not used, before it it is.
|
|
3. The screens: the forecast line says when it was read and that it is kept
|
|
for tonight (EN and NL), and Home agrees with control.
|
|
|
|
Mutations (MUTATION.md, purged copies, graded over this file): see the
|
|
DECISIONS note and the dev report.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
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"))
|
|
logging.disable(logging.WARNING)
|
|
|
|
import opt11p3_replay as rp # noqa: E402
|
|
from named_absence import first, nth, present # noqa: E402
|
|
import app.main as m # noqa: E402
|
|
from app import homeview, modetrade, 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"
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("1. solarfc: the hold")
|
|
D1_READ = at(3, 17, 5) # the last fresh reading, 17:05
|
|
EVE = at(3, 22, 0)
|
|
|
|
|
|
def rs(today, tomorrow, now, hold):
|
|
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
|
|
|
|
|
|
h = solarfc.Hold()
|
|
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 17, 10), h)
|
|
check("a fresh reading is a forecast, not kept", fc is not None
|
|
and not fc.kept and fc.tomorrow.kwh == 12.0 and fc.today.kwh == 0.4)
|
|
check("it is remembered (the hold is flagged for saving)", h.changed)
|
|
fc_no, why_no, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, None)
|
|
check("with no hold, 22:00 is no forecast (as before)",
|
|
fc_no is None and why_no == solarfc.STALE)
|
|
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, h)
|
|
print(f" 22:00, sensors still show 17:05: {fc and fc.kept=}, {gaps=}")
|
|
check("22:00 with the sensors stale since 17:05: the 17:05 figures are used",
|
|
fc is not None and fc.kept and fc.tomorrow.kwh == 12.0
|
|
and fc.at == D1_READ and gaps == ())
|
|
check("... today has no sun left in it, tomorrow's is counted from 06:00",
|
|
fc is not None and fc.between(EVE, at(4, 7, 0)) is not None
|
|
and 0.0 < fc.between(EVE, at(4, 7, 0)) < 0.5
|
|
and fc.today.kwh == 0.4)
|
|
fc2, _w, _g = rs(GONE, GONE, EVE, h)
|
|
check("the same when the sensors are unavailable at 22:00",
|
|
fc2 is not None and fc2.kept and fc2.tomorrow.kwh == 12.0)
|
|
# A new fresh reading replaces the old one at once.
|
|
fc3, _w, _g = rs(st(0.0, at(3, 22, 20)), st(14.5, at(3, 22, 20)),
|
|
at(3, 22, 30), h)
|
|
check("a newer fresh reading is used and is not 'kept'",
|
|
fc3 is not None and not fc3.kept and fc3.tomorrow.kwh == 14.5)
|
|
fc3b, _w, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 23, 0), h)
|
|
check("... and later the newest reading is the one kept, not the old one",
|
|
fc3b is not None and fc3b.tomorrow.kwh == 14.5)
|
|
|
|
# A short outage: unavailable for one or two polls, any time of day.
|
|
h2 = solarfc.Hold()
|
|
rs(st(1.1, at(3, 18, 5)), st(12.0, at(3, 18, 5)), at(3, 18, 6), h2)
|
|
for label, now in (("18:47", at(3, 18, 47)), ("19:20", at(3, 19, 20)),
|
|
("19:47", at(3, 19, 47)), ("20:55", at(3, 20, 55))):
|
|
fo, _w, _g = rs(GONE, GONE, now, h2)
|
|
check(f"unavailable at {label}: the 18:05 forecast is still used",
|
|
fo is not None and fo.tomorrow.kwh == 12.0)
|
|
fo, _w, _g = rs(GONE, GONE, at(3, 20, 55), solarfc.Hold())
|
|
check("(nothing was ever read: still no forecast)", fo is None)
|
|
# Daytime is as before: an outage longer than FRESH_S drops it.
|
|
h3 = solarfc.Hold()
|
|
rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h3)
|
|
d_in = rs(GONE, GONE, at(4, 11, 50), h3)[0]
|
|
d_out = rs(GONE, GONE, at(4, 12, 10), h3)[0]
|
|
d_out2 = rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 14, 0), h3)[0]
|
|
check("daytime: an outage under 3 h is covered, one over 3 h is not",
|
|
d_in is not None and not d_in.kept and d_out is None)
|
|
check("daytime: a sensor that stopped is stale after 3 h, as before "
|
|
"(the hold does not start before the bell ends)", d_out2 is None)
|
|
|
|
# Round 2: today fresh, tomorrow's sensor stopped or down in the daytime.
|
|
h9 = solarfc.Hold()
|
|
rs(st(5.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h9)
|
|
for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)):
|
|
fd = rs(st(3.0, at(4, 13, 55)), tm, at(4, 14, 0), h9)[0]
|
|
check(f"14:00, today fresh, tomorrow {label} since 09:05 (over 3 h): "
|
|
"no tomorrow, nothing kept",
|
|
fd is not None and fd.tomorrow is None and not fd.kept)
|
|
fn = rs(st(0.1, at(4, 20, 55)), tm, at(4, 21, 0), h9)[0]
|
|
check(f"21:00, tomorrow {label}: the 09:05 figure covers it, kept",
|
|
fn is not None and fn.tomorrow is not None
|
|
and fn.tomorrow.kwh == 12.0 and fn.kept)
|
|
fs = rs(st(3.0, at(4, 10, 55)), tm, at(4, 11, 0), h9)[0]
|
|
check(f"11:00, tomorrow {label} for under 3 h: still covered, not kept",
|
|
fs is not None and fs.tomorrow is not None and not fs.kept)
|
|
|
|
# The bound: midnight + 3 h, and the day a figure is for.
|
|
fm = rs(st(0.0, at(3, 23, 0)), st(0.0, at(3, 23, 0)), at(4, 1, 0), h2)[0]
|
|
check("00:30 is covered by the 18:05 tomorrow figure, as TODAY's",
|
|
rs(GONE, GONE, at(4, 0, 30), h2)[0] is not None)
|
|
fmid = rs(GONE, GONE, at(4, 2, 59), h2)[0]
|
|
print(f" 02:59: {fmid and (fmid.today.kwh, fmid.tomorrow, fmid.kept)}")
|
|
check("02:59: tomorrow's 12.0 kWh is now today's, there is no tomorrow",
|
|
fmid is not None and fmid.today.kwh == 12.0 and fmid.tomorrow is None
|
|
and fmid.kept and fmid.today.day0 == at(4, 0, 0))
|
|
check("... its sun is counted on the day it is for (06:00-20:00 on the 4th)",
|
|
fmid is not None and fmid.between(at(4, 3, 0), at(4, 7, 0)) is not None
|
|
and fmid.between(at(4, 3, 0), at(4, 7, 0)) > 0.0)
|
|
check("03:01 (midnight + 3 h): not used any more",
|
|
rs(GONE, GONE, at(4, 3, 1), h2)[0] is None)
|
|
check("... and not the next evening either (past midnight + 3 h)",
|
|
rs(GONE, GONE, at(4, 22, 0), h2)[0] is None)
|
|
fe = rs(st(0.1, at(4, 21, 50)), GONE, at(4, 22, 0), h2)[0]
|
|
check("... the next evening, with today fresh and tomorrow's sensor down, "
|
|
"there is no tomorrow: the 4th's figure is not the 5th's",
|
|
fe is not None and fe.today.kwh == 0.1 and fe.tomorrow is None)
|
|
# Never today's figure for tomorrow.
|
|
h4 = solarfc.Hold()
|
|
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h4)
|
|
# Next day 00:30: today's sensor is fresh again (4.0), tomorrow's is gone.
|
|
fx = rs(st(4.0, at(4, 0, 20)), GONE, at(4, 0, 30), h4)[0]
|
|
check("00:30: today is the fresh 4.0, and there is NO tomorrow "
|
|
"(the 3rd's 'today' 3.0 and 'tomorrow' 12.0 are not the 5th's)",
|
|
fx is not None and fx.today.kwh == 4.0 and fx.tomorrow is None
|
|
and not fx.kept)
|
|
fy = rs(st(4.0, at(4, 0, 20)), st(9.0, at(4, 0, 20)), at(4, 0, 30), h4)[0]
|
|
check("00:30 with both fresh: tomorrow is the fresh 9.0, not the old 12.0",
|
|
fy is not None and fy.tomorrow.kwh == 9.0 and not fy.kept)
|
|
# A tomorrow sensor that is down while today's is fresh, in the evening: the
|
|
# hold gives the tomorrow figure for tomorrow, never today's figure.
|
|
h5 = solarfc.Hold()
|
|
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h5)
|
|
fz = rs(st(0.2, at(3, 21, 30)), GONE, at(3, 21, 40), h5)[0]
|
|
check("21:40: today fresh, tomorrow down: tomorrow is the kept 12.0 (not "
|
|
"today's 0.2 or 3.0)", fz is not None and fz.tomorrow.kwh == 12.0
|
|
and fz.today.kwh == 0.2 and fz.kept)
|
|
# A set-up fault is never covered.
|
|
h6 = solarfc.Hold()
|
|
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h6)
|
|
fb = rs(st("lots", at(3, 21, 30)), st(12.0, at(3, 21, 30)),
|
|
at(3, 21, 40), h6)
|
|
fu = rs(st(0.2, at(3, 21, 30), unit="W"), st(12.0, at(3, 21, 30)),
|
|
at(3, 21, 40), h6)
|
|
check("a bad value or a wrong unit is not covered by the hold",
|
|
fb[0] is None and fb[1] == solarfc.BAD and fu[0] is None
|
|
and fu[1] == solarfc.UNIT)
|
|
# Several sensors: all or nothing.
|
|
h7 = solarfc.Hold()
|
|
two_t = [("sensor.t1", st(1.0, at(3, 17, 0))),
|
|
("sensor.t2", st(2.0, at(3, 17, 0)))]
|
|
two_m = [("sensor.m1", st(5.0, at(3, 17, 0))),
|
|
("sensor.m2", st(6.0, at(3, 17, 0)))]
|
|
solarfc.read_sum(two_t, two_m, at(3, 17, 5), h7)
|
|
gone2 = [("sensor.t1", GONE), ("sensor.t2", GONE)]
|
|
f2 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
|
|
at(3, 22, 0), h7)
|
|
check("three or two sensors: the kept figures are summed",
|
|
f2[0] is not None and f2[0].tomorrow.kwh == 11.0
|
|
and f2[0].today.kwh == 3.0 and f2[2] == ())
|
|
h8 = solarfc.Hold()
|
|
solarfc.read_sum(two_t[:1] + [("sensor.t2", GONE)], two_m, at(3, 17, 5), h8)
|
|
f3 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
|
|
at(3, 22, 0), h8)
|
|
check("one sensor never read fresh: no partial sum, the gap is named",
|
|
f3[0] is None and any(g[:3] == ("today", 2, "sensor.t2") for g in f3[2]))
|
|
# Restart: what the store holds comes back.
|
|
saved = json.loads(json.dumps(h.dump()))
|
|
hr = solarfc.Hold(saved)
|
|
fr = rs(GONE, GONE, at(3, 23, 0), hr)[0]
|
|
check("dump/load through JSON: the kept figures come back (40 min old: "
|
|
"inside FRESH_S, so not 'kept' in the screens' sense)",
|
|
fr is not None and not fr.kept and fr.tomorrow.kwh == 14.5
|
|
and fr.at == at(3, 22, 20))
|
|
junk = solarfc.Hold({"r": [["today", 7, "x", "y", "z", []],
|
|
["tomorrow", T_ENT, "2026-11-04T00:00:00+00:00",
|
|
-1, "2026-11-03T16:00:00+00:00", []],
|
|
"bad", None]})
|
|
check("a damaged store entry is dropped, not raised",
|
|
rs(GONE, GONE, at(3, 22, 0), junk)[0] is None
|
|
and solarfc.Hold(None).dump()["r"] == [] and solarfc.Hold(5).changed
|
|
is False)
|
|
hp = solarfc.Hold()
|
|
rs(st(8.0, at(3, 9, 0), ), st(12.0, at(3, 9, 0)), at(3, 9, 5), hp)
|
|
periods = [{"period_start": f"2026-11-04T{hh:02d}:00:00+01:00",
|
|
"pv_estimate": 1.0} for hh in range(8, 16)]
|
|
s_per = st(10.0, at(3, 17, 0))
|
|
s_per["attributes"]["detailedForecast"] = periods
|
|
rs(st(0.0, at(3, 17, 0)), s_per, at(3, 17, 5), hp)
|
|
hq = solarfc.Hold(json.loads(json.dumps(hp.dump())))
|
|
fq = rs(GONE, GONE, at(3, 22, 0), hq)[0]
|
|
check("an hourly split (Solcast shape) survives the store",
|
|
fq is not None and fq.tomorrow.periods
|
|
and abs(fq.between(at(4, 8, 0), at(4, 16, 0)) - 7.5) < 1e-6)
|
|
|
|
# Round 3. F1: a kept reading is at most KEPT_MAX_AGE_S old when used.
|
|
hz = solarfc.Hold()
|
|
rs(st(0.4, at(3, 0, 5)), st(12.0, at(3, 0, 5)), at(3, 0, 10), hz)
|
|
print(f" 00:05 reading, sensors dead: 19:59 "
|
|
f"{rs(GONE, GONE, at(3, 19, 59), hz)[0]}, 20:00 "
|
|
f"{rs(GONE, GONE, at(3, 20, 0), hz)[0]}")
|
|
check("F1: a 00:05 reading with the sensors dead all day is not kept at "
|
|
"20:00 (20 h old): no forecast",
|
|
rs(GONE, GONE, at(3, 20, 0), hz)[0] is None
|
|
and rs(GONE, GONE, at(3, 23, 0), hz)[0] is None)
|
|
check("... nor after midnight until 03:00 (as today's, 26 h old)",
|
|
rs(GONE, GONE, at(4, 0, 30), hz)[0] is None
|
|
and rs(GONE, GONE, at(4, 2, 30), hz)[0] is None)
|
|
hy = solarfc.Hold()
|
|
rs(st(0.4, at(3, 17, 5)), st(12.0, at(3, 17, 5)), at(3, 17, 10), hy)
|
|
check("F1: a 17:05 reading is kept at 22:00 (5 h) ...",
|
|
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(3, 22, 0), hy)[0]))
|
|
check("... and at 02:59 next day (9 h 54 min), the midnight + 3 h bound ...",
|
|
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 2, 59),
|
|
hy)[0]))
|
|
check("... and at 03:00 next day it is not (midnight + 3 h; 9 h 55 min old)",
|
|
rs(GONE, GONE, at(4, 3, 0), hy)[0] is None)
|
|
hm = solarfc.Hold()
|
|
rs(st(0.9, at(3, 9, 5)), st(12.0, at(3, 9, 5)), at(3, 9, 10), hm)
|
|
check("F1: a 09:05 reading is kept at 20:00 (10 h 55 min) but not at 22:00",
|
|
rs(GONE, GONE, at(3, 20, 0), hm)[0] is not None
|
|
and rs(GONE, GONE, at(3, 22, 0), hm)[0] is None)
|
|
|
|
# F2: the 25 h autumn day (2026-10-25) and the 23 h spring day (2027-03-28).
|
|
BRU = solarfc.BILLING_TZ # Europe/Brussels, or the encoded EU rule
|
|
|
|
|
|
def bru(y, mo, d, hh, mm=0):
|
|
return datetime(y, mo, d, hh, mm, tzinfo=BRU).astimezone(timezone.utc)
|
|
|
|
|
|
for (y, mo, d), label in (((2026, 10, 25), "25 h autumn day 2026-10-25"),
|
|
((2027, 3, 28), "23 h spring day 2027-03-28")):
|
|
hd = solarfc.Hold()
|
|
t17 = bru(y, mo, d, 17, 5)
|
|
rs(st(0.4, t17), st(12.0, t17), t17 + timedelta(minutes=5), hd)
|
|
# real hours from 20:00 local to the next local midnight, each hour
|
|
start = bru(y, mo, d, 20, 0)
|
|
end = bru(y, mo, d + 1, 0)
|
|
t, miss = start, []
|
|
while t < end:
|
|
if rs(GONE, GONE, t, hd)[0] is None:
|
|
miss.append(t.astimezone(BRU).strftime("%H:%M"))
|
|
t += timedelta(minutes=30)
|
|
print(f" {label}: not kept at {miss}")
|
|
check(f"F2: {label}: the 17:05 figure is kept every half hour "
|
|
"from 20:00 to local midnight", not miss)
|
|
check(f"... and just before the next local midnight",
|
|
rs(GONE, GONE, bru(y, mo, d + 1, 0) - timedelta(minutes=1),
|
|
hd)[0] is not None)
|
|
|
|
# F5: old days are dropped from the stored hold, so it does not grow.
|
|
hp5 = solarfc.Hold()
|
|
sizes = []
|
|
for dd in range(3, 13):
|
|
t0_ = at(dd, 17, 5)
|
|
rs(st(0.4, t0_), st(12.0, t0_), t0_ + timedelta(minutes=5), hp5)
|
|
sizes.append(len(hp5.dump()["r"]))
|
|
day0s = {row[2] for row in hp5.dump()["r"]}
|
|
print(f" F5: stored entries per day over 10 days: {sizes}")
|
|
check("F5: the stored hold does not grow day after day (at most today's and "
|
|
"tomorrow's entry, with yesterday's tomorrow)", max(sizes) <= 3
|
|
and len(day0s) <= 2)
|
|
check("... and a day before today is gone",
|
|
all(row[2] >= at(12, 0, 0).isoformat() for row in hp5.dump()["r"]))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n2. The real Controller: day/night 0.34 / 0.26, cycle cost 0.07")
|
|
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}]}')
|
|
CHOICE = {"a": (True, "leftover_solar", False),
|
|
"b": (True, "cheap_hours", False)}
|
|
DAY3 = 3
|
|
|
|
|
|
def opts(cost=0.07):
|
|
o = dict(rp.options(True, False, 2))
|
|
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE,
|
|
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT)
|
|
return o
|
|
|
|
|
|
def stops_at(last, *, down=()):
|
|
"""Sensors that update until `last` and then show that reading for
|
|
good. `down`: (from, to) windows in which they are unavailable."""
|
|
def f(now, ent):
|
|
for a, z in down:
|
|
if a <= now < z:
|
|
return GONE
|
|
when = min(now, last)
|
|
return st(0.4 if ent == T_ENT else 12.0, when)
|
|
return f
|
|
|
|
|
|
def never(now, ent):
|
|
return st(0.0 if ent == T_ENT else 12.0, now - timedelta(hours=9))
|
|
|
|
|
|
def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
|
|
keep_store=True, snap=False):
|
|
"""-> (controller, rows, pre_rows). `pre`: the times the forecast is read
|
|
before the first cycle (the evening). `restart`: a time in `pre` at which
|
|
the add-on restarts: a new Controller on the saved store."""
|
|
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
|
|
clock = [pre[0] if pre else start]
|
|
rows, pre_rows = [], []
|
|
cur = [store]
|
|
|
|
class H(rp.Hass):
|
|
async def state(self, e):
|
|
return sensors(clock[0], e)
|
|
|
|
def build(store):
|
|
hass = H(("a", "b"), lambda t: 300.0, {"a": 40.0, "b": 40.0})
|
|
tmp = tempfile.mkdtemp(prefix="o27-")
|
|
c = Controller(dict(o), hass, store, 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
|
|
# A house profile a few days old (300 W round the clock), so the day
|
|
# plan is made as it is on a site that has run for a week.
|
|
c.house_profile.load({d: {str(hh): 300.0 for hh in range(24)}
|
|
for d in ("2026-10-31", "2026-11-01",
|
|
"2026-11-02")})
|
|
return c, hass
|
|
|
|
async def go():
|
|
c, hass = build(store)
|
|
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
|
|
task = None
|
|
try:
|
|
for t_pre in pre:
|
|
clock[0] = t_pre
|
|
if restart is not None and t_pre == restart:
|
|
# The saved file, through JSON; `keep_store=False` loses
|
|
# the hold only (the control for the test).
|
|
data = json.loads(json.dumps(cur[0].data))
|
|
if not keep_store:
|
|
data.pop(m.FC_HOLD_KEY, None)
|
|
new = rp._Store()
|
|
new.data.update(data)
|
|
cur[0] = new
|
|
c, hass = build(new)
|
|
await c._read_forecast()
|
|
pre_rows.append((t_pre, c.solar_fc is not None,
|
|
bool(c.solar_fc and c.solar_fc.kept)))
|
|
clock[0] = start
|
|
task = asyncio.create_task(c.run_tariff_refresh())
|
|
t = 0.0
|
|
while t < minutes * 60:
|
|
now = start + timedelta(seconds=t)
|
|
hass.t = t
|
|
clock[0] = now
|
|
if t == 0.0:
|
|
for _ in range(500):
|
|
if c.tariff_plan is not None and (
|
|
c.tariff_at is not None and c.tariff_at >= now):
|
|
break
|
|
await asyncio.sleep(0)
|
|
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)
|
|
rp_ = c.mode_runs
|
|
rows.append(dict(
|
|
t=t, fc_none=c.solar_fc is None,
|
|
kept=bool(c.solar_fc and c.solar_fc.kept),
|
|
runs_on=None if rp_ is None else rp_.on,
|
|
reason=None if rp_ is None else rp_.reason,
|
|
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
|
charging=hass.plants["b"].p < -200.0,
|
|
soc_b=hass.plants["b"].soc))
|
|
t += 5.0
|
|
keep = c.o
|
|
c.o = dict(keep, arbitrage_enabled=True)
|
|
try:
|
|
c.mirror, c.mirror_why = homeview.trade_verdict(
|
|
c, c.fresh_tariff_plan(clock[0]), clock[0])
|
|
finally:
|
|
c.o = keep
|
|
c.cards = homeview.battery_rows(c, False, True)
|
|
c.block = homeview.solar_block(c, clock[0])
|
|
c.hold_saved = json.loads(json.dumps(cur[0].data.get(
|
|
m.FC_HOLD_KEY)))
|
|
finally:
|
|
if task is not None:
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
m.datetime, homeview.datetime = real_dt, real_hv
|
|
return c
|
|
|
|
return asyncio.run(go()), rows, pre_rows
|
|
|
|
|
|
def why_of(c, name):
|
|
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
|
|
return card["why_key"]
|
|
|
|
|
|
EVENING = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
|
|
LAST = at(DAY3, 17, 5)
|
|
NIGHT_START = at(DAY3, 22, 0)
|
|
|
|
# a. a forecast that stops updating at sunset
|
|
ca, ra, pa = drive27(opts(), stops_at(LAST), pre=EVENING + [at(DAY3, 21, 55)],
|
|
start=NIGHT_START)
|
|
la = nth(ra, -1, "last cycle")
|
|
print(f" a. 22:00 with the 17:05 forecast: kept={la['kept']} on="
|
|
f"{la['runs_on']} reason={la['reason']}; B charging "
|
|
f"{sum(r['charging'] for r in ra)} of {len(ra)}; Why A {why_of(ca, A)} "
|
|
f"B {why_of(ca, B)}; Home mirror {ca.mirror}")
|
|
check("the forecast was read fresh before 17:05 and kept after it",
|
|
any(not k for _t, ok, k in pa if _t <= LAST + timedelta(minutes=30))
|
|
and all(ok for _t, ok, _k in pa)
|
|
and all(k for _t, _ok, k in pa if _t >= at(DAY3, 20, 15)))
|
|
check("22:00 night run: planned with the cycle-cost gap (needs 0.07 < 0.08)",
|
|
present(la) and not la["fc_none"] and la["kept"]
|
|
and la["runs_on"] is True and la["reason"] != "not_worth")
|
|
check("... and B (Cheap hours) charges from the grid in the night period",
|
|
sum(r["charging"] for r in ra) > len(ra) // 2
|
|
and la["roles"].get(B) == modetrade.CHARGE
|
|
and why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread")
|
|
check("the day plan is made from the kept forecast (levels for Leftover "
|
|
"solar), as it is from a fresh one",
|
|
ca.day_plan is not None and ca.plan_targets() is not None
|
|
and ca.block["state"] == "plan")
|
|
check("Home's mirror of the gate allows it, as control does",
|
|
ca.mirror is not None and ca.mirror_why == "allowed")
|
|
|
|
# b. no forecast all day
|
|
cb, rb, pb = drive27(opts(), never, pre=EVENING + [at(DAY3, 21, 55)],
|
|
start=NIGHT_START)
|
|
lb = nth(rb, -1, "last cycle")
|
|
print(f" b. no forecast all day: on={lb['runs_on']} reason={lb['reason']}; "
|
|
f"B charging {sum(r['charging'] for r in rb)}; Why A {why_of(cb, A)} "
|
|
f"B {why_of(cb, B)}")
|
|
check("with no forecast all day the night run still needs 1.5x (0.105 > 0.08)"
|
|
": not worth, no charge, 'Prices differ too little'",
|
|
present(lb) and lb["fc_none"] and lb["runs_on"] is False
|
|
and lb["reason"] == "not_worth"
|
|
and not any(r["charging"] for r in rb)
|
|
and why_of(cb, A) == "no_spread" and why_of(cb, B) == "no_spread"
|
|
and not any(ok for _t, ok, _k in pb))
|
|
check("... and there is no day plan (Leftover solar takes solar only)",
|
|
cb.day_plan is None and cb.plan_targets() is None
|
|
and cb.block["state"] == "no_forecast")
|
|
check("... Home's mirror refuses it too, for the gap ('not_worth')",
|
|
cb.mirror is None and cb.mirror_why == "not_worth")
|
|
check("... and nothing is saved for the night (no fresh reading was made)",
|
|
cb.hold_saved is None or cb.hold_saved.get("r") == [])
|
|
|
|
# c. an evening restart
|
|
cc, rc, pc = drive27(opts(), stops_at(LAST),
|
|
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
|
|
restart=at(DAY3, 21, 55))
|
|
lc = nth(rc, -1, "last cycle")
|
|
check("an evening restart (new Controller on the saved store) keeps the "
|
|
"forecast: the night run is planned and charges",
|
|
present(lc) and lc["kept"] and lc["runs_on"] is True
|
|
and sum(r["charging"] for r in rc) > len(rc) // 2
|
|
and present(nth(pc, -1, "last read")) and pc[-1][1] and pc[-1][2])
|
|
cc0, rc0, pc0 = drive27(opts(), stops_at(LAST),
|
|
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
|
|
restart=at(DAY3, 21, 55), keep_store=False)
|
|
lc0 = nth(rc0, -1, "last cycle")
|
|
check("(control: the same restart with the store lost is blind, 1.5x)",
|
|
present(lc0) and lc0["fc_none"] and lc0["runs_on"] is False
|
|
and present(nth(pc0, -1, "last read")) and not pc0[-1][1])
|
|
|
|
# d. a one-hour outage at 18:47
|
|
OUT = (at(DAY3, 18, 47), at(DAY3, 19, 47))
|
|
quarter = [at(DAY3, 16, 0) + timedelta(minutes=15 * i) for i in range(24)]
|
|
cd, rd, pd = drive27(opts(), stops_at(at(DAY3, 18, 5), down=[OUT]),
|
|
pre=quarter + [at(DAY3, 21, 55)], start=NIGHT_START)
|
|
ld = nth(rd, -1, "last cycle")
|
|
inside = [(t_, ok) for t_, ok, _k in pd if OUT[0] <= t_ < OUT[1]]
|
|
print(f" d. reads inside the outage: {len(inside)}, with a forecast: "
|
|
f"{sum(ok for _t, ok in inside)}")
|
|
check("a one-hour outage at 18:47 does not drop the forecast "
|
|
"(every read from 16:00 to 22:00 has one)",
|
|
present(pd) and len(inside) >= 4 and all(ok for _t, ok, _k in pd))
|
|
check("... and the 22:00 night run is planned and charges",
|
|
present(ld) and ld["runs_on"] is True
|
|
and sum(r["charging"] for r in rd) > len(rd) // 2)
|
|
|
|
# e. the bound: midnight + 3 h
|
|
mid = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
|
|
ce, re_, pe = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 2, 30)],
|
|
start=at(4, 2, 30))
|
|
le = nth(re_, -1, "last cycle")
|
|
check("02:30 (before midnight + 3 h): the figure from 17:05 is still used, "
|
|
"as today's; the night run is planned",
|
|
present(le) and le["kept"] and le["runs_on"] is True)
|
|
cf, rf, pf = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 3, 30)],
|
|
start=at(4, 3, 30))
|
|
lf = nth(rf, -1, "last cycle")
|
|
check("03:30 (after midnight + 3 h): a figure from before the night is not "
|
|
"used: no forecast, 1.5x, not worth, no charge",
|
|
present(lf) and lf["fc_none"] and lf["runs_on"] is False
|
|
and lf["reason"] == "not_worth" and not any(r["charging"] for r in rf))
|
|
|
|
# g. OPT-28's shortfall sizing on a kept figure: Leftover solar (A) is sized
|
|
# from the kept forecast exactly as from a fresh one, not "no forecast"
|
|
def fresh_all(now, ent):
|
|
return st(0.4 if ent == T_ENT else 12.0, now)
|
|
|
|
|
|
cg, rg, pg = drive27(opts(), fresh_all, pre=EVENING + [at(DAY3, 21, 55)],
|
|
start=NIGHT_START)
|
|
kp, fp_ = ca.day_plan, cg.day_plan
|
|
print(f" g. kept : short={kp and kp.short_kwh} uncovered="
|
|
f"{kp and kp.uncovered_kwh} targets={kp and kp.targets} "
|
|
f"need={kp and kp.need_kwh} sun={kp and kp.sun_kwh}")
|
|
print(f" fresh : short={fp_ and fp_.short_kwh} uncovered="
|
|
f"{fp_ and fp_.uncovered_kwh} targets={fp_ and fp_.targets} "
|
|
f"need={fp_ and fp_.need_kwh} sun={fp_ and fp_.sun_kwh}")
|
|
check("a kept figure sizes the night by OPT-28's shortfall, with a level for "
|
|
"Leftover solar (A): not 'no forecast', which has no plan at all",
|
|
kp is not None and A in kp.targets and kp.short_kwh > 0.0
|
|
and cb.day_plan is None and not cg.solar_fc.kept)
|
|
check("... and the level, the grid need, the shortfall and the sun counted "
|
|
"are those a fresh figure gives (counted once, from the right time)",
|
|
fp_ is not None and kp.targets == fp_.targets
|
|
and kp.need_kwh == fp_.need_kwh and kp.share_kwh == fp_.share_kwh
|
|
and abs(kp.short_kwh - fp_.short_kwh) < 1e-9
|
|
and abs(kp.uncovered_kwh - fp_.uncovered_kwh) < 1e-9
|
|
and abs(kp.sun_kwh - fp_.sun_kwh) < 1e-9)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n3. The screens")
|
|
blk = ca.block
|
|
print(f" forecast line: {blk['line']!r} (state {blk['state']})")
|
|
check("the forecast line says when it was read and that it is kept for "
|
|
"tonight", blk["line"] == "Solar forecast read 17:05, kept for tonight.")
|
|
check("the same line comes from the Forecast tab (dayview) on today",
|
|
homeview.line_solar(0.4, 0.3, "17:05", kept=True) == blk["line"]
|
|
and homeview.line_solar(0.4, 0.3, "17:05").startswith(
|
|
"Solar still to come today"))
|
|
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"))
|
|
k_en = en["home_words"]["solar"]["kept"]
|
|
k_nl = nl["home_words"]["solar"]["kept"]
|
|
print(f" EN {k_en!r}\n NL {k_nl!r}")
|
|
check("EN and NL both say it, with the time, and the NL is not the English",
|
|
"{at}" in k_en and "{at}" in k_nl and k_en != k_nl
|
|
and "tonight" in k_en and "vannacht" in k_nl)
|
|
check("a daytime reading keeps the old line (daytime unchanged)",
|
|
"Updated 09:05" in homeview.line_solar(1.0, 0.7, "09:05")
|
|
and "Bijgewerkt" in json.dumps(nl["home_words"]["solar"]["line"]))
|
|
check("no forecast: the old 'No solar forecast now' line, no 'kept'",
|
|
cb.block["state"] == "no_forecast"
|
|
and "tonight" not in cb.block["line"])
|
|
check("control and Batteries screen agree on the 22:00 run: not 'no_spread'",
|
|
why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread"
|
|
and la["runs_on"] is True)
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED:")
|
|
for f in fails:
|
|
print(" -", f)
|
|
sys.exit(1)
|
|
print("all checks passed")
|