OPT-34 round 3: the latch ends on time, with the run, with the mode, and with the night
- 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:
co-authored by
Claude
parent
57b0eb2f20
commit
bb57a85b35
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user