- Tomorrow's grid block is drawn to where control stops charging. A cheap run is sized for all batteries together; a slower one is still below `charge_to()` when it ends and control plans the next cheap slots. The tab asks `modetrade.energy()` and `priceruns.plan()` with the packs at the curve's level and extends the run, so the curve charges as long. - Each battery's block ends where it is at its own level (grid) or at the floor (give). The curve puts a point where a battery reaches its charge level inside a step. - `homeview.mode_runs(ahead=f, rp=...)` reads each block at the level the curve has when it starts, with Home's own filter and MIN_KWH: no give block at the floor, no grid block at the planned level. Home never passes either. - `no_tomorrow` names the hour from `solarfc.BELL_FROM_H`, where the curve stops. - test_ux54.py part 7 compares control (`_mode_roles()` every 15 minutes) with the tab with no tolerance, and adds the cloudy / floor case. No decision, command, timing or option key changes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
971 lines
44 KiB
Python
971 lines
44 KiB
Python
"""Runnable check for UX-54. `python3 test_ux54.py`
|
|
|
|
The Forecast tab: today's and tomorrow's solar, the house's expected use and
|
|
each battery's expected level, per hour. It reads the add-on and decides
|
|
nothing. Real `Controller`s in known situations (tools/ux54_scenarios.py).
|
|
|
|
1. The hourly shape: what each forecast integration gives, and that the tab
|
|
spreads a state-only total over the bell and says so.
|
|
2. The blocks are the plan's: a block is on the tab exactly where Home has
|
|
one, with its words; tomorrow's come from the same builders; the gate
|
|
(UX-53), modes off and every mode are followed.
|
|
3. The level curve (`daycurve.curve()`): inside floor and ceiling, the right
|
|
way in a charge and a give block, from spare solar and from a shortfall,
|
|
and a fuzz against the invariants.
|
|
4. A part that is not known yet is a note: modes off, no forecast, a lost
|
|
forecast, learning, no tomorrow, a monthly cycle, no level, stopped,
|
|
shadow.
|
|
5. Measured solar (`SolarToday`), and that keeping it can never cost a cycle.
|
|
6. The words, English and Dutch, and the page and its route. Reading it
|
|
asks Home Assistant for nothing and moves nothing in control.
|
|
|
|
Mutations (MUTATION.md, purged copies): listed in the pull request.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import random
|
|
import re
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
|
logging.disable(logging.WARNING)
|
|
|
|
import ux54_scenarios as S # noqa: E402
|
|
from app import arbitrage, dayplan, dayview, homeview, modetrade # noqa: E402
|
|
from app import packmode, solarfc, uitext # noqa: E402
|
|
from app.daycurve import Batt, SolarToday, curve # noqa: E402
|
|
from app.peakguard import PeakDecision # noqa: E402
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
UTC = timezone.utc
|
|
DAY0 = datetime(2026, 10, 26, 23, 0, tzinfo=UTC) # Tue 27 Oct, local
|
|
KINDS = ("grid", "give", "cycle")
|
|
|
|
|
|
def at(h, m=0, s=5):
|
|
return DAY0 + timedelta(hours=h, minutes=m, seconds=s)
|
|
|
|
|
|
def home(c, now, lang="en"):
|
|
return homeview.build(c, level="ok", checks=[], now=now, lang=lang)
|
|
|
|
|
|
def tab(c, now, lang="en"):
|
|
return dayview.build(c, home(c, now, lang), now, lang)
|
|
|
|
|
|
def block_key(r):
|
|
return (r["kind"], r["from"], r["to"], r["start_h"], r["end_h"],
|
|
r["what"], r["why"], r["amount"])
|
|
|
|
|
|
def tab_blocks(out, day=0):
|
|
return [[block_key(r) for r in b["blocks"]]
|
|
for b in out["days"][day]["batteries"]]
|
|
|
|
|
|
def home_blocks(status, n):
|
|
return [[block_key(r) for r in status["plan"]["runs"]
|
|
if r["battery"] == i and r["kind"] in KINDS] for i in range(n)]
|
|
|
|
|
|
def notes_of(out, day=0):
|
|
return out["days"][day]["notes"]
|
|
|
|
|
|
def say(key, lang="en", **v):
|
|
with homeview.in_lang(lang):
|
|
return homeview.day_note(key, **v)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("1. the hourly shape")
|
|
# --------------------------------------------------------------------------- #
|
|
now = at(10, 7)
|
|
c = S.make("owner", now)
|
|
fc = c.solar_fc
|
|
out = tab(c, now)
|
|
d = out["days"][0]
|
|
fcs = [h["forecast_kwh"] for h in d["hours"]]
|
|
print(f" owner: {len(c.fc_entities)} + {len(c.fc_tomorrow_entities)} "
|
|
f"state-only sensors, today {fc.today.kwh:.2f} kWh left, tomorrow "
|
|
f"{fc.tomorrow.kwh:.2f}")
|
|
check("the owner's shape (three 'remaining' + three 'tomorrow' sensors) "
|
|
"carries no hourly split: the add-on sums them and uses its bell",
|
|
len(c.fc_entities) == 3 and len(c.fc_tomorrow_entities) == 3
|
|
and fc.today.periods == () and fc.tomorrow.periods == ()
|
|
and abs(fc.tomorrow.kwh - 16.0) < 0.01)
|
|
check("...so the tab says it is an estimate from a daily total, and fades "
|
|
"the bars", d["sun"]["estimate"] is True
|
|
and say("estimate") in notes_of(out)
|
|
and out["days"][1]["sun"]["estimate"] is True)
|
|
check("today the hours still to come add up to what the sensors say is left "
|
|
"(no hour before now)",
|
|
all(f is None for f in fcs[:10])
|
|
and abs(sum(f for f in fcs if f is not None) - fc.today.kwh) < 1e-3)
|
|
tm = [h["forecast_kwh"] for h in out["days"][1]["hours"]]
|
|
check("tomorrow's 24 hours add up to tomorrow's total, are 0 outside 06-20 "
|
|
"and peak at 13:00 (the bell)",
|
|
abs(sum(tm) - fc.tomorrow.kwh) < 0.01
|
|
and all(v == 0 for v in tm[:6] + tm[20:])
|
|
and tm.index(max(tm)) in (12, 13))
|
|
# The day plan reads the same forecast hour by hour with `between(s, e)`;
|
|
# for a state-only forecast that spreads what is left over each span's own
|
|
# tail, so its hours add up to more than the total (a finding in the pull
|
|
# request, not changed here: no control change).
|
|
plan_sum = sum(fc.between(s, e) for s, e, _h in dayplan._hour_slices(
|
|
now, solarfc._bounds(now)[1]))
|
|
print(f" dayplan's own per-hour sum: {plan_sum:.1f} kWh for {fc.today.kwh:.1f} kWh left")
|
|
check("the tab's hours use the span from now (`dayview.sun_kwh()`), not the "
|
|
"plan's per-hour call, and so keep the total",
|
|
abs(dayview.sun_kwh(fc, now, now, solarfc._bounds(now)[1])
|
|
- fc.today.kwh) < 1e-6)
|
|
|
|
cp = S.make("periods", now)
|
|
dp = tab(cp, now)["days"]
|
|
check("an hourly split (Open-Meteo's `wh_period`, Solcast's "
|
|
"`detailedForecast`) is used as it is, and the tab does not call it an "
|
|
"estimate",
|
|
cp.solar_fc.today.periods and cp.solar_fc.tomorrow.periods
|
|
and dp[0]["sun"]["estimate"] is False
|
|
and say("estimate") not in dp[0]["notes"]
|
|
and abs(sum(h["forecast_kwh"] for h in dp[1]["hours"])
|
|
- cp.solar_fc.tomorrow.kwh) < 1e-3)
|
|
# Solcast's shape: `detailedForecast` rows, kW per half hour.
|
|
d0 = S.day0_of(now)
|
|
rows = [{"period_start": (d0 + timedelta(minutes=30 * i)).isoformat(),
|
|
"pv_estimate": 4.0 if 20 <= i < 36 else 0.0} for i in range(48)]
|
|
st = {"state": "20.0", "attributes": {"unit_of_measurement": "kWh",
|
|
"detailedForecast": rows},
|
|
"last_updated": now.isoformat(), "last_reported": now.isoformat()}
|
|
sc = solarfc.read_sum([("sensor.solcast", st)], [], now)[0]
|
|
check("Solcast's `detailedForecast` gives a split too (kW per half hour)",
|
|
sc is not None and sc.today.periods and len(sc.today.periods) >= 16)
|
|
check("the forecast the tab reads is the one the controller holds "
|
|
"(`Controller.solar_fc`), read by `solarfc` as the plan reads it",
|
|
out["updated"] == homeview.hhmm(c.solar_fc.at))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n2. the blocks are the plan's")
|
|
# --------------------------------------------------------------------------- #
|
|
SCN = [("owner", 10.1), ("dynamic", 10.1), ("dynamic", 0.3),
|
|
("dynamic", 18.5), ("sunny", 0.3), ("sunny", 18.5), ("off", 10.1),
|
|
("off", 16.9), ("cycle", 10.1), ("nofc", 10.1), ("stale", 0.3),
|
|
("learning", 0.3), ("periods", 18.5)]
|
|
bad, seen = [], {"grid": 0, "give": 0, "cycle": 0}
|
|
for name, hh in SCN:
|
|
now = at(hh)
|
|
c = S.make(name, now)
|
|
st = home(c, now)
|
|
out = tab(c, now)
|
|
n = len(c.inverters)
|
|
if tab_blocks(out) != home_blocks(st, n):
|
|
bad.append((name, hh))
|
|
for rows in tab_blocks(out):
|
|
for r in rows:
|
|
seen[r[0]] += 1
|
|
print(f" {len(SCN)} scenarios, blocks seen on the tab: {seen}")
|
|
check("today, every battery's blocks on the tab are Home's `runs` of that "
|
|
"battery (grid, give, cycle): same times, same words, no more, no less "
|
|
f"({bad})", not bad and all(v > 0 for v in seen.values()))
|
|
|
|
now = at(10, 7)
|
|
c = S.make("dynamic", now)
|
|
d0, d1 = homeview.local_day(now)
|
|
n0, n1 = homeview.local_day(d1 + timedelta(hours=2))
|
|
check("Home's own price blocks for today are `homeview.runs_for()` of "
|
|
"today: one builder, two days",
|
|
[block_key(r) for r in homeview.runs_for(c, d0, d1, now, False)]
|
|
== [block_key(r) for r in home(c, now)["plan"]["runs"]
|
|
if r["kind"] in ("grid", "give")])
|
|
tmr = tab(c, now)["days"][1]["batteries"]
|
|
check("tomorrow's blocks are the same builder over tomorrow's window, read "
|
|
"`ahead` (the level now does not filter a day that has not started)",
|
|
[[block_key(r) for r in b["blocks"]] for b in tmr]
|
|
== [[block_key(r) for r in homeview.runs_for(c, n0, n1, now, False, ahead=True)
|
|
if r["battery"] == i] for i in range(2)])
|
|
|
|
# Modes on: Solar first draws no price block; Leftover solar and Cheap hours do.
|
|
c_o = S.make("owner", at(10, 7))
|
|
o = tab(c_o, at(10, 7))
|
|
check("modes on, both batteries Solar first, a day/night tariff (the "
|
|
"owner's shape): no block on either battery, either day, and the "
|
|
"battery says it follows the house",
|
|
all(not b["blocks"] for dd in o["days"] for b in dd["batteries"])
|
|
and all(b["line"] == homeview.run_house_what(False) + " "
|
|
+ homeview.run_house_why(False)
|
|
for b in o["days"][0]["batteries"]))
|
|
c_d = S.make("dynamic", at(10, 7))
|
|
dd = tab(c_d, at(10, 7))["days"][0]["batteries"]
|
|
rp = c_d.mode_runs
|
|
check("Leftover solar and Cheap hours: a give block exactly on the give run "
|
|
"(17:00 to 21:00), on both",
|
|
all([(r["kind"], r["from"], r["to"]) for r in b["blocks"]]
|
|
== [("give", "17:00", "21:00")] for b in dd)
|
|
and rp.give[0].start == at(17, 0, 0) and rp.give[0].end == at(21, 0, 0))
|
|
c_n = S.make("dynamic", at(0, 18))
|
|
nb = tab(c_n, at(0, 18))["days"][0]["batteries"]
|
|
check("in the night before the cheap run both charge from the grid in it "
|
|
"(01:00 to 02:30), then give at 17:00",
|
|
all([r["kind"] for r in b["blocks"]] == ["grid", "give"] for b in nb)
|
|
and all(b["blocks"][0]["from"] == "01:00"
|
|
and b["blocks"][0]["to"] == "02:30" for b in nb))
|
|
# UX-53: no grid block while the gate refuses.
|
|
for why in ("peak", "not_worth"):
|
|
c_n.mode_gate = why
|
|
gb = tab(c_n, at(0, 18))["days"][0]["batteries"]
|
|
check(f"the trade gate refuses ({why}): no grid block on the tab, as on "
|
|
"Home, and the give block stays",
|
|
all([r["kind"] for r in b["blocks"]] == ["give"] for b in gb)
|
|
and tab_blocks(tab(c_n, at(0, 18))) == home_blocks(
|
|
home(c_n, at(0, 18)), 2))
|
|
c_n.mode_gate = None
|
|
|
|
|
|
def at_h(b, h):
|
|
"""A drawn level at hour `h` of the day, NaN where no point is drawn."""
|
|
return dict((round(x, 3), v) for x, v in b["levels"]).get(h, float("nan"))
|
|
|
|
|
|
check("the curve charges in a grid block: A (planned level 64 %) and B "
|
|
"(Cheap hours, to the end of its 01:30 run) are well above where they "
|
|
"were at 01:00, and stay put after the run",
|
|
all(at_h(b, 2.5) - at_h(b, 1.0) > 8.0
|
|
and abs(at_h(b, 3.0) - at_h(b, 2.5)) < 1e-6
|
|
for b in nb) and nb[0]["levels"][0][1] == 55.0)
|
|
# Modes off: the verdict's legs.
|
|
c_f = S.make("off", at(10, 7))
|
|
fb = tab(c_f, at(10, 7))
|
|
check("modes off, arbitrage on: the verdict's blocks are Home's, today, and "
|
|
"tomorrow's charge comes from the same builder; no level is drawn",
|
|
[b["kind"] for x in fb["days"][0]["batteries"] for b in x["blocks"]]
|
|
== ["give", "give"]
|
|
and [b["kind"] for x in fb["days"][1]["batteries"]
|
|
for b in x["blocks"]] == ["grid", "grid"]
|
|
and not any(x["levels"] for dd in fb["days"] for x in dd["batteries"]))
|
|
# A battery that is Off has no block and no curve.
|
|
c_d.store.data[S.unit_key("inverter-b", packmode.USE_KEY)] = False
|
|
off = tab(c_d, at(10, 7))["days"][0]["batteries"]
|
|
check("a battery switched Off: no block, no curve, and it says so in the "
|
|
"plan's words",
|
|
off[1]["off"] is True and not off[1]["blocks"] and not off[1]["levels"]
|
|
and off[1]["line"] == homeview.why_words("off")
|
|
and off[0]["levels"])
|
|
# The cycle is a block, and the curve stops where it starts.
|
|
c_c = S.make("cycle", at(10, 7))
|
|
cy = tab(c_c, at(10, 7))
|
|
a0 = cy["days"][0]["batteries"][0]
|
|
check("a monthly cycle waiting today is a block, the level is drawn only "
|
|
"until it starts, and a note says so",
|
|
[r["kind"] for r in a0["blocks"]] == ["give", "cycle"]
|
|
and a0["levels"][-1][0] <= 20.0 + 1e-6
|
|
and say("cycle", name="inverter-a") in notes_of(cy)
|
|
and not cy["days"][1]["batteries"][0]["levels"])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n3. the level curve")
|
|
# --------------------------------------------------------------------------- #
|
|
T0 = DAY0 + timedelta(hours=9)
|
|
T1 = T0 + timedelta(hours=24)
|
|
|
|
|
|
def flat(kwh_per_h):
|
|
return lambda a, z: kwh_per_h * (z - a).total_seconds() / 3600.0
|
|
|
|
|
|
def run(b, sun, use, **kw):
|
|
return curve([b] if isinstance(b, Batt) else b, T0, T1, sun, use,
|
|
floor_pct=20.0, ceiling_pct=95.0, **kw)
|
|
|
|
|
|
def lv(out, name, h):
|
|
"""The level at `h` hours after T0 (NaN when there is no such point)."""
|
|
return dict(out[name]).get(T0 + timedelta(hours=h), float("nan"))
|
|
|
|
|
|
B1 = Batt("a", 10.0, 5.0, 50.0, True, False)
|
|
o1 = run(B1, flat(3.0), flat(1.0))
|
|
check("Solar first: 2 kWh of spare solar an hour fills it at 95 % "
|
|
"efficiency, 19 % an hour",
|
|
abs(lv(o1, "a", 1) - 69.0) < 1e-6 and abs(lv(o1, "a", 2) - 88.0) < 1e-6)
|
|
check("...stops at the ceiling (95 %), and stays there", abs(lv(o1, "a", 3)
|
|
- 95.0) < 1e-6
|
|
and max(v for _t, v in o1["a"]) <= 95.0 + 1e-9)
|
|
o2 = run(B1, flat(0.0), flat(1.0))
|
|
check("a shortfall of 1 kWh an hour is given at 95 % efficiency, 10.53 % an "
|
|
"hour", abs(lv(o2, "a", 1) - (50.0 - 100.0 / 9.5)) < 1e-6)
|
|
check("...stops at the floor (20 %), and stays there",
|
|
abs(lv(o2, "a", 6) - 20.0) < 1e-6
|
|
and min(v for _t, v in o2["a"]) >= 20.0 - 1e-9)
|
|
check("a battery already above the ceiling does not charge and one below the "
|
|
"floor does not give",
|
|
lv(run(Batt("a", 10.0, 5.0, 98.0, True, False), flat(3), flat(1)),
|
|
"a", 3) == 98.0
|
|
and lv(run(Batt("a", 10.0, 5.0, 12.0, True, False), flat(0), flat(1)),
|
|
"a", 3) == 12.0)
|
|
slow = run(Batt("a", 10.0, 1.0, 50.0, True, False), flat(5.0), flat(0.0))
|
|
check("the rating caps the rate: 1 kW charges 9.5 % of 10 kWh an hour",
|
|
abs(lv(slow, "a", 1) - 59.5) < 1e-6)
|
|
# Roles.
|
|
GV = ((T0 + timedelta(hours=3), T0 + timedelta(hours=6)),)
|
|
hold = Batt("p", 10.0, 5.0, 60.0, False, True, (), GV)
|
|
oh = run(hold, flat(0.0), flat(1.0))
|
|
check("a price-mode battery holds outside a give run (no give), and gives "
|
|
"inside it",
|
|
lv(oh, "p", 3) == 60.0
|
|
and lv(oh, "p", 6) < 60.0 - 3 * 100.0 / 9.5 + 1e-6 + 0.01
|
|
and lv(oh, "p", 24) == lv(oh, "p", 6))
|
|
oh2 = run(hold, flat(3.0), flat(1.0))
|
|
check("...and charges from spare solar while it holds",
|
|
lv(oh2, "p", 3) > 60.0)
|
|
GR = ((T0 + timedelta(hours=2), T0 + timedelta(hours=4), 80.0),)
|
|
ch = Batt("c", 10.0, 5.0, 40.0, False, True, GR, ())
|
|
oc = run(ch, flat(0.0), flat(0.0))
|
|
check("a grid block charges to its level at the rating (5 kW x 95 %: 47.5 % "
|
|
"an hour on 10 kWh, so 80 % in the first hour), and stops there",
|
|
lv(oc, "c", 2) == 40.0 and abs(lv(oc, "c", 3) - 80.0) < 1e-6
|
|
and lv(oc, "c", 5) == 80.0)
|
|
reach = T0 + timedelta(hours=2, minutes=50, seconds=31.579)
|
|
check("a battery that reaches its grid level inside a step gets a point "
|
|
"there (80 % at 02:50:31, not at the next hour), so the line and the "
|
|
"block end are exact",
|
|
any(abs((t - reach).total_seconds()) < 1.0
|
|
and abs(v - 80.0) < 1e-6 for t, v in oc["c"]))
|
|
two = [Batt("a", 10.0, 5.0, 50.0, True, False),
|
|
Batt("b", 10.0, 5.0, 50.0, False, True)]
|
|
ot = run(two, flat(3.0), flat(1.0))
|
|
check("two batteries: Solar first takes the spare solar first; the other "
|
|
"gets what is left",
|
|
lv(ot, "a", 1) > lv(ot, "b", 1) - 1e-9
|
|
and abs(lv(ot, "a", 1) - 69.0) < 1e-6 and lv(ot, "b", 1) == 50.0)
|
|
big = run(two, flat(8.0), flat(0.0))
|
|
check("...and the other charges once Solar first is at its rating or full",
|
|
lv(big, "b", 1) > 50.0)
|
|
GR2 = ((T0 + timedelta(hours=2), T0 + timedelta(hours=4), 80.0),)
|
|
mix = [Batt("a", 40.0, 5.0, 50.0, True, True, (), ()),
|
|
Batt("b", 10.0, 5.0, 40.0, False, True, GR2, ())]
|
|
om = run(mix, flat(0.0), flat(2.0))
|
|
check("while one battery charges from the grid the others do not charge "
|
|
"(they rest) and Solar first may still give",
|
|
lv(om, "b", 3) > lv(om, "b", 2) and lv(om, "a", 3) < lv(om, "a", 2))
|
|
ends = run(B1, lambda a, z: None if a >= T0 + timedelta(hours=5)
|
|
else flat(3.0)(a, z), flat(1.0))
|
|
check("an unknown sun ends the curve there", ends["a"][-1][0]
|
|
== T0 + timedelta(hours=5) and len(ends["a"]) == 6)
|
|
stop = run(Batt("a", 10.0, 5.0, 50.0, True, False, (), (),
|
|
T0 + timedelta(hours=2)), flat(3.0), flat(1.0))
|
|
check("a monthly cycle ends the battery's curve at its start",
|
|
stop["a"][-1][0] == T0 + timedelta(hours=2))
|
|
check("the curve starts at the battery's level now, at `start`",
|
|
o1["a"][0] == (T0, 50.0) and o2["a"][0] == (T0, 50.0))
|
|
|
|
# A fuzz against the invariants.
|
|
rnd = random.Random(54)
|
|
bad_f = []
|
|
for i in range(300):
|
|
n = rnd.choice((1, 2, 3))
|
|
bs = []
|
|
for k in range(n):
|
|
gs = ()
|
|
gw = ()
|
|
if rnd.random() < 0.5:
|
|
a = rnd.randint(0, 20)
|
|
gs = ((T0 + timedelta(hours=a),
|
|
T0 + timedelta(hours=a + rnd.randint(1, 4)),
|
|
rnd.uniform(40, 95)),)
|
|
if rnd.random() < 0.5:
|
|
a = rnd.randint(0, 20)
|
|
gw = ((T0 + timedelta(hours=a),
|
|
T0 + timedelta(hours=a + rnd.randint(1, 4))),)
|
|
bs.append(Batt(f"b{k}", rnd.uniform(5, 25), rnd.uniform(1, 6),
|
|
rnd.uniform(20, 95), rnd.random() < 0.5,
|
|
rnd.random() < 0.6, gs, gw))
|
|
sun = rnd.uniform(0, 5)
|
|
use = rnd.uniform(0, 3)
|
|
out = run(bs, lambda a, z, s=sun: s * (z - a).total_seconds() / 3600.0,
|
|
lambda a, z, u=use: u * (z - a).total_seconds() / 3600.0)
|
|
for b in bs:
|
|
pts = out[b.name]
|
|
vals = [v for _t, v in pts]
|
|
if min(vals) < 20.0 - 1e-6 or max(vals) > 95.0 + 1e-6:
|
|
bad_f.append(("range", i, b.name))
|
|
for (t1, v1), (t2, v2) in zip(pts, pts[1:]):
|
|
hrs = (t2 - t1).total_seconds() / 3600.0
|
|
if abs(v2 - v1) / 100.0 * b.capacity_kwh > b.rating_kw * hrs \
|
|
/ dayplan.EFF * 1.0001 + 1e-9:
|
|
bad_f.append(("rate", i, b.name))
|
|
# holds, and not in a grid block or a give run: never gives
|
|
in_give = any(s <= t1 < e for s, e in b.give)
|
|
in_grid = any(s <= t1 < e for s, e, _ in b.grid)
|
|
if b.priced and not b.solar_first and not in_give \
|
|
and not in_grid and v2 < v1 - 1e-9:
|
|
bad_f.append(("hold gave", i, b.name))
|
|
# Energy: what the batteries gain never exceeds the sun's spare (net of
|
|
# the grid blocks) at 95 %.
|
|
check(f"a fuzz of 300 days of 1-3 random batteries: every level stays inside "
|
|
f"the floor and ceiling, no step beats the rating, a holding battery "
|
|
f"never gives ({bad_f[:3]})", not bad_f)
|
|
|
|
# On the controllers: inside floor and ceiling, the right way in blocks.
|
|
rows_ok, moves_ok, n_curves = True, True, 0
|
|
for name, hh in SCN:
|
|
now = at(hh)
|
|
c = S.make(name, now)
|
|
out = tab(c, now)
|
|
fl, ce = arbitrage.soc_bounds(c.o)
|
|
for day in out["days"]:
|
|
for b in day["batteries"]:
|
|
n_curves += bool(b["levels"])
|
|
for h_, v in b["levels"]:
|
|
rows_ok &= fl - 1e-6 <= v <= ce + 1e-6
|
|
for r in b["blocks"]:
|
|
inside = [(h_, v) for h_, v in b["levels"]
|
|
if r["start_h"] - 1e-6 <= h_ <= r["end_h"] + 1e-6]
|
|
if len(inside) >= 2 and r["kind"] == "grid":
|
|
moves_ok &= inside[-1][1] >= inside[0][1] - 1e-6
|
|
if len(inside) >= 2 and r["kind"] == "give":
|
|
moves_ok &= inside[-1][1] <= inside[0][1] + 1e-6
|
|
print(f" {n_curves} battery curves checked on the controllers")
|
|
check("on the controllers: every drawn level is inside the floor and the "
|
|
"ceiling", rows_ok and n_curves >= 10)
|
|
check("...and the level rises in a grid block and does not rise in a give "
|
|
"block", moves_ok)
|
|
c_s = S.make("sunny", at(10, 7))
|
|
ls = tab(c_s, at(10, 7))["days"][0]["batteries"][0]["levels"]
|
|
check("a sunny day with Leftover solar: the level climbs by noon and "
|
|
"reaches the ceiling by evening",
|
|
ls[0][1] == c_s.inverters[0].soc and max(v for _h, v in ls) > 85.0)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n4. a part that is not known yet is a note")
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def no_charts(out):
|
|
return all(not b["levels"] for dd in out["days"]
|
|
for b in dd["batteries"])
|
|
|
|
|
|
c = S.make("off", at(10, 7))
|
|
o = tab(c, at(10, 7))
|
|
check("modes off: a note, no forecast, no house use, no curve",
|
|
say("modes_off") in notes_of(o) and no_charts(o)
|
|
and not o["days"][0]["sun"]["forecast"]
|
|
and not o["days"][0]["sun"]["house"])
|
|
o = tab(S.make("nofc", at(10, 7)), at(10, 7))
|
|
check("no forecast set up: a note, and the price blocks still show",
|
|
say("not_set") in notes_of(o) and no_charts(o)
|
|
and o["days"][0]["batteries"][0]["blocks"])
|
|
o = tab(S.make("stale", at(10, 7)), at(10, 7))
|
|
check("a forecast that is missing or too old: a note, no curve",
|
|
say("no_forecast") in notes_of(o) and no_charts(o))
|
|
o = tab(S.make("learning", at(10, 7)), at(10, 7))
|
|
check("learning (no house profile yet): a note, the sun is drawn, no house "
|
|
"use and no curve",
|
|
say("learning") in notes_of(o) and no_charts(o)
|
|
and o["days"][0]["sun"]["forecast"] and not o["days"][0]["sun"]["house"]
|
|
and all(h["house_kwh"] is None for h in o["days"][0]["hours"]))
|
|
SUN_FROM = f"{int(solarfc.BELL_FROM_H):02d}:00"
|
|
o = tab(S.make("notomorrow", at(10, 7)), at(10, 7))
|
|
tmr = o["days"][1]
|
|
check("no forecast for tomorrow: a note on that day only, naming the hour "
|
|
"the curve really stops (`solarfc.BELL_FROM_H`, the first hour of the "
|
|
"sun: its last point is there); today is whole",
|
|
say("no_tomorrow", at=SUN_FROM) in tmr["notes"]
|
|
and say("no_tomorrow", at=SUN_FROM) not in notes_of(o, 0)
|
|
and not tmr["sun"]["forecast"]
|
|
and abs(tmr["batteries"][0]["levels"][-1][0] - solarfc.BELL_FROM_H) < 1e-6
|
|
and o["days"][0]["batteries"][0]["levels"][-1][0] > 23.0)
|
|
c = S.make("owner", at(10, 7))
|
|
c.inverters[1].soc = None
|
|
o = tab(c, at(10, 7))
|
|
check("a battery with no level reading: a note naming it, no curve for it, "
|
|
"and the other still has one",
|
|
say("no_level", name="inverter-b") in notes_of(o)
|
|
and not o["days"][0]["batteries"][1]["levels"]
|
|
and o["days"][0]["batteries"][0]["levels"])
|
|
c = S.make("owner", at(10, 7))
|
|
c.auto = False
|
|
o = tab(c, at(10, 7))
|
|
check("control stopped: the plan's words, no curve, no block",
|
|
homeview.why_words("stopped") in notes_of(o) and no_charts(o)
|
|
and not o["running"])
|
|
c = S.make("owner", at(10, 7))
|
|
c.o["shadow_mode"] = "on"
|
|
o = tab(c, at(10, 7))
|
|
check("shadow mode: the plan's own shadow sentence is on the tab",
|
|
homeview.why_words("shadow") in notes_of(o))
|
|
c = S.make("owner", at(10, 7))
|
|
check("the levels count on the trust the plan counts on, and the tab says "
|
|
"so", say("trust", pct=70) in notes_of(tab(c, at(10, 7))))
|
|
c.solar_trust_pct = 100
|
|
full = tab(c, at(10, 7))["days"][0]["batteries"][0]["levels"]
|
|
c.solar_trust_pct = 30
|
|
low = tab(c, at(10, 7))["days"][0]["batteries"][0]["levels"]
|
|
check("...a higher trust fills the battery more (the curve uses it)",
|
|
max(v for _h, v in full) > max(v for _h, v in low))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n5. measured solar")
|
|
# --------------------------------------------------------------------------- #
|
|
t0 = at(0, 0, 0)
|
|
sd = SolarToday()
|
|
for i in range(0, 3600, 5): # 2 kW for the hour 10:00-11:00
|
|
sd.add(t0 + timedelta(hours=10, seconds=i), 2000.0)
|
|
sd.add(t0 + timedelta(hours=11), 2000.0)
|
|
check("2 kW read all hour is 2.0 kWh for that hour",
|
|
abs(sd.kwh(t0 + timedelta(hours=10), t0 + timedelta(hours=11),
|
|
t0 + timedelta(hours=12)) - 2.0) < 0.02)
|
|
check("the hour in progress is what was made so far (half an hour: 1 kWh)",
|
|
abs(sd.kwh(t0 + timedelta(hours=10), t0 + timedelta(hours=11),
|
|
t0 + timedelta(hours=10, minutes=30)) - 1.0) < 0.05)
|
|
check("an hour with no reading, or under half covered, is not guessed",
|
|
sd.kwh(t0 + timedelta(hours=9), t0 + timedelta(hours=10),
|
|
t0 + timedelta(hours=12)) is None)
|
|
g = SolarToday()
|
|
g.add(t0 + timedelta(hours=10), 1000.0)
|
|
g.add(t0 + timedelta(hours=10, minutes=50), 1000.0) # a gap: 60 s counted
|
|
check("a gap in the readings counts for no more than a minute, so a "
|
|
"restart does not invent energy",
|
|
g.kwh(t0 + timedelta(hours=10), t0 + timedelta(hours=11),
|
|
t0 + timedelta(hours=11)) is None)
|
|
g.add(t0 + timedelta(hours=10, minutes=51), None)
|
|
g.add(t0 + timedelta(hours=10, minutes=52), 1000.0)
|
|
check("no reading ends an interval; a negative reading counts as 0",
|
|
g._last == (t0 + timedelta(hours=10, minutes=52), 1000.0))
|
|
nxt = t0 + timedelta(days=1, hours=1)
|
|
g.add(nxt, 500.0)
|
|
check("a new local day starts again", g.day != (t0).date()
|
|
and g.first == nxt and g._hours == {})
|
|
c = S.make("owner", at(10, 7))
|
|
o = tab(c, at(10, 7))
|
|
got = [h["measured_kwh"] for h in o["days"][0]["hours"]]
|
|
check("on the page: the hours already over have measured solar, the hours "
|
|
"to come do not, tomorrow has none",
|
|
got[9] is not None and got[11] is None and got[0] is not None
|
|
and all(h["measured_kwh"] is None for h in o["days"][1]["hours"]))
|
|
c = S.make("owner", at(10, 7))
|
|
c.solar_today = SolarToday()
|
|
c.solar_today.add(at(8, 0, 0), 1000.0)
|
|
o2 = tab(c, at(10, 7))
|
|
check("the add-on that started at 08:00 says measured solar counts from "
|
|
"then", say("measured_from", at="08:00") in notes_of(o2))
|
|
|
|
|
|
class _Hass:
|
|
def __init__(self):
|
|
self.reads = 0
|
|
|
|
async def state(self, e):
|
|
self.reads += 1
|
|
return {"entity_id": e, "state": "1500",
|
|
"attributes": {"unit_of_measurement": "W"}}
|
|
|
|
|
|
async def _read_pv_with(add):
|
|
c = S.make("owner", at(10, 7))
|
|
c.hass = _Hass()
|
|
c.solar_today.add = add
|
|
await c._read_pv()
|
|
return c.pv_w
|
|
|
|
|
|
def _boom(*_a):
|
|
raise RuntimeError("no")
|
|
|
|
|
|
def _survives():
|
|
try:
|
|
return asyncio.run(_read_pv_with(_boom)) == 1500.0
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
|
|
|
|
check("a fault in keeping the measured solar never costs the control "
|
|
"cycle its reading (`_read_pv` still returns the total)", _survives())
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n6. words, page, route; reading asks for nothing and moves nothing")
|
|
# --------------------------------------------------------------------------- #
|
|
en = uitext.words("en")
|
|
nl = uitext.words("nl")
|
|
check("every sentence of the tab has an English and a Dutch form, with the "
|
|
"same placeholders",
|
|
set(en["home_words"]["day"]) == set(nl["home_words"]["day"])
|
|
and all(re.findall(r"\{\w+\}", en["home_words"]["day"][k])
|
|
== re.findall(r"\{\w+\}", nl["home_words"]["day"][k])
|
|
for k in en["home_words"]["day"]))
|
|
now = at(10, 7)
|
|
c = S.make("owner", now)
|
|
oe, on = tab(c, now, "en"), tab(c, now, "nl")
|
|
check("Dutch: the notes, the totals and the blocks are in Dutch, with a "
|
|
"decimal comma",
|
|
on["days"][0]["line"].startswith("Nog verwachte zon vandaag: 14,4 kWh")
|
|
and say("estimate", "nl") in on["days"][0]["notes"]
|
|
and on["days"][1]["line"].startswith("Verwachte zon morgen: 16,0 kWh")
|
|
and oe["days"][0]["line"].startswith("Solar still to come today: 14.4"))
|
|
c = S.make("dynamic", at(0, 18))
|
|
nl_b = tab(c, at(0, 18), "nl")["days"][0]["batteries"][0]["blocks"]
|
|
check("...and so are the plan's words on a block",
|
|
nl_b[0]["what"].startswith("Laadt tot") and nl_b[0]["why"].startswith(
|
|
"Goedkoopste uren") and nl_b[1]["what"].startswith("Levert tot"))
|
|
check("the language goes back to English after a Dutch build",
|
|
homeview.pill("running") == en["home_words"]["pill"]["running"])
|
|
c = S.make("dynamic", at(10, 7))
|
|
sent = []
|
|
orig_state = c.hass.state
|
|
c.hass.state = lambda *a, **k: sent.append(a) or orig_state(*a, **k)
|
|
snap = lambda: repr((c.mode_tick, c.mode_runs, c._plan_targets, c.arb,
|
|
[(i.soc, i.batt, i.last_write) for i in c.inverters],
|
|
c.mode_gate, c.day_plan))
|
|
before = snap()
|
|
t_a = time.perf_counter()
|
|
for _ in range(10):
|
|
c.forecast_payload("en")
|
|
per = (time.perf_counter() - t_a) / 10 * 1000.0
|
|
print(f" forecast_payload: {per:.1f} ms per build on this machine")
|
|
check("building the tab asks Home Assistant for nothing and changes nothing "
|
|
"control holds (modes, runs, levels, decision, readings)",
|
|
not sent and snap() == before)
|
|
def _broken(c):
|
|
real = dayview.build
|
|
dayview.build = lambda *a, **k: 1 / 0
|
|
logging.disable(logging.CRITICAL)
|
|
try:
|
|
return c.forecast_payload("en")
|
|
finally:
|
|
dayview.build = real
|
|
logging.disable(logging.WARNING)
|
|
|
|
|
|
pay = c.forecast_payload("en")
|
|
check("`Controller.forecast_payload()` gives the tab's JSON, and a fault in "
|
|
"building it gives `ok: false` rather than raising",
|
|
pay.get("ok") is True and len(pay["days"]) == 2
|
|
and _broken(c) == {"ok": False})
|
|
PAGE_SRC = None
|
|
|
|
|
|
async def _serve():
|
|
from aiohttp import web as aioweb, ClientSession
|
|
from app import web
|
|
|
|
class _Sup:
|
|
async def info(self):
|
|
return {"options": {}, "schema": [], "translations": {}}
|
|
c = S.make("owner", datetime.now(UTC))
|
|
runner = aioweb.AppRunner(web.build_app(c, _Sup()))
|
|
await runner.setup()
|
|
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
|
await site.start()
|
|
port = site._server.sockets[0].getsockname()[1]
|
|
res = {}
|
|
try:
|
|
async with ClientSession() as s:
|
|
async with s.get(f"http://127.0.0.1:{port}/forecast") as r:
|
|
res["en"] = (r.status, await r.json())
|
|
async with s.get(f"http://127.0.0.1:{port}/forecast?lang=nl-BE") as r:
|
|
res["nl"] = await r.json()
|
|
finally:
|
|
await runner.cleanup()
|
|
return res
|
|
|
|
|
|
try:
|
|
res = asyncio.run(_serve())
|
|
except Exception: # noqa: BLE001
|
|
res = None
|
|
check("GET forecast answers 200 with the tab's JSON, in English by default "
|
|
"and in Dutch with ?lang=nl",
|
|
res is not None and res["en"][0] == 200 and res["en"][1]["ok"] is True
|
|
and res["en"][1]["days"][0]["line"].startswith("Solar still to come")
|
|
and res["nl"]["days"][0]["line"].startswith("Nog verwachte zon"))
|
|
PAGE = open(os.path.join(HERE, "app", "static", "index.html"),
|
|
encoding="utf-8").read()
|
|
JS = open(os.path.join(HERE, "app", "static", "app.js"),
|
|
encoding="utf-8").read()
|
|
check("the page has the Forecast tab between Batteries and Settings, with "
|
|
"its own panel", re.findall(r'data-hash="(\w+)"', PAGE)[:4]
|
|
== ["home", "batteries", "forecast", "settings"]
|
|
and 'id="s-fc"' in PAGE and 'aria-labelledby="t-fc"' in PAGE)
|
|
check("it asks for `forecast?lang=` (relative, for ingress), only while its "
|
|
"screen shows, and sets data as text",
|
|
'fetch("forecast?lang="' in JS and "scheduleForecast" in JS
|
|
and "innerHTML" not in JS)
|
|
labels = set(re.findall(r'''\bt\("(forecast\.[\w.]+)"\)''', JS)) | set(
|
|
re.findall(r'data-t(?:-label)?="(forecast\.[\w.]+)"', PAGE))
|
|
miss = sorted(k for k in labels if not isinstance(
|
|
en["home"]["forecast"].get(k.split(".", 1)[1]), str)
|
|
or not isinstance(nl["home"]["forecast"].get(k.split(".", 1)[1]), str))
|
|
check(f"every label the screen asks for is in both languages ({len(labels)} "
|
|
f"keys; missing {miss})", len(labels) >= 12 and not miss)
|
|
CSS = open(os.path.join(HERE, "app", "static", "app.css"),
|
|
encoding="utf-8").read()
|
|
check("it is readable on a phone: columns flex to the width, the chart has "
|
|
"no fixed pixel width, and the controls are 44 px tall",
|
|
"min-width:0" in CSS and "width:100%" in CSS
|
|
and not re.search(r"\.fc[a-z]*\{[^}]*width:\s*\d{3,}px", CSS)
|
|
and "min-height:44px" in CSS)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n7. round 2: a block exists where the add-on will act (F1, F2, F3)")
|
|
# --------------------------------------------------------------------------- #
|
|
Q = timedelta(minutes=15)
|
|
|
|
|
|
def level_at(b, h):
|
|
"""The curve's level at hour `h` of the day the list is for, straight
|
|
between its points (the first level before the curve starts, the last
|
|
after it ends)."""
|
|
pts = b["levels"]
|
|
for (h0, v0), (h1, v1) in zip(pts, pts[1:]):
|
|
if h0 <= h <= h1:
|
|
return v0 if h1 <= h0 else v0 + (v1 - v0) * (h - h0) / (h1 - h0)
|
|
return pts[0][1] if h < pts[0][0] else pts[-1][1]
|
|
|
|
|
|
def night_evening(h):
|
|
"""A cheap night and a dear evening, flat between: one cheap run, one give
|
|
run, nothing the plan of tomorrow evening would add before then."""
|
|
return 0.08 if 1.0 <= h < 5.0 else 0.34 if 17.0 <= h < 21.0 else 0.20
|
|
|
|
|
|
def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None):
|
|
"""Walk the real control cycle (`_mode_roles()`) every 15 minutes from
|
|
`now0` to the end of tomorrow, each battery at the level the tab's curve
|
|
has at that time, and return the tab and the role seen per battery and
|
|
quarter. The tab is built once, at `now0`."""
|
|
c = S.make(name, now0, soc=soc)
|
|
c.tariff_plan = S.quarters(now0, night_evening, days=3)
|
|
c.tariff_at = now0
|
|
if guard is not None:
|
|
c.peak_guard = guard
|
|
if sun is not None:
|
|
c.solar_fc = S.forecast(now0, sun[0], sun[1])
|
|
c._mode_roles(now0, None)
|
|
out = tab(c, now0)
|
|
d0, _d1 = homeview.local_day(now0)
|
|
seen = {inv.name: {} for inv in c.inverters}
|
|
t = now0
|
|
end = d0 + timedelta(hours=until)
|
|
while t < end:
|
|
h = (t - d0).total_seconds() / 3600.0
|
|
for i, inv in enumerate(c.inverters):
|
|
day = out["days"][0 if h < 24 else 1]
|
|
b = day["batteries"][i]
|
|
inv.soc = level_at(b, h if h < 24 else h - 24) if b["levels"] \
|
|
else inv.soc
|
|
c.tariff_at = t
|
|
tick = c._mode_roles(t, None)
|
|
floor_, _ceil = arbitrage.soc_bounds(c.o)
|
|
for inv in c.inverters:
|
|
role = tick.roles.get(inv.name)
|
|
# a give from a battery at the floor has no energy behind it:
|
|
# Home's own rule (`mode_runs()`, `MIN_KWH`) draws no block
|
|
seen[inv.name][t] = "no energy" if role == modetrade.GIVE \
|
|
and inv.soc <= floor_ + 0.05 else role
|
|
t += Q
|
|
return c, out, seen, d0
|
|
|
|
|
|
def tomorrow_match(out, seen, d0, role, kind):
|
|
"""Per battery, tomorrow: control has `role` in exactly the quarters the
|
|
tab has a block of `kind` (no quarter outside a block, none of a block
|
|
without it), with no tolerance."""
|
|
for i, name in enumerate(seen):
|
|
blocks = [r for r in out["days"][1]["batteries"][i]["blocks"]
|
|
if r["kind"] == kind]
|
|
quarters = [(t, (t - d0).total_seconds() / 3600.0 - 24.0)
|
|
for t in seen[name] if (t - d0).total_seconds() >= 86400]
|
|
for t, h in quarters:
|
|
acting = seen[name][t] == role
|
|
inside = any(r["start_h"] - 1e-6 <= h < r["end_h"] - 1e-6
|
|
for r in blocks)
|
|
if acting != inside:
|
|
print(" ", name, role, "quarter", round(h, 2),
|
|
"control acts" if acting else "tab has a block",
|
|
[(r["start_h"], r["end_h"]) for r in blocks])
|
|
return False
|
|
return True
|
|
|
|
|
|
t22 = at(22, 0, 5)
|
|
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0))
|
|
tm1 = o1["days"][1]["batteries"]
|
|
print(" 22:00, both batteries at 20 %: tomorrow", [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]] for b in tm1])
|
|
check("F1, modes on: at 22:00 with both batteries at 20 % tomorrow's give "
|
|
"block (17:00-21:00) and the night's grid block show on both",
|
|
all([(r["kind"], r["from"]) for r in b["blocks"]]
|
|
== [("grid", "01:00"), ("give", "17:00")]
|
|
and b["blocks"][1]["to"] == "21:00" for b in tm1))
|
|
check("F2: ...and control, walked every 15 minutes through tomorrow at the "
|
|
"level the curve has, gives (role GIVE) exactly in the give block and "
|
|
"charges (role CHARGE) only in the grid block, on both batteries",
|
|
tomorrow_match(o1, s1, d01, modetrade.GIVE, "give")
|
|
and tomorrow_match(o1, s1, d01, modetrade.CHARGE, "grid"))
|
|
check("...and today's blocks are still Home's runs there",
|
|
tab_blocks(o1) == home_blocks(home(c1, t22), 2))
|
|
guard = PeakDecision(engaged=True, floor_w=1000.0, ceiling_w=3500.0,
|
|
ceiling_from="cap", projected_w=3600.0)
|
|
c2, o2, s2, d02 = sweep("dynamic", t22, (20.0, 20.0), guard=guard)
|
|
tm2 = o2["days"][1]["batteries"]
|
|
check("F2, the UX-53 gate: while the peak guard holds control never "
|
|
"charges tomorrow and the tab draws no grid block (both batteries stay "
|
|
"at the floor, so no give either, which control cannot give from)",
|
|
c2.mode_gate == "peak"
|
|
and all(not b["blocks"] for b in tm2)
|
|
and tomorrow_match(o2, s2, d02, modetrade.CHARGE, "grid")
|
|
and tomorrow_match(o2, s2, d02, modetrade.GIVE, "give"))
|
|
c2b, o2b, s2b, d02b = sweep("dynamic", t22, (60.0, 60.0), guard=guard)
|
|
check("...with the batteries at 60 % the give block stays and the grid "
|
|
"block does not",
|
|
all([r["kind"] for r in b["blocks"]] == ["give"]
|
|
for b in o2b["days"][1]["batteries"])
|
|
and tomorrow_match(o2b, s2b, d02b, modetrade.CHARGE, "grid")
|
|
and tomorrow_match(o2b, s2b, d02b, modetrade.GIVE, "give"))
|
|
# Low sun, a battery at the floor when the evening give run starts (no grid
|
|
# charge, the peak guard holds): Home's rule draws no give block for it, and
|
|
# control has no energy to give from. A little sun fills it a little: a small
|
|
# block, and control gives from it.
|
|
c4, o4, s4, d04 = sweep("dynamic", t22, (20.0, 20.0), guard=guard,
|
|
sun=(0.0, 0.0))
|
|
check("F1, cloudy day, no grid charge, both batteries at the floor at the "
|
|
"evening give run: no block on either, and control has no energy to "
|
|
"give from (the sweep sees none)",
|
|
all(not b["blocks"] for b in o4["days"][1]["batteries"])
|
|
and tomorrow_match(o4, s4, d04, modetrade.GIVE, "give"))
|
|
c5, o5, s5, d05 = sweep("dynamic", t22, (20.0, 20.0), guard=guard,
|
|
sun=(14.0, 14.0))
|
|
gv5 = [[r for r in b["blocks"] if r["kind"] == "give"]
|
|
for b in o5["days"][1]["batteries"]]
|
|
check("...a bright day fills them: a give block on both, and control gives "
|
|
"in exactly that block",
|
|
all(len(g) == 1 and g[0]["from"] == "17:00" for g in gv5)
|
|
and tomorrow_match(o5, s5, d05, modetrade.GIVE, "give"))
|
|
o3 = [sweep("dynamic", t22, soc) for soc in ((55.0, 40.0), (90.0, 70.0))]
|
|
check("F2: the same with the batteries part full and nearly full at 22:00",
|
|
all(tomorrow_match(x[1], x[2], x[3], modetrade.GIVE, "give")
|
|
and tomorrow_match(x[1], x[2], x[3], modetrade.CHARGE, "grid")
|
|
for x in o3))
|
|
|
|
|
|
def off_prices(h):
|
|
if 23.0 <= h < 24.0:
|
|
return 0.05
|
|
if 7.0 <= h < 9.0:
|
|
return 0.40
|
|
return 0.20
|
|
|
|
|
|
def off_case(soc):
|
|
now = at(20, 0, 5)
|
|
c = S.make("off", now, soc=soc)
|
|
c.tariff_plan = S.quarters(now, off_prices)
|
|
c.tariff_at = now
|
|
return c, now, tab(c, now), home(c, now)
|
|
|
|
|
|
for soc in ((20.0, 20.0), (60.0, 60.0), (95.0, 95.0)):
|
|
c, now, out, st = off_case(soc)
|
|
tz = S.brussels(now).tzinfo
|
|
v, _why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now)
|
|
t07 = at(24 + 7, 0, 5)
|
|
c.tariff_at = t07
|
|
v7, _w = homeview.trade_verdict(c, c.fresh_tariff_plan(t07), t07)
|
|
t06 = at(24 + 6, 0, 5)
|
|
c.tariff_at = t06
|
|
v6, _w6 = homeview.trade_verdict(c, c.fresh_tariff_plan(t06), t06)
|
|
check(f"F1, modes off, a charge today at 23:00 and a give tomorrow at "
|
|
f"07:00, batteries at {soc[0]:.0f} %: Home says 'Tomorrow: gives "
|
|
"power at 07:00', the tab draws that give on both batteries, and "
|
|
"the control cycle's own verdict gives at 07:00 and not at 06:00",
|
|
any("07:00" in x for x in st["plan"]["lines"])
|
|
and [[(r["kind"], r["from"]) for r in b["blocks"]]
|
|
for b in out["days"][1]["batteries"]]
|
|
== [[("give", "07:00")]] * 2
|
|
and v7 is not None and v7.dearest.start.astimezone(tz).strftime(
|
|
"%H:%M") == "07:00" and v7.dearest.start <= t07
|
|
and v6 is not None and v6.dearest.start > t06)
|
|
|
|
tm90 = o3[1][1]["days"][1]["batteries"]
|
|
check("F2: a battery already at its planned level when the run starts has no "
|
|
"grid block tomorrow (A at 90 %), the one below it has (B at 70 %); "
|
|
"control charged neither A nor B outside that",
|
|
[r["kind"] for r in tm90[0]["blocks"]] == ["give"]
|
|
and [r["kind"] for r in tm90[1]["blocks"]] == ["grid", "give"])
|
|
# Today: the give run is planned and Home draws no block for an empty battery.
|
|
c_s = S.make("sunny", at(10, 7), soc=(20.0, 20.0))
|
|
st_s = home(c_s, at(10, 7))
|
|
o_s = tab(c_s, at(10, 7))
|
|
check("today: with a give run in the plan and both batteries at the floor "
|
|
"Home draws no give block, the tab equals Home, and it says the "
|
|
"add-on plans to give, never 'No charge or give blocks planned'",
|
|
not [r for r in st_s["plan"]["runs"] if r["kind"] == "give"]
|
|
and c_s.mode_runs.give
|
|
and tab_blocks(o_s) == home_blocks(st_s, 2)
|
|
and all(b["line"] == say("give_later")
|
|
for b in o_s["days"][0]["batteries"]))
|
|
bad_no_blocks = []
|
|
for name, hh in SCN + [("sunny", 10.1), ("dynamic", 22.0)]:
|
|
for soc in ((20.0, 20.0), (55.0, 40.0), (95.0, 95.0)):
|
|
c = S.make(name, at(hh), soc=soc)
|
|
if not c.modes_on:
|
|
continue
|
|
o = tab(c, at(hh))
|
|
for k in (0, 1):
|
|
for b in o["days"][k]["batteries"]:
|
|
if b["line"] == say("no_blocks"):
|
|
d0_, d1_ = homeview.local_day(at(hh))
|
|
lo, hi = (d0_, d1_) if k == 0 else homeview.local_day(
|
|
d1_ + timedelta(hours=2))
|
|
if c.mode_runs is not None and c.mode_runs.on and any(
|
|
g.end > max(at(hh), lo) and g.start < hi
|
|
for g in c.mode_runs.give):
|
|
bad_no_blocks.append((name, hh, soc, k))
|
|
check("'No charge or give blocks planned.' is never said where the run plan "
|
|
f"has a give run in that day ({bad_no_blocks})", not bad_no_blocks)
|
|
|
|
# F3: trust as the plan counts it.
|
|
cap_big = 100.0
|
|
f3 = curve([Batt("a", cap_big, 50.0, 50.0, True, False)], T0,
|
|
T0 + timedelta(hours=1), flat(10.0), flat(2.0), floor_pct=20.0,
|
|
ceiling_pct=95.0, trust=0.7)
|
|
stored = (lv(f3, "a", 1) - 50.0) / 100.0 * cap_big
|
|
plan_sun = 0.7 * dayplan.EFF * max(0.0, 10.0 - 2.0)
|
|
print(f" 10 kWh sun, 2 kWh house, 70 %: curve stores {stored:.3f} kWh, plan {plan_sun:.3f}")
|
|
check("F3: 10 kWh of sun, 2 kWh of house and 70 % trust: the curve stores "
|
|
"5.32 kWh, as the plan counts trust x EFF x (sun - house), not 4.75",
|
|
abs(stored - 5.32) < 1e-6 and abs(plan_sun - 5.32) < 1e-9)
|
|
f3b = curve([Batt("a", cap_big, 50.0, 50.0, True, False)], T0,
|
|
T0 + timedelta(hours=1), flat(1.0), flat(2.0), floor_pct=20.0,
|
|
ceiling_pct=95.0, trust=0.7)
|
|
check("...a shortfall is not scaled by trust (the house needs all of it)",
|
|
abs((50.0 - lv(f3b, "a", 1)) / 100.0 * cap_big - 1.0 / dayplan.EFF)
|
|
< 1e-6)
|
|
c_t = S.make("owner", at(10, 7))
|
|
c_t.solar_trust_pct = 70
|
|
hi_ = tab(c_t, at(10, 7))["days"][0]["batteries"][0]["levels"]
|
|
check("on a controller the curve is built with the plan's trust "
|
|
"(`solar_trust_pct`)", hi_ and max(v for _h, v in hi_) > hi_[0][1])
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED")
|
|
sys.exit(1)
|
|
print("all passed")
|