OPT-11 P4 review: Cheap hours ignores the plan; midnight-stale forecast; stricter ids

- Cheap hours only fills to the ceiling with or without a forecast, as the
  design (section 1) says. Skipping the sun's share cost 0.31 EUR on real
  2026 prices (29.72 against P3's 29.41) and saved nothing on the P3 set.
- A forecast reading from before local midnight is stale: until the
  integration refreshes, "left today" is still yesterday's 0 and
  "tomorrow" is really today.
- Forecast entity ids must be sensor.[a-z0-9_]+ (they go into the
  /api/states/<id> path).
- House profile: a stored day that is not a date, or is after today, is
  dropped instead of kept forever.
- Text: the forecast line says "for the rest of today"; the learning line
  no longer promises "tomorrow" (a plan needs one full day).
- Tests for four surviving review mutants (hour sample floor, the dear run
  after the cheap run, share by room, daytime use) and the fixes above.
- Replay tool: `--fc zero` (a forecast of no sun).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-29 22:08:47 +02:00
co-authored by Claude Opus 5.5
parent aabc2a27fd
commit fbd884797d
10 changed files with 127 additions and 56 deletions
+75 -21
View File
@@ -116,15 +116,15 @@ check("the harness bites: Leftover solar grid-charging with no plan moves "
_read = solarfc.read
def _as_sunny(today, tomorrow, now, **kw):
return _read({"state": "200", "attributes": {"unit_of_measurement":
"kWh"},
def _as_dark(today, tomorrow, now, **kw):
return _read({"state": "0", "attributes": {"unit_of_measurement":
"kWh"},
"last_updated": now.isoformat()}, None, now)
check("...and an unavailable forecast read as a sunny one moves "
"n2_sf_ch_fc_down (Cheap hours would skip its charge)",
_moved("n2_sf_ch_fc_down", lambda: setattr(solarfc, "read", _as_sunny),
check("...and an unavailable forecast read as a dark one moves "
"n2_sf_ls_fc_down (Leftover solar would buy its shortfall)",
_moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read", _as_dark),
lambda: setattr(solarfc, "read", _read)))
# ============================================================================ #
@@ -204,6 +204,18 @@ check("with it, tomorrow's sun is counted",
fcs, _ = solarfc.read(st("12.5"), st("20.0", at=old), NOW)
check("a stale tomorrow is dropped, today is kept",
fcs is not None and fcs.tomorrow is None)
# Review (PR #156): just after local midnight the integration may not have
# refreshed yet, so "left today" is still yesterday's 0 and "tomorrow" is
# really today. That is stale, not a dark day.
MID = datetime(2026, 9, 22, 0, 10, tzinfo=L).astimezone(timezone.utc)
EVE = datetime(2026, 9, 21, 21, 30, tzinfo=L).astimezone(timezone.utc)
check("a today reading from before local midnight is stale (not 0 kWh)",
solarfc.read(st("0.0", at=EVE), st("15", at=EVE), MID)
== (None, solarfc.STALE))
fcm, _ = solarfc.read(st("15", at=MID - timedelta(minutes=2)),
st("18", at=EVE), MID)
check("...and a tomorrow reading from before midnight is dropped",
fcm is not None and fcm.tomorrow is None)
# ============================================================================ #
print("3. the house profile")
@@ -241,6 +253,14 @@ check("only the last 7 days are kept", len(hp9.days) <= 8)
hp_rt = dayplan.HouseProfile(hp.dump())
check("dump and load give the same figures", hp_rt.hour_w(18) == round(
hp.hour_w(18), 1) and hp_rt.ready())
bogus = dayplan.HouseProfile({"2099-01-01": {str(h): 9e5 for h in range(24)},
"zz": {str(h): 9e5 for h in range(24)}})
check("a stored day that is not a date is dropped on load",
"zz" not in bogus.days)
bogus.add(UTC0, 500.0)
bogus.add(UTC0 + timedelta(hours=1), 500.0)
check("a stored day after today (a wrong clock) is pruned, not kept forever",
"2099-01-01" not in bogus.days)
check("a bad stored profile is ignored, not fatal",
not dayplan.HouseProfile({"x": "y", "2026-09-20": {"99": 1,
"a": "b"}}).ready())
@@ -316,11 +336,38 @@ ch = dplan(12.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
pk(B, packmode.CHEAP_HOURS, 30.0)])
ch_dark = dplan(1.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
pk(B, packmode.CHEAP_HOURS, 30.0)])
check("Cheap hours: dark fills to the ceiling (as P3)",
abs(ch_dark.targets[B] - 95.0) < 1e-6)
check("...and skips the part the sun covers on a partly sunny day",
ch.targets[B] < 95.0 and ch.share_kwh[B] > 0
and abs(ch.targets[B] - (95.0 - ch.share_kwh[B] * 10.0)) < 0.01)
check("...and on a partly sunny day too: Cheap hours ignores the plan "
"(design section 1; review of PR #156)", B not in ch.targets
and B not in ch_dark.targets
and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, 30.0), 95.0,
{B: 40.0}) == 95.0)
# Review of PR #156: four mutants that survived.
_hi = {h: (3000.0 if 17 <= h < 22 else 2000.0 if 8 <= h < 17 else 600.0)
for h in range(24)}
check("a house that uses more by day leaves less sun: a higher level",
dplan(20.0, est=(lambda h: _hi[h], 600.0)).targets[B]
> dplan(20.0).targets[B] + 1.0)
_two = dplan(40.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
pk(B, packmode.LEFTOVER_SOLAR, 30.0),
pk("inverter-c", packmode.LEFTOVER_SOLAR, 80.0)])
check("two Leftover solar batteries share the sun by their room (65:15)",
abs(_two.share_kwh[B] / _two.share_kwh["inverter-c"] - 6.5 / 1.5)
< 0.01)
_h = lambda hh: datetime(2026, 9, 21, hh, tzinfo=L).astimezone(timezone.utc)
_rp = priceruns.RunPlan(priceruns.Run(_h(13), _h(14), 0.02),
(priceruns.Run(_h(8), _h(9), 0.4),
priceruns.Run(_h(19), _h(20), 0.4)),
None, 0.3, None, True)
_pl = dayplan.plan(fc_of(12.0), EST, _rp, T0, PACKS, trust_pct=70,
floor_pct=20.0, ceiling_pct=95.0)
check("the plan is for the dear run after the cheap run, not an earlier one",
_pl is not None and _pl.until == _h(19))
_hp = dayplan.HouseProfile()
for i in range(30):
_hp.add(_h(10) + timedelta(seconds=5 * i), 500.0)
_hp.add(_h(11), 500.0)
check("an hour with under 5 minutes of readings is not kept",
not any(10 in r for r in _hp.days.values()))
check("trust 100 % counts more sun than 70 %: a lower level",
dplan(12.0, trust=100).targets[B] < partly.targets[B])
check("...and the sun counted scales with the trust exactly",
@@ -437,7 +484,8 @@ for i in range(5000):
bad.append(("range without 0", p, lo, hi))
if t.roles[p.name] == modetrade.CHARGE and (
p.mode == packmode.SOLAR_FIRST
or p.soc >= min(95.0, targets[p.name])):
or p.soc >= (95.0 if p.mode == packmode.CHEAP_HOURS
else min(95.0, targets[p.name]))):
bad.append(("charges past its level", p, targets))
check(f"decide() with a plan on 5000 random cases: ranges hold 0, nothing "
f"charges at or past its level, Solar first never ({len(bad)} bad)",
@@ -567,17 +615,17 @@ check("dark: ...only up to its level (the shortfall), not 95 %",
check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
for r in charge))
# Cheap hours on a partly sunny day: skips the part the sun covers.
# Cheap hours on a partly sunny day: ignores the plan, fills as in P3.
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0})
charge = invariants("cheap hours, partly", rows, c)
tgt = nth([r["targets"][B] for r in rows if r["targets"]], 0, "a level")
top = max(r["soc"]["b"] for r in rows)
print(f" cheap hours partly: B level {tgt:.1f} %, reached {top:.1f} %")
check("Cheap hours with sun forecast: charges, but stops below 95 %",
charge and present(tgt) and tgt < 90.0 and top <= tgt + 2.0)
print(f" cheap hours partly: B reached {top:.1f} %")
check("Cheap hours gets no planned level",
not any(r["targets"] and B in r["targets"] for r in rows))
rows_nf, _s, c_nf = run_day(SF_CH, fc=None, soc0={"a": 60.0, "b": 30.0})
check("...and with the forecast down it fills towards 95 % as in P3",
max(r["soc"]["b"] for r in rows_nf) > top + 10.0)
check("Cheap hours with a sun forecast charges as with none (P3)",
charge and abs(max(r["soc"]["b"] for r in rows_nf) - top) < 1.0
and top > 80.0)
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
DROP = UTC0 + timedelta(hours=1, minutes=30)
@@ -660,8 +708,8 @@ rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
rp.m.datetime = datetime
sb = homeview.solar_block(c, c.solar_fc.at)
check("the forecast line names the forecast, what is counted and when",
sb["line"].startswith("Solar forecast today:") and "we count on" in
sb["line"] and sb["state"] == "plan")
sb["line"].startswith("Solar forecast for the rest of today:")
and "we count on" in sb["line"] and sb["state"] == "plan")
why = homeview.day_key(c, B)
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
check("...and says so in plain words", homeview.why_words(why)
@@ -723,6 +771,12 @@ check("a forecast entity that is not a sensor is refused, in plain words",
validate._forecast_problems({"pv_forecast_entity": "number.x"})
and "must be a sensor" in validate._forecast_problems(
{"pv_forecast_entity": "number.x"})[0])
check("an entity id with a path or odd characters is refused",
all(validate._forecast_problems({"pv_forecast_entity": e}) for e in
("sensor.x/../config", "sensor.x?y=1", "sensor.X", "sensor.",
" sensor.x", "sensor.a b"))
and not validate._forecast_problems(
{"pv_forecast_entity": "sensor.energy_production_today_remaining"}))
check("empty entities are fine", not validate._forecast_problems(
{"pv_forecast_entity": "", "pv_forecast_tomorrow_entity": ""}))
notes = []