- 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>
801 lines
38 KiB
Python
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")
|