After a fresh today reading in the night (02:00 on the rig) went stale (05:00), Hold.stand_in(as_today=True) served the 17:05 "tomorrow" figure again until 06:00: the plan went back to 95 %, the latch took it, and Leftover solar charged back up. read_sum() now skips that stand-in once every today sensor has given a fresh reading for the day (Hold.replaced()); the stale newer reading follows the OPT-27 rules. Co-Authored-By: Claude <noreply@anthropic.com>
843 lines
40 KiB
Python
843 lines
40 KiB
Python
"""Runnable check for OPT-34. `python3 test_opt34.py`
|
|
|
|
OPT-27 keeps the last fresh Forecast.Solar figure over the night. With the
|
|
sensors silent from 17:05 it ended at local midnight + 3 h, in the middle of the
|
|
night grid charge: Leftover solar's planned level went with it and the charge
|
|
stopped (rig, 2026-10-31: battery A 49.8 % at 07:00 against 79.9 % with a fresh
|
|
figure). Forecast.Solar may not refresh after midnight on a live site, so the
|
|
add-on must not depend on it. OPT-34:
|
|
|
|
1. a night charge that has started keeps its planned level when the forecast
|
|
goes (`Controller._latch_charge()`), and the next slice of the run is planned
|
|
with it (the wide no-forecast gap does not refuse it);
|
|
2. the evening's "tomorrow" figure is today's until the bell starts (06:00
|
|
local, `BELL_FROM_H`) instead of until midnight + 3 h;
|
|
3. for that case only, a reading may be up to 15 h old (a 17:05 reading is
|
|
12 h 55 min old at 06:00), but only if it was at most 12 h old at midnight;
|
|
4. a fresh figure replaces the kept one at once.
|
|
|
|
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
|
|
|
|
Mutations (MUTATION.md, purged copies, graded over this file): see the
|
|
DECISIONS note and the dev report.
|
|
|
|
1. `solarfc` alone: the window, the age limits, the day, DST, restart.
|
|
2. The real Controller, the owner's shape (modes on, arbitrage off, A Leftover
|
|
solar + B Cheap hours, both 10 % at the start of the night, the night rate
|
|
22:00-07:00, tomorrow 6 kWh, a 2.5 kW peak limit so the charge takes hours),
|
|
sensors silent from 17:05, whole nights at 5 s per cycle (run in parallel
|
|
processes, about 2.5 minutes each):
|
|
a. silent from 17:05: both batteries end the night where a fresh figure
|
|
ends them (the base left A at 57 % against 87 %), and the screens agree;
|
|
b. restarts at 01:00 and 04:00 change nothing;
|
|
c. a charge that cannot start before 03:00 (the price dips at 04:00) still
|
|
starts at 04:00 on the kept figure;
|
|
d. a fresh figure at 02:00 replaces the level at once, up, during the charge;
|
|
and before a charge has started, up and down;
|
|
e. the last hour, after the kept figure has ended at 06:00: the charge under
|
|
way goes on to its level, also over a restart; the screens say so.
|
|
f. F-1 (rig): one fresh reading at 02:00, then silent again: when it is
|
|
stale (05:00) the 17:05 figure it replaced does not come back.
|
|
3. The text, EN and NL.
|
|
4. `_latch_charge()` alone: a battery that leaves the plan leaves the latch.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from concurrent.futures import ProcessPoolExecutor
|
|
from datetime import datetime, timedelta, timezone
|
|
from multiprocessing import get_context
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
|
logging.disable(logging.WARNING)
|
|
|
|
import opt11p3_replay as rp # noqa: E402
|
|
import app.main as m # noqa: E402
|
|
from app import homeview, solarfc # noqa: E402
|
|
from app.main import Controller # noqa: E402
|
|
from app.p1 import make_sample, split_signed # noqa: E402
|
|
from app.store import unit_key # noqa: E402
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
|
|
|
|
|
|
def at(day, hh, mm=0):
|
|
"""Local time on 2026-11-`day` (CET) as UTC."""
|
|
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
|
|
|
|
|
|
def st(kwh, when, *, unit="kWh"):
|
|
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
|
|
"last_updated": when.isoformat(),
|
|
"last_reported": when.isoformat()}
|
|
|
|
|
|
GONE = {"state": "unavailable", "attributes": {}}
|
|
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
|
|
BRU = solarfc.BILLING_TZ
|
|
|
|
|
|
def bru(y, mo, d, hh, mm=0):
|
|
return datetime(y, mo, d, hh, mm, tzinfo=BRU).astimezone(timezone.utc)
|
|
|
|
|
|
def rs(today, tomorrow, now, hold):
|
|
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
|
|
|
|
|
|
def near(a, b, tol):
|
|
"""`|a - b| < tol`, and False (not a crash) when `a` is missing."""
|
|
return a is not None and abs(a - b) < tol
|
|
|
|
|
|
def used(hold, now):
|
|
f = rs(GONE, GONE, now, hold)[0]
|
|
return f is not None
|
|
|
|
|
|
def hold_of(read_at, today=0.4, tomorrow=12.0):
|
|
h = solarfc.Hold()
|
|
rs(st(today, read_at), st(tomorrow, read_at),
|
|
read_at + timedelta(minutes=5), h)
|
|
return h
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("1. solarfc: the evening figure serves until the bell starts")
|
|
h17 = hold_of(at(3, 17, 5))
|
|
check("a 17:05 reading is kept at 20:00, 22:00, 00:00, 03:00 and 05:59",
|
|
all(used(h17, t) for t in (at(3, 20, 0), at(3, 22, 0), at(4, 0, 0),
|
|
at(4, 3, 0), at(4, 5, 59))))
|
|
f_late = rs(GONE, GONE, at(4, 5, 59), h17)[0]
|
|
check("... at 05:59 it is tomorrow's 12.0 kWh as TODAY's, the whole day",
|
|
f_late is not None and f_late.kept and f_late.today.kwh == 12.0
|
|
and f_late.tomorrow is None and f_late.at == at(3, 17, 5)
|
|
and f_late.between(at(4, 6, 0), at(4, 20, 0)) is not None
|
|
and abs(f_late.between(at(4, 6, 0), at(4, 20, 0)) - 12.0) < 1e-6)
|
|
check("... and at 06:00 (12 h 55 min old, the bell starts) it is not",
|
|
not used(h17, at(4, 6, 0)) and not used(h17, at(4, 6, 30))
|
|
and not used(h17, at(4, 12, 0)))
|
|
check("... not the next evening either, and not on the 5th",
|
|
not used(h17, at(4, 22, 0)) and not used(h17, at(5, 3, 0)))
|
|
|
|
# The age limit of this case: 15 h, for a figure that was usable at midnight.
|
|
h1230 = hold_of(at(3, 12, 30))
|
|
check("a 12:30 reading (11 h 30 min old at midnight): used until 03:29 "
|
|
"(14 h 59 min old) ...",
|
|
used(h1230, at(4, 0, 0)) and used(h1230, at(4, 3, 29)))
|
|
check("... and not from 03:31 (15 h 01 min), though it is before 06:00",
|
|
not used(h1230, at(4, 3, 31)) and not used(h1230, at(4, 5, 30)))
|
|
h1600 = hold_of(at(3, 16, 0))
|
|
check("a 16:00 reading: the 06:00 window ends it, not the 15 h limit "
|
|
"(14 h old at 06:00)", used(h1600, at(4, 5, 59))
|
|
and not used(h1600, at(4, 6, 0)))
|
|
|
|
# Not used: a morning figure at night (12 h), and no come-back after midnight.
|
|
h0905 = hold_of(at(3, 9, 5))
|
|
check("a 09:05 reading: kept at 20:00 (10 h 55 min), not at 22:00 (12 h 55 min)",
|
|
used(h0905, at(3, 20, 0)) and not used(h0905, at(3, 22, 0)))
|
|
check("... and it does not come back after midnight (14 h 55 min at 00:00, "
|
|
"under the 15 h of the evening-to-morning case): it was not usable "
|
|
"at midnight",
|
|
not used(h0905, at(4, 0, 0)) and not used(h0905, at(4, 0, 2))
|
|
and not used(h0905, at(4, 2, 0)))
|
|
h1105 = hold_of(at(3, 11, 59))
|
|
check("the same for a reading that is 12 h 01 min old at midnight",
|
|
not used(h1105, at(4, 0, 0)) and not used(h1105, at(4, 0, 30)))
|
|
h1201 = hold_of(at(3, 12, 1))
|
|
check("... and one that is 11 h 59 min old at midnight is used",
|
|
used(h1201, at(4, 0, 0)) and used(h1201, at(4, 3, 0)))
|
|
check("a same-day figure past 12 h is not used: a 07:05 reading at 20:00 "
|
|
"(12 h 55 min)", not used(hold_of(at(3, 7, 5)), at(3, 20, 0)))
|
|
check("a figure over 15 h old is not used: 17:05 read, 08:06 next day "
|
|
"(15 h 01 min, bell started)", not used(h17, at(4, 8, 6)))
|
|
check("(a figure from the day before is not used either)",
|
|
not used(hold_of(at(2, 17, 5)), at(3, 22, 0))
|
|
and not used(hold_of(at(2, 17, 5)), at(4, 3, 0)))
|
|
|
|
# Today's figure is not tomorrow's, and the figure is for its own day only.
|
|
hd = solarfc.Hold()
|
|
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), hd)
|
|
fx = rs(GONE, GONE, at(4, 4, 0), hd)[0]
|
|
check("04:00: today's sensor is gone: the 4th gets the 3rd's TOMORROW "
|
|
"figure (12.0), never the 3rd's today figure (3.0)",
|
|
fx is not None and fx.today.kwh == 12.0)
|
|
fy = rs(st(5.0, at(4, 3, 50)), st(9.0, at(4, 3, 50)), at(4, 4, 0), hd)[0]
|
|
check("a fresh figure replaces the kept one at once (04:00: 5.0 today, "
|
|
"9.0 tomorrow; not kept)", fy is not None and not fy.kept
|
|
and fy.today.kwh == 5.0 and fy.tomorrow.kwh == 9.0)
|
|
fy2 = rs(st(5.0, at(4, 7, 55)), GONE, at(4, 8, 0), hd)[0]
|
|
check("... and at 08:00, today fresh and tomorrow's sensor down for over 3 h: "
|
|
"no tomorrow (the 3rd's figure is not the 5th's)",
|
|
fy2 is not None and fy2.tomorrow is None and fy2.today.kwh == 5.0)
|
|
|
|
# Restart: the saved hold keeps the figure.
|
|
for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
|
|
back = solarfc.Hold(json.loads(json.dumps(hold_of(at(3, 17, 5)).dump())))
|
|
fr = rs(GONE, GONE, now, back)[0]
|
|
check(f"restart at {label}: the saved hold (FC_HOLD_KEY) gives the "
|
|
"17:05 figure again", fr is not None and fr.kept
|
|
and fr.today.kwh == 12.0 and fr.at == at(3, 17, 5))
|
|
|
|
# F-1 (rig): a fresh today reading at 02:00 replaces the 17:05 figure; once
|
|
# that reading is stale (05:00) the 17:05 figure must not come back.
|
|
for label, kwh in (("brighter", 40.0), ("darker", 1.0)):
|
|
hn = hold_of(at(3, 17, 5))
|
|
f2 = rs(st(kwh, at(4, 2, 0)), st(kwh, at(4, 2, 0)), at(4, 2, 1), hn)[0]
|
|
f4 = rs(GONE, GONE, at(4, 4, 59), hn)[0]
|
|
snap = json.loads(json.dumps(hn.dump()))
|
|
f5 = [rs(GONE, GONE, t, hn)[0] for t in (at(4, 5, 1), at(4, 5, 59))]
|
|
f5s = [rs(st(kwh, at(4, 2, 0)), st(12.0, at(3, 17, 5)), t, hn)[0]
|
|
for t in (at(4, 5, 1), at(4, 5, 30))]
|
|
check(f"F-1 {label} 02:00 reading: used fresh at 02:01 and kept for "
|
|
"a missing sensor to 04:59 (3 h)",
|
|
f2 is not None and not f2.kept and f2.today.kwh == kwh
|
|
and f4 is not None and f4.today.kwh == kwh and not f4.kept)
|
|
check("... and from 05:01 (stale) to 05:59: no forecast, never the "
|
|
"17:05 figure (sensors gone, or showing their old readings)",
|
|
all(x is None for x in f5 + f5s))
|
|
back = solarfc.Hold(snap)
|
|
check("... also after a restart at 03:00 (the saved hold keeps the "
|
|
"02:00 reading)", rs(GONE, GONE, at(4, 3, 0), back)[0] is not None
|
|
and rs(GONE, GONE, at(4, 5, 1), back)[0] is None)
|
|
# Two today sensors, only one read fresh: the sum never had a fresh figure,
|
|
# so the evening one still stands in (as before).
|
|
h2 = solarfc.Hold()
|
|
td2 = [("sensor.t1", st(0.2, at(3, 17, 5))),
|
|
("sensor.t2", st(0.2, at(3, 17, 5)))]
|
|
tm2 = [("sensor.m1", st(6.0, at(3, 17, 5))),
|
|
("sensor.m2", st(6.0, at(3, 17, 5)))]
|
|
gone2 = [(e, GONE) for e, _s in td2], [(e, GONE) for e, _s in tm2]
|
|
solarfc.read_sum(td2, tm2, at(3, 17, 10), h2)
|
|
solarfc.read_sum([("sensor.t1", st(9.0, at(4, 2, 0))), td2[1]], tm2,
|
|
at(4, 2, 1), h2)
|
|
g5 = solarfc.read_sum(*gone2, at(4, 5, 1), h2)[0]
|
|
check("two today sensors, only one read fresh at 02:00: the evening sum "
|
|
"(12.0) still stands in at 05:01", g5 is not None and g5.kept
|
|
and g5.today.kwh == 12.0)
|
|
solarfc.read_sum([td2[0], ("sensor.t2", st(9.0, at(4, 2, 10)))], tm2,
|
|
at(4, 2, 11), h2)
|
|
check("... and once both have read fresh, it does not",
|
|
solarfc.read_sum(*gone2, at(4, 5, 20), h2)[0] is None)
|
|
|
|
# DST: the 25 h autumn day and the 23 h spring day. The window ends at 06:00
|
|
# LOCAL, not 6 h after midnight.
|
|
for (y, mo, d), label in (((2026, 10, 25), "25 h day 2026-10-25"),
|
|
((2027, 3, 28), "23 h day 2027-03-28")):
|
|
t17 = bru(y, mo, d - 1, 17, 5)
|
|
hs = hold_of(t17)
|
|
ok_in = used(hs, bru(y, mo, d - 1, 23, 59)) and all(
|
|
used(hs, bru(y, mo, d, hh, mm)) for hh, mm in
|
|
((0, 0), (1, 30), (4, 0), (5, 59)))
|
|
check(f"{label}: the 17:05 figure serves 23:59 to 05:59 local", ok_in)
|
|
check(f"... and not at 06:00 local", not used(hs, bru(y, mo, d, 6, 0))
|
|
and not used(hs, bru(y, mo, d, 6, 30)))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n2. The real Controller, whole nights (parallel processes)")
|
|
A, B = "inverter-a", "inverter-b"
|
|
PROFILE = ('{"periods": ['
|
|
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
|
' "eur_per_kwh": 0.26},'
|
|
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
|
' "eur_per_kwh": 0.34}]}')
|
|
# The price dips at 04:00 (a dynamic site): the cheap run starts after 03:00.
|
|
PROFILE_LATE = ('{"periods": ['
|
|
'{"name": "night", "days": "all", "from": "22:00",'
|
|
' "to": "04:00", "eur_per_kwh": 0.28},'
|
|
'{"name": "dawn", "days": "all", "from": "04:00",'
|
|
' "to": "07:00", "eur_per_kwh": 0.20},'
|
|
'{"name": "day", "days": "all", "from": "07:00",'
|
|
' "to": "22:00", "eur_per_kwh": 0.34}]}')
|
|
CHOICE = {"a": (True, "leftover_solar", False),
|
|
"b": (True, "cheap_hours", False)}
|
|
LAST = at(3, 17, 5)
|
|
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
|
|
+ [at(3, 21, 55)]
|
|
|
|
|
|
def opts(late=False, cap=2500):
|
|
o = dict(rp.options(True, False, 2))
|
|
o.update(cycle_cost_threshold_eur=0.07,
|
|
static_profile=PROFILE_LATE if late else PROFILE,
|
|
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT,
|
|
peak_cap_w=cap)
|
|
return o
|
|
|
|
|
|
def sensors(*, silent=True, fresh_from=None, fresh=(1.0, 5.0),
|
|
fresh_until=None):
|
|
"""Sensors that update until 17:05 (or all night) and then show that
|
|
reading for good; from `fresh_from` a new reading every call."""
|
|
def f(now, ent):
|
|
if fresh_from is not None and now >= fresh_from:
|
|
when = now if fresh_until is None else min(now, fresh_until)
|
|
return st(fresh[0] if ent == T_ENT else fresh[1], when)
|
|
when = min(now, LAST) if silent else now
|
|
return st(0.4 if ent == T_ENT else 6.0, when)
|
|
return f
|
|
|
|
|
|
def run_night(spec):
|
|
"""One scenario in this process -> a plain dict. `spec`: start (UTC),
|
|
hours, silent, fresh_from, fresh, restarts, soc, load_until_s, marks."""
|
|
start = spec["start"]
|
|
soc0 = spec.get("soc", {"a": 10.0, "b": 10.0})
|
|
house_w = spec.get("house_w", 700.0)
|
|
store = rp._Store()
|
|
for u, (use, mode, sell) in CHOICE.items():
|
|
for k, val in (("pack_use", use), ("pack_mode", mode),
|
|
("pack_sell", sell)):
|
|
store.data[unit_key(f"inverter-{u}", k)] = val
|
|
if spec.get("latch"):
|
|
store.data[m.CHARGE_LATCH_KEY] = spec["latch"]
|
|
sens = sensors(silent=spec.get("silent", True),
|
|
fresh_from=spec.get("fresh_from"),
|
|
fresh=spec.get("fresh", (1.0, 5.0)),
|
|
fresh_until=spec.get("fresh_until"))
|
|
clock = [EVENING[0]]
|
|
|
|
class H(rp.Hass):
|
|
async def state(self, e):
|
|
return GONE if spec.get("gone") else sens(clock[0], e)
|
|
|
|
hass = H(("a", "b"), lambda t: 300.0, soc0)
|
|
out = dict(marks={}, soc_at={})
|
|
|
|
def build(sto):
|
|
tmp = tempfile.mkdtemp(prefix="o34-")
|
|
c = Controller(dict(opts(spec.get("late", False),
|
|
spec.get("cap", 2500))), hass, sto,
|
|
rp._Pub(),
|
|
stop=asyncio.Event(),
|
|
peak_db_path=os.path.join(tmp, "tariff.db"),
|
|
price_history_path=os.path.join(tmp, "ph.json"))
|
|
for i in c.inverters:
|
|
i.dev_min, i.dev_max = -5000.0, 5000.0
|
|
c.auto = True
|
|
c.house_profile.load({d: {str(hh): house_w for hh in range(24)}
|
|
for d in ("2026-10-31", "2026-11-01",
|
|
"2026-11-02")})
|
|
return c
|
|
|
|
async def go():
|
|
cur = store
|
|
c = build(cur)
|
|
real_dt, real_hv = m.datetime, homeview.datetime
|
|
|
|
class _DT(real_dt):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return clock[0] if tz is None else clock[0].astimezone(tz)
|
|
|
|
m.datetime = homeview.datetime = _DT
|
|
try:
|
|
for t_pre in EVENING:
|
|
clock[0] = t_pre
|
|
await c._read_forecast()
|
|
pending = sorted(spec.get("restarts", ()))
|
|
marks = sorted(spec.get("marks", ()))
|
|
first, t = True, 0.0
|
|
hz = spec["hours"] * 3600.0
|
|
while t < hz:
|
|
now = start + timedelta(seconds=t)
|
|
clock[0] = now
|
|
if pending and now >= pending[0]:
|
|
pending.pop(0)
|
|
data = json.loads(json.dumps(cur.data))
|
|
cur = rp._Store()
|
|
cur.data.update(data)
|
|
c = build(cur)
|
|
first = True
|
|
if first or int(t) % 600 == 0:
|
|
# The tariff, resolved here at its own pace: the real
|
|
# refresh task sleeps on the wall clock.
|
|
hor = float(m.price_option(c.o, "price_horizon_h"))
|
|
c.tariff_plan = await m.resolve_tariff(
|
|
c.o, now, now + timedelta(hours=hor), now=now,
|
|
history=c.price_history, watch=c.price_watch)
|
|
c.tariff_at = now
|
|
first = False
|
|
hass.t = t
|
|
imp, exp = split_signed(hass.grid())
|
|
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
|
ingest_ts=now,
|
|
ingest_mono=time.monotonic()))
|
|
await c.read_inputs(now_mono=t)
|
|
await c.cycle()
|
|
hass.advance(5.0, 10.0)
|
|
if int(t) % 300 == 0:
|
|
out["soc_at"][now.astimezone(L).strftime("%H:%M")] = (
|
|
hass.plants["a"].soc, hass.plants["b"].soc,
|
|
hass.plants["a"].p, c.solar_fc is None,
|
|
bool(c.solar_fc and c.solar_fc.kept),
|
|
None if c.plan_targets() is None
|
|
else c.plan_targets().get(A))
|
|
while marks and now >= marks[0]:
|
|
marks.pop(0)
|
|
cards = homeview.battery_rows(c, False, True)
|
|
blk = homeview.solar_block(c, now)
|
|
a_card = next(b for b in cards if b["name"] == A)
|
|
out["marks"][now.astimezone(L).strftime("%H:%M")] = dict(
|
|
line=blk["line"], state=blk["state"],
|
|
why_a=a_card["why_key"],
|
|
fc_none=c.solar_fc is None,
|
|
kept=bool(c.solar_fc and c.solar_fc.kept),
|
|
target_a=(c.plan_targets() or {}).get(A),
|
|
soc_a=hass.plants["a"].soc,
|
|
p_a=hass.plants["a"].p,
|
|
charging=hass.plants["b"].p < -200.0)
|
|
t += 5.0
|
|
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
|
|
out["latch"] = getattr(c, "_charge_latch", None) is not None
|
|
finally:
|
|
m.datetime, homeview.datetime = real_dt, real_hv
|
|
return out
|
|
|
|
return asyncio.run(go())
|
|
|
|
|
|
NIGHT = at(3, 22, 0)
|
|
SCEN = {
|
|
"fresh": dict(start=NIGHT, hours=9, silent=False,
|
|
marks=[at(4, 4, 30)]),
|
|
"silent": dict(start=NIGHT, hours=9,
|
|
marks=[at(4, 1, 0), at(4, 4, 30), at(4, 6, 30)]),
|
|
"restart": dict(start=NIGHT, hours=9,
|
|
restarts=[at(4, 1, 0), at(4, 4, 0)],
|
|
marks=[at(4, 1, 30), at(4, 4, 30)]),
|
|
# c. the charge cannot start before 03:00 (the price dips at 04:00)
|
|
"late": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0,
|
|
soc={"a": 40.0, "b": 70.0}, marks=[at(4, 3, 30)]),
|
|
"late_fresh": dict(start=at(4, 2, 30), hours=4.5, late=True,
|
|
house_w=500.0, soc={"a": 40.0, "b": 70.0},
|
|
silent=False),
|
|
# d. a fresh, darker figure at 02:00 during the charge (a lighter house
|
|
# use, so the level has room to rise)
|
|
"dark": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
|
|
house_w=500.0, fresh_from=at(4, 2, 0), fresh=(1.0, 5.0),
|
|
marks=[at(4, 1, 59), at(4, 2, 5)]),
|
|
"kept": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
|
|
house_w=500.0, marks=[at(4, 1, 59), at(4, 2, 5)]),
|
|
# before the charge starts: a fresh figure replaces the kept one, up and
|
|
# down, at once
|
|
"pre_kept": dict(start=at(4, 2, 30), hours=0.25, late=True,
|
|
house_w=500.0, marks=[at(4, 2, 44)]),
|
|
"pre_dark": dict(start=at(4, 2, 30), hours=0.25, late=True,
|
|
house_w=500.0, fresh_from=at(4, 2, 40),
|
|
fresh=(1.0, 5.0), marks=[at(4, 2, 44)]),
|
|
"pre_bright": dict(start=at(4, 2, 30), hours=0.25, late=True,
|
|
house_w=500.0, fresh_from=at(4, 2, 40),
|
|
fresh=(30.0, 30.0), marks=[at(4, 2, 44)]),
|
|
# d. the last hour, after the kept figure ended at 06:00
|
|
"tail": dict(start=at(4, 5, 40), hours=80 / 60.0,
|
|
soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)],
|
|
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]),
|
|
# a fresh, brighter figure at 03:10 lowers A's level below its SoC (A
|
|
# stops, B goes on), then the sensors go quiet: the forecast ends at 06:10
|
|
"lowered": dict(start=at(4, 2, 30), hours=4.5, house_w=500.0,
|
|
soc={"a": 30.0, "b": 30.0}, fresh_from=at(4, 3, 10),
|
|
fresh_until=at(4, 3, 10), fresh=(30.0, 30.0),
|
|
marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]),
|
|
# F1: the add-on was down when the latch of the night before ended; it is
|
|
# back inside a cheap run, with the sensors unavailable (no forecast)
|
|
"stale": dict(start=at(3, 22, 0), hours=0.5, gone=True,
|
|
latch={"until": at(3, 7, 15).isoformat(),
|
|
"born": at(2, 23, 0).isoformat(),
|
|
"levels": {A: 80.0, B: 80.0}},
|
|
marks=[at(3, 22, 20)]),
|
|
"stale_live": dict(start=at(3, 22, 0), hours=0.5, gone=True,
|
|
latch={"until": at(4, 7, 15).isoformat(),
|
|
"born": at(3, 22, 0).isoformat(),
|
|
"levels": {A: 80.0, B: 80.0}},
|
|
marks=[at(3, 22, 20)]),
|
|
# the same, B already full and a 1.5 kW limit: the run is sized at the
|
|
# battery's 2.5 kW rating and ends at 06:33, before A is there, so the
|
|
# next slice is planned after 06:00 and is only A's
|
|
"tail_bfull": dict(start=at(4, 5, 40), hours=80 / 60.0, cap=1500,
|
|
soc={"a": 65.0, "b": 95.0},
|
|
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 45)]),
|
|
}
|
|
# F-1 (rig 4v): silent from 17:05, one fresh reading at 02:00, silent again.
|
|
# It is stale at 05:00, before the 06:00 bell: the 17:05 figure it replaced
|
|
# must not come back. Brighter (A's level drops), with a restart at 03:00,
|
|
# and darker (the level rises).
|
|
F1_MARKS = [at(4, 1, 55)] + [at(4, 2, 15) + timedelta(minutes=15 * i)
|
|
for i in range(19)]
|
|
for k, kwh, rst, hw in (("f1_bright", 30.0, (), 700.0),
|
|
("f1_bright_rs", 30.0, (at(4, 3, 0),), 700.0),
|
|
("f1_dark", 1.0, (), 500.0)):
|
|
SCEN[k] = dict(start=at(4, 1, 30), hours=5.5, house_w=hw,
|
|
soc={"a": 50.0, "b": 50.0}, fresh_from=at(4, 2, 0),
|
|
fresh_until=at(4, 2, 0), fresh=(kwh, kwh),
|
|
restarts=list(rst), marks=F1_MARKS)
|
|
t0 = time.time()
|
|
with ProcessPoolExecutor(max_workers=min(6, os.cpu_count() or 2),
|
|
mp_context=get_context("fork")) as pool:
|
|
futs = {k: pool.submit(run_night, v) for k, v in SCEN.items()}
|
|
R = {k: f.result() for k, f in futs.items()}
|
|
print(f" {len(R)} scenarios in {time.time() - t0:.0f} s")
|
|
|
|
|
|
def show(k):
|
|
r = R[k]
|
|
print(f" {k:10} A {r['soc'][0]:.2f} % B {r['soc'][1]:.2f} % "
|
|
f"marks {', '.join(sorted(r['marks']))}")
|
|
|
|
|
|
for k in R:
|
|
show(k)
|
|
fa, si, rr = R["fresh"], R["silent"], R["restart"]
|
|
|
|
# a. silent from 17:05
|
|
check("a fresh figure all night: A ends at its planned level (87 %), B "
|
|
"full", abs(fa["soc"][0] - 86.9) < 0.5 and fa["soc"][1] > 94.5)
|
|
check("sensors silent from 17:05: A ends where the fresh run ends it "
|
|
f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %)",
|
|
abs(si["soc"][0] - fa["soc"][0]) < 0.3)
|
|
check("... and so does B (Cheap hours; the wide no-forecast gap does not "
|
|
f"stop its slices): {si['soc'][1]:.1f} against {fa['soc'][1]:.1f} %",
|
|
abs(si["soc"][1] - fa["soc"][1]) < 0.3)
|
|
check("A charges through 03:00 without a break (no step at 03:00)",
|
|
si["soc_at"]["03:30"][0] > si["soc_at"]["03:00"][0] + 4.0
|
|
and si["soc_at"]["03:00"][0] > si["soc_at"]["02:55"][0] + 0.3)
|
|
m1, m4 = si["marks"]["01:00"], si["marks"]["04:30"]
|
|
check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
|
|
"line says so (EN)",
|
|
not m1["fc_none"] and m1["kept"] and not m4["fc_none"] and m4["kept"]
|
|
and m4["state"] == "plan"
|
|
and m4["line"] == "Solar forecast read 17:05, kept for tonight."
|
|
and m4["target_a"] is not None
|
|
and near(m4["target_a"], fa["marks"]["04:30"]["target_a"], 1e-9))
|
|
check("... and the level is the fresh run's (the kept figure counts as a "
|
|
"fresh one)", near(m4["target_a"], 86.87, 0.2))
|
|
m6 = si["marks"]["06:30"]
|
|
check("06:30: the kept figure has ended (the bell started) and the screen "
|
|
"does not say 'kept' (the charge was done by then in this shape)",
|
|
m6["fc_none"] and "kept" not in m6["line"])
|
|
|
|
# b. restarts
|
|
check("restarts at 01:00 and 04:00 (a new Controller on the saved store): "
|
|
f"A {rr['soc'][0]:.1f}, B {rr['soc'][1]:.1f} % as without them",
|
|
abs(rr["soc"][0] - si["soc"][0]) < 0.3
|
|
and abs(rr["soc"][1] - si["soc"][1]) < 0.3)
|
|
check("... with the figure kept right after each restart",
|
|
all(rr["marks"][k]["kept"] and not rr["marks"][k]["fc_none"]
|
|
and rr["marks"][k]["target_a"] is not None
|
|
for k in ("01:30", "04:30")))
|
|
|
|
# c. a charge that cannot start before 03:00
|
|
la, lf = R["late"], R["late_fresh"]
|
|
ml = la["marks"]["03:30"]
|
|
check("the cheap hours start at 04:00: nothing charges at 03:30",
|
|
ml["soc_a"] < 40.1 and not ml["charging"])
|
|
check("... the kept figure still stands at 03:30 (no charge had started, so "
|
|
"no level to latch): the plan is kept, not 'no forecast'",
|
|
not ml["fc_none"] and ml["kept"] and ml["target_a"] is not None)
|
|
check("... and the charge starts at 04:00 on it: A ends where a fresh "
|
|
f"figure ends it ({la['soc'][0]:.1f} against {lf['soc'][0]:.1f} %)",
|
|
lf["soc"][0] > 50.0 and abs(la["soc"][0] - lf["soc"][0]) < 0.3
|
|
and abs(la["soc"][1] - lf["soc"][1]) < 0.3)
|
|
|
|
# d. a fresh figure replaces the kept one at once
|
|
dk, kp = R["dark"], R["kept"]
|
|
check("a fresh, darker figure at 02:00 during the charge: the level goes "
|
|
"UP at once (02:05)",
|
|
kp["marks"]["02:05"]["target_a"] is not None
|
|
and dk["marks"]["02:05"]["target_a"] is not None
|
|
and dk["marks"]["02:05"]["target_a"] > kp["marks"]["02:05"]["target_a"]
|
|
+ 3.0 and not dk["marks"]["02:05"]["kept"]
|
|
and dk["marks"]["01:59"]["kept"])
|
|
check("... and A charges on to it (more than on the kept figure: "
|
|
f"{dk['soc'][0]:.1f} against {kp['soc'][0]:.1f} %)",
|
|
dk["soc"][0] > kp["soc"][0] + 3.0)
|
|
pk, pd, pb = R["pre_kept"], R["pre_dark"], R["pre_bright"]
|
|
t_k, t_d, t_b = (x["marks"]["02:44"]["target_a"] for x in (pk, pd, pb))
|
|
print(f" level before any charge, 02:44: kept {t_k}, dark {t_d}, "
|
|
f"bright {t_b}")
|
|
check("before a charge has started (nothing charges at 02:44): a darker "
|
|
"fresh figure raises the level and a brighter one lowers it, at once",
|
|
all(not x["marks"]["02:44"]["charging"] for x in (pk, pd, pb))
|
|
and t_k is not None and t_d is not None and t_b is not None
|
|
and t_d > t_k + 3.0 and t_b < t_k - 3.0
|
|
and not pd["marks"]["02:44"]["kept"]
|
|
and not pb["marks"]["02:44"]["kept"])
|
|
|
|
# e. the last hour, after the kept figure ended
|
|
tl = R["tail"]
|
|
t5, t6, t7 = (tl["marks"][k] for k in ("05:50", "06:10", "06:40"))
|
|
check("05:50: charging on the kept figure; 06:10: the figure has ended",
|
|
t5["kept"] and not t5["fc_none"] and t6["fc_none"])
|
|
check("06:10 and 06:40 (the second after a restart at 06:30): A still "
|
|
"charges to its level, 'charges from the grid' is the Why",
|
|
t6["p_a"] < -500.0 and t7["p_a"] < -500.0
|
|
and t6["why_a"] == "grid_charge" and t7["why_a"] == "grid_charge")
|
|
check("... and ends the hour higher than it was (+8 % or more from 06:00)",
|
|
tl["soc_at"]["06:55"][0] > tl["soc_at"]["06:00"][0] + 8.0)
|
|
lo = R["lowered"]
|
|
l0, l1, l2 = (lo["marks"][k] for k in ("03:05", "03:20", "06:30"))
|
|
check("a fresh, brighter figure lowers A's level below its SoC during the "
|
|
f"charge ({l0['target_a']} -> {l1['target_a']}, SoC "
|
|
f"{l1['soc_a']:.1f} %), and A stops",
|
|
l0["target_a"] is not None and l1["target_a"] is not None
|
|
and l1["target_a"] < l1["soc_a"] - 2.0
|
|
and l1["target_a"] < l0["target_a"] - 10.0
|
|
and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5)
|
|
check("the sensors go quiet, the forecast ends (06:30): the latch holds the "
|
|
"LOWERED level, so A does not charge back up to the old one "
|
|
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), "
|
|
"while B goes on",
|
|
l2["fc_none"] and l2["target_a"] is not None
|
|
and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0
|
|
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0
|
|
and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0)
|
|
st_, sl_ = R["stale"], R["stale_live"]
|
|
check("F1: a latch from the night before (ended 07:15) loaded at a restart "
|
|
"inside a cheap run, with no forecast: nothing grid-charges "
|
|
f"(A {st_['soc'][0]:.1f}, B {st_['soc'][1]:.1f} %) and the line does "
|
|
"not say the charge goes on",
|
|
st_["soc"][0] < 10.2 and st_["soc"][1] < 10.2
|
|
and st_["marks"]["22:20"]["state"] == "no_forecast"
|
|
and not st_["latch"])
|
|
check("(control: the same latch, still valid until tomorrow 07:15, does "
|
|
f"charge both: A {sl_['soc'][0]:.1f}, B {sl_['soc'][1]:.1f} %)",
|
|
sl_["soc"][0] > 11.0 and sl_["soc"][1] > 11.0
|
|
and sl_["marks"]["22:20"]["state"] == "latched")
|
|
for k in ("f1_bright", "f1_bright_rs"):
|
|
fb = R[k]
|
|
mk = fb["marks"]
|
|
lo_t = mk["02:15"]["target_a"]
|
|
after = [mk[h] for h in sorted(mk) if h >= "02:15"]
|
|
print(f" {k}: A level 01:55 {mk['01:55']['target_a']} -> 02:15 "
|
|
f"{lo_t}; A {fb['soc_at']['02:15'][0]:.1f} % at 02:15, "
|
|
f"{fb['soc_at']['06:55'][0]:.1f} % at 06:55; lines "
|
|
+ " | ".join(sorted({x['line'] for x in after})))
|
|
check(f"F-1 {k}: a brighter fresh figure at 02:00 lowers A's level "
|
|
"below its SoC and A stops",
|
|
lo_t is not None and mk["01:55"]["target_a"] is not None
|
|
and lo_t < mk["01:55"]["target_a"] - 10.0
|
|
and lo_t < mk["02:15"]["soc_a"])
|
|
check(f"... after 02:00 the screen never says 'read 17:05' and from "
|
|
"05:15 there is no forecast, with the LOWERED level",
|
|
all("17:05" not in x["line"] and not (x["kept"] and
|
|
not x["fc_none"])
|
|
for x in after)
|
|
and all(mk[h]["fc_none"] and near(mk[h]["target_a"], lo_t, 0.5)
|
|
for h in mk if "05:15" <= h < "07:00"))
|
|
check(f"... so A stays at its lowered level to 07:00 "
|
|
f"({fb['soc_at']['02:30'][0]:.1f} -> "
|
|
f"{fb['soc_at']['06:55'][0]:.1f} %)",
|
|
fb["soc_at"]["06:55"][0] < fb["soc_at"]["02:30"][0] + 1.0)
|
|
fd = R["f1_dark"]
|
|
md = fd["marks"]
|
|
print(f" f1_dark: A level 01:55 {md['01:55']['target_a']} -> 02:15 "
|
|
f"{md['02:15']['target_a']}; A {fd['soc_at']['06:55'][0]:.1f} % "
|
|
"at 06:55")
|
|
check("F-1 darker fresh figure at 02:00: the level rises, is held after "
|
|
"05:00 with no forecast (never 'read 17:05'), and A charges to it",
|
|
md["02:15"]["target_a"] is not None
|
|
and md["01:55"]["target_a"] is not None
|
|
and md["02:15"]["target_a"] > md["01:55"]["target_a"] + 3.0
|
|
and all("17:05" not in md[h]["line"] for h in md if h >= "02:15")
|
|
and all(md[h]["fc_none"] for h in md if "05:15" <= h < "07:00")
|
|
and near(fd["soc_at"]["06:55"][0], md["02:15"]["target_a"], 1.5))
|
|
|
|
tb = R["tail_bfull"]
|
|
check("B full: the run ends at 06:33 before A is at its level, so the next "
|
|
"slice is only A's, planned after the figure has ended with A's "
|
|
"latched level: A goes on charging (06:10, 06:45; "
|
|
f"{tb['soc_at']['06:00'][0]:.1f} to {tb['soc_at']['06:55'][0]:.1f} %)",
|
|
tb["marks"]["06:10"]["fc_none"] and tb["marks"]["06:10"]["p_a"] < -500.0
|
|
and tb["marks"]["06:45"]["p_a"] < -500.0
|
|
and tb["soc_at"]["06:55"][0] > tb["soc_at"]["06:00"][0] + 8.0)
|
|
check("the screens say what control does (EN): 'No solar forecast now. The "
|
|
"charge under way goes on to its planned level.'",
|
|
t6["state"] == "latched" and t7["state"] == "latched"
|
|
and t6["line"] == "No solar forecast now. The charge under way goes "
|
|
"on to its planned level.")
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n3. The text (EN and NL)")
|
|
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
|
encoding="utf-8"))
|
|
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
|
|
encoding="utf-8"))
|
|
l_en, l_nl = (x["home_words"]["solar"]["latched"] for x in (en, nl))
|
|
print(f" EN {l_en!r}\n NL {l_nl!r}")
|
|
check("the new line is short and plain in both languages and the NL is not "
|
|
"the English", l_en != l_nl and len(l_en.split()) <= 24
|
|
and len(l_nl.split()) <= 24 and "{" not in l_en + l_nl)
|
|
check("'kept for tonight' is unchanged (EN and NL)",
|
|
en["home_words"]["solar"]["kept"]
|
|
== "Solar forecast read {at}, kept for tonight."
|
|
and nl["home_words"]["solar"]["kept"]
|
|
== "Zonneprognose van {at}, bewaard voor vannacht.")
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n4. The latch alone: a battery that leaves the plan leaves the latch")
|
|
from types import SimpleNamespace # noqa: E402
|
|
from app import modetrade, priceruns # noqa: E402
|
|
|
|
CLOCK = [at(4, 1, 0)]
|
|
_dt0 = m.datetime
|
|
|
|
|
|
class _FixedDT(_dt0):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return CLOCK[0] if tz is None else CLOCK[0].astimezone(tz)
|
|
|
|
|
|
m.datetime = homeview.datetime = _FixedDT
|
|
_tmp = tempfile.mkdtemp(prefix="o34-")
|
|
lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0,
|
|
{"a": 40.0, "b": 40.0}),
|
|
rp._Store(), rp._Pub(), stop=asyncio.Event(),
|
|
peak_db_path=os.path.join(_tmp, "tariff.db"),
|
|
price_history_path=os.path.join(_tmp, "ph.json"))
|
|
n0 = at(4, 1, 0)
|
|
lruns = SimpleNamespace(cheap=priceruns.Run(at(3, 22, 0), at(4, 7, 0), 0.26))
|
|
charging = SimpleNamespace(action=modetrade.CHARGE,
|
|
roles={A: modetrade.CHARGE, B: modetrade.CHARGE})
|
|
resting = SimpleNamespace(action=None, roles={A: modetrade.HOLD,
|
|
B: modetrade.HOLD})
|
|
|
|
|
|
def latch_step(fc, tick, targets, now=n0, runs=None, packs=None):
|
|
CLOCK[0] = now
|
|
lc.solar_fc, lc.mode_tick = fc, tick
|
|
lc._latch_charge(now, runs or lruns, targets, packs)
|
|
return lc._charge_latch, lc.store.data.get(m.CHARGE_LATCH_KEY)
|
|
|
|
|
|
FC = object() # a forecast is read (fresh or kept)
|
|
lt, saved = latch_step(FC, charging, {A: 80.0, B: 70.0})
|
|
check("both batteries charge with a forecast: both are latched at their "
|
|
"levels, to the run's end + one slot, and saved",
|
|
lt is not None and lt[1] == {A: 80.0, B: 70.0}
|
|
and lt[0] == at(4, 7, 15) and saved is not None
|
|
and saved["levels"] == {A: 80.0, B: 70.0})
|
|
lt, saved = latch_step(FC, charging, {B: 70.0})
|
|
check("A leaves the plan (its mode or use changed): A is dropped from the "
|
|
"latch at once, B stays", lt is not None and lt[1] == {B: 70.0}
|
|
and saved["levels"] == {B: 70.0})
|
|
lc.solar_fc = None
|
|
check("... and with the forecast then gone, A has no latched level (it is "
|
|
"not charged back to 80 %)", lc.plan_targets() == {B: 70.0})
|
|
lt, saved = latch_step(FC, resting, {A: 80.0, B: 70.0})
|
|
check("A back in the plan while nothing charges: it does not rejoin",
|
|
lt is not None and lt[1] == {B: 70.0})
|
|
lt, saved = latch_step(FC, resting, {A: 80.0})
|
|
check("B leaves the plan too: the latch is empty, so it is gone and "
|
|
"cleared from the store", lt is None and saved is None
|
|
and lc.plan_targets() is None)
|
|
latch_step(FC, charging, {A: 80.0})
|
|
lc.solar_fc = None
|
|
ia = next((i for i in lc.inverters if i.name == A), None)
|
|
st_below = st_at = st_after = None
|
|
lc.mode_runs = lruns
|
|
if ia is not None:
|
|
ia.soc = 50.0
|
|
st_below = homeview.plan_state(lc)
|
|
CLOCK[0] = at(4, 7, 5) # the run has ended, the latch has not
|
|
st_after = homeview.plan_state(lc)
|
|
CLOCK[0] = n0
|
|
ia.soc = 80.0
|
|
st_at = homeview.plan_state(lc)
|
|
check("the screen says the charge goes on only while the latched battery is "
|
|
f"below its level ({st_below!r} at 50 %, {st_at!r} at 80 %)",
|
|
st_below == "latched" and st_at == "no_forecast")
|
|
check("F2: and only while a cheap run is on: at 07:05, the run over and the "
|
|
f"latch in its grace slot, it does not ({st_after!r})",
|
|
st_after == "no_forecast")
|
|
|
|
print("\n5. The latch ends: expiry, a changed mode, the night's bell")
|
|
long_run = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2))
|
|
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
|
lt, saved = latch_step(None, resting, None, now=at(4, 7, 20), runs=long_run)
|
|
check("F1: past its end (07:15) the latch is dropped even with a cheap run "
|
|
"on and no forecast (it was only dropped when no run held)",
|
|
lt is None and saved is None)
|
|
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
|
CLOCK[0] = at(4, 7, 20)
|
|
lc.solar_fc = None
|
|
check("... plan_targets() ignores a latch past its end", lc.plan_targets()
|
|
is None)
|
|
CLOCK[0] = at(4, 1, 0)
|
|
check("... and _load_latch() does not bring one back from the store",
|
|
m._load_latch({"until": at(4, 7, 15).isoformat(), "born":
|
|
at(4, 1, 0).isoformat(), "levels": {A: 80.0}},
|
|
at(4, 7, 20)) is None
|
|
and m._load_latch({"until": at(4, 7, 15).isoformat(), "born":
|
|
at(4, 1, 0).isoformat(), "levels": {A: 80.0}},
|
|
at(4, 6, 0)) is not None)
|
|
|
|
# F3: the mode or use of a latched battery changes
|
|
def packs_of(a_mode, a_use=True):
|
|
return [modetrade.Pack(A, a_use, a_mode, False, 2500.0, 50.0, 10.0),
|
|
modetrade.Pack(B, True, "cheap_hours", False, 2500.0, 50.0, 10.0)]
|
|
|
|
|
|
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
|
lt, _s = latch_step(None, resting, None, packs=packs_of("leftover_solar"))
|
|
check("F3: A is still Leftover solar and on: it stays latched",
|
|
lt is not None and lt[1] == {A: 80.0})
|
|
lt, saved = latch_step(None, resting, None, packs=packs_of("solar_first"))
|
|
check("F3: the user changes A to Solar first (no forecast): it leaves the "
|
|
"latch, which is then gone and cleared, so the narrow gap goes too",
|
|
lt is None and saved is None)
|
|
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
|
lt, _s = latch_step(None, resting, None,
|
|
packs=packs_of("leftover_solar", a_use=False))
|
|
check("F3: or switches A off", lt is None)
|
|
|
|
# F4: the night's bell
|
|
lc._charge_latch = None
|
|
lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0))
|
|
check("F4: the latch began at 01:00, so its night's bell is 06:00 the same "
|
|
"day", m._night_limit(lt[2]) == at(4, 6, 0)
|
|
and m._night_limit(at(3, 23, 0)) == at(4, 6, 0)
|
|
and m._night_limit(at(4, 6, 0)) == at(5, 6, 0))
|
|
r1 = SimpleNamespace(cheap=priceruns.Run(at(4, 0, 0), at(4, 7, 0), .2))
|
|
r2 = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2))
|
|
r3 = SimpleNamespace(cheap=priceruns.Run(at(4, 6, 0), at(4, 9, 0), .2))
|
|
lc._charge_latch = None
|
|
lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0), runs=r1)
|
|
check("F4: first run 00:00-07:00 sets the end at 07:15", lt[0] == at(4, 7, 15))
|
|
lt, _s = latch_step(None, resting, None, now=at(4, 6, 50), runs=r1)
|
|
lt2, _s = latch_step(None, resting, None, now=at(4, 7, 5), runs=r2)
|
|
check("F4: a back-to-back run that starts after the bell (07:00-23:00) does "
|
|
"NOT carry it on: still 07:15", lt2 is not None
|
|
and lt2[0] == at(4, 7, 15))
|
|
lt3, _s = latch_step(None, resting, None, now=at(4, 7, 20), runs=r2)
|
|
check("F4: and at 07:20 it has ended, though the run goes on", lt3 is None)
|
|
lc._charge_latch = (at(4, 6, 10), {A: 80.0}, at(4, 1, 0))
|
|
lt, _s = latch_step(None, resting, None, now=at(4, 6, 5), runs=r3)
|
|
check("F4: a run that starts AT the bell (06:00-09:00) does carry it on, "
|
|
"to its end + one slot", lt is not None and lt[0] == at(4, 9, 15))
|
|
m.datetime = homeview.datetime = _dt0
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED:")
|
|
for f in fails:
|
|
print(" -", f)
|
|
sys.exit(1)
|
|
print("all checks passed")
|