"""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, if the reading is at most `KEPT_MAX_AGE_S` (12 h) old when used (the night plan); after midnight the evening's tomorrow figure is today's until the bell starts (06:00, OPT-34, test_opt34.py); 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. from 06:00 (the bell) the figure is not used, before it it is (OPT-34). 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: the bell start (06:00), 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: still used (OPT-34: until the bell starts at 06:00)", rs(GONE, GONE, at(4, 3, 1), h2)[0] is not None and rs(GONE, GONE, at(4, 5, 59), h2)[0] is not None) check("06:00 (the bell starts): not used any more", rs(GONE, GONE, at(4, 6, 0), h2)[0] is None) check("... and not the next evening either (past 06:00)", 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) ...", (lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 2, 59), hy)[0])) check("... and at 03:00 and 05:59 next day (OPT-34: the bell starts at 06:00)", (lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 3, 0), hy)[0]) and (lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 5, 59), hy)[0])) check("... and at 06:00 it is not (12 h 55 min old, the bell has started)", rs(GONE, GONE, at(4, 6, 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: until the bell starts (06:00, OPT-34; it was 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, 5, 30)], start=at(4, 5, 30)) le = nth(re_, -1, "last cycle") check("05:30 (before the bell starts): 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, 6, 30)], start=at(4, 6, 30)) lf = nth(rf, -1, "last cycle") check("06:30 (the bell has started): 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")