TARIFF-24 round 3: a run planned with a forecast keeps its gap to its end
checks / unit (pull_request) Successful in 51m57s
infra-pin / fresh (pull_request) Successful in 1s

Losing the solar forecast mid-run used to widen the gap at once and stop a
grid charge (and a sensor that went unavailable for a minute stopped and
restarted it). A run under way when the forecast is lost now keeps the gap it
was planned with until it ends (Controller.weather_held(), recorded by
_wx_track() each cycle a forecast is read); the wider gap applies from the next
run. The plan key holds the hold state so the plan rebuilds when it ends. Home's
mirror (homeview.trade_verdict) asks the same weather_held(now).

Tests: test_tariff24_weather.py part 4 (going: the charge continues to the run's
end, the next run is not_worth; flap of one and two reads: no stop, no restart;
Home agrees) and a test_modes_p4.py scenario that runs a forecast day at a gap
between 1x and 1.5x the cycle cost. DECISIONS wording follows.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-28 17:17:56 +01:00
co-authored by Claude
parent d424b862d8
commit 97686319f3
6 changed files with 191 additions and 42 deletions
+116 -31
View File
@@ -14,8 +14,10 @@ DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
0.105 and the reason is `not_worth` / "Prices differ too little".
3. A dynamic day with a 0.12 gap at cost 0.10: on with a forecast, not worth
without.
4. The forecast going and coming mid-run: the gate follows in the same cycle
the forecast read changes, both ways.
4. The forecast coming (the gate follows in the same cycle) and going mid-run:
a run under way keeps its gap to its end, the next run needs the wider one;
a forecast unavailable for one or two reads mid-run does not stop or
restart the charge.
5. Home agrees with control in each case (Batteries Why, Home line, and
`trade_verdict()`, Home's mirror of the gate).
@@ -69,6 +71,7 @@ NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
FC_TODAY, FC_TOMORROW = "sensor.fc_today", "sensor.fc_tomorrow"
TARIFF_REFRESH_S = m.TARIFF_REFRESH_S
def opts(cost, *, arb=False, sensors=True):
@@ -108,8 +111,23 @@ def forecast_mode(mode, t, now):
return at
def take(c, now):
"""What Home and the Batteries screen say now, and Home's mirror of the
gate (it reads the trade switch, so ask it as a site that has it on next
to the modes)."""
keep = c.o
c.o = dict(keep, arbitrage_enabled=True)
try:
mirror, why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now)
finally:
c.o = keep
return dict(home=homeview.build_plan(c, now, False, True),
cards=homeview.battery_rows(c, False, True),
mirror=mirror, mirror_why=why)
def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
house_w=300.0, soc0=None):
house_w=300.0, soc0=None, snap_at=()):
"""-> (controller, rows). `points`: a feed-shaped plan injected every
cycle (no task); else the static profile via the real tariff task."""
store = rp._Store()
@@ -118,6 +136,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
rows = []
snaps = {}
class H(rp.Hass):
async def state(self, e):
@@ -163,9 +182,19 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
"generic_dayahead", now), None,
"provider")
c.tariff_at = now
elif t == 0.0:
elif t == 0.0 or t % TARIFF_REFRESH_S == 0.0:
# The task's own 30 min wait is wall clock, so the
# simulated clock restarts it: one refresh per period.
if task is not None and t > 0.0:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
task = asyncio.create_task(c.run_tariff_refresh())
for _ in range(500):
if c.tariff_plan is not None:
if c.tariff_plan is not None and (
c.tariff_at is not None and c.tariff_at >= now):
break
await asyncio.sleep(0)
imp, exp = split_signed(hass.grid())
@@ -182,18 +211,17 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
reason=None if rp_ is None else rp_.reason,
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
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,
cheap_end=None if rp_ is None or rp_.cheap is None
else rp_.cheap.end))
if t in snap_at:
snaps[t] = take(c, now)
t += tick
c.home_plan = homeview.build_plan(c, clock[0], False, True)
c.cards = homeview.battery_rows(c, False, True)
# Home's mirror of the gate (it reads the trade switch, so ask it
# as a site that has it on next to the modes).
c2 = c
c2.o = dict(c.o, arbitrage_enabled=True)
c.mirror, c.mirror_why = homeview.trade_verdict(
c2, c.fresh_tariff_plan(clock[0]), clock[0])
c.o = dict(c.o, arbitrage_enabled=o.get("arbitrage_enabled",
False))
last = take(c, clock[0])
c.home_plan, c.cards = last["home"], last["cards"]
c.mirror, c.mirror_why = last["mirror"], last["mirror_why"]
c.snaps = snaps
finally:
if task is not None:
task.cancel()
@@ -274,8 +302,9 @@ check("without one it is not_worth (needs 0.15 > 0.12) and says so",
and why_of(cn, B) == "no_spread")
# --------------------------------------------------------------------------- #
print("\n4. the forecast going and coming mid-run")
print("\n4. the forecast going and coming mid-run (a run under way keeps its gap)")
CUT = 600.0 # 10 min in
SOC = {"a": 40.0, "b": 60.0} # B's cheap run is then about 2 h
def going(t):
@@ -286,8 +315,73 @@ def coming(t):
return "gone" if t < CUT else "fresh"
cg, rg = drive(opts(0.07), fc=going, minutes=25)
def flap(n_reads):
"""Unavailable for `n_reads` forecast reads (60 s apart) from CUT."""
return lambda t: "gone" if CUT <= t < CUT + 60.0 * n_reads else "fresh"
MID, AFTER = 3000.0, 7800.0
cg, rg = drive(opts(0.07), fc=going, minutes=135, soc0=SOC,
snap_at=(MID, AFTER))
cc, rc = drive(opts(0.07), fc=coming, minutes=25)
_end = nth([r["cheap_end"] for r in rg if r["cheap_end"] is not None], 0,
"the cheap run's end")
_end_t = (_end - NIGHT).total_seconds() if present(_end) else 1e9
lost = [r for r in rg if r["fc_none"]]
print(f" going: cheap run ends at t={_end_t:.0f} s; forecast lost from "
f"t={lost[0]['t'] if lost else None}")
check("going: the run planned with the forecast ends before the snapshot "
"taken after it (the scenario is long enough)",
present(_end) and CUT + 300.0 < _end_t < AFTER - 300.0 and bool(lost)
and lost[0]["t"] - CUT <= 60.0)
check("going: the forecast read is lost mid-run and the run under way KEEPS "
"its gap: the plan stays on and B keeps charging, cycle after cycle, "
"until B is full or the run ends",
all(r["runs_on"] is True and (r["roles"].get(B) == modetrade.CHARGE
or r["soc_b"] >= 94.0)
for r in rg if r["fc_none"] and r["t"] < _end_t - 60.0)
and all(r["charging"] or r["soc_b"] >= 94.0 for r in rg
if 60.0 <= r["t"] < _end_t - 60.0)
and max(r["soc_b"] for r in rg if r["fc_none"]) >= 90.0)
check("going: from the run's end the wider gap is back: the next run is "
"not_worth (needs 0.105 at 0.07), no new run, and no grid charge",
all(r["runs_on"] is False and r["reason"] == "not_worth"
for r in rg if r["t"] >= _end_t + 30.0)
and not any(r["charging"] for r in rg if r["t"] >= _end_t + 60.0))
_sm, _sa = cg.snaps.get(MID), cg.snaps.get(AFTER)
check("going, Home agrees mid-run: its mirror still allows, the Batteries "
"screen says it charges from the grid, and Home does not say the gap "
"is too small",
_sm is not None and _sm["mirror"] is not None
and first((b for b in _sm["cards"] if b["name"] == B),
"a card")["why_key"] == "grid_charge"
and not any("price gap is too small" in ln
for ln in _sm["home"]["lines"]))
check("going, Home agrees after the run: its mirror refuses, the Batteries "
"screen says 'differ too little' and Home says the gap is too small",
_sa is not None and _sa["mirror"] is None
and first((b for b in _sa["cards"] if b["name"] == B),
"a card")["why_key"] == "no_spread"
and any("price gap is too small" in ln for ln in _sa["home"]["lines"]))
for n in (1, 2):
cf, rf = drive(opts(0.07), fc=flap(n), minutes=30, soc0=SOC,
snap_at=(CUT + 30.0,))
gone = [r for r in rf if r["fc_none"]]
print(f" flap, {n} read(s) lost: {len(gone)} cycles without a "
f"forecast; B charging in {sum(r['charging'] for r in rf)} of "
f"{len(rf)}")
check(f"flap: the forecast is unavailable for {n} read(s) mid-run: the "
f"plan stays on and B charges without a break (no stop, no "
f"restart)",
bool(gone) and all(r["runs_on"] is True for r in rf)
and all(r["charging"] and r["roles"].get(B) == modetrade.CHARGE
for r in rf if r["t"] >= 60.0))
sf = cf.snaps.get(CUT + 30.0)
check(f"flap, {n} read(s): Home agrees while the read is lost (mirror "
f"allows, Batteries 'charges from the grid')",
sf is not None and sf["mirror"] is not None
and first((b for b in sf["cards"] if b["name"] == B),
"a card")["why_key"] == "grid_charge")
def flips(rows):
@@ -301,19 +395,10 @@ def flips(rows):
return out
fg, fcm = flips(rg), flips(rc)
print(f" going: forecast read lost at cycle/t {fg}; coming: {fcm}")
check("going: the plan is on with the forecast and not_worth the cycle the "
"read is lost (within one forecast read, 60 s, of the sensor change)",
len(fg) == 1 and fg[0][1] - CUT <= 60.0 and fg[0][2] is False
and all(r["runs_on"] is True for r in rg if not r["fc_none"])
and all(r["runs_on"] is False for r in rg if r["fc_none"]))
check("...and the grid charge stops with it: no charging after the cycle "
"that lost the forecast, plus the 15 s the inverter ramp takes",
len(fg) == 1 and not any(
r["charging"] for r in rg if r["t"] >= fg[0][1] + 40.0))
check("coming: not_worth until the read is back, then on in the same cycle, "
"and the battery charges",
fcm = flips(rc)
print(f" coming: forecast read arrives at cycle/t {fcm}")
check("coming (no run under way before): not_worth until the read is back, "
"then on in the same cycle, and the battery charges",
len(fcm) == 1 and fcm[0][1] - CUT <= 60.0 and fcm[0][2] is True
and all(r["runs_on"] is False for r in rc if r["fc_none"])
and all(r["runs_on"] is True for r in rc if not r["fc_none"])