OPT-34 round 3: the latch ends on time, with the run, with the mode, and with the night
checks / unit (pull_request) Successful in 20m15s
infra-pin / fresh (pull_request) Successful in 1s

- F1: an expired latch is dropped whatever the run state; plan_targets() and the
  store loader ignore one that has ended (no stale latch after a restart).
- F2: the "charge goes on" line only while a cheap run is on.
- F3: a battery that is off or no longer Leftover solar leaves the latch.
- F4: a run carries the latch on only if it starts by the bell start (06:00
  local) after the latch began (manager's call, 2026-10-31).
Tests: test_opt34 scenarios stale/stale_live and part 5; test_modes_p4 asks at a
moment inside the latch.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-31 23:04:36 +01:00
co-authored by Claude
parent 57b0eb2f20
commit bb57a85b35
5 changed files with 191 additions and 30 deletions
@@ -16,7 +16,16 @@ known, so the add-on does not depend on it.
no-forecast gap does not refuse the next slice of the run (`weather_held()`,
as TARIFF-24 does for the run under way). While a
forecast is there the latch follows the plan, up or down as a fresh figure moves
it. It lasts through every cheap run on and one price slot after the last.
it. It lasts through every cheap run on and one price slot after the last, and it
ends at once when its end time passes, whatever the run state (it is also
ignored at a restart if it has ended). A battery that is switched off or
changed to another mode leaves it.
*Night bound (manager's call, 2026-10-31):* a run carries the latch on only if
it starts by the bell start (06:00 local) after the latch began; that run
finishes, plus one slot. Chosen over a fixed 12 h because the night tariff
(22:00-07:00) needs the 06:00-07:00 slice and 12 h from a late start would
reach into the day. With dynamic prices a chain of cheap slots cannot carry a
night's level into the next day.
2. *The evening's tomorrow figure is today's until the bell starts*, 06:00 local
(`BELL_FROM_H`, `solarfc._bell_start()`), not until midnight + 3 h.
Nothing has been produced yet, so it is still the whole day.
@@ -37,7 +46,7 @@ plan, the screens and a restart also need the figure itself.
06:00 and unchanged. After the figure ends, with a charge still going on: "No
solar forecast now. The charge under way goes on to its planned level." /
"Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau."
(`home_words.solar.latched`, only while a latched battery is below its level).
(`home_words.solar.latched`, only while a cheap run is on and a latched battery is below its level).
**Not used.** A figure over 15 h old; a morning figure at night (12 h); a figure
for another day; a bad value or unit; and no latch for a charge that has not
@@ -50,4 +59,4 @@ A fresh figure that LOWERS the level during a charge still lowers it, as before.
**Mutants** (all red in `test_opt34.py`): no latch; window back to midnight + 3 h;
15 h without a limit; no "usable at midnight" rule; the latch without the narrow
gap, without its save, or not kept through the next run; the window 6 h after
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off.
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level.
+7 -2
View File
@@ -536,8 +536,13 @@ def plan_cover(c, name):
def _latched_below(c) -> bool:
"""OPT-34: no forecast, but a battery is still below the level its night
charge keeps (`Controller.plan_targets()`), so control goes on charging."""
"""OPT-34: no forecast, but a cheap run is on and a battery is still below
the level its night charge keeps (`Controller.plan_targets()`), so control
goes on charging."""
rp = getattr(c, "mode_runs", None)
if rp is None or rp.cheap is None or not rp.cheap.holds(
datetime.now(timezone.utc)):
return False
targets = getattr(c, "plan_targets", lambda: None)() or {}
return any(inv.soc is not None and inv.name in targets
and inv.soc < targets[inv.name] for inv in c.inverters)
+49 -18
View File
@@ -221,18 +221,29 @@ CHARGE_LATCH_GRACE_S = 900.0
FORECAST_READ_S = 60.0
def _load_latch(data):
"""`(until, {name: level %})` from the store, or None if it is not a
well-formed latch (OPT-34)."""
def _load_latch(data, now=None):
"""`(until, {name: level %}, born)` from the store, or None if it is not a
well-formed latch or has ended (OPT-34)."""
try:
until = datetime.fromisoformat(data["until"])
born = datetime.fromisoformat(data.get("born") or data["until"])
levels = {str(n): float(v) for n, v in data["levels"].items()}
except (TypeError, KeyError, ValueError, AttributeError):
return None
if until.tzinfo is None or not levels or not all(
if until.tzinfo is None or born.tzinfo is None or not levels or not all(
math.isfinite(v) and 0.0 <= v <= 100.0 for v in levels.values()):
return None
return until, levels
if now is not None and now >= until:
return None
return until, levels, born
def _night_limit(born):
"""OPT-34: a run that starts after this cannot carry the latch on: the
first bell start (`solarfc.BELL_FROM_H`, local) after `born`."""
d0, d1 = solarfc._bounds(born)
bell = solarfc._bell_start(d0)
return bell if bell > born else solarfc._bell_start(d1)
def why_raised(resolution, claims) -> bool:
@@ -1028,7 +1039,8 @@ class Controller:
self._plan_targets = None
# OPT-34: `(until, {name: level %})` of a night charge under way.
self._charge_latch = _load_latch(
(getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY))
(getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY),
datetime.now(timezone.utc))
self.house_profile = None
if self.fc_entity is not None:
self.house_profile = dayplan.HouseProfile(
@@ -2384,10 +2396,12 @@ class Controller:
of a night charge that has started (OPT-34): it keeps that level."""
if self.solar_fc is None:
lt = self._charge_latch
return None if lt is None else dict(lt[1])
if lt is None or datetime.now(timezone.utc) >= lt[0]:
return None
return dict(lt[1])
return self._plan_targets
def _latch_charge(self, now, runs, targets) -> None:
def _latch_charge(self, now, runs, targets, packs=None) -> None:
"""OPT-34, once per cycle after `modetrade.decide()`. A night grid
charge that has started keeps its planned level when the forecast goes
mid-charge (the kept figure ends and the sensors are still silent).
@@ -2400,15 +2414,26 @@ class Controller:
is none, `plan_targets()` gives the latched levels, so the charge goes
on to them, and `weather_held()` keeps the narrow price gap. The latch
lasts through every cheap run on and one price slot after the last
(`CHARGE_LATCH_GRACE_S`); a charge that never started has none."""
(`CHARGE_LATCH_GRACE_S`), but a run is carried on only if it starts
by the bell start of the night the latch began in (`_night_limit()`;
the run under way then finishes). It is dropped when `now` passes its
end whatever the run state, and a battery that is off or no longer in
a mode with a planned level (`packs`) leaves it. A charge that never
started has none."""
old = lt = self._charge_latch
tick = self.mode_tick
run = None if runs is None else runs.cheap
in_run = run is not None and run.holds(now)
until = None if run is None else run.end + timedelta(
seconds=CHARGE_LATCH_GRACE_S)
if lt is not None and not in_run and now >= lt[0]:
grace = timedelta(seconds=CHARGE_LATCH_GRACE_S)
if lt is not None and now >= lt[0]:
lt = None
if lt is not None and packs is not None:
ok = {p.name for p in packs
if p.use and p.mode in packmode.PLAN_CHARGE_MODES}
lv = {n: v for n, v in lt[1].items() if n in ok}
lt = (lt[0], lv, lt[2]) if lv else None
# A run may carry the latch on only if it starts by the night's bell.
carry = in_run and (lt is None or run.start <= _night_limit(lt[2]))
if in_run and self.solar_fc is not None:
# Rebuilt from the plan: a battery already latched follows its
# level up or down whatever its role this tick (a fresh figure may
@@ -2419,14 +2444,20 @@ class Controller:
if tick is not None and tick.action == modetrade.CHARGE:
lv.update({n: float(tg[n]) for n, r in tick.roles.items()
if r == modetrade.CHARGE and n in tg})
lt = (max(until, lt[0]) if lt is not None else until, lv) \
if lv else None
elif lt is not None and in_run:
lt = (max(lt[0], until), lt[1])
if not lv:
lt = None
elif lt is None:
lt = (run.end + grace, lv, now)
else:
lt = (max(run.end + grace, lt[0]) if carry else lt[0], lv,
lt[2])
elif lt is not None and carry:
lt = (max(lt[0], run.end + grace), lt[1], lt[2])
if lt != old:
self._charge_latch = lt
self.store.set(CHARGE_LATCH_KEY, None if lt is None else {
"until": lt[0].isoformat(), "levels": lt[1]})
"until": lt[0].isoformat(), "levels": lt[1],
"born": lt[2].isoformat()})
def _mode_roles(self, now, maint_results):
"""OPT-11 P3: each battery's role and range this tick. Grid charging
@@ -2500,7 +2531,7 @@ class Controller:
blocked=self._charge_blocked_q == quarter,
done_charge=done_c, done_sell=done_s, targets=targets,
draining=any(r.phase == DRAIN for r in maint_results or ()))
self._latch_charge(now, runs, targets)
self._latch_charge(now, runs, targets, packs)
# A charge the guard or the headroom stopped stays off this period.
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
# (None) in a quarter stops this tick only, so a gap cannot cost the
+8 -3
View File
@@ -810,11 +810,16 @@ 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")
c._charge_latch = latch
# the latch ends at its end time; this replay ran in September, so ask at a
# moment inside it
_real_dt = rp.m.datetime
rp.m.datetime = type("_DT", (datetime,), {"now": classmethod(
lambda cls, tz=None: latch[0] - timedelta(hours=1))})
check("OPT-34: with a charge under way (B charged in this run), the level "
"stays: plan_targets() gives it, the line says the charge goes on",
"stays: plan_targets() gives it (the line: test_opt34.py)",
latch is not None and c.plan_targets() is not None
and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B]
and homeview.day_key(c, B) is None)
and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B])
rp.m.datetime = _real_dt
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,
+115 -4
View File
@@ -262,6 +262,8 @@ def run_night(spec):
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)),
@@ -270,7 +272,7 @@ def run_night(spec):
class H(rp.Hass):
async def state(self, e):
return sens(clock[0], 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={})
@@ -410,6 +412,18 @@ SCEN = {
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
@@ -537,6 +551,18 @@ check("the sensors go quiet, the forecast ends (06:30): the latch holds the "
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")
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 "
@@ -573,6 +599,17 @@ 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}),
@@ -587,9 +624,10 @@ resting = SimpleNamespace(action=None, roles={A: modetrade.HOLD,
B: modetrade.HOLD})
def latch_step(fc, tick, targets, now=n0):
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, lruns, targets)
lc._latch_charge(now, runs or lruns, targets, packs)
return lc._charge_latch, lc.store.data.get(m.CHARGE_LATCH_KEY)
@@ -617,15 +655,88 @@ check("B leaves the plan too: the latch is empty, so it is gone and "
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 = 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: