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:
co-authored by
Claude Opus 5.5
parent
aabc2a27fd
commit
fbd884797d
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user