TARIFF-24 forward-port: pin the hold's edges (review F1, F2)

Part 6 of test_tariff24_weather.py, the review's probes R4 and R5:
- R4: a forecast lost while only the day's give run is under way holds to
  that run's end (22:00) and no further; the night's cheap run then needs the
  wider gap. Pins the `solar_fc is None` return in `_wx_track()`: without it
  the hold chains into the cheap run and B charges (mutant M2, now red).
- R5: a give run that starts during a hold runs to its end (`keep_started()`),
  with no grid charge after the hold. Behaviour unchanged; a give run not kept
  goes red.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-30 08:09:44 +01:00
co-authored by Claude
parent 5ac1017eed
commit 9dc22fb987
+72 -6
View File
@@ -20,10 +20,15 @@ DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
restart the charge. restart the charge.
5. Home agrees with control in each case (Batteries Why, Home line, and 5. Home agrees with control in each case (Batteries Why, Home line, and
`trade_verdict()`, Home's mirror of the gate). `trade_verdict()`, Home's mirror of the gate).
6. The hold's edges (TARIFF-24 review R4, R5): a forecast lost while only the
day's give run is under way holds to that run's end and no further, so the
night's cheap run needs the wider gap; and a give run that starts during a
hold runs to its end (`keep_started()`), with no grid charge after the hold.
Mutations (MUTATION.md, purged copies, graded over this file): the doubt always Mutations (MUTATION.md, purged copies, graded over this file): the doubt always
added (the old code) fails parts 2 to 5; never added fails the without-forecast added (the old code) fails parts 2 to 5; never added fails the without-forecast
rows; Home's mirror left on the old rule fails part 5. rows; Home's mirror left on the old rule fails part 5; `_wx_track()` also
recording without a forecast (its `solar_fc is None` return dropped) fails part 6.
""" """
import asyncio import asyncio
@@ -127,9 +132,10 @@ def take(c, now):
def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
house_w=300.0, soc0=None, snap_at=()): house_w=300.0, soc0=None, snap_at=(), t0=NIGHT):
"""-> (controller, rows). `points`: a feed-shaped plan injected every """-> (controller, rows). `points`: a feed-shaped plan injected every
cycle (no task); else the static profile via the real tariff task.""" cycle (no task); else the static profile via the real tariff task. `t0`:
the first cycle's instant."""
store = rp._Store() store = rp._Store()
for u, (use, mode, sell) in CHOICE.items(): for u, (use, mode, sell) in CHOICE.items():
for k, val in (("pack_use", use), ("pack_mode", mode), for k, val in (("pack_use", use), ("pack_mode", mode),
@@ -140,7 +146,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
class H(rp.Hass): class H(rp.Hass):
async def state(self, e): async def state(self, e):
now = NIGHT + timedelta(seconds=self.t) now = t0 + timedelta(seconds=self.t)
if fc is None: if fc is None:
return None return None
at = forecast_mode(fc, self.t, now) at = forecast_mode(fc, self.t, now)
@@ -158,7 +164,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
i.dev_min, i.dev_max = -5000.0, 5000.0 i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True c.auto = True
real_dt, real_hv = m.datetime, homeview.datetime real_dt, real_hv = m.datetime, homeview.datetime
clock = [NIGHT] clock = [t0]
class _DT(real_dt): class _DT(real_dt):
@classmethod @classmethod
@@ -173,7 +179,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
try: try:
t = 0.0 t = 0.0
while t < minutes * 60: while t < minutes * 60:
now = NIGHT + timedelta(seconds=t) now = t0 + timedelta(seconds=t)
hass.t = t hass.t = t
clock[0] = now clock[0] = now
if pts is not None: if pts is not None:
@@ -210,6 +216,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
runs_on=None if rp_ is None else rp_.on, runs_on=None if rp_ is None else rp_.on,
reason=None if rp_ is None else rp_.reason, reason=None if rp_ is None else rp_.reason,
roles=dict(c.mode_tick.roles) if c.mode_tick else {}, roles=dict(c.mode_tick.roles) if c.mode_tick else {},
free_to=c._wx_free_to, held=c.weather_held(now),
why=None if c.mode_tick is None else c.mode_tick.why, why=None if c.mode_tick is None else c.mode_tick.why,
charging=hass.plants["b"].p < -200.0, charging=hass.plants["b"].p < -200.0,
soc_b=hass.plants["b"].soc, soc_b=hass.plants["b"].soc,
@@ -430,6 +437,65 @@ for label, cx, rx, on in CASES:
check(f"{label}: Home draws a grid charge block exactly when control " check(f"{label}: Home draws a grid charge block exactly when control "
f"charges", any(r["kind"] == "grid" for r in hp["runs"]) is on) f"charges", any(r["kind"] == "grid" for r in hp["runs"]) is on)
# --------------------------------------------------------------------------- #
print("\n6. the hold's edges (review probes R4, R5)")
T_R4 = datetime(2026, 10, 30, 21, 40, tzinfo=L).astimezone(timezone.utc)
TEN = datetime(2026, 10, 30, 22, 0, tzinfo=L).astimezone(timezone.utc)
c4, r4 = drive(opts(0.07), fc=lambda t: "fresh" if t < 300.0 else "gone",
minutes=60, t0=T_R4)
_held4 = [r for r in r4 if r["held"]]
print(f" R4: forecast lost 21:45; held {len(_held4)} cycles, to "
f"{r4[-1]['free_to']}; after 22:00 on={ {r['runs_on'] for r in r4 if r['now'] >= TEN} } "
f"charging {sum(r['charging'] for r in r4)}")
check("R4, forecast lost before the next run: while only the day's give run "
"was under way the hold is that run's end, 22:00, and stays there",
present(nth(r4, -1)) and all(r["free_to"] == TEN for r in r4
if r["t"] >= 5.0)
and bool(_held4) and all(r["now"] < TEN for r in _held4))
check("R4: from 22:00 the night's cheap run needs the wider gap: "
"not_worth, and no grid charge at any time",
all(r["runs_on"] is False and r["reason"] == "not_worth"
for r in r4 if r["now"] >= TEN)
and not any(r["charging"] for r in r4))
def r5_points(t0):
"""04:00-07:00 at 0.20, 07:00-10:00 at 0.30, else 0.25 (local)."""
out, t = [], t0.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
for _ in range(4 * 30):
h = brussels(t).hour
p = 0.20 if 4 <= h < 7 else 0.30 if 7 <= h < 10 else 0.25
out.append(PricePoint(start=brussels(t), eur_per_kwh=p,
provisional=False))
t += timedelta(minutes=15)
return out
T_R5 = datetime(2026, 10, 30, 5, 0, tzinfo=L).astimezone(timezone.utc)
GIVE_END = datetime(2026, 10, 30, 10, 0, tzinfo=L).astimezone(timezone.utc)
c5, r5 = drive(opts(0.07), fc=lambda t: "fresh" if t < 3600.0 else "gone",
points=r5_points(T_R5), minutes=320, t0=T_R5,
soc0={"a": 40.0, "b": 30.0})
_ft5 = nth([r["free_to"] for r in r5 if r["t"] == 3600.0], 0, "the hold")
_in = [r for r in r5 if present(_ft5) and _ft5 <= r["now"] < GIVE_END]
_out = [r for r in r5 if r["now"] >= GIVE_END + timedelta(seconds=30)]
print(f" R5: forecast lost 06:00, hold to {_ft5}; after it, until 10:00, "
f"on in {sum(r['runs_on'] is True for r in _in)} of {len(_in)}, B "
f"{sorted({str(r['roles'].get(B)) for r in _in})}; after 10:00 "
f"{ {r['reason'] for r in _out} }")
check("R5: the hold is the cheap run's end, before the give run's end",
present(_ft5) and T_R5 < _ft5 < GIVE_END)
check("R5, a run that has started runs to its end: the give run that began "
"during the hold stays on after it, B gives, and nothing charges from "
"the grid",
len(_in) > 100 and all(r["runs_on"] is True
and r["roles"].get(B) == modetrade.GIVE
and not r["charging"] for r in _in))
check("R5: when the give run ends the wider gap decides: not_worth, no grid "
"charge",
bool(_out) and all(r["runs_on"] is False and r["reason"] == "not_worth"
and not r["charging"] for r in _out))
print() print()
if fails: if fails:
print(f"{len(fails)} FAILED:") print(f"{len(fails)} FAILED:")