Files
goodwe-addon/goodwe_controller/test_modes_p4.py
T
glenn schrooyenandClaude Opus 5.5 fbd884797d OPT-11 P4 review: Cheap hours ignores the plan; midnight-stale forecast; stricter ids
- Cheap hours only fills to the ceiling with or without a forecast, as the
  design (section 1) says. Skipping the sun's share cost 0.31 EUR on real
  2026 prices (29.72 against P3's 29.41) and saved nothing on the P3 set.
- A forecast reading from before local midnight is stale: until the
  integration refreshes, "left today" is still yesterday's 0 and
  "tomorrow" is really today.
- Forecast entity ids must be sensor.[a-z0-9_]+ (they go into the
  /api/states/<id> path).
- House profile: a stored day that is not a date, or is after today, is
  dropped instead of kept forever.
- Text: the forecast line says "for the rest of today"; the learning line
  no longer promises "tomorrow" (a plan needs one full day).
- Tests for four surviving review mutants (hour sample floor, the dear run
  after the cheap run, share by room, daytime use) and the fixes above.
- Replay tool: `--fc zero` (a forecast of no sun).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:08:47 +02:00

801 lines
38 KiB
Python

"""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)))
_read = solarfc.read
def _as_dark(today, tomorrow, now, **kw):
return _read({"state": "0", "attributes": {"unit_of_measurement":
"kWh"},
"last_updated": now.isoformat()}, None, 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", _as_dark),
lambda: setattr(solarfc, "read", _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 forecast for the rest of 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")