"""Runnable check for OPT-11 P4, the solar forecast and the day plan. `python3 test_modes_p4.py` Drives the pure modules (solarfc, dayplan, modetrade) and the REAL Controller (tools/opt11p3_replay.py's engine, with a forecast sensor in the fake Home Assistant), in simulated time. DECISIONS/OPT-11-battery-modes.md has the record. 1. No usable forecast (none set, or set and unavailable): every command, trade and role is byte-identical to release/1.0 5a4cdc2 (digests measured there), and the harness sees a change there. 2. The forecast sensors: the three supported shapes, units, stale, missing. 3. The house profile: hours, days kept, the night floor, ready. 4. The day plan: sunny, partly, dark; Solar first never gets a level; a Leftover solar level is the shortfall; hysteresis. 5. modetrade with a plan: a fuzz against the invariants, and no plan = P3. 6. The real controller over a night and a day: sunny (no grid charge), dark (Leftover solar buys the shortfall only, and stops there), Cheap hours skips the part the sun covers, a forecast that goes away mid-run, and every P1-P3 and SAFETY-79 invariant on every cycle. 7. The Batteries screen and the Simple view: the forecast line and Why. 8. Options: validation of the trust and the entities. Mutations (MUTATION.md, purged copies) are listed in the pull request. """ import asyncio import logging import os import random import sys from datetime import datetime, timedelta, timezone HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) sys.path.insert(0, os.path.join(HERE, "tools")) logging.disable(logging.WARNING) from named_absence import nth, present # noqa: E402 import opt11p3_replay as rp # noqa: E402 from app import arbitrage, dayplan, homeview, modetrade, packmode # noqa from app import priceruns, solarfc, validate # noqa: E402 from app.tariff import BILLING_TZ # noqa: E402 fails = [] def check(name, cond): print(f" {'ok ' if cond else 'FAIL'} {name}") if not cond: fails.append(name) L = timezone(timedelta(hours=2)) A, B = "inverter-a", "inverter-b" D0 = datetime(2026, 9, 21, tzinfo=L) UTC0 = D0.astimezone(timezone.utc) # ============================================================================ # print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)") # Printed by tools/opt11p4_identity.py on release/1.0 5a4cdc2 (2026-09-29), # goodwe-addon-checks:1. 1080 cycles each, battery modes on. BASE_DIGESTS = { "n1_ls": "13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea", "n1_ch": "ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490", "n2_sf_ls": "9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22", "n2_ch_ls": "63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b", "n2_ls_ls": "98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265", "n2_sf_ch": "95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2", "n1_ls_fc_down": "13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea", "n1_ch_fc_down": "ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490", "n2_sf_ls_fc_down": "9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22", "n2_ch_ls_fc_down": "63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b", "n2_ls_ls_fc_down": "98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265", "n2_sf_ch_fc_down": "95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2", } import opt11p4_identity as ident # noqa: E402 got = ident.digests() for k, v in BASE_DIGESTS.items(): check(f"{k}: same digest as 5a4cdc2", got.get(k) == v) check("every scenario is pinned", set(got) == set(BASE_DIGESTS)) for k in [k for k in got if k.endswith("_fc_down")]: check(f"{k}: a set but unavailable forecast equals no forecast", got[k] == got[k[:-len('_fc_down')]]) def _moved(scn, patch, undo): patch() try: return ident.p3.digest(ident.run(**ident.SCENARIOS[scn])[0]) \ != BASE_DIGESTS[scn] finally: undo() _gcm = modetrade.GRID_CHARGE_MODES check("the harness bites: Leftover solar grid-charging with no plan moves " "n2_sf_ls", _moved("n2_sf_ls", lambda: setattr(modetrade, "GRID_CHARGE_MODES", (packmode.LEFTOVER_SOLAR, packmode.CHEAP_HOURS)), lambda: setattr(modetrade, "GRID_CHARGE_MODES", _gcm))) # OPT-17: the controller reads through `read_sum()`, so that is the seam. _read = solarfc.read_sum def _as_dark(today, tomorrow, now, **kw): return _read([("sensor.f", {"state": "0", "attributes": { "unit_of_measurement": "kWh"}, "last_updated": now.isoformat()})], [], now) check("...and an unavailable forecast read as a dark one moves " "n2_sf_ls_fc_down (Leftover solar would buy its shortfall)", _moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read_sum", _as_dark), lambda: setattr(solarfc, "read_sum", _read))) # ============================================================================ # print("2. the forecast sensors") NOW = datetime(2026, 9, 21, 9, 0, tzinfo=L).astimezone(timezone.utc) def st(state, unit="kWh", at=NOW, **attrs): a = dict(attrs) if unit is not None: a["unit_of_measurement"] = unit return {"state": state, "attributes": a, "last_updated": at.isoformat(), "last_reported": at.isoformat()} fc, why = solarfc.read(st("12.5"), None, NOW) check("Forecast.Solar shape (state only, kWh): read", fc is not None and abs(fc.today.kwh - 12.5) < 1e-9 and fc.today.periods == ()) check("...the rest of the day adds up to the state", abs(fc.between(NOW, UTC0 + timedelta(days=1)) - 12.5) < 1e-6) check("...and nothing comes after sunset (20:00 local)", fc.between(datetime(2026, 9, 21, 20, tzinfo=L), UTC0 + timedelta(days=1)) < 1e-9) fc2, _ = solarfc.read(st("12500", unit="Wh"), None, NOW) fc3, _ = solarfc.read(st("0.0125", unit="MWh"), None, NOW) check("Wh and MWh are converted, never guessed", abs(fc2.today.kwh - 12.5) < 1e-9 and abs(fc3.today.kwh - 12.5) < 1e-9) check("a sensor with no unit, or in W, is refused (unit)", solarfc.read(st("12.5", unit=None), None, NOW) == (None, solarfc.UNIT) and solarfc.read(st("12.5", unit="W"), None, NOW)[1] == solarfc.UNIT) check("unavailable, unknown and a missing entity are no forecast", solarfc.read(st("unavailable"), None, NOW)[1] == solarfc.MISSING and solarfc.read(st("unknown"), None, NOW)[1] == solarfc.MISSING and solarfc.read(None, None, NOW)[1] == solarfc.MISSING) check("a negative or non-numeric state is no forecast", solarfc.read(st("-1"), None, NOW)[1] == solarfc.BAD and solarfc.read(st("lots"), None, NOW)[1] == solarfc.BAD) old = NOW - timedelta(seconds=solarfc.FRESH_S + 1) check("older than 3 h is stale, and stale is no forecast", solarfc.read(st("12.5", at=old), None, NOW) == (None, solarfc.STALE) and solarfc.read(st("12.5", at=NOW - timedelta(hours=2, minutes=59)), None, NOW)[0] is not None) check("a report from the future is stale too", solarfc.read(st("12.5", at=NOW + timedelta(hours=1)), None, NOW)[1] == solarfc.STALE) check("not configured says so", solarfc.read(None, None, NOW, configured=False)[1] == solarfc.NOT_SET) # Solcast: detailedForecast, kW per 30 min, the whole day. sol = [{"period_start": (UTC0 + timedelta(minutes=30 * i)).isoformat(), "pv_estimate": 2.0 if 20 <= i < 36 else 0.0} for i in range(48)] fc4, _ = solarfc.read(st("8.0", detailedForecast=sol), None, NOW) # 10:00-18:00 local at 2 kW = 16 kWh in all; from 09:00 all 16 is to come, # scaled to the state's 8. check("Solcast shape: periods read and scaled to the state (kWh left)", fc4.today.periods and abs(fc4.between(NOW, UTC0 + timedelta(days=1)) - 8.0) < 1e-6) check("...the split is used: 10:00-12:00 is a quarter of the day's sun", abs(fc4.between(datetime(2026, 9, 21, 10, tzinfo=L), datetime(2026, 9, 21, 12, tzinfo=L)) - 2.0) < 1e-6) # Open-Meteo: wh_period, Wh per hour. wh = {(UTC0 + timedelta(hours=h)).isoformat(): (1000.0 if 8 <= h < 16 else 0.0) for h in range(24)} fc5, _ = solarfc.read(st("7.0", wh_period=wh), None, NOW) check("Open-Meteo shape: wh_period read (8 x 1 kWh, 7 left at 09:00)", abs(fc5.between(NOW, UTC0 + timedelta(days=1)) - 7.0) < 1e-6 and abs(fc5.between(datetime(2026, 9, 21, 14, tzinfo=L), datetime(2026, 9, 21, 16, tzinfo=L)) - 2.0) < 1e-6) TOM = UTC0 + timedelta(days=1) check("without tomorrow's sensor, a span into tomorrow's sun is unknown", fc.between(NOW, TOM + timedelta(hours=9)) is None and fc.between(NOW, TOM + timedelta(hours=5)) is not None) fct, _ = solarfc.read(st("12.5"), st("20.0"), NOW) check("with it, tomorrow's sun is counted", abs(fct.between(TOM, TOM + timedelta(days=1)) - 20.0) < 1e-6) fcs, _ = solarfc.read(st("12.5"), st("20.0", at=old), NOW) check("a stale tomorrow is dropped, today is kept", fcs is not None and fcs.tomorrow is None) # Review (PR #156): just after local midnight the integration may not have # refreshed yet, so "left today" is still yesterday's 0 and "tomorrow" is # really today. That is stale, not a dark day. MID = datetime(2026, 9, 22, 0, 10, tzinfo=L).astimezone(timezone.utc) EVE = datetime(2026, 9, 21, 21, 30, tzinfo=L).astimezone(timezone.utc) check("a today reading from before local midnight is stale (not 0 kWh)", solarfc.read(st("0.0", at=EVE), st("15", at=EVE), MID) == (None, solarfc.STALE)) fcm, _ = solarfc.read(st("15", at=MID - timedelta(minutes=2)), st("18", at=EVE), MID) check("...and a tomorrow reading from before midnight is dropped", fcm is not None and fcm.tomorrow is None) # ============================================================================ # print("3. the house profile") def profile(day_w=None, days=1, evening=None, start=UTC0 - timedelta(days=1)): hp = dayplan.HouseProfile() t = start end = start + timedelta(days=days) while t < end: h = t.astimezone(L).hour w = (evening if evening is not None and 17 <= h < 22 else day_w if day_w is not None else 600.0) hp.add(t, w) t += timedelta(seconds=5) hp.add(t, None) return hp hp = profile(day_w=600.0, evening=3000.0) check("a full day recorded is ready, with 24 hours", hp.ready() and sum(len(r) for r in hp.days.values()) == 24) check("an hour's figure is its mean", abs(hp.hour_w(18) - 3000.0) < 1e-6 and abs(hp.hour_w(3) - 600.0) < 1e-6) hp_half = dayplan.HouseProfile() for i in range(12 * 60 * 10): hp_half.add(UTC0 + timedelta(seconds=5 * i), 500.0) check("ten hours are not ready (MIN_HOURS)", not hp_half.ready()) neg = profile(day_w=-2000.0) f, base = neg.estimator() check("a sunny net use (negative) is lifted to the night floor for the " "solar sum", f(12) == -2000.0 and base == -2000.0 or base >= 0.0) hp9 = profile(days=9) check("only the last 7 days are kept", len(hp9.days) <= 8) hp_rt = dayplan.HouseProfile(hp.dump()) check("dump and load give the same figures", hp_rt.hour_w(18) == round( hp.hour_w(18), 1) and hp_rt.ready()) bogus = dayplan.HouseProfile({"2099-01-01": {str(h): 9e5 for h in range(24)}, "zz": {str(h): 9e5 for h in range(24)}}) check("a stored day that is not a date is dropped on load", "zz" not in bogus.days) bogus.add(UTC0, 500.0) bogus.add(UTC0 + timedelta(hours=1), 500.0) check("a stored day after today (a wrong clock) is pruned, not kept forever", "2099-01-01" not in bogus.days) check("a bad stored profile is ignored, not fatal", not dayplan.HouseProfile({"x": "y", "2026-09-20": {"99": 1, "a": "b"}}).ready()) # ============================================================================ # print("4. the day plan") P = modetrade.Pack def pk(name, mode, soc, cap=10.0, **kw): return P(name, True, mode, False, 2500.0, soc, cap, **kw) # Prices: cheap 01:00-03:00, dear 19:00-20:00 local (as in test_modes_p3). DAYW = [0.20] * 96 * 2 for i in list(range(4, 12)) + list(range(100, 108)): DAYW[i] = 0.02 for i in list(range(76, 80)) + list(range(172, 176)): DAYW[i] = 0.40 WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(DAYW)] from app.price import PricePoint # noqa: E402 def runs_at(now): pts = [PricePoint(start=s, eur_per_kwh=rp.all_in(p), provisional=False) for s, _n, p in WPTS] return priceruns.plan(pts, now, required_eur_kwh=0.30, charge_kwh=6.0, charge_kw=2.5, sell_kwh=0.0, sell_kw=0.0) T0 = datetime(2026, 9, 21, 0, 30, tzinfo=L).astimezone(timezone.utc) R0 = runs_at(T0) check("the price plan has a cheap run at night and a dear one at 19:00", R0.on and R0.cheap.holds(datetime(2026, 9, 21, 1, 30, tzinfo=L)) and any(g.start.astimezone(L).hour == 19 for g in R0.give)) def fc_of(kwh, now=T0): wh = {(UTC0 + timedelta(hours=h)).isoformat(): (kwh * 1000.0 / 10 if 8 <= h < 18 else 0.0) for h in range(24)} return solarfc.read(st(f"{kwh}", wh_period=wh, at=now), None, now)[0] EST = profile(day_w=600.0, evening=3000.0).estimator() PACKS = [pk(A, packmode.SOLAR_FIRST, 60.0), pk(B, packmode.LEFTOVER_SOLAR, 30.0)] def dplan(kwh, packs=PACKS, trust=70, est=EST): return dayplan.plan(fc_of(kwh), est, R0, T0, packs, trust_pct=trust, floor_pct=20.0, ceiling_pct=95.0) sunny, partly, dark = dplan(40.0), dplan(12.0), dplan(1.0) print(f" sunny {sunny.verdict} {sunny.targets}; partly {partly.verdict} " f"{partly.targets}; dark {dark.verdict} {dark.targets}") check("40 kWh forecast: sunny, and B needs nothing from the grid", sunny.verdict == dayplan.SUNNY and sunny.need_kwh[B] == 0.0) check("1 kWh forecast: dark (not even A fills from the sun)", dark.verdict == dayplan.DARK) check("Solar first never gets a level (never charges from the grid)", all(A not in p.targets for p in (sunny, partly, dark))) _use = 3000.0 / 1000.0 # the 19:00-20:00 run at 3 kW _want = 20.0 + _use / dayplan.EFF / 10.0 * 100.0 check("dark: B's level is what the house draws in the dear run (floor + " f"use / eff = {_want:.1f} %), not the 95 % ceiling", abs(dark.targets[B] - _want) < 0.1 and dark.targets[B] < 95.0) check("...so it buys only the shortfall", abs(dark.need_kwh[B] - ( _want - 30.0) / 10.0) < 0.01) check("the level falls as the forecast rises (partly between dark and " "sunny)", sunny.targets[B] <= partly.targets[B] <= dark.targets[B]) ch = dplan(12.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0), pk(B, packmode.CHEAP_HOURS, 30.0)]) ch_dark = dplan(1.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0), pk(B, packmode.CHEAP_HOURS, 30.0)]) check("...and on a partly sunny day too: Cheap hours ignores the plan " "(design section 1; review of PR #156)", B not in ch.targets and B not in ch_dark.targets and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, 30.0), 95.0, {B: 40.0}) == 95.0) # Review of PR #156: four mutants that survived. _hi = {h: (3000.0 if 17 <= h < 22 else 2000.0 if 8 <= h < 17 else 600.0) for h in range(24)} check("a house that uses more by day leaves less sun: a higher level", dplan(20.0, est=(lambda h: _hi[h], 600.0)).targets[B] > dplan(20.0).targets[B] + 1.0) _two = dplan(40.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0), pk(B, packmode.LEFTOVER_SOLAR, 30.0), pk("inverter-c", packmode.LEFTOVER_SOLAR, 80.0)]) check("two Leftover solar batteries share the sun by their room (65:15)", abs(_two.share_kwh[B] / _two.share_kwh["inverter-c"] - 6.5 / 1.5) < 0.01) _h = lambda hh: datetime(2026, 9, 21, hh, tzinfo=L).astimezone(timezone.utc) _rp = priceruns.RunPlan(priceruns.Run(_h(13), _h(14), 0.02), (priceruns.Run(_h(8), _h(9), 0.4), priceruns.Run(_h(19), _h(20), 0.4)), None, 0.3, None, True) _pl = dayplan.plan(fc_of(12.0), EST, _rp, T0, PACKS, trust_pct=70, floor_pct=20.0, ceiling_pct=95.0) check("the plan is for the dear run after the cheap run, not an earlier one", _pl is not None and _pl.until == _h(19)) _hp = dayplan.HouseProfile() for i in range(30): _hp.add(_h(10) + timedelta(seconds=5 * i), 500.0) _hp.add(_h(11), 500.0) check("an hour with under 5 minutes of readings is not kept", not any(10 in r for r in _hp.days.values())) check("trust 100 % counts more sun than 70 %: a lower level", dplan(12.0, trust=100).targets[B] < partly.targets[B]) check("...and the sun counted scales with the trust exactly", abs(dplan(12.0, trust=100).sun_kwh * 0.7 - partly.sun_kwh) < 0.01) _room_a = (95.0 - 60.0) / 10.0 * 10.0 / 10.0 # A: 60 % -> 95 % of 10 kWh check("Solar first takes the sun first: B's share is what is left after " f"A's {_room_a:.1f} kWh of room", abs(partly.share_kwh[B] - max(0.0, partly.sun_kwh - _room_a)) < 0.01 and partly.share_kwh[B] < partly.sun_kwh) _flat = (lambda h: 600.0, 600.0) _sunny_net = (lambda h: -2000.0 if 9 <= h < 17 else 600.0, 600.0) check("the house's net use in sunny hours (negative) is lifted to the night " "floor: the same sun as a flat 600 W house", abs(dplan(12.0, est=_flat).sun_kwh - dplan(12.0, est=_sunny_net) .sun_kwh) < 1e-6) check("no forecast, no house profile or no price plan: no plan", dayplan.plan(None, EST, R0, T0, PACKS, trust_pct=70, floor_pct=20, ceiling_pct=95) is None and dplan(12.0, est=None) is None and dayplan.plan(fc_of(12.0), EST, None, T0, PACKS, trust_pct=70, floor_pct=20, ceiling_pct=95) is None) # A plan for a run beyond today's forecast (after 20:00, cheap run tomorrow # night, give run tomorrow evening) with no tomorrow sensor: no plan. T_EVE = datetime(2026, 9, 21, 21, 0, tzinfo=L).astimezone(timezone.utc) check("the sun before the dear run is unknown (no tomorrow sensor): no plan", dayplan.plan(fc_of(12.0, T_EVE), EST, runs_at(T_EVE), T_EVE, PACKS, trust_pct=70, floor_pct=20, ceiling_pct=95) is None) off = [pk(A, packmode.SOLAR_FIRST, 60.0), P(B, False, packmode.LEFTOVER_SOLAR, False, 2500.0, 30.0, 10.0)] check("an Off battery gets no level", B not in dplan(1.0, packs=off).targets) maint = [pk(A, packmode.SOLAR_FIRST, 60.0), pk(B, packmode.LEFTOVER_SOLAR, 30.0, idle=False)] check("a battery in maintenance gets no level", B not in dplan(1.0, packs=maint).targets) # Hysteresis. cap = {B: 10.0} check("hysteresis: a 0.3 kWh move on a 40 % level is ignored", dayplan.hold({B: 40.0}, {B: 43.0}, cap) == {B: 40.0}) check("...a 1 kWh move is taken", dayplan.hold({B: 40.0}, {B: 50.0}, cap) == {B: 50.0}) check("...10 % of a small level is enough (0.3 kWh on a 25 % level)", dayplan.hold({B: 25.0}, {B: 28.0}, cap) == {B: 28.0}) check("...a battery new to the plan takes its level, one gone is dropped", dayplan.hold({A: 50.0}, {B: 33.0}, cap) == {B: 33.0}) _flips = 0 _kept = None rnd = random.Random(4) for i in range(400): lvl = 40.0 + rnd.uniform(-2.0, 2.0) # a noisy forecast new = dayplan.hold(_kept, {B: lvl}, cap) if _kept is not None and new != _kept: _flips += 1 _kept = new check(f"a forecast wobbling by +-0.2 kWh moves the level rarely " f"({_flips} of 400)", _flips <= 8) # ============================================================================ # print("5. modetrade with a plan") def p3_chargers(packs, ceiling, done=()): """P3 option A's rule, as 5a4cdc2 wrote it.""" return [p for p in packs if p.use and p.idle and p.live and p.mode in packmode.GRID_CHARGE_MODES and p.soc is not None and p.soc < ceiling and p.refused >= 0 and p.name not in done] rnd = random.Random(11) bad = [] same = 0 for i in range(20000): packs = [P(n, rnd.random() > 0.1, rnd.choice(packmode.MODES), False, 2500.0, rnd.choice([None, rnd.uniform(0, 100)]), rnd.choice([None, 10.0, 5.0]), live=rnd.random() > 0.05, idle=rnd.random() > 0.1, refused=rnd.choice([0, 0, 0, 1, -1])) for n in (A, B, "c")] ceiling = rnd.choice([90.0, 95.0, 100.0]) if [p.name for p in modetrade.chargers(packs, ceiling)] == \ [p.name for p in p3_chargers(packs, ceiling)]: same += 1 targets = {p.name: rnd.uniform(0, 120) for p in packs if rnd.random() > 0.3} ch = modetrade.chargers(packs, ceiling, targets=targets) for p in ch: to = modetrade.charge_to(p, ceiling, targets) if p.mode == packmode.SOLAR_FIRST or to is None or to > ceiling \ or p.soc >= to or p.refused < 0 or not (p.use and p.idle and p.live): bad.append((p, targets, ceiling)) if p.mode == packmode.LEFTOVER_SOLAR and p.name not in targets: bad.append(("LS without a level", p)) check("no plan: chargers() is P3 option A's rule on 20 000 random rosters", same == 20000) check("with a plan: never Solar first, never above the ceiling or the " "level, never refusing, Off, not live or in maintenance, and Leftover " f"solar only with a level ({len(bad)} bad)", not bad) # decide() with a plan, in the cheap run: every range holds 0, only chargers # charge, SF never charges. now_c = R0.cheap.start + timedelta(minutes=5) bad = [] for i in range(5000): packs = [P(n, rnd.random() > 0.1, rnd.choice(packmode.MODES), False, 2500.0, rnd.uniform(0, 100), 10.0, live=rnd.random() > 0.05, idle=rnd.random() > 0.1, refused=rnd.choice([0, 0, 1, -1])) for n in (A, B)] targets = {p.name: rnd.uniform(0, 100) for p in packs} t = modetrade.decide(R0, now_c, packs, charge_ok=True, sell_ok=True, trade_why=None, price_now=0.1, headroom_w=3000.0, floor_pct=20.0, ceiling_pct=95.0, house_w=500.0, targets=targets) for p in packs: lo, hi = t.ranges.get(p.name, (0.0, 0.0)) if not lo <= 0.0 <= hi: bad.append(("range without 0", p, lo, hi)) if t.roles[p.name] == modetrade.CHARGE and ( p.mode == packmode.SOLAR_FIRST or p.soc >= (95.0 if p.mode == packmode.CHEAP_HOURS else min(95.0, targets[p.name]))): bad.append(("charges past its level", p, targets)) check(f"decide() with a plan on 5000 random cases: ranges hold 0, nothing " f"charges at or past its level, Solar first never ({len(bad)} bad)", not bad) t_ls = modetrade.decide(R0, now_c, [pk(A, packmode.SOLAR_FIRST, 50.0), pk(B, packmode.LEFTOVER_SOLAR, 30.0)], charge_ok=True, sell_ok=True, trade_why=None, price_now=0.1, headroom_w=3000.0, floor_pct=20.0, ceiling_pct=95.0, house_w=500.0, targets={B: 50.0}) check("a Leftover solar battery below its level charges in the cheap run, " "Solar first rests", t_ls.roles == {A: modetrade.REST, B: modetrade.CHARGE}) e = modetrade.energy([pk(B, packmode.LEFTOVER_SOLAR, 30.0)], 20.0, 95.0, targets={B: 45.0}) check("the cheap run is sized by the shortfall (1.5 kWh), not the room to " "95 % (6.5 kWh)", abs(e[0] - 1.5) < 1e-9) # ============================================================================ # print("6. the real controller over a night and a day") AIM = rp.PEAK_CAP - 50 FC_KWH = [None] # today's forecast in the fake sensor (None: down) async def _sensor(self, e): now = rp_start[0] + timedelta(seconds=self.t) kwh = FC_KWH[0](now) if callable(FC_KWH[0]) else FC_KWH[0] if kwh is None: return {"state": "unavailable", "attributes": {}, "last_updated": now.isoformat()} if e.endswith("tomorrow"): return None loc = now.astimezone(L) d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0) wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat(): (kwh * 100.0 if 8 <= h < 18 else 0.0) for h in range(24)} left = sum(v for k, v in wh.items() if datetime.fromisoformat(k) >= now) / 1000.0 return {"state": f"{left:.3f}", "attributes": {"unit_of_measurement": "kWh", "wh_period": wh}, "last_updated": now.isoformat(), "last_reported": now.isoformat()} rp.Hass.state = _sensor rp_start = [UTC0] FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining", "cycle_cost_threshold_eur": 0.20} def seed_profile(c): if c.house_profile is not None: c.house_profile = profile(day_w=600.0, evening=3000.0) def house_flat(w): return [(UTC0.timestamp() - 10, float(w))] def run_day(choice, *, fc, soc0, hours=21.0, start_h=0.75, house_w=800.0, refuse=(), extra=None, patch=seed_profile): FC_KWH[0] = fc start = UTC0 + timedelta(hours=start_h) rp_start[0] = start rows = [] def cb(c, h, now, pr): sh = dict(c.allocation.shares) if c.allocation is not None and \ c.allocation.shares else {} rows.append(dict( now=now, grid=h.grid(), shares=sh, action=getattr(c.arb, "action", None), roles=dict(c.mode_tick.roles) if c.mode_tick else {}, soc={u: p.soc for u, p in h.plants.items()}, targets=c.plan_targets(), plan=c.day_plan, runs=c.mode_runs)) st_, c = rp.run("p3_ls", house_flat(house_w), WPTS, start, 3600 * hours, check=cb, soc0=soc0, choice=choice, refuse=refuse, extra=dict(FC_OPTS, **(extra or {})), patch=patch) return rows, st_, c SF_LS = {"a": (True, "solar_first", False), "b": (True, "leftover_solar", False)} SF_CH = {"a": (True, "solar_first", False), "b": (True, "cheap_hours", False)} def invariants(tag, rows, c): charge = [r for r in rows if r["action"] == arbitrage.CHARGE] mixed = [r for r in rows if any(v > 0 for v in r["shares"].values()) and any(v < 0 for v in r["shares"].values())] check(f"{tag}: no opposite-direction split on any cycle", not mixed) check(f"{tag}: Solar first never charges from the grid (A's share >= 0 " "while the site charges)", all(r["shares"].get(A, 0) >= 0 for r in charge)) q = {int(r["now"].timestamp() // 900) for r in charge} banked = [b.offtake_avg_w for b in c.p1.blocks if int(b.start.timestamp() // 900) in q] check(f"{tag}: every banked quarter with a grid charge <= cap - margin " f"(max {max(banked, default=0):.1f} W, aim {AIM})", not banked or max(banked) <= AIM + 0.5) return charge # Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing. rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0}) charge = invariants("sunny", rows, c) _last = nth(rows, -1, "a cycle") print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, " f"{len(charge)} charge cycles") check("sunny: a day plan is made, and says sunny", any(r["plan"] is not None and r["plan"].verdict == dayplan.SUNNY for r in rows)) check("sunny: no grid charge at all", not charge) # Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run. rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0}) charge = invariants("dark", rows, c) tgt = nth([r["targets"][B] for r in rows if r["targets"]], 0, "a level") top = max(r["soc"]["b"] for r in rows) print(f" dark: B level {tgt:.1f} %, B reached {top:.1f} %, " f"{len(charge)} charge cycles") check("dark: Leftover solar charges from the grid in the cheap run", charge and all(r["runs"].cheap.holds(r["now"]) for r in charge) and all(r["roles"][B] == modetrade.CHARGE for r in charge)) check("dark: ...only up to its level (the shortfall), not 95 %", present(tgt) and tgt < 60.0 and top <= tgt + 2.0) check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST for r in charge)) # Cheap hours on a partly sunny day: ignores the plan, fills as in P3. rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0}) charge = invariants("cheap hours, partly", rows, c) top = max(r["soc"]["b"] for r in rows) print(f" cheap hours partly: B reached {top:.1f} %") check("Cheap hours gets no planned level", not any(r["targets"] and B in r["targets"] for r in rows)) rows_nf, _s, c_nf = run_day(SF_CH, fc=None, soc0={"a": 60.0, "b": 30.0}) check("Cheap hours with a sun forecast charges as with none (P3)", charge and abs(max(r["soc"]["b"] for r in rows_nf) - top) < 1.0 and top > 80.0) # The forecast goes away mid-run: Leftover solar stops at once (P3 option A). DROP = UTC0 + timedelta(hours=1, minutes=30) rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None, soc0={"a": 60.0, "b": 10.0}) invariants("forecast lost mid-run", rows, c) before = [r for r in rows if r["now"] < DROP and r["action"] == arbitrage.CHARGE] late = [r for r in rows if r["now"] > DROP + timedelta( seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE] check("forecast lost mid-run: Leftover solar was charging, and stops within " "a forecast read (60 s) and a cycle", before and not late) # The forecast is far too high (40 kWh, and there is no sun at all): no grid # charge, so B covers less of the evening - a money risk, never a safety one. rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0}) invariants("forecast too high", rows, c) check("forecast too high: no grid charge, and B still keeps its power for " "the dear run", not [r for r in rows if r["action"] == arbitrage.CHARGE] and all(r["roles"].get(B) != modetrade.GIVE for r in rows if r["now"].astimezone(L).hour not in (19,))) # A refusal wins over the plan. rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0}, refuse=("b",)) invariants("refusal", rows, c) check("refusal: B refuses to charge, so the plan's charge ends and B is " "never a charger after it", rows and not [ r for r in rows[len(rows) // 3:] if r["roles"].get(B) == modetrade.CHARGE]) # Off wins over the plan. rows, st_, c = run_day({"a": (True, "solar_first", False), "b": (False, "leftover_solar", False)}, fc=1.0, soc0={"a": 60.0, "b": 30.0}) check("Off: an Off battery never charges from the grid, whatever the plan", not [r for r in rows if r["action"] == arbitrage.CHARGE] and all(abs(r["shares"].get(B, 0)) < 1e-6 for r in rows)) # No house profile yet (first day): no plan, P3 option A. rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0}, patch=None) check("no house profile yet: no plan, and Leftover solar takes solar only", all(r["plan"] is None for r in rows) and not [r for r in rows if r["action"] == arbitrage.CHARGE]) check("...while the profile learns and the store keeps each closed hour", c.house_profile is not None and len( c.store.data.get("house_profile") or {}) >= 1) check("selling stays off (option A)", packmode.SELLING_ENABLED is False) # A forecast arriving mid-period makes the plan at once, not at the next # 15-minute period. COME = datetime(2026, 9, 21, 1, 3, tzinfo=L).astimezone(timezone.utc) rows, st_, c = run_day(SF_LS, fc=lambda now: None if now < COME else 1.0, soc0={"a": 60.0, "b": 30.0}, hours=0.5) first = next((r["now"] for r in rows if r["targets"]), None) check("a forecast that arrives at 01:03 makes the plan before 01:15", first is not None and first < COME + timedelta(minutes=3)) # Battery modes off: the forecast is never read. READS = [0] _real_sensor = rp.Hass.state async def _counted(self, e): READS[0] += 1 return await _real_sensor(self, e) rp.Hass.state = _counted FC_KWH[0] = 1.0 rp.run("modes_off", house_flat(800.0), WPTS, UTC0 + timedelta(hours=1), 600.0, extra=dict(FC_OPTS)) rp.Hass.state = _real_sensor check("battery modes off: the forecast sensor is never read", READS[0] == 0) # ============================================================================ # print("7. the Batteries screen and the Simple view") rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0}, hours=0.6) rp.m.datetime = datetime sb = homeview.solar_block(c, c.solar_fc.at) check("the forecast line names the forecast, what is counted and when", sb["line"].startswith("Solar still to come today:") and "we count on" in sb["line"] and sb["state"] == "plan") why = homeview.day_key(c, B) check("B's Why key (plan, dark) is tops_up", why == "tops_up") check("...and says so in plain words", homeview.why_words(why) == "Too little sun is forecast, so it charges some from the grid.") _d0, _d1 = homeview.local_day(c.solar_fc.at) _blocks, _ = homeview.mode_runs(c, _d0, _d1, c.solar_fc.at, False) _grid = [b for b in _blocks if b["kind"] == "grid" and b["battery"] == 1] _need = homeview.plan_need(c, B) check(f"Today: B's grid block is the planned shortfall ({_need:.2f} kWh), " "not its room to 95 %", _grid and abs(_grid[0]["kwh"] - _need) < 0.01 and _need < 3.0) _run = c.mode_runs.cheap _packs = c._mode_packs() _m1 = c._run_mark("charge", _run, _packs, lambda p: True, ((B, 40.0),)) _m2 = c._run_mark("charge", _run, _packs, lambda p: False, ((B, 50.0),)) check("a level the hysteresis moved opens the run to that battery again", _m1 and not _m2) c.solar_fc = None check("the levels are dropped as soon as the forecast goes, even before " "the plan is rebuilt", c._plan_targets and c.plan_targets() is None) check("a lost forecast says so: no forecast, Leftover solar solar only", homeview.solar_block(c, datetime.now(timezone.utc))["state"] == "no_forecast" and homeview.day_key(c, B) == "no_forecast") check("the Why line wins over 'saves its power' only while holding", homeview.battery_why(modes_on=True, mode="leftover_solar", off=False, reading=True, phase="idle", shadow=False, running=True, held=False, refused=0, peak=False, sf_ahead=True, role="hold", has_at=True, day="sun_fills") == "sun_fills" and homeview.battery_why(modes_on=True, mode="leftover_solar", off=False, reading=True, phase="idle", shadow=False, running=True, held=False, refused=0, peak=False, sf_ahead=True, role="charge", has_at=True, day="tops_up") == "grid_charge") for lang in ("en", "nl"): homeview._LANG[0] = lang t = [homeview.why_words(k) for k in ("sun_fills", "tops_up", "no_forecast")] t += [homeview._t("solar.no_forecast"), homeview._t("solar.learning"), homeview.line_solar(9.2, 6.4, "10:15")] check(f"{lang}: every P4 sentence is there and short (<= 2 x 12 words)", all(t) and all(len(s.split()) <= 24 for s in t)) homeview._LANG[0] = "en" from app import uilayout # noqa: E402 simple = [f.key for _g, _p, f in uilayout.fields() if f.simple] check("the Simple view has the solar forecast card", "pv_forecast_entity" in simple) # ============================================================================ # print("8. options") BASE_OPTS = {"setpoint_entity": "number.x", "max_w": 2500} for v, ok in ((70, True), (30, True), (100, True), (29, False), (101, False), (70.5, False), ("x", False)): probs = validate._forecast_problems(dict(solar_trust_pct=v)) check(f"solar_trust_pct {v!r}: {'accepted' if ok else 'refused'}", (not probs) == ok) check("a forecast entity that is not a sensor is refused, in plain words", validate._forecast_problems({"pv_forecast_entity": "number.x"}) and "must be a sensor" in validate._forecast_problems( {"pv_forecast_entity": "number.x"})[0]) check("an entity id with a path or odd characters is refused", all(validate._forecast_problems({"pv_forecast_entity": e}) for e in ("sensor.x/../config", "sensor.x?y=1", "sensor.X", "sensor.", " sensor.x", "sensor.a b")) and not validate._forecast_problems( {"pv_forecast_entity": "sensor.energy_production_today_remaining"})) check("empty entities are fine", not validate._forecast_problems( {"pv_forecast_entity": "", "pv_forecast_tomorrow_entity": ""})) notes = [] validate._entity_notes(notes, {"pv_forecast_entity": "sensor.f", "battery_modes_enabled": True}, {"sensor.f": {"unit": "W"}}) check("a forecast sensor not in kWh gets a warning", any("kWh" in n.text for n in notes)) notes = [] validate._entity_notes(notes, {"pv_forecast_entity": "sensor.f"}, {"sensor.f": {"unit": "kWh"}}) check("a forecast with battery modes off gets a note that it is unused", any("battery modes on" in n.text for n in notes)) print() if fails: print(f"FAILED: {len(fails)}") for f in fails: print(" -", f) sys.exit(1) print("all checks passed")