OPT-34 fix (rig F-1): a replaced evening forecast does not come back
checks / unit (pull_request) Successful in 21m5s
infra-pin / fresh (pull_request) Successful in 2s

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>
This commit is contained in:
glenn schrooyen
2026-11-01 12:26:05 +01:00
co-authored by Claude
parent d4c01daca5
commit 6263a57b6b
4 changed files with 117 additions and 5 deletions
+95
View File
@@ -36,6 +36,8 @@ DECISIONS note and the dev report.
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.
"""
@@ -193,6 +195,47 @@ for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
"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"),
@@ -431,6 +474,19 @@ SCEN = {
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:
@@ -563,6 +619,45 @@ 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 "